From 5b89723f59d35b99dde886ea3d213f7823fc8d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 12:39:44 +0000 Subject: [PATCH 01/60] Add Hatch: multi-agent harness design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hatch is overclaud's multi-agent upgrade — a harness that coordinates multiple coding agents (Claude Code, Codex, Kiro, Antigravity) on one repo with role assignment, an Agile-style workflow, token-tiered context, a single-source-of-truth compiler, file-based async coordination, and an audit ledger. overclaud's single-agent optimizer becomes the Claude compiler backend within Hatch. Adds hatch/ design set: README, docs/00-08 (vision, architecture, roles, coordination protocol, context+compiler, workflow, governance, orchestrator, roadmap), and spec/ schemas (registry, ticket, ledger). Modeled on a human Agile squad; staged Phase 1 (convention) -> 2 (CLI) -> 3 (full orchestrator). Links Hatch from the root README. --- README.md | 12 +++- hatch/README.md | 75 ++++++++++++++++++++ hatch/docs/00-vision.md | 49 +++++++++++++ hatch/docs/01-architecture.md | 98 ++++++++++++++++++++++++++ hatch/docs/02-roles.md | 71 +++++++++++++++++++ hatch/docs/03-coordination-protocol.md | 90 +++++++++++++++++++++++ hatch/docs/04-context-compiler.md | 80 +++++++++++++++++++++ hatch/docs/05-workflow.md | 68 ++++++++++++++++++ hatch/docs/06-governance.md | 70 ++++++++++++++++++ hatch/docs/07-orchestrator.md | 79 +++++++++++++++++++++ hatch/docs/08-roadmap.md | 67 ++++++++++++++++++ hatch/spec/ledger.schema.md | 62 ++++++++++++++++ hatch/spec/registry.schema.md | 81 +++++++++++++++++++++ hatch/spec/ticket.schema.md | 74 +++++++++++++++++++ 14 files changed, 973 insertions(+), 3 deletions(-) create mode 100644 hatch/README.md create mode 100644 hatch/docs/00-vision.md create mode 100644 hatch/docs/01-architecture.md create mode 100644 hatch/docs/02-roles.md create mode 100644 hatch/docs/03-coordination-protocol.md create mode 100644 hatch/docs/04-context-compiler.md create mode 100644 hatch/docs/05-workflow.md create mode 100644 hatch/docs/06-governance.md create mode 100644 hatch/docs/07-orchestrator.md create mode 100644 hatch/docs/08-roadmap.md create mode 100644 hatch/spec/ledger.schema.md create mode 100644 hatch/spec/registry.schema.md create mode 100644 hatch/spec/ticket.schema.md 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/hatch/README.md b/hatch/README.md new file mode 100644 index 0000000..36a7981 --- /dev/null +++ b/hatch/README.md @@ -0,0 +1,75 @@ +# 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 + **điều phối** 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. Nghĩa kép: orchestrator `hatch run ` chính là *nở một agent ra làm việc*. Đặ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. + +## Nó là gì + +Hatch biến một repo thành **không gian làm việc đa-agent có điều phối**. Cùng một codebase, nhiều coding agent (Claude Code, Codex, Kiro, Antigravity CLI) tham gia với những vai trò khác nhau, làm việc 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. + +## 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ì | +| `board/` | Bảng sprint (Jira) | +| `ledger/` | Sổ standup / nhật ký hoạt động / git log | +| `protocol/` | Working agreements của đội | +| `compiler` | Bộ sinh tài liệu onboarding cho từng người mới | +| `orchestrator` | Engineering Manager / Scrum Master | + +## 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ụ | + +Vai là do **người dùng chỉ định** qua `registry.yaml`; bảng trên chỉ là mặc định gợi ý. + +## Sáu trụ thiết kế + +1. **Single Source of Truth → compile xuống từng agent.** Một nguồn canonical (`context/`), một compiler sinh ra `CLAUDE.md` / `AGENTS.md` / `.kiro/steering/` … tự động. Không copy-paste, không drift. +2. **Token optimization = context phân tầng** (L0 mission → L1 role → L2 task). Mỗi agent chỉ nạp tầng của nó + ticket đang làm. +3. **Role assignment** — map vai ↔ điểm mạnh từng agent. +4. **Coordination protocol** — điều phối qua artifact trong repo (board + ledger), async, không agent nào gọi trực tiếp agent khác. +5. **Workflow** — Agile ghép spec-driven: `Charter → Spec → Backlog → Sprint → In-Progress → Review → Done`. +6. **Governance & audit** — ledger append-only, gate trước merge, giới hạn thẩm quyền agent. + +## Mô hình điều phối: Hybrid + +- **Conductor** (CC) chạy planning: bẻ epic → ticket, gán vai/agent, đặt ưu tiên, ghi dependency. +- **Workers** tự **claim** ticket trong lane của mình, thực thi, để lại ledger, đẩy ticket sang review. +- **Reviewer** gate → done. +- Tất cả qua board + ledger. Không có lời gọi trực tiếp giữa agent — y như một đội người: manager lập kế hoạch, dev pull ticket, reviewer duyệt. + +## Đọc docs theo thứ tự + +| # | 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 → compile per-agent, 3 tầng token | +| 05 | [workflow](docs/05-workflow.md) | Lifecycle Agile + spec-driven, 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 | + +Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.schema.md) · [ledger](spec/ledger.schema.md) + +## Trạng thái + +Đây là **bộ thiết kế** (design docs). Chưa implement. Implement theo [roadmap](docs/08-roadmap.md) sau khi docs được chốt. diff --git a/hatch/docs/00-vision.md b/hatch/docs/00-vision.md new file mode 100644 index 0000000..254ec04 --- /dev/null +++ b/hatch/docs/00-vision.md @@ -0,0 +1,49 @@ +# 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). + +## 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 | + +## 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..9e47dc6 --- /dev/null +++ b/hatch/docs/01-architecture.md @@ -0,0 +1,98 @@ +# 01 — Architecture + +## Sáu trụ + +``` + ┌─────────────────────────────┐ + │ ORCHESTRATOR (act) │ ← 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 ▼ + ┌──────────────────────┐ ┌──────────────────────────────────┐ + │ CLAUDE.md / AGENTS.md │ │ AGENTS theo ROLES (registry) │ + │ .kiro/steering/ … │◄──│ Architect·Implementer·Reviewer │ + └──────────────────────┘ └──────────────────────────────────┘ + ▲ ▲ + └──────── PROTOCOL (working agreements) ───────┘ +``` + +1. **Context (SSOT)** — nguồn canonical duy nhất. +2. **Compiler** — sinh file instruction native cho từng agent từ SSOT. +3. **Roles + Registry** — định nghĩa vai và gán agent vào vai. +4. **Board** — hàng đợi việc dạng ticket file. +5. **Ledger** — sổ audit append-only. +6. **Protocol** — quy ước phối hợp (claim/lock, handoff, branching, DoD). +7. **Orchestrator** — (Phase 3) CLI điều khiển toàn bộ. + +## 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 +│ +├── roles/ # L1 — context theo vai, mỗi vai 1 file +│ ├── architect.md +│ ├── implementer.md +│ ├── reviewer.md +│ ├── tester.md +│ └── ... +│ +├── context/ # SSOT — tri thức canonical, 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 +│ +├── 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..07a933f --- /dev/null +++ b/hatch/docs/02-roles.md @@ -0,0 +1,71 @@ +# 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. + +## Bộ vai chuẩn + +Lấy theo một squad sản phẩm điển hình: + +| 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 | + +Đây là mặc định. Người dùng thêm/bớt vai tùy repo (vd: `SecurityReviewer`, `DataEngineer`). + +## Map năng lực → agent (mặc định gợi ý) + +Phân vai theo điểm mạnh quan sát được của từng agent. Người dùng override trong `registry.yaml`. + +| 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..9f1b019 --- /dev/null +++ b/hatch/docs/03-coordination-protocol.md @@ -0,0 +1,90 @@ +# 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. + +Không có lời gọi trực tiếp agent→agent. Mọi "giao tiếp" là **ghi vào file**, "lắng nghe" là **đọc file**. Đúng như dev không ghi đè não đồng nghiệp mà comment lên Jira/PR. + +## 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 +- [ ] 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..4f8087f --- /dev/null +++ b/hatch/docs/04-context-compiler.md @@ -0,0 +1,80 @@ +# 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). + +## Single Source of Truth (SSOT) + +Tất cả tri thức canonical 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` của nó | 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 đượ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ả". + +### Ướ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..b8205af --- /dev/null +++ b/hatch/docs/05-workflow.md @@ -0,0 +1,68 @@ +# 05 — Workflow + +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 + +``` +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. + +## Hỗ trợ nhiều chuẩn product dev + +Lifecycle trên là khung. Người dùng cấu hình biến thể trong `charter.md` / `protocol/`: +- **Scrum-like:** sprint cố định + retro mỗi sprint. +- **Kanban-like:** không sprint, WIP limit theo lane, pull liên tục. +- **Spec-first nghiêm:** mọi epic bắt buộc qua PRD→Design→Tasks. +- **Trunk-based vs branch-per-ticket:** cấu hình trong `protocol/branching.md`. + +Hatch cung cấp khung + cơ chế; *chuẩn cụ thể* là cấu hình, không phải 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..b681d49 --- /dev/null +++ b/hatch/docs/08-roadmap.md @@ -0,0 +1,67 @@ +# 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). +- 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. +- `hatch sync` — đối chiếu board ↔ git ↔ PR. +- git pre-commit hook: chặn output stale, validate frontmatter ticket. + +**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. + +## 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/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:`) | +| 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..37f19ac --- /dev/null +++ b/hatch/spec/registry.schema.md @@ -0,0 +1,81 @@ +# 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". + +## 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: [""] + 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 + +# Biến thể quy trình (xem workflow.md) +workflow: + mode: scrum # scrum | kanban + spec-required-for: epic # epic bắt buộc qua PRD→Design→Tasks +``` + +## 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//T-.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 + +- 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 đó. From ce3431634178d06c554b222ab102eda33b58b331 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 12:53:13 +0000 Subject: [PATCH 02/60] Add shared Knowledge Base + per-project roles/workflow - Knowledge Base (kb/): shared, read+write memory store for all agents, distinct from SSOT (config-in) and ledger (events-out). Three-store knowledge model; KB->SSOT promotion at retro. New docs/09-knowledge-base.md. - Roles are per-project user config, not hard-coded: registry.yaml lives in each project's .hatch/; standard roles are a starter template. - Workflow as editable template: lanes/transitions/gates/ceremonies defined in workflow.yaml (scrum/kanban/spec-first/lite); redesignable per project. New spec/workflow.schema.md. - Update README, architecture (7 pillars + kb/ + workflow.yaml), roles, context-compiler (SSOT vs KB), workflow, vision, roadmap, registry spec, DoD. --- hatch/README.md | 26 +++++--- hatch/docs/00-vision.md | 4 ++ hatch/docs/01-architecture.md | 52 ++++++++++----- hatch/docs/02-roles.md | 12 ++-- hatch/docs/03-coordination-protocol.md | 1 + hatch/docs/04-context-compiler.md | 19 +++++- hatch/docs/05-workflow.md | 36 +++++++--- hatch/docs/08-roadmap.md | 7 +- hatch/docs/09-knowledge-base.md | 79 ++++++++++++++++++++++ hatch/spec/registry.schema.md | 7 +- hatch/spec/workflow.schema.md | 92 ++++++++++++++++++++++++++ 11 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 hatch/docs/09-knowledge-base.md create mode 100644 hatch/spec/workflow.schema.md diff --git a/hatch/README.md b/hatch/README.md index 36a7981..5f3047d 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -21,12 +21,16 @@ Mọi thành phần trong Hatch đều có một đối ứng trong một squad | `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 | | `compiler` | Bộ sinh tài liệu onboarding cho từng người mới | | `orchestrator` | Engineering Manager / Scrum Master | +> **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 | @@ -36,16 +40,17 @@ Mọi thành phần trong Hatch đều có một đối ứng trong một squad | **Codex** | Autonomous Implementer | Thực thi nhanh, lặp nhiều | | **Antigravity CLI** | Implementer / Utility | Linh hoạt, gánh việc phụ | -Vai là do **người dùng chỉ định** qua `registry.yaml`; bảng trên chỉ là mặc định gợi ý. +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). -## Sáu trụ thiết kế +## Bảy trụ thiết kế 1. **Single Source of Truth → compile xuống từng agent.** Một nguồn canonical (`context/`), một compiler sinh ra `CLAUDE.md` / `AGENTS.md` / `.kiro/steering/` … tự động. Không copy-paste, không drift. -2. **Token optimization = context phân tầng** (L0 mission → L1 role → L2 task). Mỗi agent chỉ nạp tầng của nó + ticket đang làm. -3. **Role assignment** — map vai ↔ điểm mạnh từng agent. -4. **Coordination protocol** — điều phối qua artifact trong repo (board + ledger), async, không agent nào gọi trực tiếp agent khác. -5. **Workflow** — Agile ghép spec-driven: `Charter → Spec → Backlog → Sprint → In-Progress → Review → Done`. -6. **Governance & audit** — ledger append-only, gate trước merge, giới hạn thẩm quyền agent. +2. **Knowledge Base dùng chung** (`kb/`) — bộ nhớ chung bền của hệ; mọi agent đọc *và* ghi. 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ó + ticket đ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. +5. **Coordination protocol** — điều phối qua artifact trong repo (board + ledger), async, không agent nào gọi trực tiếp agent khác. +6. **Workflow = template sửa được** — mặc định Agile ghép spec-driven (`Charter → Spec → Backlog → Sprint → In-Progress → Review → Done`); user có quyền thiết kế lại hoàn toàn ở mỗi project qua `workflow.yaml`. +7. **Governance & audit** — ledger append-only, gate trước merge, giới hạn thẩm quyền agent. ## Mô hình điều phối: Hybrid @@ -62,13 +67,14 @@ Vai là do **người dùng chỉ định** qua `registry.yaml`; bảng trên ch | 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 → compile per-agent, 3 tầng token | -| 05 | [workflow](docs/05-workflow.md) | Lifecycle Agile + spec-driven, ceremonies | +| 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 | -Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.schema.md) · [ledger](spec/ledger.schema.md) +Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.schema.md) · [ledger](spec/ledger.schema.md) · [workflow](spec/workflow.schema.md) ## Trạng thái diff --git a/hatch/docs/00-vision.md b/hatch/docs/00-vision.md index 254ec04..9100007 100644 --- a/hatch/docs/00-vision.md +++ b/hatch/docs/00-vision.md @@ -31,6 +31,8 @@ Hệ quả thiết kế trực tiếp: - 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 @@ -41,6 +43,8 @@ Hệ quả thiết kế trực tiếp: | 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) diff --git a/hatch/docs/01-architecture.md b/hatch/docs/01-architecture.md index 9e47dc6..ecf409c 100644 --- a/hatch/docs/01-architecture.md +++ b/hatch/docs/01-architecture.md @@ -1,10 +1,10 @@ # 01 — Architecture -## Sáu trụ +## Bảy trụ ``` ┌─────────────────────────────┐ - │ ORCHESTRATOR (act) │ ← Phase 3: EM/Scrum Master + │ ORCHESTRATOR (hatch) │ ← Phase 3: EM/Scrum Master │ plan · run · gate · status │ └──────────────┬──────────────┘ │ đọc/ghi @@ -14,43 +14,63 @@ │ CONTEXT │ compile │ BOARD │ ghi vào │ LEDGER │ │ (SSOT) │ ────────► │ (tickets) │ ───────► │ (audit append) │ └─────┬──────┘ └───────┬────────┘ └────────────────┘ - │ │ claim/execute - ▼ sinh ra ▼ + │ │ claim/execute ▲ + ▼ sinh ra ▼ │ events ┌──────────────────────┐ ┌──────────────────────────────────┐ │ CLAUDE.md / AGENTS.md │ │ AGENTS theo ROLES (registry) │ │ .kiro/steering/ … │◄──│ Architect·Implementer·Reviewer │ - └──────────────────────┘ └──────────────────────────────────┘ - ▲ ▲ - └──────── PROTOCOL (working agreements) ───────┘ + └──────────────────────┘ └───────────────┬──────────────────┘ + ▲ đọ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. -2. **Compiler** — sinh file instruction native cho từng agent từ SSOT. -3. **Roles + Registry** — định nghĩa vai và gán agent vào vai. -4. **Board** — hàng đợi việc dạng ticket file. -5. **Ledger** — sổ audit append-only. -6. **Protocol** — quy ước phối hợp (claim/lock, handoff, branching, DoD). +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 +├── 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 +├── 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, compile ra ngoài +├── 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/ diff --git a/hatch/docs/02-roles.md b/hatch/docs/02-roles.md index 07a933f..88ed5dc 100644 --- a/hatch/docs/02-roles.md +++ b/hatch/docs/02-roles.md @@ -10,9 +10,11 @@ Nguyên tắc cốt lõi, mượn từ squad người: **vai (role)** là một Đổi agent cho một vai = sửa 1 dòng registry + compile lại. Không phải viết lại context. -## Bộ vai chuẩn +> **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. -Lấy theo một squad sản phẩm điển hình: +## 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 | |---|---|---|---| @@ -23,11 +25,11 @@ Lấy theo một squad sản phẩm điển hình: | **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 | -Đây là mặc định. Người dùng thêm/bớt vai tùy repo (vd: `SecurityReviewer`, `DataEngineer`). +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 (mặc định gợi ý) +## 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. Người dùng override trong `registry.yaml`. +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 | |---|---|---| diff --git a/hatch/docs/03-coordination-protocol.md b/hatch/docs/03-coordination-protocol.md index 9f1b019..2d6d7e5 100644 --- a/hatch/docs/03-coordination-protocol.md +++ b/hatch/docs/03-coordination-protocol.md @@ -75,6 +75,7 @@ Mỗi ticket kế thừa DoD mặc định + DoD riêng. Reviewer gác đúng ch - [ ] 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. diff --git a/hatch/docs/04-context-compiler.md b/hatch/docs/04-context-compiler.md index 4f8087f..448eaa2 100644 --- a/hatch/docs/04-context-compiler.md +++ b/hatch/docs/04-context-compiler.md @@ -2,9 +2,22 @@ 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 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. +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) @@ -23,9 +36,9 @@ Mô phỏng cách một nhân viên thật mang theo thông tin: ai cũng thuộ |---|---|---|---| | **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` của nó | Agent đang làm ticket | On-demand, chỉ khi claim | +| **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 đượ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ả". +**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 diff --git a/hatch/docs/05-workflow.md b/hatch/docs/05-workflow.md index b8205af..9963543 100644 --- a/hatch/docs/05-workflow.md +++ b/hatch/docs/05-workflow.md @@ -1,8 +1,10 @@ # 05 — Workflow -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. +> **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. -## Lifecycle tổng +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 @@ -57,12 +59,28 @@ Thay vì mỗi agent đọc lại toàn bộ lịch sử (tốn token), Conducto 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. -## Hỗ trợ nhiều chuẩn product dev +## 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: + +- **`scrum`** (mặc định): sprint cố định + retro mỗi sprint. +- **`kanban`:** không sprint, WIP limit theo lane, pull liên tục. +- **`spec-first`:** mọi epic bắt buộc qua PRD→Design→Tasks trước khi vào backlog. +- **`lite`:** chỉ `todo → doing → done` cho project nhỏ/cá nhân. -Lifecycle trên là khung. Người dùng cấu hình biến thể trong `charter.md` / `protocol/`: -- **Scrum-like:** sprint cố định + retro mỗi sprint. -- **Kanban-like:** không sprint, WIP limit theo lane, pull liên tục. -- **Spec-first nghiêm:** mọi epic bắt buộc qua PRD→Design→Tasks. -- **Trunk-based vs branch-per-ticket:** cấu hình trong `protocol/branching.md`. +`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] +``` -Hatch cung cấp khung + cơ chế; *chuẩn cụ thể* là cấu hình, không phải hard-code. +Đổ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/08-roadmap.md b/hatch/docs/08-roadmap.md index b681d49..875eaae 100644 --- a/hatch/docs/08-roadmap.md +++ b/hatch/docs/08-roadmap.md @@ -8,7 +8,7 @@ Mọi thứ là file + git + quy ước. Agent tuân theo protocol thủ công; **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). +- `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/`. @@ -22,9 +22,10 @@ Thêm CLI `hatch` cho các thao tác xác định, **chưa spawn agent**: - `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. +- `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. +- 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. 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/spec/registry.schema.md b/hatch/spec/registry.schema.md index 37f19ac..a5a0973 100644 --- a/hatch/spec/registry.schema.md +++ b/hatch/spec/registry.schema.md @@ -1,6 +1,6 @@ # 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". +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 @@ -60,10 +60,9 @@ policy: implementer: 2 reviewer: 3 -# Biến thể quy trình (xem workflow.md) +# Quy trình tách riêng ở workflow.yaml (xem workflow.schema.md) workflow: - mode: scrum # scrum | kanban - spec-required-for: epic # epic bắt buộc qua PRD→Design→Tasks + ref: workflow.yaml # trỏ tới file workflow per-project ``` ## Quy tắc diff --git a/hatch/spec/workflow.schema.md b/hatch/spec/workflow.schema.md new file mode 100644 index 0000000..c0d2c5c --- /dev/null +++ b/hatch/spec/workflow.schema.md @@ -0,0 +1,92 @@ +# 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//`. Đổ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 | + +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). From 3929c66dbfa6b4135200058c87a2438462e3c43f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 13:45:12 +0000 Subject: [PATCH 03/60] Implement hatch CLI: scaffold, multi-surface compiler, workflow engine Phase 1+2 of the Hatch design, in Go (single binary): - model + filesystem-as-database store (board, ledger, KB) with markdown frontmatter parsing - `hatch init`: scaffold a .hatch/ workspace from embedded templates with 4 workflow templates (scrum/kanban/spec-first/lite) - `hatch compile`: SSOT -> per-agent surfaces (CLAUDE.md, AGENTS.md, GEMINI.md, .kiro/steering) with L0/L1/L2 layering + manifest stale detection (`--check` for CI). Surface table derived from researched agent CLI docs (docs/10-agent-adapters.md) - workflow engine: transition authorisation, gate evaluation (command/checklist/required-field/policy/human), no-self-review, dependency gating, append-only ledger - commands: init, compile, validate, status, standup, gate, ticket (new/claim/move/show), kb (add/query/index) - unit + integration tests, Makefile, GitHub Actions CI https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- .github/workflows/hatch.yml | 31 +++ hatch/.gitignore | 4 + hatch/Makefile | 32 +++ hatch/README.md | 31 ++- hatch/docs/10-agent-adapters.md | 74 ++++++ hatch/go.mod | 13 + hatch/go.sum | 12 + hatch/internal/cli/compile.go | 51 ++++ hatch/internal/cli/gate.go | 63 +++++ hatch/internal/cli/init.go | 41 +++ hatch/internal/cli/kb.go | 171 +++++++++++++ hatch/internal/cli/root.go | 50 ++++ hatch/internal/cli/standup.go | 52 ++++ hatch/internal/cli/status.go | 77 ++++++ hatch/internal/cli/ticket.go | 238 ++++++++++++++++++ hatch/internal/cli/validate.go | 34 +++ hatch/internal/compile/bundle.go | 88 +++++++ hatch/internal/compile/compile.go | 111 ++++++++ hatch/internal/compile/compile_test.go | 68 +++++ hatch/internal/compile/manifest.go | 124 +++++++++ hatch/internal/compile/render.go | 88 +++++++ hatch/internal/compile/surface.go | 71 ++++++ hatch/internal/config/config.go | 59 +++++ hatch/internal/config/validate.go | 170 +++++++++++++ hatch/internal/config/validate_test.go | 63 +++++ hatch/internal/gate/gate.go | 133 ++++++++++ hatch/internal/mdfront/mdfront.go | 90 +++++++ hatch/internal/mdfront/mdfront_test.go | 46 ++++ hatch/internal/model/kb.go | 36 +++ hatch/internal/model/ledger.go | 32 +++ hatch/internal/model/registry.go | 80 ++++++ hatch/internal/model/ticket.go | 41 +++ hatch/internal/model/workflow.go | 99 ++++++++ hatch/internal/paths/paths.go | 84 +++++++ hatch/internal/scaffold/scaffold.go | 100 ++++++++ .../templates/base/board/backlog/.keep | 1 + .../templates/base/board/blocked/.keep | 1 + .../scaffold/templates/base/board/done/.keep | 1 + .../templates/base/board/in-progress/.keep | 1 + .../templates/base/board/review/.keep | 1 + .../scaffold/templates/base/charter.md | 17 ++ .../scaffold/templates/base/compiled/.keep | 1 + .../templates/base/context/product/.keep | 1 + .../scaffold/templates/base/context/shared.md | 9 + .../templates/base/context/tech/.keep | 1 + .../scaffold/templates/base/kb/.meta.json | 4 + .../templates/base/kb/decisions/.keep | 1 + .../scaffold/templates/base/kb/domain/.keep | 1 + .../scaffold/templates/base/kb/index.md | 5 + .../templates/base/kb/learnings/.keep | 1 + .../scaffold/templates/base/ledger/.keep | 1 + .../templates/base/protocol/branching.md | 6 + .../templates/base/protocol/claim-lock.md | 6 + .../base/protocol/definition-of-done.md | 11 + .../templates/base/protocol/handoff.md | 8 + .../scaffold/templates/base/registry.yaml | 57 +++++ .../templates/base/roles/architect.md | 11 + .../templates/base/roles/conductor.md | 12 + .../templates/base/roles/implementer.md | 12 + .../scaffold/templates/base/roles/reviewer.md | 11 + .../templates/base/roles/tech-writer.md | 9 + .../scaffold/templates/base/roles/tester.md | 10 + .../scaffold/templates/workflows/kanban.yaml | 51 ++++ .../scaffold/templates/workflows/lite.yaml | 26 ++ .../scaffold/templates/workflows/scrum.yaml | 52 ++++ .../templates/workflows/spec-first.yaml | 66 +++++ hatch/internal/store/board.go | 132 ++++++++++ hatch/internal/store/kb.go | 153 +++++++++++ hatch/internal/store/ledger.go | 104 ++++++++ hatch/internal/validate/board.go | 82 ++++++ hatch/internal/wf/engine.go | 227 +++++++++++++++++ hatch/internal/wf/engine_test.go | 100 ++++++++ 72 files changed, 3747 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/hatch.yml create mode 100644 hatch/.gitignore create mode 100644 hatch/Makefile create mode 100644 hatch/docs/10-agent-adapters.md create mode 100644 hatch/go.mod create mode 100644 hatch/go.sum create mode 100644 hatch/internal/cli/compile.go create mode 100644 hatch/internal/cli/gate.go create mode 100644 hatch/internal/cli/init.go create mode 100644 hatch/internal/cli/kb.go create mode 100644 hatch/internal/cli/root.go create mode 100644 hatch/internal/cli/standup.go create mode 100644 hatch/internal/cli/status.go create mode 100644 hatch/internal/cli/ticket.go create mode 100644 hatch/internal/cli/validate.go create mode 100644 hatch/internal/compile/bundle.go create mode 100644 hatch/internal/compile/compile.go create mode 100644 hatch/internal/compile/compile_test.go create mode 100644 hatch/internal/compile/manifest.go create mode 100644 hatch/internal/compile/render.go create mode 100644 hatch/internal/compile/surface.go create mode 100644 hatch/internal/config/config.go create mode 100644 hatch/internal/config/validate.go create mode 100644 hatch/internal/config/validate_test.go create mode 100644 hatch/internal/gate/gate.go create mode 100644 hatch/internal/mdfront/mdfront.go create mode 100644 hatch/internal/mdfront/mdfront_test.go create mode 100644 hatch/internal/model/kb.go create mode 100644 hatch/internal/model/ledger.go create mode 100644 hatch/internal/model/registry.go create mode 100644 hatch/internal/model/ticket.go create mode 100644 hatch/internal/model/workflow.go create mode 100644 hatch/internal/paths/paths.go create mode 100644 hatch/internal/scaffold/scaffold.go create mode 100644 hatch/internal/scaffold/templates/base/board/backlog/.keep create mode 100644 hatch/internal/scaffold/templates/base/board/blocked/.keep create mode 100644 hatch/internal/scaffold/templates/base/board/done/.keep create mode 100644 hatch/internal/scaffold/templates/base/board/in-progress/.keep create mode 100644 hatch/internal/scaffold/templates/base/board/review/.keep create mode 100644 hatch/internal/scaffold/templates/base/charter.md create mode 100644 hatch/internal/scaffold/templates/base/compiled/.keep create mode 100644 hatch/internal/scaffold/templates/base/context/product/.keep create mode 100644 hatch/internal/scaffold/templates/base/context/shared.md create mode 100644 hatch/internal/scaffold/templates/base/context/tech/.keep create mode 100644 hatch/internal/scaffold/templates/base/kb/.meta.json create mode 100644 hatch/internal/scaffold/templates/base/kb/decisions/.keep create mode 100644 hatch/internal/scaffold/templates/base/kb/domain/.keep create mode 100644 hatch/internal/scaffold/templates/base/kb/index.md create mode 100644 hatch/internal/scaffold/templates/base/kb/learnings/.keep create mode 100644 hatch/internal/scaffold/templates/base/ledger/.keep create mode 100644 hatch/internal/scaffold/templates/base/protocol/branching.md create mode 100644 hatch/internal/scaffold/templates/base/protocol/claim-lock.md create mode 100644 hatch/internal/scaffold/templates/base/protocol/definition-of-done.md create mode 100644 hatch/internal/scaffold/templates/base/protocol/handoff.md create mode 100644 hatch/internal/scaffold/templates/base/registry.yaml create mode 100644 hatch/internal/scaffold/templates/base/roles/architect.md create mode 100644 hatch/internal/scaffold/templates/base/roles/conductor.md create mode 100644 hatch/internal/scaffold/templates/base/roles/implementer.md create mode 100644 hatch/internal/scaffold/templates/base/roles/reviewer.md create mode 100644 hatch/internal/scaffold/templates/base/roles/tech-writer.md create mode 100644 hatch/internal/scaffold/templates/base/roles/tester.md create mode 100644 hatch/internal/scaffold/templates/workflows/kanban.yaml create mode 100644 hatch/internal/scaffold/templates/workflows/lite.yaml create mode 100644 hatch/internal/scaffold/templates/workflows/scrum.yaml create mode 100644 hatch/internal/scaffold/templates/workflows/spec-first.yaml create mode 100644 hatch/internal/store/board.go create mode 100644 hatch/internal/store/kb.go create mode 100644 hatch/internal/store/ledger.go create mode 100644 hatch/internal/validate/board.go create mode 100644 hatch/internal/wf/engine.go create mode 100644 hatch/internal/wf/engine_test.go 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/hatch/.gitignore b/hatch/.gitignore new file mode 100644 index 0000000..f9d5677 --- /dev/null +++ b/hatch/.gitignore @@ -0,0 +1,4 @@ +/bin/ +hatch +*.out +*.test diff --git a/hatch/Makefile b/hatch/Makefile new file mode 100644 index 0000000..65ef497 --- /dev/null +++ b/hatch/Makefile @@ -0,0 +1,32 @@ +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 + +build: ## Build the hatch binary into ./bin + @mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/hatch + +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 index 5f3047d..5d8fdbc 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -73,9 +73,34 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.schema.md) · [ledger](spec/ledger.schema.md) · [workflow](spec/workflow.schema.md) -## Trạng thái - -Đây là **bộ thiết kế** (design docs). Chưa implement. Implement theo [roadmap](docs/08-roadmap.md) sau khi docs được chốt. +## CLI (`hatch`) + +Hatch là CLI viết bằng Go (single binary). Phase 1+2 đã chạy được: + +```bash +make build # → bin/hatch +bin/hatch init -w scrum # dựng .hatch/ (scrum|kanban|spec-first|lite) +bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering +bin/hatch compile --check # CI: fail nếu output stale so với SSOT +bin/hatch validate # kiểm tra registry + workflow + board +bin/hatch ticket new --title "Export CSV" --role implementer --priority P1 +bin/hatch ticket claim T-001 --agent codex --why "sprint S1" +bin/hatch ticket move T-001 --to review --by implementer --agent codex \ + --why "code xong" --handoff "endpoint ở export.go" +bin/hatch gate T-001 --to review # đánh giá gate mà không di chuyển +bin/hatch status # board + cảnh báo WIP +bin/hatch standup --days 1 # digest ledger +bin/hatch kb add --type decision --title "CSV streaming" --tags export +bin/hatch kb query export +``` + +## Trạng thái implement + +- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/Gemini/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. Có unit + integration test, CI, Makefile. +- **Phase 3 — đang tới:** orchestrator spawn agent headless (`hatch run`/`plan`/`watch`) theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard. + +Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. diff --git a/hatch/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md new file mode 100644 index 0000000..ce6add0 --- /dev/null +++ b/hatch/docs/10-agent-adapters.md @@ -0,0 +1,74 @@ +# 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` | +| Gemini CLI | `gemini` | `GEMINI.md` | `gemini -p` | +| Antigravity | `antigravity` | `AGENTS.md` (dùng chung convention) | IDE (chưa có CLI chính thức xác nhận) | +| (generic) | `manual` | — | không spawn; tạo handoff cho người/IDE | + +`AGENTS.md` là convention dùng chung — **một** file phục vụ Codex + Antigravity (+ Gemini nếu khai `context.fileName`). + +--- + +## 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+.) + +## Gemini CLI (`kind: gemini`) + +**Instruction file.** `GEMINI.md` (global `~/.gemini/GEMINI.md` + project + nested, most-specific wins). Import `@path`. Có thể đọc `AGENTS.md` qua `settings.json`: `"context": { "fileName": ["AGENTS.md","GEMINI.md"] }`. + +**Config.** `.gemini/settings.json` (project) > `~/.gemini/settings.json` (user) > system. `mcpServers` block hoặc `gemini mcp add`. + +**Headless.** `gemini -p "" -m --approval-mode ` (hoặc `-y/--yolo`), `--include-directories`, `-s/--sandbox`, `-o/--output-format text|json|stream-json`. JSON: `{response, stats, error?}`. + +## Antigravity (`kind: antigravity`) + +IDE agent-first (Agent Manager; surfaces editor/terminal/browser) ra mắt ~11/2025, quanh Gemini 3. Đọc `GEMINI.md` + `AGENTS.md` + thư mục rules workspace (**`.agent/rules/`** — chưa chắc) + Skills. Global rules `~/.gemini/AGENTS.md`. +> **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/go.mod b/hatch/go.mod new file mode 100644 index 0000000..508cee7 --- /dev/null +++ b/hatch/go.mod @@ -0,0 +1,13 @@ +module github.com/fioenix/overclaud/hatch + +go 1.24.7 + +require ( + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/hatch/go.sum b/hatch/go.sum new file mode 100644 index 0000000..a01295b --- /dev/null +++ b/hatch/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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= +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/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/gate.go b/hatch/internal/cli/gate.go new file mode 100644 index 0000000..1b588d8 --- /dev/null +++ b/hatch/internal/cli/gate.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/gate" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +func newGateCmd() *cobra.Command { + var to string + cmd := &cobra.Command{ + Use: "gate ", + Short: "Evaluate the gates for a ticket's transition without moving it", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if to == "" { + return fmt.Errorf("--to is required") + } + b := store.NewBoard(ws.Layout) + t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("ticket %s not found", args[0]) + } + tr, ok := ws.Workflow.FindTransition(t.Lane, to) + if !ok { + return fmt.Errorf("no transition %s → %s", t.Lane, to) + } + if len(tr.Gates) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "no gates on %s → %s\n", t.Lane, to) + return nil + } + out := cmd.OutOrStdout() + failed := 0 + for _, o := range gate.EvaluateAll(ws, tr.Gates, t, ws.Layout.RepoRoot()) { + status := "✓" + switch { + case o.Human: + status = "⊙ human" + case !o.Passed: + status = "✗" + failed++ + } + fmt.Fprintf(out, " %s %-16s %s\n", status, o.Name, o.Detail) + } + if failed > 0 { + return fmt.Errorf("%d gate(s) failed", failed) + } + return nil + }, + } + cmd.Flags().StringVar(&to, "to", "", "target lane to evaluate gates for (required)") + return cmd +} diff --git a/hatch/internal/cli/init.go b/hatch/internal/cli/init.go new file mode 100644 index 0000000..f1c03a1 --- /dev/null +++ b/hatch/internal/cli/init.go @@ -0,0 +1,41 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +func newInitCmd() *cobra.Command { + var workflow string + var force bool + cmd := &cobra.Command{ + Use: "init [dir]", + Short: "Create a new .hatch/ workspace from templates", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) == 1 { + dir = args[0] + } + l, written, err := scaffold.Init(scaffold.Options{ + Dir: dir, + Workflow: workflow, + Force: force, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%d files, workflow=%s)\n", l.Root, len(written), workflow) + fmt.Fprintln(cmd.OutOrStdout(), "Next: edit charter.md + registry.yaml, then `hatch compile`.") + 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") + return cmd +} diff --git a/hatch/internal/cli/kb.go b/hatch/internal/cli/kb.go new file mode 100644 index 0000000..0037028 --- /dev/null +++ b/hatch/internal/cli/kb.go @@ -0,0 +1,171 @@ +package cli + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "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", + } + cmd.AddCommand(newKBAddCmd(), newKBQueryCmd(), newKBIndexCmd()) + return cmd +} + +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 := store.NewKB(ws.Layout) + id := nextKBID(kb, 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 := store.NewKB(ws.Layout).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 := store.NewKB(ws.Layout).RebuildIndex(); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "rebuilt kb/index.md") + return nil + }, + } + return cmd +} + +// nextKBID generates a per-type id: ADR-NNN for decisions, KB-NNN otherwise. +func nextKBID(kb *store.KB, typ string) string { + prefix := "KB" + if typ == model.KBDecision { + prefix = "ADR" + } + entries, _ := kb.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) +} + +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 +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go new file mode 100644 index 0000000..afe426d --- /dev/null +++ b/hatch/internal/cli/root.go @@ -0,0 +1,50 @@ +// 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, + } + root.AddCommand( + newInitCmd(), + newCompileCmd(), + newValidateCmd(), + newStatusCmd(), + newStandupCmd(), + newTicketCmd(), + newKBCmd(), + newGateCmd(), + ) + return root +} + +// loadWorkspace finds the .hatch workspace from cwd and loads its config. +func loadWorkspace() (*config.Workspace, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + l, err := paths.Find(cwd) + if err != nil { + return nil, err + } + return config.Load(l) +} diff --git a/hatch/internal/cli/standup.go b/hatch/internal/cli/standup.go new file mode 100644 index 0000000..0e678b3 --- /dev/null +++ b/hatch/internal/cli/standup.go @@ -0,0 +1,52 @@ +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..e09d9eb --- /dev/null +++ b/hatch/internal/cli/status.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +func newStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show the board: tickets per lane, assignees, WIP", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + b := store.NewBoard(ws.Layout) + out := cmd.OutOrStdout() + + for _, lane := range ws.Workflow.Lanes { + tickets, err := b.ListLane(lane.ID) + if err != nil { + return err + } + limit := "" + if lane.WIPLimit > 0 { + limit = fmt.Sprintf(" (WIP %d/%d)", len(tickets), lane.WIPLimit) + } + fmt.Fprintf(out, "\n## %s — %d%s\n", lane.ID, len(tickets), limit) + if len(tickets) == 0 { + continue + } + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + for _, t := range tickets { + assignee := t.Assignee + if assignee == "" { + assignee = "-" + } + prio := t.Priority + if prio == "" { + prio = "--" + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", t.ID, prio, t.Role, assignee, t.Title) + } + tw.Flush() + } + fmt.Fprintln(out) + printWIPWarnings(cmd, ws, b) + return nil + }, + } + return cmd +} + +// printWIPWarnings flags agents holding more in-flight tickets than their limit. +func printWIPWarnings(cmd *cobra.Command, ws *config.Workspace, b *store.Board) { + tickets, err := b.List(ws.Workflow.LaneIDs()) + if err != nil { + return + } + perAgent := map[string]int{} + for _, t := range tickets { + if t.Assignee != "" && t.Lane != "done" { + perAgent[t.Assignee]++ + } + } + for _, a := range ws.Registry.Agents { + if a.WIP > 0 && perAgent[a.ID] > a.WIP { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %s over WIP (%d/%d)\n", a.ID, perAgent[a.ID], a.WIP) + } + } +} diff --git a/hatch/internal/cli/ticket.go b/hatch/internal/cli/ticket.go new file mode 100644 index 0000000..f75799f --- /dev/null +++ b/hatch/internal/cli/ticket.go @@ -0,0 +1,238 @@ +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()) + 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 := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 := wf.Move(ws, store.NewBoard(ws.Layout), store.NewLedger(ws.Layout), 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..02f0c03 --- /dev/null +++ b/hatch/internal/compile/bundle.go @@ -0,0 +1,88 @@ +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, and the L2 pointers (not the content). +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 +} + +// 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)) +} + +// 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) + } + } + } + return Bundle{ + Surface: surf, + Agents: agents, + Charter: charter, + Roles: roles, + ContextRefs: refs, + Project: ws.Registry.Project, + } +} diff --git a/hatch/internal/compile/compile.go b/hatch/internal/compile/compile.go new file mode 100644 index 0000000..05d4a64 --- /dev/null +++ b/hatch/internal/compile/compile.go @@ -0,0 +1,111 @@ +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. +func Run(ws *config.Workspace) (*Result, []string, error) { + repoRoot := ws.Layout.RepoRoot() + 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) + } + + 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..23e8f50 --- /dev/null +++ b/hatch/internal/compile/compile_test.go @@ -0,0 +1,68 @@ +package compile + +import ( + "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, gemini, 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 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/render.go b/hatch/internal/compile/render.go new file mode 100644 index 0000000..7a9a48c --- /dev/null +++ b/hatch/internal/compile/render.go @@ -0,0 +1,88 @@ +package compile + +import ( + "fmt" + "strings" +) + +// 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 do ticket bạn claim quyết định** — đọc frontmatter `role` của ticket.\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) + } + } + + // L2 — pointers only; the agent reads these on demand when it claims work. + w.WriteString("## Context map (L2 — đọc on-demand, KHÔNG nạp sẵn)\n\n") + w.WriteString("Khi claim ticket, đọc `context_refs` của ticket + các nguồn liên quan dưới đây. Tra Knowledge Base 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("- Board: `.hatch/board//` — ticket bạn đang làm.\n") + w.WriteString("- Knowledge Base: `.hatch/kb/index.md` — quyết định & bài học chung.\n") + w.WriteString("- Protocol: `.hatch/protocol/` — claim/lock, handoff, DoD.\n\n") + + // Operating rules shared by every surface. + w.WriteString("## Operating protocol\n\n") + w.WriteString("- Một ticket một branch; claim = `git mv` vào `in-progress/` + push ngay (push thắng = lock thắng).\n") + w.WriteString("- Mọi chuyển trạng thái để lại entry ledger (`why` bắt buộc) trong cùng commit.\n") + w.WriteString("- Không sửa file ngoài `context_refs` đã khai; mở rộng scope thì cập nhật ticket trước.\n") + w.WriteString("- Đổi assignee/role ⇒ ghi Handoff notes. Tri thức đáng giữ ⇒ ghi KB.\n") + + out := w.String() + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out +} diff --git a/hatch/internal/compile/surface.go b/hatch/internal/compile/surface.go new file mode 100644 index 0000000..2475571 --- /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 CLI — GEMINI.md"} + 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 "gemini": + 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..2b6e9ae --- /dev/null +++ b/hatch/internal/config/config.go @@ -0,0 +1,59 @@ +// 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 +} + +// 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..89e0fcf --- /dev/null +++ b/hatch/internal/config/validate.go @@ -0,0 +1,170 @@ +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 + } + 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 +} + +// 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/gate/gate.go b/hatch/internal/gate/gate.go new file mode 100644 index 0000000..6fb0931 --- /dev/null +++ b/hatch/internal/gate/gate.go @@ -0,0 +1,133 @@ +// Package gate evaluates workflow gates: shell commands, checklists, required +// fields, registry policies and human approvals. +package gate + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// 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. Human gates never auto-pass; they return Human=true. +func 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 := runShell(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 and returns the outcomes. +func 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, Evaluate(ws, n, t, repoRoot)) + } + return outs +} + +func runShell(cmdline, dir string) (string, error) { + c := exec.Command("sh", "-c", cmdline) + c.Dir = dir + out, err := c.CombinedOutput() + return string(out), err +} + +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/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/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..973d0c0 --- /dev/null +++ b/hatch/internal/model/ledger.go @@ -0,0 +1,32 @@ +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 +} diff --git a/hatch/internal/model/registry.go b/hatch/internal/model/registry.go new file mode 100644 index 0000000..bc68dcf --- /dev/null +++ b/hatch/internal/model/registry.go @@ -0,0 +1,80 @@ +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 | gemini | kiro | shell | 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 +} + +// 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 +} + +// 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 +} + +// WorkflowRef points the registry at the per-project workflow definition. +type WorkflowRef struct { + Ref string `yaml:"ref,omitempty"` +} + +// 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"` +} + +// 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..6ab0d53 --- /dev/null +++ b/hatch/internal/model/ticket.go @@ -0,0 +1,41 @@ +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"` +} + +// 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"` + 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" } 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/paths/paths.go b/hatch/internal/paths/paths.go new file mode 100644 index 0000000..81fc630 --- /dev/null +++ b/hatch/internal/paths/paths.go @@ -0,0 +1,84 @@ +// 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" + + 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) } + +// 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`)") + +// Find walks up from start looking for a .hatch directory and returns its +// Layout. It stops at the filesystem root. +func Find(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 + } +} + +// 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/scaffold/scaffold.go b/hatch/internal/scaffold/scaffold.go new file mode 100644 index 0000000..1c70939 --- /dev/null +++ b/hatch/internal/scaffold/scaffold.go @@ -0,0 +1,100 @@ +// 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"} + +// 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/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..dfe6da7 --- /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: gemini + kind: gemini + 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/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/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/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/kb.go b/hatch/internal/store/kb.go new file mode 100644 index 0000000..ab3785a --- /dev/null +++ b/hatch/internal/store/kb.go @@ -0,0 +1,153 @@ +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" +) + +// KB reads and writes shared Knowledge Base entries. +type KB struct{ L paths.Layout } + +// NewKB returns a knowledge base bound to a workspace layout. +func NewKB(l paths.Layout) *KB { return &KB{L: l} } + +// List walks the kb/ subdirectories and returns all entries. +func (k *KB) List() ([]model.KBEntry, error) { + root := k.L.KB() + 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 +} + +// 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.L.KB(), 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, "` `") + "`" + } + fmt.Fprintf(&b, "- [%s](%s) %s%s\n", e.ID, e.Path, e.Title, tags) + } + b.WriteString("\n") + } + return os.WriteFile(k.L.KBIndex(), []byte(b.String()), 0o644) +} diff --git a/hatch/internal/store/ledger.go b/hatch/internal/store/ledger.go new file mode 100644 index 0000000..721d8d6 --- /dev/null +++ b/hatch/internal/store/ledger.go @@ -0,0 +1,104 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// 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.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") + } + 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/validate/board.go b/hatch/internal/validate/board.go new file mode 100644 index 0000000..61277ac --- /dev/null +++ b/hatch/internal/validate/board.go @@ -0,0 +1,82 @@ +// Package validate holds board-level checks that span tickets, registry and +// workflow (config-only checks live in the config package). +package validate + +import ( + "fmt" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +// 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 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/wf/engine.go b/hatch/internal/wf/engine.go new file mode 100644 index 0000000..a651399 --- /dev/null +++ b/hatch/internal/wf/engine.go @@ -0,0 +1,227 @@ +// 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/store" +) + +// 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 Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID string, opt MoveOptions) (*Result, error) { + 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 must be done before claiming into an active lane. + if tr.Action == model.ActClaim { + if err := checkDependencies(ws, b, t); err != nil { + return nil, err + } + } + + // 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)) + 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 *store.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..cd959cd --- /dev/null +++ b/hatch/internal/wf/engine_test.go @@ -0,0 +1,100 @@ +package wf + +import ( + "testing" + "time" + + "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 setup(t *testing.T) (*config.Workspace, *store.Board, *store.Ledger) { + 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) + } + return ws, store.NewBoard(l), store.NewLedger(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, b, lg := 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 := Move(ws, b, lg, "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, b, lg := setup(t) + writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) + + res, err := Move(ws, b, lg, "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, b, lg := 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 := Move(ws, b, lg, "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, b, lg := setup(t) + writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) + if _, err := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}); err != nil { + t.Fatal(err) + } + if _, err := Move(ws, b, lg, "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 := Move(ws, b, lg, "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 := Move(ws, b, lg, "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, b, lg := setup(t) + writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) + if _, err := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex"}); err == nil { + t.Fatal("expected error when why is empty") + } +} From 61da7ffdc212edfe3fbef8871827e3c028c8c85e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 13:46:56 +0000 Subject: [PATCH 04/60] Fix .gitignore ignoring cmd/hatch; commit CLI entrypoint The bare `hatch` ignore pattern matched the cmd/hatch source directory, so main.go was never committed and CI `make build` failed. Anchor the binary ignore patterns instead. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/.gitignore | 4 +++- hatch/cmd/hatch/main.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 hatch/cmd/hatch/main.go diff --git a/hatch/.gitignore b/hatch/.gitignore index f9d5677..355714e 100644 --- a/hatch/.gitignore +++ b/hatch/.gitignore @@ -1,4 +1,6 @@ +# Build artifacts. Note: patterns must be anchored so they don't match the +# cmd/hatch source directory. /bin/ -hatch +/hatch *.out *.test 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) + } +} From ae6de85593f1ce102626bdfca107114692c195e4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 13:51:11 +0000 Subject: [PATCH 05/60] Add Phase 3 orchestrator: headless agent adapters, run/plan/watch, TUI - adapter layer building native headless invocations per agent kind (claude -p / codex exec / gemini -p / kiro-cli chat --no-interactive), with capability flags from registry sandbox/approval hints; kiro/ antigravity/manual fall back to handoff when no headless contract - orchestrator.Run/Execute: spawn agent, stream output, record ledger; --dry-run prints the invocation without executing - git worktree isolation helper for per-ticket runs - CLI: `hatch run`, `hatch plan`, `hatch watch`, plus `hatch board` Bubble Tea TUI dashboard (brand colors) - adapter unit tests (no real agent needed) https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 11 +- hatch/docs/08-roadmap.md | 2 + hatch/go.mod | 19 ++ hatch/go.sum | 43 +++++ hatch/internal/cli/board.go | 23 +++ hatch/internal/cli/orchestrate.go | 190 ++++++++++++++++++++ hatch/internal/cli/root.go | 4 + hatch/internal/orchestrator/adapter.go | 74 ++++++++ hatch/internal/orchestrator/adapter_test.go | 67 +++++++ hatch/internal/orchestrator/adapters.go | 77 ++++++++ hatch/internal/orchestrator/prompt.go | 54 ++++++ hatch/internal/orchestrator/run.go | 127 +++++++++++++ hatch/internal/orchestrator/worktree.go | 53 ++++++ hatch/internal/tui/board.go | 112 ++++++++++++ 14 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 hatch/internal/cli/board.go create mode 100644 hatch/internal/cli/orchestrate.go create mode 100644 hatch/internal/orchestrator/adapter.go create mode 100644 hatch/internal/orchestrator/adapter_test.go create mode 100644 hatch/internal/orchestrator/adapters.go create mode 100644 hatch/internal/orchestrator/prompt.go create mode 100644 hatch/internal/orchestrator/run.go create mode 100644 hatch/internal/orchestrator/worktree.go create mode 100644 hatch/internal/tui/board.go diff --git a/hatch/README.md b/hatch/README.md index 5d8fdbc..8ddda21 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -96,11 +96,16 @@ bin/hatch status # board + cảnh báo WIP bin/hatch standup --days 1 # digest ledger bin/hatch kb add --type decision --title "CSV streaming" --tags export bin/hatch kb query export +# Phase 3 — orchestrator (spawn agent headless theo docs/10) +bin/hatch run T-001 --claim --dry-run # build invocation; bỏ --dry-run để chạy thật +bin/hatch plan --dry-run # spawn Conductor bẻ việc +bin/hatch watch --dry-run --max 3 # gán + chạy backlog (tôn trọng WIP) +bin/hatch board # TUI dashboard ``` ## Trạng thái implement -- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/Gemini/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. Có unit + integration test, CI, Makefile. -- **Phase 3 — đang tới:** orchestrator spawn agent headless (`hatch run`/`plan`/`watch`) theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard. +- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/Gemini/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. +- **Phase 3 — xong (cơ chế):** orchestrator + adapter cho từng agent (`hatch run`/`plan`/`watch`) dựng invocation headless đúng theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard (`hatch board`). Adapter `kiro`/`antigravity`/`manual` rơi về handoff khi không có headless. `--dry-run` cho phép kiểm tra invocation mà không cần cài agent. -Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. +Có unit + integration test, CI, Makefile. Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. diff --git a/hatch/docs/08-roadmap.md b/hatch/docs/08-roadmap.md index 875eaae..74eeef1 100644 --- a/hatch/docs/08-roadmap.md +++ b/hatch/docs/08-roadmap.md @@ -42,6 +42,8 @@ Orchestrator tự spawn đúng agent cho đúng ticket (xem [orchestrator](07-or **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/gemini/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 ``` diff --git a/hatch/go.mod b/hatch/go.mod index 508cee7..a3e1131 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -3,11 +3,30 @@ module github.com/fioenix/overclaud/hatch go 1.24.7 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 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.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.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.16 // 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/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/hatch/go.sum b/hatch/go.sum index a01295b..78707d7 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -1,11 +1,54 @@ +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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.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= 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= diff --git a/hatch/internal/cli/board.go b/hatch/internal/cli/board.go new file mode 100644 index 0000000..829b7d5 --- /dev/null +++ b/hatch/internal/cli/board.go @@ -0,0 +1,23 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/tui" +) + +func newBoardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "board", + Short: "Interactive TUI board dashboard", + 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 +} diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go new file mode 100644 index 0000000..9186277 --- /dev/null +++ b/hatch/internal/cli/orchestrate.go @@ -0,0 +1,190 @@ +package cli + +import ( + "fmt" + "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/orchestrator" + "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 +// the first registry agent eligible for the role and available on PATH. +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) + } + for _, a := range candidates { + if orchestrator.Available(a) { + return a, nil + } + } + // none installed; return the first so --dry-run still works. + return candidates[0], nil +} + +func newRunCmd() *cobra.Command { + var agentID string + var dryRun, claim, worktree bool + var timeout time.Duration + cmd := &cobra.Command{ + Use: "run <ticket>", + 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 := wf.Move(ws, b, store.NewLedger(ws.Layout), 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) + } + fmt.Fprintf(cmd.OutOrStdout(), "→ %s (%s) on %s as %s\n", agent.ID, agent.Kind, t.ID, t.Role) + out, err := orchestrator.Run(ws, agent, t, t.Role, orchestrator.RunOptions{ + DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), + }) + 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().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().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 = orchestrator.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 int + cmd := &cobra.Command{ + Use: "watch", + Short: "Assign and run claimable backlog tickets (one pass, respects WIP)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + b := store.NewBoard(ws.Layout) + lane := firstLane(ws) + tickets, err := b.ListLane(lane) + if err != nil { + return err + } + out := cmd.OutOrStdout() + ran := 0 + for _, t := range tickets { + if max > 0 && ran >= 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 dryRun { + ran++ + continue + } + to := claimTarget(ws, t.Lane) + if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 + } + t2, _, _ := b.Find(t.ID, ws.Workflow.LaneIDs()) + if _, err := orchestrator.Run(ws, agent, t2, t2.Role, orchestrator.RunOptions{Stdout: out}); err != nil { + fmt.Fprintf(out, " run failed: %v\n", err) + } + ran++ + } + fmt.Fprintf(out, "watch: %d ticket(s) dispatched\n", ran) + 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)") + return cmd +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index afe426d..c50f7d9 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -32,6 +32,10 @@ func NewRoot() *cobra.Command { newTicketCmd(), newKBCmd(), newGateCmd(), + newRunCmd(), + newPlanCmd(), + newWatchCmd(), + newBoardCmd(), ) return root } diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go new file mode 100644 index 0000000..3a3043e --- /dev/null +++ b/hatch/internal/orchestrator/adapter.go @@ -0,0 +1,74 @@ +// 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{}, + "gemini": geminiAdapter{}, + "kiro": kiroAdapter{}, + "antigravity": manualAdapter{kind: "antigravity", reason: "Antigravity is IDE-driven; no confirmed headless CLI"}, + "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..a3031ae --- /dev/null +++ b/hatch/internal/orchestrator/adapter_test.go @@ -0,0 +1,67 @@ +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 TestGeminiAndKiro(t *testing.T) { + g := strings.Join(build("gemini", nil).Args, " ") + if !strings.Contains(g, "gemini -p") || !strings.Contains(g, "--approval-mode auto_edit") { + t.Errorf("gemini invocation wrong: %q", g) + } + 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..1b6e9db --- /dev/null +++ b/hatch/internal/orchestrator/adapters.go @@ -0,0 +1,77 @@ +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} +} + +// geminiAdapter drives Gemini CLI headlessly: `gemini -p`. +type geminiAdapter struct{} + +func (geminiAdapter) Kind() string { return "gemini" } +func (geminiAdapter) Build(req RunRequest) Invocation { + mode := req.Agent.Approval + if mode == "" { + mode = "auto_edit" + } + args := []string{program(req.Agent, "gemini"), "-p", req.Prompt, + "--approval-mode", mode, "--output-format", "json"} + if req.Agent.Model != "" { + args = append(args, "-m", req.Agent.Model) + } + return Invocation{Args: args, Headless: true} +} + +// 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"} +} + +// 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/prompt.go b/hatch/internal/orchestrator/prompt.go new file mode 100644 index 0000000..e8e30da --- /dev/null +++ b/hatch/internal/orchestrator/prompt.go @@ -0,0 +1,54 @@ +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() +} + +// 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..5baa758 --- /dev/null +++ b/hatch/internal/orchestrator/run.go @@ -0,0 +1,127 @@ +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/store" +) + +// 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) +} + +// 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. +func Run(ws *config.Workspace, agent model.Agent, t model.Ticket, role string, opt RunOptions) (*RunOutcome, error) { + return Execute(ws, agent, t.ID, BuildPrompt(t, role), opt) +} + +// Execute builds and (unless DryRun) runs an agent against an arbitrary prompt, +// recording the outcome in the ledger under ticketID ("-" for system tasks). +func 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) + t := model.Ticket{ID: ticketID} + out := opt.Stdout + if out == nil { + out = os.Stdout + } + lg := store.NewLedger(ws.Layout) + + // 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) + _ = lg.Append(model.Entry{ + Agent: agent.ID, Ticket: t.ID, 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 + } + + // Execute. + 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 + cmd.Stdout = io.MultiWriter(out, &buf) + cmd.Stderr = io.MultiWriter(out, &buf) + + _ = lg.Append(model.Entry{ + Agent: agent.ID, Ticket: t.ID, Action: model.ActStart, + Why: fmt.Sprintf("orchestrator spawned %s for %s", agent.ID, t.ID), + }) + + 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() + } + _ = lg.Append(model.Entry{ + Agent: agent.ID, Ticket: t.ID, Action: model.ActProgress, + Result: result, Why: fmt.Sprintf("%s finished (exit %d)", agent.ID, outcome.ExitCode), + }) + 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/worktree.go b/hatch/internal/orchestrator/worktree.go new file mode 100644 index 0000000..34b2928 --- /dev/null +++ b/hatch/internal/orchestrator/worktree.go @@ -0,0 +1,53 @@ +package orchestrator + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +// 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) { + path := filepath.Join(repoRoot, WorktreeDir, ticketID) + if branch == "" { + branch = "hatch/" + ticketID + } + // 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/tui/board.go b/hatch/internal/tui/board.go new file mode 100644 index 0000000..0fb2205 --- /dev/null +++ b/hatch/internal/tui/board.go @@ -0,0 +1,112 @@ +// Package tui renders an interactive board dashboard with Bubble Tea. +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fcaf16")) + laneStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2a2b86")). + BorderStyle(lipgloss.NormalBorder()).BorderBottom(true) + overWIP = lipgloss.NewStyle().Foreground(lipgloss.Color("#d33")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + helpStyle = dimStyle.Copy().MarginTop(1) +) + +type model_ struct { + ws *config.Workspace + b *store.Board + err error + cols []laneCol +} + +type laneCol struct { + lane model.Lane + tickets []model.Ticket +} + +// New returns a Bubble Tea program for the board. +func New(ws *config.Workspace) *tea.Program { + m := model_{ws: ws, b: store.NewBoard(ws.Layout)} + m.reload() + return tea.NewProgram(m, tea.WithAltScreen()) +} + +func (m *model_) reload() { + m.cols = nil + for _, lane := range m.ws.Workflow.Lanes { + ts, err := m.b.ListLane(lane.ID) + if err != nil { + m.err = err + return + } + m.cols = append(m.cols, laneCol{lane: lane, tickets: ts}) + } +} + +func (m model_) Init() tea.Cmd { return nil } + +func (m model_) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return m, tea.Quit + case "r": + m.reload() + } + } + return m, nil +} + +func (m model_) View() string { + if m.err != nil { + return "error: " + m.err.Error() + "\n" + } + var b strings.Builder + project := m.ws.Registry.Project + if project == "" { + project = "Hatch" + } + b.WriteString(titleStyle.Render(project+" — board") + " " + dimStyle.Render("("+m.ws.Workflow.Template+")") + "\n\n") + + for _, c := range m.cols { + head := fmt.Sprintf("%s %d", c.lane.ID, len(c.tickets)) + if c.lane.WIPLimit > 0 { + wip := fmt.Sprintf(" (WIP %d/%d)", len(c.tickets), c.lane.WIPLimit) + if len(c.tickets) > c.lane.WIPLimit { + head += overWIP.Render(wip) + } else { + head += dimStyle.Render(wip) + } + } + b.WriteString(laneStyle.Render(head) + "\n") + if len(c.tickets) == 0 { + b.WriteString(dimStyle.Render(" —") + "\n") + } + for _, t := range c.tickets { + assignee := t.Assignee + if assignee == "" { + assignee = "-" + } + prio := t.Priority + if prio == "" { + prio = "--" + } + line := fmt.Sprintf(" %-7s %-3s %-12s %-12s %s", t.ID, prio, t.Role, assignee, t.Title) + b.WriteString(line + "\n") + } + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("r reload · q quit")) + return b.String() +} From 135ac4dace6cd020c1c9441bb570f328deb42285 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 13:52:11 +0000 Subject: [PATCH 06/60] Add hatch sync and pre-commit hook installer - `hatch sync`: reconcile ticket status with lane (--fix), rebuild KB index, report compile staleness - `hatch hook install`: write a pre-commit hook running validate + compile --check Completes the Phase 2 automation backlog. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/hook.go | 59 +++++++++++++++++++++++++++++++ hatch/internal/cli/root.go | 2 ++ hatch/internal/cli/sync.go | 71 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 hatch/internal/cli/hook.go create mode 100644 hatch/internal/cli/sync.go 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/root.go b/hatch/internal/cli/root.go index c50f7d9..71c6243 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -36,6 +36,8 @@ func NewRoot() *cobra.Command { newPlanCmd(), newWatchCmd(), newBoardCmd(), + newSyncCmd(), + newHookCmd(), ) return root } 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 +} From 24ac81b62892d3bd551ef505cc941929b94046fb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 14:22:29 +0000 Subject: [PATCH 07/60] Add architecture & workflow diagrams (Mermaid + rendered PNG) Three views: system architecture, ticket workflow state machine, and a ticket-lifecycle sequence. Mermaid renders on GitHub; PNGs in docs/assets. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 2 + hatch/docs/architecture-diagram.md | 126 +++++++++++++++++++++++++++++ hatch/docs/assets/arch.png | Bin 0 -> 112601 bytes hatch/docs/assets/sequence.png | Bin 0 -> 74404 bytes hatch/docs/assets/workflow.png | Bin 0 -> 55148 bytes 5 files changed, 128 insertions(+) create mode 100644 hatch/docs/architecture-diagram.md create mode 100644 hatch/docs/assets/arch.png create mode 100644 hatch/docs/assets/sequence.png create mode 100644 hatch/docs/assets/workflow.png diff --git a/hatch/README.md b/hatch/README.md index 8ddda21..d3e1717 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -75,6 +75,8 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | +Sơ đồ trực quan: [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) ## CLI (`hatch`) diff --git a/hatch/docs/architecture-diagram.md b/hatch/docs/architecture-diagram.md new file mode 100644 index 0000000..05c4193 --- /dev/null +++ b/hatch/docs/architecture-diagram.md @@ -0,0 +1,126 @@ +# Sơ đồ kiến trúc & workflow + +Ba góc nhìn của Hatch. Khối Mermaid render trực tiếp trên GitHub; ảnh PNG tương ứng nằm trong `assets/`. + +## 1. Kiến trúc hệ thống + +SSOT compile xuống từng surface agent; agents điều phối qua board/ledger; KB là bộ nhớ chung đọc-ghi; orchestrator spawn agent headless. + +![Kiến trúc hệ thống](assets/arch.png) + +```mermaid +flowchart TB + classDef ssot fill:#2a2b86,color:#fff,stroke:#2a2b86; + classDef surf fill:#fcaf16,color:#1a1a1a,stroke:#c98a00; + classDef store fill:#eef,stroke:#2a2b86,color:#2a2b86; + classDef eng fill:#fff,stroke:#2a2b86,color:#2a2b86,stroke-width:2px; + + 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 + COMPILER -. "manifest hash → stale detect" .-> MAN[("compiled/.manifest.json")]:::store + + COMPILER --> S1["CLAUDE.md"]:::surf + COMPILER --> S2["AGENTS.md<br/>(Codex/Antigravity)"]:::surf + COMPILER --> S3["GEMINI.md"]:::surf + COMPILER --> S4[".kiro/steering/"]:::surf + + subgraph AGENTS["Agents — mỗi con 1 process, KHÔNG chung RAM"] + direction LR + A1["Claude Code"] + A2["Codex"] + A3["Gemini"] + A4["Kiro"] + end + S1 --> A1 + S2 --> A2 + S3 --> A3 + S4 --> A4 + + ORCH{{"orchestrator<br/>run · plan · watch"}}:::eng + ORCH == "spawn headless<br/>(claude -p · codex exec · …)" ==> AGENTS + + ENGINE{{"workflow engine + gates<br/>(transition · no-self-review · WIP · deps)"}}:::eng + WF --> ENGINE + + subgraph STORE["Hệ thống file = database"] + direction LR + BOARD[("board/ — tickets<br/>vị trí thư mục = trạng thái")]:::store + LEDGER[("ledger/ — append-only audit")]:::store + KB[("kb/ — Knowledge Base<br/>đọc & ghi chung")]:::store + end + + AGENTS == "claim / move" ==> ENGINE + ENGINE --> BOARD + ENGINE --> LEDGER + AGENTS -- "tra cứu + đóng góp" --> KB + KB -. "promote khi chín (retro)" .-> CX +``` + +## 2. Workflow — máy trạng thái ticket (template `scrum`) + +Lane = thư mục trong `board/`. Mỗi mũi tên là một transition do `workflow.yaml` định nghĩa; nhãn ghi rõ ai được làm + gate phải qua. + +![Workflow ticket](assets/workflow.png) + +```mermaid +stateDiagram-v2 + direction LR + [*] --> backlog + backlog --> in_progress: claim · implementer/tester (deps done) + in_progress --> review: handoff — gates tests/lint/handoff-note + review --> done: done · reviewer — gates dod/no-self-review/human-merge + review --> in_progress: changes-requested + in_progress --> blocked: block + blocked --> in_progress: unblock + done --> [*] + + note right of backlog: vị trí thư mục = trạng thái + note right of review: no-self-review — reviewer khác implementer +``` + +## 3. Vòng đời một ticket (sequence) + +Conductor lập kế hoạch → Implementer claim & làm → gate → Reviewer duyệt. Mọi bước để lại ledger; tri thức vào KB. + +![Vòng đời ticket](assets/sequence.png) + +```mermaid +sequenceDiagram + autonumber + actor H as Human + participant CC as Claude (Conductor) + participant B as board/ + participant CX as Codex (Implementer) + participant K as kb/ + participant RV as Claude (Reviewer) + participant L as ledger/ + + H->>CC: hatch plan + CC->>B: ticket new T-001 (role=implementer) + CC->>L: note · why=kế hoạch sprint + + Note over CX: hatch run --claim T-001 + CX->>B: claim → in-progress (git push = lock) + CX->>L: claim + CX->>K: đọc ADR/learnings liên quan (L2) + CX->>CX: code + test trong scope + CX->>B: move → review ✓gates + CX->>L: handoff (đã làm/còn/cần) + + Note over RV: no-self-review: RV ≠ CX + RV->>B: move → done ⊙human-merge + RV->>L: review · approved + RV->>K: ghi learning mới +``` diff --git a/hatch/docs/assets/arch.png b/hatch/docs/assets/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..cefcfde9c61e9a6154c3ce8e69f01d47becc8916 GIT binary patch literal 112601 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 literal 0 HcmV?d00001 diff --git a/hatch/docs/assets/sequence.png b/hatch/docs/assets/sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..7f1bfda7c4a196c8487eb31ea45c24d7b49ee2f7 GIT binary patch literal 74404 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%0At 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 literal 0 HcmV?d00001 diff --git a/hatch/docs/assets/workflow.png b/hatch/docs/assets/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..b26b1a9f0b086d02bb3d13f0dacf8ea77ec46ba7 GIT binary patch literal 55148 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 literal 0 HcmV?d00001 From 53b351a91cdee8edd8dac56b29884f7e26f67c32 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 14:37:13 +0000 Subject: [PATCH 08/60] Add PDLC workflow templates; plaintext-first diagrams; de-brand colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - three new workflow templates covering product-development lifecycle styles: dual-track (discovery∥delivery), shape-up (pitch→bet→build), stage-gate (phased requirements→design→build→test→release). Now 7 templates total; all validated by a new scaffold test - architecture-diagram.md: lead with plaintext ASCII (arch + workflow + lifecycle), keep Mermaid for GitHub; drop the rendered PNGs - TUI: replace palette with neutral colors - docs (05-workflow, workflow schema, README) list all templates https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 2 +- hatch/docs/05-workflow.md | 15 +- hatch/docs/architecture-diagram.md | 155 ++++++++++-------- hatch/docs/assets/arch.png | Bin 112601 -> 0 bytes hatch/docs/assets/sequence.png | Bin 74404 -> 0 bytes hatch/docs/assets/workflow.png | Bin 55148 -> 0 bytes hatch/internal/scaffold/scaffold.go | 5 +- hatch/internal/scaffold/scaffold_test.go | 37 +++++ .../templates/workflows/dual-track.yaml | 68 ++++++++ .../templates/workflows/shape-up.yaml | 62 +++++++ .../templates/workflows/stage-gate.yaml | 76 +++++++++ hatch/internal/tui/board.go | 4 +- hatch/spec/workflow.schema.md | 3 + 13 files changed, 346 insertions(+), 81 deletions(-) delete mode 100644 hatch/docs/assets/arch.png delete mode 100644 hatch/docs/assets/sequence.png delete mode 100644 hatch/docs/assets/workflow.png create mode 100644 hatch/internal/scaffold/scaffold_test.go create mode 100644 hatch/internal/scaffold/templates/workflows/dual-track.yaml create mode 100644 hatch/internal/scaffold/templates/workflows/shape-up.yaml create mode 100644 hatch/internal/scaffold/templates/workflows/stage-gate.yaml diff --git a/hatch/README.md b/hatch/README.md index d3e1717..1b7cdda 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -85,7 +85,7 @@ Hatch là CLI viết bằng Go (single binary). Phase 1+2 đã chạy được: ```bash make build # → bin/hatch -bin/hatch init -w scrum # dựng .hatch/ (scrum|kanban|spec-first|lite) +bin/hatch init -w scrum # 7 template: scrum kanban spec-first lite dual-track shape-up stage-gate bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering bin/hatch compile --check # CI: fail nếu output stale so với SSOT bin/hatch validate # kiểm tra registry + workflow + board diff --git a/hatch/docs/05-workflow.md b/hatch/docs/05-workflow.md index 9963543..891b6e2 100644 --- a/hatch/docs/05-workflow.md +++ b/hatch/docs/05-workflow.md @@ -61,12 +61,17 @@ Hatch không ép thời lượng. "Sprint" = một tập ticket Conductor cam k ## 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: +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: -- **`scrum`** (mặc định): sprint cố định + retro mỗi sprint. -- **`kanban`:** không sprint, WIP limit theo lane, pull liên tục. -- **`spec-first`:** mọi epic bắt buộc qua PRD→Design→Tasks trước khi vào backlog. -- **`lite`:** chỉ `todo → doing → done` cho project nhỏ/cá nhâ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 | `workflow.yaml` khai báo (xem [spec/workflow](../spec/workflow.schema.md)): - **lanes** — các trạng thái (= thư mục trong `board/`). diff --git a/hatch/docs/architecture-diagram.md b/hatch/docs/architecture-diagram.md index 05c4193..5a03a7f 100644 --- a/hatch/docs/architecture-diagram.md +++ b/hatch/docs/architecture-diagram.md @@ -1,19 +1,81 @@ # Sơ đồ kiến trúc & workflow -Ba góc nhìn của Hatch. Khối Mermaid render trực tiếp trên GitHub; ảnh PNG tương ứng nằm trong `assets/`. +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 +## 1. Kiến trúc hệ thống (plaintext) -SSOT compile xuống từng surface agent; agents điều phối qua board/ledger; KB là bộ nhớ chung đọc-ghi; orchestrator spawn agent headless. +``` + .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 │ workflow.yaml + ▼ ▼ + ┌──────────────────────────────┐ ┌────────────────────────────┐ + │ SURFACES (per-agent, sinh ra) │ │ WORKFLOW ENGINE + GATES │ + │ CLAUDE.md AGENTS.md │ │ transition · WIP · deps │ + │ GEMINI.md .kiro/steering/ │ │ no-self-review · gates │ + │ (+ compiled/.manifest.json) │ └──────────────┬──────────────┘ + └───────────────┬───────────────┘ │ authorise + │ nạp L0+L1+con trỏ L2 │ + ▼ │ + ┌──────────────────────────────┐ claim/move │ + │ AGENTS (mỗi con 1 process, │◀──────────────────┘ + │ KHÔNG chung RAM) │ + │ Claude · Codex · Gemini · Kiro│ + └───┬───────────────┬───────────┘ + spawn ▲ │ claim/move │ tra cứu + đóng góp +headless │ ▼ ▼ + ┌───────┴──────┐ ┌───────────────────────────────────────────────┐ + │ ORCHESTRATOR │ │ HỆ THỐNG FILE = DATABASE │ + │ run·plan· │ │ board/ (vị trí thư mục = trạng thái ticket) │ + │ watch │ │ ledger/ (append-only audit: who/what/why) │ + └──────────────┘ │ kb/ (Knowledge Base — đọc & GHI chung) │ + └───────────────────────────────────────────────┘ + kb/ ──promote khi chín (retro)──► context/ (SSOT) + + Ba kho tri thức: SSOT = config VÀO · KB = tri thức VÀO+RA · ledger = sự kiện RA +``` + +## 2. Workflow — vòng đời ticket (plaintext, template `scrum`) + +``` + ┌─────────┐ claim ┌─────────────┐ handoff ┌────────┐ done ┌──────┐ + │ backlog │ ───────► │ in-progress │ ─────────► │ review │ ──────► │ done │ + └─────────┘ impl/test└─────────────┘ gates: └────────┘ reviewer└──────┘ + ▲ │ │ tests·lint· │ gates: dod· + unblock │ block │ │ handoff-note │ no-self-review· + │ ▼ │ │ human-merge + ┌─────────┐ │ └───────────────────────┘ + │ blocked │◀─────┘ changes-requested (review → in-progress) + └─────────┘ + (lane = thư mục trong board/ · mỗi mũi tên = transition trong workflow.yaml) +``` + +## 3. Vòng đời một ticket qua các agent (plaintext) + +``` + Human ── hatch plan ─► Conductor(Claude) ── ticket new T-001 ─► board/ + ledger(note) + │ + Implementer(Codex) ── hatch run --claim T-001 ─► claim (git push = lock) ─► ledger(claim) + │ đọc kb/ (ADR/learnings, L2) ──► code + test trong scope + └─ move → review [gates: tests·lint·handoff] ─► ledger(handoff: đã làm/còn/cần) + │ + Reviewer(Claude ≠ Codex) ── move → done [no-self-review · human-merge] ─► ledger(approved) + └─ ghi learning mới ─► kb/ +``` -![Kiến trúc hệ thống](assets/arch.png) +--- + +## Bản Mermaid (render trên GitHub) ```mermaid flowchart TB - classDef ssot fill:#2a2b86,color:#fff,stroke:#2a2b86; - classDef surf fill:#fcaf16,color:#1a1a1a,stroke:#c98a00; - classDef store fill:#eef,stroke:#2a2b86,color:#2a2b86; - classDef eng fill:#fff,stroke:#2a2b86,color:#2a2b86,stroke-width:2px; + 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; subgraph SSOT["SSOT — nguồn canonical (.hatch/)"] direction LR @@ -23,22 +85,19 @@ flowchart TB REG["registry.yaml"]:::ssot WF["workflow.yaml"]:::ssot end - COMPILER{{"hatch compile"}}:::eng CH --> COMPILER RO --> COMPILER CX --> COMPILER REG --> COMPILER - COMPILER -. "manifest hash → stale detect" .-> MAN[("compiled/.manifest.json")]:::store - + COMPILER -. "manifest → stale detect" .-> MAN[("compiled/.manifest.json")]:::store COMPILER --> S1["CLAUDE.md"]:::surf - COMPILER --> S2["AGENTS.md<br/>(Codex/Antigravity)"]:::surf + COMPILER --> S2["AGENTS.md"]:::surf COMPILER --> S3["GEMINI.md"]:::surf COMPILER --> S4[".kiro/steering/"]:::surf - - subgraph AGENTS["Agents — mỗi con 1 process, KHÔNG chung RAM"] + subgraph AGENTS["Agents — mỗi con 1 process"] direction LR - A1["Claude Code"] + A1["Claude"] A2["Codex"] A3["Gemini"] A4["Kiro"] @@ -47,80 +106,32 @@ flowchart TB S2 --> A2 S3 --> A3 S4 --> A4 - ORCH{{"orchestrator<br/>run · plan · watch"}}:::eng - ORCH == "spawn headless<br/>(claude -p · codex exec · …)" ==> AGENTS - - ENGINE{{"workflow engine + gates<br/>(transition · no-self-review · WIP · deps)"}}:::eng + ORCH == "spawn headless" ==> AGENTS + ENGINE{{"workflow engine + gates"}}:::eng WF --> ENGINE - subgraph STORE["Hệ thống file = database"] direction LR - BOARD[("board/ — tickets<br/>vị trí thư mục = trạng thái")]:::store - LEDGER[("ledger/ — append-only audit")]:::store - KB[("kb/ — Knowledge Base<br/>đọc & ghi chung")]:::store + BOARD[("board/")]:::store + LEDGER[("ledger/")]:::store + KB[("kb/ — Knowledge Base")]:::store end - AGENTS == "claim / move" ==> ENGINE ENGINE --> BOARD ENGINE --> LEDGER AGENTS -- "tra cứu + đóng góp" --> KB - KB -. "promote khi chín (retro)" .-> CX + KB -. "promote (retro)" .-> CX ``` -## 2. Workflow — máy trạng thái ticket (template `scrum`) - -Lane = thư mục trong `board/`. Mỗi mũi tên là một transition do `workflow.yaml` định nghĩa; nhãn ghi rõ ai được làm + gate phải qua. - -![Workflow ticket](assets/workflow.png) - ```mermaid stateDiagram-v2 direction LR [*] --> backlog - backlog --> in_progress: claim · implementer/tester (deps done) - in_progress --> review: handoff — gates tests/lint/handoff-note - review --> done: done · reviewer — gates dod/no-self-review/human-merge + backlog --> in_progress: claim · impl/test (deps done) + in_progress --> review: handoff — tests/lint/handoff-note + review --> done: done · reviewer — dod/no-self-review/human-merge review --> in_progress: changes-requested in_progress --> blocked: block blocked --> in_progress: unblock done --> [*] - - note right of backlog: vị trí thư mục = trạng thái - note right of review: no-self-review — reviewer khác implementer -``` - -## 3. Vòng đời một ticket (sequence) - -Conductor lập kế hoạch → Implementer claim & làm → gate → Reviewer duyệt. Mọi bước để lại ledger; tri thức vào KB. - -![Vòng đời ticket](assets/sequence.png) - -```mermaid -sequenceDiagram - autonumber - actor H as Human - participant CC as Claude (Conductor) - participant B as board/ - participant CX as Codex (Implementer) - participant K as kb/ - participant RV as Claude (Reviewer) - participant L as ledger/ - - H->>CC: hatch plan - CC->>B: ticket new T-001 (role=implementer) - CC->>L: note · why=kế hoạch sprint - - Note over CX: hatch run --claim T-001 - CX->>B: claim → in-progress (git push = lock) - CX->>L: claim - CX->>K: đọc ADR/learnings liên quan (L2) - CX->>CX: code + test trong scope - CX->>B: move → review ✓gates - CX->>L: handoff (đã làm/còn/cần) - - Note over RV: no-self-review: RV ≠ CX - RV->>B: move → done ⊙human-merge - RV->>L: review · approved - RV->>K: ghi learning mới ``` diff --git a/hatch/docs/assets/arch.png b/hatch/docs/assets/arch.png deleted file mode 100644 index cefcfde9c61e9a6154c3ce8e69f01d47becc8916..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 112601 zcmd42WmH?;6Fy35p)His;!caZd(lF04FnC);_eiOwiGW=+?^l+LUEVk?yf}w!QJhq z?fd($b?>M9<*s$}>8zZboY}Kyo|$=Oc8IF73>F3n1_}xamYl2<5CsMO4GIeC<ug>| zH$H|1VJIlCP~@Z}G~82n7oUH?HNEFQq?d`Ikn^OmwH$A#j72R$3nc?E<$uOtYGe9c z{yQ|Rtu1?}4)q?iprsFfO`tl0FD$O%fQAQ;R>#x`jU5~FUy>Q4mWw1)S~BF~`4*oe zu9tj%!}(GrFUBOAXK=vHcbNNqSZ5pZ{r^681R0P1cfEl6MDc&u4hpY0{&%C5RigFD z|8CSs&Ft1o$Ed40ranUX%<bTOg84$x?BCrQ(8*^xh*06=JxsNO0|f<f%Eir3_2JKI zQ9jQr&p5?{De&=lzCA(_&v(1Or9l7tm_x(I7jzCdbJ^qhVH%$QzC%Gt3;!XZWu^+p zk3#^C#h1kk5o0z!wvGmMn!9hY57mR&^$hgDYQ?6<9H!&m{@dUsw6vpgFMN*xX=e78 zk54()?+z-4A!X46vC)X2gaH-RpYubix9#0K6TM-S;qlc*mOe`>t~axikuNYCcOgYW zM@I=VZFIvb=M4gqDOa=aZr~MW;h4B(DWKbSnH_m_*7V8EV|?!QyTh$`4EFc~PRsil zgAyIfe@p+YDc(8(noPgHT!c4RGcrY2Y!Bd?PmES9d3o8eOYUp+Yx!yJPBIVl>*Bgp z%HH=VFmI}hzCRj#%HDiZ)U1`^`-pA3vC+seL&q=0*O0yaz|g0D|3auQ_n1v17!{SV z0H@bDL;E;C;$>p)$^~nB=hAGn+rjChq=ttZ|Klx3U-C3ZUixTkqLbiXrEwDB5@{YT zVhX;9^3QTr4;69w?FQ~02V*Qgwq|Aq+RRo}8uXvMVe;Oq7^VKdGKb!!%?9nSmAj5> zWh|wcdRmOumg7HMYhRt;iUueCTVbZsn-AOBp?)`)epSZH+CoJMRT$ESw>$NbVH3J} zgSST&jWdMP_m-bobydl)%oam1N<@?I?zdLmhE6?KN@xkkL!s0Mjb%1h)j#9LV+_IZ zHu^lSTMNUv>}>kNt^SLsO-%eMn{PGE(*(4bw_LkN9#y+Y8~S=a*e9(PyncN9qH{8v z1Jb7I3rh?*D=+z8UC65W(t{x>tw%kGR*WZBZ+NcJd6KH|?SYgh#R)mbdW1o!8Az<k zgWgxfF{1bB0%vYQ%^qFgs&h-4fEw$ztIRRVP8!%F4ndruAmW*=&5T8socfBcsg?xj z*tv!4A>=U;{B|@!#%9_tlevkkuA!w5(+e?bFzC12_M)nj9vDH;a6vv6<OTG!9X*g4 zO67LPBH}7Y_&UXYfzWD*3<U?ClkmM6@K5g?eY|BQH}g5mr_Dx1eBe7eX#YM(=ejkI z9x@~ZRgVZi-y=<A@pQb13cCJ|Y4T6uC^gDIUABPTJ0Q>&>X+Ve?r&ez#|d_q(t#92 z+;4#UY$opukB3&8{1+|HLk>RSyPxgj67NbR1$sFgthfcr22{29-EUEBNj~uBmtJ)C zjIs*MG2av&R8T*~#{N_>RKUFWuxNR7xSsWef7w2aD`D4`nXs|i`NBR9RqZSbD+SvF zi(<0>=Vq0b!GghDLI0I+|Il!YqO%GGf#zi=_W@o<MCb`2_g+Zk=0k&L>!s{jJpaRO zDYxBS_APgdpSik$1Mt1WVv-wSQ<dFKNQJJ1@zOnZb8#?v%5POl4KE}3PT15Y!Xr+G z-{u}B<TOEU^T0gJKBr;w%38(e><o5Xu>KB$rln1rusfVA7O%AuZ%*KIKDmEt^+n;= zK0ANvHs_~U``&mz)_H2ms9o3dYit^G+7_P+W>V3&zgjG@&Sr0>cX1Xjx*OUS$PH*b z3v2d7+)ry_8|&*EMHXVB&3w5Jj<8=ACT0Cz&r`BIUU__5q~ccvpV{)PY5n>0+342v zX30X~2C46I#qrws@%sI}hfMXZpbtVdzIyaNR~V||_W=4dF!6k42{?T%`R?2ci)i{J ze)T=##qqK4OU#sxe|u@itij=`_9cX!h1KxBYs@RY;oD0hmt}w4eR4wB2SrYo>oNK% zR==adi=>0kS?&8iCT%mmOGcVSOnma~u}<AWB!yhMABBHt<x_4i28xV!4D_G|HZ?hU zvRm$1ZjC|cLtXBen%|LEyYIE#paSM_cg&i!UEq3FC(So7i`H=kL$b~8Qy(ez=KJME zz~inlZ1A<n-54oP#pU7ZRl{&0*5hR9l(Egb>sjAOdQH(A)x_Yr<+M+uVft-dL%LTr z@2(l8=EgII44WTLi*>%RYWDsBFCLc}9LTtBy|acZxY$FfyXtnz<tyDBB26qN9|JC| zk5VU+beKF=<Io+Pw$8c(B2=KI!k$yn5|0C!AjP+SVTKR;7Jy?(H=2u+e$13Z+4AXw z1Dk%*`opzOpWNc@(+A)Ldi_)CnB}sLBe}&*#}$hx7lhP&!!9ZL-qGYJ)!p8K*|UEN z)_;VW2b0ewI&6JssH6cmO`Gw%Y;aYWE+IYb>dTLQEOISUiKqxl?|FAOAGH`H=C@Jk z>hsWe9R9AuD36~AYYL@j=)(y2=-KMi$&kg219mR3HJ~v&CgxygbJ1lHfI~!ABhAD{ zRfwrhTD2`)b-7BkKK5DkV|39I#Xzg7C1#<4{y74#0JXjt9T>T_tzjyoQ&t?Wzzcm7 zRr9!DpZ4`BFM=Q}Plkc>+}x^$>82ZBvj9!~bo1KwYVQ-!*iPx8cD=76jg~<pe_6xK zhfH(U`m(21_py~8lZyMU0fcp(F*yBA+DrTJj7|KaB86k7^*h9`6lnTsm4^qqM|py+ zY2(cz{}hP_x#dxAB|QIR=5tx|LXO;HkyDS!x&9@`e~S8?(qTSAyJZc6>RJ!rO_?tg z(`)+Pj!~~J7vnR}L5)~VaVb9D1&Y*P9#EE~;U`|L-3urDY;LX}PwLbSddJW!lyx2& zac&ia(grWLrmDAEUnOkHni`=h9y3gZQ{j+KqVIculJ<`eflP<BNKq)NSVpz$!}pM8 z@B3+4-CE3dC#Qy9MgdkX&ws7We<;qMf8J5=Hf<^`c6VMLDW-Q_W3t>|&ls8{+X2J- zpnsmrfcEa=vmL@u%pzp}WhAf{_=@^5+lX%G<w75$y*9^%A97RCWNscPH$ID`3MbLa zzFt)P&`JsYTvG$5LYfa2PRqk$2Gzi;^Hqr{&hHxLqJHKno;?|qTW)Ebo_LSDhA3O@ z0;DJiG9NfAYCl=9Cq$38m|BHPCi0fQCWcl`Rb+zhn#GDbUUkv*J$e*)pd7n%6A)Gf zkm17m82LnAXNh-ThvXIhV6(7i)~A9t$faMA!Gd3Rg?i|Ok*N!jNFfw=nY?#g-}Jln z7Cyxe331fA8KtZ|<~m5P;%%AhtD|v`uFn>bY`9Zky^(b|FcCa(x(WQ00ppX>Gv_x2 z7>wREYS42{VlzI*GS?KX=sfJm4$@tancP~geAJtzw0Kn6S!Gm6I7IdI-!|K#K2o2~ zl})eGxI231ObCnd3{H|L!UsgARk?rW`nlYeKV9PG*!NQU<g7|JPtn(NYDT<|f8W=* zq`Tft)aP^`WK<_x<Vt)vlCkVXCGl&{@OLJ?8RWrwc5IRA;-~M&kH@;n^x?|Q?7z=U z{XF^JjPSFF6Tg8P3%8V=<ZqVT*!%)N#tlJ@HWx|T9jx@2t~c)_S7BlEd{}$nzew9Q zYFegNI6&YMe6nxVV;8-i7O!*ap~me>zqElLA7t#$uLiC%i)e1JijG7i(D64U$x;w7 zv&s#RFDYfE^Br`<6~gXo&kxpgms)z<dL(^B4VzC#OTF<&d$C1v?(Y4>-05vra_$=C zmpqK^jxv_h#jNA6`{9_r8iZP5fr@@Pr41|KiiUzgQ8jV_n%z}WXz5T6hjlT|-P%6| zW-1-B5QMFrrKXm!v7P<;CHEpT2LS@17uHPZrk@Nh5LQrR{eVzMmne%Ho=$Qfq}N{P z?6@zHU;W(tBwQhUkX4f*zfV`{oKWreVBOohL;Uw|TWte46Mugi-}F5jQ*cf=xLgS$ z?~mW(c|pXTd6P?F1fQV~v}#%UB-JSt(QI+yhpj-FEc|2(=bucKuQfO%%vJTgS8H-M zt!SI}Whs774_@06henc#zHMq>k@DJ8Fifq#sqFsYINFm?kFN1Ai5EBj%$b<UefW%w zV>e5Wlx4v(1T|=hyhkWPfMcaO!dv*?|0B>x05AVtqM+D4N2UAUbuI1QJYc_@64T(W zWsNHd(?0^f&2($EBT}5xc(Y$<|KRp(5X-~#X<;#s_t6z%M3s2wRl_GZyWdH!Mkqy< zZF{?9rRj<GzubN#pz?+yN7K5vc!C?DjS;}SBYt)k4uMn&Q$$Jx{kU;K+$cbcNGGh& zZK(vb|0`AM31U<KC(Zx89(nQz_kTAQaQ@#EF#q2?;5hVIj-m`v5~A)EqO<u%iR^Yt z@9|phpc!TxeYltPM~%!5ZZ=L1@Z9kyRFBfu4gt!-o{t}2`xm|Xw#<DUNBnkdYWe*- z_dO-<CQWDwyQ}+$T5zjfs4T_VLJPezhP|(|<wEPUA!#ZDVNiEK2b#gk5a7#~kPy+a zegZQG>L*Qk%HH-LrSjcprP8ShBT~2rV*a6}IHoc&M@Yx&p~3x1Ica^=8wZnP=a}0$ z>eknCaLyH{Ew~%&UfkiWqFf)0EY5x+dwM^K%vXnu4&%G~{y4p;pUBn`K)w0eW-Qks z-V0I8<an8Vgsci+?+b#l<-TA1sJ=$SVT@Z_)30K}HHLAj$C!0Bl&4TThd)b@kqt-~ zj13fSW>9uP>@xa$A2Z_;hQ}H<8%zo8t_>qSnDWlp=6MVswKaB_J`X#_>+x_47G=4` z+9E}-07?&<A!2^+GHP|RGA>agRd@5=s()(|!hh@h64e8I`YkG*h1zd;$q3)rb4M}K zfZ8|)m2~XfEDw)g*IZTvHwe(dh*rmFjt!Y4v5<EE@V!8j7IT-`{P<{8uJ|o&&D1q@ zp~`9x0=cx9=(-CBNc?KP07L^s9q~MN!;E66YB8lJN2@Vkg8I9{3p~QjT_KGe+3yrd zh%6!RPg?TfdqdRH5iis%`nihPbGDB0&x2D)@&#NShU=sWmX5cAjl1Y+3nK=7_;{y( za~@NUrzhh&BH*d4^VUl#WaP~kGlHx;5mKuXPg%wO#|0Q1IY~w_3J+Bv9Hs=DWWYcd zqtyJj)aJuWw?_VJ*+}CFR+E!2tY$G)F-xAi_LXawjK+EVIO%#MJ=M8dF!b;hWc$5l zWXFX;h-wdftX7UPd>DMReZ;d5Z(6$P&-JD5mg!J9%XdO6_JE3en6}BP4Jia!%0<Sj z07w#kT9kj7?;7at<{#vm$hDEnxYBJaI80?-VArl0-&LpE<z?YxJSxA?!Xx7HHOUm< zcUD1HXVS5ghTL^g6{-mP)Z<@&k4=qr#u}>)?p>osg<R!_vK8QA?AzVFm?H^4*GS&m zs}M<j4~o|;u4$TfidXNrGYVe!J$lWusZpo-Ix!XxhaD>Da%3O8N2j}!*8TIi$&1|r zwWP&tHG3e<D{S(`KX-@qF{08*T#8&YUU&!dNtRFwt{){8`swM?5qN#S?q0t%a*Yx= zJSu$6erMVJ*71PWdS8wUFO3ODIepe|A|)lkXZe+=<>`)G9+j<a*XZ_dB9#EmB%<_; z=pDh661njX!4&MW=fg^VyN40<ni`S1JdV%;X$a6>FReY%mR;lZ@r3n7N5x$Bf|cXI zk9;0pPqRJ{rg2_Zr`XL_3Uuk9gq%%5Zc!8OZsPJ;faqO0DE_H(uz9|wwwrvZssr_L zu$&7Bf`_kj;cv|ioXGB$q|VjJ2Xcj()4ae?6me4?uBVKecqsH!)F`m76ilK_KTf%x zk_^M^)T@q@%U1%wyPx@LzE}G5&0)HiCfo7wY@7jE%;kCN40gssHNE#~{pM{La6*yz zVy5wJ2FpyNs1lK=BCJ<d#IN!aqI-UBoel(Tq?A(B1i4N7`WY&nfGf@BOyaM44er)G z)Evj=zV}aoq6NkbQ=+34E<Qm-z2?48m=kbx_IOdY*q9Byb!{t<8fLAFD<;LM6Y}np z4-#=(j{b<{Rh`hEr<5ufZU8eT9xFiVoxGO;Cp4V^k@Z24{ob<W<F-gCLxkwv7l)&H z$=uD*P(XSNr3w==w!~fkNE8oYprfJF*tzfM2(odz8GrrD@P1#q1-l`X6!2wqxP>nl zk&#mJ2p1PSncLC3>%8uOz&c65cSEGKJJompq&6Yp0ynLH*t9IhybGrL(^dhtSpbOH z2uRVWvx@WXSv#@PP%P-1WZa<n9yu%QRu%FOQd&U*lC4q=vD|TW%zR=MP0_uQbSm*- zfT?t!x|Hg{Wnga`?i6f`5Qay|1Qo2jZ%{cmOB)YeBT)A8a{A`P4#0U|Sm1v4H3N8> zzLw*ORshtrXOB@%7S@TXu-V5BnzT{SU7OqV?Cy^oi(XxgH8<!38fr3*J6N4fnO9FU z<SXB}EANGh`P3-TX4IW$MXi)04iV2Lx6BO|GqUl`e{LOwH`>HOUiUeu4Q?z1775d4 z0hu@~*!N4f3*X`>00uIdZa_P)3D^4SO<PT939Q_FRP2ic*gVwS_kxCfTSS%E=k!j_ zlizaVR=7F#%iiV>zECS!z8#B~_ea<a>5bDxo+_9-S=wDQ^+mow^<alY8vqNvRiE@< z{*-1uCK5Ezsr2j~>iG7Y(|i~*Atbysxnzh<DQOd(z1xnqY%e^`FpTeapZs9DBj*M) zshhWk=~|gt@PLaeoKtmT;&U=m5$ub=SrO}r-3%_<%hoFD*$wlW@tgi?{HEqlJ(r0* zw(jq<`CRv7ZpJHDJ2vxL_;<$!rJVd|)%|K$z4V;$h}cVdHPS?Q)ftzZEc%}xDc%HW zk*&Htbo9!ldsR;`;25`82~y_GF4;*Art8Di&AYma*_A)u%Z0%5`8L1|lRn2**I5T) zlT;an2TCca>n(%lUqIy7mQ(#Yfwk+A%VDBtWBH{Ne6(z-=b=^DHpGwrV$J6whp(UI zXk4+h5<^2v^p_Ha{Q9rG)jk}EA*`L9N}Q!UV>H=gLy{!)ig0Aa&NYn`Go_%1xwXGE zg-pf>ia-f_2+TN6CsX&|(UI><DmslImC}ra98Gd@YDK)@`x#G>i(1cYXs;UY82R>D zxFSn56Zi~Z3YcmbAv7(hk{g_wRks=`Jk{EJ4smRxYCd&f!pCJ6CMx>47}H!`&@)w| zw(@qe$l12ER6;MN2)~<QvCdGFuuxc<F(ic>UPL@qWE4O@E<drOlCMZ|ceUTKu&|m& zify+r&#;Y;Ll3kcC@@8@zQ{1#g8*8F?T;L^y!--;3bk`f8Ow)0&MmA`?U_o_hnP@c zSV{(NODpIqCNH2LF+}da8_*ojSA?EwG2K#n?L@|u9Q+i|!pnQ&dHO7YV^U{6AC%!N zU!v6L(lF<2IqyX2dFOYs0gs2+zH7EpD3kCEXfO@TPHV7|j`;p;IS!W_2%B@|STl;A z*7jy#wcR*83ZW33Lr6J}1~KvqZT#w@XG!AIWxP;jpiKs6B{md+_3R3!El($jJEwYj zNgjm-5P3cSyJf#gQnSAHir?J6FO!`qr|S6f#`_kbmB&}C{qqW9G-W+h8K)EyX<%P} zYvy8)1&xiZ!vs3-IY#%G=bbItbFkOe4l`z=H<S&0{+cUonaRAk(E5a{Fi2Yu#>Mf* zbKiAj-0SV@FtqCZ(DzY^TkH^5x^U^Hos1MikzMVu4ug6zXHY7aDC}ZjGp{4K+V_om zUnn=XT_I4`o%06wIqZ<SmXT1*G!~Q46y|>1px=3310o}|8+LrMYL>yMf7R6jn<s!N ze#ayeb6)K`Bv&bJ9$PH$T4>LA&OcX>xB0FGb!*yDjVu^ijyLl3HR*U``$j=9Hv3H0 z6TZ~lDML807}YOK-*kpB{x4kGrQ@aohBBw~3(+`3oEVMAI@TkrmFwAS;Z~UVoasg& zM;KI%20Kl7%#f*G_sNKMEHDqNCIW4!_E+MjRKUC4zR-2wkapC^rMN`AP-j;b4i2yt zc?3P(sLH5KT2&Q#l+;@SFr0B~>>LUSDRXps6mv}<egTA-=*paYGz$3MmajXtXBige zoFvTH<+;RfSzDzrQ3w_xd`mRBU1{X#9esB3%X8yWuvdM}#8JIlguhokCBDMCKP%jl zh3Z#}N_PIX#w@<kyC|83pTP!BzpAvvOM%v%$5OPN<fx|JQWfew#5_WAOQ5XU(L^~0 z_4=`!Fc!6xl#KS<7$X|09eOsSjkYf#PGL+rUDrv#X9!y*#rj=txX6d!%$Pa&fGe+> z{Ng1bUHG9o7>;Yj(6{WW%w@as`*2}E5)eLi_~T=UXtfo+PZvy4Y4wK(wNBp>73+yA z6WZJh*QhUY#@Tco3J&Me{}4lO1rJ}5cYaA2ubIJePc!8GFD^b`zuZ=1sgr<H;&~wV zlEVF^UkG$H+jf3DY?GEvC-*9{iLN)Zzjp*}DCM-w<$>Ft!qeIKxN$m9jP6XehlIQO zZhhvX5?Rb)A9FgT=+z|5I^5r6Xuir1w1CK;8uFDdWd=_2VRBUp5^~KPUOG(gbyio( zt~^|t`&pA5=+vAfVGZxk)d+2*Z;E++MSTJ4D>K~*V>HBJyjz1;F%n}H&vUQmmAY#+ zTWm;X3}!K75f}N*MdwAX4E2~XELR)({cg;y%BYver!WEyK_#mIb=Qg0d4Bw*nv!qH znGKFG?0<g^G~}NGzcmDY8}O!0x;u$U;DS}5)>n=*^Ifxh+Fff6<T61)mh7c=<4_ei z23@y@{KeFqRc<4?h#39ZRhSsd-0js#ourtzXl#++^`!+>zg!aUqR=aC#qU4ewRGIb zKX2hkI&Orz-DPA5ZmoI5j)zp`N3d}dg<@0QacrdntT%)qB||-`<K}($=UbuH+N&ZN zd&-)YiSI{iFD5gbCU-TW+<UFZA=VdZjk_>w-^EW74+c_m4T7EM5(~NO{5aehm*Zv5 z=y`)Z3wpvP=?u2!f$78(L42DkKBjYSF;*IzWBm5a*YoS6!x^&~oqsnR<+s`YgD)s% zy}dO4RpBRQ#gnX4v&Okw7j#djl=QGYyE-*h^vrY69kG|2iiR1nx#ScQ_I}0l!-L6` zT0Fa0o#VJ5MPzq9=6uqw+4eH{P+h)A#gJV|(f2@b*ml^rMx-Wck)$qirz@OVQqr_o za(6E7Vc|rE#NM@}RDq0WAIb_HC3!Ms0Hb!^nE#qZO7kqk<zPO>dI|hf45AZFkEL_E zglM&$H<&Z<@Cxhs>QB3TP@hm0b$4T^plmSwZ#{O*^a!fWR4aA9Jd<zqLyD&sa4e~n zvdBoneSJS6Lh^!CapR+<oRlz0iVQL;TR3a3Z_K8YMGg64%KW}ycnr?jc8$qkjy=14 zqR{3NznSNT-EiH?@Yf0RneSdp26%}Tta}rtThZba(+71mbjk#9n_0aO6{5dy7f)%| z?%eBC^*$f;!=Uf(foJQUon2U<^*K1sT_5o0BZvjiB;nQkenx8|?GTm{e4nM<FHY5N z4=M9~qhkA3FE%5F)1tX;khloAhoXs*%Qvk2f=U5PpFw;#iwvt|qrQbY2=)t{55!mE zHU7sc?x8$Ve>awwwv;&4XG-dPK{0IlTk*yAEo+Ky!_V`D+3-ywa8UK};dx*CE*){G zGv7V;ro7l_#(eWb#(y9?hgskA-@l>!|AwXbR-6_h<?ZH1xbcmJfIG6=_<MKitES!J z$IqV#7i!SF63{oWd@G!O(pfBROueHGgT2pEZ584-zZ$>@P`=Va`@18*N#^)J)SoCn zVC@{34Q-{^pP%lNzsAKKO8p^4WkhZ<Rh;-7_kCgoBeVL!jN^y%-KR;o7|#wrkeecY zQYaruELCnbyrxD+e|qsDP4B6n@PBo@_zk)uS5kq!p`f#}Oyn^Y)k2!v-Mi?F3{w5X zilbvLig#vRdfxi!Tv7Sljq=US@dXTW!|Cpl|2?kT;1=a<;4M0ARK|g*N8I6K9KA*e zA-Mbh^T|)GOJ^VtPx)`}UwmDPJGw%xTZoa+T3K7`-CX0w{QDyZ(hugl(CC<hg_;8a zf0#nyX@xe6`A{m7B!7Z-Cg8=F^6y>ZL6WU5AdG+u>d5bYXOj=ROFp?-62qCy*C!d2 zqTZM5OHC~t4!$|kSOm|Wh~2x#{`Xin4vT*M#E~ZmR4@MF_|&DD`)k$HkPe^A!$;=B zW`4Iv{Is-86|=irjINFnEp2S3KW{XUkN%qrX#Fg@wKYr1=Up;7;prg<8H;%Lj<m8a zT|`nPW9as3bIr~U*4!*wOxn|bBgkTd^DkYkElG|vPK&9S?1YF42!#BCE2jy$o!cnO z_iY(8wZWZKk^=+LslFq)UZAht-?dPP-8V!=nt#ajYw;ajS&>H{Viap}Uv4(6nwwi{ zqGlA&UHIXue{<0ipW2xIZ&eGL&l2s3_8O4AWCm7Hno!)^1TRLsrMGV%{rdIg(W6jU z6Xm=T9$sv^Xj)R5zQg@(K(kK{gY@n!Hw}wo$Y@TQvPa$ZOqqHR_5%Sv{`&*t7T+e% z(;pRVPyZwHT+op(kOKhV0hpb5auX94!|8*zHaus$#M@hpTchnYGT3PPjZLO6QezD4 z1^h(CxVcqIV9H7&q_1D&5g`ciCj7HQTg45tUWw4ANq_ouU#Pyxz*!6(16mMKP~ef1 z%a3yG;QTk@fMiEM7qV`-xI!S%)m5kD_*e}dzT}hRP;Vcf+dH4HUr}jr_^tZk2F=Y& zNRIN0dham%P=7^W2ongTIfdp)87JTJ;|I$8O;Kjgb6VvzL)WT6oOb8UV<?md05HWP z0nSK;ylsj^Zq|U{+yBNPhLC7;!$qQGYPua7DnC~N>5+p#^W48WG4S&vQ8pnmG9SEO zsOs+J(TiNC3y6h)(*gao8u(sfysd3)3|RH+L*~fHu*&}CChn`>3I~jx!TNd9c?x3| zE`ox9x;ivMHaztuDd(?WZy7ByFt#N|-}=xb#fbkm$&rTam(Aytmv?<C)GV@w7F98U zOidwqP^k2q-|B}_UUd_h`d}56!i0ng0x`M8#YG|)BRuN(1g-dZD8h$@4VGE0s}G1$ zmy-G+O9NzRtbN^JvAy!8rhI&*yzT4Pv9>m@(3gyyyW{!!DJjg;i*fJUmEX0I{6{Gx zUcBOPWvi+>!&8}<Kul~~VWdNgczH?+M?*WQ<__X|Y0}dd0P?n!lqw+|`b=j(dJ$9- zge60PqF_93wP@Md7<mwY&{J<qQcE*0J27NQ6a)9}&L<ZbQghLX!*`mtOM{5f1_D1G z>|61ru!9+nE)vvdq^{Tll#uR%+{UylWAgta3x}%X=IY9dbV6n(^KFj>kJ^~jEUpqS zN%A%um?7`>>)b+soh+xigTv?91=%Dth%!#dQ$n%PST?FCO#hcsmahRgE?bN5+8li) z{S8X0r%Kh%bH^v)aC~+DHmT5z#zqsQSLp_lRENcWrMC<H*!d2}C)xA-MOK)PvZVi) zXo-3aO)KSe#r)bIqCRYFZ9h364Gp9LO4Z6yq8I`EKCAx)MSg#M&3uW5^2!6x@Q*%~ zbGqSq@u-;cnJz-s4aJH|T9%^v7D08D8UW51snz|Jl_6dmG}Wo1>Lj}bGQ81Bel4vs z;Fe56yjXT(A#{5+V5z&U4M*>7N_BJdFPmzfxSw)+>UJ-c(%imVnXha!VPIk9hOH@L zd}1WX%`ME%J|0N2iQ5)H`vrmEI|=$|G<kbBqM~|lQFnogM&JFH2Guh>QPh8`mFE(U zcIx>Ut`tFtk<QSQnYNxMIOW}^34jj;C{-FO{vzNHDuS(&;DnH3Sj|1ws>P%54WOKy z<eib9DL%8FoD}k0J3bDz(XzIjo0;ixP0d{h?Ot;>wRVXc!-A!T16k2+!wo*xe+x3y zbhh9iVsu?)JN?qvSDs!OQKXrJtsy1#D#M=qWB44nMV6hN@mS&?*njGIR{y$8TZ*u5 zVP`IE)Xu@yR)kP+a++WD89)mJ-JYAP&!e22o}9#d;%#V1sk^k82Bg<Yo+FJFuWM)k z;sF{4Co(bp^}3p(A3bW;x@8O?0e8!JMk1~E6o!u<mnM=T$!!a<74q{#Lkq?U2`V|D z(Q1=^8{5G8?fCchv;$5%-&>rzk@<<^G_EAYxHu9RI}Jb?>8lySNy=KJq=TpEjv`cb z5_gtRRrgWS^({un?33BQs4MOk`1yJAnpMK>9emnAv6NqRMoLcI*7mWf3Lan<#;1x0 zP)}}0wsVWDEJnsbZf<3)Uz{L4y#!))b#*$Ms`YPio?Frp0vy!|32$|mI52o&i~i9# z3|@Y6^PRdsHGx27tgIh~+S<}$yeWg{%CX>QrtBYce)<=IbFLT+8U#=4udc2}An-`U zTKieRde<2{8;pU0fsuh>El_{WQwq2(wZ+kNkN0}6L2isb_+O}SK@|uib9vI2Zb6tF zO{ez;U@J1sm%<7H@lI^`_CeH6-&ta(fb~0(eu?L;`o^?BtD`yzOycVJvYxOCpY7YK z6N6rrZrZ2?u&5}7X>PlYi4Q}b!7{s%@l9*w5(!K<d`F@eYopT@A_dpg%bEx=qD!{o zsXFyo5dw^nd8SyHNlN}|{enGrE$i8*n+g8$PpnF@CyFCmiHdYy>r#X{qXm;wYIFc+ z8^WgcSZjp7KH%-!FR?zV{+Rg%5)bpM^ksX{L^iM}EkRhp7p~NlsgVzvxgkY{IW>XS z&Tlo;)kq1c`55Bv@pK)daGNmk#Ab!A^|z%gQ|wDjaWT+d*!2yiDXgt67gWPlbW1di z)g@o!HSxRRJ=><i3DIDJ{46w;v|3yJdZO@mR<5<AO_a|?2=C6pn7-zo4om`&!(E}G zPaYrnU~9o6)Lk@jcpAF_$3Jox#>h%8r?>XUOn;`d$8CF}Ut0v0@2nS@S8%-h)i_{Q zQBq;jB;vZ-H>ClE-rnMbLJ&+%6V;!Z!oo(T9HW8x3U>^3AM=GpXk$OmG=Q>kO}Y2? z-$MrR{g7@~VEg_fF5&yQxHv~ayuqv@Z_y0Q6bAQI5*`SP>Z&+R+a>s)SFz5h_`^Oc ze6>T+(Zz%u)3Lc`TOxt2oW5!4gY{0;oUR_7>F*<LP+S**z9S}4$}wG;<hBc^SG2sy z6I9XZyxP!|<ygHC3;uLh`cv<^qek5dj;2O1iD^GZ9YddIRk5PJfq{;iTJPXWw3L}w zcim;L=ufS24udJeWzpY+{1E;CS1r^pt^FG${Uhx4=T1y<eRGth)$w7F;JwAlmmQsO zCE$F|A(#&S&nm^;0zN;lXUtM%h#P|k$`q{NF!ZG-lbl!ovfzPg2(bla3o1EIcGBvD zzbXA1?^NuY*E8eO+f|E|U3c;jpj}nIm2q~*b8)p?;D!+9@?w&P)7=ypEFg|gkH;$W zr1f;6<!-P_HwYdE+WAVo2^TgYVM1Qf0*Ari{9=jn;AGeI^aWB>`wCcyh<m-m3M#*h z^}AV`Y4YLu`4oeX-8_BmU1g(-ZJK}d`*|Pb5pHXE-ByHCEdMMO{o@1?wQ~sxIemQ* z{*1QQY(L5a)Cm*Hv@{%NaRD4iSi(!=K~o{bWeDo;>`cCkb!Eky^zhIbhEDat>$bV= zEUwHRyMr{XYWeuP8f}w2{eKaWkQ9tg%yma^q92xbtAIboiX@f8x%C^LCe28*wG8#r z*8L12y$K1+mi6$cYZ~N5z{sfSyBXw};_B<_9Jwty|8;hiQ6J%oKV2!O&?&NVNv;S| z|CV=NbpAv>XLJ@buQp?joh#{^ul&vSAS&ZNRHWW%Ah$NDy6_BIF|FdL($`;9)t@qG zHvb%JtFuucRG7C`qS-}ghX@GPG{wv%K?E=%vyO{S3Nc=50cnl>eY@&}*c8Tn&j8_) zT?Q>fLwoF$T2F>#WI{Pg_C;J32z>t<e~>dt5eK>R6x9_tPS0Z-uoC1Pv82@gnk_N~ zqC=r~U|zv%m?=3hS~B4vB0phdi?`~_XX^w43uQijOmdxYY5EdPS3e`Kjr?wK?mWVm zjcox$*iFfq|GqBL7LUGWYPlm`j(FF&*BJs-TI+Lm1xm5wVK6f>)pdl%Z(i@uN^d#Y zk~l4i9yV4fLucO@R>#oPyMEE`8<fsc1$%K~BwE?#{F*j^m6wklF|mp(%u&;?Zf?pH z7Bu1Xr6a}tr(0xoiYq?t7Pi?^(G9G|VPeb3){$oWksYY1R|Eu&g8Fo<tatVpLE~** zW*#w;DJksH@{5eG9l&|BsA=Arc1RZ_SHBIkGd<wyg{Xn0I>UuZ$0w&IEK-D&NyrE@ zWBbyjN9T2Qb)8n){CvE<SqL!slcP&?2m1#38in~?yGSTK^V0dI^`lYg(v-E#C8Kcf ze5WQRR2%B+lQ-0>y9YN3en-<`Bb9xw*vA=E{t&GB`Cr`H`GA~$fcYzU+Zl<wG$5rj zmg*cVE-ET5EQFhlp`LRw!z9$r!h5Co*s8lafA@~7zyBpa4k8T72(?uw+@}MpPR?5S z)e^?1w_z>zEwM+3Wk*nm%ok~~%WKz0m|0cTFMXP759}XBg(;bE5o`T6j?4g;Mi_`r zZK>h7EXKec5jCCZJhS~d7P=r1NLzcx#_Yp~Oz9j)w^k59P<fAA9}NDRV~O)4LoCYN zVu@R9j`uf(9I^^}|FaICyz6UpwjyXz1=}w)?Q@gRu*zb8E#mJ;#Nl*3K8{WvG_#uB zsCB^dI?_<@ZdIt#SSb1S{!v3ovQc4}acV-m=L-tq%levZ^FA~c)_gp%Ef=9U`AL?T zRRRken->@u*=W0Pwduj!S~MYZE<mp?^V3kHYUfSIc4Vw(i_hk3(nJPc@Jmy}&$IRS zykQx;>V+D8O)g+nK<U4d521@JWj^D+!U(|WExB*X=IP`7vO1Tbr9f<LRjCBhK<}0f zvsT@He?gt#rFL_iTxj-X0@7C)|0v!zktyLNg;3h?rgK`$3|2W7`{W1GgIgmyc%ryI zXHvEdBNJ1$Nncq@x-l1AAO<rR(dn2fsrF{)Ni8hEpoEk%y-k_Ta`h{{CwZ-+e2M0G zY`Pi}BYYm|CNle!)2l9kx`B+{=t4VxOhJ4z$W{{t!3G9@b2+NLg86FFsZ(oVcLu1R z$M$i;vKjL7q-CDJ&@zT=E6_&O#>GP!le2h{kq_y~O5~3jyNeQr37t3zcxySrs<`J5 zf&AQj)gnn&Q-;I7Re5=V4f1NSnnhK^`RF1D{1`?r)J{{j2<KY#I(_oDTTPax{32#v zj=N{73lr%gNJE&VhSt-?syM3Qi6|Kw8rIht-*z;;IWOYbqj^sL2O?aUg~K{m-9_ll zi^3Fq92}6!{`|?fKb;LrpcAfoj;_UF%AQg_X+X><WXbfizET?mvitBG#d^Yo^*he| zDwCF;DD5iDscDjI{#=^7csx^UgQORam*L~61<wN6O{}%U@K8FxBUT;QjxqoWSJ~5g zXPfm(>s=Q>?VKmnSf#Lte#hs3T)?gv?Wq*fAYmF%osY3izM=6F0eF9;ToC#*^`dMu zoUV1y=uAt?g+zxY*2*z{>v=z`qNe)GoOmoYvw9;u0Oym)?uHrs!(;Eh=0;;^ryZpa zZudmUyTR180W(YohPJgX-kL~yTD!60J@?6b^(jjMbl~~#LLL8<v3Fi8ZD=sE0ZuO~ zO$yVDUj01{N1DG%!V$1|j`F+X392u1P;oPx7wTlR?T<6``C1JulJG9h`fNe4ys3;9 z5?jT@#QM0Tw2V~p$ka>B#7y~v6})GZR?_pDl%YCr0F}D7{lTGLI(QfX@(?RxF+oiQ z!J$p*^m6u*YF;UR9#1@Lj9jwWcl(aUvb_D!T2W6{%$(ZSRj>m8r{sUAkIxZ7NcTl4 zwxdmUG%3^G)rlJo#LU_SfEFe=i^^5Vcc9M#)J;uIBOE~$+e|PUsLtECrlL0xzKD=X z*C6xqa9Z}dt=4_y9=Sn9F|2pRvf~P2uxdjHVYt^Fxq7}C0Ejm}Lc$kTR13F13U3${ z4~-V+rtpZE#cj$zV;xZ`jTaFlkQ3x{$QJpdntcC1;>_Vhc3Zc9{%r*S4H_|qawN0< zURhByFxampDk0Kho+#Ec?5_>&PfQ+MCuoocyD|^gmz9<2yfHo4?$IwPDS_+;=CO`5 z+sSo*WwhH~Taz5V4sE(3i(#}I!U7SES~FBw-YIRDwi(b&+K85TD=CE=KNaZ??pvzn zugwg7v_NNSI@cn0!MfAs-MFY#?2bfb@ryq=hayzUNA`Kagll#OO16`m?0U4;Ws~jF z-mKWq#Z2|;8Ej@vg(*&#g@7&8idc<Tp>Vf<q-k+s_F4cusa^3qL^JLysh1R7fW@<d zCTt{`Ar9&P%yzst?x2#b)$UI5;>iIs?gXAuk~vcF%g)KKRRd6qFU$mTYT%xde32NZ zff6EdJ?)m2nWMF<$4>))+R5dQkB>iW<vZudTo|FCjD-IoOFr94SU8WrRp!QfU!|C~ zYZ)t211%}BO;V|9#P3x6_3IbXlOvF-rDEzXTvO~WXGv0&9#0_}?JfHNB}Uc{LZ^aL zifT|(%Gl7IRr7;obM{CiF2x=nUfo)EfCib`5YQl{oeb0xf8!Yw`|)i@SK#0n18lUM zXgk!ld*3S-oeSkDK7KuGib@1I?zpXj`}eM{(HyO&i>2Wl>9aO>YZR14;y+}<XVR`t zP;s~b0gyMWh-`D_Da<KVt}sX*!X=Q(_JjBAVC^eCTaeW(<&XE27^W~HW3|Lp_p|zT zEl2&^S{eIU|Hzxy5VZvr@`n3adq82h$dBXGQ=XK;h$CG7jQ*0{EKtRl0(aphC5<n< z%+agT-&X_2oyb%|VPn>S=WpDqB|P!UY;!+#lWm8V;kkhpl9aig+1lL&8cWAqZC$nN zxWQMc+>d*6rwtxHE;kQEQRg6s5?!>O{W;)a$IW(Km-RKxI@2Z3R0^&w)~S?KWgNC2 zDwZvHj>GEZC(dRQ-4Ew!(Sc0jlY)aJx*cctvOxW=)!27-kz-`-jLBnAC#)NlI!03w zq>D{h@KtN{n}m)q+o+V2g_R)Wsze`W<696bIF4^`Z%0Stuw^*C$gd^)F@nLfQ|cBT zi~#{EXF;KM{qYlJ#zs`sc*UeCT(rJ}$&y8yl<$$W)kxZ3?hFCE^!A-k4<2;9Wa<|E zJm|!bEcEHQi()M@^wYARd;OGAV{LdcSL(F+Xnn(>ZeS9<Y*oIOY@ER>4KG33(Y8cW zff1j-K3)Xkv`d6YRJppoc9K*VGy}2DR~m_mifZj3%)7A|;Y0vOEV~bkFb=2=9f0A3 zmF5dTq-mLUayb2D@nEL0PwhRC&&zt}E1d35z+plQ&E2xK7@2^BZ%1%CvKvrO`*#%4 zfz|e%H$H%7(DQ*;A*a^T*qAziB^IM<R~SL$^jbGsAsH1Fm4`&B-wA2G`T6;GOj@)i zhS9Uv*JXJ)9A2oVkp;C!o#f_Y(F=ulwP?F*ijEh(AlccQQ{J$G#>PfnX{`vjJ{Y(` zB&?Pyv(p+U9~U1#_cX~#u{0T=yns;SfqakDEY*SIO^;%WT-qV?pT*6L3?lyOh6Hv; z3QvZ6u?NA^rM`^U$U#vTy}zcAxpwF-%%=F!_FKPyEwVCIg$uxOm~TMxpP*1EnZ2oD zttm2l%u<f*#4?1ht&JCN_laub(^@R(s<kh47%%~zt5x(ddg-wxY;JD8lUB|qSj%&! z?~IX$oWCwihG?8>NA$+}$e+}?6wh*Og6oq0puZ>pTWZ#*Ux?8zPP^vfsJmtREH6KO ze;=+u=Jk4frM<2_Fn}9*qRjU%XZsn~`h@Mvu6#RezTqife@XHg#(ExJ-^9d(z*74p zdJ&KT?;L|AxvR7DD0!$2fF&Q0$Xd2q&IE&o&B%b<c3<>BG-Aa^C2L-!@7jj;CJ$C7 zC^_m7Eee)!5WY`Rr0e^g9M0FtYr)xV#L&Il>o{lATF6I6@=!f)K_YsyE6|rf<_J(k z+NIPVyA=7y>DInTcvLm{b7z++1RpK|He1&gcC8ZiAB0vi6gsl}baHdMI6hv51qKFU zJV8yzqE%mrofSgab`dJk?1#uM#8T0)sjW^0Ap_e$Kj$}`*&$YogDLvzw49OqSKxcV zxHPHfvrtI#+2R_IN%!6=Db~2A*^GW|Cv`2&@AQb>^-MaJUC_z#^*~C(>Gs2&!)^km z|G^7WB#sWg`Aa%m7}}<53%)E>t}R+{*j*Sf(@N<CEgVPi=eh^$$su7=0^d}~o0RSS zDuG$G6O)%EKB3wuS-oJDl2UDLUzu;{jz$j_SLY&qMVy6WuZFE{iOBgX`sC_<R@QTV z&k6hr`fkU(WLixvQs=u3#bh4KQ^aVb@Nw|_k0n}T79@POip2c;x^RW-)Jp#8oqUl5 zB5Gs)evQhVd=Q{1!%z5h<6v!rLSypgD(U!pxQz2!pki1;<$V{nC{c^&S=Pht<|`!3 zeztq|w@L6hW<}b-+_szIg?_^i=xpS!V1ls@K?0@YnGO%UoXJZKzp5@aRPgkE4Vpn# z!Bo%ewJCGMp<RiBOgKv_)@=7`$}>(!(xt1&rW;M4$N79IY7#|5LnGz0nQ8L67)au# z^u1gOCsi+oE2!ufdCQfS4E7X^{@k(ZnepbK_PtzBWNysz1nKBX2Y+#;$)e(1;cUuR zT>jz3u=#_>QTG;WVaA~2blx7|wvdH9(KV9xZxayvwH@0e&-=r(JMwi7UBXdzLO&Al z>k1<I=I}dVA27Jnx-FS03n?*-2wGho-$wTlxBl&P$*@6EXPnV&1BMf_$n_*glI<&R zqhxn~;Vw$b$@M<m_&pqiu~!q|(HS&&Z{gUU6}Ai3Q9b0+K-%~!n+$UD1c^O|CFEXn zVr0?Lo1dIlF!^&ye{6zk;gip)7e`;wzQhdw8PED0P@QNwoJG~d#lxt!R2(Z86II6# z2@D9Lp}n5pU(W5Tbzp#ism~Nhh%Fm}0#m<}lug7&0gJVy!{uWOluaq*tyO8HBSWJ& z!i91Y<#b%-<?BQ_OePrMaT(DDZriFGg+_Vb!(>A*>d#-@fnSgJ#XX%W^+Ptx#NEFB zNlB3D5>bGFtyjHx8yaXDaXnp({YHt=tQ=d>j*=kF+IjJ)c8tjk6KVmSw6eCcs(sG{ zV+!rhCDU5-uEqoar`Zfw5g<W(HU{;s9-Xc%P=mDJ{ikBYL~|Xc@{aL)st!xi>3mjQ zXM1luUuir4`-y<mjvo#_*9t28!_QuP=S&5cy}!Sex#&{+5c|pD9<?-)$Z<bZa-KlR zP*BItPYLAJ?6o@St<#ucF+b|pi!)s!XQIa^RyN9GW!92dX^dHIBJ!{#BFFO3Zr-9Y z8WzWCaQ)q%IXB&Cg9K(HP$dMPNEQ>dll3r@$P*pmrdy~&wCBu^QRp_v>NM`%5IP4H zdv%s>S;pG>o)?9)SZ#1r?@dZl(&;4BNhgR95<Y4Y=r_2d03b8|MXY}v3<p%OjNsI# zS_9W<1b$DNs{LZUE0V(5lYSpYyOHkYS+l?Hm_ELg%*Xn^K&aAoYCD@s$>uxN`q8v8 zZp^J|vFH5P!TFby5hTm+L>q&ZkVoeN@2<_{hkk}+h)^;b*Q?JDK~Jl1RHa(VHui~h z+8MX<*&!M`dyit8ZRYc`gEuLCyc!9|WA_$7T^fM->d6<nrwE+noXA&V4Oi{@rXg!3 zJ7f<Fr!_Kiq1Cp&CQaSZk^4%~n)Y6wPSO{2rLQ7;PL68w#^tS)?*uO36R}BsW=TJ` zM@GY;1=G{tLlVk*kJ{==(}yM*)=C8#CM2;J%RVixOg;$K+U-#y>vk8?zi|sC^PQDx zcWa4W%?2E<*1C>tzz};Jlo@`H98@$Zd866Wjz1M{8#M#Uwa;yoWr_Q_FrmdCk6%a< z>ST@Qn5UqyW9{sG78(V9$|HAc;`vN{=p!BGsn*POUkK!V-k2dtq+HREYF*d2dFkKr zS1O4c!x~#m{+Fv{Wi-Ozg1&-LU@OgWuv)$Drd%gifL~^;t|E<*r$~+$F5_)oTrMS} zwpCP@i$|*o5DpFFIySskj|helpVl$e$y*zhTklCdd(ucej=St@Zf|A=aH(WRR-5~e z{xw8fLB!$GQL?A4kFi;_fBwF`+BS~7cN<<BxV%h3+)f_uY#(S^1{HfBP(ZYib3Q8k zLiegHufmdkeXu%fdJdq9k+*l6Nhegdnc(Tpolcela>8Y&8z^H$D#z5nPp)>yMli<U zOg9s{Nvd21EfV8|2r%>@&ku;#jK<SD{G9+m7>Qo3<Hqz>nh&R&r+aL)1>pLlquDMb z0`>KW?P8m=1hh(nD<Zl|hI<y`G;_?ssV3zC;?ao$*dY0+D)ZEhwwC+TWqx%Mn5#YT z>J3D+-ILzMUM9ocWuC&{RylOe)1Krk>jlB<1IC6WdE}g!?_iba4<_n-ZI`n3zAvh; z*XRca1wkhIASLO!pp**Gbi=S@kh5|IoA2$>+x>kiBu%!6&h#gjML~h}ec*6+ch>-c z?yh%AyCW&JnAWfYgRv;dZ|1Wb>=C|+Gt8D&9rUHX*CPbj0L6ugq%3C9+xx*o&X6zR z>>?+HzN723>IWX+D!<tnWO(9T%^kJxMzW_cNBiR=%ou0Q2Oc*q$*4X&F>@!Q%!C;s z^M?hoq{JRxhjj#ix+$^xc8-}ABpb?amvN?&7APmpCLfVdw$M-bgZ8DL@6bKA;bNm2 zM7>aZVC_QLbo%K2<|!@C(W%VSKgZ^ki|IePHm3id4*s9>@-?Yd>)QY00{+jDqRQl6 z6KD~VN-Hg`SRsi}9~&9@(9*Yd1Ox)VOH;c!XQ}qAMOs-LF4VX+CnS(Mn<d8{n3ud_ zDZ~1`sQHqj&*Qjv5R;JZ;p*t^pQIJ{-+7_WxH+vo6xdbY!*w@gT-+y-?cc${@H`j{ zwyz{CEWGYicPDd>PAiW~ugt>CY;9w+J6SYctW$ydc#Xp2v}K2%Oz*aal*MQpeM!Ge zh3a+ZX?f~9x1oM%<opS8dFcQ8?&9=Dd?|Z^YW<$s@w_s49D*AX5$cqbls6zRoAuSz zHoI3D!v~`|-FFwul;q^(czAdO8-#cr%ax3#L%Y9}^*d-@e)qItQm@v(cRo~A?y5U3 zRL?1|;Etp$g(5{QEX>jx-eEE+uVzj|R*~~q|2(OZI;?Z79GhhNcQo}4nO)wfg^i}9 zNd~ab_#sM!&#E1j*#FlXoDeQ9E_Y#rQiCQw+c~^fuQ;qG3VM2aZuT1@Niw{T<s2M1 z?oPMz_dV|e?uHCHXf&mjn?ovA^cNeL&*dPi#N$tqBNopC2;F!Pwk38zWQnSw;j%G6 zybo2*KdG-z2}XT{@|*8ZRfGKS=b7^JbSLMSk&iO~oW6e}dzXhSN~#s35c$P-aj~%$ zRdWagVzI$(e6q~AyZLG(1&=~0L-cOi&@VhZ9FlZ*eU>9Q;eN5`R;MR&K4T1CYrYA* zJ!rtx&kQcaA{S0fbA;V4dBtV;RF_p&PR!5GFE3|sN%)b5ZhKzrFYeNrRU|2-Ss|lo zPhn<eYr3@B%ruv4b_1j^DCj8?gp|?$s!rT(nuW$n&`zylw3J+us@q*QOO=PTJ-FQt zH(WXgSV%=h6+tOxpsC5{xZ0s2dX-yVj^vU9ZqD~&`r&vSqeANb1O}BBueDy7szj}> zt(ATnj#XJqV-fQfxM)9<$<Psw{=yqLf3UT6zFjWUX|%(J-gIor2LJ%*l~Xf&<VvUK zPv~CipOw|El21)dX}+s<SZOO%&u0kF=34~yPxOwD$22g#diEzG2>d$;rjOMrK8+PZ zW*t#mSpatc#5T?#1z!(#rIXGnYitA;YNU(#iBeOqEqNU*R#{Kmx7?k#VY1&|u0`Sj zRa-mOj%sytdt>MoXmG5AuMs&5gEtok!^pnrB%7F6OJ?Th6dXgt<;={?M55XJ3<^K* zp;vgEj&KD(Js182w$}m3UGhPaChQcQil=6yr|Z2j5WYc-v}d$Z3&#|Er$g1=0x`Yo zi6VuQ+q=6qc0KgTW2FTm?&bd%S8p8`Wz<EDj-X>uQqrJwC@mc#DIL<?-3`(L($Yvb z0s_)q(miyC<RA@0*FC)NeeZXF-~7#wIdh(K_FjAKwbxT1n>u_pj{<?k|6ig)a7lTZ z|EJT-EFRxEjl>mmQZ5+RKEg5A>gejqQ~zvK#@(=bC>GcHTehsCBIXZLyrk&gQTS+% zl9H0^{=#KHLz#Rk;`*Rf@OrnBT|8$r9G}I16)G84_j-F19~H_dv5J-V=g*(--@mU= zPi#W*C8Zd=*GKQ~w&h&2zinp)PbJ1#obP$fkuq1uG{ZvdqQ`Mj(sn6$s-d@pT-|bK zcdlP!3K=5LJ10M|Kp=Hb9<p;P{zJ2610Vl=F-`WbV3&Fl@mA_8*S(~t;HW1LZ6*j4 z&c;#*WQ?R@Q}H5Rz{cgIq{4s(2Br_rPo?RIfY%lKlTY^-J%SDn4iyy@`>prh{VCyM zh95paEK#bftK-OL2jbwv^{+uY`de5+sY?gVrgwBqOsnm|&AwJA;$&pj=SS^^ZP<P~ zn<;anab%$E*~B(spe*NCz#}Tr*bvhub1=9%?0%VM5e|3+;d=icRuvU=U1;=Ke`utW zm(03}8I;$7kFnmwuQy;<$rUQ?%WQvs>7au#h>e3)(#K1_7-+e>+IfymE#F^hF<G>g z;ge1tL?Ir@ci4uEXc$fR5fOPTYGSQvB5Lk$m@N_oPoh=$KH7i%H#Es)sNrPRw80j0 zTo|K-iE(syne$$3AW@@?|3#WcQ47!2DGDlTu3S04XZA!$SO(xb0O|RkRf5>|;o6)4 zOS?n{WygsJ10%`=8F#j9&d2B=cj^==$Fg^L*u~yHYr<q8;T_KMLp#FO=XyQ>R8~Yp zjfOpu_G{gv12HKn^Np^yS!kAIaq{xtI|C)z^Q`qs*+Wp`K_jzKU2gDp_3-b4yU%w1 z{7AgAv+fsrhfZxp-R01Wiwm$V>HMA*XVayDvhPNNZ!KoAy>3z_`A5sJsk*cpHN9pY zWu^#&fVa{K!hcozsM#;T;&E#!RPs<<v$FmOH^A&+2``=5Jf_fyakj--hM#)mDPFEc z369Bn-{0Npx^Kk45e|}0<vLovzvFxlMI&@t?~U&5m6+QqFv~>dAqJ6>i&_#P%S|nr zo}T_IB9%uO0Y}KGac=JI-Hk`dO-}qAw+amlqcd6klaIb%GW!b_a^?hItQD699JjSF zS>^1Yb48_VBO5*{2s#VWS6z;~)>|`|`yA<dn2=2VhXT&d;8{{;ilvIQ(MV7!KI!PO zzP`Rj$yum*v|$?Nv<VD}lQk|XYUF6GhfbCdl#(<VU&dx;E(!#1%>gXW%F0^nj(FJQ zk#v5#PKDx0hx%0K*VlAQX3GLhOmbQBcJ^4DtemoBQ~L1Oq$x@cjs%nx8&yHptGwzh znKNit7-HHtA1oIQxyQtbhlH~Yo3lDt-YGtYj?wD)<RoWVuIF*l?bK69=I#Ht+_0?0 zcl}Js8zPZ!q79#8RcmW&2l)G&Jv|Qn5zWuMn%VpFlr{*^GjXHxio$eb!)d$&0|TZ* z$*22`2SLHXa-fn2X`)K%OAm=FY@g@J)7re39Y(D79W>y|5sw_32#yjh=oH9hY{pRc zQ>|IWNm4Z~ex_Fjn`m!e-W;6fB~n1jF=1AGv{u$`-+9#EFWK`rR3IRZ(>@?_K7a;j zj-DVrLWaP<JOBeOmdDa7ICY=D<!gO5vgOj*c$Ep3|Kz82R4}nH`9A%0zFEkeHSa&i za}nMdDk`d@qa*PsQhv{~-QC&BhmeMX$JliE2SxDyhOL>xusb|!H%RA1lOXS>42&s= zUn<)yWQvcTKGcEHvxgI!hQASCAe)w*8{%D9x8&IDacYnTy>*#=>aa?Pa1FT|p6Q@w z^!)dmhf8w4zNzns()H|ds7ioJ(9Rd}zBGO}a*44=|4rl%=em7wjFrgZ)tgyU$vj2R zGImcBr><hHubr7BFe0JGbDSP|cH#2TZ~``TVy?s1``d#>*X7&of(&-TA#fLy;^RgA zw86<RAE^oG8LB5tlkIVK&uI(GM}!%T#F))JdO{5&{Y(#NZyb)%c}bRW64!;%GTqi; zg0LChso4OUEP1mshgC(Iepj{NoRPPxh|WCO$fH;Z%3~XYGSAgHgYu+DkSd!0MYgIp z%{mA8MBGyZ+{4illsL*<)FNkYKaKS|^<i&l@r!=`%v_Y18@bs?)a{NS0=c7}spVod z5KpIfaFEYoy%$@KPXQoJ9UTEP*z3?#p1+#Hm{`*r5h(F>p&b1&ABBa@|LyDyWTKdx znXOnP(>i3g5*=|^JCD`6eC&(lx5|bffY=`)H9#f9zBjtg@k(`!%;E~A%~%dl-k8rG z3keCuZMf^A&tgNWaR0YxExbJ>`>uzxd`nLfQPAm^BD`m!fNA{ctgTm|3o=ui)@PKx z-ZeRSdib{^uF1{Aqg1ojW-Rv;B4-4gi87yChP~2p?}H>3LwMST7^X=Yu4;~mI@Uw9 z^U{u%=<NE0^KS7>)*YrloXXvSHc1)VS0~g?_d<WTO^;cANyJBDFHm-(MwR;s6zZ@y z{Cx$se3_g~$mk6=-V+TcPxefMhSA~p=;-ezOgskcH~K@JP7~x?=$YNymX1Ao$E|84 zf_5dVT0D8iy}%lIDuRRm-clk@(T17A!cb&=IKRAH;q^-ZnIrSTxn8I;$KG?Gp`l^Y zsyqA}Oh(FOw*sJ_*R-~4--g3nR-Q;V-I9<EaUO~cAt7Piqk3U$8!1}~_-FWZ0E1<= zpzeaa3=0d(;4ILKWk|kAp>5AvcC>(zX_oefFgRtK7tp`SWkT=q>n<j?^V+A~%iE2T zN`u>?Cw_2_X{mz1-2WFy8n7g>i3h?E9{kq(yedSqYm_{C!?@V?yVi1Ya&y?fbvFo6 zQy=Y3kMhc1rwZO8utmK;efl(z$b^A{;<+9zE^v3T2J$#1HFY|ldki`LT(zNWptm+1 zYwy6ongdmYSVvlUdOC?k+zKIFSm@LEY^5n18{2`|pVU`*_gePYczl?WU+bGf#`LdV zKZZgT5hFZgr6Xz0`+Wj=#GxulcjWe>a$09bWWbv2L+w{UC$)vV(ffA;_pGT2F{}pg zo?N3OAzv(KbcsKrX72Xp23|8nPU%VW9y%PdWafdbq^Jm&*`2RFn5(wjj__XbdwO?T z<}><$y7!k`GYk^HeS3CAwVmpjGA-I#i$gPO83)3g8McST-Q6APiWoaP`_0+BEx4R< zL^!EVeu4owFgtKodi(mapiw<*<TNi*>cnX7DcUA^?18k3&mKOK$Zxf3QC-K8r+|~m z27x#HF9g;`>y(u>r}A?+H1$V>%4kkLs14{X)P26r`~(4AG!#82CkI@~PuCG^JyAXz z@v2aCVz>L-bDz7*t*VTL0y(m|;#}XpPXUzT2&0qa^{uUQpuYy-Xf^luZ|2k;w7Hjf zY?xPj*-3P7-ekpMyzmwVjflPtpzO(0{B-$C>82j26RMudA(SP!HS!`fO7hD-mM(-0 zr0iNdMLmHN{%@swe?xy@PG=UXz0JQhwctbvH_KTTunrQMHfH@{mKbt&1+I~jyu95A z|GCv{MP6N<8>kB6$VVB+>+IJQm6W`^yy)y$Qiilmda>qg2OU3L2kXm#*g^1tmlym% zIwCo_k-OV#rI`t;Mok_uS*N;H5a|(<!k@5JS4Gg0d8VJQz}4l?M0_~*n)m8Bj>`Z3 zq4p#am4ggF0fmb%HW2O$*M03B_O4b>=uD#;BE{u45yK)X^?Os^2w+@%Y%H4@EK}F} z5@eGN5RLP>LePgtG6XX=VMwk^?lgO>qqY*$yj!k@hK4pajK-hpIdBqA|4oLSIn;|h z_w==E1oxbEA(lo?GH>kb*T<5{4KAG0{VsG?**J|qe}1!sVQ<AygWHx5)Z^T5JmX`% zdTBh!)z#JG3TL0XWjFN+#P=DYLU;)N7vq8z-;}8A;Nx?1?2el_i&ZIjcdb&SzA8vz z`ypL5FLr&>k<tA>F2EdULqGyd1BI#|^3w<K8PA|fD)1%AMBuoWf<5ouPe$D$;aLJI z$tq+yrMZ;6w{(1bsri_gm`h7bsF`f_c7Hz>MTCV(0gy#!^^bayjqZZc<wF)~SG(3& zZk2rl8#24_^2%9n$STcs70^zz*g9A-L+WJy^Zd&h$(Y}Rb3YJH96-q{8R#DnfCdfV zr+2WLDP#2=_%UP>A?A3tt3HP}G&FSkfZTlUZsOwN0-j>r-5&T9=;c*aAxazfvvs*a ze`NnVpJH7?TAGYpst#K6)%ErD)m6~^*RNlhA*LVn;UU;GwB}f0U7+A4;<D3oB?74Y zn4G8-7ScjRXY%63iwbdBZNpx{oY7t?nX}%$NLK2rVk(>$8|Ze!lD_D^|A`3PwrwK3 zXG2I77IuzsID<8@qodi5yo0mWSIT%vg0}agQ?lynKN1sFWn_lHhIU7iq-12Yy6nwO z>v%?-waBLPaa+$-fgWZ<Lj$--G<(_(;~*yoN@`_`kTpLen&Zqd$HVs`ZeY9e%Fo@t zI9g+5WDE%jL58B*ooS?}2nSn)Cfh~F78hGOIkDe<Lao>y&3U?`QRnS~=!rl{|I9J* zTb1v92P8A%zkxT6O)onjR7=BXBs}?jonx^A^Tb=`)lED8={j!wt!Fkk#)$|bH$X0g zh9W#}@pQi&!GtkJ)4MsBzf|hABVC>Hf-V28-(kiwx3yc-s^{+Rj^L5c{wy#5A;lk4 zMn3<sHr8#r(x8ZbtTc5_Qplft8-XtvqVMwZ^5QD-=HxH_#B<F$PgW)urPvk5Hg}NZ z$-W58R{!_4)CI!%z)$*O?_Buy=`qhZlG_xGn$^AeUdR2_WpU3OljL>Y-e*s%&Y|^V z3aFhWTs4xzP=tI`PVg3oh)Ajx=v7XJ4WszQ8Y7o|?r)JX_&!`JfNNo7L>be6b>$Hn zTTxkAuSLhj#Z~bf+8?+d;C)%2TL@0O*4Tt{Y#fmR>yOaTP+IfV{LaP2v>j=2eeB!% z!_!k^?PS9e?DudC>cE&P_YNu<1cHPJZjWJy_McjGrsT0S4)Xhy@tGMjruP*XDb{7& z;8@`?YGvA@^(d5+G#1Tnt$a}=$s~#tk!|$`*Y?ciKiP{zDv>ddi;kS!V7bLhI0!ZC zLa}U`g0Z}~sHnfcfAu5PoPDU%YrsK#;B?tv;4VGa0w3_FPtl2c+a3kJ)mr+jp^==J zD6SwSA@S_?awGq%Gjf$e=V?ifYKH(7g2G0G27Nl8F473X{GVX37lcI3_-<96myd73 zK=@N^-t&>5KVil18|Dw$(^T+Cf5m+ymt>Pm=lj&^?7Cpz)8M=V+QRv=X;*;K0P&29 zk`gAM4-VRsPZ!o!g-uPVAE_kc%p}RIJ+kUrY!{7!U#^Iy0h9s25+FZ_0w+Mk0~9Ym zK@|)JlLuvBMux7Qa3MAx#}m$Nm03w`?KHzfqpj`jPb{&Dg}FH-2$DU^0DbbT#s`zo zmCoHq*siQ^OFIZTsc<N{b)21@^Uz4<-W3!^MPXQfsvV5N=L#vsWCnEh)h*)e><+}+ zg~i2Sbdm<A?J<B3_-+n6Kqxi_)Z6OnD*PpnbxxQFIaF1M2{VxvvwzbJgS#JEAdO^3 z72D@Dm=DB<Qptx$_0YL>TDuSJu^Aa;iJXCRpc?(XUr<yOx?`%UqN1XpAQX))vuzpK z@&|reE-tH8kBtn^{!ajVr0Ffftx%$(DTS6iHKA5uI()Of$NwuoKOZ#TP@=wxkpESr z^i1s>vU2f_W;lV;Y=rX)R&B`GW9@l<x!$YAag8M5b?J+t0uhZ=(EBDz@J_-{LQPH0 z%Zooq#5^J<CPtblGyy(71OVpg@v#BKt`eRSEtO1rk>4sC$DRc|(O|lilF7&5V+|*w z?+hA9X*jW*fx$koD7m|Hdm*dLzz*<%B&;bUEVT~mJ)4R0FvWw)gRJSDn=|umAc_`D z{Zz?>C|YZ4H-G*o#1g%?Dg-bYP?q-%-rVdrE`y@lC4h%ZkJacvtzMY@w2!5oT>C6K z$K3d>$zakFC+Ar0{w}Ab4<h}_$l)+TrTR1J@}YvpV1^0C-i1=f=X|fqlGi5xI+v{3 z8r-)qfD1yXSuD?+H~RB6)`St8<vpaDl_oMqM(Y4n0}gPBN-lhH$!z2+R-WV5@MxSt zomom!5`H)r3T%_MVFTnsQycJn@v8_oWF9VEZb*v5smxi^bHlb#1!2#SSi=Av^8O_F z#%gFfmqc1y{mMBmB$I-j9bTWDlT28`FS<&oRVq@B*(IXoAcAIVt1cno`%d)ucbE9S zYSa9jMr~W$hfP$GJ7gL;qa||9v8v+_ox7J58j0xY0YCb)*kW#(-r4*d>KlH}`kTn~ z7d=<Xp01^yz~1QJe=mwHOqpL13p!}AmX{#q4}(INMbmi<kbt0#(CT@<4-V%B86ctn zSJ-_T^DQds{Ol~hJ62AhZ}IWkb`7bqBLM-y?Cfmo{T=UKyh8Q_DA0mQJ$eB<`RGIA zwmBOpNr6xyjn~!tY}T|R7^C3#bJKFO2uMnMz*9%nFhEe4)6>#&Ic}of3gzr2{H7kR zKpU^Q_|noUQ+YW#IjMEd!^0OF8!L>~Ao&z??*T_<O+DvfyRkrmRH1o(Hfl5jt@*A$ zFHockht`hx`17=GF{cktVjV9rwez4mov87%PpYvLCTFWy6$X9%+OsRFa(V{T*4y6L z-__u9!e6jP78nyXuR&2G5ao;6nVBk-qs9fsapfz1!ceFb<D#mDSbVSGjAo}czvm6@ zyEQU4J3ZU`u*w&#V`DGE?^uvm$-SIrX<T->7_rd8i81kZFX$B%^BAP0cy*}D-_bOT zS-jMqovm8qu3oBIicb7UrQLpnZLYjzkIvxY$Tav~W%5c|PAy{&m$XYh!DZczqZ!pc zu_S>lvnjwJ2M8!Yyh0`72(JlJEzvmbCbETzF}vz~d=a(Zu)}BpD5B`bPi}5p0YpM& z>!64*=X}f0uU#<S)!B*I9yeX6vlkE`(RF_cTcnzSWz-1=qx0Y2oY>pjBWTbK{g%If zNC8BXo$3kr93Rti793y#u8&2@%#2oMd%&NUaMap;mYpH7_}8!X80usbW8?UF7>aS3 zLv}ar8olp4lJ+_Tq7mqFS+`0NeAAmutE{R!7(<EW%E*0uTp?Tkk&=-w8|Twon?#(y zLBr(J(#4Iv1Qo6BmmBYlN$-T(NsY}3-X_)P-vsc#7<2H?_~P1!A61#6>+PKKhCI^0 zAROslu(^e6w)J0+vCs1N4!#@1)f;D;$Z1)(y?dEoi-+fER(B{A4jd&5c3;s_FWT1o zShyJyy*h#fn7@-O+h<y=oRkekzP$!*(x**p1|rG?2ziS1H0-8>Wy?s3ATTX@>X#+M zze*p~dUEbM9D3t*W3D|+%0zVW=iZYwJG0BeP4%|!CeKz)E7jFnRwJ3w=ExGoU+(So zbv`SG?ZL{kj4%d{cgLGcggXNclgbfvnqVWbv+MAEgoVVE2XCfdbrQI~_1tzl-l@Uc zo#e!DlS{%^ayIHwIBT<%qJ}PC&^t-vanF_Jb>vnR9s-hNG|RJD=N=TljB39>ynZQv zCL~_4T>$6|A<ayR8ytFts<<CNK4mFdS-q99KjZY31(bz%R&Gd)?p3VBC#7@Zp$4?k z=@)@Ah-sz~P+mvJ<p2En6ENJMqMO*(dMIv^LW-Tq0g=qj&3%XNfq)AE5uxCtl~;d- zQ6P5D@kkX+j0R$U*(-wK&XmH*sjI82y2eI_znE0wHH=n@n)At2N0(owMAgSJ?w>Qs z;}~)^{yiu&k-qUMlR@RkExEi7+GSl#!p~Sz!o!HDRN0QZt=r8bi%LXGlc;5OLsh%k z_xHmRZs%lTl4AIo+&ih`h9G63wh@1n!si_BGsk=I8BP0UZg&ZbgSvZdBVKS5e#5{< z?<F?qb^J)u%gK1wn6Z6H*y9@Su}n)G<NK1&>l1i75@)8y*NM$lY>oYPfs^<O&Nb3l z=j*#PYm_y~^c1RZHVlS58B6fPn(70*8~iPQ6-A<@l^e~qars2=>3d5Thwlt64$8YY z4MbTT$8=%9un95ybe5cQ^#+H6w5-Ef#bf(KMkn)SQb0c^ARs{Xml})dF!2K&FjZif z8%K59dVhXj7l@36guhS$0P_RP1JW8Nr{q$_Y<BOPi$D}SRtP+9zlx5Ij@#{UWwk5p zDJuR47VLlQwuD|Kh>E|ZDyphVGoL?yt}^UFmy||AM&7QD2oKMt8{XNHuo(wjYR?Y@ zzoLeQ<oNFHE@0Z_4@G){cl95jz;riVK$bR;XZh~uwY2yhQ_Kg(P*^L+Pm#V+$f0A- zE}R&U0-Omx9^Ozj-A9APq~O-p61kLK;jsxP%n($b0OtfwhMDr?l+=o%{tAEA1N?w@ zF~^5d>kUmIB8-E2R_=*GMgF@LyG5>d-ddg7#i6vt@u*2$PFu%;J`0${qn@3}N@VyF z3PZRdO%(ptfe6N_@o6Gd_M9#Y33BSlBX8$fvZyQZZq!s}Lkm`8xe~Q<6Q}ri@(czi z!;_H4?jlJ(K2&jz)=KVzOi5EO6N+#d3NeEI;mtVXt+$#NYh4A}M5_6Q(Y0AS*j=B* za-tow0RTy=SriY)Kgzhjc?fn@m~!*KK*Y?Y$S7;Q<EcFaP>-OBWG<j$OVaBKu?_f+ zi9#h76;9vE%+^#TYcN}JH7+Np@Rk)|1K$0*fcBEVSKmf4BSBMxc|N0mVPT=Fs%me( zcKj#`TpvcOY-qqM4VSp_Z6_Qy`@(U+mef5Cl}diL;X0A8YvSta3WbRQtn~p;L;(}; z&3nMq)dY=fDQT>4P~`wvSfIF#M?};vD#6~RkXGEN5fZ^@J$@`xt(5rIeN6Xrz0c)x zZMn*Fj>M|qq2F|3c)gWnug}^2^})LTZt89h@6=mOfwfZ_P1SkFuF3Br0>9T=4QYq_ zWarDbH#U^Z-V%7+da`~tF~ysj3!o96jV8IC{YGP2%VnG<x1g+AecU_eTH`Kw*}sGG z1@#GU!aLqoJ2t!!*FzXV**nsi@*h~%#wyCnPo6#nWB;mg#0uZ5Y!(}$Da6MTsGbmy zj*eCh3f>)I*y!^JjAWJo6=v4hLsL=}5P(_o=KXPWGcrha7Q$AblmXNJJ>U_5;4-@K z+6hKoS2;{YL&K(NuOusrz99o>$~-!fXU|bkatz-nC@8>xy1mCrG0|l==D^{@ef5fz z$LSk6;CLm7kmEQ{e`nQvrZ<Lgo-348{ErKG;5)dAi|Em`Y1xW?{mMH0SyU8M`r`4; z$rcKj@zeUczGs~VeS9(Zk;<}kLe}t&vu@B_1yaB{Ok#ffZzO5&Pu40IAN6$-0*YDt z8>?A-o~8VOrB;d#(?R_Ea9fuN^i-bw^R^YesMM->bK@WtqmNYw-g%S1(<cBvJnmew zziVGI7Ed&d+tzS4O*}a?QKlKIC=P<^x;SvXNH1+D0@sCgQ;^c1oD$$qWNzi%u{FsX zL;u<QHSTz1uP;gayHUI^0;nU+PuK<&5`^X>CG(EKd94x^aNHaO{B3Ft37uZHVQZCw z>_F4SYS_XbP+FtTq3z*}{ykszzTN|bauo$3Hnwm*R(BT!E=g`pPft&4q5+!40G!gA z|G99o2JMaFR<!DOuTTO#K)DH82kMn3XkLv9gPnmgwB7Zk34yW*evtA3eW{=@3Ys~E z?a8uFdN*X)^QJQ%RUrXF7d}BNSX49yii>&j+^`U=>CD641hT$5F}J$0`T6ml9-d&K zV9ZO5B>eNC{??KCzX#adCV$qGqH2C=Vc#S!mDCkd+WcEEC#q>WKL;U6SsCahRzt`S z^fD?onC{(UgKNXe`|;UN<DdB`guBirC*JFD+{Zr=F4I7)Ipj?~yBPSe7*!@v-Q{9h zeP^ii?L=R^tWkqJqjw2C%J8Stk(<83!7J00#?2yu`?mF7GF5@oi&sAPbs^Z@kINR1 zhqp-a<feDo5OQ(}UR9Svt+r$F@sl1HspWO=Smoj+^W<p()$xh08^i`dp#0%^ec2;; z7hUrVL>3{Go68ZO&4l%u;jCs{JiLsGEI}R6hshC((D<$Q2-&xE01P;eij#RAwm+pa zxw>Ay7jQe&lT2oIkoR0Ua^ttID}G=h7G2kSF|YP>Y@2C`u7LcRCO~DygL>=;<C++Z zm>CGSV|zs_1neluJ^cw+)i4jRS+o-c)5MSkZ)ayTFhLw2KZQk^JVzhHZt-5bB)X3h z7*H@W0vuQ;YkejXBJ?y(6rG1+&PJtp5)|NWz-$+kxSBi(jc!M);H&^fs$`ZRGK*qk zYb%4xo}7XLfPIBh96Y?9$*@NsT!noy=*<6(7ho9VM}AgQlh9XEQF-Mhs!07Y>$w-r z+5RHYBHTs+)OlUfK=BT=qX=?5T-*>5a^qM~f25w^Z~?X<bItCrg0XS6n3ASSehrnR z)8t%P#6UQholhoA*Wy;h6L_zg0^S|rl{92Br;@+NEy)PDCAc017)-~*1FSzM2M5p# zLu)attuy%Cj{%<q#4X5A(WNCM9+(aYY%h(+`QHFjYj{M27oe=+5C||Oc+*?&xW#G( z=r}s7RayP^%y*wvRq<x++_9~{(=r9?I4@#@Lu_VPMI_x9tznH8156k+QwZ5hvIiv? z&$AC{nPJl%9gC!t^$iAExhY?L3sG`jhajn3CFgVXb#w%vB+<6&)hs>YB3&YgOGqHC z&GL<M$a{ngy|}z&f%p#G-kgkp;*f~NpfmSVEdByIsED-2;!;wiJ}Ci81XO>?Xrhm6 zds%GBt4cnW*-1!9Jm72lg*>nsk0G2<e<<RMLXRopL&<aLV+&bm3Vt_etgWqy3dLx7 zv&dFxSJ&7_)^G&-7BXbn8eN6Ur&3mBK{?gvhHTX{zxr--jP0C?TZ2@Uot;gq`VTN{ z$dI~x?M4?bAd|rW&3K@PAXuPwI^0~Scf?^EEq}!0Fg{HU`mI8m4~dKeoC)1JyDXsb zD1|<K>Zp(X{`<bOGI*dXSHO88_;q1JA#gQ+mop=gby(B&33!7DXenR?MEd*o%>r`c zMLezGm>GMV3Sc5&#K}6G5u$BQnyG-`m?<{|_A&%~ZinCB2%8E201YBSDnebK+f$Gl zBw4U;(e&*!fm{ft>wFcP9vwY8k}sDb0H2wZ1--Nr5>PsRWzS+#T9?s=f+tu9j-CuK z84(p#c6ZN{G`5B*V_>+-$vsabx3`b^sBdlPn>&yGD0V!n8RcpP5Vi92latk@rLvS* z&jUY}eknXX5AZFd7?te>84aC;2j|r*vC%Q#N67e)wxMi_7xT5YOmuWV-dn!9nsk9u z$8Nh4^`|s7H?OU(UY#FAjZL_S5`ByWrwYm4!vi#n$Isu&7_+7h_NW8Y1#hv!#Cr0{ z!on^KOFQp%-}H2k%TfN@zLAl+S79t-iZp?~eRT3aMTp99C>2sgRDsTHyXeE~x_H@~ zXGuIzlG&wJA0Kt~*>9K>`%NdZAtJyGdhVl*%|3`MaY;#T%U<U!aCX4^f-b@X1*SWG zYYpZp@T=JRQvjO+7mhXs>%;<7BY`g96hi~kP>`s7ZL%sVf>2_t@SN2Nswd-!1A`gB z7w-yTX+K87q2k7VES>hstC@p?gAf%O8f#V=QbAd+_9e$yavIpgH1Aa&ot`!WAsEo5 z0Gpw?c0m6OX9(U03*J1^{>qb*olVYYp3RZS4!VP^@Rt_VJYedLfq{VroZ#QgWBGtv zW6vw01C3{}l+yf`mMmb{kr*0UG&UAm!D{IoBeE)s8PnRjFd`>Q7gO>#;7x5K5P779 z&RXS1;ciHR3I&KI2-%fraVlKEg3{nl4M4BHKUW>*khi{WgwT29dnTsaa{%}UZP&&3 z4(k%oG&vv<|3q|Uaxh*n0!|&!@Zme*#P-Em3r8>iU^9IHB$dzmLwVl-&T+V;L8GJV z(^;5~?XzcxdC%yWVR9fMh=j~BGVWD`q10+~q9r+*r%aPRPiOJ`=~Do2GG`|Q4g5T* z)6>%hF`AK}YS3Y}F3rz3*&0p*+O4{SmTR$a{C1#Sf`PK^(^DFgq2#X2%52{N-auMW zA{@3w`yLWho&``0fUV2`ZSZjbEa!+rLqfJEi}C1Fc%DBjEVoD!#17fSe-_rVvX8nx z8I=D7HbAa_9jKU?!b7JcoW^|%9qx}?55m=t=aAdMU=#g#%d85wlbC~LM*)%cl5E+u zQYa<P3p!N?N5>ss`94gfe2!d|V%NR7Q=o^6A`>JX#eys_f@N#Adfz_4IxuLPiOuW$ z52s5y&R%|y-3c!5l-8&XWF&wiP5JTTM|3nMP$q&S_6fVo2i1lQ=RL$phcKcvSELIB zdpf#8J7-*_(A>{gfk9Ae>a#qqzM-M`M^4y56;%{Hm6rVE0TDq`Yz!l$g2@WqH-=F3 z-94tFK>+}w?W4`~*1y&1Js&qF6HHdoJB$n`GHbTz7&2@}e%kru8qTQOf=jEKO~;uT z6BdS&F(O{rbOKC89@tYMp?1Ld1N;s)-G*$A6L40Ex?ZP~ASoEo)U$=v78V!hB$<OV zjE08hy6naIBAG`gbLgi+PExfzbuhW02QN^Q7^d`YI6z5IfVzlAQB6$)Sc4>HlP4z} zyEXYj1Vdv#enf&|L}H*KL{lFxCce2&Nz`5XBQ}Wtx7n~`xJu)mYiH1(WZ?g09&YQ7 z4>X|y_kX_qsoE`K{nS0{U-xYEsCqzvJ}iar&ow+Ku~uZz$GZJPMnA<tbJOuxO6u;7 zo_9o)O1i1<%9+Tp-`0y^#*TIarhhJuVRT{=H$!}@1|0nd)gGV*yg`c@AM%0bb!^{~ z$4&``;0<_fMT>5$H!2Uy93{T7BhW8+U7wBqeG&RYERy(>csjmQ-LsSj>62d@5O1Y( zJ8ri5Av?YOodKv!$^djf-{&hequc+IvGNo(e|{##S(md{l$VbJURFQf#Am-nS|Jy1 zKSDOftkg~6r&ePpue11jOq{Il{$+quSs1UCqp|jDM97hw`$m+pk}2x;p6*NbOTVlo z0?Qhso<X0zK0krMmo(}vH$7GTbt`1Gc~3^Pk3Lplj2j;a%CC^(Ojmt5#2p7bw}|GY zVqqMTE~COnntR3-AR|oSv?T=@1RPpm*T?gths@{8WRWr=4;vS^D|39uSV>*I-t}PV z@?=YCU4$hzF%d6d-mhkS|F?dPfD=&afG%N~l$6xAREB^&ExV;H$ZI^2VmSjNOj!fK za_h;HJh7aRuvh~F^6qegqR*=cFg9cS^BtSp#kHrV((4c(>EBPENdKSOcY!CdR9Q4` zl8`^y1~d&+zE^H;pklg~4D#j|NyaJBg{(CE`9vN|Gxqmae+2`7>a101aq-$!i~YC@ z4*{_gb0`X@)l8;5t&Ybw)elsPsYf4ZPA8P)9^&lzvd@EN4%u{NZH=vK6BGkLQ4kdt zmI4r)LH>N$jxeXq0@vJ6DLFYom)#lA0Kvk-f&vafDOJ9aU(b;slSCmK=sWP}M+05d zg+C(s2OtNbHv=~LWsW`a!q5Dy#BgZNDOvRY>PveGQ-&zJ`_QD|-p2EZ0db@<K1M8p zB2?&3RGB~0Ge0yEp3Uw<6PWT1z&OiaPKHvs9k2eA40vim1XJvO;5BrbJy`5WJFL)2 z`MxC;p4hp%l9>rN8Wt870(O|}%b^hHwqj#rD~wOn{9Z(bCBSV}bad=(Y~E-C+aubK z4vyHtUmv5BBp-s8Vtl84w7?$in(Mk3HESH?hovw(yT;Aa@nNS4zb|slbfEj<TGO*> zulJCEjpZjD&oO%BNg-dl3I_|9nh`B;#o99>9(z5z)YiW};jF^8&j;>IX8Th^%5Fk5 zmyB4|35i6>F-4`=jbYWW>qh+!XyNAugRU1)$O<K`p+>VuwFtQaeLQMvza0^nB0m}C zeONs-4U#cJrgs9*iAv|PUj<S95(pnC$jO6H3H<Go=N<z`J}#Y+zS)1Pie$v8osdjt zU_i6G>yLsL>ge#|<RlDWNI2H3R}xC~#61j|UVF>;8V<)EE=r)4>PX$NOUC(YG1@Rw zi*Baz%xdzbrG3ob-6*1(U8Y|}MZby+FqQC_ykA#X^i;H#^&OG1-N(<io2PKxBq?cX zs;hMSck|V|oqZq^ZjY8paWRSm<yCo7Vc3=&D&uksA`ipSOI+ZxUpJT`)l^S5@MC+X z^Kd$UBamx~hP~3C$AqNHdU@T3F|~ZNTPP&qae@On#0x~12YMNrhDzd}owN<`l12#! zH@7=1QwV39wN4#eAsaDo@4m~E%h_2cIy*;4H1vJ_f%rImeXf?rBt^VWq^(D+IEX;w zi`1$6r2UPa1r-k~Xf)S?CN0?QR9dP6k`+*hve;=<l^=EC>81+$c+XXt1BwBaCj;Oa zATYQ?jLQLyS~l%VZ6#ZvEApM{9~>O$Z}<>0c6ow_6wInr_CGEF=?evgFgZoQwtu9p z9nl-T25!Wncuyhx8SY3BH%Rm)N9|9^9zS^!K_&=X{kSS4h&aW>!~g`0^J8ORWV{37 zzg)#_iYUH6nUy^Dt0KS}#M}E0aIq2Dl|;ZkXGO!o;l)VWzy_n&wHP?8h+XrccoA|u zDD3%by#@tDi77L_2s!5ai{bG%rC=I%x*9b21aJOg04)+Kr0uf#Y|e5-;QE)^Ta%S_ zZy;I$ZM4BOUc7>5yMvOFl4pDKiD_wG50b-Z`qb0Bc)%dBtA76M4a@|WeMYk7>1cn7 zm+Aa%_6QusF|GJ2`|QzpgoT+|rc+;G4i!35z9n<YP}r+?9)APz0KNfvu;D9c(QR&R z0W_WFcQS6qZaMYqf&BXV92=P10cZ<S$i?0DLC<VmU1skJ5BNg0_w4w?seO`iBl%&C zjh6R!<yl#NqoeviKl{s~KT_J6oSO1*bNgM2^l1G3TcFMYj2lhl3GxTLlLMdoYYd-@ zfYw&Q07iXq*@4LLYDf3JKXd&0ax0_OW|0SE=ZATM_Q^MT{x&5)e**nt>cm7PFg^6T z66x+1JKG+|Hl+8%u8;RE1Yhfn0mS~exVY?lk_GNA-F|(%&^#&ImP=|KG6>v5zH{O< zmEY5)BM6PD>ChiETLWYdKzg_bj}df(L(6}Z0u{fL_}1`7I*)TCqm9j~P{7q}_dj%f zh%8(MDc|Og6$j{4c>}SIcBUq=)^sb-K7qWVMcrYK1c4_fFzMdaw%(%QCToYt7Jzo& z5a4kYQZ{j<smH<fS65U#UJ1Ym3JheaBSxZzi+NwaTSIIQV$%$N4WwIJ^%xb+Iy8U` zqdppUP80NL0qGXC!4Hgd3WD}a1hAikK(r@-SQd+bfZJ+@8LB-HLnW2|<+#t+7Xls5 z1R@b2m$Y?qLI9CKTiYY_t&h`*2?_Q39n(NeFvrxId%90@G~&akQ+HS%O>rR7@EEqS zx71A3gWUMsqs$Wl_j7s<X{g~nXo^KoWrjN>ywj`!&j2Vz+6Z9OJ&q&%1t9wXm{d{0 z3I);@p+x6>t^I^|%S>vASARQbt`Xap2R^M_uKN>y$k)K75DHmr1H{mFm@a)hD;#(@ zhRUXL`Q?0rz&1q1#2)f1YC|@aR01DRr1GRi`o-b{lhAC@FqJZ$TTrYs|HfqXEzN6c zItDhFPDdvv1RQVmkJlBru!%o#0`CgI%dTy1c4rdJ0FhKqlt4c?zdTv;GmWkXpr}1O zw1a97kL5|-AKc#^Je)dYnm369iSIPePN#KkLE(M8-uG)>_zAWXiFnlA*_0;HlFz+T zmi*yvWhEZtm$xkCWMfpm`{n?|^1AHm!O)0-V92HRL3IWBz`1g8psS@dn8^Twj>m3! z&%@*wRCWWz3ny3C9-<zwJ$RXl%GQfl9sW;jjf}P34%=DET6lc^d~IxMwtw-)*JfyB z#CCV496V!y&RjabVWL7ZJr5Cx&Lk#nH82$clqx{YgU^LKw+mDN`2jk=Jb^e|7BuQS z7aN#>GB!d-r=oV*8}yc`Ma7_CY^WQ!aZ+JD4=CM#s3D}KbA?+T{%0l=7wVJ6>LDOL z2e2JAbqEyr1>@Td?oarUsO=;H+j@06J-t8c$WBN1_x{ed9X={7LXNtK21#rJPw7=y zppdHTN}C^`E||e=vGChhmem?i?ZgkSL2FmMdp#T|UsbiW7t>H+FPZ&fxt-vhC``XD zZqEVhYd>~#$!1{a$qEP$g(^fRwjAt?Ab_zXH}~t3Ww;XxWbp-wa!FyKJ}gfriTP1l zN$Lb>K(^jpSvKp8JU9fbu%na7dhOTyfO?<*d?9tVk_=dNf>>4hwP&rqv5^h_Pc}n< z1c17S;jAoq_^N;GjFUZrQCv)Hb^ToSA9y4;t6|{XEI{p;`oZGgEZ7?+Uf%mayt|t= z<ecUvW?xQ-R@khSPPxI8fKo<YUS4YIATa7r^D69#u&~q904UnkUhOhIR~Fa+z<hyA zRk1JL3*jk`i3w$Dnb}@j0|GJWz(2qSS8D`NIggS2bjk`k#Km%sK!_s?U<^gd?;-UJ z2mg}|PCiWn!;AxiG^}>AfI9_p+Y>+^s&fJ#6rM8MmRZLuM7{YqHR2yGkgsWgg7|yS zn*AP#bx>`bJzc;cfM^N^;CvayX>r6vL|`g1^71c{O|kLtYHgPVrhuf~e;7Xa<78*r z=e=?N%u~{&9#G$O0!jsdT^R!GJSl8yX+4-In77J`eFpq{<oHi<y3i1NV9X2D=_bth z4VTYH8-eXH$Y!CweFUQ{%poE`zzkeedm6CA+aIS5Z)F_JG@d6;>)!9cvYw+tR-j;t zXK{XhE=PqiQ7J_TFK>MNfn)%~kwW6~K=ngw|FgK5ftL30=m<P$Ixqr)O~6It<8yDr zg$hVExJZBhXTV&S0oUHmk&OM#O>mcO9z;Pv{UpoN#%ne6duB!rs{L75IAGMw=wXcU zi8LtbYjj29Z4!Idj$SfzGlhs`Pg;PDWORV36LTNW?=J@Hy8V3)h#q8!S-Y9l#Ey(p zx!T)^h*y=fRw;_}3s!Tj_jS77eWc8K3)|yeUj*b{uhb?T!^w6&zkXSN2xb7jek8WH zXGO?L<taiTFzdSddVVmY0s`~<AJR^bH_|=#!OwAba|44IKp)Gyg}P$`E`Gt`>9nBY z$5y9pB~(ZQT4*SHGIU4OGVLFc^!o>2VI|?_av@s_GiC=gjbANG5=sprYLtkR*f9g7 zmiKQ~=N-r`^+Z&y`=XuGt*;IzM7M_DgS=HbY88=9>&(iTY;8TS%$UKd+0?W!AZba6 z5WB(#1x#_V{M%OzV_idCLqmb2k%a8y1LF~!QpN^929>Fu^XRmV?KDSu&B%sGAekxn z{2mA(emeo_PpP5v^l32Tpg~6)w?f+l+6YI$o?+eXROuoXCMLVdpga_!EoGQfpPgNk z{ZUh0xlD1piqK0&MmQoreU;Ke^H*1gLE(fly?v~N)`kN{H+G-VzITfjY>gZXr35po z7$uL}3G>C1UyDi7cmp}KGh;;3P+(@(JgUd6tjbw5F<H&itTLTBIb~Um_JuUcfSS=E z`{L8)HvPH%Nwm}#Tv8lAg^8}rv{5ml$Q`7%Nwq;SFB#Guhl_BTA8rLOHC78VCt>AR zI}VQ-Nf~24Ksn+VN258R!+mD#E*)Q9KEhrw0nl66tqyA&%eQje;uM!&TeQA3`=hE( z={u(Bc-09N)oCRw%JXQ%vqJ|*+{-I>5s^AcT7?*m9bb`bSp<p5Hm)`wUuTI{x*|;k za2wbWHI+#tbU1Mmk9<`$$<qKPd#<k}Z*C~PTS!~H6?FrvWy}e|P$0TKY~9qGLv8hM zdqe7y6Ty(hxuDuW1MvFctcRX@tkWVxwm?v_DF71=MmHA^IE@Wr#{td=ihDdB<LAvy z<;@Khk88IiX_I=be|vkFFINEBSocxL+I31)fhYgF6x^XJLq%muaWGfWd@{azs}coX z6o%5;(7?zyNb`1(hJAvUvU7fXJOa$eHfA}|O`teO4tHABtWT`%qCsTA=+*)UnNQPR zx@XLH#z$Pyjz2cGSo<9vC*ARiXk}l#I98zdT3!2dkjCZfKn0cg@`VqMg}ZUU6>E_1 z@89qLr+?W;P*v?Sn9HRz4}F$+{%-@1WDf)!@JR)RKXv>yR+j=?b>h;}kCA<chs;>- z6Eu>pG!-l?vRGk_rg1*^cl-yf%ku-p11dDt*o@AhQ5c)0D$3Ie4=TXhu1%%9y!X#t zX|K;Q8rIPBKKP!@YWDzCWoc=-^Y0(PRjRsPq7eX;`aJ?8jkssF4c*<{0Jl5~{FuyY zhozJ+4OG(rTg-t!$to`1G0pJmZg~1w+Kz~rJBI#Mm{<hiAOx^iX9I!4PaRo@<9p6j z&i0C%TvtDEa^gX)JLd<|80~NUyCb(XRZY3P8tm@<nv{z%&bP-6-4r`Bh2Ef+lC!WJ zff=k#@J6gujS^CJvzK7z4E_<6*aEi*rG2A9`gn_nJfA8Kuo)=TN34Is=Fb5Vtgt}_ zc#}Q8$fGAR1F+j=K;K*p-tgTzbED~&wESD2#c^|CC^N0TF>Jqy&7W~XR!}oFSp%^c zi7;1MyGA_MgZ=5i5NxttZUF||7LE1wqxw2fn2X8qf*Wh~_ja`7Gnf1Ao&qiHa>qw7 zC_H6<r9EJQGwowmW-)4N4}eS_Hy011Mix&WKL%BYkKQ7*1vxXv>!>U82}(YY+Q&&# zpJ>|d8S}MMM4>7`J=oxO<;}?3fZ@3us#$KCw+Hg9{mm11RAN{SsCAA37=HWm84x<e z#>C)%AiCU`Mu}sLAjDlnp~zW$=4`yUIYh&0%YE6?Bd_ob1_iB`gNB__08iuQZm-YD zxa?jdZ;jU4>Us>gfMm4?G)9Qs*;r>_UXUrgH|zLI977kl1|$2*ZpW*-EPA<S_#t=T zFQ~M)3&;%-xv#@7^9H=#oM-t_899yn<G{aERaG5HVhMOumFBv<$Q1o;S<5@4vGH4b z1ebG>*<u65c>b9<CiaJvOxuPGpSvF|+n^BWZ=03Q5a6ex5~akFN#Tfarnx}!jWlG& z#{fWNBfgax?2F4D4-pB;H>biAe4bp>Pxhz#i(B21dtE$|!pbT+LLEGuOm7-#=-$%M zY(=J*RvXi(ZBf7cq}`lD-~=A_2C#<)!yk6JQk;G|wFMbVS6!R2m3QCBsqF~UE{QtD zJG)VzGt%m@2&K95^Io6~XELx(C9s;l%-8F{{sQt6vT^coYt|pkPe|H)HqLfXVAN~h za&2?}{ml_LLsXTs$Frt1cgKm*s64ScERV>({Pr14GPB7YLH~Zf$j&LnUHMyfpY)00 zi~K8ZtR2KZLT%3x(3&5=KKFb|iN(U>zL?q=n`2;bBY3gWOO~JY?Rl!$)RbovmBEy{ z48nA<Rmo?3@_oZ;dK&n}v*pvL?wZ<KscZOcWY=m1MF*~Irh3eT3pu1pk!~rAgg2+b z=mzYBpnZc#vAv6U^r4`0>m-+TIwB>q`N)HlXHs<Qysw)gJ-w79fl*yuT1XLjO@F0; zBermr4UUj+aoUmZd@L)zoAvDYmyum1&lSq>7@UU1z0LqTZ_j)uqB=p$W#D%gGRb<I z-&#nB7Xie{z@XgjIQQ$l=N@Q|7^B+MhC6~n!zSbE@OAVv@c_siNq?eEnv*)-m;Z4A z&EE>M+B+eT%xfUhB>`iT6IsI8_z))>1%*2{Q`F15>+$=`k(QVkLTIh9zW(HO@!9UI z<Jl2+M@NUf%41&_S70V&Zr%mD<R34`-g*2h{0ru*QfgBHT7liJWYRi)&CYg+?xlL& zmI!tL?^S-1pbuiUl1kUd@nf+ye46Q}Et#>|&r~5WTBwvJpnY-ugIw;%d=iV7hv>Q9 zRIJZ5E5yw7{_fm6j;T5%+Ep_w$&QB`_+LEtInScV%}Wc5h%kbz@Ohl0XG$s9Pad@5 zSuc7t6*ZLV3Q`5ij4JSd{rI`Hh9IZ>XrW#aG~YC02B>bY=uK}~fKN0KUW)$(domA~ zO3s$MbJt)oMST6nSfPG5q13T8vfSW4&}TzBgNwr&5q=u|K0`N0Kag;9XXHjJ*@R&B z@}o}Rs6CyO{7V#<tjq8|^^tsDbJ*`)o*l3Bx9i(Xt(rd;#tOD2<m;1_C)j>qptzWF z7G!;|-F=<H)-^UB2pW8(00usDckcoUCS%BoRh%D4n4C4XGBUT|huqybZu;DN&J2w6 z)6Vd6NstonViWI1VEWs9)3K$emB+(T!NvJJCR#8a987n0rX-sLlcRPvguwpVI9|qN zaZu#E{nKXSPrm`DDS{Vt4|G}Au9pa7Vi)$U-@D_slGoVJTS?g|i8<bJ`5dXF=Lb`@ z2ODo4(HkX*Dh*;hRp{;YbbKc`nf7G1^|97l3^|q&yR~<kR1#>G3moSLFs0~WAC~ds zJBb=^npRjZyM>NRGZn|T(Ro7drXw_NspSEmBYY;>-qis$k5^^ny*GWkDbGU?&P@}n z`79a_W8Za{GYr0wcAL^uU|?_%(q``N(SSzuN)*o`F<}blmC6R*=XA+|f}q4iPx<ez zB45i224%9o_itd8<Lffw0SZQ$K24)!+-%3Of&nf%Zb1a!$hInVJ@B<RyuP^3QG34P zVi9#r)m#LJW!;cBi%TmaUgt3wQY%MlP@L!ybj>Awa|@LJ|2Y}y5*y1b%g4ny{_K4( zKu0d7e~n!<oyHUAUX(<)Mjo@;Y}GC=JX)A4On?@8@humhiG{y^vr5>&UXdwEC^RVC z;1zAjr#@#H>0><0i30R3BRKz4`}55@qpv&8s!@xqI4pVTD}B%uFGu&r$pr4H{o!BK ziLDnXI<UBIS16mu=#S{VWkxlx@0@O-R7s@`mF|^9TaUH9nJlOi)X5nxZAmS^_|)yG zaIC%_C<>g4<xg8?xNuCk45+X1)SJq*xMf-Dh|+G#fWEiwvazI#HEjDV-<y>3-gE3) zty`=zRBOra<0g>}LC>N{2YTwNa$ULFx+KI-={E*sk5RBSSGU#758`D3Y4}FU&$rEL zr-$!a{YwkXXsIh?4e_bwD_Tze)5!WhLM8Jeu#+d=vlkaj?gwwivauY){ES)t`Zt}& z{V|^{(lm@yikzvHay~NVO}~-fUM$mvT^xb2x`$b&2o5IQvD7(E*LL;o^>%C=ZiaL| z{(sfMq*1n9h<()zI6v-KoB0X(=Ys#sP^p>PG$`4+%tjy=fng+C%(2;YJIWu>X`GUA zYe5l~%pGp+c?t{ugi(^V)o?yrlEF-6{Hc+HL?hm9gjb2kn_pGw?U396T7-PYm!nAo z(ad6p^%eY`7pYS{Uq!x+bCk+yR+%`&sCb!w+8e!`*5aRfwS^1c7nQbHN~}+~CS!Zb z(*BQ~BkySr#q(L5g|9!B31D&9^}pBYBKoGA88`>R8)p+6FYm)x1sB#lP>AT>8PPb- zdez*ymO2ceL}|772GtrM-{Tub3%&AUyq6(}Ws%AaPizQCOP#Ni==QwQZlRvK`7LPM z#iA1!27<%FYmngfB_-*#v<_3CFyqVXz8fYC8CnYk&9!)q{uBp?exF-qQ&x^mywAXG zTY|Dwv5-N=JSvi~Ldu%6f&LW=cs;r?hbF=Uo1bnzPp4pf#|e$l8hLtUa~w%XXo(!p z@107lVuA=-1561-4fWe&%alZ>ZN-{Y;Q3VRqL3Tj)%9C0Lp_80uKDHM0VnO#)?3M3 zY2CBG_qd{b_wf0(=A?Y9tx{H#9b&wb3*jklQS*}wzO*b>(RI?rpP?RJLrHD~Da1^v z*Yrc-Jj#^Bh#UA8w)T$}9-72H!>>83e2yK#gTz$R8QHrZxc)6;$b`N87bYFmN}7_i z&SW?_|H^8~<)_I+)wg`Au3(*p&41+_ClgJ%mnbcB6;!9?Jy`0Tjsj_kiyg;Jg0X17 z;k75@v4g@3>Y`M!r+O`_#i&Jw#hlMttpAbIoN+R_caHT;B?PA6iJ~OzZK8RvdxS(S zRIFoc;<dg+z47llt630S;NwrP{uM*QS9qj*^lOF7XU?W(-TdSw=h+6d9mDunc6j1D z&Q`&$v7RohfyYs4dcdXSMdK}OAG|3AaIycbd_n(W>W0hBy5wE-=r&#Sr~+e-B`tlC z%?nFHO43qyyw6CFG7PAJ>?S!>^keyq=<dIdPusf4=ZVW<Lo$HlyFj{%5=YT|?lw_V z?N(D${RZcSbiP=B5ZuQxCy;Q;$9&GrylQxL&P1c4bQl=>u;UXFA;kJa5NPc0|Dojv zLFbj5LlQ91qNk^}>&?PZx{y5FB(3DeJ)-MYCpIC&@I}{o4+K@SPM(1uMLntYpEf(W znquj^tjFTSGXoq-|8=vH`X!k7UpK2C((CT_=(q7Xzk2lkk4M!hQ<zXjaSUE%u6Zog z3Er<IY}zO}$y81nm2Rg*{;EGpbd@$UVR={yDOdlxE6A0F6>K8NhdYz%s*9&xz<V7d zLymMC_YWrupDz}FD~#L|=*AA#tZ;N+^$4@&zaMzZL75)1nABp9e{k${7smT5$HP>$ z!X1TZt6m)?xjXXx^1-+h&3?jbPn?ZELE-Oder=RV*rvKRKRpkt`Ens?=uUT_#&a-0 z;y=mz_CU~D*5w(x`FnqBL%%R>P+A^U^Rrj~h%Wr+Za{{D=lA6F_sYTu#10!U8I24K zN}{5HKz@7%3Sg(B^VTU=F~gkqvn)xdS7DeKiP!iDu?ERRkNF9!uwbjp=&o0I>H(CR z4n}EmZ2bI-78d_X+GF3_Rfs7dY2p~Csg3^(J6W{0$K6n`;-gY0kA^Al{-M$$*8~QX zz_<<Bm$3i<GqW=7*RR+lztven-@T_LaMk)qi}mBP$9Og8A5jBUW5w0E^VPUWM^o8r zAT{r<U)AL#OTD(*>p%C<J?owF%=aPk*1lb&-}@eau$Y6h<05df-A$y~Tr<HRmSpkb za(Sv!t(!O5rupB_NMzayZl{L7|Jy6WCIrvGOV>?2%RA<TY}%zUWf=;Iy5H$S32%f{ z>y>^OkE#tA@?JcJ>YS8E{Ax<S>`T(c7`SyJ7)&U``Xy%?@Kl$ub$bp0Jhr2^2_S7~ zfUDgrZP7W{i(nClWGd4RWrYC`zpAntCf$mdPD+oT=(xBtr%^9=Sizf(>%M+q8Tfs# zMNgJHVJlTU+S6DO{{OJ`)?rmOQU5mvAgzF;fGFK{KuJMC>F(}4gmf$2sC0t}NcW+; zQ@Re_-JS2C_j5hZdtJZ%kH^h9d(WOdGi%M7S)XtEkv3xjj04t|^yE3K#}MP&tRTH8 zu{5gJ&>q_Gu5=$BuIP?G5+TyZs4?Ta?x#ZEVHJ!-Y?==8?(c01v{;9<$&D^UdNq&V zv5p}E!6t3vlPQHW20d<v5b7{<4YmutxiTck-bQ)tBeR}Jm7aO`YeRMCJ%P^$WtP;^ z`<NyZA!Btck)nM(<SGu|x|$28WD9tM1)%im$B`Ev>Y>=|s=>d!40_AMk{I*_1$+16 zvC{U7i{xv!xr0-9disCD_0WN1qYLJ4Gufk~ySs1BI6+vz4sS=U-heVB&hF2W@YS5# zVcA!djD?#RUQ$~9(HIY==y2`W#L~;Gsv4poh0OD}5jgH<r)8D+s*C$;Yuk7GqkY7R z+)+VoydHZB+5U1@!`J?D2B=@PGL)r~BY#^fwmCn)Im%USJF8{w^0?Cym4D6ed@}40 zbE45(%<M<kJXkoh@{^9AEL-mO9oMLXjm-qb(nwvhh0h3F4RwA57V3uYJYi@evN)P6 zna#7>TVMx~>Qt8vXSj#q$cRhLe^-gU&ac9v%6c-xwS<Cqr1I_TLV@PQ1j?$_-1A>r zH-of3F2)RZeTrVsg{W+G<6ZFHPSjEaL2wUapaw^H@g2qH2ccX7+MU{hT#luWyIx$b z-|uhxqgTa{*#Ez)!Bd@J38dn`xbi!7c*9-!^72VuD8nTjb|D>pa_V3_VEUWh{O%|v zW!Bjhdy9rJrBzr|oUjloqHZ-%WPuTJQ8b^aor?tNG0F?S?ncYXRW&%za4+=|$~@EM z+iCbA;?VB!Vm1P^6aO%e?~MjS{bxA2i^ImP{Af&mt%_0L&fpa3tFTk6rL2zlHQ`eA z$ZYI058f0(<HOGMyS^^jJ@^#|l8m|4+1#CAXy$DeS<2@8Zu{lt_D`szze`$H3_EKA z<J%MRx=b=-l9la_y7W3aRbtcM`o2+P2&@yj;yJb749DMk(5o8osgT9ldywlF3u0<Z z!_?TdJ-7OL#0ewR{~&$RHrqIfVHBAQ(Srv*-07KqOnu`;e{NHdbt#TD&KWF_U--Vv zS!=s|RYQ?Py&)oorD>=^k}J_l2Qi)l_YfAXW`u4~A*62g8zVMDLaWIVgATMvkab;5 zpl$^s$>Nf<RwqigcO20^FEeSE4cU8sb^xEh_}JSEAF?V<TFSAPr)zL`rBU=Ism~+F zhPI+hy@S)8={JEn-@;(q6~l7F`CIklvbV5rcgJd^P~Bk3yrPgSn^K9mp-O`yNZijW z&+Vtd#}!!S7JSIVJn{@&FIWgwb;<W6o_ZIojcXslv)oV$<_kSZisuWpLGYz3j=g4P zV>$KeH|=uWIm#VDDLTx3B_m<N9fv4|tW#qn7n(aS2?@3*V5T_TZKpgKE37p%>p}h# z-vJ@9d0VB<a`BP#sxVaC+liiYzfC@G&Tr%Dal~2ihBSm}4cni4_A=kCK{WK}nwinI zWgoGFRZTkCDYL`yNDn!|TMeIJkh2TRtmjD#{q#=7m6M<-7a?!C2jX@KyE9@*|4`QR z2RLOw(!%BY6Ga%Pz(BvVRc;TnNN2bPnxpkeui%zygr2ds{iEM$A!qsRY_14<6DV5F zWo>&d;V&^uYL6o0;4VDQAq`HsI&tte8alRJu?NvNUpW}yovmiG#bVJ<%>!{u^i6Ap z$cdj>p}_JtFYIQE<yMJ(SS@Svp6~6N`wzy<ci$V%5E-}1+jX+r0kSoTF9m`1hn)MR z>Af|X8rR)Co{4vh<Q`^a&H>QaZ_jXu1zxyaNQR7Ah6^&)GM7%YeaQ|xgW{nbn%b-h z;SnkhYjFHeEC57u=E*SFu&QQ%!_K{GoJAaFm53Y;))h#2($lyCGICUfIF2|<A~tx< zkbTufe6;hZdiwPjY0Qn~TqeT}4^at;Uzl%f^rMzIC4?RQxM3759=j~@0&@-xs$E^e zXQK+1V~exh;&Fa3V=?D=QasmP<#%W6MkG%|Z!;WA!*^z~(j99y&M3xy%<H)q=AX=q zM8~(GC3kY8@INv!o}FdG$Nvxqs{RQSVesp;us-AI&<%e}AAC*iQsc(@{iRJk)ij&& zuSu>LM~ObmPj^kLls04Xkf;j1h2|wMfd+af7wWxrn_GRWC#33>D(*pNEta=sp|ua& z!z^(m;N9svwGsa3TWExKd>R)+d8*zr^&_lKyynk<sN7W>J~iPxWd-?(Yx=^qcSq5_ z<2~L!44mC^PC<^dCUr{59pa9q6dD^;^|GsPKhGw(IzzU(_Yoa!i9)vh^S#=T^ELOd zEb#L_8AA-O@wuMhS*`3bmm&o)*HStiJdUU;wK6T%&-<#XpLDFDW$|X%_>jys<qk@^ z*Nbwi-*b_Gnm?-R8&w;Di#}Q!QF9SKXud1tKlyl=b7I!~HM~rAe~~qL!uR9Z>4c?{ zzUx3+%KjX0JKZKz0RrE85QdlOF=pmbe_{!L>mc`*)l|ydN})g-{J2VI>dLFF2HJp4 zG&|@bo`<zXQ3S`@DJWBDGfyw(ML=kzHKEI28x6C#A^ZRtORx0KQt`T(y!m`vv0<^j zPfQG<rI~6DV-1C}^YMh<O{kwTeU5Iz>0{;~?s;eFu|_LSmVWYLcEg)kfWQ*|Zn{5L zrj)E?B0zN2`5>e%o_==4oWKr3qrC|Xl~+N&kUmMt4H3w|ba+BVVo|+l6n7MG;Q?>1 zFUFg5uYF_~oh-<3g8PSS_Jy67qwmx!#~yd@zALVMfl#Z&wLb-qw5_=UwD$FR9@B63 z?Oivy_<g*318@Q2hZIL4q`;fsi><l15m33TEG@o`?tmSJx5XiktLrEE->3=`4IdXV z#2e&4%atuf#W#1BUA8&hR}s(qes*lbX$(7&nm{-jvcH}&3}u6OMzkNc<BVZ%(=PB< zR~%EYLyOl}q8&+#oror5Ft?&vvTdH%X`5-ascCWE#FH#<At!K2!x&*oMN=>dXTrkf z4(v%&BpG4gEHTmiMh2mfFT1`l<`CY{!DhAb_th^W9P?(`{KJ}8*oqS~Qsxb@V8^;# z&7G~y!D&CNVx0$7#>%p-4t54DWP^MCFQ$}2!Z^*BVH7=WuZ(!>28m=(mR;y8RWHw3 z6g2Eu3EVee^FgsJ*f@5IV^%4dg|#3)Fu8Vp?4l&w{wj;ZgQc<hokgv)tj4e|R*bl8 z<irdcQ&7XJBuSekcXcB|$BK;o$2EGi1(p6Q7Yoq>d-GuKG_~c$TuB^VuUu3)TVjv~ z5zuQFKWbxZual1#OP=UhG#9t#HN=~9(Mt0(wJ4U#<B1SfMIu`c_l#;oQr&HU^HRoJ zS|5-egQyW9v#a;KI6k~mIm*IkihEODm)zARs`2tl$6%63T$c~0hGp($@cH3$oEk`- zHdC2s%P<#<7>YZx8ZXrLm3M6A>}AWgT{6QIgHaOVLn;$(yx@$YqQB_qMR<!)$GQiN z{!L2)h}d;$;5oMye+u1hL`j~Qllx|#zmQcJ_7dX`-rUYFIJUF2eJPH%2^s6-EZ#>w zv)+!he-sSMp(lvwQW2sTv8krJI?V_a#E)>O4#-RwIn^5|_QMEH-Pmi0Vfn(;aT%4h zkfl^|yW6X1sd{Voy6^fFr>Wu;(Tkz0ul}}@nTR}?#Kb7&$PrD|x52O|1X_2Qe*D5B z*+#5rG4&$4eYG85gI&b@>x7Eq?4^5clYBw#%1ejR^)2QwwNafiP8uw+KZ_S43Hd!e zjN&QKMCI<Gqw}qXr-{Jiwrcf=G<|C@92{OX@ZW?TA%oF38p}xCi-dGh9qz8>Kj7kz zi_6JkZuxs{wuw7h&5V%k+(;@4F2#a`Q^t%Mi;HS?56TV9ZCF`&3A3m76mWb|*w1az zGz`Dkuvb=@;I6HhVzUam@a=;2;BI}NebnIuVPfD6F(hkv!|x06=Wf>*WZY~FwYbB) zy>#jMX>O?#6;r}KPBPWi$IZLe#kMs@OyWDM9i%H$6Q7MmU0GA*c6`8!M6wmkEH!W( zNbSe=g~|V_Tm9y(r{{2mgmY&392W^@)vjejR>O)1*KCk`?Zkr^KkI5Xd``oks-9k+ zscnvg_4Ae^8OpuBl<1tyanP{773L)*WwWO7l&+ClJNT)DFnOVnQJ}r<M{&j*sO;Ru zQtfC~Y#y>WsYNnQTL~Ii{9uXAn#S(QV=h-Dx%$k!XOLv?V{b#tJaf8^kx_estPXLb zpdLz5Tt3XBm+OG1n7bpNMAfE;P};>mh$R4C)guKZEys7<2<t{=_?-E?@FnSVf8rso z7r0U$Z#dt%-K@O7<+v<BxSSfgsa=&>`MumF3w0`MS2!G&CZ=;`jwPZS%?T&z<+a); z7T}ljkVove%!{n3Kr`yyBb##GjN(Z~OJJ>$g(ciA=02=TC_kj3nZ53}=y^I-4Jkg> ze*DO@sxtLWj*at6Z5#tqh6cMWAE(l7+ga9f!|B%cwThz@4*-BCDaQe=hDr^KWoRsx zDqP@VGPtA5%OznhcE-+U|M05fkk#`rPqkFOi2t|E(Zp8X>9OeQ5gc(ClTEuyn96vd zt<#`@os1adI4(3?Q_@lpAUAS@IJDYX)8Kc<kHR8!1n<W6uey(BIy{dUoAex4#ph&% zdL~3egOR3C4MHr=M07jb#e;iXxOU^si{~6#-}PL2Z{I56QaSsn<F%D`U~;B-qJt59 zeUaYbtSz^It>;L-*K@Y>uFSt`MMf=oaFw#<B3EF+s^rwn;3!k&6{ObAh|oCL-p#cr zg7|TQ14~?>;uA*kU87_+ye=o^`agwEy|pGm&Tag+y-Bq~kO<+pIh!=D!Rz)<w?Th4 z8e_|IHJfKb<yFkv!Z_yc-roJOVlD3dp~JI~l=CfXZ!i!^=M``8N(m0q+OmbcO*R&$ zlpX@K$%6Aw%H3s-;++n{0OskQC*xaZKJ(YXw=n4Kuzp<~&%Algf{;=uMXPq??aejB z%Xp4-_@hf-+vtWk-mg<<{izWd@^WqMc;~g?h>yrcoWGP~R)~4iANt_KpviX`PN(dS z;nF6O^&{WPcTAL07I1#Pz??A2EfrG@r(vcwf3(>>1-8d!InE}xa*MT7+sF>d`4t~c zNo9&nUmdt1C3%Ys11j-Vy2zP}?S*?@#NgVm{3-fGPIjfqu0>P#{w}U@E-6<g*36UF zXFMtmw-XIY+wjYk?b*6Qo_970gUZJE&>EhRT*e%Z&56t;H`N~(9+vO8Uehb9yDo{G znm{Wek2)^4AggO1*`&wRt{ji(_4=^jLjFh?;>x9+`(rb;VfnM&;fW@s<9H}S@5!wu z1v!m_?Dv;)-&V(@OttVbN6&DVPX3T58#2GN8I$eznC6+v{n6C7InP|_MYiUW({OAJ zojDKF#JV}iHuiuuv<SGpQkiLqr-O|yC_(GeICY}goDpjvzwc#$%*`vm#mB`P9*PFk zvx~i|=g3mRefoLrC1o4Sn4JhUp5kg&bmeIk`>PMXr1)aByWMtQkP^N$yu|Ldv=HbU z62JU_vA1NuqDc*%EcSkTffWARXWd5zKAus}eWIfj-T#`dtVu_E)9n^fv~yd$t&!>k zv0RE`B&q6K+j5IPy>JG;hiE>>V;}T};gn#fGD>_oPujvrWXy_uMtlU(51Z`SE8NbV zZE>8(ANqf7ec=Df|EeiUSybJ9wIY$B+<YYInfpVHa{SM*9d30~8Ig+*AG*{%GB_g> zyDlhmo3f-^lVqn$IkQVmPP*-}1zc=|GD3Q$K43eLLj+QssivLVt{!~hd>v5*HiYd1 zT=Lot=$yZ7pR;a^w)vl;ib&S+0U$q(?GjVdaz~+z;ji%=b~Ylb-9&i|j7$JNuh}Z- z43mhN8XKJPA~OixJ1g3qA>nKHwB?e1VSu7nJEDQdF>&4Bx^-evVy`c%C~szEy7S)5 zX)d$W#v2NKeKoh{)wW@ADjzDIUg&}I`pSWfnWk%KE-946G<rq>cj8BGxM90Bo&j-< zOA9Th;qr5&Y|(&ni%YM&{Dun;1^0(#Ss=it51$Muj!w>~eXbhq{F-NQK-^&>2obzp zlTVtZ&RwMPDg4+cSyx=4p6f{h&ZHW*lkq<q2_uBW96Tyk=J!{E)9qdBwXaR{fBEw& z$kiFG)IXg<-6M}#C7ZUkKAu`BuI8KTyOY}+eowlRE`GCauo*m@652PnFCvzLpA~N9 zLf|fX31{AAmi+W$CE_T>I820({brKKtb+Gwg9?wwHPR9uUz*Jw{uq9_oE0sKgYCw& z<G$gcTI$^AoF*Nh{ca@<V$SCh5X%d;&j?hSUmjOvb&FTHL+;Wye?Rt=746Q12JY2< zUyn#RybLzBe-#vZt*X?W$(KIP6k0HSa<g^uKALaZdQ~W2N;x?`BkTvRm5R#+-3f!o zO`Lm_Y#JZ>CX{RkD~ZP*#vwr8(Dcn?K}aZok6<WS;PjHHsnqJYVgGcDQ6lc8)x^<C z8V1T)?UBTXmFT|U1`Z6*N^OP9Vtkk}qtjAy6NBgQ(4IVoP)ag++V*lLv#nA=RhIf$ z{v#7lK$p2V9$v~-A^}@)_pDYV72X;W#Uc05jJ5mFK+HmW1%w32oFnMcC2-cs$k|s6 zMglSzxF`kuoiW9N0%SEDiPd8wWa*>h+2js`V|h&t>{?d>zi6fN+rOcHZ`DxYN9_K@ zT~?LpC`7jU^H#Rpx$T+!hclerLY?Z6$V7Qzi8z8J+y$|(x7l^s@KYBgDi_MO9>cL* z)hrIu1JS{Q?o8w%rD3|2pzDYqkwHp#ahKbrOJL2}Q<KH(Ej6kVr=jX@3N)XT+cV_u z*b-)9xfBxYD_qOPwXgh(iz&Q9i!Y^%N6CZ_J{~~RE-ba;Tl*;P*w^I1p1`9ZGt%*8 zu<7)~yvkPWo0W{b=A@J2x+{p8l8J%4i%JY_q^jPGMYvr1m6CoO-xtU2$Az^U$B2l& zAyviO3|eW7b7=|q1_zuZYm`^&UN7hZi8!|3aZB%65iVHqc`F(`9gbuR<<ZaJmBbSF zj5~Q2Zg{|98U3ty%pNwIkA=3n8M`$RCx+Kb($89QEMLn!tRhoc9w|Cy5%;pEMeur* zdSaqD$;J^swP!z=%L>i7W60T;kAaNWd}CPT?PvI3XuNU;bJyJG0sB)pmL%r$O=g2# z?iRZXW5=X}#GBJP4*wb2Y`4{U*bzC7$slQ6Or4`+WZJp_TD@2bhy6QP@&CjEc6PdM z{YLiI3Tc@}+r`$i_+^<@ubSeREGDm;VZA?+`#1%OhZ=a}M~#np(0;BkkP9A;{?NRj z<cA1ET?$;BuA`(i%cfmciT7C0E7(#6pCs-~T8xXZxlMN?e*cL^`-kCEuqO8nv%8OK z0>9>Gt$h8@vI-5xvzS%%V$CCQg#xzLYZo5uc@^LOIJpw>A)YY9SFLF1Mn!Cm8Dc)P zNs%VIPzmU^8yMcYrf_O!Y#8Fl-i1`1O;Z}XlyhsZJ=+|vk^b~7fy2$N=Rq3!sIizY z3Yy3rq^Ww%fjlL!Oqj=Ul%m)Dcydrnf{a|P`eK;R5G6gY=QQqWoh@GV$7wTrmbhJ} z3~PPTgQ@QdbgMzJ$u4G2TS2izm~~g9)GpG#9M)4y$%ORvJRHtb-3P~l1(O$l_J%U0 zJb8-G|0=woJ!d6f*k1N0ww=9QPZk`XJyu>R)Y`u(#6@UxDw$3vvm`6A?EBW3NoFRs zlTb5X#!p2dlnAqNpZW=jpbj{)IwmhOMb@5vUVNB;D+cXPK&$Wh6n{KP|9vrJBt}b4 zpuYAj86QT5O~3R=GGRPx+u3mHN99xquD$GcTFQKG{^_!v$v(22Yh+qt&U#mtjq;6P zT#3S;JhcB;0ms7JPaf>a%%9U?)D8-?89U4fV$gz3AJ$$kPSSb%aY>mH(RU~ssa6lT z)U%kM42U!(nDrzsM0<IANoMu#Nf!r{o)7acP=-Lxq}LjpgEpX5($3s>f7G3eMOTx3 zuLbsC4Dsfq8eN_TXs6dWtK?MX8k7kmpB~6Na7Xy6Rz~z?k*%3G_t_$;mk1PxNf_s2 z5A7>i%#-Pl8Bh;(suzzR-U_kPFF#j0lrlO%qMyi=c5)|W|8uZVR~6lmpo+a*D`^(= z6D;r;_Rlg+7?hoNgPjTnYgmV5@TTApSn>nt3F{FuJ7abY;+!AELx(>2jqRtR%7I$C zz_UdMm;vl9U$}A_EU~sxK4on$)I@ESz|$sqC1Nt#=S{E&wE$n&bOU=R;-i6+%5Igz zd8KioS<DF^V3=~*6viS0c;CC{Y2z&ptaqhJB)BNif48-b{Xj}Nn59*eZ2KO2-YS<4 zTa0p%^Z^Xv69pcj*~#&1s$&$z(;^4tbU$i%Mga#6vuCUy8ot@#pS!a2Uj8kJret=w zwsX|%%kRh&q@Si<vnc3c#2#k|XCvKZllKk1uz44NPx(Rb`#Xy7A1+j|#)Bz@zBio) zVB4$|q-Z%wYszIaGa9CwWCjGfG4UgQ?^HA}DeJ3AyhY9Zwe^hat=$$e`z(B5SU}fp zC<NtmX5FPZwX75=v-3jRD6(tk)pXn&SC_mI84c5?AAQ9vav4xEoVMIT!yZz7Uu%nd zl@<_(j)a7;v!nNHc+>mNtu#tlg39~zKD_Wl_wi<*vg#|3!E`J6O@&C#yRKnEy5X0T z_FSdD2f?HzNQybz_-nUijEuTk!&t3B-#WCz!B&Zw;!P+TrxlL^yV(eafDZ?gP!+4d zdt&Ooy41kHCk)+xMt$hm4j~ISdM=+PYDL4Th3VQd6GId5EqY`r+ro%w=YEE}xS9Pr zAAm9Xd2I35@Q7H@aYs4?B-fnW@@CaRplUuc_9pPWQDNbD6=8bJWI)12$zsxewGqdw z8ao-ICNk?Bdcq*Z7;KlPDCHaNEFEintiv#N-F$T|=^J_ze$@J?Ph3QeHx#c}ttfty zyJlwvQPM<%|62hElKZQ3w%||_3lIK?1{bcv=2qqqD>s3uY85lWEix<Dm!T&H2LJz{ zSup)K5Fb+H)&CnGg84}XmVY2M#tJV#Hm;R~A)*dBcTD$^^_Cyo=o&kqk@6TOiO%)A zRIMa^WJr#5KvbP|no4|O;2w!j6GUg8h;6Lu84|-cJ=D>O`K#2Y7!R>Dv}3JZq*aY3 zE}p&j)hWzk)>z$7m7W$`WU7B;wgp?kSQ7(eP0xUA93aBL{T*^M$Rk4f>OuO%`r-?7 zqE|mq<94Uc^hz}I(2;O(oC7}ekqN&#TxWMbgv;OTWQb0U>}@DXrk84kXwsE_9(hnq zo=?W7u711%7f2$6IB!JU>OoJ{(LBs{Fxr$=D6z}!cK3gj9IYLBPL<vGUI*?;G_-40 ziby8G?A`3gE*3|2knx%GHE%<(_-r6`5676bBLqLv#;T0uOXHwh!!_vi;>WvRQQ7D) z7n<9n;W)SZ*d7$iO!LxOp4+!6la>6c!1vXP?h4>rtV;M)r)LmKD~ypx>xwT<$-s3o z8fqw<<M(r#NH}rB1*=H`sB{_C+|gT6ig4s-s630O;Y<3FE1#GXi6I`qlNc|nk^X6r zHsaxPwzQFNM+@Q|Y@hAb6hrAUD{hBUUPaJV_;sa2ljeQ8f87q}Jy*1|I<!{L;{7Wf zWznZy;D>}$*Zf-K*lzZaQZ7xmo{ed^I6yNt^A@#d9l{$*9K1ZM$t5lwcVe*5x1}mm z)^<r(t)frMI#ZPV@hE%;SE7WpN{0xdzst*4icV-g+aOld+jiZBGZyBAt4xw_Y->@d zDoC}bQfC<E=g1KgxWaJrnAx=mGWo2gIC=WZM<$7OCzs*pV`B1M`X#}JGJ=A5b3g+A z45P%l;WZl}J#VL{8*zBDz5``~o`J_;^u)}yzG0}K$Ssyg&yjA(bIu0!2AZDvnQJRs zV()3^E6HeQU09XS>Q*z4_8+i;ehH-8e98TGc`nR)!hC^I)+1YDyM@D?n0Jf<jc+;5 zxM)~kMj;<3`<!+t;eiJgRsSd>SBl`7vU(!)W8iV%Wz0{xEvDe?<@rHI<lMM$jRN@; z=5k9&Ok!78iIGaVYQtFf-jgz@g8IiEetEs>{zt@SrF6dJvdLxaN{2lgBSRu%YM@Ek zk9FNybaS4cQ=>~K9u%c8iRYK@&f9dH#?}8?mry#%NGj+?Qy=@4$m%3a*(!A@fA^&u zjZz|c;JqimB2-f(mp{5qNS_bBEMW^)`~e_$adorgQow&gvhrM(i|{!ABKK7YK^zP8 zSFK@N_^KX@PErtan=(d2!L6mDNaxS9+w%+M;S{os$@qh?UqIbY3bo3@grayY^@DDI z+I1wlpq`$N+Y52gD(l}WKX9pKcP=(ELLuqNDYqBOwzCOY7Gsl=9~|{qW?as1+VFwV zMLgQm)AZ)ql71%5UD)VkswGLg_iHMCDj>8s`B3J)MX>#GySQzhkHl(wX0OHcZQe=A zUSGSrd|uQCh+(Y)29CK?hlMeFF3qy(HFS-HtK<>2lb)y2w;p&(KTivTAkl4wZ@#9s zxv}{b9U(JG-~3qUu3L-9?I7vuS&HR!{Q$3T4}xLzRm&vS#@In}tL*9oee~-pb<!~7 zY}48iJJMhXb*cdh!i!Wdl&K_%)h2{S(XOac{MLfW<I(FgGcokTY-SOEq)Ps(cQ>Sc zlZ_u<*=$Focle`<dsdy+J)z{ssF}Up^Ap2~7$6bpQ8g+>{MjC!tm(2}XDnq-Z!Q++ zcJ1XcG~gk9o4s9Ead*XT8|Clfb175*ON!B^&bE37fqyu|a-x{hZm65O@B&|il+C%) zx+|F2<KW;4Nz|K@{zZ&Ece1zXkc1Y{LaM^4uM9g<V8QMmLZi43_KjaXT+Wkzw?r&1 z0O_rB%-PMNY-lZTd;MKqfa+j7?P|k!k`|lnYnmJxi-(-hmzedgWi+xKPow(4Kn+)P zvYTaV=1h8H1Z$p&;8@FTyU%)G6{dfWPSJUMwmn^rj~s6Zm3c<Sle1zWDXFFw%0C;G z-7Tj8GovaP&;KicMzA4m`knu^Ej}ZTZ5>O}m^k6vw2Pc4k8p0#3p00ReRvDM(dhFA zE2WHyryJA7nOR|=g#@JOh|0sxuGpS$?G(wsRA@a9hOHR<UW`PYNmk~v!mM2j{os&J zL?l7xwtl=K-l}ZGsQ1&G&W?dnw@4WF^=>t=oeN1_JhYqLnc1VsSf6+<y!->(?X6ri zL+L2lsOqkANveiYY|k)yMdeuWh(;#X&llZ84aiq)!4Mzyy`6QzWU6j9sdgo+HDpOM z4ZTI?*>+-HxZL-)qtKF5o>I)cd{}RS>&CXl#|o5alBK=`r;O*&VrA+rU0rIYNqam2 zM{_16-CDQlXqVXw?y-)srNWoNj;SFwHv~9HD#vhVZMNv^fRndl8XLE-mwFd5uAldQ zzSEZ^L6QvF6_^BDrn!J@_f(RId=EmSD$1kX7bV;M<YA@DjH^CUH%rDE;@blHE7QS! z{EmfJ0k;rC)t;}5c(nvO*0+ljhGaHtRaI+C<<l?j2GKG@>aUB_sitc_H?Nc`iExzH ziZG{iFbe2iOb3f29(%40-%gwPOC{eP7{I6aFFLGJYL6>n&f86yntZ0sd}N*m=6&Z& z9n+=eW-O*Xia)LmSG*cDIQ>JlxC>3=1T2%xZq^nnC0-TQB*)o~v&S%#5M9M&6vy;E z``B<SJM5gbQryE|cb$bWkX-pa_O^6&;E-$A{3Ns%NiuZed}!4l=8WXm6Sc4?uK0>e zY?Ykb-ukwHC`yZf72^Ck<XgtXYH;0;OcA%k%`7bFflO<wWD;TZV$0rK<e4#-6dvib z0!cpH2wbLght!`m`6h=Z87iG3RIL_w8%<YXbdTO=NlHHPJQ@(sT`3Gdi?}_?QbjY< z@}xKI8o42>Kc2%1HdhFm6<@sw4jP)oxYRr9Dv+(mCA1tgIG~HAjAd<5IRcwwW~Z45 zDx)q?ReggEFMqWQKcY1#<`rnWUIp>hnK83Wa-6=i6CI1Zi-r7afrhqLHn6|Z#(iZs z!JuHG>F%gzOv}_IZskIy2Sl3mZ&$I9*aXs@rK)u%`E4!Y3_EAW{H6HX#~;Tb^SC)u zwI&#*9EB-r=>}*k69ya9;?J!q^Ew@5dC0B`FI;meqr@gql6PW{Y)=IB9`3(`Ipxo* zzYQkQNyhI`FOHh?gk9XewDpoL8lAbmUTI3T!U|@HxtmZsA}(cozZ~tqVi7m!@|ipA zq|9+wGU@SyRN^I7hL{`Q?*FqF#cR?cS>WzOG559!A(dqNABbQ~1n&k4zs~A#ajk&X z)zF#`XBDntIj@y`$?h4-W!}Cx&ceXQR;nKqNFB{Os7JCl-0{3U8s@Q*)+3=@im0S9 zrCmB8DElrERe82_fzF))QZ()sF4K7&d7U2#OF)I9ZhNOAE61}0@P-kw&Nw+@MM6CU z__eBBMq9Qy|0g)R6<u?g?d%C}+!7e({8CYxJt$La$jEjd^jKSpmR?o!(o`X4DLEm2 zyoL;-9u05w@7BCdZt<lV9qD_G#HHLn6>fB>etdgnvu9E$hQyWY-7t^pk+_w8JJeDx z=@HJA>;o7^PeJ%nP+@^>%H=(@kpZmmJD7vjA_Rmo`ZQ^w=4IK>C|jg<<$mADd$dE$ zBS0QC$nnA+jxYBoEKBNBul{uLBWjw>isj1T%<w()TVJs6s?8BG*zw5YO{b>~GPA>e z+k*xx+54R+B26yo&LZ+{P)RD!x~WUL8NKSq2$~4hogjnj>c?4NLT@#ijmh>hNXzSQ z=Eok6u=Te$c@{C^c|G#j_osO@bH2JG#oNh^kJ=?$Z>O(oi3?>~ypnRBj)RrB6QQLp zJFT4dl6BgnZ=5-eIW+nVz1K}T;whPxbn5zX>}_n1)7>8-+`Gddg{dEO(6Kze>-Md) z63o}p;qhaTD-7Ee@uQSoI_mJ!4S|8?#P4Xpx37v@s3|BguFY6mu}wT_d+^2K3)AQO zOANRJ4QPO%aup5qYs%knSrG^4TmS|F9I5IF?8X5q6mEAnDTLGUjOubVS{K-$O0oaD z{aEFz%)7LJjWL4IQ1fvT5%9-%6@aQRl_W!>bAN>-0_D~+vr$1LF0gP`J-`0<;sndY zQ%PMTod}JDk7?_Oh|oh8Bv_I@y{{7_TyNw4=GvIr`-=LFVa=ckknXUvx7SZf{;0$2 zEfr3$H?==loRv&O`t+r~wKq_(#~LTA<9Um^B0evnVGuW(H_S$WZd5usIvQoKMB2@x zg8Vt^jF#>)4#JX^I=*EJ(#hf?_D{leD?nNu0)oczy!Q?N`TTn6pD?gh%VOGYc_YiO z1QVBS_5CV3CK-QXLIRI|J31hLRt|UNtGVx&-z9X;BSB{qbL2)r>X<bWNam2f^R?GQ z#(Y6TOMB5mcH7w4UUB!ZnuYoRWF({dHUr*h(eC4Wn)3Y5ed~+!gTcL9*9x%=ee0U2 zDD&Cd@d9MBb^I6GeL(E${#AIm61`@fp#eD{-dN$OZYaQ$1sU+5uxxHlzY-F9s<BRm zMb4#@r?nZ{$6KK?+E&xBomOAB(K1uVW9cV)2m`YB5^TfL;o+pEK~16W?_Ua+KOZuf zKlL_#CAXXOoWJfqb94ph!a$5oxH4^&!|d&yrpUiMj?5Isoa}63PwC!TXK#?E!@$g3 zF+NwxMPKS=&kw-2?j9cXH8rkl+k`nXPX84TpzaWqzw!R0*7Uf_r#Y!;-kW>dMk(oj ziA48kP%{_s9u$*q>p;r0P-vz{Bf<*b($A&EhlB{zRQesQ1HShBs;b&ik%=eA3cM${ zsJ@`#vHyz?+INtMRbQLp^>is_W#R$620+}CJ)O@`a6v}Ac#M2Svy6?z^(kZMIj#~O zt^yvOQgqB{C$A<BcBHKlwV!_v;e8wYV*B@E<M_{*vU=WQwSy#9{KMQsW`&HbwY}*$ zHFa}zITw0wx8`PCt>j&hE6~zn+TT9KYR6rtmY-Wt(AOi6kEi;cGOicGFv7zyZ9M;F zR#%tb31qW_lK!RGBXGT=v)_SJe_@&)t>%8OrDZtl+wFB-a`Sfc@N@p8#lgY%0b%kO znb}`5pH^T<Tbm4c!D1Qd41r7<?=!{}l{Pgr&g)S{oqTFPVud$(U-|g}EOXVph$;de z+9PAHGqlDkz}IkC+oS{UR~Td)f_(jfHvX)FSryrW{NDpnF<af;x&6QJ6%<C~&h=P0 z#CTgtv6TZqFho_0=F6MaOl9hs*&VWvZJbc$7YJrS2R4q?pmp}$5EH3<tt|7Wfj(~h zM@JEt7oyiUAmxbG&7D^DC^aTPkZ1nrf{7_$6JfsloM{&+%`<wevHC?smx8i3bwsy{ zG6f1@dn!u(Mq>PcpFR8iPErVmT~nb0LM4$SSImG6Sv5z3w!D1d;3foeq84v<42i$| zsh6KW^A0~BM$R&jTT&v$fE}c%$t2Y_=J$!g?s-jRb+t7H$n6ZuHHipjo<}+7HTTYZ zc-scE&$P!(YXc@BebU0ROoiGgc+#b97RDKSVilP!XvMKEiGd+fkTy<yf)r7|Ff;=1 zsQXo^Mr9k-HEkeQJTmnZ8QHnhfS*I2?s*UI46osvK`sBE6@N@P0Q!L5=HN*{-1p|{ zpMXq3q7LId8?i;N=5`x?3+eV!1$aRq>&@*&EzFW^$<VoFh9)!czDvrzPPUM1RDMj} zYLUprd{;lGurPN}W?KK}6Va?uThnKf#2cPy>9mL<V}TIPlG{%O-+n7{*F&I-D3v3D znUO*Vv0uc~P3Y!jfv#LhfTpjdr4UwNu*UA{=IWw|ZuF=1O)9%mKaJzp<|8YcK~_L6 z`+iE>yvhbP>7qu?53~^pfuve+uJm5E0s*1_<IAT39|-190ce4NKPB9Xdu?TLQ3y_# zm+;>gvWklbbs7mg!60u4HdunkV@@A5+3=t)Sq9VPf5#W~dySd@4oj4KyLNxPK_(5S zj_3l&fHhDXQ%N;E{V0eC2v7+c-0G@@D@259lHj(VgC+!akB%kRc6a+5-vWxL3-VuZ z!W2k}<O4~!Ch}Af+4ZDz%=ZNt<N{|&9<3R$5cC?c-zVn(@153mcKSe#lillw+#mf- zdc{Bq8BjU<k&VBDEB!sbsC%K_TS9C8hhN`;v`A1DOhJFvmJ<4%s{pbRhWg({zZ{ub zSa1-a1J*{u!hsV%KR<f)gVM<mP(g+qL;#`te{Vizn4gyiasrdE!2TRZCZ=-;xZRhq z)KRmVP17`h0*sA~rNIjZ!tE;fD;pbKVB?vo?jN#S0awR|1FdmqSl17Jpv3yne|^IG zP>{vx3+hDB?=sT-@t`fGic!l^HGBBtlo=F!swbUMf@zvw<ye{TjKMs2suz{am0fhq z)1Ln(Qh2%?f`arJLDUC*{7%#>z)SggReeg5WQv3lRm4+Ux3x3g;RV20y?;^fI`max zSq`G#dRBm_kgbCQz>>Y@!^J?}ctio}nQwvQ#lPx8K-fk7Yr5Ax-n<A)NFaW&zJMNA zgYB3xbMuAAuWU!%!H_7RYJR_<E{>ovE1@N>%>~76$le@i$MmfH(E2B#YbO8@i4X|n zajJhVd(LOnkmwq|a9r^U`Bb*?XG*!fy>f&$S~<HI#&}zl`=aCds`+E2)9Du?f88-q zSx`NU9E}V)MQYg(e#oxu9L2OU#ShzK(;15PpUbl9Vt<4NswSlO_Cj1}MTC8w*^Lyx zerJvtTnSW-N;foU!v5;6P8S>#QmCj}U_t(dj&9mv*uFQXm&{WZP3V>12@bcz`&@}0 z1{Ss-`<*m!%Qp`B@8Y6zDSzzC%Ca>@oeZ%n=257$!*1gAnK~tub5siF_kZux_rOo9 z2Np9-Ce7e)L2d2)HM*d*J345XwbgV7MRHLIu(gvlrJZip(wvcBin&VA344ibiOGY_ zsU6s^Mc(HAlyUU%3M%_q-9fWo3!OJ7^y6Z^Mky4{A4l6Aau6Ft*_wK7(i7Hu*sRZ^ zrCGi?x<gw<XLuOBqApi9u9P*R$y>W}vO{}uVM+$|*b<;T7P0N0RaS;dbFu%L-^YWW zLWz5(Vb^ivXTNBpvTML4`o_@u^QP;~oaN%blUE_bsPR<_oGGS8S``cT=wF7st#fu( zN+{92@N<vL@#<Y>{KJ@4I@M1O9+1oDZ_fdm(z;43M714^s@&|c<VMi1(LGOJ>jsWj zJ}1WaLs1$1s?7DGe@G-$e_0-f)34((n#pd&`DQXMR3S2PyKArnRZy2)Kuk79T_<tf zuu}o$)sp*#SoEj%I+%@aQDfq3m8jne#W<AtX1*!~uXLA6e)|R4-c24W6=ViQB~Qi( zEsp2M(&~3s>Ktw+bBwmW#TM<YwYUA*Oc~<NB^Qb0>lptdRP9F*OM61d;ZXgz5xNSt zHPQ*?cU(0b-3dhtUX1G7J!>Y23*z`p82A(Bxtjz^t~W0pf-&L!KQ9)+(%<oXB(@O7 z;JH~OLCR^lx*aDA`x1EYqid&b%H+L}94h*DtIh*`iOFJJe=&0wvvLCsk_PR_L_Uq* zqo1QJ_A6~xTg0Md<zzZL@h5&#_%VNXovdiw-@q#**7j#cfiWt)w`ge_oAi_IrX9h< zwNy#g>v2^{@5U~GMCkCRNWv2;D`X5gh!sUHD!I}04G_clUO$WzYF36>l=T0UGgb73 zYxk8~HUAuPp$(B3841)Xv~r@;#Lf$dXR~rheAn3Ef%&z6d}IU&iVK0EofPESE&P&0 z0i$JQ7U;+T-14BZp|%CT{=%j<C$;G1&HgEd`YKe7+}u8wkx;Le05<<_=ycj<ONlv> z)o2B#2#3;lryE4;cWczEVy8c0&&o2Oi=tEFm#0d7_S~MjTu|d{41>{Z`*Yb@<u$?~ z7zLFpqdRn%%}UAMsl~5s^#bpTfAXWRCFBWC%sydZ(c~8AR8Xo~T-EMUAV&?I4C(CA zCf<_ymJw1#k~dvu|LXGc2#^9w#kY2FSUqrhTutq#>!OPn!Wx^}l0B++|F)z@$H&LV z#<0I!1&(I!%tZavGtiZ_DK+kRQYHqs7R_Vj${pb3{(kYZ<jW@mtq<xZPdk^jV9y%a z<s;Ru=0DCheb>r~j1&SnqdU*@bUtHcsV^@a&dK0Ve4{b=^>WxFrZq95gp0DHmq=0V zm277n^u1#i2|dRUM<D(8Cn1RVP~#B&@+#HwknLqkv@=eFDaX0f-<U5x4VNITeAV=~ zRS~sz>K$>$p={UKY2&o*eT=Z10GL0yqhhJ9B`cTrMDP2f%=_Snvw21BWiM$xenr2^ zxYemS$IEWr$66L*LpgjR><j*X)QMJh`lLcdXAduJ<Fv3RD{0x&Kc8AiqpByRKEeO( z@ZF|K%SU&G4fawQn@r+S8jrwStz)lO##H!eeX&`%YHqocaG!b;%a7Re`N&^ucsmiF znzN+hXyC=ntj=vglm|8ZVjA|b`=;BJ3l%xKdS292y~Ddxx0|fNg1MA8R7@ltPbjdD z3dU35#-7%AU9F=FHx%7lt4?5=@rBq1XsDaH*A?w+YgmR#;E5ybBF5KHQ&_m{)iq!P zj}*)fmKOOb5bFK_Dg;op;INwIeK-)LRoJlG14Y(HJ1r2aedD?m6dTsBm6MaReqe2E zZ2bMx2NxPEE2~QclxbY|Ex<^b1IaTIsaXKrr2^o{K#S<(!<hjPeIUd1XE*G|9ZCSv z+rKpm=vah_K*Rk30L1<0AzX8YD4&mHH?aYMz5BP&o|8lO5MkitrOs6ry6FGJ0zj)m zKsXPrZK0B)1fTvnHPz(C#%o$mr`o`|=3A#G@%JX%o{0)3*)ZI9@x@$QS&;%?-OUEq z1Q;N2g@N~j($!#F_QM&Ro9M;m<qs}E-*8|dq#fuK-5-vCjD>QNfG_|7sOZ0E)=3i+ z6J;7u5Hus$|M@fEbS2t<Q|dK+MOt_T2@^|2Rg;Fb)A+2!qYK8~?z%wua2dmf8oDXG zV*4@_3q1Nf*WY;y!VH#wi;b=AYlMm}4Qrh%T~0HFqSQXgJf*m}47S_MZvhb8Ra}QE zx4?Y%98`r>?uQ67alyfWiR%8uP8v|w(ObT0A?e?{R`1T_wY7mW{vH8jSj4%7gs4AT zfi@T^(GEu;w$k&m(M?>%7vANi{ja(Fx>GG?xX@z&%UFlf1yfA$tKVDJqJ3{ed*v2N zTS230xi%L>|Bt>skGZCBl+;u85JOl$ijFIPX!8-Z+&V+f?E`p|L^Hw8q9@;fJ4;Ea z85Mm0gexoCN6ebbHq5TbF!K>sFd7eYhp=IHq(gF1%R(qrURiI8KbX;0-CFwsSvJUo zCx2b-o7K5+Al(j4QA9Yu7l;MaV2M=Moc#O@<d#p&-v3P5`XqYQ2I9kbp9wvz($C|$ zx!737tC>5lCsm3yk;+%SglrXzo~tK(recP4ng;5bUyIt0fy+Z13R73zDJD@roiM_C z;beaiU8sOQNIF~o(bQ%-HokK6#2wLxHH)hd&aV^|55gHa7&T$EQMAB>*YNFda+eIc zP8V(FdlMpd7tU#x8WC&y?rxU$;0*UY{Cc4>G{xwz$l$I4S7wwJV`rl{Ydl?kcxCzQ z|62BFe9nm&9+-X$CcJKlT2FjPnQn+%Q;uY|gpO6Xhh+-4I-%zCdc?59#p&NVs2m4P z%twg)zDvk7Jtirz(q_GHYU>QE!D7$9OP57m6y+R0GQD;t9+!+_&7)(ajBzc09QeJp zSDlNRPD9A*?f#}8ZU#AT-cGxU;wo#t+d{i9j$!YcIGc-TAs6H-<kROg*qltEhg24o zIz4#RmI{fESFSDCg*Nn@Oz1chdn?w_oMwxCxv}VRUH)5{d6VbA1z5LA!|_?{bMjhL zy#fm<dQot`$=-zI{y3#=p*EtM{7s31reE7d!@Pqgn#Xu5Huul?i>8;`G%b(vndSuA z8ZpOJQkqJm(tl!9=yj!RuQ?&v;n4{x-Xo6tJ>L)?DQBXE=W%n$(N%rhr1fKDW?$9U z6?DKZ1GZ-fUkZ=`tEW;t#_oP9lv-MZl&|`9*HGL>ML#V1d*?P(1dm<Oi1=BJGwXwt zKbbp{)9Sz3;zQ}u-83{^Sy9XfOWXom>k+?xAYa1&!42l;iq9=QA>$tScu`~Gx&~cQ z9(uqMgk#e8F}x<`vVw)MXt7n*br3>7eG>cn75bc|7!et^EhFh}sHz~o-$JBFw+QJT z#tUcVe};qmO1DYtwK7|O{j*p5N}nbQ6%?aHlwMjTQ_6oj7jtE<7J>>c_^r@}`4^P< znD<e<aoTLQDJ&4<keEJ}QrLPL!X%T*$O6<?EwrX-#?@3_RXI2-!B3l%BFzgumDH`2 zqkhCjzBOyxd;^?<FQk8?TL=ipL>do5r6x{)yna6w)@o3gQ3zFA?aRTmny{vHeATx^ zSe!pzJ(O1(>d;E*)=qD6S{=%6xD)kikicyE4T?CygHRo{xl*Wi$T-Bikxgp>C+o2` zk52?NRwbH>@7EuQ?dE1Ymyp#WY9$QTRxf;2F!q)-@1Nf=zWj;fRd?SA+e1#S(t#JG zOeXFhABv9jN^VhU5kJ;!aDyY8_fOiV8;{<`;^JgEiJpU(=E4csb@yW^AJGz{Zm-&v z^P<TFjsz+jb3C+4v^#o*SWZFt@O64OXIc3)oKwSbs#r)ri7|J9y;YABUY(E~xj_eS z6bVr-ipeNy^w@j@#q85CX%WZUg?1`Ttrbs|JwhH*OHmE`?jzhL)7PR~+CH5p1n(4I z4eow8q;WRf%=3ndN#iqg6_>%dtow)9R6*}_x#f=5^OSyBL%a!pD>gg>v$~d*WVygb z73)EhJ;IoT45&vD+ojHqqsaTz$U0gekAnAN)+08q?x2sdq*v2b4GT|r?-RL<;i}0_ zF#fMyzC4a^u%3z|ojBMZv#lc0m%%~7)d!Vp_on7Qljp)ADBv4%lhfGgheojX+gV(( z=Cr=vtVXa$Y*G0`cV*S7FM+Cro3+@GZSbim#e#g6MC8syOy$RD7D$k!+sPzu;0LOh z&hyiicE&{IqSvO1)%oEGF??kI4^O~xNj8e$D}KU{GPCeOiSvb{Xc6bR&u1;h)Dk!A z!)IJCXqXERL)31|df~2G&ER=vRiY#+HR>(;e`JHHn1QI<(8s`BOdNiUd<r*pjEMh- zpMv*dBC5GpgO63Ey*DAI{Hut_Vf3!kaaeTkWVsbQ#~{7QgyL|LJ~JN9)L^3uM~0uO zwwW#??5fGj1A#!Cue)7cVaIGz$BOVv7u|)1%4woFhG>LoG(eI?K}i4#p@okTxB)h@ zy|NMu<=q|#eF^}=Y+nLbI6~Ra@vXwE&s#d3)2EmGO+UshT9h5>U^;s_t-+;S1p!rt z1$Ifmd5AVlN=gC*>5Vj?3IS%#*787f6ezU1uf`caIkxKvBLU(ES4(7fpHhi0|9IZE zELdks#0k0J)Oz2CBHm%Gk?>P%{hW5pLonv@-VGzIhY+AYMo8WI7j~HmFD+${>7hiZ z07EdNC_#rwxzSKILE(P3e1|UYD==_>1(RdDH>6l~`wtN2XG_)cAJ3nncr8&OMf9(d zaN5v;(S7|ZNcZ<IRg(?HL_j!;jfjxf*MEIK>VGM?*RKU3Aps4haS-|jACF~#T<`g> zt$G5#1#1`0S+BnVooX`R`)qCk&t;(J&D*yC&55&)2#l$jxjBlRG(*p6vz+<gc+0?z zb=7orHE<;cdN?2uaxLU91t|aBgF^?%4!j2U04~p)``domZ3ki;K$8{WSSgRr@Cw#~ zXmsy52?Ne0<{I#rz;||Oxwt9-@f_#NY|!`6AYD0!ZTVMvU_U(5!}~EiGm{GZ!dm)d zV;H$*T4;|-Mk1-Rk2f)!5@F!A&6^i?NPG28yriH8>(Kv;Up#&&sH>~X%qxKQ4ow7` zbV<>g0T~77-5-U29uDxy)$;51EhzrFU8(Aa`}>XAk*Ui16o)!rImLaoz~aZi^LuGx zfJ>!)`7#x#tRfuiJw!=?5Hm8|*S6h%ztkfT$rWCEdH<kHpq}mCczSBz7q;-?@={4b z;prFB?mRl00dBL1-YKt#<!!SD^k}FGe)N>$M)|(aoDK-@>tf^n)eW0;1GlcR4$zzZ z!Ng~c&0yD=@4)&iqZnH*CPn>ip>0%I*XEF8Z9U)2Gv4C%U^*piTAZir!ob)AIov15 z5BvN3>-ISe_>ek4hp5|q0NUQWKTn@N{g#+0f#5>5S2;@7@iyM8C}Yo4Ls7A<gkeeG zqAz^ytx;)CnS%cQh&9UB*<Zk1ns8u)7f(#=x?tMr5(2#2p{svGPc>z+iiW(rKIam; z+5mlVj{a2`N@jo9;eCuv^TEHWFKBKCs+?$`r?n838;ty*#4#lB1P@mI<X_v-#2{dq z2ioS3Q_plMWO0_7W$M77t_czc*R27g6nsv7<m2lLq-lWY^6cYB0%zFZxj=;J-B~M( z1oL%PkYIgU3t{M+HZ0JlpMduXB!r^GfP%o2FE{><^71$vfgD7kQd#K@oIv_P+=b@S zHog(s*!ZXJgT8+Hs2Sw|Uok^)>EE!W9W@qM;9+jK20h@#^o*;;%<=8b70}3F!VAs< zJs3P!HR+4L(*w)xugIj0Z|1-$ge1&=tzG}&*Hpmn+t}FnYnQk|L=gOSeU{XD`8yE0 zi3k5Vekmy_Nf4WN^||DA9lBQGz=E5*4XP&i);sLL4{0)q-1b4>TXx6P^g{kxUbp$g z#KfStNjm(BX>tW^|M2sp`{wp(I7@o2)EEo6bQ|l^N<NtXGo$!oDgE+k2${g`K-&9% zg?RL@5GYjA04=>NTCOH}U*!3EfU^Mq@_i5zYOdL<2TN@(99&#n59_}C8+-=%Y_@@` z{C6a9c>?6<3E7Rd=NeK#;WX}}c`jeUr02fV&&bz->Ck<P1=^wH;F4!;-dOyf!p}(G ze)hS)Riis#VsdM!sv?4t13c?l&75iZeV&iN-vaL9|7!@ty(t27ZEci%d`bU)1*9mz z2bWZ!EC*COZ}wZTzz~US^ZD~<M@Q-bw1L-@V7dZg1MQ%7|Lc9m0MeFa$33%_;tA$u zE<r%h_<sDVd-wkRdnlCg>uz8<sPy}mB}5J$1i{X44&c}1vX4W#W?$albm05_bFjxT zfRd#~gplx@hI?Kw0eJ;L2!D@Ion<<lne@iyXIfg54$JkfV}t8Tl=2hMtMLD;r%IrK zMZ^N2?%OGVPyJxsZ^R7JQvwE8V0?gzEudGD4+I0`FX&G!Nb(i!Ea>4Ke@g`Jy?&9H zl$7GSTQxsFe=lebsL790)#Es!YJicOPJ)NW3Ff{J>r@O4DZgqV=(E_HAOHRWn(zON zaf~A2q;;TxAt536AlPPKw8yClPC$K+kC!*#=G!^wW1v3YjsI8FXJCj0<o_E$iF^Yz zC;>NCTS-X-p}JPk8;S3D;C&gZLFdztk6hS6cY0XoUCQoJ<fz;an&HCJFPN(v8}}9b zEi)6029bG=R%~&5C5GZ9rGG5vZnboD8bb(&eT5!8SZ`kKjf+bL$z$Pd<D+pX$9nem zF<YBv2W;yY>oK9#)kN3%HMEz~<=KJ5b+>LzOa=Y@j#1CR;uWT{sw!xxF5gN@%C@(m z4+XU!&dmK1rQoV?TIpuSMGgN=!y4{ijj&#QdpRy6R1awV^bhMMGQ?xb)pi1LVt^UG zxIps$%4hS+`yCfhGTU8Tv|DU-{FU%Kd$4CEylbU_+VQU;Y$QQLCBi4-*1{02_@7un z=(}(h7Y^r3Q=nvhI;z~^kB$-+9E^z`iT!0abo6;{-$T>XA}RgF|Btb+42!C5yG9g6 zQb1ZjT1n|<kZwe}1?i5Vh7u5v?rte*=?0~{85*QJXQ-jRjraY0@B19b_v70?=9t-e z&ED6(Vx4QP6A?;`>>9wVsBrahjn4wH?JGLo5EAlW$bPmx77CQ%?2OpN1V2Y=tt5Ie zM*p5HsDoIjaXu~feICEQ#<ch$kj~(t-myCvVWfLsNnG9EE#sJ((UWk5zamydYNRjT zoXlpO!jM^V%(tCfHQS{A)W~Rk;sa8UHKUT~5^#;dABj9a+UJPmz(^E1`m^fCmelx= zc1izot78uzICIwQW9+YOr~Tgnzv}h<SwDQnrJE6-Rvw^=L#X8(T$bF)<QnNS?t6O* zt_d;=UxcObGmnQ3S7d-1y<rz!WKR9ovH9E0`2sg`6(pe}ggYe~<ryQx6G_{TKV*Uo zY#SS26*IChv?8X>kk?GhGbr%*Zi9yywe#fYoLyXsOUH<(OHAflH$9b4Y(N~))gzvA z5M&9wN2^sEv4GQ9tYud9O2s9*8+?mCU&PoytZo3qNE;atwyV~!!<lb<a##=;C<(MN zz`}Y1`v)|sm;CUd4F%owZseY6&0bgE=k^w8wx<Ko^PWKPRG_W+a!{7p<Je7o8g})e z;Ke4T-Ci$a2L9^nxcjxpuANBlG;Kxe=!u^_W|#zv*W;w0&&}+FPlD7KT?t+N*Wddv zIBXo|be+3(72P>@Y+_zBqKbjF`mDHLwIYy8;(KtH=NwDg&wJ_;3T*XZz0|Cw^0IfH zTi0@?w<dQf9kb_cc`R1T{V2r=2ZK$O8EjS;*TE4rU^7>GLDGw+Ast~w#W@j;dpXhZ zV4c<kO%(@y{gVR>)P;gb(gwU*Jq0(X+s(z`;3mB3wzE#^5y6U9wK;QUP^00kAFbWt zU}H>x7Gs31R@<l9`#ekSTEo7j+fBfd{$N;a)B7G6s5vxjJ5K72wdt`6u2Aa(xiNt7 z#P&pwbAMa~xPMR(c6-q_nq;xwn+@Kb#$Ic>aO*B%9eGZ#@X`#3nn!vYeR8^IS2^jM zERntMjn4Ly#oXf?&0H4bQ230C=;_47emTCkvBf3cTiiR$z#S^u9gc>Nx0XCP$*LlB zOO?ka=0&WhtPb%erVbAyeswu71*^cW=zN#IU*%3?udDl}eBWAIoo$bYiAB<KzUZC# z!W!F-7Kw`aLW%S;scpA*MmmIHexDCK`7`@&aEcGI3Ifmk$Zi$%u#u}s9+}uOORqzH ziWM3Rw`rM9nPhb#lw!?LKe<wHW!D)YBjVv7cOgtzg~cP|)UC@0EdTg@Cn)CtEi8e> zhuvo>=lFG=--IrGMpQUK-v5~@=v9qNfRrT(Z$)=dJ-#~U+nCidgTZe`mHmSd%b$i7 zMrYofwA{+yK&NU$a!Jj6_OC{k0aAMA&(8-Xz7ZaM^DU>PX9BmbCB%xT)TpS{<clpJ z+k>0z8z||$<A6Yuf<tiTaQMr44SpZ+Z9-g*yMMV>$>P@XiO9GjJ`7oEj1c>%UsJnQ zF{0$Kd2XLVTsmAmXTm%rq-q!+FSA#<<LO+JNbNPE*$?MQcj<D)CvzAqIkA2{J$LeV zaO8|WDRea~2k|?yM=jSY`9gKwzTz`xMgfJ2Cui{uj?eX8kE$nUiz3aci_6ikr92=L zPgz-p$c6a5OWG1)-QV#nQhoA|ACzDqbE>ez>R=BAg|)SHSVS$Y!qO7?69~;v11n6h zpgfc?y3U1yIYao}B*7~a3Te$`Ycw)~q?GT|s!4&uO`mV=W6sC=nXh~!djd@9>Gu`A zgm(gnZdWiFIpi@woa|rMX^(^vgHpvpBWC!C@mIzs#r}gqJa)`jVR5)#{ll(8nRAGb zauDYrhKblE2u~y<S{!1hO&T)YlUay|0@)qe7l2Q0+9+MV^f`xCj?6kl_FxVw>Q~%P zhLoZQCcIjT5?80!3nqGFHD<=RjL>8ahkphsA;6P}GpoRtcKWB@&|hz1Qqeg&++KWE zQ=3}CT~e;B*Y$olGSorvw|AGJVk35Q{EKz8CdU+9Ahrl&dM}ANDC_L*7jAm|i{}^^ zFDtGDKqHzJpRd51`-fba30K!4$e%Mvc3~TVXGfM#L0pXDunmFCg5i9JNa_2Dz^{v2 zx6d$*Bk5Wg^NQuEh;`JxwF&r~W46UViaKuZa#@MOrdh95DfkB?r}mc{1dgxH_9XG` z8f+Dd)2*-f^A0@icm$kbyE@_d#%cux>Q98v2CIEF+DQzzOS;&krVETM0;T45+gAys zGGrka+doP<%CYWBN0f&4I4qn!b1(a4AJfwwW=xu{O@r>AQ?>7o(3LTrqhr#z8|>$@ zW;pu-O_@w;Ei~Z?(4gGirgO9z+=nSSC?bMs&Cn#W!3{YSKHe^{2$bxugJ$6_9*Az3 z*^h7QJIv~c7vE@)wv`Z+k);`Vee<6@bR#bX2v(m-LI@~?)C4U)z8?Bh>C;aTD2|Ky z2&ta!pRjx#Fl-RE_=u6tnR1FpUx|kY0&;EGPjWUiu4Z{FwG$K+5L|bapu+JLDXKZx zxa_>mb4O!tO-KYRrVjD_1Juv%;`dx93^t+PSwX_I5bpzs4zYCgeag`F#8h-P7+n|^ z3mrx?23t7GxojpzH#j)>Ue6lB%rH%{y-d);bedNwQ>>CgLgC1cJWm`#T}y}tUsCAT z(m3~So;??9*mAYgjxFF$W(vY8q|P*G>rRlxFojwpE`LBKe(h;Y=lRrRrq>_jBtLd9 zp=0JspTsXH$VFe{F3*q=`%(X^pH?TPs#|DHY4_XmcBzchvvK<O_1<0yDK_bg$JX&& zJhL)}>o>V)9RayFZH(Mr-l{?tB#A$r{VYaoy*mdWcgM?BUIg?)VwsHW{VHut?mqhF zgJbKfNgK6W$wXCcgQHam9M`e1K*1^-lyEsZNwj4xr|q=O!c!w${`)Hw2P31Y!q6el z?-~X?TXZ`g5yN#ZrVd;5m{##D6`QMgR-2i4+#`l(T?xN<Nf^}$Qq4tN6x3<q=x5Yh z=KOdTP2HGj=bdo4>yi+^<6eny1WHN1qtHn>db0$++Wgk?lF5)0bW@#`-072m6*red zn%-04HW!ZDYfEqKXnT0`lDQ4gPPx!RRio3LWANF*7nKp-&TmG{PGBGB*&6}Pr_6fD zhkKD_neRD4<5Lb*xe?B{RG-B&fnKL)h{BxDsaliMwrIIx{g9tY3rN@=#^Sh0)bsR# z3kS?sx^Hg1*4BC^cG?5R!@~A8^ksrWNN``$U}96ed3pLA?3%yI+QvkH75-M(DIv-8 zRzB#$jF?(#Dn7o#JJ!=$f<Z8I^n;S5Qep|Oif;7Tp^lQLwSiKO>63Wp?5{X7k0ojw zX~@g5BTaN}Uz^=XDugZEFHv|Wz9QjwL)=_i_%d(EasJ{@NzrhRH8{ec4tE!1Io-Yb zQ-9~15yrf-(}C(KDgm$QIsJ+eSxOC+ZJFZW9G$3rIl>W@kMF**x%ZeZCHqJ4n>k?y zyfq3A3j)KDqcY<gsq0xrhp*p1fcD0WIQEq4k?%#KZRiql9D|n<{1;r7TZMhaybe6} zcPM(3LKtnF6-7rxJY3&FOSqq>W-?O61)?!)5rXf!O5O}l2oG3o!(}?PTifcJdF?JL zIWlo{Tyj|O0+MbdhtWKpZYeU(D2RyonC}XAK4zRD_;?nmYsc5y+?@Z6xz>4-)Y~|u zUG`TolH9npclx$(di&jbT~y)DSM{K}J45D|k9PsD!0hRUcE&A-&tKnHWl$tH-c96) zZSsJa1p}pEujaKevK>YOFU|?4wazS^)}1nK$(dVMU`q0{JnkpetLv&qot*bx#5$Q= zP;f)~^i~b_{JEoofLb>-Vt<m;1-?t>L{ZUp!>RRlhi;@t!ClL28$GV?`c(X+I(dkd zKzSS98<(5YHSrZ+D%6W^=;Ez_B>c%?X163bdarwT&tq*Fn23XU3nvqS8UyEj{1jd= zu@<QW<biMRi{<O<TSg#4sHmQf=N1AH5CABQOHV1fAeOc^k%t$(iAufOcsumtH^yfH z=foB{WwLosTox8;w6q$CjcBp2v-~W*ZKVS+Qs&CU)X~*VOT3-v*<0psGa5?aWpzAq zAl6~}Xok*f?ICCVTV*~y8z*?GT_04yH~F&?PA56@%FLn9PSv&M_xQnl3?U?sFWvBx z6TOOiOO45jR**H~z};4eh&vLMXe3*ybng6X^_dt^{S}2n%J=5xoBKm=Gm{g*|9o`E zX0tlekIgqWUjl?nBnnT(<#}~JFE}+;6OMzx2!FM-;4`|b!n8VvOqZ?ngJL#a-8ux; zIQNL<T9pm&k~iG9KV=IwsGGjCWVsoj%Af142O~dD>d=~vuR`{67u+csO0{s)XudDy z(wY_wx5>OGwbcJ+<85k<FF&187)*K4B3!QJcR~So^DFhwRl}>Nqof@XC#O6Y{t~pE zL#&?Q+<#|&a|^c!8kQ&F?W_pR$e#G_uJbYCIdjTra5U8+CGrJ6LsBD`>bpl^)SAY$ zhbN^ABq__9Xm+1KM`z6tPH*zMUIH+_6wa}?y!9YoTiSzBt(Dv@b%S8~l9Z!qn%w*Z zOj43w^U4r~s$4-8hq+`~?n!kp-i{exTpl-O9y)2&dBG0Vnvrd-P3>iyfv3zW0SK1_ z?+xwjo-2Wdc5tFl+#l?8VW&3@Qq}_9<oyhFSeUEHlNRpQf7b47AZu*jeSMq7wobv= zSE1pBsmpUm_k)euzRkA<`%d5PZr+BwefV))hl%5iV$~<d2vv$j#GdX8s&ftnQSKHu z?GWP1wo0>Bbbh;nRyys_Ez9_NXNw6rYq=w|KKh(nkN11+uM*}<Kz7GR5{6=)o@y#s z$}$9Jth?^Qz?r}ZjGUNo-ApLn{zv`3{K?^<)n-yjc5aDuhx9W<-Ng&H*2ogKGV2W; z(iqR$QeyYDXMY4N5q}o<T<wT=n`|4ISJ=}*DfLxu+p9IvtI!<96Y5L|m1EyEMXHQB zjn^mNi2@J0=If=cyMZmKIb?CGlfqjA0-eVVb#8am^9vv`7?%<E>{B_auC0%utk?4+ zOi=NZGQBdTPxI4Tn8!C#^n%d|QAw{7IeAo_R^=4_tmbxUbn8_b)0QdS*k#yC!&VFp z|MWVvD+qwLl;&PBm>$QI|H0w}k_&V3#r{hQ`_)33c#$(Pn2cAgeY-QU1th<+Gp805 z1ouWo0RTjO<I<v?b3Fx9&@gN*qY(n($+-V1^EeiCU%48srPN95U9lhB5oYkoIIEo5 zZRJW)o6X?<kdQG^Q$7VP)(0M<e4v3q6%+d!oC)L~sIbg1J4xO1shdy{XE-w+g-xq( zEO3B(a5o8#Zmef<6W)axEZl6ZHTNcSd*BndL7+qGUp@bW1(-GQl+2eFj)vxFuqK3b zRcU}uc7|*AgbRwF-)yz<T4Y7NT;9(JE8Np46;!pbEBD!6?mW8K1L<&FTJF5)AdJQf z9>CgDaBd#GBwX`!hZaA=!@>6Omgrh`ZEG;5Zvl-^K8F>cpt)6Tj!gShm}rIQtMrAX zG`W8(@Y!DNGRdT4x$es?enUNO!D!S;^;X8m?$EpJTj50N)tb6g&^e${o8!9^T=R)t zLh#yONj=X#0QI$x_Dm0r$X&K!W?q$?x2;gW>=CM>X4^Olwj!4`78g`k%B|AZJ-eZ< zRZcD=hUyA>I<)_U$Af0Af-c3j$`@Daj*j*ckBqOpk&R5R#OQ=#>n~H3>*j^;n=5NC zj!5~eX0}MVK@Pn=pm(F7QSL#!p#&0=a`dFjY#W129w55|#0WZ8hwF;%o}$2k>B%(S zHswNx`59VZ8b4&4@jJF#A;#MlE$wB<U0XVm-g+Y!$vZAi&KUjfU~)sN|E7~5ju<En zCp?aB<B>$3Nh>T85nfRGUd!cN{k9e+BjFE6S_tWKyqYY8@$sRU4eLh9dUdp~8yf!p z{hJ+mF=FaF2Tnzjotzr9Wci@ZQ%dxOq-z_o)zw{m>Ns7gLfR5v4sB8;7LA2uSrW^E zp-=wmYABF3c&%zg*n3E~S=i%yrZ*y<Jn!=Mh#q%4uobcU46DavJa;TN^+RFA^we4O zfLV0Ym%o&PFA?oq7sNsk-t=z=@0KJ|IB&0)-fWxaO{5EVB6@KbXEN9_1lOM%>S z3rz*Pt%L5TrLpwcR#@h)MTJCUN6I&Nv~*?$+)VrDr-Smg3}=Um@ZHnj&+Y+=4Zr*8 zazyGeV&Bko*t0b^w;|VNt@{9;bX%zM3>A!rM=xADt-&wruJ68a1V0{6G`M5kA^V2_ zvw$6e1y(7DC@wXz7#A<dk(vOI#_!*sUhGY~{`n=6B8jtI)afwd#hv?x@mANt{03U^ z6#HdP$6D#}rC7cK{u3AVX@+i)V=ib%Ie{tp_H-baKrVc3_jn~F;ctlmkna?#(*y(_ zzO_j8!gm~=G-4y(-=FmKMKIN8XB*B{2T7Oz1Lyu}b#yif#Kz9Svq?-OJ%xpGaEy4K z&l+N5zc)AkBrT29@)Gq0w=<t0ka!xNMTA@z3_6aMdHj5V16!Er{W4Dk#eMwDvLv~W zr{$i`31VM{PKo?_N&}Q8pdm63Oj(tLuLO`6drGuof!t@=A1y3A{`|_iybO~0_xf2A zmk7v&1$-N)(|c)((Qtwi8~eidF`1zX5<@D|ua!ujG)Dy7J+_bC?f46j95YG;TOyK2 zK0|PGe->R)Q87mn6CD$iYSgRO*sCWzbg63Vg$)oNkfp+j)d!OQ0KJ(=;_G<C_*<4? zmvML~Va?SQEiv(I5H<xs07k<~z$6+qU^!wX@Bp|`rR1LPs00P$x<{+bl;}otgb?QY zzK@rah7z}iQWdkB-1jS_BFP5U?RE1N$>bnFlJ?Od&zlk0nRCk_IPxO7%_p-j_A!7z zgEPEuX~2+eN}ZY9u{eYJ50G=~-@pBrdAPax+w=eYxq}nE-65Kb$*(j*zDO${>@!fz z>UqOAZCvvDV)pAcN*R<1XUkWmC#ClmpFQkYC{3DGMSU`tnKu2mknLq`e0+Y2oNock z;!zvd1|{8le~<mlrb@q;nR}yVQ?Br+9w1fB0L-f&CU6|FF7~bI2l{RD1CAamOS`@_ zd#0=?;g0DWK@*SmP#wW!cXzE|5&`azT1LiViy74qS%{s2SbY4zsHiVrknXdxyg8j6 zom<Qb!WsDY2BGe5pVIz#^y{j*uBhg<HRE}|5q;LD=z2)XwCb4N=QRnEJ{AA)q~S=` zsmKd4bu1)prvmP7@O7*AaBaU{*{+C;Q5^rUoGVy{PO|WMxx8O3JW+P>cAaeIvDa$O zNv^*I8q=Y^sVmM7ko)v~kV<jQ`qIu?w?mo|5Uj`|JcWZJLr6sF=W4ZQ+eOt3SGs`I zjsQw~gdW|i!jj0>P8Ymx+h&L8)%AM3fVv<Mzfr<p5F;|O3i>kQ)KvaR#jLO-E6B}b zM_GUQ&UV$iESIOyELF9Ee>}(iY~xG?47Ff63MX+Q+cEm*T?NYtlH(B&$kX?zh3BUQ zDgarjmpx<E)l3|}Dmc1>ATo(GKpjIO`zO9CqV#85hLF@sfHJ-7qlzjxdQ-u`$19E7 z)3ce(5HH;p%%56gwLDvG!3X>sfC}}`HQ=0{t@BZolwe0)wc;91>;4f|AkU`{d-9-7 zXljPrYF=8JUnTn@kT-Pykmc{?Tv7k2+Sv5*vfG@d@-!CZ^}%!sBnl0op#y!fTbm;W zusJIqI^(~T{Cd2<;TR-HbZr;#2Y`TiY^a{aPM%sZF5#e?m*%pEJFX^Bp6wGz*IOWJ z2i$1D*~55$<_!>VmJ|?fi;RqPwXh(5D04mDgAp3A?-UTd+Q@ocn3-uO-ucV0<K^x3 zHNI)cLx|{Cy2#&H)@ZTUY2R{Ls){tQ66x@7pvT*r7Y)!iJS?NJf8z@3`G3RF4=bDu zqwM}}0zT>)|0(pt?>}V#G**vCbv1CG#Q!-<u2_rZZcHpwpPYxf@~C5g!W~lmOpO2e z+RDtMhj49Pl5>$CtVk|DfjDv8#m5caoP6_CeQIn-2iti0u+ioap$@jVZ`$m@fFA}a z{vzQ-LD^zWF0E_w<Fh(~c2>*PwFrVyN)E@<Ti({xh5Oy=)Oi&e%{L9DDHz}vx~VO& zZpv+;tL8&BoY275tapr`FzCJr{5rlIe~mMcb8k<QVivqUViShWpMj_ymcgN<C=|Hr zQ_77!&OIl){5j$B@RzhVVN$L(v$1{sFMV}wT*}m6R)KHFc|oBJTq^jyPU9kuTbiHs z_v~P<ZqvSXyjY!;fuUP!hD?v@BL|Bnm1^n9IWB_+o^<<hFwi+J^-oRs1=MK*v5@t3 zHs>de&%~fhoo62CUkz7$b=R(R@t<cTMI9UKE(?J8homKk(qx<G^psx^aX67Ir%L_t z{#k@&J!7eL1MTOvLl6q5SDVZy|A^P=fSecrK^eH@%Gh1w1PP`bQMBFHw3ity(^cX` z>*b${6f?{5gMuP_G^wB#Z9*{xlRPg1LGRlA)ux{Xc*GIvE`GCF{J^83Eb^jUUqjVH zL4+AjcKg%Q_{H17-s?kp|9F<Sr6X!2@|_Z#9~idO($&=~I=eu+4A8=Ok{-Qp(%FdS z)@=8Ih?Z4%@yLWAIlT~e#|TDdfVgHEu;PgF7Dz1|W9f5ssrgnZ{bI_ML<p#(+p?N^ zyHJz)@Cj&3?28qfR72htkAd^t$v@7&OB2`)`qfQMNt=VZ#WBzF+Y$#;keQ=VuIIh^ zYb;68K2F4u<S&0<6)SdDv#?0+BJlpCDjz04iYVjasoxulH5P<eDQZI=Sdcm2+xK0? z<%_~52SZE5$u(WMbSw4w(^Is5n9)QlD?e@#C>1A$@QclzpY1tOLEEZ^qq&mMuu7L3 z_Xd7WDLp|)wdx%n9E4U<xvR>H8+fdDS}GRg5vHIx5__CY_3t{eM{Zb_Hy7F5y8z_n ztY6--MIx-vNxvo9n;AJv)s@a#Wz-(tZt~Dpm7-bPSNQev8P<aK&W=paT|vTc)i<|W zs8+wzLj4B8dA`cxT>)n;BsxC>VGN;BY{r?jEw3ddBrefW!B|eiX2J6?^G4%)e|<hQ zqk93_OGut95joi#^R0=3g7<`c+X1?CtUmAtO<~IBZ}bH=9(!YMD3R~TD@GJO3@hC_ zC!9db1f}nii}A;FTm=z7+t8^G?-vZrtmill6{~};<Le%EgVy71sxrcHAlSzQ=z4i$ zkKP+?_A40LdGWh08v82JC!L<Al`37Mcb%H?2*d58(8DA0o7q3lTVVqf?{CNiE!>pC zy;H5YcIy)=(?V}6e5|rO&bIRl@~GN=DrB-OG`^vs5`+iXI86)f{(eWkW{^~|mZMeP zc@gOa={CEESYFfy(Mes8c-ttBSKZa|_X64{d!J;vkLV~4uuTDP1?43i*fL^ST&Htl zu!ukIr}-74qrZ8u#VWyWrrc;JvTPA9rQzFxp2lakwP_=@BQG|G*kC<StrV5p9M<az zVLp$?Bo4NkK2*ge@4Iz7Fo5RjTRKk%?vpOnP}<CA5?U*8oLuEk+B{Fi#;@uGBYYA8 zYtyM;w0zHSv3U<ulN`@kbZTys0_4!J=Ff+|6wz%HnW{k)_10snbQrY0{+__Sca3G% zW8b1_f$yhuFp#&gcumj6dpOB|74rFX;Kv*k+K{_{T$V1|1A1C}Il4)!6KPqmJ2Yk^ zr;m6yKbREP1hp2DHCTfBb&zsX^mgpk#U{hX%e+)@pNweVN76_&PM$9gmLNR$=1_{c zkK_jg<KT&)o2xw9RP5Xg;q${eH63;gxYsGHbY55^xwPoBkHPhLOwBy@Mo}e}wbsv# zAAwxPUX^D@J*wMOt_Bq-2DbAV<gM0KYuK`7H~7{*g+o@gGzY_zDRbV4s0>tm`B8K( z<MkMcF)BmJQmP-^)QNc6e>f~hwzK9Y44M=X=%9<+7SeNK&HSiEZnH7tzF=xj*h*4_ zXZp_(k(;2qXxlH6imZaUAMSW43rA^ONbY}HjRk?o2hZHbYaIyhgMuQBt2D}P>BN*; z85{vCad<C9WnBJyYJc6x7c#tKrkX<SdCfq;5@pOg%IaSKm%5inuIG8I|4Rx>_HQ)Y zb7ye+01}S-%b=jUE4@2+JXX)Ew9*sDofF)~lEX_Ln8A=)$C`^Bx=_pc#I#RQq`vT0 z&Fko>F+gSgg~h+dJggHW=~6}gdf|`O0JlW<6Z9RPqf#qZa^@t?g7iFaL*eXAuvNlP z@XMdkH=*d~Gqv7#Gp(HE0WyqhH_wUbXPfu!O&tcSH_7cw^7bWNz1}+c<bRsmHGy?J zp^?n@v6w%cz?tBPhyKVb&Wyl`C5m>rB0#95wZt`Eyh(2GUX)Lf>B6M3axEI1Xdc^p z!v969xDZCd0JHT7RdnZ_dQ}CPm|#O@XHH_8Z|1R?j5R5o8x2U!D6p_dlfO@7b_er` zZ5}@Eu8&W}-7bcFOtL?yWksLiQb`ofsB+#?{v9=_I+ivS`(b9hK{NC&@F?h@B{ksL zL=H3?2DMA4;V`k*i!#=~*+fVoNIuq;-8PS!V>VSXh?R)5TP)^({p9;LsLi;zGAW$O zK69!tg3d;o`9q1$Osx*%t$gLNB=e%6LU$7w+~u4pyeYGXB2*Ug{E?wfTf={_0H9}T zAcp8S+eNi;E$H1J;3#_5)BKSB{?pQxD2d(;+D;XE@EY6dtt!tqeDd#h+c@f>+BT^c z9?$-`%X_=0d~gL|m;gvZwY!o%j*MQZW$Bzgp~ZOIo|_oH?G(M^US=qPjEqkO3#ftL zx>+>t_vL9wrBmtvJt=2Td_R4Ue8eVUifE4eE;f77Al>)1fV-Vi^ME#yH|H-1(yJB| zYk)q_M5oWPtX6%~($YpjMJW8&aXKMDS|TWmqNEEAAeU|%GkZCr^UIiALAR|%(`wYJ zhNf(h0X>0B^P6jZ?>S%ZH)BVfX=$Ih#El>QaZT!X?&UT(p|wejSrh{rftp6P;A!<x z<&k*#m>C}%^yWb686SfZb|NjqpE+TgFNo-H((*3~hgc>vSasX}{J5Q%{=ptJ#)=Fm zeW~9Gw(rhvvZj=dYP3ef$u_p7I%454>O%KMG`xX}ShrdI5ahlW#GsKo#NY(Mc3o?n ztxn8Taagu?6_+Tt#meO5Mk)x~pn<xKRlhMkeBd2?S|a^cA(&aX+WFQHe}E*Ed1Y_O zpx^4+R-I5jX%oaba2(*t>Z60)HyW-vG~KLpdKvS9)%TV=MU6-+u4vI!9dQIcAIHDJ zURL0LRS((xwb!9rj`Tcp`mi7}nOj;3pvv&Sik#l?(^|X|t$D}E->6=~qMG4C25PwP z4U%a79wx|KFxS7r246<;y4X5)>$EI2+q1rxFVR>kbr*~{17~aF)S90*#9#N%+osh( z@m%#`J-lM1XZ*#q7xY5O%#o&oU}EBa%X=V)Tn@1>LZGy4LrlaVB~AIhzvDET&L_q} zQ0xeo^K-jZmDRF-t!rr@h9^Yv<6q99B$01sm`*}IQ!2><dv&c$e5{nQSW0>F2*pf7 z5(kUJ(1dRopVhov@U)F@3qSeCfpWTY*-*n~(E=7Z2!Ba>2vRrWv83zJjQ1dqoqs9E z^caQ1d3lHFyx_dumrYXQ>_jHprTnw8gUNWL1-Yfl53MKWDg7^@r$(k+e0+2=U!zvW zH7uMA=%FqpD!m(X5gfHQPCu$5kB8og%1o}MLY0o@Ax$N8JXJ}kGk$}DVrd}#-t<tM zQ6Ac%omaQ2$)JuWiaJ#uDW#Fe{X??)O-H7iX_NCy+~Xa?U9n%4LV|-sVeB?Dm8OZt zJ(WZbd#S|p6f;ZaupNK4BqVS2;+g^4of2&?UHTMUR<^e_mf`1|(cMx_1mvvKdvMtn zIq{O-^Fx-HP6pi&cKCGqk8bVK`@;@9=w0at1UzR%qRvaqj7zq33iMicSM*yb=l8Z5 z)r?|`cN+vnU8j6FXAKQ%yD$=!(X6-cH`f}}#MeoM=9ayU`%^q<%K0IaRoJm%N0W5z zIE;dB{0h-!zBnkIASN94Z~l(}GYp(drLYsmnWl|j7U>5J#RGiRCbxmPoTUx!k7`SP zy^cc(-DbOzOE$=W&Vd|>DLZx#g0`I-EM~QJw1PfR8DjJs&0Cr(*p_vlr)s|-Vqx3h zH^p7t5zEZ06|PBp4S(u71nCnaR)b`F<=TtApQAnM{ZxL3>)ZMvukI{tBBQT*krqcf zuwPo=)Yr?|>3pokKws1l35+30z{92K<90oa(IYc7w53v4zVP!ls-Foh;sq)D$c#Zu zBeDrw4r)9C3tqd9Zwd6~{R*t_<{6Bd*TtR`!F8|S8E^XC9}2~pWZma~Twb(FYdtJ= zRxV0!`pET!pU?}GM)UD-ZuBN!eAz~IC+Op;CJFn(%_2tQ^Y=4rj09MMEnXeIelGW( z&9y*MUUtKFCH3w2nIc^in-5G|#k%NLmFonK8L7Gr7lJ}=TJbmCCs~9>uAcpr)-8u_ zyYo^QC7R==w~!&%LQ+3d8!x`Y{d~FU#eC2mjP$)jDEDys=e_3q`#*L&#Bhb~qM=du zunUc?qMdAI8yuXjl;LzJ=W%;b+j&aQGr0>2rtg%hORg!(BkcX<jy47)j<HIcwxRR4 zCe?~Lio$M+Fl%_@lXD_=LtvQC{nb#3-X@0DX8E*9>01=rp3HG|z+SB)I>AfhIlGI9 zG!ocV0;ZBN<Z58m8^pojF>=lvlDMf9S0b%@_f>qAKK6+$5uRZH4oqEsuGXo;z=Z?Z zCpZx_4YlY5+3ujNm8FyFvnSV>5cJjODG%B#a0z{_cAFi{71Bv~l_eE<<?EC%jFx~( zD@U<hYjrCZWKs5Vn-*sVi_N2lS+@va#~;Xf$ZMDOF<P=c<@Swe2VSlgvE#mkTnX%C z6j7*oe7H_CWU&PGQKY=5!*E{u{4}}A^Nz$JZEpJR`T1)5rK^Magxs;^V)p34F%1@@ zyKz1lkJyA)@3zAvsWPj)zwd6JKStEYSG609&bGsA=9gPuRyD9HQcov;HFSzuirHVt zxC~O#%>Jsx<Sg1%Ked+`c4FGAusoF2^ye&Gr={ez;Q*ATPmayTjzC=ZerJMOO413y zHvdOI7`Vi1ZwG`5=>8E9#&{T?#);+SnT}IGzi#_!<LBdeI_ID6nNQ;5<J~{D^Q6&# zax&b(fdaR_jWdN}aC0I%djeGi`wXU`NbW`3`pEXlVS2x#dU_7=^)(%`P**l~x}@NE z<`$F@3n}ADndg&v4d+ZSoZZ5JKNxX=f8j*iA|o^phxy2wGVN3xX}Gs>lbi`ZmaLCG zWCB%<PmjFDZsWOUP%Bz<cJ^97)mmo(n9x~__Ba99+HmXrmvm+uuDsZQ;ljfL1`IBc z8j&M-rNte6^c$3teTAq0*)qBa01Vq`C0p3}cj5o6LITSXu@U|I{XfF{zd!34x2c|y z4Np&}{<~FV1Tf9v*L833{ht;8*z-L6w%7Ov<$3u2OYa{=!NYeXSCqdX&fo6~`2SEc z9)A3P9)R^|`%^}SsN>O3=WvB5z;XNueVRSa&Xo2?Nf=DN<OM|wb8>filiDQGb`s0j zdi5>E>^T)cPY>qGhvd|Hv@si$*}nG9l>>@IR#rJKewbeI`GzS0GVZkvevnTFi_14g zMm@>^&JAH}%j%BN_0LcF)iTBUB&ZMa@+zu31ebY-Yyt7Y_t{RsjX$Z}$Q~=Hj2+%9 zMGiXpaWVHRem#alXxh<Irqe5|lVeIfz`S7F6xu?d&=?QUd@nZ!YB<aysDP3$ii;$v zh!A~$@kNISj&ysqSbUcUpQ&G=Lw?4Gg0-7|6eM&taxgK{S70!5S>;j+!t4GELOtBA zFHL}l#FvP!4M(CtfHeJgfv^B~lWd?bm{;JU+tKKsmD@tVa{x0Xl$MuvX?^}@T^2pS zx*n|nqQ`u%(l&|zJ(~UD=;G|`iq_WuJW;qVFk<SEp#ek>|13g;542_N(ujY5|E&XA zpZNp?n(F`RDuDH0GSDC4iS$?k3@&r?zqlQ+^5H<BxbHBAk^rRmVfpva3ML($0bt_r zu>5<Zql3eyz3zYS21I3`(>yQ!*M4AL7D85z|E$1hyUFeVxXrQpVLctM|I*<BNUQ^3 zYHn+)Gd{g~y6wxEQj(G)JHD|4CSYadAtW?=0L}1-F%p4fFV#3N*SpqMwwQm}8M7DO z-qJ@m`TIQF1=sE#GjYHef1J$o<^+_$dhg%V6a}n@i_<H<TB@K9;1<3)9{Pgx_R*K+ z-NUwJs)q~evIO;fMg>Urx@Es6oL=u>)$3*lNqze(2S(ZsURYp%h3R_nT504pGEzFA z{}bscDlV?m9+LnxG7k%c9i#uBZ@{N33jTweh)_a#IqRc`wcBnF55@2a72qoJQC}Zm zKm!Z^L!N7UB41QML-?)Bh@PJQ?;{)i{rBl(^#t^+;R5?-P3)wkr2M_LBG3L-g55pR z66k|wh8WOfKAh>|Az2b2$3y>nSpY5g&vSjKbbkX(U&!TXaB&{?{SpD#2LiZh&-mWi z--EN(9>C45Eqb~ZqQrr0yuSqoirjGAL<n&2lOrCe*JofVK&<fo&C9=!{r{eWJz4(W zbF45qKAinwvDf%ptkKWz9v%;2jsLkMV4C&s?%DsU*nzDJ_^N*qe=ix<?lp%8(E^f) zS(G%*|0-O8dSa~ppk*l#hI-gIYFYFDJ`GTGxF*1v2cj+i?BM^G_PbpHXpjEaJtX9N zDAmLA-_UJV=qoTU2t+X+%7#%_fpe>Z1Zf47_v2@P^OK~&co(n0+6!PxwD6c&4AW@v z{TD^xPX1LV>~sNV2d)9sA27fi2MbN)D-;(O@A+MCuB`mta0Kr6FCjm+XOH;FA}El- zOd1*)T_45%DFV_%6EM26uy~bAh!@&|5%{m(`$Ai8g!h5!!2hthsF*pI5C|WY{21lo z1b6+`LdqI2ssS|IG~~l<u^xjWbxn*_c|EU+WoK&*b&5i0FSL7XfL5Y0xjUkB#eU&{ z9}$6yw15V*M%(tv%Kkn#Y#c1MG>^WJhpFWObe6#kV!GF^p=x<#(ibW9pH_ut)QiLO zX=urMmGSjC&Dc*Bo;%=n>qa@grguAU=Oq?9ERC24Ix8@1;(>G!F_+~epc=8rQOXo1 z<KN8s@MazGUO=+N0O})|ZGW6Y+a08=5|?ZPcuh*#4~nbdU=!a2K!mOO^l^Ij<lL^S zrp<fpe&3G52&-w1;<&d#*txm*M4CNnjV#e!%zA9}Y3Asv+WtjZB33Sgt|cQRWwqso zy$el3dABi?RSYoAd@Y<d@a+Dvw=!&V3><uzC@W#iZlr2aZe9OnDO)xfGSgZ&G^J9) zO@DT5*yv+gReNw->#=vHQ+rgQE|Dl`|2prs;BU=u<V!r;o33lM<;-w$D7-k{Rzf-q z0OTMl1&-Yj;dCwY53KmB&7h9J0Dyg1|7=07s|)nx>G11OlpDk{cH1z+=O?%gO3tSV zu6M<P#YJb&pX0n)*2<>nF!~P`P-}ZHkx(e?dI>uMhbVV=X-~nE;sKMD3wL2TuvX?h zM;<>Xch(<&=S=PI-2v|PFBVyktS=|`ORrDAsFQ2A*nQr+x+cl#{yOerWqmeV{!#Tb z33~BCL!)OyyUMk>8PcXqa9DnQ2)o}@_YgWZPxCme-Pns^KE?61M)cgfhG9fLcUJMb zg%%&tPzV$|8HUjd-(M0&vVHcOrC8Rw>h|HnOt?8}lUKYujnfJ9qDUeBIKMP!e~Ms! z1|^fztugV@ZY?WoTyOH~Gi&*5^fdSqc<F#Q4${akFmAZJx2FYl*=`>i8k%i*yhjfR z>Vo{SM38v=eJDp)`uoAT(>a+gdr+Y|=RK*lZV4$VY)YijwXaeVeG$*;hBHVc+1rYW z*OYWC_=i&^-x)8&gv)(zZzgscF4Ktp=x7!sp|^9<T{MoJd$MJt%cIu)v#2^w*LHr6 zoO+Ko@_FJ(4Nrp=G)rD)Shb?NeL<Alefs<}vdm>?AzST|MJ*fkV1R`zx1~&bJCPlG zzudJbZVRCebcN}6rv~ok*XO-Iyt~z8uH$lWX};3NIF#Gy5;48oLRF__P%{nSrj=@i z4^83GhG{j}TqT4~FO)O19F!hd^Er~Iv!WU_I-q!M&rM=l#j2?Jpq^0z-IYKxI@ZxZ zT<1vesjUZTvBF|&53fUCI-IvI&a>&rYy?G){!b1{mJ`q<{<^Jl-0+LRJzKtSZORoF z14K(}9(^<s>{PxYpOg=0D=n|8ikEnGKUM!MWv78A<(Sa0v(CqaF`4dm{@iP|s3K6y zR_BJX1Q{7I1FG-DnL5g-w=|yo1J>^xX*D+88EHz?O|m_@k7*rQ=j8)eIL^7y!@|Uk ztngB_`^tpgzL_5`J+QfAUu18~Y(EKelQ9mv`aUKL^cHnSHT{E3@fC~Y6Az9UmqfGa zp2rpZCJFhGQsu%9Tq4szH}mCs7u&~ey*(gi1O#G=mjz@+09S_^=&?_Tb<&Ik;^%5V zuQ2L+aLE56*w?GcJ5C9xM<yBn$wL(Ljn?@qD+LdmY`ZS#zLAUQ=(rHQW9Q=RWzKv2 zHhV)4_c)>1N)t&YtG$9>ql=$YJ(zwn4JFk3ZF)4E57(coX%jS+g^GF9)mE95-BwiZ z3(FC)1l~PG+SVIHN!|gh3M3=~2g>+T+DXvjv>LRjo>SmEE0h?kkL3^{JNZ^d#REPP z9>h-L>`u)3D>+7f<wCDI5`b_x;qea&r|Z2+?RgC9=r`ZLx6@!0Gk>Uv`Lo&71{tcD zDABNYQ?1`8&v1F1)dSdQ*tcO4y*Tb?9C;JJb#VmR-R%z}Vso4P@eS*M4GZZ)CiTZR zuA(q`(6xrTKl?^fKb4hNvB-B+IuAg4p3_WhBfFYV{R3aFg{ZrPYo6O!HJ^ZF&!~*a z!~d$z_ay$oJ|>(AGo!jeOFKa+68qUeaz`>sU@)$o5mWCZBxvC3;H$9b(WmDg^&(ag zxbNl`21PkYry%$94K=sSThL^54)icQ6WqpU0VUJo&jWKF1KuQn6NtV-Kkl2C3vpge zPXMuUlx_p1(|oZ`Ws-fsSG5CysDz&mT5%%rF>!7V=iMlSt<1sw{H(#c?F5I2Z>(#$ zl5~bn7q5mFFE_b@J~D(l7JHJzt<-)Q_qNP7#w^$r<Hr9!ar)j{kz4atWxA9+PgZeZ zEB@zLt=4>YHnRoWo;pXe0Jmo@W+cT{eCytn*Xh@AD+*j(r24nO=r)~N(DQYANGR*w zu^6-<)0@v$7p$pi@8<UA(U-&E=uu-6rPU(Lxh-<r<hM~uX>Z)U2Ao1^^ecZ{Ca=Xc zxzLhDl_U}GUmER`aoH7GPA^Ss*zlNZ$=rlUCx1<5S3K_C8+l86Ys_w!JRLv5?7=vf z>2p9XuiH|Xe&zRPR&F)+ABP)ScGl;arPO}M?$0?~58OYiv9CB@Rr1N@kL!J`(bHhJ zTza(;(W%x$<SS2^=&oh!Zsnm<yY7ghPvdo6+$YH~@MJ`bT~-u$lM;U><k2vi$Su`k zDwwlK3*|RL1Oxx$m(l*S=yAENv@*$eZ3+>?mvY=?5hu`mYt2@TsN>C$H@@5^19R1( z1mg^!*a^~0j)~`%VT~RwD~)rZHd2(j?1~j;BNd`fn!Gt``4%P)$~WzguhCvUCry-e z$K%%hX(AUgj`)BS5rMP-q&fI9{E}!UQqc!70qFiXK)3L{XB0PkzRv=&UJ-E`OQR0+ zJR2QCZBJ}%^C`@^y-Q-`@HZ>Fb~7Ddcfc#x3yKyW(jwz3I4k+dXXh<gkPakExjQ(} zo*A678*a8WotZRA&DZCU;)V@jm+fmFr@6kedB5&A-R+J}WVE-M*mu#GKPvOuYD;|+ z!Cb}Zwkm5xP?q{l7qB6{FdPba>bo%3CI8|T4_6Y=c#0kF@s;)5QfEs5L(c3E_WmBd ziRWo2ju<;V8yuc?9<eKq#DONM9$xdwQ)ZLoAyPtRO%tSLR@oPw@nKe6>VGsC&^g#x z(iY>@&DD=NA}#z2#3568#nnaa2j!b{0?j(A+N=S|FI<s<J_;Dpr6m$Bh1fCkLr~KJ z<2$AFejzW!O@@%ccv#=ah*Wmevu6XSNFoJ*Ut8M=B!tG>g+^vMaWJqP^`iAa2N*<H z6Orr_=>&;t5RJS1x1klMW6xkwti;VD><~L2>oylD%9p)864AM;c^_kJu|lygOm{EC zG-mdlNzMATYs0V{k?I~4uxVqO3V+V+8(v9`i_a6X#BHv>yaV`Q3~IB#eBVECl~wHR zeUOptm*4&7SoUO<OitGNL|g+;$6uMo;`N5<l(Tee$fxxsuE2$nkXm9!1$5i<*DY<t z9ud5niBjcto_%wTS)!ar8>8%KH&67(q6aN}9qTC(_)|@T1kJXqYZR-4&^%edhZlmD zaj$odfkb{4qb}87;{!ZM+2iL5*@-(WC$I{llR!d#-=6;=c$mK^M~GV>;JgW?10$@% zBdmaOtnDq6Dkk}>)uSUkqBrpa*Y1$-{1{05DI&P!JX|+*i*wCkEmFWB0<PHZfKFI+ zz7KJ3P`088nPqGjs_C(Y5&yrOC<=~ZT^$A6HGfpYQ~$Wq7gr0QWgjHTsM1uKMY`{% zI`&KjKZjK})wV$fjYchH+JfbJaFFWp3xEL~bQ&G~zH0;8Yh3Z<k3Qf4EH6C+U-Iyw zwPzjm`N{d2fvH(jRh+)2t}aMqw7|y)lss7ObKlyV3d#N#Iod`B^ONmBfQcXq@_Rx- zr95D^_+Y+(G&-SIOHBq`quZJrZ@&JO{wu|2P$kp=QF83hxyTC`Kz_`QlPdsW!T|)A zfBqDcZ7?tv%gJ))<t0W?j4&`W=P1<#1^qN=YrRFt<bLD_oOSCB0J~80T*bzuDzlLY z60Rb2Z^}czWMDc1Oh|ytC8H}$2_(p59TOAjia0oEJyW}uRkF@9rO*t%SRg4oIV)o^ zDmF#$8r$8SFyO2U|HyPsg*3``c~i#QJ$ka%12|8}VK@Fey94T6)|S01AqKhN7sZHT zK!VI^{hcjVQsrbqsjX;gV`?h@=g&_X8v4tvMg|6U0Q^GwDgqstO!QAoz#3bR|ITd4 z06K<9<i`F!Q|xS@nPqZErE?<74vSt<Rw6JRI2@-HvH`4tF<ub?h2nHPJ;^SNMGa6y z5|T7A$phRt{tm{sKLI!x1SfFi{OCxk0LtOaPYp<b&`Q<IRX5dCi7)~gQaJmJy@Uk6 z8dcES%+s^p$OF-Gq%Q*P8;%0tH@{X_SK)B*T#~R?qcjZ_S$7wT+C5SoV9>>%CcQ3` zg$_)(wsSx9>TCkQVL~FQ`M5YMtKRt7WH8+ac#-t_#@^n+9^>ixdDd^d+ktVJ*W3S0 z+U7cHfXADgyS^3)$`1#Omp=is!QlsJEluA*CQHY`#pQ5*np<$f8Fs)HJBGs@A7^gf z@#zOpQwW32m)0dZ=z_)94_UHK@ciii-mEYB4-ne|i&W`^Gl!LxRthD5{;nnL3MRqn zJib?~nQ(S~ULB-B9uZhq*OZtTl$a=>sDx#|Twh<G<-`cQG@qC$oOlaSL<%0n`L114 z@zJtgJK#mn;`lt7SRw18+(fhCbuFeE00*iiQ&Wui8Jw?}k#TrlEQ*Z{w0jq-Vq%Y5 z?H>b$`Q`QZ{#0>bWn4%KlCN!Ti51lHsHv-yVoaUO>6A_YYFRjg(533f@7acU3h9DE z0oCtW4{UnWD1-bj80)i(iqQENWsq$&nFvEeu+Rc*pCJ9ozzq%ZaKm_wlwNAGe*jFX z0TcxdG?UfA#l8B6Pq!n3xJIQ!yp2FrJ!RXNQ(aAMmLxCF<h;0+vwvCxa3kDbF+@+@ z241jV(L#K4Pat(6A@ZTL)AIsff7NuZ3S)GzV<>t)M!JX@(;S`YrTXMomSwUZ@Yh)_ z9Syj%V0`$suh$PcY*9o_%an2D=wxA0zkCN>3cp^n+s<IJXHVL$B?o3I0Fp%Z@Sf6L zPo@bqG*Nr+;){Rxy+sH~|Iy~++WEL6jj;0tUN`|37TwUi2qf?$BLC-Edk-YCMgI@I z1uAVW-|2OB@WPY#C6oYYi+}`#PoSS$h-Wh@Cv_wKna-mjR)pysFgC$QyB^J40Mn)W z54(=~^UdPaljfhox(E!-qd<`W(xx#nE`bG1pVP5G#YkE6fY<8zX(i^8__ykV((}tc zW9$s;lZlN1E(SGX8(!1#%iw{HnGQOgFB|5(vYAhi=U+ka3tIdSivdR);|GB#Q_Rzl z<V|8u?C}@(zOA)I%*BWSW&!NvK%qA8%~4WS2d&UHr3F16tFBz%Y39*p+il+aRVLH^ zkPoLi=gilW6(wlzt4sDnncBLX)@H)e3yq;`IY-LsT?9T29(8YRyYRAIwZEGR`G5z! z4{z|XQK+~cf3d{^Bv_4Dz%XvKV|I;xUS<y&`DR`ZphBFB((BBuZ`-@>#6)y|BvC*~ zw_s1chC5XCC?QLsZXB}MKT(Q!nr<#Gft019$ryQeTUuhVhgZOpIWQiV*i08)GE>1T zY2G)NnTAi&rs=i|Tcp3m4Gh&OFW@`5Qa-G{>Tu6rZEog=oz2%6&rf@WBsxfQn4Hz@ zEwQNF4WHd>;PTu{1OuuKQQvQdBkOquG4)y(44<gfy&Uu_HlEfM-Z#PG?r#bY=&VY# z(i5fRnJ9s#C@a<KdD6RH9+85MNUCjn_!bXfRABBcUeN00Wn2BF%W;+*G}}&>B9bhh zaOx=3lIt{Rihis8xngp3x<O>Lhn(*$TlwWv(cbg_U;!<;HvB!&B4bY<ZDX`}pn7_* z!{0(DowjC2wI;;MWxkbka}*XD<U<e=vK?ej@*A@j1tIRfIg$Y(Hp%E9@OWoE{Km-T zxI`<$nYn9=k(g2^&ABY;Qapl$dsLspWC|ZJAJ=&t)b#{cL2e@sv$$`V(eU~b1yY;3 zk`$tppKl>AdY@Fk;_IsUC0D>_RZ|@WkBDl#ZZ3<Cl~Krm6^+=;1GR*X@oc%!T`Uj_ zHyrJ>Kw~7czrV;BP*R7&TPi`$h~`)W0xQk2EQcJw{YXS(xURct<?!atRd&u(?6WN% zP<2Td7-4;hP2y}K0RWnSL(1P*hAiR^iymo_ICD8j(c7CxJ_`uvkQv7IiRZl0L3ZAW zVF9(pJ^cvAYAGqKKzmaAojzDxyzQx8Uk0KY-kzhOr+31Lc_>5@9JAD4bY(HnuQWha zpop6;Eh~+s*J7#=rUA3D?z$>jtz^$Ev34F6`7af#ARsADl{&2iDQI5B+H2p?j1}g7 z&2iY%7`hmkZ`r(Ms*QXT$KncK8yT@(s(3vPIbSrfBd5)d_6B+Bt7}P0+AgxyH5m0; zz8xNzPG-@mIvu_yiMU-$rQCxmwpH3SR!6Ga_F;PpvTzFU83Oto8CJpZkA@cg^DW*N zW0l1n^c=o;8s)8X{z{36CBL&Su`MOyn7ghmsi}PB+8z}^M@1_zAVw`lJhZZSy%t+5 zX;pa|a)$3$;_3AUbiH_{Q|U!TEi`l;drwy9X(jqbA+e+jWTTZyL9$y2s<OM!RTEJE zx?R59n=3LAd^R9F6@&%*%ysR0h~?>h+qiSnyl7X(w=3hsb9?9Na+ulpd5<Pc5d|sz zhY1GV!@TRknC90a&{E~$VMf|kBi(kdNoNgi4#1y*T5>5>B?5kr*~^EAE1Z+OA>khD z8zL7y5fVZ<r|h$N{!;YKB_>1yCANnBc2Pmeb4l=N?3Aj$q$HjZAI#lxDax$2l!%26 zk2>I}gb%Dp$kEskAwxt$NLZgq#_a>OH(YuvDrjb-FTfZzmN&&bn`~hEU3uX7M7>!I zZXmjh?wi?a(~Clx;_DafWQd!&p~y?zAtgrKh>Ynf@2$xE=Vo^E+|I8fh1xuBV{%H* zL$>fam=8yl2cmikgsJ$QOU|&WB&7b;0XM$IJ!mt9-GQAKnBE@sw+<;S2;K)qLftma zGwczoQ?O^~rTzh3R<3hf?Adr4cBHR(qLje&fc$!Opm{8mJ{&`SB=v4GH`|Ds!@_$! zv?DNt7!Px}HrI3no6K^)<5!nVt(1Zpi<S@PUSZT|EveyT7Y+ChhQ8X_S)@XkdL`~; zbdk7z(z%s^Y79K9n4hdIOcAzU0cbTQ!Aa{O*!R~Uia)KYs;ZzMN|8q^zpQL2nzKeR zy{LEy=+3!;fggWcYwPc^gNPAVr(8usR+98|pDeAo4{xj+Xpl7E5omPY+qsur4nsn1 z^0R%x2IDV!KI-b~r`37PCXak$cK(<cEDBd2xAkF>q``bSONIL&XU*G%Lso$_lu8Oj z_aX+4)tYw1;%*t)z8UB4x{bup{*-=(Z_DO5BdwZ0RO4iALmO7*9VDPlc0wl=UO`-z zk{|kTcWgF#p!_Yt->8HRn)Zl^cKfyuB1L+qfG(qJdCH_wR)ILVbKG4clOlW65xgaE zw5gT^-{{(Z2enKcXgd87@LdEHQ@|FhY{T7-PR7T0Jap#c2KD*$X}$WgAYX2_<fgfD zwHe<Ed)6g!iqIe<x8lV^U*itMA4Z)YJPNjP>vbdQ>Yk<BR?2&MwNU#ye*FNi{P6=w z?~p1_S2ZioTB=?=W}bT$`)fFwXRJ6P(0`2~GRWx7_FFey<?TO7y+FQ(*vzDyS~rPb zZdhKHQ?>NDt~c8Mhp?}XYOCwsq)Mq#q);eQoZ=Ms7MJ1@oEEp>5FA>(P%J=@5{d+h zyIXO0EAH+RBtSB^@B4kfnKf(Hn*0k3?!CF^?7g4!h{dC}KPNO@Jo#Y8J<+Gt^K`ZC z<8!b9YlZCY{pEKVzjgAb=%0e^BU<%bV=^7NXGa|>K1!w8Pp67lO>EqMJLKYa8arQD zZNaKEUv`z#jm&p@x~?}wsa=+RxuZCML8N?kPlz3=uAF|PEa-D8w!$st;c<nTza8vd zS=U>+%=jG>UM)g=suv8TZPZ*i5DEAf0TL1y{+Mnk;QRG^*@4WW-6m;u??zF7Zgq;) zyi}VkM*T^$V6H}9LXyiJ&p2fV)+U49{Il4;b^NVCyHV%zoYSWfXXA)8))cyDYo*EK zaT@p{%!3!_t@J}GvvpTy<zMf5bT+U&v$y==C&VKd(Y**G6Vx*XJncz`vWQYb55Zu= zH{Z|7$$HgNf`cE2aoYGLv%Ncr`3)##2I{+yFMWOW$qgVxfR41hyl?tFDWT@t_T`s! zxNxiWs9HI9f^1joIab(2K2q4~7x=ofK*FkHV`BvVG5MyzSfO8~iS6)5@FpcA$ANu0 z-q=-U{g@9R<#9LFk4{5sw5G3Q>rj1J3qKP!iTD$4{@LgN6f)>(xkUb4?KzeMZsq-t zfO!_+-uLqFk(;Lv-5vkyZoHTC-bsKDmCOjivlLyYUkWg`N?Mi3t1|^Xg`G7^eztkw zGdHKZ!SPhE+eNj}J=)%U>8vBXPgGI=94ZG=mChQ|YOhd&1*TKAwgd9;0&Sk+dDqP$ z_Uo@jefZKZI2@7cZ9!|Mv+M1dE5sK2O8O`GG-o8FOfTm*n(uW&M!#L5AkEsf?Z%@T zi8q;`h`L)^7-g+H`i#}6;73|N0qQc$_V%)1rKNo3d9luwT3t7T!)O+R@rfJ#0a@L7 zy2?TmQei>A!41XToW-Mj(_utQj=YSM(@CQP3pDV;A4$PcLyLN`+E&zX$00FZT7Tsk zyER0JENZa!x|TXy$$leBk9_~|5P9?M8wAo^Zn|lvm?Xu$Q#x=tg$s1(Km%$JGUoch z{{Lr(C>K(yTUbc&D}lFBE`OqGP36pbm-N@<_sMltZYVYRYHJW?b7y9}croC95fV^= z*-WyWA8j?hq(4v36~TUZA{X+>RWMv-ucI6YAfw`$aogZ=5eqVWpV?EQy(2EZf$u+< zkd##GvaL3AnTELn)JqHmSX;vXMBTq$nFdZ87ri+X%=D&21V{x_i-55RNk+;sGCYmP z3wZtIfZt5KgSNGFjm?wt+Gek|3%-48b@uZhoLv+-uFfTQm?`FbcDfS7yn%ZV8ZiKL z6AI$*l0>~?kM=f&CM1l!J8`%1v3}o9yzAy;O2iT*Z)&**6i4G%owElU;Uo6Gte@4N z2<9MO?VPOlMc&D((JwOLaFSW0B?<@Z9<B5&fm=~{?%D_UZ=CLqSx!o_AS1_qh)eN; zQVf13k9K@A35{$$^<z9PZ<*fCDMM0)L;Antv_HYeTJm)!Q>6MkWc-`|a)Be#x%C;+ zM`vSWL#`S};{>=5#>dBdHa1mMjB}Q+YsvI$7!~6^uJyS#TsfWBRpbtF+8ygseB<Ek zy6+l92cUh2Cnw(qHu@?=TzB~Y&VJ-yHifwDMpf<t2fX+Ge&PQZ=)vLq^-r<pNI*bx z^CW{fWc>a5+6}-o(0UU+Hr!)dG-AnQ*=C&k`PD&3xb93*u|41uG+}tNW_|c=b0;)y z4=2thDk8(!gr==cVr*3D87cQtjEppJ9PAJOWfpz-p9%1<Cu6TXFzau<0*Vw=&XjUZ z=tepmU=9dU=k&pBSQ2Ius&v8DR%51lbl);QLuJ=mfYZ!5chE}ChWCy<i&;Ib!7PFD z$7|%2GnApr(__BQ`YC|23ISOBza_vQKGe}MJ#X+nM~`hgG=t5^El{PB+ug}j89*bp zK9*0rK}8T5&zRE=cVv_IV3GHG6&d$dH#T-;b9H0i*4<k>*Kg7XysYgjdB;a{vzw}_ zS|*FYzj6z!Cr71eQmw6DlLV_<6@V=*tu3>Ra#OZo)-!e$24I(p?R!?%)Do>}N(!46 z=H`O+{Ixaw0PCWbUr1m^N$JeW3jX$m!<MwZLFzsfn!+5?<-A;5HyoUwAD+L@lzO<b z(YbtD3jkt2`+)iK-<!dz**VW-%+p-Za7GB{Nf#G$C$SmH<y!CIX|e+5-C3%GY<6se z!WI@F@x)GgC5c7_pL^9!aCJR}Ohk4c?Dfsf9P5T4oEg^TKtiGD_KG4(ft@5+`E^l| zJ}#~xbakV1n7l+vikqV>y%evpL`Ifvs^7}mx{N{Pd@n3m<?O3rr9o-s>(}Iq-N2pe z<j0$9yK32_4vN1nh%Ypm?u!fti|QS&_b@Z@J`QrBe1!`s8ctocOL0FCFRB-!sW9^N zd{>>84PZNShy<>ozs!q&?Js?4zWl^ZooURis!A6JsjC{QtE%c58fL!DAF;NP*W{s~ zDB@<oe+*s~@$fhjX`BlZ2YvY+Z}|9uuwYu-_UQK0?}(4ty5+T#zE*|26I)Ey^51Me znv>>F3}2mHH-<lnk=6I{cE>jYl}6<kN2NsJ`ZO^50JflB#fY!DO-*nB&i%&(-JyDC z=e5E&#SS|9?=!mk#@S(N1_n@B+81GSjg50s8BHxM;xVD@L{ETqEenypSZr(i4zy&Q zmJJ1CNo4B2O>#L?Bu_W8v!#;(t=1Etj=A6OKYaS>l_q+L4=gZ<+11H9;H4B9@s^X5 zQ$XO#2!kTN<FEAf@UPMe5@GgQLEbh@?ek|v$I>8r`7+-O-`n;(cgm<F9stqSj4pJI z@h}jjU{Ii}?odH)Zl(ux#PTiE<Si;6oq_uL{x{Ck7v1Z2ckbMvZ@0NR0qTvvQ0b?Z zy9hNM0|Q0dOh#VelWrQI3<CC&TMkW4)BuNsX3K?#L7CK7#HvC&`A2H)ZI<YlJGY(f zp4V5)+~De^jn4R<hHXL$Hs}74?E$9vFG@K|yDSOeWJ;@{%L{EX?iaB6Nvyx3JUl!+ z8kxwOjTC!JJ$k5OnrJGJ;PVe<t3h8GpdysFw(h9sJWEQ7hp|Jc0?Zs&rGtazAI*`M zc5ChMcx(H0O^mAfoRc6i=;B`M!Z5OB0o%mQrIrO?4J>8DJ$K$B)N&`RbCgnNs_g-5 z)W0+w0IqsAVrjKVETxpC{Qf6~K=~;Y<%PHP>h?N(i`!Mcrsb1>^Tx`TwG8ER!a+DW zhj9(yeCaQAJiVzS-DWmw^XNV&y?W9Jqt{_0rQG2Q&X1%*x`2J&E1-`A%+G%18H-(I zspu{6`^@~>4JP>d90*jP6mol$+S`{ehVkpd91pVNhh^Ivbq)+vDpLy93b0*y&q`X% zuzR?e_a)`s!SXEUvgqXn3(9*E6N{sK$~L*TdD@N9rQ>Gze}=b!0>AA8Y?Oa91^=cB z?UH<-Y;H{AvQk@{tG&f>!8bCxXl6-K)6f7exl1O(;q=71&bzh>z^{#S3x9DCzSLne zsxoZSm&l}x--+!|Tix4R3r*9NQ3c^MddACO+BW$X<=Fw+jB*9*_A5ooCOMDDK3A$? zg7q~^%+a4BWzin@lOD^v!|{I`W~`cp#l=CNm1D<uB%N40-XA`Q+Guq(AFaTnAYBXI zatVc*9_otUSzVnZRnKCTq)jTN)s+YsyU4sGHP!WOT<<Fb{+OR@K;pl4+unN3zFb$u z8a7~zPfqh?@^sy&k%f7dlyLOaINlSzeB*Pzw=7H{3jg_}r4OJcEs=U%7#_vTMzgt+ z0|Xu}b4koCcs-mwaM6@AnP8-63_2GHczek7uRVenRe+Dx%@Eu2{H{E|GOmXO?VTzb z8$|$d`abzt(L=RJg*sutJ?b!a;r0W|24)!-@4jb6;AR(kxzxbx0)8`=rqX`j;L+bz zaAv~?lpoQ+gE?g}Aub#9xRlqh_frmUuGUH(iPl0av1nhMr9-v7_3l*zp2tR@ds6HG zN9lX#PFdMEt<!A;Yl1a7Z>7ta4Y?G~eLG=_p%FJ%vqu1bQk{v%%1X*+N=f`mEsB_k zo<xY({W>yjqyGaQfeV#8t9VGJ48X*CU;R#y*YrB_keKb&>hhP+hSUAaW)szzJ5SZ6 z1aV<~$7h72+q^LQ?VlF7aLYAQ*Z$mJ9Q+xK3=C?jpDQ3+`Gv*BkalHODYdC}^+Gl< z-pjT(sQdnIHzq0IGej4fBjP=K3Crg>N{U$|<{XV0Pm{*~kCF3lBuZZRVaHW-C2|+9 z?HY3cX%ir`xSP71Vn5__wVpd2B~_1HUSD@L|Lu~q_a!D@)<y3LA2)Z^_<Mo6jfqKJ z^5wY3X1K|x-2Cdt_k93bnq87~B{zpZ;d}d^8rHt(+E?oejWXJaru~Ua<U*rpevA9x z5`TvOegb^mKgsVsl;uSa^^XISlhGBm-Q4ftFFx48=X4H6aS1!+L%;k4;h3dgOMVTf z_ft=Id(=jqfdVRo+MVAx;Wlu3YO&y1z$im+#-U;GHYE=%;;*6bhc+gZ47jFOJ1Fg5 zPJ%Cl?;&SVepwmbxz$&F@AU8hw~)iBE9LZ1oVxqo0FH}QlU~FBAl3Ks*jv7L8gPRA z-2KApa@bcQq*rBZ9M8$AckoB;ul}a3<z-$M%Vv;K>Ov0gLk4T?kku6cy&nU!8G%5D z-RYL}Qb_{?0dgZij8v&>OPonlc{wyCW!4qwe!ZN2IN}0VLTt)xghH4OkjAki*&?a4 zQ-#JTFf_p9Q23n>Y}89coslH63<-rf(_Oh^c_L|OVOwoK(y!epj~-L%(n~kL%{Esi zW_F_n%Lez6pJPeNa3=B>dkW5oFam5Z&jmYH+_~p$u+?R%zn3*s<{DX&i;&GjXzYyA z8F+n(<NBVYZ{Pv;0z8_}w^uiS>4r0y@VCgtxzJ*G$IWz<tN2wc51&b!C`^hyD*9A+ zV`5in(<eohj%<4BY@co$I#RlFjm|HxTMKAjzZ4Vt{H-Dcv*b$1>2t0#`Sk(r3;)63 zzr4bwDvqZNOz)zVDpFy^tgI7bCELTF5j`3<%E=AaJq_1=4L&~eICK6j?+NG{cQRBF z?}qhzdV0b{?aO=@+Jbw~mtEK@#FJ9PSfNVQVi~oW;y~kP?NcO@I=ChKlkAz7vF6yH zKWncF8xRlk=zi2y@%9dg9@T>PoPopJYz=FrAB_72Cv^F8X(_wLW%#4`9Gb9(iyb~G zTxIu;iJO*V;ayWwj+pBKgOej@(ZR~#b*0UOAJRAdY<ny8Ia=)#v+=ih%p&%-vM^p4 zHT{;0JC1k=L6|VGP>1kIsnemE2I_4yeGU;8LwQk@L(EL0VUl;dXo+Uo!OzlD^w{%e zK23WzE@X@)*}^<MttPj(Ok?|eu4_1NO$|-Df=Bh`7$qHs3FfSVG<<w~`byT%g9~YU zIdj%oyj(JBHC-#G;c_~9FC`3&n4~HXiZ>IL%N6bp+t8wKi~3-B$XZg^n;?dP(7cHq z>U^0%{ZGu2OygSh3TrE<)`*4({U<}sezpY~wm{+pNa7|)lSHcMv0?teu&bgs#K0;h zev`7@q<cLfr9cpcr;}H8^0G9Y22rF{SrzKem{nBe)ibfvjvvJOG*5F=>-a0}%H%8y z%jH4NiFLaDUR#UP#YR2RJJcA-I=v(2IP$iXHQ~ZQy3o(%S8JoZ2|~|K2P55QR6o5t zkssyV3=8vh^cE5`otcB>-<HBm$=IjDZucRo#e*sb*GdEw2kepY@*ewhshT+hKb0R1 z@p)Eg9QcBodod@l8ZqIj-7#e@Vm9w+EElG~=bL9Jd7xc!uC3*yqKb`X_8>r%ufL_2 zX;ss;F6m;X2fS}fWf}3GShiBXIJtipL|XiZ{!8J&=BaG8xCu}Bv^DQk^2bX0@_0kR znaP>D@Ev(i=4Vru1=6QHK#48k@GA{fF0QNjDUI_eqz&)!c6;-!rtU>|CK<?z1u+Gh zm`pGdD)RUq9{!|n(%`MX6S!FX+f>~LhU6GL|AX!Ju+jehQJ#E;1;wjaP0-0jCBoUQ zJ&IV%_X36H`zQgi^<(_{3v;!@w-#h|!gr1_SZ%g+^Fy{sU~cR_j@kqSZgNdF6yaUh z%TxNQP}wiYBDc@ti#OhCi3z>~6?5qnk|1jF51ILVnoV^gInXU{S6ZuK=246M%!a3{ z$uuWf)zY$xLG~;{aj%6|%cs72)G|yJO|Z{!vYz+KWE@;h;S}cwz{!eKD2z^)dx)#L zx^~`8vh;BtUw*U1=^40O>JA*Ler~Lk!>*HHr*?*mI>=H|H+$^oa`6OtSUm7(k(+N} zX7D6yz<l;Ri!1k;l(@}qYe7PY$E^=I7IZi-hPeeg96$t#eVqZZeKKZh`Pfyn^=ToS zcx&G!psGy2^6lA8ep9C)xb9_N&o;xmsr+pb%|=K>A4blKS!zq$9Q-a06h^iNG_Wrp zaF#Y&Z`UOeg=^+;fwT;>KYXmr)ygkya!8-U>mBZ!neqJM&p=pnj&!!Sv|QmN1A*A0 zl!POhqZDQ%8b8<X^e#_t4!8GC2OFY0jlXwxW&;DKC#0gK45?+~Q&<^EFFEkcMnvIt zrE4T_>JSH;wIX8Llqbo?N<%V}Grd0L{V-X=$eXv?2dm2?A;Iz#6n6KQ790tg=g}vT zsu{XbRO`PI-ledfQ|Bg6J{eZ9w}*IofMeoj<1fRes&soNUbYQMfQ~P!_tzE5L!kA) zq4Fjj3|!x=O#-+XLj4L#gh8O<PdxI}KApKom^g|da2l`7AXVwz`B=(}jb7przdI8@ zjxmW+7Frf-FGfExp^(U_p&lbR(l~+vhdhhl7{|}4pU`muQZ=CW?tDG5Q><(?=o19L z=uKMD4Tt?rpVKx2qBh&%v#MOB_(3rnYu@u0n+aOj&onE}a>}inQ6-W$tv?2!A~-nl zMRS0Y?}Y4w#t#*i*JPekb*nw6i=h1c_3Qxo-8G%GM*Jnai9>_)ZR|k(Yvr?<b!N$& zSzEUoGF9hj(+R)&!>OmP0ZO?8>xgE9c8jlT&MN_k0lq1w*?M-k@m3xAZ5Q)>Nm#Xc zPgGB>L|O_9j1ttvPKrl0jy^@zkM)JM5OSZ@!esu0r&QF{A4pew*A|9a4Z-_!(xibG z;_qPuz;M5pHk7IU-v8IU-fL3QeL3iaQxf{X%qm#|O3tjOZ1Og<MVy9WR?{W`DL=Qm zJ)Kif5c^QVr32t#BxHptu~lr3{ty`%&S_{!W?Rx8g)>%ilcxSi1|6So7&Yn3WHRgJ zbvTf<B(Qdtt?TF~6OAa4+SejKOGiD&dkjv;SJJp|K%yAB<#OHkw+Z9}qEZ-|@|e`~ zS8_Lsyjl7HcR+ytP4+;Awehs*>04KWVi7$_Da;3af3JNQ0p0X;ay^St*|LWuBmZLR z<Zb~%&}xh9^I-L6enuf;^5-&IF=#?uHQI!OCS7*f4k18@lhLInoCOkeE8S`3OGq_Y zv1lZkUFd#gTc{*+LwR0-Nm;wZ_K2pLu{M{i;1J5}W)THDP>KlO<UnhanH+<4NO%2Q z8|a~iI@<Ba=L?H|pPI9CS87uVAGZ?l9pbh*n{6><PGo5D$ufA=%u<Gmw`^_~`8Z6m z+S}QeOS<UUlk6`N%do6%Klg<m@ippNO#JFGt<qdp7mCWzIy*bs@(IaS>{VgRIu18p zU58xf3$uKD=jk#WQtu1r!<G6toOHATkO?gF!{&_G*ao?|Py-gq&Zg3Fjbyo{u0MdV zjvoeFm}D9TV%P@b<HiXrU4Y%h23k7st#6YZ@iQnCXPoU`XYEia^x7BjJQc88ajFHF zD_YF6b<KveoP&CuJe$WVv=6G!&u4QXMop%hq=d{=X>o8~&9F?$h!@u9zMxoDa6;t8 zOHR7L%8~+;QqM_wckx+eS&C&2R(7>XI!h-%WX!4zy~1Y2{*F|X-jS`zDG$3%#<S-` z^CqU;n}=NrT;psbLn!8t3}QWuc-HqzmkTi@C$CB^&uyHIB5HJhYx@WuqV`Yqx6Z*` zpKQG}?Rh4kdX^UDg?5DB{hU_Z)zYp%On9}bviHXv*?AaE>?or*Q^uk??R`G9=Cl1N zSxT9$t#`eKiGY3$YHoO!xY-}{IZu6Wp5+=9z1T}+*g&5`MM%Qo6jE4%IR8jI?4q)z z*nTnItueCv*tdC+KDJ-1rq`!is1?3$O}M^QBeS`Mj4wp(^1$RrA`%}eg9#WxpzQrm zYLO3AXeVZGZk!$6s?3qh1c!2Rxn9x2vKOnOsh>`seo5RsgnPZ4-r;1>UmAR(5=|LJ zJkj~hpm1k&m40OH4am68+<;!T$@1!z;V?RIsu_1i)Nu_tyTP1sRkv!5v<>@VF*q>H zwcK{=L59EI*W`Pm=ZFEd-_326xb>~le1$UIEsYM$h*7g~3T_e<-%;vkI5*$T2<fUS zKP>w2L0B<(l3S)df09BBT*_pJQhBg%>-O!2S7^BkVpKo}>cUZTAMTdBRd)-{rkW{b z@ye9p)Nm9PNs4f{b9!H(FBwO7_)=3RY>BPQI3hGUl7hU#W{O=|m|{8y7vFk|>^}}Y zf<(`Px7L=a8V1&ZAx;l6U?ca_a@Mk7(y~{tGa=(U3Ka~{Kwy2~YTCzAY9bRt7=C)9 z)6M?c>k~SmQu~TOu>}X1uZa@wqYevGT^_hWvoj~eMw89QE$FL5g5c#K_Ouu5o7&3N z28)`UUOP{6E}D+3o#loMAp|m<t0Pi7<9rF`<$*L$NlWciOA5nDzE^x?0OIBY=Bygl z+1G7=oQnTo0b0xx#{^l0>O}C(H5kLzf|5biPXGz!l#?(DTi1ty4Qh95LNEz+?plJ% zOkvMgo_KIO);p4`*Qd~d&pk$d&^mct&?OA|CYC!*7p)pMc{`m!GkFdhk6qjAS{`q7 zRX#b-TTJHMMAbXLy!Ik$L>;$ZTme47co<>~RV{-B$^mHYQ96@c;lQMv+LoKUoot8| z6ogrEvN0{wz6)^Vie*xG_b+#iFdu=Om8@*Li16#^Gf!f+8!^ySw(sr1!0!Yj^0&Fa z95C}|c=zifjFb~L)>Cg!)gIjOfBmb_Ob^mqUqqW{CVG2f&!I6Qz@1QsFMO<<m!TW4 zSSdPUc={uusFGAMxI>K?{~hc-gPbzE;aUG?zEKjty&>Tpo5DfKc4Lru|6<Y2CiW;v zy-~E=c#qEErq6t>1rbIJp>}!;8OjXB5q$8MZf8)JMFZ3kAg6;&N(~OL_7cqt4({B& z`?jk_wNNJ3THsC^4PwPXpDank#3VjEJiOVjaQ3Co9-ao}_4bACu)ux6PO+-_u@i)y z667Z3$}ivZa(ek>4tn@jgoriPMSsOv>~NRQ9W4NinQ)P$aN@2P{?~sG(SgtV;?)(W zE*d$V!U7t5Ce{INQk(Xb(7yAvFhkO9SD`L4nE<nCw}70<qWZA5mLaI#^>$9ip6viL zyYg=w5%Tucoc*X_b24^TfRD%;%iK<W$yAqifqX3~DJ%2+vC9vo=TaXXF#k<ow)@^? zvbDA?mz><bu;5BXRlW7Yj8ZO7;7eYu7Qn5O&a6G4pK)_^v@*b-;QeB)$13HSmId*9 z5q{-&LsREu)Ip$Nyh?Xr@dI}4VbuJg_0$BM1HGm->4_b=zl8obD-x*4aNt1t_rW>n zvrd&O{e<8bAcE8SHE1Ls9G;~+WaXs}VUHC=t5fyDmguRevj7jin%ztzxc56!;9ArE zlVR!4_NBD`c0DCISr@f#0FxVezcY$ZV69YTO^O3@r|LzJcCl(PMQi)beo-fMHMM3q zRn*yjXfBg&HA2o7AAoW9_wi!Y(Ipchfj^`P&~>e69B5T^3#*Vn5(;|vA7;wTGeUvl zu||<3>~W&5EQ0J3LytHjyC|PH4Z)fl9<M7nZ~^0ZlpE$GM+`s)4w(OGII1-;r{@w3 zA<CDh_gKkA`o-)q8#4j}3JKn)itoY<1wWkWDKVSyz~Xf8-_i{*>kV8H=iR?W_=-Eh z3g~(b#GF<rU#~$<=ikNe$@OkGe`?ik7D5MqVp2D%(Xg@Mgx_L^N*e4`2x4J@9QNnt z?Jr!s#$8o`JU>(36lNwS-6)%bn%vUTV`=iU8S;}KgXwZQ^$<m_;`a9Twh&yg(|#_K zu1KHFR1Ib2>gjUF6aJb9GI(QM7~$hhgn?TH<G&$Zr^m;DFmPV)7U2!H!l3fG3cwrO zL&bvEg$ds`Z#!K&RBFHjJ=nZMspPFxTDKrQbY&DtrCCti9M9y>t92PzDVH6PH<)l! z?iox6DXKaP4`6;id9goaqP>eysQmh&4OEnN`7;z?D?lKE$?m{NJIk3xh7+fhQ*Re( zLP8NWWk)|@3WGw+%?C(py+0m!T);qz4z3+hzy$*<<=&loNSgbOGL35c{flP{DL>l) zS|>oRj{3|4)Bk0RIst>V{7zC@z3(xYe%x{p1I>~JoD-LKUEYCkWb8jXK%9hRt(of8 z5!!_ZevH3@h5C=AiRjo7At|}kc7EZO?MyV^sCkC1hX}ui_-C?@BsbFC5s&m?VC~21 znQ+wu&uXY0t+KD1e>uEo^7UTe00eT<b>qz?M*B0Nu}*s-ioxbKIOO>Y4Fh8l@jcv< zRM%psZ6ROr(PS09>@2ZryKO$1!S9|9Pa8K;WjC!*hWy0nXwEi~6r1w18K}z;$>K7u zELh0a&v*#lEu<%ndY&N0@rMAZj5X{7#En7fXN?f^jsv-qPfRU@yxeOR46f7~QL~3) z!+}jlroYMe<_eRdnr{cfws6=xAKMA%*kmYoM|mj*5OpZb<s-YA`}!(Ton6@Bey1TS zu07a(eaC)ot%O#am|?>z+c^SfOzrh5!4R6{@0$BZ6j~#qrFT#cnJ4y~bYmIzJjrac zC+`=#T0NfVd+w`goNqn~BTB$oqk>$!*6nOYJwN=M$;UrRN4gtlZmWAW6d0eo2p_|g z*<fN>H_u!)rVj~-D1^0p2}*)6^-dUg`Nj=<(}JgpJ%TS)sL5`9VP8yZ)V0s|<}lj; zk-(fXi8(=ji_c}iiTWYbWW3$n^fsSCYriSJev*8gOERV5b`xqodyg|!(WrE97*2V5 z4xX(yo?dXJjNh3xxCrKd+IB%S{44(?1t~;aAcr{??`bJaE1WYl&Uh`B6kD=qgVG-K zy;f{(ZM;4KUq?5f(^8s7xgyZrlYaEJAUy!ZVP63@iT!=nc=>>UfRN|pf=+AQj;oy_ zsIAO@m7@U<pOFfk<5Ve?nD%Vdzcnf&Vp_Z`VM2Y*t=Gg%%#!5f_lT_ICE}46JJ5mn zyv&qf7EDYcaMg}jR4a{9@s$c|oY&*SjTa&HYs(BFQG}4Ncafoww(rs06GdOPulF@0 z-jOWvy?^lc>|+0OzLIZTodRW(o1#0Gx<~AbLr!%_!Be8xUVAOSszaIEORT6-qqA1* zjK;&fo9uI#K~KF>jE9~?@%|uva<!|HH<k=W!KsxENvH02+d_7lD1#3hC4YoVl~y#X zj@luNK{8jiFumIZg;7K&4s9b%_^)#MGqAG^B~APu0p1gFp$YvuLM_nf;ilaSiW|hv zmzJPK{aBWH!O!wo7T*4@J)W%dA>}f0dzU&w2@f6>qSkoQ4yXcwc<2fp$Vczp^aN5i zpKDif^aZy!*K5+d6e)GK{I^!BlpLaLoK!9)p(cVUWyUt=)3s{N_yM(;;UFBEt$W<@ zme1~Hh{ZGwy(n|rZEq2O=fPpIK51L7*(>pq0PD;X@b!iZy6*f^%}ihy+u62C=DN01 zq5ye$8db3gvuVkXcg0^fjRl5Whnq-EW-viGbB<gP+`QVo`JhcAb5jk@Z_&piRmN)h zN_rXtCTGbJ`r#^6QQwKC71FFHa-KwOPJW|VEB%&`Jm%>t-Dge*T5K(h&uL~BE~FWC z@UU%HtnyHwh_5B3Ha*MX>Q!7pubLpvsWUwd?0SwP{s#_on-ok{P0V-JZ11JXI<M%n z7WEN6tk~EY<UY()%GXHtF!tkg9@yHhGN?T1pjTu&j?#Q2TX}v72@29`q84<?`jN>z zzB5PnZG*o@Khee!_r$XDgy%wPe1>`@y=4F4Jh)J4PrFnrRj=boY^cx%%^|yA=}S(Z zvk~?o5x!t77vX=4Ra-KqV;N~aQnueF5qL(L+IIYU$%=B~{SxtSE|^x5BGN*Ezpcw? zK)=Au0~<FXZvO0dx~0lx1Ws&z<A8Q}k9}*eKXkU*cg$}$(?n_@Z9Jly@%QYc`72N` z_>)fl2j!biemDOyzTW}f`QQ`iHOb@CXKHN%@4UTbdq4=-(>2qu-Fs0U9Ol<r{it$; zCIe(I6_a~AgJ6>W#EY#_dN(G`XSKBnedjLlGAWon^Ia9O_3Ai%gtN>Qd_H9+x9i^{ z#ldL1+<>MY{mIUJb-uKWG~GdYsD1f~o{fsF-UKB*)kN6(C9I$Me7Zae2-z4Me8urz z(9Cde4NFZoquC|hWvonmw(WBv;vwGis=HV!lz_y!@kW$K?85shPjmIq`V3*c59vjP z!srgNw35=_j%(s@RS4=J7ht}_mrvS1NDv2se38{dhM<$o%&Tl4l4}Zh-Ts5zjqa&% zv67>9iV?h<4BSzuQDO>r5}C)g5r%d&W@U!PE3%pRc54vk(|kLdz6#A5dRtdE%`v4v z`$Iskbp16ES??=;Z&en76V$@m^s(M%=(ia=E;Nxv@GrJr?Q)i`_ROBhz%_Gk2h-L~ zJqg#eJ%by$+e?NJrZ_S7Ss13bO2(w4aB^h5Q0Jsk3GYqi&P7uilizZfBDAoFeIFAT zC@hdn+w6N2@JC0;Fq%KX%oQ~zMFh<irU6lYpef&I%+0A!b_gV(-RK%T|E`rbTf(AA zKrW)Ae|bLY(qZGgLFiNd&g3!@ysTCiLed3lQpK!jd7R8d94R_u2(uQw_I<!wS;Yd> z%APiX;~JNpWfHZeT%umAi7+pxecjs(@-br}`cL10sf%pduX^9bi6%&#toqAyKphX9 z2e^%wFWh#y+noF8UjFVKwh1XJVz3pFi1HxiX3!aFKtnF3dqe3zQgEmH`59l@&Tc?` zcQYksM1sgxTR8&FQiO6#vpw~Sxx-v)E&%u8gfgwg%MWTTpeqLDNuw8+%G-TZ#AHuS zELC_+*5|}<n`Xc*F7HA_@7wgXWsN*<E46R<GPpJ(uz`AA=tce|&E@i5%Q=ILXd)*q z8H<L<PmF`j;1N%GtX7@wX|u9+aW%M`{R|P6z{;Y!nKp3ca=LJ1=4{SY<Rc@^V21;U zS+N`%AyD6=Gne}M6FTDwOQ*sNhW#Mn5$O8K>dvJU4YlloVZP=oF^gSOWzRkTkCC&- z@|J;#>OO*IJ-gGrIS90im6i7Bc?jV_Wlm69(ZH_?(T_^`0yIWRNAeZSrCLNpG0Zfw z7qxXdpUsMV5>S^ttF6m=MqZ4qslsF$Vi;sZ#Bo$r`M~9@0P>gR$hm;?8rl5u+SOtH zgf%*5EiZ;$xHR&hR&&tH>JVF|VnkV9_a$9!c&O>flc-4Iv8twDGZQYtNCJcI?0tgm z!FCykt$Y223H-hAz;7R%DMUT$EH5(zS_&nYIwyq|Fdg5k3`CBlJ?+{9Q;P$?^&D9) zqvLy$rG}oLEW)7a6twY^Oi^)18I;e6bz}FLcOr9)AV$e=opOt~rwG|tm~yqcZzm8^ z6L(hTMt6RuKjz%#@5A6*2&K+-F~I#O0H|pBAJ>_==jf##N9GWiy}{{iRk5T^XZ?#R zUJP;N=d$i7jno1A7m7rQLvZe^pbPtW>=9bC^HG(P*GIw(v>zKQHkPu<DZ-c~W`(Z9 z-<b0I4YBViaM9ioc1FJy;c|C3O2_7!<GETASYG;rrpCor!9)ch8kPxAReQ1Txf_a+ z-ExgqB^t8*4A639TS(L~QCR;bn|_|3O7~kKy+nI@+!u!Lv5NQNCO<YGr&NfgRhCfg z7Yk7>J^T!<+*!jB*(L8x<nin@dGA-3c(oSOs#cCYP$J)Sg=T(dyd3C{eNS>tzo!U4 z{8LHs(x>I=vJbw#Wa>>POFC$i;}-tW$5`*zH$iJ~cE9tXQi#V1KWSMPiRH<Eu>g(J z%ay7$MseTh=GB2Y%`HQ}o7=T&=lncZO{qz=mKRTMeY2!(_-B4$s*tUMf_iVIhBu$5 zuNP6_)W(@<KAzD+xeYBolPNCdaEwZ>NQWb_Le^eS>y9U;YPP{#rV``5y_+#zK4~EI zo?n^eaT!F~#Jwn@vZYtog+&?9)F_zY(c_gZr+k;78Rdl`^>Rrv^4r$EBY_VXT_K-Y zug5*zB&FdiA})*D6SW8%Vs20$(Y7S1x=)I)>jptu)<NH|%6AmdH1f=!^-M3P-6nMP zG&D5ht_N1e7H21UHFG<f=p8TJF-FY;zmogM{9I>TZ)~`Wm|TP%3`UR_nk~cHO#e@W zb5Rgz&d_z|e)5zH{QHK_2q^HV$hm(VqW#i<DfQx?7+L>Wse74b%oEJKem)#WRKbq> zeav%J8(9k~Lr**$reFEW#dg?d6^G8zwEA~Vwyzi8c%F(z@dV5+^y4JmY&9t4&T9Qd zY#zNj9-aG?Ov4%A(-R5(+9fHzG6SgzSq{&`CYnJlCXq37!dGF69DA|`P!*dxQ_hQL z2Ip8In|v<YclWCbDKYQ$rS9Bd+1LHp^(S#OsX`QKM-QIGKgC^o@`^awqtf7F#=x3U zq|C1~W`nukKiy^$mvo6WX%vD%1qq=%y+n_+&n}_4v(<3#s|z^d(EH|(jJLfp&kKMo zOqYQ*VziNcFkJ^pYq8~sj;<Y}05+;m1o$hNDzbo5bj~7;e05Fop!1n0rSqIe)rGT7 zx>MV(01e;Z%x!W$&&Bl>`?h@v38(|=tY)sU0iXKu=Xy03#v*4QH1|nP@0H}A4Qhbx z#k=JbqqlS{GZ?CMf8NR8fue2LHg5q9ty-|ovj`MU*FJM-$nTMNSHq<k;_}^%4Vjpk zn;WH}r1;3_=~DSell1le)`**wk6nrk!!z8Jy~X$L!ZL8FSJ!k=k34QdrH+MkU&@V4 z1VN>|q6p#iJNk}W)Iq!PQF^oY>^GY-1pLGdlhfV8d?$@Q3tq`dhpEtnK5J%^fp!j^ z9}zE<FK(NeO>7R?q<PT)+Zqio>l*;E$it1boeXSY)zyIklrvV8fuXdbul@GLschh+ z3I~x!?kmL0tW)m3%dbUU^;2t3GdGf2m!l5s>=XchM6UIjXGagkDz0Zk$iO4_#?VM` zex)0~E?^rPGFWd;r_>#1B{v1`W=d^zI!KeM>dmB_#GGdRKG#w@t>oRUav*Qmn_lgk z8HDvOob2r-09Ff>2JJZ`Q*50i!s{cf>VUCNMf}mtpG`Q1dOc-Mj^O25^-7qOc)r#Y z{<})Klme6!`RZq7w|<hIAgKw8eS2BZ$&hj{4Ll+;^!zElPLo%zBp4Xd4a#*QwU_($ znENM!6grS~r$MBp%`1~5VedJ8U)0ilvCJAz{*exx5_(LaR%<2ESt0fHo85ml?uks) zHa9`$R+W_>Mv<|{l+h`~GJE))ygXnQ2Jen^<_>Be2n|G~5e%ExlUYd~#cTX(Dw(+L z$$ao1+!s`g=3Ujp=e20a;r5Kafkr4lpOmL0r&+Hg8!8n!;=OpeNBRE(gx6f-<qu;2 z^x!)kHe8^igo982p{D$`Clb^Lvj$r~z}hlDV`!55qs3$1a^(Mn0g{SL<O6+uZUZn? z>DcW2#i=~_w!6Xz2%Bp|!!{4|-)t)7+jNB+fWiObn|krxb+}{{?DNehxI#A_@KPYe zYf#qb8ri=tF>5QkMaAO-<zQ|z*fXBcK^${sNUh2C8SwMgza2czU8WGRo^KexFhrdZ zh`&m=9O=s>1bN2HvjTYRx?|8`4x5gO@+%M~grVW6#A0`LzJp>dr>%Lhi{O;hWuoD@ zxCc9u;;eTpm$IShf*@5Vhvu8qnE7JMts@~9y>LGI_&Z{n-XK0Xbj66m+ACrd4Gn10 zIft5#Nk>gm#vP29%q+kFe7@V|q=uTeJGJVz!x|yz`?Z2b%&4z74|AHfClziC;6^%l z4;^s%fmr3rCPVE@cD8D1`fxg|3vm+@6Y<vUi-l|EJO0n3lj}Iy4kSi*4Qh@s`UWW| zq*wIZlm9_y>q!+Vu}gL;8T{U|(owWgMu&t7UWJYqGbo&%45B@@?ZsShu`Z&ep`S~~ zg97{M9Gbn;k})@@7|+f9Mdcs-(KphSIg1O|!EbrDE0-e4G)U{Bo14xjrep?vuJ=zL zB`%>I{t2rEA4$ey=SU90@n^OHLN%5!A#sL^Y;3G=O_lIcWR0{i$!pcKQ!(W!Amtd4 znBh`rh+gHy$ikMI{%b}zJRx#1m-tnDzIqYP(4547Io&MX$AhxIE3$Zv@%qu_upt&S z0$W1=AHRPS4IC%VR@J{Xram|szU)zh+!gte>20+D9bq*3di`%40)Wx-EioFS4(f&P zI;dmn=e3$DKB4d3UlRQ<-Z}h?|63yZILZW~-hN-iP)Enrsm$p>IF7yL_8;O407L%t zazB{X27t){fnFF9+xsj0Is1PO9uA^VvBl59Gda95i@h2;T5hvBXwKXVFdCH-i<&$w zxFSiZLhBkg_E;m$7QGv-lzZ$K<p2vVSzIwPQWj#&DrMKB_g*SZ_fsD+P&@>F;E;V2 zF`(x+z|Nyjt^48kk7nUFZy?#{drbkD%a}_X>podbbAg9t$&r>#Ny3dc7|Uxw*l;m= znk$s=FngDz!h-$G0%*=%SOJZyV5(B_R1^u#+^ypXWO03@B+P|CE0q`21!BD4kdQy& z9dy&HS;;%q3z7}fHDFKhs?!??s`Vtd5tD9xwDveG5t0M3M4x}RSL#x7=R8*ub0FWD zSoFnwp+Bo-xYwSQGTpSw5Xb((;-BwiIDH3s6(k0yH6aoeT!q<JrF5}WAQW88d)OKJ z2JS9mcizY5AEmZgpQbz=O1=u9d_=wn*~hK`+AMY?LBc@Nd^3~B?G%H;E;eoxnqW>N z_F}b1w|P1J-hEA&cGQbbQCK0Xkj|w-((V%W8db)**jb?`l3mRD>0JPeyZ`4RPy%Fz zoT2npE%&{H`;-`Gdm+Jc;I5oM_=(F<<wjF1mhoV-<oyUViw|K2+C*Ajs^Q$N4L1Y` z^*D%_u%0kTuXe8R5QhI@qm`sZ5(7Keu^k~tk`otT4U3MCS}_5C`F?fz+6%<AE`;U* zXYB{N#mk_pFlisup%jC9SJo35QD}}1-OJ9=Hu=(OF$rx0%@{br?O8-2`~AfalM-od z)L>Ofk*n|gqgx+FVNJe6euH(#&8!(aWI%Ppe*VL-X6Xa)ICK6<t{DDuCPtxKrX%ET zceZ2{d3asbYb*3qJFVIN@EiM1moP8)+T2h+?gsnp%~rtO{U#o-i&wO5*OLdc$E){j zHk!DzC-`(Jii=CmyUOVny{}J2%MTT9XR{P6#y|yfe7IYbN2_wT2&U5|8EC?A+^?Pd zLNC8<=52-X`X<J?lKqi8#J&p^^XrI00=>sS3(R^4nAK0OQ6VMGc8gTHRp6OVYWDos zt2;b!DyoK9@~$YBq<RnGE63S%Bl@A~cP&t9gGD<b7q`y2cwdr~$+bih-);w-o}oXd z`+?-U*qmU9iQ+E+UVeW~hNrg6A7)s_!i*ng8BJTJ&PW)U&;7|-NA(Rq2DXktA6(yr zP(G%b|DuhT_hyljJ@5NVAaP;;m^v~p2ShXx8R0-zcd{zQhNmnam^JZRO&nvfn0(C; zwEsK!;PL{;ruk~KZIqbCjwE@Uh(Zj~5GwnUVlqKjKmY3NxM7Tx`WdWC+Wlnjfx46y zMJD?|cje<YiOyMz*qz=nlAu~akW_)N_>TM>0=g=Kf4)xGDcyHi<C{0e4P$5h%VB!> z4^l(k<$bRA{nN>K{OR35t=gY!Y4xFCm#pGQjb}zD?Fv;5(t`zy*W1-ISj}gqZk8+O zA>!^xvt~v=Dx?-Ev%&PY$^u~{BP4jmZ<>yv%J%ubLO*@}dw1lu-TpL<yxUCydEZv! z7IezHF64#G9rf-xo!489ac@Dz{eyhtxHY~P)<Zrqswcn4$@{(rxd2?N5uv6?uaKg7 zluBsHdBM_{Vh+>vF`Uoc1&H3;Y{zyuYo=w`Q!{$kqsaH?8J53cQC;1PYPg1~>PRf( zNHC3nlc^lQ`;L_CnV-K+cugke`>90#X2DC$4dx#f>O=Y5`f{yift0n9=;cOTp9Qv6 z6mvd2!l80wV;z5+`9qQ9{6R9Aqq}7wzfn%TnSk1U7@w%YdFrS0F{gQkTp6dr$AUw7 zK+-l^S+*ORDR;8lB^H`mx%wb%qdWa7O}j%aM%-5P7+p;Adb22exZwYWNRHvaB09kZ zfJ1;uTi;b|E^{^d^$OVbl>na?V8d?HE__IU_l)3=6A}E)o4}!0wwW{}+J>_Jjqk$- zv+fK$Xp^e?7An4<N#l4!@d|IzlLqCiH<iT|rKtC|w;T5e_tRO=E^d53{1mnfY7*6D zh4qr&bb9^ijdiD>iu6i<6}92@ng407#cX80X=PlTwv>tRK4<i&qd3c1*|p{uU((uV zM?gxysUGfr?=&%Pkh-%_#g6}UwYTIM;Q2HU?DRZjjkCHkvXKhGpUK|hH$Hv*_*-P8 zTSbrk`kG_{>+9>kZ@wrGCcVZkh7*UHf~e7_L<1aOLIMPwz80!OcYmm~s|sP0P<R-r zHq4OO=-;DTDAIIzSxvu4Oj!L`F|TsbOxwf=e!Aj*hvYEbW5vevmo!B9_Fr^>W=_ug zz6AhV)K1{*`7^ln7qH)$9+vrdNQr7nNtSH|$#jK>Y38u!xmEhSc|td0e0qGkA=V=z z2PtVYY&ts|``=G`wPqM&gkx#hzRHj4HF=d$T?vlI6x71;jxM=ZODC&euWt>luV0Wi znR)-ZIGTZ`;9~4?4zs2)?DuqihBj)4pOx7LG;Z}&eQ-)i1~oLUPbbKX45RP%+s^~9 zrQI)ZwB(3?E`mCYl;7d!s`c&y_~Kh;==MCsZ;$>CfeaZNE{cOaQ&#DnHKJTfEl3~r zdBLhuBc}j2d0+Vyu`?N-!hP6$_PJ~y>rqIJSHB0i)!k}0t*DY=VF7a|Y#t^4d%}r* zGQ;HRaqjJ@D&u3A(>vM5BH7|WR)hMV!&P*Xx>*EPACU(x7GJZ}AlHTuJfcl~Di=fl zuOWGV9@zd$qZ7v`CwV#yv~+Zk&A30{VwgL?j?MtI8sw3mYwlzHgsjv8)V7hd(d1Pr zVJNl89$@eLuSNj9H_gQBPtpGJ*`bYylbSs_3xdmUkl{|0N1r7AhV17i(K%&P^yl4$ z03XVEVdb4m!M;bA5jyst!qOIh2CbIEX!Amf4c+&3*7Wb)bvGNh$hf^K2x^>PB>fuQ zawEbp5ln(FR<-6gl~wk{qNDnhQUCtOfN9(7U`6>SevE||HhwnE9(=%&D;u<D+%qvN zfxl;VcDAdlOEr|+WlKe7)y=bdo>@t0Ey+T<sSK5Wpw`|FJ&$^6K_+G>`w_nS@-AE( zpLMXin^9v|I47{0MD}{;)yMsnZb}N#kkA?7dE~?-bC<m>rEcJIt-MJlz$JuL>C(f7 z&61<OmbiFOq;0Z&TdZ6AT*=E<ebya<P4F%Iz>?g+6<f&drhU1FB{2%x^}*twM`^YT zoFuZZ3wpN%zSWB=HjEu42n9yzKT5-$r)16CMQfSk@i@pp$9*C5)=^HlxZ$1xm)eCb z+I<MY6ae|t`ye(U0MYtk<W&55kzS3&n>Q>p$)%<$+c5#*%}Apf^Ob;sdzSUj6Y==W z^>)5J*#2nLbA;98??{!k%<C3K@NRX)BY3=!xf}`0OT?zPeYMuRQEkxg?B3wLxY76| z(*5#5=nd*rw+Uu+@o9Dzc#RKQtShSuWiy_!!DiLs2!YSbm+Lk1g7@ZoMn^|uV`EoV zj6JPN_0P{^T~es&;f%)IhtL3dI40DAGvr8^Izu3BPhFQBbof+;jMMVQ_4F04Uph~9 zxAAueI%}k(Yhg57_R-(Lt`LCjobsIGua!<$H-{xeBy(5>CW5-Y**xJoYZr?Ho$W40 zdEIRHEXAzSsa2WEl`WVe?m#sysKwtFzm8}3@Bo|*|CQgo$6g8hx(UXCL+0oFKlm9^ z<_%3HU(4T1wu!kPu$lu7vxgACO<*ECZaVn_r!y=otp5(mMS+Qa+L$5eU63@b!SzV| z7%2Q9_|hf15xI3hK2`KK?xPN`h5W8Sc^ayqwgV>#g1G&iDTFKPxt~k`@pUnK-9|aT zZ^;FA3jo?!fkGJ>Yn)hX8yme~xeRd$334!KYH-hC{Ldfl(Z};cSoNRVf;+kZ`KM5F z>~-rCY&CWE_Ks(749FQ6#e2OrvmB)TyuPz_iOs=o3N`p^o6q2q{0E~_>@v-0rIQ;U zvh-$I7S~GMFt4*j5M1eY3E7yo)h|e{%96}G+r%L52tL`RY|hEgxiebM9`djj7M=DS z<4dXBZ!@fU+SyEBy&tQTl1?CrOBl^&zd6VaEOroJvI7(#F6{vZTyy<omdCHWaeeEy zl)a%Ph1UIFm+ufO9SH7n+81T1Kd*L+0ZM^`<@UTe$15g)9Du3;1g}D6w3L)-R1(4b z_=xN2`uz<y!-lP2s(^cxX0FDUFJHJ-Grmk^$w6;!iVib{9lug*vnhFAgdDu!7^&GD zk+3MDC~}BObl>W4p9EoU5t$@Sr<VM+&IfoSdSc(2?93%^GUcYonscXyC1^e@7u!At zYlaZrtjP2kHkdbhB9EPeMoWmZ3~m{mfn5xM+yH$Ra9^8${f7W&Vs|)!RwvX;lG}Vn zCZOW)WJ&&_(X2>Va$F$o(7==ru#~JFshtI?n`QJkAm>UR0gMt`Ijv&>>!)pbHiJ6J za$1>UzHiyGk?#<kCY@)8i<phw(U24G)<j}Meuuq3L5{0+A3DduzfMK@|GdDQys?6b zX{<0B>DFMaD-2&94FLIYKN*(7XmOQdTu`}T_1$)W2x>x5_8sZ>_4W0Aczq%BnO!|j z-B2L`owBmBOa`#pq}UDg4{Wi704_yU7Uc_$d5TDW&r7l(S@&|Y9oJ-3s0C$G!Pbt2 zsdF6r?KVJLXu5Ck56%dTe_Oi!=FYq2Rhk+a6{3(78U@mKrlB;dJA#d~c3EX*Wpt_; z&wwgUzL@<nNu2%g-6Llw>LC%ajSgbmn<I&qKcy}A?WD-?L^p}ThhH$=z~f0ES*aKc zW<WGa6}k5w_Rkvw{2xIXz{<eHg*KUTL}h!WBRo<i7Xf+Qn1%Pz)YOy#Ucb**qRmo> zy=iA_>q0A!D|W*HBx4>N*Ni~vXMhp5bpO@eY(qpwtM}4k&gNja^cB7f-hcRUKif#Y zOUtmTDX8Lu^_#qPyo1ux76Wiaw_efb_MO|+<0Y7p^T<cG)LQ+PBtW2Vug7H0{_<(Y z#-J{=0G-3x#8CeWb@sygEk@*EZ>3?&>hG)mZ+<7h>Q0)OnMrt*3-CXemzRO?o7UFW z&Il5TVmFtQQAbjMkT=CR5+VSU5qtYXV8sq3at3souA!5;ail&rKIw)1eDdFEyjiL* zdY1JAeq%v?e&(@!v+wQwyI-1{{oLJaGRt3wjP3h*>NmK@n9Bj*bZu=dQ+%&#CLt-Q zpw%d?n3&jT?vEec<wKoC+DSL7Q4dTy-<a%Np&{4GK)+J-@E=tnkN%HT)Y<1pE_6?) zYNKXTQ?+og4$_m8XTCi)kqY`yTs*!V(a|CB@@4kS%qpvdoqAjFGiUCDD=djrSwH~) zWA{6+r6JLa7ca=j$Vf=!GU-M;gimDuTpv>UZBll<;6S>+e7WIwD8>V^BLr^jueCj} z#J|f>Y-2Vp{K80HeoM^V{ew9q@blq`z<S@6c@^VR_CStXV{{i>3oMdy*)=L6uP{l& zTNHACs<>w&o|wGx4<MvDW0B4riJ{v@qW1P|adD%tgpwIY`q&HuT`NPk-Io)!1-`)Z zYrU;T82`8A&nV&9T<PiTmGXDw$qK=i24L%gxoElITD_IZu;X1}e@_g#aw-%Nw`P;i z(21o1;fD!#7%Nj!+Iy0a2z2{SgbzV%8jJqHhma92xd7?9<|Q#PF)8hXhYx`;Rkwq= z+IhQz;|=O;h#DRT@>Apqf&mV|BQ65}ymv)8D(-_R3b6|!WJE;VtB^L5hm^3?)vb|o zBh1Cx%~i{4r~D(V8!cA6*dEhb_hbA<d1pHDAC`gRYH{4&mv1O3**)XWTmYLoR!22g zS63@5tDgoRKlTIeYzscv<Pb}N)W1}ip7jh1$05mk^uRkg6=>fNET#QN&ye2Nha-#L z`M&#=jhV)TRQGNkK09G>Fd}@pI|x@rAU2f}hc7Y>GI6lxC=v?GiK$6k1oZ-45R5%r z>+4ET8sI7T`7PqnJREVG&B%f)7HW<0hndTrod7G7Tj}wmN3Vq49aL1J0W0`z9SRT0 z|Hac=hgI2p-@}N20f>ZjC@GC}gLHRycXx+KcOJS!q@=r~rMtVkyWZjXe1Gq`;Nmal zo_l7_p1s%Jd#!J$`@TZ@h`#v$dW25`RgssLz@ei7t*j2L($av5ZG=k_lQvJ@?DyTa zc7>DY#p25M$3TQ=L@Yu_`;HTdYb~d%E(T+obS~w&T3S@Lzvw4`hWdnq_a3mpT<OLj zM3mPa`jIGtj!^yMIivoVyQ@z~c-Usv?x6KNR!gXCx)xuo5BO`Nr<NBxqnb80NA5>y zwcemX_lV=A%?2<Lf+`tjbPTdQc}>mrLivW+g0m;j&s-K!3L}BEvB3uPca%eZZPwZ2 zDT)*wg7NJtrtGd2CO34aC#yh2mrdg92B0g<&O;Ao#?2E|q7)|+P7ojiD#k_#mM-ot z7pdd5i{znQYpjbYImb91bP)pNoSYAZ1?kz!FOU@7|0){%`yC@9uJTYV0ST|7ih`zG zu{f7e>90Rxc}t)}nbCTW;PV+WyoQFGW_g?C*#02!*r*j6RBTVP0SV4K1T+}fD=cL4 zhyIFhFqzIdNwP&Bthy;DMTNjwx!)04x}XR8@Xct!oSK@hxz0df7hJ1yg$l5+u;AK! z6-y)=5+0t$Vx8W|L9qjnAC~_n<!sRgg~ZM_=ja&T)s?>%vzJZiX@ssWBeQvQM2hmR z+0rllZKqzAhyX3DhZpS~lZv{!a3yit&_Nv|=5NP4v$UaHnV<##q3>thve8SvzOOvE zDdICB^eMFam)+u!i--nd;WxFQ7E(AwfUIzWg-|cE`A~8N&>gDF$;lZA<9z@A9cWYY z2OuOdTdED?hyk+H`TrRX<vNY+sE<hO)dQpZ=9Cm>I|^D_>5u}7vOfus>4|AnY=+<_ zSkJq<vs)`@U&_I(ojl$))VHojf32A3O1t!!Qp_cdia?hh^QchCtOnpK`=g}fII5zp zeZ0R<dg^$2x`~L}i+b;J7j!?y#<iZi1?3E`wB#+}%}uyul6VLQ3Q10AsJMv<RCDev zGJD<P&shslPGuz;2Lt_GN9XfkJig1ytGUm>HTb_i;~oJ*Wp;A3>D0sfvrRW_HZ01L zNkt3C>hd`SXHj)+O-VAh>LG*QIij&LZLt@ZCMGtZ%_pGe#3hU}vY(CyFc#pax!|&A z^sG0`%?}Mp4KYj6&<uvpFL%7~Zg%J2-3f8bkt)>;nU(05ymb9oTBgNUGZj}mRt-*# zGK<F&1#D-JVh+|&^?`ygLNq%rPE6-7J&S4UNA2-<41Br-w6u2|Y;B8p9cjGQ8*d{I zK8Y&#EafLvlv$s*%@YYBWJ9>QwXCd4VXQLqg!)ELsfmemwOhT`dm;<!<ubTgEzzNP zr~fNaUGdr(4@CX=Id?o99ki57l1>>InbWXLOfajM?*fT#a~v}>Y{c$>xj7;Clp*Wl zYG!bcCB!F{{S=TyL}a8lvn#i?gOR48sTt_&4^>t*wKTEG6$z)#LXk{N)ij~sJWyGh zpD)SJk1!HaCN*Yp+uf4DA&iC}p7hr;#ANaH>DYFE>yI!Pxf24DS)2VFgnKJ%Xb4)j zWwnk(;t-aW(iu6kZBZnnfA|@4a}&;O*`0dcqlXFU>*{_PjNc(aj5kR|!^C8DIawi> z&le$np)y#6;eO%yY=A*R3Xs~U&3+~C=^0C8QK2~|P?%-<cz3PsvMQ~$#D|g0J}S_c zz{N(9{t@Zb0($cB*hVk%m)XYpSsKuPjQwTw<QE`UodaePpe}u(`3*2uuRU9{Rpn%5 zk(--bPW3=hsf^4=#Q3%KbyGjmO>j>(eEIMD$mI*1MTonnMU`&H_|Xyc!wzg69^=*@ zq=Z~&1u{0iJU}gi7-^EQ0XRIbuY-dycXp12Rz6@M1n_YPFSFEZDkx0oM{;d#`fsBG zSMh&<s{ZB{dN?L>HytMOyA|xqOT??UqSErYduH~gyCiP$%)n#a$y1=fVx|gX79f;{ zZVk?n=!^dP2@TDLHT832G?B2LhZWG`_%E-(8|k>x#-gih2P|vKXrdqRW~ur{GgSdi zC3SR=NrC|F<AX;4!n@1UQ@}F%*~XcIlF~F`W9MFo_S1WR3DVGrh;%OZngX(Hb}-~i zH936x#r*%n0zjGU<zA8atp^L}5M^zsVRNfBt#<2~nQv*SX|EwcDOcFzut<b<6unN+ zx3LobrJ$`%fQw57>3=>6<QWkMo7nunt`hwH&fo*v^=vvg)C&<IeMi^&bTHEf;>ue) z*-=4o!wGA~=pR@LLs+1%6(l7swX{^dp7M@c=A+Zn7R$7Gl1UKp*d2f?M;QHr)mr~| z=rfJ8d77kCWq|Jb=T|#hR0*I><$KZO?0yWwm4+zU$CE=x(HbgpM#d=S9Yr#-)Gy`y zBWPurnN@#I^mMaAgxp(NaDzK3nXgs>?RRoVJJNe7s6Xx7fIEbDSisx5OshE&%7Ide za{re|1%#AInGAVtqc_sSQ!qN(4;L|D6=g?6P!NMw<Bz`>qne%LChVL34ZB%>r0|RD z>q!%Os)UB9sE=R`Y+zq#0EUKG6W&6NyyL#z;IZG9;(un{`RC9eR-yzf_<e5CZ+a{Q zMQVOha<sE@{Tx$uzyzDQUT&fJ-Is!=^(QHK0yd`$?{dr98~nH452xhdG`<Wi|04Us z#LNu(-RoMV(*C6}nlg@&?63o-L-ilTZmzpAC9A7n;zR@(yH2jJvAz+835jcI9l;zC z#rE-460?uWh~|(J(0$fv^Ldib4}*WV8~GxrQ%Cfl-{c08aNGCGk4ImiM_m8)BbJEA z?kEC=2Gtk=!hFb!g$i%+6vS*PkjF$&1ZwjB{=XL%vH`6QAD>0mSZ}oaYv>1X<+fS- zBRK-<3H;56+cn_Rqxjr+nHj$D;Pyq6u+6o%Ke}?#NJigXUx!BE^Wos*@90k^nRG)k zv?c8G;4#EN^*$dTv}x(ZNe9wc6Cf{zT7duG{DQ{DD)f8kjRssWbMkn53X2)5Kg8@9 z0086s{PeQ=(gEk2-bc%EI5%ZS*o(4Qh%tQp*_oKhk}xU>@E1Jq_z9!Qz<$?F|5qX8 z;11{}WO`}IM>%gdj^DO5^H=7Ey!TTpaYXwXAFoJ<z<*BE!{}i0OMvj@%QRv*a&QdZ z=zKfWr0Rw0{K=7!5N;@$sc-y0nQwNK|8-OqmzS5<shqdJd7i7YolX1}4YNWs@(?7& zC~jsNB#@q!<?GRc(~Jvb=L5uEtbGF}HuM;4aDdqMFr=>j6z&58?l-*=f@fC$ts!8W zR=oa~=b{EV*~8@wdL17Ls;i|YSGHGmsEo-ICMG`x{D{*Y$3T63bVf#<wRT2OkZ&Y6 zwm1$A&Cc$WARyY^-a>z2rJdHcXT`-xhWdboa(BO+BL}vM9r1s27Ahzj7^cN%miX>1 z`T+-B2+GP&O;bxrNlVM>D`1bdjgc}2*Kjz9{XK-jFgK2m#c^O7n2X{E`^YovQPRxJ zOWq5Pth9dt!Qp2&a%ey|`#(5hUWVfQZwJ4;+?Y@V?%viGyY62QCg5Wen=-0}=kw)c z)q{u&oYX9GIbNS<Kcs;B+uOAT-9-^Ct#U2}id=6=e?BSuyt=w*&-)jFbzWsa_W43? z0Q7gL(D8RU(ZvxXdzxKj=SNm#H3ucqu%(Talw<KFKI_<`SIjG`s#?;@{FW)IzUsH5 zrFF(88#SGw>xv`SH}&JK<s<rQzfF_Mp=@(?;Q!qZ4hB{f7&76FGcU00nMMDFhBolw z;Zjud?=liWG|<UCC1+W}5ZG~kzJI(SaQ*WFE<D_EXh;%fPF{PN*Dp5|gLd?Duom!@ z#fe0K@DK307#Zn+E{>%Q>rKS;<HYoI2A4W7KM6MVsP};%9Bfaw5RDdRN{8(iaLP6D zTRgxY2WsFlj-ouenlfu|oi73o5?hL2T0^p#0+rwarlq;mwA0r>QAV5RT{A8>rvyd( z#jYPiqoY3HpQ@-l7ZdLXh9NI&Y^D7*sT3{=x;royK2|Su&``V(Ui-YeJ2)B$zdebv zGJfFt!4Zv#^>?L|2Kz|czcEQ62`QHLRY){Rs9uh+_D4&2JR)0zk+F0vP2%=PzIe+n zgIc&`=FJE|7O0+5xZGwJW;R^eaw)b-`r3>LV+a8w%H2oNvvMDb!*(@p#l52bNTOtF zOdy!_Lj*ku28J=fM3BGP{EI8c3~w~<?IX{vA4_7mWfb^;goMXJbGKVy{NH4Oco>+h zF^~_-tw-swq17(^w(aFP!|0}!`qO<}abWK6kI-%<%}tdll|*^gi{v5uA70;1QE#+Z z!h=-Ab-(Mjn%^%Z!MCtQ5oK^4C+wmO`gzT?lzA+eby2T>R!Z^u9G}EWHg=yM`mN%P ze&V##!T$B!UmNMM&=CwDB5}Dw8a8Z3zV{!Fj@Q?j40@rkso%kPzP)%`U9FTye>_o` zdT{XY>h&EG7D`MM-E>dSiyWSV_kT9ZDtr?Cw+grZfFioeO-OGd-8=q)-_S&oHZ)si zB(ke0-NkZ7<Uk~an3G=YXz=5d%&&?W<x%>OUJ)!HvS4f)%xR`jC!o_GF98`!Wt0C^ zi2P4DEGW+Pv;;C1@x&5q8~z?2hX(bdy{=J%T$dYcpzvJjl1?iEtiRBAo@Cfjj$#J1 ze8bI(nBK4RTvee*8^iyg*95P>%^-7+UQ#&>(^o?aZj9(y3w;hvWsetWI)on&B>E*! z9#6qXph(<YeVK2E<ZY-h5Lv;6ItTRO--d~O;^7n{)d^=y+O2npAfylBmM#9qa+nh5 zKr8Cf`W0(bt1_24@TmOsUyMLzBD-(nrpVUBi5C~UZnpPh3<_eJNw$JUv8jm(V02R? zJ%@jL5*d#7CHB&>`|UQDxMTJ0MBzd#nSDqI4d|&C5=2z3JGsUrg9qwAm>z$=2in`V z_r^hu92UwgYsOn##Ps?#hyCMv6HmFMlHa>nYgntawoih?qMoIA<YEYM+BqnEZ~1Te zLoQD_jc(lS%#iZ6>1~V`z1MB&VWS}mAMxYeFYfz|o`NMUp4rO-;eF=Euc?e2L;DwG zIC3<_E{+uPBIo2KpIL;N6ow2AXa1<;aO<Z|4>xK(@}-~f^k>g~=>7X!ZfVIbk;nSd z1M5ro0xfgX(I+a5UrwIM6(`60gz(qI_zJ}?o<=s}6b4F1ZkGMuvSnPbu05-)*ZOhR z59dYhGN?p(-m$fA^KR_1U{F@^5R?4gvh?^ClDb^GdHj#v7vhl_hCA#0Ic3SMnl`nb zjvDecp%=DhtG&~*UJHd*bU=T#c<&@3{7tVPLym~p80CGo))H$9+E;i0D$`%<J@=~y zKf4=>@=w($dMm^A3<neT#At~p-R<oa(hJcucJHs0<61uOI*(OqH2tC`mmO#AGb#QD zzL2VN&re?9iY9Q|@%6gc4NULV`IVi80{E#wA#0#yG9x>c#EaaV+t-)ie#ci8APk4) zo#qE2;qZT9iuc;gaXuKE`ZbH-^=I6y?Xl9<e0CKsHCXE2Hs#|ZYz`~)vaQqE5om}p z_}6be@ZQLt$IWnjHc)$>(cVW9bU`{7QP3zSMW)x`#{O~D&GnIpik+@G=nqlHV51mN zo}~3M3R6D1qJ57xY^{O4D>-A+zFd?06;XD*^iWVACzyN}DyiAcHtgpwi$)AlXId4V zA>^i*UroJ2HLg&<Gyao!dZ1hsRf<oK>Hm`(@p0?9(wXc;QZe|cO?rNV)Y7VfIL{RP zT4usihs(X^L(Zj0NkD!jEc3<z#`E?0M_gDu!Hn$VVD>#u*k>Y|*F!lp@<tQcRN02* z=x@;)MjW!-`BNPFBvTehcb|#LRnU>i{S%&A7KS%BHWubLws@_**sh6<mQahxD1%lT zx6}WTTd%GU%>@OkUw9n5$Et2!MDobCQyRGoiM@MQPRBy+`QvD|>bcsxCd{7TtDQOS z{e?OQ<<{eBeoA)hhIJ~>xsd2>76UaR$J74Dl+%rh`l`*Fpah=gOVRe5p6*_g0J7UN znG#E<zdAg^rj|^~N>gKbrAKjE+DsOSR1_582T-iZKcBoI76V#olUM7JJv)3n`k`S6 zX<Y7R7R4c8w>6Ic^O!W=lYXV2!z&o!dpj$un~fYn{B?{=6UDl4lNdQV-F#>!$YjcH z!=Q0-mKvy(o#ILP`o#ayrO2c8Y$QhyZlK~Eu1ySD%l|kmX!gFL<CsE1!)`co4R|&M zW)w@F4Y|hK0hx8^=@%`$C3!3Bkn8ilB5nKO;lc4V5wy|oM}a*em}7s>x4B-m<aP3T z$K8LYk6t$xLeXOv$dGi%b+?F%vONsqxfXq1X!Z#8)&Jp@yhi?&?~5&yX<;E<Ty4`` z0e@JdtfY8V``?WC{8X#HB(<L*qM%MaK3w61jS1Z=EbPzwciQ88a0S1A-^G&co1_ZZ zlQb@3p`s#x`t;3mt}{=38{VIFx8Vto(}k1g`6d`O-qf_OHy%{S{%WrW+Kp{x>;F^} zKX<2<h;VwpBVm-tx@5+YzWJc`F{rtT{p~4A#Z$2l#z)$<Np~f@fGytK(Br+?FT(It z9S;s=Ki`F9TwlPq-p&TsxT4T|g>A|xIoYO~7t0JAVL0D?*CKDe-bjY|7n|^{Sa09} zBV%v}j{=KkVT{Q#Zu{d&lRp{X6(qeK%Aj`s@(vJ+?VZ^*O#Jls2e~O}(cs4YQgy=$ zC}{rIlAo@Rrxlf^K=knr)yicf+8tDlwvGH(!>}TELQN7#A;HpbpketPRyGU?CB}*` z)rliq%mX@GaZ&Y+dHn`!^tggU5PUtG(vkg0D5(TbWo_op#^>>Nl#YQNk^H>u%z)s2 z8M=*&y0lD@>E<2;1|lM?r9aO_j|L9|DQL~>7=mYE{^Epd%WkVBlD4!|t*3J7P(?wZ zU(|Q}aEbI0IJCS?pTj93aMAr;FytyN+5S9b>_)`*n^}7N0es)<&W>BC-ZeNHo%uU| zL>=^S{k0I66&0Ou#t)}zS7mGv+y3_t<hiXBx@b~pJvSysESYk03VUrmXL9e4={RY3 z_isAO^C-U8n~(RA2lLK}Ek`ViadyL840IeEP*4bltcW1msslVCJ9DQ~u_@|xR;{w< z7?Cm2a)Fie41=j(84p*5)tIe3S9fU$(crqMy>6yNx@}JM+(YXxS8oHTanhgCt-b^& zmRl@^Q>}io{T_hm{S>>3G)(mr=^uc!(~KV5&P!;e>2?kacl{^2md3U2X4iRnE~_q5 z19~<b9x`P$clS4gxeD_pYL?PE3{5Gjzw4`zQ0!|GxFgb8Rc)0X(qT>dXTM^Wi~*}G zk&C8J45sTltWF(oyxoRQc2bw90mDiP5_9W=0W+6TOz*9De$Ss$+_IVg<pta?gs($8 zRyl36D{^z!I)h{3_|Fr+1!w6i&*}d^EFi06Yd8b{(<dpNox(!XhpTzc<`xQa)6Q(c z)^|ufxoW^DbQU!>HeMfwgh>1U1qt|Im=!S__O$+l=9|{%d!VfPs56|7K`QMPiQsBz z_)LcGEG!U4^w?(QoXRSzzjR)!Ty*SqwmP~8MLz88>gtMkOYT^=CP{0tvBh2aAcR$~ zcXM{Iz4SZrc0ivYn*;WTm0d|1r>eGw28amG&XVKeSmP-yHhRBSvxl|G<c)tCHz}+f zcj~7$UGFJ~%KF{Hy{xx8W`h30S~*Vl<K10aMn)poTnc8K<x$<m8p~@C2b3tyxBCea z?Hbc-Efu#KV_SZC^|yOC9p~E4$?3GW>y)aV@Fw}VqQ%&_6w@A-s7O0<ojoMvUj)H4 z26{qQ_wSCsd`1IxNn2tt0W}K?{BMx(AtD~fb^0MfIWHBeHq79bJl;uRgodK~!<IJG z5f~cxPghg(N?8kV&JUvawsCNCCkMduWqoFMbJ;}t&Y(}2r=<lm6t!4qgXNw1fQB)w z75y{VnoY2qr^&!>gDL5tv8AOjbEUIfKhLR=h>;P9!6Bb-4`-S}`+l*$K59x$J(ibS z^S*AJ{r)|%5f21Qe_w2Oo6N*ichX=&(A%!=aMf?$qQN5JvdN)f73Ln}FT3rN4@)$^ z7d%P2s<;VDf*I-(ifCnu(I5|7>sBwjQ(%sdcVyQ@_PjLxC2-!!W^V<6q|Z=RZ$9A8 zR__NQ6J@8nZpjuZ@PSue*?#H8_xS=!4}!b0@i?yF{eSeu_OzT2Jxiv#yhcnUmBi+M zO&WmAeKE2K6A(71#_#o1?)N6LXmV9ncGdf)<407KxtlIN0v!oSe1=DbX2}QlgYq{I z4|Gw8V0g%X!|y8z3LX~9cLUDP*0X>4b#*%&M~Md@IhVw_*+)ijRuvo>NlItn;G}kb z7I8T5JhPmE$3}0q`A?7@tJLL(z2`|gPh(@>T9-WtPl54+(!hR!0BzD%>LMyCe#gb# zShqf&1`<S+YCYbgl2LG-wvXkVb3tki48q7-(|JdzA?j~6ES2rLi%`j=juzc_Zfqap z$W4(rqp*;X#h`}l4^(#Nt)JI@-x!VSB!dUEm4US79iEX0tk2Vm1N%O}h>3_I@wx79 zx81#-?^A$9#zGLnrt9iLBw(RQs!8JJS{$#7j4g1{wK{G;X!9j<zuE}x8W?1W1e0fj zSuyr_x{cV{nyOv)f_vToum;U*06aOPivx&bhXEu+*4OnJv0I<+_5l~EGtgto%gduw zE{zk*lg}p}g{xg;ZXcH`#1(>id;4_n2ewj?!tc2CdFbS%h})YLf#SF&<<qB6w$qxm zFDg!TR4?I;jt}66c4|z66V>kyE1$K%VYYBt3nnue1bwxb0rl$<9GrvMIXP)kY+n{( zR;qG@r9I9ETTV`*gz=M(kB3owLf!5Uy%+4n)iiL83|@m_J^mY(H?uFgV|51P?Ik3_ zK#F*-+8jcyjt;=ZXb}VYBoSh@R<GuQJhc*1D4v|6{-;sl`A)r2ex$*7@BhLqo%CyY z-9AgEBt$p>a3C}Czw^!%n~`yRd|W11nC^a5?+RpGe~g;oa=9^<c&vxBz(7ya7S3BW zhJ%f7ZrSTVsswAK#K(v5st)G)rbl@^Us6Ie7&N^=jj+7TaD`%Y{}OQY0%}3;+kWjh zF@SBI16B_*zz>M_KEvazudR)D&IlwXVOA;_S!zA57uzwkZirshF|upgIy*ayi2(vp zC?D?s${8NB0l5X>7bPd(?LgwGnm+_okt^+fZ-gUoMF9bjwRe$BosmY%^V2<7IM*&p zn;|7l>CIM(r}x9UC(KHW=lS51V5k>AnB9sPK$#z<NITFlp{84>rlvq$0H|)**w{z~ z^c2y;__JV51q5K4DT#=Ph>3sJ*^@}?OE<vS<k_Td7L~QA*V`e#sx#Q!WCA9LSU~8% zAx}u59tRA}h@fmd0Sh}Y%|?~=fBu|+lv>5C1xRTDg7AydjSYk0y4;teHI^${^${PR z2JQVj-qzXj^|qhySU&n_^Ox}yj{f@ZyY&V0MOV7D&8n7w&F<%xz|zvvantz+$p{l) zA@ShgV6i;Au8>cJKy82o**R4Js<bqg%$$L~8`VcUr+E<tKVmcvOiUT!1*~EfcXqCo zDu93g{yie1=VrWezEm1E0fF@E{Ggz>!2%V4(p^nDrK`Ie#pjjJcH_a7)r(#7LcH@% z6ZzQ=95pZwP4sIh%)Ji;+{M%ThfeqB!#vM}EXyaH-X|;H@214RqCGubw`S1k{*WMb z-^mIzDebH%Cewt*WNm0nV@?T&dJx0IQwr=5eOayd?Qcnex%=5I#3%)_0+GSNuB-kS za)98w_3?^7@PlL$eb@YaO1G$3UJSGQ&1xXmAs5RlIOejgEp%v`KBEB@Jvz+B#T{=? zTia3fng>894CT`T2HkN+@H^dxn)Vsry)#Jf7WMb{uX;GB90q4>eAQ+Io0bKe43Hu( z#!`Zp9fMvY#?)weay#w}h9{L!`Qv=N`b~e!tlAmbM?5O}Tl!O($n!X$??4VsdP5*E z3?5Pk{gLi_>-sGXxTy!ss>d$N^?R<4m!;vYn@+oX2)rfW!QuY+7KTyE?P64zfu7!E z7L{xiyHk{IX3=e5GB}va-gmS~XQ2)a+UDI+!~WE-Uv$!JU%p%=sCNSt2iTw&i|Q9S zYmv1d6Z1Z8$kA{lkihGvw)HU_9ccPKK0KJL`o6(esk8Y){cu&=ipOS093G4I-a{2d z!@8-td3!j66rG_82*0A!qzXdOd{<Do$rUZE;^i1_z8?Av9$`cB!ZD1w6V|QNKtowH z^lwfIKEu0Zz07-&51yL(kZM0!%o@Nb;8GzlS-qOKmKL=|TziXvKn%s-w$W$$?p^ZH z(NSDn9q@ubLqWD!TKi-Q<4#Y%$>;0b0ISy)?<?B!3JUJy9d~_8i)dC9a3Y<KhZ>)s zICkBRmpX=dT8k^k_v-WYr;7WW7ab|7!w53;Dm}l$Q}v_xd;yh=_oa>WV&X+!_wz|G zrOuqke`ZseQx>c94Jn&v-EEy>t-!74;KYH~xq5Kq)G+noP4n!b4N5`d!lL93kh{30 zetPw)tVL;OeSLFXvZ<-rq<ucciDG(Ae)n+waGk%Yxv9xBC?mti0aa*&2>muy%RN*O zI=WYVLyI9SQYL8F>J^mE4nQ&=X{e}vLbdtN97gQz!R67{6&3<2<SQx-7vb;UcWalo zxSnqG?u5RQoBoW9losl~nYT*e#TidYFQK#LM$p2;bBfr{HZO{&`H=4Hq;|+ix>Lno zT-s$?$zH-NY+SHU!^5L1FCRD_56_Ahk?znIk9QIBZebaEi)nYO=V^I)j@e>>EA2yF z0)wt^(aMLn9v)4Bi09QRF44U`)6>FK`B=UznxUQCEe_OnpG?d?Uws;>79iA74OkM- z%owH7Wj;5u$DnpeaGI8!Q^c)`F$$8DOiO<&`Z<#})<+kHFrd`GiYnJIItuwc)*VD< zXPc6+lN`;({oO9*k?W!<%gGVBi)-2ue(CRER5{aU+b)?>uT6(Ha*To5B+9LJb#fF1 z1uLb;HM1TATT<!A*`b3*hkMy<7}5;1OH+pHrn5umPIup49g0-V{|v008lov|ZPTVO z$b%;D7g13&QYn=A_nrhLi?Z}3ZfIF?#<^&`yOW23(y)zbuBxKo)M&TGQ?K=M1R;h= z>BS(+J-(Mqy3E%^)>EN`%M{jAxQ3DL(NScFELd7vT4*}Vk=nJv!6_$uNPU{~Hf%&q z4bAkN<M>hOs;Ln+?!Z{>EhFN`Z;WV}yHJvF9-6OtF?}^i9sFSt5Z!i~q019b&q%dE zN$*Wv%SnGf9@@uih<AO<yJ$F}s_bihn;vN?=MS3~0<!7JTCjR~wmv1H&f!0@zCb9k zO{{BKnCj(UPY-^WZf<I>jOD(OYGCm8j#i+!xW*96`vKK(RbcKLM+#d^_c<!MzZ1sm z-C#hN5T#}^*Zvkw;9^n!l*Q_SQK>kgF3`TCM7|g8bfAug8TmmWN2P0;*DKAJL+neY z%)Y&e?}Pi{8j5mhscYP4pxagn+r$oC-IKqdOYd;Uq|!!dh>y~g(eV1(*klF`{|oYa zQ5!lUNT}bYUcEck%`h4;z5n?RrJQ$C^H$EdTxu`AthL{=CyV*f!u+oBaibGn-Sv`c zOG~zDB`GCZJ4im~2uJ1~zJ^xGv%`FaNvx)30g^ISN1T%e=2mOcX?DwuYQhl+)oOSs z&Jt~6Bvh0kF`=pnG=xeN2^D(2nclRY?U1BSku~Zoagw1&e0)eH8Hwm>8|{4M;mieL z5kCwta-5u>v)N|b&d{~4=*Wr$eKo*jut;QORl(&PU}*!@m2&+0!utBc^6~|J8+$l| zLQ69i)pMy8pG9Rp?rX(;`-YMfmbORc;P^>Npp=O5;^JI?hYv6k;I&ESe636pl4{E9 zFtXT^piW4}wl^1#E%Y}xEr)>%*|0;FOyg{iCG2FOlHj8RZcrQ4OB-2cnFVutOW{oG zbC6ix#kH}v`=MXsaf@al0=He~C%RE*nNak{03%UGQ?(k4EMwFpDjz5W6h_)#g{!}R zQ<aoNRGeMq=YN<hGUerMESDgKhc}LmA+}>{CjT;x*X>B2@cC=+@iz7d<E%*qW_W7f zjv7SSMo6Cv@vTWdy+xw6M3}cslyqFOV&Tk+j<((6x0FUR^ZMlEh&}8%QK~8`i1_55 zPWcS&*RKW>Uqz!N_;T|0sRSDccdWzPczGH|DoC#utZyw$4^6(Qn(PTo=jCm!tqp<e z1aRsiA7K>gDMq&-BYQ<hqhx)A*g%7~en}9mC}(D{sZ-&}6&l=MH3Rw5aMHa#GVL@T zZ#TKIpayn7p_~K}59MMPUK(f5;CX(e#lu-K!c!tBKVilA`i41=ihpXVv$E>uSCBjZ z!2XVoPr*4GsN;_t^ufG7wbfiEHn>#AyLM7s!}F4);^N}Miq*E)wfUfxWk@PTj3iv{ zzA(o_G1|glBQrH+d3A@|Ye7ZTl!h`VE2eApM2^DH;T@8Ee#AQjczA*3I>H~&k2@%m zL)f$bY-{cUrULG!{{Ee^c4A~?PX6(uGa5N&M#X7EL{tKSyB7Ec<_ND)FB*NI$X{Wm znk01E;(@RYqU|Nj4}>|pA9kh729lXJw^Di>93A@%d=ZFTZZ~^6^`-;Qjaxd!h_SOF z-EEmhjB1g140{eGLa}M-_+R<`hzN;{hu($+z0H11Z7UN%QloEJ2ma;%dIUffj^^5W ze0uWGQ0bgnGGjG9x5dL+s2Bl(@Gri&7?i{~Vx+g2JI|&g1E)8q3Hm+r;^Ol{5enVm zVX1WA-D>ISKCiIjCNSxg7n}SP`VL`Wg5!7B*H^b^xNZ-egyTQ`C0nsU{46W#+ioX; z2nPi<4JCz@wOn1D^sY@uhrXJLDG8(xTRiWVuAHovnoN&d?nN~;E-rR=eSODvc0}g3 zCY3Wz7(s}s&It>gTRM^W_-Z^Zvk5%Ybyj1KCJH9SltfIaOoE%c_e|LSTMq0i4o(Cd zN}$r}-djBq0p3Wi$~Px=Q&#r%K2Z{XVDi~{cn|zM^uuhlEpn|yu=?4bA8kHaGB@M? z==oCNwQ7wa;@IG)vNk?QU<jwmw@}|NC-5HRtbB@I@@xpQh=`JtlZXHrY;=CM*^wDt zkEC6yBwOwe9q|z@`EWiEU8UFcI=vf}bb%MDlon7E-A%Xddw6u(5-GMeZ+HLs!wan( zU0XZ7GaBvi#lS-`zWmRxqh`rTXy3=uM#31OzP{?KGH>PMnNd1D0Rs(<hk+z9iEPG` z;4VH(kVvVrV4W<bW%4-jO^^C#I?iW}9jO9fkkXf)F`Kmgy$TSM0|X^GIRHfFPy5=- z_*`yl!`@nA)11qKIGtUB1-YH$e>I&AU6^0uXsGG39O3W`4>cS#G>7HC>VAH<pU$fb zl=^q+_1)|o!*`dTr!42E=$x%Ody%I}&nT%Fo1Y+S^VZ{~YD`nwA(Sg{B!WRanxs+_ z6BCIJ!ctosxT?1*oGm{AV0wr1618>&^u2AH99vkx#>G|9(2!Mgqh~lzC@ww(K4>ne zB!X}vS~ZArc)GDuOQbN5%A=yA^FalKSvz4~9a*OE9B%~$eTIG=Mj?0AJ65>4S+L|z zYkjo1SXiih&%}Phf9~u)r>*8(#=9b^a}pXBH=0*!byHM!=zcM3V)zvWob(-GR(Bt- z=?Mcus7DYsfNo&`ZiR;YW3jvooAS8ZWdWfvo2~Rvi9$n*hcR&X7&Wu~!Rn3qqnkVs zqY<hHe%b4d2EDQUA<s>CyJNiQsHhcpeH>3oiz=#p(M3D=3jt^J?J*?s(KMRIe>?o} z)xYiMAen<2Vh|$nwzj{6fkJ?mDi}9fN5Z49Fkh#jG=JXqUNX0|9PX#=9a<86!Av5s zVg*tbJ+Cbzd0G<DL4gdDQ*lxEFw%1F>ye!1wC9~;!%#1l;5MB<CZoCG$LTAsZ|c=r zZu4mv(fZaM`==p`)r|?jalXKL!KKhb%z0GF5omC??}9Gpt-Yzp3o|#F%%9|BZ2s!d z;d5(DEFQ*zOs~B)(%Y!~g5^51NkULbJ)@#%clH(<3S&|-NNZV6ML}4H?(5fI<X=tn zFbV9c6D6j8tnc7Ymk_~LJ2l4s|4%ca;f3;o8;nd&Q8&?@uUWjmCdl&d!M{KDy#FxH zCobM7uttW?0A7rhghVS(4uoL#uU|j<`MnNAiaa?xBZNAkES&Tu@Tw~-?32*Zp`^B4 zc)C1|`uMIsM1jJafZ#Zidro@gZft7m7Q*7x*+U+P*zrs2WoJPEzq0oYGg(|*dhG9Z z#_`Qr|FOyH<unhwlY}(<)6>Dp>T1;=ugCS;<)dV;7nhxoh#yFB%S7?K@}cx4t)y*r zxDG&Ewpy~w%==VXjvxTXKkuGmS6REO<?_U`VRnwqLqkeV{va)FfX6Cx<luygxuK@U z-ZCqD^Y88u{s^x?W(7hHmYsIM0Uk;@eevkyTMw|o`}--R5+hq18+(_E{SexPW)A7B zs7OrYywP9rV$;sGu0|{Q`4U+B<Sy<+H*h<`D8W0!-YpEYWoxwFx1?<3dIO28W=u2& z_#glEr(7LQPfsKo4T(sJ%0`<D6e;%f1X@1d-GuZ!*`5#DzB~O_$4cV+y-Tk=J^|Zn zpv>f#bqYPnk^ghUoi5NAM-T+dlvxIL2P`=__@>#6_FIDh3xNP)q!O)18&%cn$Vf`+ zhSh+Ziwhjn)!Npy-C1Jtck-mKG{j9!M;&HcTMx#EKVXMz=;^ue@u}$XVRo!^uKeEH zS%DAT3wgi4%g)JZ@)u_C@p@69l&M^Wl|x<}^7_rc*2kKSG{oiZaE0zJ%JucQtgKfs za1_b1=#U9W9OH=hL=H*^nH28#XW9Rm*zIU=7G^9S`eH9B6iqZ23jxZ=bJoiRLcg4* zrmfQ+40Wos^mmKLP4*!IYr^ogOLOPUqFEqLr#o42kf+jQ9TFNxOe=Le-+JAV^FzLh z0$3+Rj)w<K=tm#?lZJ+b?r!GAXtRd=Q7t*S&WBrl(_((O#M5=)i@^hYj)V+*$=lny zpqkRkz$BH!RF2{`9o|y)<m~L^%#7P!T>XlQq^M|)7)fX(d<qMTV&30B0w64a1GrS1 zKU<nq#6GK6(?K^5DzGv<&mP<Qk&D_23gynnS%LTsDY`#d7IX6kz0Ok*hH=6sCS=nQ z0R{wxfKEjzhJz!Cv38ft#_xJtNYt=5*MOevEFjeTm*@u}(GQa)G|V9HIY^&D#l%$d z&X<xB%dAm3We5==;a5^p;`5=n)sDdE_Rri}q8RikRW(j=ag1^C8FS^8e_C4NyCrmW zeXHER#`{)%{3$gyY(glC#G#3et!W<GB^2plQA|ipz2$pPrUOF=&{FjqE*Pi_A{ol? z*te&1mD@ev^o6Qxl7aKRUs#YZF*R13JgMJJOo4Xx0#wBkB@?>-ATCtMma8|~Z0nhZ znnn#5`i>zzYqDX2Ehj%;;hd39N+f0M%;%9PvqKaszG0_CV_!mog6yj`H<(761H`K? zcCaMTTl%#WRHSnn-a3mokKd?Z&mgX#Z9$AL^V!zxY`;02E;#t(A(rOqA1}!@)XW|* z8p#!IRO$ErAf`{!mWhHH-7o}ImDJRDRYXZ@RMe!QgHwvXL5)sT0Kz$vBHYhF*a-1Z z*#KC$evsFOqSN`TgWVZ{$I$Zp{TtRyL!+pm;e<Zrb&T(j`Dpe{t}MM>^ZwCMX%R;k z5F1(2nw@Rz4#%O6g)LRDJ#}uqmsFXdb1M^A6F+_|dsaKzCQImpj)~QbC4qhO1{n%d zPlG%rRZ=SAVj*`_RG(j6KDK_gcXA@p#D^~yt+C_#e6NOXY@Co(dwX$)0I^{g6(zP0 zhKDA3TAHSjJqVL39ZoKMM%Gs%Vp$u|gMX%9gyW+EtOS>;`M&hq^l4WBqf|D0efhSL z@EdLI<*|hUCZ?1~jGCINx{8{sD=9Zda9}3PCCztqvDP5EV<QQbDjZatgM{i*#-gOx z-`81jxCFi*0q*P4ptrN(EJCCFhe@-*jnL8IYhw(GSFW;N))I$%rK>pk*(EjB`a|mw zG-)zAfD1oiC<h5hxVZFk96xBg-Gzg|3*4T=_eYIHbR#^ZLW0&~pLeUL(@!ku&8G~Y zp=`c9$!Rx)VIt2pj+q^1TL+{p>dM<!&f0HAbp0KF=vv*$|AxB%A%!y<l`_w~ytP%O z^R3B8B9wOvp+&rKP<I*@%#DLvYUfQkIUOL7yyMQOoEb1G^0zNp+>{DH9w8%)4*_!Y zJAMSrVIT`Ifm($awQW~2()N{E={sa8o>7PmHEqOEu&dB7{{t;8pT3yefN4*Zh(NIb zaaLMqHM-~nrm}K$-M(shSy@paLg{G5hrr6Be$o5LB>PKT@xZ~gBN5y~5j@Ut(p^ml z$#d2_7$PFWi$2%v+Mtuq4IkcLbo`-(g2D{@NQY>r1p^_|#A@|WZR4GuGy8xL_j1wB znx@gM2DCn1>L*;@FCH6hZ+;5HOW8O)<N`_O)ea()nTu^6+Di+1-5=oozVKC{I>73% z!*5&^t;8Hk>#tjuo!?10lrNYoQF3>8Cp(U?yP*jr8$a!o2pb7R$8<gsxW+~Mx?6R| zeeq07Ks)^}rL?(8v$ok}wkL4P&GjGSY5A#BLC*ZH1AA&I6E%m1x8wNykd<A2K>jCE z`M|+=630=rU@J#<*@{MdD0|W?6p;tcSAjt-laWDh?TihM{zy=Ce~r=hZxL#@yZV7) zKRVq#&Z41;kElUaiWVT0pg7t60baVF5rRz5T{ZvKpF4~af_9+>kJVjJu$@|p77Dv4 z#m5l_)bw2H@536OUFzYNIvbU_Jn?qPzI_O6%mDm&V$!P%DWyhIWQg@UL>ro(-B07m zwT<iy#}wzxb2%E1Ay&WS`gUVuL@<>I^9z&6IN-5JFC8e}mBi82{RMzQDFe9T;0QwV z&L_x;BtyL($}{uxZHH3lm)y)N1=bo}PH|%k2hSC);L!)(17UL5<ZrGuA)%oFbyg1L zUBN@!Xz1RS!_L5BzmWH>Z4B_OTPkW~Ai_qfkMXgR_Ub&Z5<w*y)|4tw1nM^Ke80v` z(TLD4U=TqC?vJAM{7+_%e{K#=<<l*Gv?&T}<6_T@`bYVM);7r`kISDdOpdnK);Nt| zH0F#?vNbGfb~QVt*Y6HtE!Hk@i4mi#lxSADyYNXkxA)`mJjR*2xjx59jB*B99YvJ? ze0pjzF3>bK)T%mvxgxXP6v#up*0i@bcXf4CQ#)(%Y~T-k524jgjE$Wi9o@x`AQqFt z7oC6T@m|<xe<mR&(r+@R1d$UI-B%YmeCX+!HoQ-VyI<sh5rle;A+-es_dYaFo>(ue zdkSRgmc8zOHZ|#Ic->v@m+`17C@3i#XTFj*Fffq_Gp|_$_`+ai-_9rz*Gn)4MgSih zNr2SW!KF$Wdp?pUQH@w^0jzqtSTz>(mH-kQ+6;FqEX2fxSbKLe((WZ2eojkH(=p6p zC5rf%{|x|zzzH_*4&n|iD=aH3_+nYlkG-FjjkXk0!U1;>9ug1d^s0>+qgleGxM(mA zgAtk~sK@5VP<xBenfHHV(6PSEB8Hu#gfEMZ<vL326T3y?0mxQ2gOnyBk}@a-aJf1t zL<v3)!(th4gn@3Cm!>|oKzRu^{`Y}n7?_8)DU~k>ljeEse!S*!JvAyR*^)1is5%+; zJ{lP2IdWhpaNVc}ncjwmhU8?4mKME+VwKZ2L>A(&XlKIs^}>a?TgSr@xLAVYBrod= zJ~nvmIGR3{2eaqvAUR7WrO@5^%WZmkidl_H2^-1t;S__BESbstO#8XPa4Z+-6;=2^ z6#_ix>dNc>(735dL%W^9dWmsZslDCz{}z134e(xN3M(s5H#i<2&6F=&)QnX18kmtB zk;hsvKV=0TEj4q{Qc$GANsyDDrq9Nud$|=Fcc@#5o}+QTa4Eff+GIdUNM?`oCd1PI z!vZ*t4VYL6IPD*?dmKkDEo&uDBx*dLACuDEh7lJ2_g_#pa9co#6QrR!-H(!@6ETUR zZ`g4oN@+Ms6uxc{B7<maMX!#4*`PJHCz`TXquO=x>KNyMYxDArgVC5Uv*%OjK9ch6 zP;1Z9AN%`IRYlLIcl>cBzgN-*1rcks&8F{}nsFDdacYeBOAP4fo-K^;4IyqX={2|) znME!={Yh!=+k2KJmGiUy$VK~Mhj3DGirVu2%;H=In5`SWd4us+{ldRnXi1R~@@X3j zwxVD1-V!k{g$0814%y#q%TTCa0K=q%3H!p3`3d97VA9z8KK?0$dvG7x`uA{u3BzgY z%u&Jgg&k&*J$wljC8-~@@yLGof+5$eI9EDYh1Vd(<Y(>|E&SZ`eloIb`-p+vP4V}U zdbGjQ{SdAeat~%?`k#|+E0R!njEVSE`ZFG;A$D65o^^PH3j!W)e;TlhUZ0kJ&8udy zykp!YsuFX4uVYR<Q1X>AdZcrws*_jxk3IuV=o!U@hYvHg8d1UamnjNQ97<FLjOu`h zaM>6o%~GvlQAV@<UF}y_dwfK(iD(prkd7&}xT1Rsmd^?}iTP)@g>xzph`O~Nf0e0O zb=5VTQ_hl(-Y4w5HVwt}6z33q{;@HA6C8U*K_{v5V5I5Z9imzmG^LH*68gIdMqkMF zNU8WXE+fYLZ5g?xQgTrxMKzJ$Y>f~%@A?tlww1ZnBuk7;jifU5R6e<gs^h03H7X*= zaQAMc0vdCa%wdqNS^0i-DZNctB4sg!!{t0oNsN<7shG8{N~4zYqR58nE|FWh|Lh6B zl^_%CsEM$NdkvFaSP*3n2imVOP7a65qJ;&sJ2Xa~>yIWq{Y6%}_oC+y47Qiv0VIkI zduDc91peZXzDyQT1+%He#s#E!#b2AOj&GoReBp@_S1N9QF4Ery4@8MS3EUQ6jg6k7 zc9aZ0NbjQP5~C6fCYn($IsPE#VLj{;R%~x}rbcYE?jTzBWOnZgnawzMH2`$7(dAao zEiO;7>+*SE!%CS_jvi^XXY;2wk2IS-=9Lwl^r$=yPxCRZYsxL=OE1-@jc|4fUTyug zhEOk@2}x<{S{?0e&T`<GRPGI$S5BACr<YXPq<%4<%r~yZ!qV$qI`NCJkNi}D1@Cw@ zBXDaNz}h5O#=y*s?dV?;utuPzk)dr#S`+ZYv7cu?N<DAB#<Ooty7g{1o7#O+d6sf| zAN%dMElcfsv&e`bk<q1iah}Cloz34xfD<Di`!_{LVVRP5otwR&qG}hJIgeUJ%Y;bV zW|N;0Sq*$s{?^~ulo0Q7wpzyh!4gV{wdd8N*}8uX6J)cs&S`$`l4pZS#q^_m34=)W zYDNEt^RUe_Do^Lje7(hJeH<lASCUAgnwkX`9`y>3eH^w=M_wt9@s0deYL$k4XNU84 z`)4&C<ragln!}wEvoRpvHF*+kHlL1*??$SrSI?L&G;1E#F)B@1SZlm)rv}|P4wcOz zR)g7t5?we;v|QfB2}RGeOEo2Goo5a_8J09v4fi~Y!^SCgh<6Lt%B6FzTXG|%@~~ya zi-qSG)cRck)$^@pojX7q6?5d|+uORBv}jae35l!1W#w{~Wdo;aVrksyWZX80!oi%T z<}o%68a;a|W311_gpH~F<<a6N$FY;bBqCB2bFVx@YhQoJKRIixZ?>$3y|jzX!v?Yc zgnEK0Bc^b6XX6<^v(dE8iH&)gSw~96&qv@F9WyP$GH`l%Q9#aJ;!pV&<<e8>xayv6 z-CKJL$vU<NXIW{<X$rW#t)C*#^9NHhIU1S~U-!L&Dy3Gv8wfAb%guG%-ixfX3lFB( z#4qx++Kdw*7ge5zlnhVj4Uhd(fS)Ed8Aq#{RW7RC+NjPe7)R*smdbe65XzMFUWpg# z8oR_|nD|j>HXl<B*PxzKDAm)?pa^l-jZ=tDry1U2f@jd5A#85^r*<Lgr8qYb-oKl( z`M#Bc`Rd_)Im^+)(?7Bw{^kXeF<6yTihYm)ZwjKQDC8{jTC4ubT<LIWg~{lFi?H4X zWo+2FO)T$;kl>Q)-^n_q+!#xL%+J~0lu%Qb>uuMU6;{&~_++pfuiI5m7!7Du8ZvOb z`GaLQG_O>Y^)2GF=WXh&*(=1~?=Oj+Y}+=G(Di37x{}~~y1xou{VpykvZn|mr?|_m zoJ%SzWm~j%#ww61kc15u-)a&d2rhr<Qsii*2+4?Z=gmkw-8)0J%bu*r;7XCVlz`7U zoT-}E^jtC;P^(r{o@9@wo$9Zmr52wII>27~V>itf=`(Igxix0(?MVJ;+lO7TIXjpq zuUyaWv=-xT`b>rhTcnIPo^nurH03qg7dvx+tGQnn#tT$c(<NOfoT4=F0fxu_37Yhy zq>J8DLt$=iCpIU?W0vMT{*$%UUW&aE%(Sr~PVTv}Rq9j6S&!qP?VPa|)~a=lA{ypR zbke!?>@5L7u-rQIkHOW#j-qqQ*GKLo_AS5k_O<BOgJG|a@6T3iOZp;*+)So1iYmfv zk&L)el`jU~<SM(<e~XBF7@VQhKEq09CoFDpu+Cfmu{c&|z1@88rsno!mo|ul*a&|x zEP)tnW!+KG1Mu}YUM!rQAyt(;wNAwpDdY%QQvZfGNn(cYNy>`04%Q7y&3&hgv9Kkk zy7!n>v)u*YC}r!*qSoq})8i=(OP8U?M(CLK-zV1nHOuk%Dde^FW;UE%Q^{(-i(6R- zCi!9lEBL_wc#~RDk=fO4I7c!yTI{-TxH`q5et3($%KUgZ(>7pKMP)I6zV6alT_e1% zn>k?frm*1pGhzl;nr8FX;ew2-p4#3T>^n@c{lA(knYMf9TJlR{Gb=Juh&FhPvOYpY zx)K;m2?eiWsXoU!TAG{ZvgPP$m8FhO$uwB1D|QI-XvdHd8ykIf)50qhFe{pQ7(=?F zh+wEr(pz_(uF-SkJNBC2N-j)~CjPyc7Kk|U=YyuCyP~4H<Lpqp|1yd*t!12lq6MQ4 zO4`vmH^O?y`G+9`!?^d?n3Ugt2@P#Eq=(R$OV<qj3x&?75!nh8m>oZb$=ZXe=+{}B z`1x+c#S526Cn$~QJPgzIZlbNsNs%!(a%3AjUVdD$4h=ZzaG*4h9}gA%N#^&ZwRp|b z<4^wNR7TpQ-WU`kq}qqW)5GIWGl`3q`z&FtwADM_rt&R^IX$&w|NfXJ5pbfRt32~d zwIi+$-dbBTh%gure<*da>YKg~BQ9zyKMlSa9e}8o5#Zksbh>WIqmqp^?>abIm`LBd z&G&w@l~8vwETjFQJ5#5SK+87y08bR{v4JX=p@FFqQ(e&ZiF}qK0=tj@w~lE!^77+r zD7w!x@2m@sYe~WqXAaVzCoDOs->P)~c7G0)T)2TuNy>-RrJ2_nTv`xD(Hl#N929I% zo;Qux>_t}f^-yeV{L?kT<#l;lC<=G^n_r-M&b*C5Dj`ZtC`3A}QN0q~siiM8pjaTT zH`sKWA!R)YncT10EY&b~HP!qLXZ=L$Ff!1vX0I)eol_Ul>ZcT9628sX3vZ>a^6#VX zc9DkU-TNH+?~{SV!-<XOUDBQX)6^~+R%QQ|8rZK>@7?dH!(B9Z<|esdIyH?R-ii|y zz*bHV%U`%ft6yT=d~urF<|3J9aD1bZ6?`fm|KV@YL{|1+ExbsUMlTrq@^))zN3|HE z`SK<rXideo3Bgx8dH@(|`=W!rCm<(EXIf<LH0W<Y!+NFlDtZ}(wi=6aU&Gk1slH^4 zDg+)v=lIk57*trIT7+dzv!#FLl)~rcJea+nENv9bwlP(A`7?Y3y=Xo=ddwZ`aB)k` z79XKCjdb&~r_e7&Ts#@CnLxzmkMueBQTDmx{ZGk6z^0)a?>&2Wc9d?u=91%Y4E=yb zz=!+de0*B^JzT^f3855K&szGDL8JVYKhg4&n|_6yRlzQ=AC*MM%w=g7f9V%|X3yQy zzs%fM#c_c`2zI5vibuJnC``T<kJu9r+KDNXj2OXPn2|)8%M*;oh&w$Mekc-+vzVGp zqW>h;vX{o>e$!=d^zr*IX2(-{*R`UImWpo=lZIdBWYi;uOr#JXC{hs5fnSqGb@>n{ zVWaNf#nMtFLz0pcaK8-_7OE8yaiK}wG&Z*hsxHp8<q74A1QCT17A)#}EpD<#*02j5 zvMk5F=9mheM+v3h?WZw>#1U;#<JlHQhR;YUS_)ttV!t<L-Z-L96lp5DTkyi^{rGwn zkf_XCFz(S~IgpBGvkR0MhPyh3C%v!kU5ZM{5MN^$AAUxLW@hwRR@AnXN)TuB)M^ek z(IC1<(T^)2cwH86aWsomz@uk?Qk^v*III=-dpG#a(t?f8sDx^1r|3&XxWkPmY@7=5 z-lvf|ZV!Fw>9?47%xzAm2i;bcswwB^5UOd#D}3+HUZH}2!AS@inE2`XzaI*<a3R<u zsn);l9mh#;5r$JQw{l)JW-MQ|-qWq4K(+bs@d$nl453)k$YDr6ARMgQ+_kHBcv!c; zwoq%_M4kSsc>8yAoyo4;!pULkjykkPbR&6VpneZgy{47CadkjTU@tc>6v4Xm`NW+t zp@_r`tDJ=;F8YsXmM6QWF4LC-bq+|0sk$wsfG@Kz6~uqWkH7T+v`tFAUJvt4d8b8~ zBBR_wZ1s2AzVo1VC1*#K?QpW8l6{UpJYG4cByaAkV6mQtYW15p1rh7-m1u{I4Y`~8 z@>A0bn`4NI;iyS%_FUgc=5k&$<r(9ZC&i90FI3u%z9Ln~QWRIYBahyJo`0$`u9Thr zET#VcHFup+Q8aB9NlH=#6$C-aIZ2W<NPHzDFyxGcVaPdy<P1Ybl4N0+ff0}#lpsMs z7>6OFM9Dcw&~5PhcF);Ad(NKyvGu3JsqU_>>Uy5)ThG1KnKH<xSqc>vlc3^xMov?i zpH~5HtM{J$r-&$CUlFK+Qb9MVOWkhTTU-{gZdh)J;6ASB%F-GQN>a$m)oMEjo|vuX zm>>@d^i7F2Hg9*dK1(ZAkeD|(qTkxHw+`8y8CAj^I)sqWo_xJ*Bs{Ee_6d-QPfIgB zw5W|igI{HWTq}r8NCCSG0_<)cq6v}Gr@h!Mb~FwnS*^;^-`h0X;Oak(LDIx|_)OSA zrjg2A!6=!v#O8o7Uq25w9~tXXxi?jT?CFEQ{Rspm7Te7)->`rMO>^H{`ps76qB+ua zVB#}|DW2*tfM^odF@9ZnRVvb?lKzSjNzRerppA`l?zWAG6@Uz#7kyVI&|~Rf0mty9 zVNgScTCUUvrWU%qh6cST@H*4A@0C267aWfNhXQKz#ShwRkTW9@a|qKBT%OJ4$lnhq z%I~-@m@y)~vC19rJuE-!*}~q5)S~4Q2{uvRXFMNQa2)hzR6EFJofDT{Ypd>#h@Udk zu!LPG(BUje3L5M`GPwyb%$z`_$558NQjBXej_D2U=k4VszZZ?{$3z)D_uY+BMrLwh z0ud92wb)a3G+T!d({8z!B6e@KBjVp!0GkDb#AXD2D6W*j`7~ob&-M7WZEIc0DkKKi zwxhWljRwj%(%2W4oR8h>i!Ps&mb$HyzdMtG@4a@o#~jX@<q+!SMO=15h!Y}%a|5mU znRdf8xW%I<&>i$!L#go{&$(~ln(OvaN1uuCo#x2I%+@wRcxSKbvtSiuQ8<ae&;j0B zKA3MO@Yhc2<3Nj#ms(0S9mUN4<?i9fsECExKc43EM9}5wQRhqB-p!QF;_c(dWX>Lb zd45g9Uc+<B#8aW#a-rGwwOkhaL<QphpMT_^uzX0_$-iXv@@TZ&>|H+auUt26_a2<r zxK*T$_re+?$O;4;;!rckOK%$=+ZdfOBAQhjD)`NR;1VGDT5*ivPS`s;t!Cw>podgI z&d#?05BgF1^a=NTkS=(6TWZj|VAg&f>%P6oLE|0l8?`!j0;2yjx27f%++^pzL&ljg z<NH;+a{lt@k;XZtbbUu>*My02%EMrl8Po}}!+CxX+uoWx&E}tzmQ_b)Jq?WqOC5R% z)ya53p@7v@Yv(lhf9_Ypt+dSy$q_KKV)RScGgm)OILP0H9G>;2B7Kms^WjtMxH-j; zzHqRgp&614a2}0*<oq9>f7E+$>6o)Jd6V)OgphDmVoH&(I7y&2Dd@1T2HG5_^E*Qd zSMQK!JQFrUKh-sCC0864Ft+(xU>;^HWo2Q;M|yRuH?2gS&n>u4&#~282Qjx>>Rx|$ z^VL5<Wt#C;cE`{E3nIeaQv{y)sbOnS+@~TT;b>X}3yg`bQ{y;5*7(;x&Kdp{75HyD zycU=EyZ@8&D!3G9mof+Rr{^dN?vVNQn|{0RR(X!+48itOtTke~y>>u7Qp5=JLvMU} zgYOjc&PMLISz~?z;z0*-$E<ixlO)Jpx|mpP>-{(<gmw*Y@!5b)#!e=I_I=I=V2uDu z#a5&_)cVEFU}xu`?xD9+AD`qZSp(weTqAZ!yZj60y);HR&1ufqsf1;&2un+I^ROYn z@Mv;AsB|Y&b@hwucd=qq=T=QzTb?id*z$O@Tn2ACNCjgcQnzOTu7~89f&lFD*5_rI zVHLd>kkCqr-9)w<KplptglIp!f^p$8&QA)7OTPtnIeZg63y;i4ch9+$m>W3-%35YQ zROMDXSy_j!7ma^K*>8Cu-jJPg#JsP6u)N+rKz0j5Smx{X$%xkDzAxEAcb#2=u{o4q zClx0_Ytvq3ur+*n+9z9<M><f%5`bwaGIGc6yFGRM)Tr6=`?FRa%n8K!l4ExUK87Ew zF0G?6X;U>gh!90FObOK+D)YjcDj6lpC%U7cuQ(rKdr=fxx+6V`Yy$)<k(?%T9I2Ar zfewvazxO0Ld-dN*^z?(J(vebWE9+ItThaO-5U?Fz)73{&@)~4V!z{g*ELtUbbK^E2 z8uOnyLxiSE-E!lP?$DM?YrUL!E?dvkTd@YW64ib9Y#cqmso3_K1pKZP_snn2?Z{8P z=%vw!vOJ_?N;mm)bT5S2u>PvuI{7||0aTIBucgz628mw7XTHRDQ5y|`dkzNcD%lu5 zr(NHGOGJ#6MMAx@o|(11l_z29dnx~|lZ3)5)x0|Xp32}qaT*jhEe)QFJbrm7)F_h6 zK`m`G$I%Qi|Awma7K>*NsKRG^h|~*9#iv`|QEwBTHZk}ia4%L#B1~HMnZQ9){PU69 zUogM;kQ^%$I0?#c=NmJ@F+QxCYVkQAbgE|gxln#PakTRJH*I*ucQ;x4-JR86W)qJi zri$;7p?9N19n$!M-H%C2S7&;!P|#)`LP0Sp#Tbiv65HPbvj?@C#-8=<Qv+0BFa+-~ z2Q;{Vw=`DsMWM2CW+bC+zH*c^EAnD!vZ5NPlI}}?pQ$iizM#WbueZ0JNcG3-bm(mt z6sIR|;IO0dg3wv((kfjPJJ}Dl`_5GY?80tE^DThFFo2?n^`afKL#ucQRInq%=A0sk z!Gd<DkIz`tzaYu2esRG+EDQXIQTE4VT+DzE9&4bM+kYv{9sc4Q{cQuCK3buQ$R^2G zX?TU0dY{eAoj+jh%s%c)!GqFUbqGfJUAVg=!~>0(j7;<q>Y8y1z=w|$bYs{G*hKPf zeM=si=ByOF5B#4CV&61svM^z6MJ?*Ji!r{QXsVgy7s|E1v=V{s5)=zG!;_i+1!SZd zb0EHQXD!-KYS#E+1lszMQY~U5(^;BeKSqCD9wxI^r{ARYM^T=S5u!=K8>O#GQHMR? zn**xH-6WC$&{4KuV2Ge7ZxW0GxeK>k)R5D}9{}`jt&<BFG|Rf#D%W4w_(k@f-A5e^ zxI*<t^CoB;OTZi8R3UEH%bA(#fs_Z$Q-f5<0%;>2{uOJ!X&tOxqR8sKUF}r|V`Rou z{3O&)Rwj#t?&gQ8(BzOwsK0X#IhQt%aq3$&?H5WL|AufV8$VHt1iG$#LJ26t+Bl&b z>$${M%BQ~&4w(BAwD(o8wqG<!$|@aI#%YFUdhCnPlz5doi>qaWFkS!%HzvL|k8+t> zId3OZ3iJ?U%W)p9XRv!2X(k?cx<y*1Q_HfdA&OhKlc;aVQRcd@TD{LX^nA5l6<_;# zlfTNCqgWXCE|;aXTQ+<k%0_gBM18f}sIt;w|M0nYV;Af23zkf!lK7)d`6pHiWx09q zc_EQ3hlGSzoIA0J)Y-;cemdb6-K0C;eO0W&<ivzKg?m-*y&Zi)jUL%Pn{ixwz^zj0 zu~AD(#tOCa%;CQ4?tg;!-~ZBok$5uXLl^y<AHgCcD-7_icoBowN<ikfQsHIL+YXc9 z@&&k(q2earhy`=<(?Dt8nuA9~82fTo)0J-`lFlsh3j_JohVACh-S<?Mf+Cx?Q;#G_ z%7;x`bQqpycDxT*jmSB=Fpf$nQ$Vin!zmcy0Dddtx@cx7s>ZS4g`7=M_&y-GvK_ET zxwr30LZ3Mef8T%{mJ0kw<BL3{#^*K8^EF88)<sBu20bt>{`Q*N!=HwV(e850x$U$_ zkUAcY4p{p%KOCgw+#39&B-En2b#h=bI!!d78oF4wgtd?-b#}dDy+G`~LF?PJ@w807 z-^L`5L%VPZK4(xHWhVJ2$xy@26DYmp<|>G1u1J(f=M4@Gt?&{ZT39@2MIaVq|FY{Y zo~RM;Q`#W*y4^Q(C#HwbcE<^^@3<mV<sHt(Od=cuxE1+LT)nixv@aE*c8>*U-Mk;F zGUxY(axZ>=mcyl9i9BteLV6_erMfFvCmg$K$;eqm<TF9qju+^^Me5xIJfYV00=v{> zB}98MGoPm?`Uk;OATIKV$nK|28yU`y#xS2f6Vw~>V?^HIc%4-J#l<ft{KB=V?%ce= z2di9Qw{*@BmX3YbUe=LYmkzUI35A7j^;-1G^Opy>YAbaSWl|e@bb=xu1RxJbSd90R z*&)x1#!yaM4}$u?l?bN0VKzNJ)<Q*=`<d4(Dh|fhMcbOBYzpUnY2&N}*~&YbY?q7h zRNu+MGiu^Bc3MUrMcqHiZQ!8H5|I#xUS=pvGrOs|tr#Cg@=VXCu~0Uq;8%JeSZ!B} z2yV*CVQK~ID;tiH{$}m=UUiPM;|gLol<SQvC`NZ*l&Bjv_wBBn-RWSnY%(m$IGT&m z&nBNZ9k(5%DQ~M=KP24-?A7i;YjaDB|2*>DsSf*Qe0=T;^UlFJ-KG^_wU*|#TZa!X zZFl!o7PngkV*&oD|8Xn7@4o-5J}$0vX;h;GqvfO51=#Un>C3~>)q<Q8lC3X@GewF@ zqKjqtGLj%?RCDO2UE1YY{a*e~%^f|nCGv?X#l*hlLkpMr>PtI!yA>mquIf{ruUz!! zKf_L{=kUMt{W(DyIXx_oA!BfnIy<2PL~Sy%1&jL9HO&f~Q63N>5&*F5giwDJiY|@6 zZF<>FhL0qzUV0TE31~GsB%D<_f=DuFpU_2{<yniac^H(PE+2fm%nF`-Hv8eC7jV5J z8h5QPTvy+VPLEMRFxJg#5jRMv*mo7KtT%r*fR1z*6_vbv$#fNnp3XE@0;-QC)xovy ztt)>aO-W!=^(JtSb^p*{hmG397lT1n@bN^C_qoav0GG?r4MzbRoFHMCv4B$~Zl?9s zEUAyre~tq~K-bD@S9x%70<Q&u{_g(|gT?DiU)ipQc9HnO6`H(M7)%2>HL0??5HlM= zD%?)LCc0sg$hbULJu1!mIxO>Adx@Xf$Jdv~^bw%xG*3xQ3OZv4;+8G@z(O|hRqsxj zUvGTtADF5WA^<GZ&ug|IP9!n7<kjfTx8NYwjqe{*@d+yf&ky1B^Q~w7;W&Tb;&ZPK ze}2yv#5E1vG6RDZaj~$Aivg_Z$nS%KRC&h7zzctM#=mlik@4N#A@$Hy(D`Avs6^tN z>*oZeV#Zsf)V|WXOG}U1WoS=(&3fNgB^!JG_z@I*nv|8W6ZH5|RiliAB(J)XIsf^O zsQrZn&Wg0)<7`0RneXb>1=nH@q&y$p_EK1wXlNvU|MLB@GSo!0R~g=2(;e<2E&XN0 zjVx<ueDO0hE_OXG7V1PJ?XXo_92zRWG(|*q<bRxfFAoX9BE!U{l{`Fvh~3k^yM_3K zF+hZPO3K;(e(&RbW#?wdgo$^Af`St86+Hj**E-(gkh`Q0^pLkLznl<27s^=!l4D5> z3^GJ}7R8DR;SmHBKQ(4&XX`x?pA#`(jVs#P0uSwe@id(N*-Kgs|MdE)_iJ5=cwR!e z&lV;J!16MVGZWj1*`F2{Wben~;NodmTWh;2$*slsbyZ}Plyr{v-{?4>Rl_U)=ZXS- zElOqnbN>hkuA!BsGUVlgqUi*JF4atAWVGeOxw%#8@6zA9%Mll!OoZ=~GsPy7)Ar#* z>4$M0BdnI5ZB?}>q@K9m@Zvn`Q&Ev(%4_Aq-5ijb0#$kK`Kc=!wFrJ3pl@F5%aRaF zdh77c9g-r05*<!f7ak!|_oE{o&m*1(i4s0FPqkQ-l!+Vv+^|+X?A&?@O#cU7*9{IY z3U$MS0a-1G)zNeJsE{-OUfmU3Z@_ES^=Y}Q_@0nj@}NQUcfaO1z-*89Wvk{+I>4(O zswx~;p23xSD!~+vu{(L*l{zq@SDJgr$I9v%=!lF2MJNOU1$#Pfn+B?>MWNRaI)ohQ zZZskh9C&L}pKrP@rQ4z=)@cnlHVkQLrfF#H6elUDyX6(nDj0i+_QuY4G`jGC`G1$8 zYab9&`&Ed6Q&~AvDRm(66bS$-%1ZJwI)3)+8$3eJ;uswx{coe+x<!ROy}bPgL-n(b zY3?zn50<)RcT-9dMaca$K@N>$RKu8ydnnoJ>#`Ecyi)r=-D(SsiisqM)8XtOqP)<l zgb_zPZZhp#Uf5fd`<%4XG&$aj9e4BeH1-^-z`H4KC&sC~vc+?Y*fK2iA`I6cTa|)2 z6^QbyGrN1r0yJ7_kE}loI&10VDBMuzB736(0R^JIf#vNfC<2^Yc(@sj!{mXFlJJ`J zjk8Pg=@YZZnhAOo0?gJpIDHTic2yfH+jlc^uT^6w7ZNgLrhw_JbHSoZtEvm+H+MMW z^mV(HAqg8u8P|_=BhzBTHX=r1aS4l6k9b@}hQ}a3#D{V*mZ-57V5NG1?N1H}8d@=e zi)fu*g)M*Viu@JZBlj~e_&E-s;i;mUmRBL>8S53VUOZ|qB!V5s(2EVA+hv>y3C{=! zmF<@9QFjBHZ0r<7`Z0YPpK2%sw&>+-XDAbr#l?MwF>!WD??-dg)DQ?B8*P#ycAmOL zf<c9S^Or9Cl-yKFU!#}zak5qA-KBt@&tLPm3C)H^^xkSI0SuP5p%wm|U)MY{(VUj4 zA;+yPKoqYH(H6J3>FC&sR^ra!rQwaHW-t+CJedN6Ow6ebZRByKrOR4!eEjb^KX!KJ z<7+;4y<Aqn0<2!4Qn%ux!?Gl_h4-`OJoV;hbRJu8gS}gH6P?yo$sC<sJ4(IzOho2} zTgFXV6Mnm5b^|h3O<#A$Sx5ZVD*R^lbB8ZknV_&xR+p0$SPW&-JUCd9^ZrxP$YkT^ zFQWNOhO0%h9k##4@NU`EqKZOUy}fF)dFz}#h$2ATTxCtnCKmya-6fSUHfb7NfU)&8 z=lFqNoeACIq2G6WEw2{ztotJ*y>;yYGEv^YxE9e?+htj7P!h3{LWk6RUmOOq&dJmh z-I7E`+^?y6BF}L94kas_(h{$wp$1@!3E8d()h<2J@`sll&Mq}O3R51VqD#6_n3dfs zMBX7;Q&-yrLN^CvQr0Bb^0gc>d;i5~R=<;X#6QSeF}$<g7CS2~xk6%YY|684P1U0l zN?lAe;y(Tq>;?JtT(8~uK*;x^WRZ3h%NrX^FrUHYx%fUK)yFjx8r&(dfdt6?H3G8^ z&z}BQjLmjJRKF>j6K_kX(GM9@*&bJk)i%{v^p8&bRB%^<rympRHK6ux8<s(CGnkea zE8Sr7U`emr+}RnN-rUB{qA6QVtoghj?}{}j_TAd_ib7L@yeP{h5<QlarM2bIyv4AM zh1nS91lC#T6a<1TqU+5I@Qv#MF&dJe<KK*Hi`HJ>p`?&SE5YO$3;XX5J{5W?%L{ZU zEZ5&E0uvF;$&Tk(3uxg5Ev&M=9jviv&zxw75BiE{UO)<EO1c<a8U-0fqD7*(7>bmb zU6U*~9)4Tl@ZRg*{#hnd^89CFZoqIN>j<Jy;KAKnB}Jwj%w#Ec>@iJOp<I&J{tKt` z3DZ44G=TsMFvjS7{o~)z63)L|{I$D(tMNDQ)xq<EE4J|86au>T?0*mbmt%<Qi~kQF z@X=S;w6s)ASojy<`3Z0E;X1LY2fQ>147u{2wT5ai82ZYQ@|gnuvCUZ`&&10gka`ID z*g0+Vg!~l7FtWC^<;4Y@fh#;A_xBeB?()6P9S?|J{F_3|2;2cBkiY5wVP*9H0sb`p l{E5+30%$=vgjZe#4~NOxfSE4Lyy>dcN($=oNLlmH{{a163VHwl diff --git a/hatch/docs/assets/sequence.png b/hatch/docs/assets/sequence.png deleted file mode 100644 index 7f1bfda7c4a196c8487eb31ea45c24d7b49ee2f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74404 zcmbSyRa9I{*DWD|;O+zn5Fog_1r1J+;I6@)#z}B@CqM`uXsm(8-Q5W^jk~+kT+a6; z=l{pJ<G$Se)T3+dT2iuRt(rUhlhQ|Y6cQ9TI5>1!8A(+*IK;PbaPTWg@X!AwEWG!H zgTsQ8l@wF=_<jII|Dd*=D++sE%CEvNt{8!3$Oo0{)R~kg-KQBJ`NC_2p`)XRdeKuU z*a2mnW_%Frv5WS7QGsjObI&+=$YcMAyiZaas9hkcZ~I!SLR(uxH#|%DC#?)F0;aW+ z&wUQ=hZndTiU<#W_7$kxLfhZBzi)g`^f?z&?lo+14t}=hzYp3h-#^Cv=V5z=l=k<x zkxVZxQ2=AA>=65P`V`@qORCV((B!~6?ZkTEaXK5fy7*toBQ&Hm#ND0L?be44g~Y?} z+Yg)j+lYv<ZM@db7mF)zZ(sdYz*dN06nFgXZj0{;e4RztD!?j~{Q+V46C$E=9_{3M zN%;CG*ic~E?{s5sTd8`xXPxB3Q4Xh6=DSQH55gCJ^bzG@v7w{g_cUNe#mEzuFOta# zV*##R%~$vg=O++gXYu~uZ>z6ru=pp``idTG1u{7PyxFc5{M8E1)_+^XXM3am^acCC zlm(Q+qw6e{6?^|<IjgmjGFtQgz*AbxxAZ@{j1<J%rwvESAT_5q`nd30qP!A?KknLo z9Qs3;n%dI2Hp?%b90=|PFQ*D3HDR~&^C|1i_Xjla;I4Of<!sVk4%24v)TBx&5G6is z@vahw5{C!i%SCtcYx7kfCw`CXhM`x|Q3Gndq3n3)a7TRLu%Xr4##P(WbNFwNcUmH1 z9+5Ptr1Z<e6jgP+6q{pC9@I|WGIYz_c7-R>CTIHVFZ+?kw;CNcK%gRB7QNP}A1haB z9Bw{009wUN8k#&T_(DH^m%TNLIc{VJg>6w@;dee%#6Ln}m9?+4P|~FewuXr)31i|F z_(lbzM=sRE*IL`<Q%{Iju8@$B_}q&har{p|J`qS8<?jh-zwhx)WYO>Jd3bJoOJ{3& z3nEM)WJHQG+8)(gcnnSK$W`BI;WLybSbZmOWq!XInM!CDf>S^6n|0K;dVjhFQz;!U zDCe%hZnRe4OjMAQ&WXY0>78@a=T`F~TE(>Tj3~@t@Q^+L;dh(BoL_|DN#Zzig40Hw zHGl8ZrSkG{6dGFN#{kXl!!>@uZoj1^mF1Mm0=-#H2e82K*VFiLPTRxy^=6<<>MNC4 zCAWF#Ro6C?x6+twn%oa%9{DB(_ET7W6Y`=)?zNK8Noq==#I}Xh5)2@^GPUYuZ=H7W z?W5D@hLNE5HGe?eo5wZ18>E|JI2hM~gPNp5t1_;n(FV5CPi2_T+0Cj8CzT@(RPSEY zQMmEbH=awd{%<hdjGaU4;fqAa?Toi%6|&8t?RGRiy|y<yNhg#$-yme~-~9HyTfOlv z3_<_VhOvkSBL7he%Os0_F=ZDc^D#KjFx$og%7K7SjH`f1_w@?}CK)U7^>s_p1k+Q< zcMO9Yv=2tF(sDDHns1P!)JJg%@vc|Rh_MS9>3Au61~`7!*6AVL^Ngm(+cbLgts*Zf zjN&%?+)3ukQ+MRkuLqBvu!M##YK$n_ufn8^USVa2IUIm*FCAl=Jm!Mno~6V-z!^_} z>$BbN{}T9hw-<J*QhIVsjVj)cB|{sd=I^j4HI~eJ1;*Y{^WN_n+G%Fv&})@k+#aX& z-w^QLqH64m#nNQKMGNHabSh#spnRSO<?@8c{^WEMBW{~0{Bt3>snQWwb&+7-vV`-) z)NxMxAgm>s#j7dKw{L$OW<uULcYHnqrG8xB4gb1}$a0bz%bne1S$J_Yo-5t3?8o<I zQUAr$!vWL3yMP218?+sJUz?KeO$xa#?z<P1tGLSKZaKn%tUG^K@4<(5*O$zF-K%hB z#$D&6-^he@bTinLUjJ#2BD=wFAmy9$Q@H`GC9U#*N^IHZpNk<-_|wkj{?@4R1(+8t zLlSfBTLzzb=ZxFmtt<+ChYIi#H-C+?(PNc^7$dcLpO_W-?VmT{OcCG<Nz?G&*Xfg# zQ{)_JjpfsS;Y0k>YWn)0M#*KG2Z&b!UDv8Bs6KmO>3?;NlxA<FQ-5>sbX74Lzv{T= zcwg_dkxh*Euk(!mIL|cm&U&(bz0N(>ySR%DnI7N&$ob|mk?s@U!T&h0TmS8=lO!3T z>uzT<>%LNA?(Oga4$RkM3Hs`fkP(_#yQ?YYSeq^t8Q{iWH*_+r<hH+IYUBZJv%Atk z7Wt1{J5j*%#0YZTjlU@&6w<T?^BNMXcW;Pz?<W2tSQWEP`2I%_E{Yi`?QYX>3vo<c z(fQ$A4;r&Ilv<AVW&V7zpyl{GA7E>(4d!UwuYh)p{^uQ2^yjULm^X&$Mm7x#lkD;5 zqr|!sCFkCD0JGweew&^3eQ%y0*Uq!*f6X2id)}I#$GxP1lj`S7W&WEsTM@4tBS^A{ zE{Fbwo=E+EW{W@HXin3`4(?e)$u-s=RbQ*V2z~Qwvhn%Q`SVi`7_tXj!|9}kbkuoM z<abg0pnHAE7d5(mWE#5A20K9WKRUyk59*tgDD?NH`Zxanq@c9BiMKPhcPpVqq}C8v z)f~;kHb^VZrxTFV{PB{F?5l>!ABAj%ln+E`;jVhN)dxitP|~Ldt}U2sgoAQ^ky5>Q zn1UE`>}Qg@l7gumLws_6(%nhmiil_b!n9n-Ak+-%f0OV1A@aLW^YQ}M4=F@ztXh(8 z=?qMYvbK5BZ&hRW;?~yGyzx!*47O`~Hnq{UI`f&0VqaS?yu>gJY!cmWM#;dHbrRHf z2{D-%7m3G^i2HoEwwYyAr0ZZ^X};;u(A7_1?imUpp-S-SQR3xm;7FGzas!Qnkt38e z?Zwhi&{*K>(8EOsVOyX>XCk*&zbK-^^I9AX-OcYop;WpXeIxKS?pTD9ko;+-!3yOf zoYe0q@pW{?MuG5C&C%}mW$Ds;sAuK+?4<R4+F9o>GDWj53wzQ0R;40F_x|iwg7uU( zrzEJ$pWA3br}&R++qgL%0_MBFxHrA`LRU$6Pjt{D?kiQEhJTUKA-AtI=(+=S%9a<| zE^6kUXfZS&OimLH(aGq7nyj{~dn%5sI|Si~nl<+8xIN|7vi+z|Z+qko*frj~=@%T# zX6+Yq(T%<%^s6fE@n#;?ZMNMWUz%yub>C=<t2?#G4wndd4pQVlLHZ=+H^^E?apso2 z(#rPm(9$NX4O$D{Mt>dZMZ!y=ms#g=SpK4HYolD#aGVsgDOgkdR#jhITw%_?v51h> z(-Z;|3*tL66|}!1CDOY_wEl5jozlWcuw7<F6caRH$2>Bo?SdtCX+&uhppsj=6)kDE z7C|C%S5;$f6&qZ4s1M>}_o_M_xAR7wsC$b3+)F=iGf9=-X-Id?BtK2lY8)@kJZ>pk zf@(c~y2pu0!vw5XTm?H~%6pn5H?$E-(}Px0L*5TXXaaa}zpCV2t}IDRt(jQJA-5YL zA;Zom&d(S}gWeUI?^8rlp%-|a%K4q7NON|Q&TGo+=J?nR9K7uWPtQ#shzg-BPd5T> zvi-cGYjeOti>@mc9lpkC(UsTWfGuGg&4X~ZhckNVK`^;}u|wo|+eImN8xG9dYTzMm z>+rU*`+@A`NLoI&{L*6$?6Tg-`Fy33*SbDddzj9W7KPWG=uK3|tGo}o(Uiy0TL^8- zZT2tLeaGU?lKg^U(KVmBx4NvXgd-X=F?FvfYN~x?T=n!6y17bjW`@Yu4GdE2-m2=& zjvSTzo+z9UEg(3ivI`yYw%*fyZ{e=aw(QGIcYO?PYAa#(n6-sI%n!Rcs4s>B%IB4E zc-_j*H)0GoNM-NJ^(*!ZJuO*f3{x`em~7U|1DWYT7P3981J?)DZ|rHs;#Hg)=RwW* z{LOi5Mkf!qxqa5dvjkR>dM4imYD^_st!L&8kPKN47x$Tu^*yJuCu^la$ue^(b$gfe z;_zGc3vFQO#VjnhVN<90H3SgQ4{~*9<uq{=c&|&PI%!E~QDo<_ioV`ak6)kN8CRc) zd9>(MIY5S!>wQqwU#^)LtM((WG6$E3mSyMDh_RJk#tFPTFWF+%wGn4JJ`j1gE;sR# zJ2a4c9!=z9`;U5HFHHrPWa>e=Oze&acEK(vm57jaUfV@RRFF38rqS7p@ZvE5ujw%D zS_h*x{!^`^a4Zr*TRLmS7bIC(@8Np}{PM<4i(S(@1Z=2i7uPXVZ-L8OF}!i^H7P(% zv1h3+jY67hE({#lUw_+(w40leb&q+T3)-fOpo?#6u(*#M^ZS5UQh}*f1-L=pM zytsA}JU4i<9GQzp1bO`Wg-h5ST=!;?<^T^Ln^Tm<OJ`el#;e{!GrBfCNU~Qrg$R2Z zsG%~F(sHSo#hYcc>R#s#tSY4RIA2(CZ4t3gnVu6?=OYo!pmGK2T=CZD<@liRk`Wcp z2124<hqL&8yJlVTEwlL8ceuX5NO2v!I<tKbsVA4}@r&dpIQE{HZ!8WoD!H+D@4F(| zeW>Xk1-A%BbLa2-+Cp-L1p#DRfz(_R&}F|wo;qm{&BYCjJtyY9I%1oi-VCEh-SDZ# zLL{2?wV#40&!%3P>FJojLNH@!_Ign7-BQhYb=9i!=oy%O-M?%j0`WIfRuDwdgzJ5u zY7o{oBlY~}T`3l{jSIK-DR8$@1XUYB%mRn9Sz0YI;3?7RKE-qIs-Db4<JJ4T^rrbn zfM&D@b!7OCfsW6&bj<Tfq-d74C{G97i$v6)Euo8ZzQAk9sD%wWqmT_{eGD3b4qHe6 z978jsUHG`=*fA`I(&H+!$-S*s68gB|0D0==oKSAA&XZ!WyED=bs9TTxvZ_?M8hxlw zHtXSD;Upm?QL1~eUbhuYcYz;TK)Dfj`nfAR#pcmITV|RRQ$8^NvbTU;xva5#wfqFz z%iDMSAnE;a?XQwvt*$<^ebYiWMvlWO^y4-MAGrs#w?4AuAdkFxG8{P)9;R6-78XKM zgIexGkM$WEe*Taw%YeWe{R$E6#{jTcN}JB@JZf&8JPz&@&rzAfZ^kkqFDFlG<;9~q zn`J1{vWW0$cfuia2c_5uMOlRBdt)iZOpmcrkFHAV62HSGiDnOnRB53Uj#Bf!(XKBV z7!vKgbGCXGLJH#fwo-mI_Ep#NN}@$y+i<UbFHP1~Fljdr?$~{2%7?R3W?C|$=}=ee zS<sBuBn8xvaq`CP)Ti^F-%As9E$o3EMDePP<^#g)0oYbGFwFa9UloN^A4C4f*0((t zb>XaK{5u#J){v^b@U#Mm7B4b-?w<U4svTiPsrWaV3VR0(<lWl{;RgSEF%ECBSV(AU zllgXtra{M-AMSr^0on;5_c`LVI$I_0we*_r1vb_mld7P#-EO=p?=Y-+BFzUXQM+B8 z^$tXrTh=YB94(Ehb@$C}ciMw{QpU6M)MQjfe!bJkBDXOnCEd@Xs`;%{ADO1MnAPrl zhNmhHoL?h=54nYM<=G=f5*w{+fBg(cp?y6k1f5xl6rJ)4jBeD$)F3j@FBn^HE=7J` zNR=8kRtsyj_kq)7Di!x9SNIjSf|3FGv&>ctK{VdY6+VlEA#><U%LY@;mY<`p5G0~? z<w)dGX(in+*uH>Bmfu62>N!7H3pe|YkU}uSQ&SV0G=y_?YeD>9BAP7LBX}{~>vo(d zcvyU?EM~5@e!RWzh^7x8(_8*H;OpTaHC2;TA0{GlXPC9QU(J-<h?&yb=&q@WTDB!j zs%cy9l*oC{$dh^`v~>kQu$gbLF#T9DTz7ZIt5N3b*0n_7bXyl#U?V?b|3s+UJWq}R zvu8Z0djsXP0i23WreuW}6736$J|eMJ76lU$G183Sx5;=?I?R*mk7bSB>x_Z3I@dz4 zQ1~xxH>Lwa2^84Y8Zu+aa9an%vfc7<q0m{9i!OA31!XnVW0CAATIGDC<vt65??<Ps zEs|C5^?L3m17%$M@3B&GblJs(bl@2W16{ET!KE|5UM(yd-#Y|}p2S8%O8~@2_i>39 zC->MVb!xV~Q38kh;T2R-aIV>jZ|m|}o)9V1Q;rwJY*FRZw@RlOw(J9rzpv(PhNXS# zBHH_LG?O9myoe*mbZ)2W{LdH2#2U6lS+HBNaoXYxAt&6ILp5}Ha+sk^o>t*3#dc4O zHUJqAAc+aPZ3)|bJKd5eX^tr}G9jVP;u?PazG7dXRWd%L`%3^17t%e4AWo_}(K`Sm z_j2V?578<O9&fPXLvYoW7E0Bf2m+N)P9+-6cQxkRw#60+Tg^!e1M@|S)1S9)Ehp6q znaqj`I|r>B_5$^7%qFtaT<~l}_E(OXCs5k;dtI~Rf|<T#&ZAGk6@>YYr}|9|o_^sC zTFu!-QYztNwtA+XWnA_h8HR^5$b_r!g`3=))Sont9e|KloK_R_>b&wNAtpcN(evE9 zxmhH-QA|s6ftvu&LDuUhIjtv5iC7vIL67>+qH2rIq{(OZ%aNsS6fC~0;oQ<gD2mfF zwf7BK!7`q$$q}5*Fq4{TAT|1gjjn5xe2SC|RK@3!QxLPZ9v9+H!owrkM(Y=ar=$!W zegiEf1w5Pp@v1Z|#itBd@r%YV?TRPnxVS9$aW6%q7LSyM{j9<>mfHrmrSrP?B~Y<# zN{{%E>UTf*H)PfU``P6TeM;4vS;KPhphrJy3|m?yZ}tb~H-6*VFffh8G9&f+8f!sU z9__#ZK%ede#pAsePhUC7Ocwt!7=dqo+hnNd?@DNvYPHpfX=lMoRey6(H)i+li1)G< zh`0O1FZ`yaa4dG%TfEKPd+_)YT8??N6_R1WFt|(?Fx$l~xTMvT$K3fNo!(7X-q#@I z88c=kwB;4jC>|J(o55CoaM|lCAk9xSU@WJO=%25r@jk<+>O2XZJ<>3HKgsisu900( zeyGh}xR-WPjbC0)B=eFj=1bA*YTI8XE{cu|$MS~XnHME+B?vxlYlZU$FIFZh&(~Nf z>S4)dQZ(|hPmv;`<n;S2&v`W;;?-HqyQN7MeSBL)F7!&l&-bjO<OwLk{X@#V+4JX9 zaE{i`w2}<TmEJpVgp^Ua=_1f;o2Y_N%-R|*T^${WP7oO82VcmeaX?b}12iWwKD<+$ zuId!^d3AJRV~dnw$)m>`w5q){f&#$i$ZTjVzE^M|rFerqofA<@4w^m(rAWE?6(hxj zMJCNoQ`k_OwBz9r6HchKtXOp_78fL6t1<d_FVwyI=ynB6{B_wY8ix5ha1P~-H6LxX zDxmjdQhP#$64-fEpNB+)o+T`Lwh&6j;Gq%cea!YiPO;dLpNp-Mb(|hR6$G&D?Xsh@ zyyZujZdKNDViw!$o?oKm>L#o_L5bb*2@aL)8AjoV#DtcaJDit|B96gz4KF8yxI3>= zt2Zle9ZA!xUAh>#*U(yf6f-ciGZ&rq#t~S7^S2iXmIR4m2V{5iEz6aSkPJxiIM(1c zRFZ^?u@~|+N2Z`dU1sAT=DJCB7cZiwUa++|S-N{yK#9dl#!ND-DYs;X%*~DUVR2L( zNea2hx^GD~Fd#sJO5h$IX&FOJvc99Xj;zdqT47`hA3X`6$ea(fd#b9#tCEq7YRq#m z`c(X>#nc`!Cb4aqCl1W-z*d0@oURtWd|a~^{Gw$m#N|K5@>rXlKdGIulb*G0Jjb{U zjkxbTq>R@4fhnv9D)kx5?P4D3!{7zW>U#3#`lPz^YHM>aGXFGn$D(e=U6OBh7z+L! zyx^cW6yf4Q8S73V(qHF>nI>Z+pXt3Yykc)-4{E*}kJ7Aw5sUawWQaBwYogA_w+)>g z)bs^h2-*aBgjosHuN%eLC!TzEozt}w)vVNbJlOehrjKrCrJ~^iDN7=>M43Q@5D@x| zPm;tFNYoyj{ReeXo&$6}6VBWR4s5H4%4Q`IJzo|U1~R3m^MKYz`Q*VLIDuk5zrE^X z?{(%NO2=!LKHb&#j%38}&olYC)~wuC(I$%6m(}R8HH~oV8j<mP*&uW}zX|CgRGoxF zZNa_1bs($wh;tIVwM^@wGb5#*X7}7JR*D;ZFh8*^Q_`8*CFyl(*P`xWc-Ycb-?QX0 zXRnXPY1qc}Q&U$DtgBVCPyOx0UQk_!@|Ez>ya*K?B_2Rc-BDAHmS#;=b2(PNK>f*j z6F?XAS(g4+Ec}R_FiIHdhF}aTjct*4aIlFgFYAF$8@2G)rTjT1_>z+|XYi??^0!tc ztzY}YSYv%QtVYJdS(v(eHag<lHsfE35#>o3nunH6jUm}*GhqYE$y3*;lOs7!O-=ft zC0P0kkqVlESv$AZs#U@l1M~574q=3(7q8_XTVhI%z(}j7mz(+OU^S)UG)MH~Izr#Y z(R`>l^QzMHzQKmQ;QEm}V_e9W$qPz$F+8h%{nGs#Xt5SML;g1T^MvA^p4F;KU;L=U z{2H^ir^hu*ven?0In>dRNW-_S?Xb}9iIg>;SoWs~GrJO*@4@dFVc%94e3rD%2LDTa zu{nfKZ=hle%wJ=)D&Mg%)bxdT0#KE`S^?;j50ekDNE5ZGx(s|}p*HG`D!gMpIT-7Z z*o5*ZrhfC(V$BD@N#I@FZGnlje{H8<xse!lILU5A24c!|rI-_YMwrAA?-ef@Mmaw* z39q2~LSGMoLtW_T^(xK7szx?Si$E4if<X&4;QlL(PdF8g$x}BBIu~@zYLj6pLLBWL zC-Lp;K*jx&Tf~W>C@RHo-@W#7wOA3BoZJHkT7*Y}U)or>HNUltwSOL6-D7k{n>ksX z$Y-XwDt@UY;?<(kIM4x0HiK%?!EPfbJV9@>eA}TT%w)o@B`a8iQ1`OhbDZVb#cB41 zcrGp(k~ax;;s@a?p<FUIpC%T%0R~0l7DZC==9_~1Q_aGG0^EoFLlvK%oFyc~Y7}n} z3qfU-Jr=y1roTItVAh)stU(J2v!?pyi*&&ba|PIYM{b8I9}XOT)1se3>wZILw^CF) ze0`na>c7nvCU#ysuXzrFtL)x+%qIPKyIk~j8MiL<?J2_IBzGava;JN9iZaTwf-3tw z=;R6wk@3Bkm|n4yrIlixeMecTTJUeq!x>Zg+1*Dye&4pY1#02}f%(G$gmd|0d$q)X zsZo3z%zbB2Cb-Q+>*OBYNqD(Nm9itqfx!RdOPpa!qb>$7V2O26yhrBK^(Yg}-cpfQ z59q2R!^X|%G{BmA`QX*#d(s<uH(z{z=WyUO_011Sm3hI;FTBjt_%xwbrODvfNB`Di z6Z~B6S-kV{w|Ow03rh6nA<8r{Sl&EDQqBO72o`yBI`Qt-w<0U6$(MpJTh%?PZ$h?4 zc_)r$3uAUorpT>irV~>A@Yk=MWe~>lWukKoD^VkKuZAx4BMsRPCXz&WfU!f>Tqa_2 z{gY(gr2XVJCjqv{$^v?syT@uDE=*N6qFb644Q1Ca;-|!2K?_mdHu-&pG`%B938s&2 zzt<Xh9sRPC5}7-u(`Qr4Rn~%S#7T|ITib)U2JVO0M?!<&$oZgO9JkJ@wiVH+%kUth zy+FJTQ88NZ>5@O!jgG|COwPoNJnq)TbkM!(oJ2X(GEoON#d&z2_|dJ95&B3dMndlK zzqt;U8Nq(TOzB#t80D_t?1x#T&4VN|t)pJIOaTR>36AFoC)3%_sdcXH58mtoM;cde zi&Aa(-8fNAEL4GpWcEjUKjY3+-7i*atyr5?ohoG&=9S60SbU9T6+aC=gpC&?go9!} zt50)9atm4U*o)$E2~Ie4$&1!f`CW+%s9opx`d0dc^642tau3^N^qOax78|;~d02UH zcHe(8T{J!<%DxJm%&3!DUOuj^LbsbtU!s7)5uq9QgzGX07}$=y9s+O&cMoT8UgMp( zbY8th50A;5*HJUR!XWVbV%bw}y5>DOTX8j;_+I}N30v0+f=%_DknEw>UBFU(kwJ4+ z{k(8Resg6Nx4qaflc?sX(K4$H4qh^j)D<c3@fnP#?%uHh7T#0PX+G~;Z!)tW;lyg^ zv71`Jig>71>;ADKCkhD%hcVlZk7wtFA+*mrYU$Jjy5`u-te`htJLKJFZ|R3I>U-`+ zIXlVgUcE$Sy_8<$4U2yC<whZJ5EwB*U4%K{@sjNjmYif;=zL$jJXkp_)i^ltVb*^d z#1sj+?{wThe>|+kO{KG6g47v0R$By4EG%IEmMIEWwZ!0lV{@s*dPpM9f74q3Wl*OI z)FB8pS+uu1kMSSr?qPpEXdKjdb?qc>X=W?=m@B)Cp&+1&`9rEC{K}<rV>d|%uvULD z#=spOS1WXDhoctGkj~kENlW1o{ndxx|Af!P#Smi!QPWwEA@J|!e9!S*nQCwnEeOYk zb-EoFo&h`psc@+KVTa!PErbF3o8FjN*xRp{i^2;r0L|?>Kzy4)OhC)Yos*&9eC0k7 zJ|`*v<@j5pfQb(z>9CYrV9ko~?RgERLekLj?ACG4tx1pW-41yhW07GYzm<nLVOCt6 zMG}kmV^11Eo}ZJ|V-20=LtK)GFe?YGG`No5LM-Vdx(af?+gaIe=d<-~C6s&;XeaBV zihIz~CABw~3>$BuJ%!}5H_9C1lHL^8)Ef|zthWA4;W;dEXlUFe1T?p?1TSZ2-<&`> zH(p@AE9WBsuVT_ztqH%k@7bTvh2$XL&pzf}i=K3~P%`p7a`gEBGkI~2&P1-o2Ze4+ zxt;n_tlG%#fcI^nU-YM`Z8$s(7%nar-a01YI-c@7iERsPZRYdM_YxG1qRtQW1b=G* z9!rcPqI#OYu4iUGJsjd^d)o4FcoT8)BHyJ_j5-+HOKwP9bB?Lk=M3u?BA)?JZUl`* zt&UsetXvo5@8Ys{F_*`L@^vSF$VQ^Z*Lh-4*gAd`3i-xkB$E$Poo#8_?+Lrp%=X;* zM*QN8_?l&j^!uYmPoz`Fl6+|eW6>DlJ15jquNJd#*LzHg`kaX#N!n(w_j?1~>G@0b zgN{t2CzAo6O1L-<QyImt%01jjA&;D)TvRHY1@q2vWVW4nW$6^YY@&C$q2d&y3-~{I zpRXF4ueaT|`fB>ndRV1r&7j^-67d3d8>*-vBR>ku?_BQ~oGjFKQIXj_y#wzXR2E&0 z-Ue7&I`n7N+RQK<v%XLGM2TdtHFKCuF%fm&sW?7&4=J_iVIGPp9`mPCP5m)6PjI_d zaRI_0%^qW~6RO<ksn=L#7E@5z;FRX@|Mn|yezc|@pS72}ErW<b&%l5UQFNYP|98r% zCJ1wL47Ej+R=~nwP>I1w?08?IQh+4^oI$E|S8`mFlxiTi0j~J1HFa=by*Mw$=M2M4 z@8S-%5KjMnf4%cy$RlAvrzAM*VUeQrkf1dvDJ9!>7a`f>Y3Tge`p-^H<cCqg3l}Yv z_B0L-F5<KN7~}!L2aQ<80zFi^B3LW$^IWWdPOWze3Zz}Hx6a;uRuoRp3j6LA24Ak% zAa2wz(AZz_OkZg1N7tWmAp4b8qSp1^kP}LWNnxEUPHho<=NEfmZznJ-H)2-4A4tp8 zpAgq?)xt^)q{HMrr;G=ADFeXVWCA|JiX&0#V`ry=@XvP#o>bd$u-K-Q3pAUcf((z{ zvUG*xqdmKk(9Dcr6iJ2sx$%|f%x}rMNSBFGi$!aa(pDk4xD<hKU#vApV56~nmXXqO zpVE4de(JG4U$^D5?7c@0Pdzyb&OFW}V^WK%anRAp#NbtHF(?*Me$hr}t9)d!YKGQu zwPz=6YhL2_P`v?k?R3Ao2zPNITr(qXP@ti+U`0Yk%O3X*`5m;heK|jt47x@=V_2Q^ z-O-9Xx%5zITh6W{_n|orZee$5X`{6Nar^~(=^zwee?6e@uZJ_hTg8mGz72B@glOkv zV#T0AI$tjn;`7@X6+zH>_tpztF3zvcfk*Xyo3HFM9AeJh{Q2?0Y*A4NokItEjQ;PI z2<>;)Mfu5neSYbQ#Y6fYmUz`H6*9=#8;(u<mY3aeaw6;ATFknEf)RZ6vl>MKEf$rX z=AlJYO@1AP+lz$}J<4ToS<{#0j<=0;1M8U*I}CVUyFmAklCne}+KLDsHr%tbw;`-8 zhSpDFTM8U&HzLhPi3e4jlj+zn5u)nNNViPO&&^L>nsY;~M>cDH!<=e&x5kGWH_KVf z*JH@vxflEh&`jAncBrxlCbsKsCRemu{WVp!*e2&LR;>I6Uynp&3rF5?GTYBbW^~t* z0+@Dgxs}GtWNOC{0>r7OAD>z~9e@u9!LAEst)Y3ifRBg%br5SGQ)S1yLcAK};MJ9V zt_x3;8)0K_eAMW$ohlfT8`ldW7FGW_%je{`#M)mPBo7$oS_VBJXJ@sTz(&m-Yz==- zVBTq#275^(UUGvgo`cUw5IfxNUhGfWqxd>R;#yPW2$eT=`5K({U=aMEaII@V(p}v2 z=;(6X08%xWOceonDvmr%_T6z~oWErmGOJ8g)VsE4SPs<`Pz<B@R&dgt6FL`{n%)=m z<2PKW^o_2fbcIDi4Qzd(bM_bPxOcDhH{cMZNNzXwZPqX<w3||C^7J9Fk+%0Vcj)t= z*EgFyaByf7HA#l8*^?TLCikmivE5t?D}^uN0^fbMOl{?wYIe_Sh>l{YKY}u-URJxc zO>?*8-d~|@?ehMvpJO!WWN)h-4?s4G%Lh&Jq)+t|Vd0&x-G>^ygM)J<Rq(d$I_m5H zDw}7x2o+*rh1T!Fwf~T%zVzqIr^`bx_Xfvst(Bnrdsx5x26@H&zlc@i0ahM*&0nh~ z|J*09U<4B=bgg8rKM=aviJc*b?nC~)bSFb(P7B$~dHV8n@|4}|u{yXpv~k{8=LCpv zP=-ptBmKE!ZYP6(F%|db;p76A#j+|k3_t&JfnE?chP=PLaC4s8mcwrMh9vZlzV@sa z^!;z2@5oF1w)5jy6o2$G-+(LUMeDsyR`iuOhtmjHYPIVzm)!Ll!&LrKp;ltPezjs7 zt<(2%c)ppJ`B@a>e<y0`$7t=`=W4xHpX8q-LT$1(hi=I@bU-N7|JPlk1BrGnqvbc- z%lgY3LLH$Tla?uCjou4EM=o?S@ChKiYiUIhznh1cCzk+G|G&!MqO6`dWm_^`+Ecyj zmScLV);mQhN~ua$PH@L06enH7Y`N_Nc}I3MePdulsy{J+GbMyCe8G3Kx|`MJqSx$5 zWATj%)?l@Mbat=nwB8$kV_9TH-UQh^X}<1tI=YbhOXXmbrZn119ZL1W-1S{IhY#bC zEqshG8}aO@IrZ4*CHyzUT0zhJhvxt+)a`a(N_bs|ZruaM^gWcl_oXOJofm}vH#5QY zC7Fa5FUUUUA2|V*o!4WEE;+cWzt%gw{_XE~z}FJiN8-sQQFW5L&~#%$Va?|<*joLe z(7!x`XOhe9%oAnH{kpD7BE^_05++Q~g3y`qU(+?-8lCN%C3?d>>*;#tYtW@AP_QCD z$Do8!^M4e5{aTWk39VwH0oK44h-8<?kW`^|TCF9{M16`VO`3Pplf<!>%9uo|F7N-6 zB(TkdzF`1uA7NgAQY>0oLE2Ba!re~odj9^5#7fS=K_7BxUq{m+tw@JB0kC(Us~l14 zi94t3y{opw*u7Bvw^>n%=8}1VeXm!JI0B+cDc;7fMUG`2pgeQHc%P_)Phxmi-2JzO zoSsQ+jtkLfouuIdVlQF*C97_mgUR&HJ{n~7-BT`jJn|j*1%^79hwqu8Gc!p8%&^+^ zxTf{^iQ($YrYKULB#ku8S{%SRjI5KQSO0QkPDiA+dMUgBf3A?{m^VdZninvFMHq}B zj<|h;(xo1aL`O2Fgj)1$?X^NGAIvkR9m#QVHYU+L2o)oqFJ{GuxZ$7<WPGjk&XE(! zndnRoXc{x1a%{?SX!966N(qK;VV-wp?bo9pH-3Dz8Xd%R!1G$6lQ%V$MNee2{Wv=o z$xyd}cyI!s+nq}EIH}{H2ck@pl{f3&QUr!F%$8B+($mKWL8~l!U=e9)-Q8)k`XQ2C zA(HxE99>Nm&SPlULx}PksG^-H7!+l>xcYWdcLIW`<QJ_3-R;H^z(>3!JoSt}sd3eC z0x^>dnK|<h77Tx((*Bqnr)Y*g#I`l!Eob}Oa!8jpD#Dk)5p_LE*mizq1L6{uP?4it zWHY4}E%V+^(tKwEfBfoL&6VBa2*Ys^RZ77=e8H^$s1(`sxW;9HW6wf8a@u>vn94E| z_DrFg>S2nTv9)Q{R8+=j9icEkHai4n=V=@n;+k)`;~weFW^npfz$_X$;@a&v?B7)R zyT!PiR*9m~fFa*+ny&3!MjtT9-lhJSIX>*nX}Y<zTs)vq_zyeLesSbp9-Yx$wgNVi zkqMn7WuB4ynQEs+`A#By@@|KQ$&{xe4B4G7L4v*DeqMvp$U2#p#65r4Gzc|BM0${x z1W*NR{9=3w1Wwj)7syi;<R=@C&~H7K=@R;O9Y*`&?AB8}avhZ3T{&bdKo9dS<?B*R zkD7X8n+^>X&@)cZdwuu9uccRCo`<<#npyEjByQGWE2TIHvz_W%H*-_$(kc@_<qeN_ zJ$-R7hTOX3wIY1<hV+2!2#+!1qrTajC+;*+k$-nRep$c1h%)?`NUPis`S`FHF0nnZ z<J$T-nXX1ysF20R?pWilfRUm+&fL76O-%h0YBQ<a6{7{ooOju-k2G_2<`+t3F~CUY z*AbOQuGrXJ{&^&y?ll(#$=OjrP3;xsJq)`ndvv@{9!ec}zu{yk0$odg!zJQ4eT9_m zeC=@<dxT*sy_w2_lorCo#SwB^Czycv`gJHG;DHh!7S{+hIqw`Ndc-MQvSk8_cJqK| zK|r9wM+ZJDHeB38I~*Djya-+Z>5DxKUO({<8`Q4vatDfQJ$vHm)n59Y9B{!qaz=kk z+yn+tCk8rJw*%j%^BsLlqc>CFtK6Ud1}$<eq0Fm#6k0S8FJ~A38QpPvmdNSNphKPw zxPCO<bZu>chwledpy9!&^E7T33PE<MewFYJ??OJF2;-7xi$*lfya8qTKaJoHhVJl( z=T#$&e+m6sLTdG87^f@4re#`Z<Ve5iYT1Lmqz2Np^dkQLQ?Q8neD0UW1U!8c*HUl) zo?IJwL_@}CqrZ7mn*No-BNhHR1@A7=@i25@aZ&DWUSihwOCO@kNwt12zR5N(;4%7~ z;qQJofzU=Z$2vLw$!b5>OXC1CYs<n6U7lU0fgWjqub;xf>{B6RYswQ1>>Q$h-iac6 zrn%AZSqz})mx>Y6%P`S|)Bl29eL;<xt&pKCpl6?8XAckAs^Jci1oR}-;Lj}oOF?6Q zp}!#@o0poNnR@x1pbX+zTzl@6-@5h*#k*L0P%<Q9buuGnAY3}-kbyjd3a{brqYgsm zY*h-Esjta-uEk`7naO!V3{|WlB%8~5fW;m8-;5Fb%uM^hhRff7dJ!l={qa=h;cs^I z6(i*T`<n>o)nAe$9NWA9P5Q=S``cA}+*_l+dFCsnSUZ=Hl8+ngxmszdNBB3)?PQ2A zrcSV47DC0_UBCV9ebM;;-OJx32`3To^=~h5A+KKG{(Tt@8NN^u{n)mKXTg(0k8jr6 zoS%cJIm@mF(_ZC|43XF~{n6Otqnpa8HLjo@a^jS#Mx3GQ{=1ib+(hTjk;CqP?am{G zr`^eP0eovUx;BD;hHd|O>LsG+9O<k9S|bFdS^er6TIdo0g^jFe0{NHw3Gdad74an0 zx~;hF7%FNT>N%)o6%{{Cn8XX9?#HvMkVMNVd8Q^}tyz}=ZH)NC#q(y|6<Z@r0h&5H zTEJoZs>(NqAhgv3jmS^bP9xUQa+IT6hqa&G$Vc>g&8xT?!^D9z#rRx*g8UWx8QvX9 zy1U4#@H6I?^xE-fRu25F1t1XLi95XJwX`$t?ObGbdn08_D<SRN3MS18y>=v5-ZuF@ zllElm9zQT?RKhT7kfv>Wit%G()mC>lb;b#2K}|EEf$h2x2JkVxOiB0|@Gzr0jGp3a zjxfeDe5@Vfz_rOV%%;BZJ^})!_coUPm@HJ@2&Cx=`P|c_kCUM_<V;|Zs>N>cXJFdH z0tplVZ(~1_x1DXGg;1T!l~aNjw$@B{-7yob`(<X3)dutsQnk(M8dVdyPi6O<QB#u& zl;a?bE`!>3tB??_hP03Pgz05Wge>K;?+Jm!xJA}9Jo~ir)Tx6P)<3$z-kk(MMx{?k z?8$Lv`05$+`O~E?4esIYR<b_TI$BbyN!{5tzg!&G?vSjm`?Gbl-?9`X&od#Qi|fyD zN&OjFqgT&d*4GJaDtnbNaUnmgI-3@RgG^kBIc80GW)==GMvYyZa7L%{Xc7`@E1SjV z=5v(DGd15ZDskf?1E)F8MrROkV%Mz`i%Q?i6;7AnFZFTdSriM*_QaR+$Ll42C$RvQ zE)G^>qz-MZETor*$bB;=NiH)b;~-YlvcK_r!uVrH2y$aTH2qx*ons2lW9LDFK#YcU zO_hrAg&Pf0pJ(w0<lMX3)_r+B-8@NgAw6)py^yYUCEL^|D{5nFphjMjn=Yx83a<h0 zpY9elzI1S@A08g&_``0OVRN${2OCMTt(Pke4!+xh%h)E^9KZN%z#0udQ`+ogurjIm z`EeUs@e}lDS4s6uaH*WO`Mjxz29m1%fZ24-LrbH$PG20dT^epaY}Tr@^4C>2rzkD! z6>ElrfOY%eznolZV)(+2`<wMx|Ldk@-_st#R93^Iw6kl~Q^Rn{pEK6}SD+jyVA<C! z3Cp|IQ|99`kEPu$59vp<Ij?@tZenNi03{85ZF5=Gp2<-iQ<ycVaJ;_Qu(asLUDwTA z!Y4+0bpBVVwp=o~QeqyIN2%E~Tun$<-@uM8=+t#f>yPVfXStD!Mm4py%=(QNqEF|d zq$DKeh4PxnPQ$If_mfjo{^yy?dX28(QHUsZ(gIVTlF~Eca}Dh*s?SurY|Rl6Xf-;& zB;_lm#e-(ZYu~eeHb~DTD$Pty)1{GT?VlWf)Dy)oobagIqNUr}GPig7Qcd87wt6VV zs5a%+Oiw5A+rvJbQH1rQ#IV$DPc6&88;e+b@-xCl3&LUmYE}JgXlM`(rtrI2?d%k9 z&{L9rv1A4C*kO~(X`AeIaSw&!78`9mqNj$r06IXK{exgsx1>ZjPcsp4J2FUyh?;_P zB!#DfkHdWL<|5Ss8*XPx7vbYGz8<^bS0ecO;!l=U_2cmkai)4vpXhTtkS>hRe4c~c zAZmf~;%&Z<9WLO`L}LPs1V{PI;a~PY@BSWC;(}9zQ@{Cq+B90U2X`3lST?@9xnF)V zFfdS(el1sSbf=WsEnX9#g8o*2?^_BdtnTe7iQq2Ie<R;chVWvlz4>avhG_`H4EdQ( z@zmPdn*C>3;G$HX4`?g>@zgUjGiG0(D^@0KM58&P%17#FL7mX#;6{}R#lPte5#z-a zoVvQYo@hRRE?l7`90l`2`26N{ox=A5`&$(a3yVGM;kKZl;7uR)fBXL`NCaO9Yjoa> zXp57<5dm1O_C{A!C|JNhGTVdZk)oLG!A(Hm$QQ1{{QTy-z0$rIGU`8;0p2}l{+Xqv zBUb_6k@z6?np&SBk&oUKk-R)SI7BY+@bD}K&F{Mvj@zF6EG)LF&4<b~82@a?^<7bP z<D%|O(6KjT6TY86nlETgriO%sjAZiS5;f`Q>Wac{)|Evc)pI{RtP!@oebq+#XR!cK zp0!G;;1R=TZ$H7V+i;2YS@auS_onH=km$dO?&!@kU|?ipi$nXSRK&=@K*bi~bL_n= z2sqN~TW8cPf8}LvZvM=4!|hB8^cN8S<Iq2!SKZ08q=trs@bK~`im8;Szv@jv#-+t_ zsNu4j%%@i?)Qe8}E6nvgEN^*u+#z*#Ek$yjYv*X%87FMgNAz6QcF9s9%;<B`*Fbd! z<S6DBFJ9nDAs`?iPpHbtq5O@28l@4**P<Lhuji2dv)<Wf){9vN%a2?-h&e5U&ibjJ zb>AN~v5JVmN{X_n{sx`rmBP!nuTbde=|xD`FX`XDZ8u_bbu9|0%CD(O`XdBKxsHU^ zeYQD7975cWvk^>a5Ec_d`?t{)55DkQyj(#W?6M-*|6bd#H;Pz3g^BXt_J0odP1j4_ zq7NrseH8x9$0j)8X7knN5U|6?ZN5KZM386v)7u@6>wj5l*mBxS>fEqs`K`FP7^n*t zxn6X6cKR;@F;eJyyBtVJNT~Vwv)e|0yhJF*Aw>U48B*o{c=R&1TUtt5dNhqa@UH=j z#Lrz1JkQ!>gWQCVm<J-s3IAP>e+23ie?-3dEBuUN{`Ktt2W<T{^8bQK?K|!Bq`<Po zYr)J~9qpL*OAk65yPE6c-zRS%zxz~<Y-Cq&t=>#0bX8sI1_MyMttbY#|I^u5h?&rf zuPR?bB<Grf)k`e08aEk-Wz0j%k{Yf0#fgd7z1q6Xjl*ka7a2_PJ==JD+^*MFdZq<c zi;F8KuR?58FL$d@ewlJKLCnhP_!el4RZhKp;a%(~i_G`$ss*&~hCE^J-$5yxq_zXW z`|2CL|KPJD4f9;~3d<h*C{x8!7pBup-u=&b#Q7Z#kF%#p_YJ{jc3e@-ge3oo+CNF| z6`S*^hN-)|Y~JsikQnCT#X~xMl1^)f7jgszyFc>vc~bW8)Q0-sL5G~8b<bURwZmw4 z>B-iA>1C;zxkn_MGothFmkRIQ6eUb&H(v6G42}|TDeccT9|SPh5|ob292KcVW3l3E zP7!;`2FCi{g=%cta+6;2dUJL;7_lPC#aD94TQO_5L6rVGkd0!fv|q6!EJw`EX>F)m z3KZg#U@t=+$A_h8=!(00nVUfqs+PvW9MUp2>SqQeh;#K#)uVYzavDQDh1|8Z1hZma z%#(ODXpU<a^2;^E*W~_hDCjyTWRul1=!};)YdwP^rxjU}(p|VmG^neVIA0(qHyOr_ z4Yp70qq7mntRMgNs)a$!$E?C^UrT^{$kK+osCLmTR;P77?rq4IDwTJF;O2}OT9@Qa z^a!a;!%-}7zS>m7{3dNC%SYxa-C>L@V;5`lrM)~qOk=n7`8>{U)FJl+{iF~JW~Kt! zNl!ZM{k<|K<=_&oDw3@`4T+ZPjEfGdlCnC^(*f(obvA3_rh?hti74q(b0o;AYe{X@ z8s%nI%APe{o|<RbT6{`LO%(|OG!}^3SGPbrXZ@~Dpj2N^sVKgIX{%8>Rr2>}t)}=f z9dtR-`!TX(jwP~b%gh9yB{DiTLV3KfC}le(v#g4L4;wQ6&lj|CaQ6H)M({*c9<e{p zt3tU+!RqtfA+#nA4VIutg%m{j&rVmJ!zS|ATXfIxDXFbQ>8II-N#2j|Q3s0md^)`| zc7vnC?A&L<nvkr%&*0kp-<7{S!1Cg_Hrk_|2RMv$7PwoT!^7~2(FrPLC2y%gpKNu^ zmccz=EP6sDz5}amrwTbtv*h(6vG9`9_YfBaB3!g=4>{1O_iqwYtdekMP2B3s(91s* z=*-4{|HNUQG(2PEc50%V@x6JYoVYbMC$8o9JCZEbVKE^Lr(+F5JO_}o2{5Zu$()qo zP@QPxka3!BU*7ADw5a#tVpdTmRM$B-cwO(AbfOy+;YQy3vsxf#jj1<a_~0R6Mwf|v z@N*NrAocP)a?Yv~F9|;}+1i5K(HfC^CtqXWd9N-ZhTCUtr`0_ZTzMqj77;p5!HORq zV2PV9^|*9xy?tNL8=uKXkp`#%njg<#Q(0lPq7(vRUJnaFLE7&I;}`+Xkca=Eo3Hl& zl)Qs}66x<Po>?n_-ZOrda;epyI^3#u*3iAFqtrMeTPf~0GjBMF@g%oAEDYe#Y+k)Y z!&f{=&aA5(+{{Gnkrq28(H66qCpcg1JSshGnQ)IiC`m>z@dOTA(0{3H@CadKEuodA z30^{E6exHn*j6Ug@Xml#!`{lVpwx_|G`e^@q=AXYvO2(ddR}PwXYsd~@h%sZm~k#> zGNB3=^Q!hy+0g2{8jZ=BbS3ejcg<5B$YOobf_Y9nsPgJ1t7P8vWk0j>)Z}>zIg+p@ zlame;^X-`~+^ul|4Bn9g3FJL`y0dczHqJN^Bgi7)nct7Bu^;0b^=ymrSzUxO9dcb@ z4lyTviyyA$F9}lQ(Wz@b5y~}{OX>-ZWjJ4&e@55NAhp-0r4>)&u_^duXRuynp47ml zi^@q95$nutrfcgilEQQEGie%LL6@5E?#+&6Q#BUNKp!5b9Df6rzsO=GO4nhAP_^<* zUli}-{I^oe`<yerPV@}uXc^@0AB@w)jxZ8{UKa7a!?0vF{H$L(%s6oBu8b@1OfIGG z=@bZNnp4uV4Gf&RoT#E_Q^?!<A}Kjw#%0W(4bTQ+V2){|;A=)sc?ym}?3gMvOLyfW zqvcg~n9D0~q!ip5W;(2_N~2BJK0odV_6U-a+AbB#P?=5}n+Rsun{voD`+Ime?32h= z5CvYx`K>_oS(JZN>muUxf8jP>%V{2(T}gP~<kg~rlC0XxTPdC~TU&PAvL*B66)fBN zQy}|o1O^)3Rr&e+&)pB5ZW;!BlUd4CG^Rm7-NVP<jl+;I`FRz)@VLI<;1YoaM-`5Y zGe`2+Hag*y8!u<?H!S|7pS=JwQ$XCDAdI|QojQMZivR2f|D@m-Q5<i!bKz;`{HwRg z+%~ryGhAk?m9oT_RI0sS=n&_KXiCW6>F`Ubv~0VSlk>wDgeiVzB&IYSXOMLQj)*Z~ z=-?JukPHn0-BZe47zEV5lb*Z*@(^V97)7a?<FQ!6(=<n(pJYMK!jcKsh><^@lF!Y& z@9Jt2piT5!@yz~&BYWMlzu-MOdYWM0#DHyDd=xq~X5doRP(8V}<D^Fu#ltNNH60Sh z%#)-#brK57(6>va8NROXM+YxyXY5s_7?fUK=evmH=uPHCdwI=jl)*Er|NPfO%4ZMJ ziu_^cIRxtr0;qp$0g;=}P&>!RD=0yUuj**&8G$*Be&<K&seFKCeoYv9=MxyYuVC`u zQ{GY`vx}xZ&--8J=aI8EJZ;~9V96oMb=K_4dq36@k0pHjfnO7pLUT`)p<7C|xut_C zZ=;5fk}kdas~m(S2%PD^n<@{U-AGDvF8?e?L+54^NL-i~j%~C4#av6v!-(i}kAa;) z_q;6&gTj18wl<#Pl;nZuws(<C)6giOJO#kdcU(hySUY6`1Zn9BzoaF^U1l`edyTn` zQ!1?w5pe4QCXzn6X138~ftDOq{7E+<6^smTK4+`S-Ql1A2ZugiPzCzDM2o$<yW4&~ z@}^s6Yd5(cdLOp{hA*!xlzdcGR8ALcDukcW_!I2-2{xl?82<tn5`JO(UYrrvmBZUS zxrhFWqm+AVo8(uFyZgJmcQpdO<^-nNhUz_Mt{!C>b?Jj6?p2IQd;u=*F%yHg2EZqh zAgirkHw$OKVrUwAfu8dr&^3llYv%P|oZ3RGCWLJ1a@3&D8Ma9}K%(y0NEPFSceO>M zH5C(r@L+SBWQpXAvck;_GhnyLuR3=$2YZfrvs2;>Sb-Z(x<V(v7U8i?(b`P6>SyS0 zb|&}Qk`&V|X?+`yO^1q2aR!UUg~Teh&)g^3Cgh<#>U0=4Bar=H%-_W!vA}E7T3?}E zm}@H$Vx`I`Ww#H9G|x%Pe?BGt%8vR1x1H^#ivo5sl>@sNVr{$7TxtLIa!lm@;0W3@ z0QZT*Eos<zaA2G;qr&aSPwvPw)y?FF@Tx}7@P^OT7XeI)V98LE1PzvG6M3{v4LR_1 zT+cMn%f~?6dT*T=F_OuEQBn-5xqtAHIc++b(M?Z#OGw|@`mA%9`B3GQ#KDC$U5DC* z?2?VdiO7yYp(N>zuw2r@iP)oua6y=1gWeE<upld?7`Sq$_-b?R(&U_~!F_koOj+9^ zfQ6EhfBC0ggMA4gh~C>g=EmFpyc$gArX4}hJCaRSx;%IlCb1z0OfX^ANUg|<7}Nb# z<=~~E1+-rRWgavNNyMi0KN(=+rmJ>op`qg9Yzen6Fv%Naa4G1f-uq2H?p~P`qaJKa z^M@8p;1+%!`tKh5A@%F?K?{qh`el<6*>7KlP0K0E_jEY*iAa{?qz=ueqk_j5pod?d zOS65>WI4=)ECIiTzoAI`b@7D)s>`}IbGwqGd`^(VE5(et4-?o5<k*W}mH6vARr9=N z#jTqFX;SnpXc_dPxlSp8C_!P0(0c9TrfBT{!`oX&)$x4!qQpahkl;=T?iSnv1PD%W zcXxM7u;9TRg6o0cE<uA^a0~A4e(ONK^P4*}@2#1;?tAB-&LZb@S5;S6?Y%$JyJe*` zA@X!Dc#?L^`_vDE+j~@P>ZGXKbsVaQwA_<p2~6GgBrgzHx<=6S(W{48&`A&#{+!7F z4C~}wWOU~agn8dMAN*{(7&vV7BnL#F_j^y{Gy1^1l5VK*=WwIXUsHMP`Aw(daJSJ( zTcY_lo4*X~v^9notma4i-PxI2TVW|x)vI5Gp*<j<fkz<Dc$?>r7R5-x#_#)a{JRL3 z>LcOvxq!q^-ELx8n8%(%Y;Uy>N$b_)%%~VU&`Hij_{pmIh<8n%u(%YpWrsCjs^DKm zxO)X31V^cC5bgT_$BMoucMEhne$B53@Oa%NMfE3>o@iFJGqjCYAmfXHoqnk8_jB2} z!ZhN%eNm?+*lP6Pm_+!Zi|V-7r0rf9pN<6fzn@bzcKNR`)Ogg8auP3prwK^*A|M_W z|K_>b51j*<^<#5bkuWJk22_&iqjrTy?{kk-9I%h`2FIR$mEUAZ5Mg+#dRT5UmdtAP z<*r3d<364Mz@MC&y1iQE(Ez7SJ~~*jQ-U=Xx0Vp@lnyRR{pO<^05eT5Ml!t676|v^ z1^4Zh&0Qn}qw~5Q#Ge`Q@$rG^wCX%bS;G8IKQA_(#(XHP({-vVnU)^Abm);tVqdmp zU$$m@Q1$4!frzN6sGZ$0w7p8D`dlFAw6e0gPv_cSfvUS#Ye_->dZBOL!P9--H^_zG zs;FrC<`$mE{*ka5%FoU9&svABc|trFukY4EPx=SoA%DY$6T-dnR4^00Fv!7_KGqa3 z<%zn-_3^iFuO3>izGlc;Sy{2$9KBv)X>>XsQGksTyz9MYB!AOmx{pZyt#ecaY79$8 zRGWvDimEqVJ@HJNH<!DlFb<xeo>4YNOZxQjW7RsxPpMMBbQ$h@pN<di2XC6uT;p$= zPuzNnxjJL3X2oTv#b=Tp7c%9_;xIcf%gf8#+hY>X=BHs15&L_4-OlFye0Wa72=99= z$Oi3khAf4f_)A$*J;S*7%OdF#XMqk%6&L~exb-!iPaZ%1@ZrNNPmY$e{iM_Q@87?J zQG*>E;z#8AaNh-BPXM|A1`(M=tjcnpufKCU`WS9;bWT%gp0Prff*8gDK2D6JZtHfD z=Z27-J^uc_$9h5U`@=_%A|fIx&87sO(l9Yaf!mCPjNEv8y-TQk*GY_)_SY~N1;nD! zV#%Cw@B@uk-HjQUnXf%L1l`@ahlru)@+?LGci@kB68rHg&+WM<8mLm_D-_|eyv)YC zyA}*El8A((BZ5Pt!jQ7b(Cd!w{a4)}hycJVz7#w>bB6DH{rpbVs3<5l>Q84?sw@fq z7F{q@pqu_90?x@AyUk%qo^6}L4{tkrd#_V&&+&b={^Iyev0fTsk1+611nQ55L`7q0 zxaN<udvAfsgwvHz&iZgRCCZCjS&{n|!L*Zsk#7OzA>r5R!L|D0aqKs>b4mZy$=lg& z!yf(pCKTJbzgyrB|1YS_|4}^ygZQpKVWVs4=#pMO*m;@QF#$oN+*}&(YW}h?L&bBY zLh4>IMP?dJ%JOX2w9beLm~(i(yho%2VHMP@+#3d&%QCzT@)cIm<%-9$*%I4gQ`DqP ze|7e;)VvEULBcQ|uiU}zh(=#R-?o`a^!9hyQc2N}GUsmwy|P3mxC5~;82Y4h`ss^V z@?rZRO-%9$YQmcp<})>HLX-s7nBwiv1B7SlrXsWV8~Jtqtb?JcnrXj7KGu9prq#@3 z49k%!`kJlZr4Jg*9`v1X_O(yeJWU9Up*IeehkW;Lj?&Y+G~N276Gz0vr9@4dI3ukx zw)V$J5HgatEPKn`RjMyT?Jqv!Pwm&r(?cUC9WJP<nj@JhenX4frmEpld>e>NEpwBq z^6KV$;<!nEo>~`?Rjry`QJGXye)S+pQgmLPV4MV*Sbq2rmtC|-x{vm|i=)DFwMy@9 zV`i0Y7IqS~d;@H%XhHO_+;{P0D;uBxj87`VZHCif1q<4hm(fj>)bf%+q)eEM+m4-H zbtqHIQ*Nf|2K~TA;D;M2Dzs-{kLs8#Ml~yFuT315CKn5ptD<7&rr?f_rNq{)$xw_x z-Y;`5bh!X?MDq_w5W(>edm<#Spvv%ggBlG{ygDzCA%%L}3_Y90ytlJwPJ3C8L7Tc? z<IGV#vvLNmV?Vi1z^S6-eTtHzS+@7v_A7%@(Sqxp1U+cRy`z}=Z~1oOPFZwJ+(upg zJe8h6ZKxB`qB76T3{tUtq_?7Y0`vB!vBzS;Oo>sT506RTaPJOv^}tX)jfAL@`{&L= zDc?_ZGpez1N}45~7y_73b#>9=lNv$-f7qp$r*yO`hu729I;JG0ZH@R~!j%TkKKjWL zf532*Um1&ymB+G7(?e%#{#8d@Ab|oGNtxA{4RZ{aDVb@R>Nhrp*#|NyxybZ>bQPRx z<L>TJ>;pKFJZc2HtmBE$O*oUJS(F~xirkzg&9ZT>)<xr-$&&KE`Tg56_4`4yydShC zbOP(IUCMFkS+GSb)XouYKZ(Chg2X!M)c&)C#K#i(`ObH-J*IW)F?sZ(6AQ;mo$v-U zWTn4KnbETX5WbcLk=h(*Z7c=nUK>h$@G&aWT8@6IP$RNZGWpfnJhofghJ=z#D5<xF zDxhXcXMrnHe;w}iFt$BQ|Bd=#w@a&XZiL<ECsF05Na<-`XQa)DCpmi`)jYQO(tmBd zb*51`D630H8I88Oj)G!9eV?K)+#n@MhbL*sCl~DBj4Z}JS7EWmLDue`ogAarVwHH^ zXsu)YMMP1kZYaH<US)gTSZM}L$BSWidqLMHmKPI2Fu#}lME&sFkE1Bx$69$3Kb7HH zPo#`5CV6d?dMlci_`E8!Ris@@CP-j2o=Zm9hYS@`?vMI2EpK$^VU=i?^KGnH-6hQw zR?hqyl9a-0Cr8Jn#h73Q9{Jvt)|=1)Sm*6@h$`|n;#0DH$m&<0WfaV=zTZCbd6?1$ zHkKc(?8y7HjBNKK+sgcpZ+dH6Z>*`tC^fB>E6j5<Wn74{BKaU$l+?^L!bas)%?s%C zBg^v7vNIvfA^zxTNcolWJsFJX&%-{Fz>)_@QKsSvcWRA{*qP>uESJbn5h;6R?YgVi z`9ah*ltz*p2LC`F?3O6w;w3|<C<{;eDi7;s+O0DbnwDmqSLtE5#%tzu(xh9nNTwjs zT@b2G%jyCeMA8ZZ>*sBH`yxt0M&%7vik!ysd=D{$^K>X+Gsu%yokPA7$!tb$6Z^W- zFp(=|2M!Ufv4_yW!Vj;pZxZQ5GRzt~?0*<16^<2LOixG(&5i=Y(=>3u`0a`F*=6yB z_rcZ;k4Oj;W!NwXJS0Y$cX^63+b2I}jSw)HX_D6L%oH>~HNjrWCYHZDqU-x|?7vKy z%doVF<t-rWaFSIO80n(=UE(7ZM3d@f>2^MR(=Ej-T_S<$LjAO`>Ehx;&k-lh<m6m2 z=G4djh{*Z(db7s(eKV0u^|t&7(Isl_pkH^kOxI~<GN=AEw;)ZL*(YZbg`=Qiw#~|2 z_bvF`kn97jKjMC<wzTN%@Napn%1HER^jh6wC8uu2v^pE2@WLl;s4l8%qN)kACg{#w z5YS~h_<!Zk{|`B{4+spKa)dhdkQ#mxS2(?9e35KZb@pB_l)jFb;R$tEu1x90#UO)) zvUJ>rj<0I2T$r^D#4ocv_l1vGx~1Hh($}O>V_lSej%Z}mEb#yaEb&WS9Lm6cqy%I( zwNduKwh=~6Yx;fR1+VwZX?6)RYHe%+zSesF>eQ(|%EKi~1)MYQ{i;qJ#8~rlr?Y-d zUX@gCqZ!b<u!n|zGq5U<qO~Y%=fF0jQJ(0pCF*U}$Ixc<&V-Ri13<*RcVLe|oPge? zwg)wl7+&A<0z1o&wWYWG_XKRvFEdZxh6lbvZf@6y{XDlljQs_GI0K>$j+Ki`1yb(Z zoE*pP@-aM*t9_5#Q;*w?{2~xt1eupH@RF7)#Ba1}<vL}z$r`1P6whmiFPT*k`cl0J z7M#zSRi0TB_ezWoPuyBiHfUraP%yz%Na|dGRbVVTTQp%%$?O!&b-T_{W{KoWC^(Ug ziDdc}!Qsv&QP@dNZ@%5B7@K#HMH}fI{Sl=m)5(S{J+)v|*yo>wJFNU~#(i>Qi+AWC z=D0w>xvu!g05ZLSt2Vuu8hhqw^~|I#3Xb4+-t-sh3U~M~G$BOEgKW-KfScHQyJ`4+ z$x2boZozg|UBPS~WpZ;e@R)yW7;5z81-P{^{C4xrJ_y*%SNowJTj@QYA3bAWVDOGL z8cY+oM}!1Pl}mHpj6}zzC=y4!jtLy4qE_87J4DX%e?c`DS!<~guBA5G?NoS`X0RIN z<0H!Y>~$CXQp~QNad1z0e`?>sioBLc=8|C&DLHLo!%u5#@o;&0wO%5lOCG6mPUh@4 zOdLgqZogMzWE2X^Pz(AJ4dCIjxn3eh+>~T_h=0DqMW#?I>ZBc{Es*|YsCSjdS)c#C z;QRZELOLee?)b)yeJ6p>$@WN5>Q$;_X7%&(v1hX4c40SG>3o9wIHacfnG(DcK990~ z)1kFAivxOsK;-&nxHr6+=cm^**X!Mb(`!;7sPRO6pirnvK&Si6K8gLurEFJ!R=*UW zI+7Zho8O%G19HJ?%4w;qsj2CwPlHRH5wI|h^>UWF)&pljDgAF@(x0#DZqvM#p|fsO zdJIz9mwrM>g|eM~_1)3^EO;j~PbT!j>w0}3oLblVu5M9n8pe2&%LP1Wr{WIv|H1u? z6jnyn<9X}CHqsNuKfU8hwWL2xC|%R(8OLxMtxA*k$8l+dF$jfjtLS&SCHuB=4dZUb zeqGbc08D$?B%x{;y)>Xf0OmQHKF94%0_h5oSW*OQR1-BYX@KEB$mY4uy<HFgUQtoe z(7;VY69OKFHGyu-5d>)*kwU3XiwPK4?Xj{n@$!J9-pQZYq7MV)bAEf}m-p+uEs&H> zICQMoT88*4Uih|MmSR3hKVVWDNAijm0yC)cE(_$$AF0vnMq_wi)No;}qJj-#cMLqx z`pf;TWBcB&s3d->U?K+b*_vi5re;vC^rU|Y%^I%3Q&!o({&_zB>_{LmdC2$KMeL?6 z`xrz8dTQTinM2qnh<NWXkz!wu^8|%6vS6jUKBrVzuiRnY5K=DynK6sFmDT?KVh3yx zCkTt5t|wSDusQDiIUd%VTuulsq8-X(>U1I|rYzSA-|TkX`}quKEHtI(KDF>Pzd6e0 z0jM%WwYnrV^>fq5FZ9rJSa@6}6m=r``#~tWAO#iW&y1sEsK)4RlUnTD*a&QI`y3kh zLG!@p`y7DF$A^Zb)zq%nvs3y&-UxtO$D=_Z*dQ2adpadc#X?(3AgX6`O>|}lGRCkb zC70&8zkmPs{)>BSz6bOGq(2}>ZaiPXj@C1ZS1&UhTny!r_*3!XRWkNN{*}YAFIuLI z81Qm(at*6b1gR<BW#khJQL>=M$W2YUp{!R?{bePrRc`VK!^)6|$G_8R_NLDCa00AB z8>Fm0;Y5)?(fk0d;|>2v<i1+YV%n)zCGa&a06}yS`>+!CSXE(ZPzb;N_U00{33qqb zMMCmhh)l;5K(?qv)=1*n?>`JH%@zxrug95IN+dlaL@?NO(>&l}q^scgie)_Mc&gW{ z<A7YH6B!kC9M9l><rt7wQd$c9BpqcQ(tTuz>lxFfm+R6Idm=FMa$)~W;|e+>l_4ZB z4t13wcu=+`p$pmrPj}=52@TGul2_u{^C5@Jqy{IJ)Q27@)*d}558r=uQn}-CoK!xa zrmq3S;>w!{QpuZ6N%!#NWST3d9{c2`?Yq48s+E7cVgn-2Kf7{T!bv36ERVWvql(W6 zd+z+<!{fg<|MS`!K8yR+SyNM!$7Q9*=GSOVqW92bIuro;k>oO<f;?<e(@|{UcG7U{ zM&mN)b}F1HeV_cpJhB>7a{!6Br29dHL5f>+(?^C7pl=Y1rJDy6;Eh*!SQzj96w&mw zwzfXmn((qUddvq-@3_9cuBD}=>*>Ddg-=98^p^&L@qIPfC8X&qm=>CQWC4>^R*RnO zwVk>pfJpQA?t0P#B4E?EX;A3}iFW^`66>2cf1(uB;~1XsIR9;z&Woq#PcibAmUNRO zP7~&?R=~1V+Q`6Hb~J6%Ppwqqokj+}$ugmFQ{wrpTX(C*c0S<kNmsVt!y7AB&mc91 ziN@B}E2x{S=O7HY8r^rJ3x&pu5uH%3lY?&B%8H7Ucx*hp2Iu3AoE+$>DqEu{(IZw2 zkTH5E`;$vt)u9irW+ZeVO&1mx^78Zh!8neNR%EVqgK}L;r+*@YUcf7!Cy#@cEn*+Q z2ok9}3UP3P?HeW?itx<8GoP-~EHe5TlO=}Bqtg!mw_|jpTHh@0M-(hA{|5xk|85eG zF+81(h<g@qM%<Eh?0Efov8Ad(YuU*B>S91x6936~zv;0XDzqUOLHp2iYQl4zp9cu+ z`kK>FT+iw<;KbiLxQOidAgnBt5C8hQ$gS1mZZ_7ev8C)>^jq?Cz%&ReJS8C&OtcN9 zi)(hpvfqZJP9j?II)2*Zlo+?Tq~IGzeQ>8WpJ?R5!kjLiiB@ZbFt|IW$zkCnCH`1o z)7u-tV!APFE4rDy@>&hz!TksgW;MM577vb|=E~U7#5W2?ix_5$g!K+S=MF2GkAkc- zBHrSE+GxPeyxgfzHQOR3B^B!TN4mXAW{BKS?`vr&C8t02y!<H?bH&=Mc5dGl=%F{$ zd^F6VenET9F-O;Vcr+Ka<>BTwnN>71d*F9-#8VULVDEb6RKH!20Iu6a=6h!hYWeJs z<d}z}rbjyI8E>X}UwvxQc|i6#W5PMi6jeRxN?4BJh_2D*SbOs}$IC>`n_7{JY0m4! zTK`Mi>yG0w>W|Z;jkCvL+foTc))HkqKWkl9nD9<o8h5NI?RZIhDqw4Tx>oYy^_T0) z*k(y&NzK}MlD=N;*I{gI2AnFcJR6iwaJZ((#F&GpI@^DTe`Y)O*<yM=Xs5R8m!@#h z1VpHD)}u+K^BMlvIOGMhceayP5(xQ0!~F!8b8JoAXHsJMW@84BAxpgl#K@s^gj#NW z6)%h##I(0wwUCeOgnH{X<g;xKNms77ok9wA458Hxc@Wce3$w6%_fHqYn9V(rLL}K4 zTQt|EAp~npzKEo2l~=3rY<vj#BjPIfGKyw5R`ze(;%`~Te=M$Ui58i?0fR?ZlllZ& zP6@Kx8D3ea9CKZQeR-3$(5BI*dkaxYbaoySr6f#o8rw>K&z(Qo-z|Q*ZB_g__;Ra! zKT6pQJ6S2$WxZ&n-?TkOJy6WGW17AwcxG>?wsw-_S*2UeTY8>Mou4K&Kk((T-OJgw zGaGj-kV_yI_SY48N2SBKz`y`o<kqF~JVQuPSYV$+w1mY@p2|T_q3vS*s8FK;?Yid{ zayD1gd#i?aIADU!%`s(G=yxq69JIrddF#TvQ2|kk#7;W)ud;U7yqPm+!85e0?cecO zok6WmP>?N0y4f6>)>uzgDx&2a5aYbOy=>H$uGq#uBa`7<tGHcSS}Uv)XWyP(Gn(7t zvAkF_NGTm!ui5LnqCETg45PBw^@s{qo<`|XR{Ko%C<w%97?19Y1?{XX)vvC8F=iG~ zc=?n1x<aOOIeuRl<{TBMU|LMm(+Utj#<zK^qM?2;KfjXFC+b9F+~TK^eJvEOPOLRY zJ~eP8)mCU~6`LB7Qs}r|bX-vyHp?Wqs%LigbH<zV)zw6o9)619^?DHKbmALgbY;n> zMdzFSK2gufqf(s3UrFN)$rZ23C+y%_w0*g(lt~%Em}Seq)ftRui}0__T*$0+fW?eH z*tmj&EY0fa^{;undfUq4{}WQP@aUNImWe*+lA^ETDVMRMo_fZS=}~7(4UZaKY>Fsc zOc_PvM9TGfYDKp8g+heMrpaa22p5|yd+MRZdWICa5!b{D3B)`Ac4tG3G)m&7eTtbm zA#@@|-;vTi=_M!-{BLG$EtKDeNk_nXjP@cUaS|?PJK$fe$t?PjzDzRJGs{--`IRdq zno;V<%|4&ZL|Q@^8Fw-ePmKtBJ9U-1=U4-tzIqwrpS(!c2h(<o&%~d*@1@3Cb|O#+ zekqcta#a3NmaaYamFsLnQvDVFQ}NI^#_Ds=;NumBAtjNlPo`)n$UEEiqzJ-OXNT|R zKVNPZ>X8cFvT`3WPKC_jX@<UVaY(DSbKYeZ9_sAz8m{M>A^Dgs{^Dim*F=)_SenPW z=9NM@sMViK{c7aMl~uhz=I(HOoUU69a(Lz<hHCKK$IoEg4s;p=79_P`1;X{LF&D`< z6Q8`0J{SmJO|wIs?PkUuw<}SAm89Ga6Cr(t&s9Y-U8R6N63QU$AGx7(;=}JT*^st@ zT{#Zf5^A?xE?nS=p6r8F33c5eeZq_PHfhNNIoqNBDE_A9Pnm~{>?s!s!`UY8GgmVn zN50T5+bddv>+RCf$svfyL?+MkXe2}zh|!RbJPT|xvr~X2{JDvaW37tM8_Bl_HCoP4 z5D2=*fkPEClZT?5vuyRx#3Y8`uRJy<wVQz-`LczT5zC&F`?FZ>|41p`G3tT242JEN zcus=;QK4ah`&`__!^Ka0wEm*|LZRpy_q>Cty9SR%VJEtxg4v;*(b(+j;uWkv?Czw< z;%5Ypnr{_)=f1n*XdVUIWYy%2gJ`b1CFXK(NhKMFM)`$QIul>*rjVYbBsTTbkh)GD z{gJP@$q-t_^{p%KY!7#l&JuqEno8%ZdAIRaAy;=k+2^s?8M+$O5aHpN3j67Zy{o!s zFPDImgwl%xp?7yx-h!MA)*4Bg(wNn~e`;!N_)3e1gW#zgL_^`~WVn6)xeMQF>-ra^ zMHkcjAGAU|O2upCsQ4AOD#bq>v`^o??Yk~Jsz)|ou$qhfmYZPIRkkZ~a=T+O`=A-D zCq*^xlZnK0wH#+Z+5CqkFg#AH9U}J^IovPV&Usx$Se2o(Ac(y29B1sGS^%KTfgU)# zU2)-2&YCVGqIc*m`!n!t72>EjTe8KIr7JnR-}Q)|SB^6I*WAA9Xoy0}^pB$t%3YGd z1QC;yBs}|F)-9(bwLC<R2oZ#P5)|T35`Km9^Lc&Iao-!5?r$LBXW1rbp|moZpyUtq z5}s1h*V1pZITaCJjnAC7qIADvxlW6^jQaU=I4v5LbL?UvOD~Z5Sxhe>%ixTEk$x6e zsrn_P(fir!w1BA}%WO&G@2R?e><f|i*R|K#NRrtR96jsb9zQEuIGcMmJJ>sQ6B;6L zJJ;=S%R*4Pz?0K0fn!NX51mife`WD6x7h78Pje$v#8XleKf*kps9X3InY%cw5ibDq zUD5&-2O4TGuCgj=vdYP|k@ji~g?>pMO{a$N5{Ox9K#Zm#)TR-nifn{sjr_>xs@+B> zYd)HCzv%o(cS^Sg(H!=U`u3|`dO0VWYe<EB=O&BGW(*MB?3=mFoNbG?D=L0ZDQAk1 zFjQ%n#oUL3915pAkH06-^01X=r|I1=5}bC35x{sy7MU$bX!KBApgnlA6uW(OX^|Wo zL1!niAp5Wq=k886;ff;Ra4-7$hrVMavR*6xc4RIn;G1I0kX|#k+har!wiF#95t5jG zM9;-aRHI`GeXKA7>7M>oWB_gR|22{+!yT_Xh#D|{IY*qoG#T2@_Cl%H7+m)&kD^UK z7a|KD7hiiJV<DV@gj$^eyMQ=WH)L=ZHV&F8-o?Y<s1qz-8xmv$tuI`ylbiJ%VRrf7 z#gLDiF#gAiqyKI|3$6>A`u}0X{NK1hlVXRBDdFbQCD|XSbcxM6(6V5pYqkV3LsA#9 zy%}qLB50D*-?{gj*HzuG;kA|q)-!b__3^g$WKpM6=K`KXa1G0BkUoJi#MuRu>)xrM zrgPdK7H;mm)!S%p2M(jrOX!hGiMsHM1$yUBdI*WFyRJI!?p8^G(_#zADW#LGF9A1e zBJYbT{|L-jOX*1dVW2bha^6&AOk??L%)9W57YeNXkr1NKO>js!c=xWFcnP+xmJ-g+ znn53SU)qW(DJ&)<?JDxS4jhF;3=w5(b2Oh@-+;{bPoL$pX(fr51xP+XZzJ7F5Ggq5 zzyh7tZzaXhApaplS?jJ2(|;+b0uQU(ySkpn(U3>$J19&oDF?U88<Zo*iR2IeQbn^- zOBZc6EaM(!#=tQtEc-T2q!!zvsuyOsN}(7iQ1DGKU5>o4s3@~$vU5!qFF$jTc$8c| z{Z%?$cBXVJUcRN0sjIB(mjIG~kHfjyiQ}l46zZ`lEAlMn<u9j&BV!a+<n<wErYgaS z8im`!3pm!6_i5{~1$;6oqVAc1$On#)n6d<YGS-%}ZbYsfiG|QGy4d<U`fijy)Su_2 zqK}E%E-e-c)(iiAq<cIfw{LHOP#)AxfrFj4mKVu7(_`e#G?1}LtL4@_8_0$D7gMu@ zc_*fzKECrKo_aQ02HC}I<GcT}yuPjjS;hWiD|=DM8Q-VpQ}UE+o5^iZSdT_7J3w+Q zHlW*hF%TK$?rDL)%XKjLTMHHX_0i*~5Tf%IwtV8+qVdE963YQ}WmEav3Z*y>9<%d& zHkz?6Kh?N<l-<wIvLdf!p?Rc@B>Gi_vzvszy*lr{x!;~&?i<F^DtpDrq&+~{2pXXx zC3-v39=`BJj@XU{b~~4{v**|GV0ed|ih9NDHw`IiGou_vNWZ(PhT<j)r5RI{83hF$ z2DN=?Y?#@9#P%HIn(+)a1AA!#wDSY44+2CgWZ{OnLZDKze7Jy0CANK-@m`;}xF;mo z#OdB#y}zj51>&A<t$Yaz`a&(wCpx|%ejZm%EZ=w9idRcYWX<d^n4MAkvbyNS5B<ex z3XALyA&XpN_(Y*Cb>&rtcJ4pXtST$97|`{dS(aXhOhj)KK7Fs9OD%^$lWZW)26yZ$ zf}QGLo<}B>GG|A$y%L^gEzc(tJoSQs{iPa9-;7huL_G*}ueb&!=HLHsMBF+7r%Q7$ zbykWM->0SY9`nt8ULJPj|G<i{uj4o=)Wxy#d|Cl14@Oh${tj_${TnNmMi9GAfP}JJ zK+R0NxzZ(8mR~S$T-&cl&N5CwKc6xx%ueIUZfcnl<uOITn4>AC9!>9PGraFYx+=m1 z8h~u%5QGS`@;UJkOAYms71a=mm6?^Ws%5jr&<uzAK26#Yvl+)f=M^bzD99tAlou8L z+99woM>Cjjs6CgY(3f{OOY_QQme>R}S<I`mr#)Ndvg>qzpBCF%+bBFk?*)UrO5JAU zE3Hgh<4!><lf?3>jCi6blmL}CfBCmc5j%Mm;p25<^_ybBlFTZc4F1C_I@+Nd*7ua! z+4R0rcZz{>3ktQoc1450F$3b$S&LA=m~1rr(H}${9jZpd`B4p_>D|T^Pe{<Q>aKAj zyp1P_KBE|+D*&bHdC4ApxFhk{Hx)A=4xgD!?L-FL+l)q56Q>Do;f;2_`ZJ1Gh_WYW z5yWcgY|85Lr2Uh1pQc~V87iyko7wj2v7;NmL&fX~$ZT_}39;9*7HoN?gS#mv6*O-i z4qZG~?`RA<zJHZ{X2MWqpJ?m1bQA8jH3r>+Hq*TF3Eae%l?9^2Y=sB{kt|vJ#}AO= zvs#*E8Gx`s+=(?*$nk?OGIa(5F8w2F<Zvv%A5Uf{#x-p$o3n@*+7ogf`x>!31ccLz zU|N*dIu_%F(}@c?u=Qzau?Ywoq!aK_j=<+4j}a7?e0V)qUBH(v(~VVAfde?R+JRWb z)R80e^AlYq3&vJYEmoasgO?3^Rhh^sx@do7k+cC|J}I_!ueGCf=gdB)8#yO~<7Gt0 zPUAxslwUpDIRvRKzv0!Ef7rB2HvK0cuuksv%h$-6JBrUz4GF&6lq24dFR4`kmeRzC z$2aL5@71)=c1c=Tkiz$RW0V3>%8FFc<|xA=V1>|=bGZ=Yue=g-&1y^k07em}{canj zv$st2dl7kt@QPJbSb`UIFe}@b3BGOebmZa!lTrAzjohQrYZSeq*jffRDY;N)nyl;F zEB}mBG)|%X;5Pl@XQ-K7WU8;1A#EelNU6?%LOW?EwwhFEKW{Y_B10ra)^^zJp)F=- zrW7Cjt?+$t+FIXwV!qHCAGx;7h=?E`Ins+c8I`;sK^ZH#VoIrq_?Q^fO&9IT>m{FU zX|;qUwp-My*IEvRcy3lcbr=oj<f8Ps#Jfhul))-paNU7wCF2Ek%b1_kHuoiQ5c2^s zH|+Q$hMBQ+K~Z){sZCwR6eZu=cEe^odz|<9Q%03?a{F4YE^1zY>8_!cUd4hJiedKB zLK#YOu;hSZsGjs*e@V7iOMMgAN)lPRHToR?Qd~~K1H~m~v>=g$&AxGZrhymAY#Sb= zf@)4NHWh0xXQ+%MyEW3rPCS@zskK^FwS#+~=9$pi?7C19?kbyKAfjv+Ila-scA<Up z*O_K-kzyJo`|GE7EM0?TwcBp{NWKW=@4~2luPOR^r*s3xmU75akc((|hs(ZIS*oFK zN<MKtbWAL;l&!y{z=1&|M%H0`n<b~#5s&<(JfDztP|VbqfSLc})IZQ6q<Ws&F{Zq4 zXtO$^D)x*hCDZ4Tz@(aeat_DEtsN)2?W!$0t-S%tF!$(6I9<>lom92q%!;gnl|)#o zs#4mnqL`!e<dm?c=ASpinI|VF0BgLmxHU21w72dOnseTpu536=+Lb~nf$Hn4dZ9sN zQ!Fo_=9X-9QHX|9MB9%78Bzk1PkW}C4wXOdH0P{G+mBq=gkVAjNsp=FGt#l;s5NH^ zB!=uXW5E>8J=;#w&=&1H9i~qB;xRcDRsm;O3;~>(7q&Z9xiq=&NmrB5WHN*0H(QJ* z)3tIoj7zDNib#Ux^hic++#2}rfX0#oihRMK_f_o>-{Xq2n3#O3e7HO&S_fU-*^^I5 zlQv}x{R1xA{UgG<46v)3YOYk`ui3ZSQTLcD7Tk91%`%DAa%CK`dgYka<A_aV-pWYT zwRN#apj!7y*NZ>*lbt2#J5*3vfP-rbF^n9i=@IIt(^%q*HRvI@CdA2cWFpVcGY>+w zPcbQA9<dgammQA`S<iASV2;}VsuO2%pj&@kBhC4~>_gfBb#Hzz4P7KTi_5bqByDC6 z{2Y0B&es?Jr4Uz3qg;8r-~FT%Fv3Hj&+jw|G{n=~`VQs#nmwuZ&wB5lois{A-7_@c zy`7jppr4_DilUbl-EF8OYb>vBti`Lu`#>#H+!r%bjAad_C4vT4R|G+ao@#o@vRd^+ zTmy};!0`qZ$(AE;9yKjbu?Ptv4LU?gx$5^69yzl~7`ubp3TL_)NuWr;*kqpD?`UDQ z>LK|b%4Dc$5-vD+B-eVu+5xKJsSpNT+IQr-DE(HE&VyY<<>9J4#j91T)|v9ckV-{0 z%pgA26_z~DSG)pGqAj}As%SfZ&<G80#|rCiDAurhXodY4qw&`_60TzndX1ctnNRgg zO%Yu}bO}qXAl%MBIA%eHX++?nBN%rC>BIxTq1C_k(qhl*GNbF#43N88KSgrR3sVNl z$jyp4Wz!}qAgvjll0~S~1Sv>umGdJd$b5@WQYhm#KJ12l5!1WzeMCv?k&_e99Yd7M zjc;$RxmTSzlie;)+yL^0iHQjWzYa5YckiUDTK=D9rY%k-dx<kzr;jNxszdwt>V|jr z2DDx{N7|AvU<b!lubc%Z90zsmSPC~|RNuziNQ~~hWtel3aCFgg&k7@dw_@blxAG6T zI`bl(9!S;hc>$&LR!Q4P0{w(5HrnXf6W^3tvtG#U97njY<h&2%Dn{9W!?~y&BkpD$ zori^&v7IxUB^QgJ9}{qF#yfLc@C)5T(pJNPXgY7#JFr)qTNn8#$+(C3)0;Nz)2|H; z4E)e;3ji$B8x$aO_Jf0iTeiM>j~k?<;cl%jDbRgoz_$|`#|QJATw|&=sUcSGi2C9C zU-VPwr*oY2q*@z^t+|iQtD!_#P198G{u>Gpf=9%SybtNL{cA@QYKXos2jz7n!|g|M z6O<2_t~zCxPb!*6tS>~B%^m0e#tP0;FeNNJthwu3wS18RyO>_H{4?1HDN#ttbiSXC zCe3!=;hU^)`r~sqi+;?MR%<++U1xDLW7`Z;+1?OX|8xz>nA&E>fWH=tdF*j}IoI9Y z4PD;>ehh^vHG9%z*L|0ZLrQv_to+&ZgqSGD^l~liZ{FGyiz-*E_C}jAOj$@i5hR;! zaB=e&k0|9#y?sOnSk6Em9sc?3de=ikLqih!1e#NWSK7e95RMh9)dI3<XHt+fKyybU zc++FO>%zS`txY}!c=O5dbu9AAd5)sQ+DFi5U4B}RAruOcRZxg{098Hz)Fh*&O<{dK za{?+pe@5?PO@D5ne8s;#>%e9)CxUUv3kV3v=fPO@BBByOMnzlG=>s4!&&3)c6i>2* zyW6(H1inV2(Wq7drejg#O-*K|56pBJKx2mtKBDAwQcx+ypcj2ROEd$T>btS<rm+Ue zoiu0%fNB%W%*+h>5(!`&Do#%9vxnC$%)bjZeq7YrZe7fNucJc<+6Gev3`fA?zI|HR zcx|>lS)MJK!tHV6TBMY~VgazvWWb@dsLRmr7MFDnZ+!a*7Yizp)%<$@qYkUYSJZ$3 ztXgd?a8`L)043%={?oT;UwhrN{xv4(30ip69idh5IbgHGSYBQaY|?t1cSlpJK(WjE z?Wf?4CrQGraIQDr+M2S)(b0<C#GjNH7O<hE=_r*GE>=8hQu`vF-HQfFdf@&KBN z5PY85p`oEMNy7(0M%LAw@7|Sxr+UmbG`&x;J%G?<*p0eSI`{9mK<9{HG;0yq=<3qa z763^X(2n!Q$H&d4DvsmVtR^;2oID65R{X_Be;|x*CeXr0iV*+1@%ebv^av5ERDb;E z<;l^o<nDNJiv<U_g4!qhq<%I={T|1qFroWvy#v$*FfV`E05~=?BjekH7tf#5VZa0S z^Bd@YRKB{auX?^VJ_kk|E$~2^-wa3lK}ZOi;Cre7MF==HJtvM5@wnd9fV({@y$Zr) zjHXiJgZ|naO2`Hu!ZAP!x}5jJ=e|A~c0)(z%Si$J_<xg9S5e>9ePDtNkx3<7*E3@P zt9{wJy8j_o@CvYexCS&DoL8^jL(gRX>?#0yPn*c-Tu!#~^76n$yBJ92Ur|fB8Mfcj zXuND9EdCT076v-`2Ks|a)cXz$YF(Y}zg8b6X@v?gh|MnGmp?%76y3xBKXla4mcYDE zVRg|-Puk3fF)1&>c>q&ob5EFqfmsV2E+J>}h-h1Hd~YV~&Z~qEYTNB~cX%lHq3`=- zsXC6&V7u+SA1D|Y+ZGONYCE)1LY=gCSW$@w^q_uIL-Zm_8M$pBa-6?U)rg>2xQ5QJ zy6f$rp<Y2-vvnDRJUVu}%I1mEHj<BWzMMuS1K^rJ1g3^}pBhVfotx4L0TNNK%sf^X zE&bxmjscOr$>t!RYLyMvfN?S3pe0x2Zn!uZon<r~rfyhaneyU{s+^qZ4T8d&5?42S z^`FKOQLlgw`=s<q?e=3B%^<!z3>8ci340qQwtQ;WsaKTf!x;;2Bchg@i~3fkxWw`Z z2<1AYy#JNA+NyYUEW(bhE*l!AWljW*6fBtOgv75fMN+$%czKCR4S*BF-&L+ay9a*W z=@)g_`QA90s#_JXj!ZM9{3i#NmWZ7P$f=m(u?|kK;=>Gbk*A0vNEjbEali`NPWBHs zeXZ3Bk3x#?3L&ahN#qEwRCI$?Id`!A#YEUr&0D11TeVBLbeac1EOu|MDCh?wQLkr+ z?!c-f1@~&wy=SD%$#>&sZ*6Vu8NRD!vp4Kx7~coPF~Vn0W5$O*!u$SpMJb%<n}o;} z<}6!aBP}gxB>FpoC-H8H0_ApFumqk1B7O>3ZlNlWB*VGWiCMP=uiD)Pm(FlvNzVP& zvwcUCXLKK+EV}%{f)b)!Z6gB+y+^r{jG^jprYXWDVZ7Z&?Qz+L-uBmVLeJA5!ooBq zy}ZBCf@L}#A(`3PjfZ_4GsBrX5-Hqjva)5i>qCdD{m3vFSjwgU$+bikJ4z+B_oD$; z0D}x8U@!NIfj}-6Qt-XWb_=|uu&QO=ga+IT&#?T|)Oo0BiqG?PUt&Z&&MMu`aKND+ zeCM9_LX&K%m@UC!bNgj2snpM3^{dDb%4x`EZ2q9djZOH>d}<3y*|>k7Y{F_%a?PdC z?Cl7u{6=A6?j?SJ_reSqO3j}8+2r}UYug?h-uEU$6c?(UNgw<Oqxp+W`DYIMaOS2# z7pj<04cHJB?w>7;RCSkarf9uqC6CYlw_Wq^!Za90C<HhaMF~`4<mEQChRrUaRIbZi zs9rxke?lYM0_W2i2iTyG&@dOQ$!XxWbjQ%1CR4yB)Y|Gv<k5^$280P0s_gys7Xgbf zMxZ+|iRnq<X)G%%tFKQ2EGWQ|g#J1ah{4Wa{mJ63N3a6N#*U8*C-qB-i8SA7fnRyi zIM!dEOnF=kr+8;Tse;fIjPBvRD;9>8w}oDkM0O}95-fG9+`iN1(TgEPBdUDkJ=Awh zz-j~xfAQu}rYP7%!LmA6X`x|jdjv?CMe_N8H1xOiIJGl77SY{13#y=u6skWc-69fR zS+kLo?SL8B7M_$O4%41!V?dK+zVA9j6?b%^24r5*BsU9fl+mW-pE=qAbp{xk6y7*( z{LbZaIdMIiFp$ZXxTbB{eLl_o&ou%L4&m0e5tj2yy6X|Qmqlho5cm~v?amdi!1_-D zqZ4xY*u$I0W!EiYH;(r;3?Eh>coSqdB9AM}XAa*zVv!<pzS-8ptE2Vde>FU@sp<V3 zqi(!_k`1m3iyZ#36a&dXOZg$Uh*7z6ndTSzbtr{zUU}t~Z0RPJ95nl&f@J5YtzgUI z^&N-G&f+Hu3d+&UuKTG0&n0+a%y7uW9${W3^55M}=pVzA)8wBMxF71L&sH;O5W}iA z0^kZ7+BKzt2@04DTUadSs+N{}>7m>duBmT7h&~pC`8$t3nVN1@m`yoL9<u^vLVlq6 zj{@mjB5>1n2EokmF*MqluI9O3m*nT;dme=G&usda^9jkLcRKAsLU>Nz;5R`6ot>RZ zN=jd&1;5{mxdfA;E-1vrKJZfS-+TVljHT0hct6$l12x{LVa5I3K&8OsG{De*Bl-=n z<_P|(lctsbUXLtS_F_}xR{5+lW!a!7wph0l=`L95{`;&{?|^L@5jdUz>fKIl>?^)& zAYz+qaN}U_02gaA`+P^S)BKRYYPBFrTQ94uOcB%VhBjdMZnxobC)DF=(bE&=?u;CN z6qZ0oU<5x;lp5SF(|WKu9c0Xy-#RKRre3V<QddcGU8?)=&C@zUzh0&HxA`wYmJ`#w zS#qd?teU;T&3<P<1iparU_js{^nB3If3R<Z;#7B^|0|G8oudH|4)sNZ3FoL0cE9eM z9XDM4)jMeQ*t~tRNWS)z1PF`l9^)Ustc$?ivwD3p-Zfi53hW~Uzu#}xJC=N1gMTcG zquPkk<Rs<~h{_Y+dbO8E2?&r#B<Kv7tQGqAO@@!?+0lgVNxe<g3!oE>NQ}?;Bvro} zKxB)N9uzDY9I96c`EKs^nZ8FVho+yJgmRANcW391Mcm8LRSP*ZtiwV%S4URr1ok|L z*=_@Dp_K`*dvzd*YOK4OR-W;5p=*)_fZ04%v7TpdZ>X&@#4okg^UWLEpC6yx5V3)5 z=lH!GGql^^FMhF*H>I1Cpi;F{(+naZ=KE<-HN5{))Jdtj*NA6wb|<eOXe`g}1%slh zSMGFA%~v-QvpSMg6E3`%-YyYo1^u=cMgiSFm3vhV(acBcRZAFnpT`yUqDD|B>rju3 z=$&756@(+zPhv~5n(0SNXETH+Opy43m&5Ez5VOdfitiOvccoIga>$8OsdVS{*pu+m zAG{?YP^T!&ViXDYKFj~)m|T!iuK+u%ZuE;rpD7npSn^2?R)twtQCT4)T5A@qB&3^` z!ARujD_Eg-{=QLuN4IXqxUkfEBl}=zc{J^dvAB9Ni`qicXbn4M_a_Dtk^E0~OhyYy z!O_5G?{$`Oq4;*YQSQ6i-+|G-a%PkA9UIbd`2tW=wpL?yWjx~SEE4QPn*jNIIc%A@ zSsSL}nhZxB{j5%1ak-8FaW@<^?4q6**~0v%!B(bb5Ffi&%9xqKa%yJ7tsE0i12YE& zbSZ*K0``67PbTx&CI1?r1OPJMc9&y$_D}6{7jNxitoEt2oRSjWEYSct%TW=HC+s2b z)sou!fB{On<!>cm6im9rxA3|6-8c>>?#k|ft?c;*30Z)@Mx<_TKC^Nzot&V&felTe zl|#9B>WGNqJZqrJ;DsvYn4(F=I5d=@P`xy+&RKfwn-5{I!J2;{&VDjZC2^9)kvL;q z{#=q2=(XM%3kVsmI^fsVbB=E?lnNPqMNbjpxjy53iS9pyUa)2O0}Y^I)>NQOG=gjh z%5C?xfPCq;Y!k6Iz4HAwST98I)@;qPzW}TQRl{IpN}Pr{aarr9>fDU*?I388*F5W| zZo2L}3-WGv&K6DkfCvawq4n=<R=VxB{kQC*>^?-~4U-r?#HQ>mxGnZEw3Dt0sfeez ze)*d)OcjznGugz5rFUv>rMfu!AADGxS=yCEOVIVvcS~g=>uczAv?>O>{0TnJjD|0a zJZYOMz>z$&56m^a-jEX@i5`$$toQ;CoJ}J3fL4}TWfKR_<B4~`mU>d0iu`ISS%Q(B zg!(7{!5(MtMY+Uksn{Oaj^YX`9>!8ds;$GX8Z+Ln+M<@Y6IAqx8H&>`g1(6CeiZw! z^lH21Xux67-oAK(IGxK*MIJjq6d+halP&VVZ-&JlPXYi>cA|1#f#{TK-@-n<;p~m< z(C*QQ9W3VaxEwF7K`Rwc8zjFp{_?@(sI5(*mj8eTU1*R&xu<pM)4uVLlvz1Q$S^Md zO6DZmm|`m?V+9fv^yyIi6^5%Z+h(nDCN@eavI}hzzw07wcabrVOq64Z{#$g;7;+b# zzuPdM@ETvw=S_2B6Cuc6h(L{sl0(R)Xk5hXjKUI>?szfywPqE5cEXTmPS!lV+}Wb! zXRWE7M72CMa^AFDyZLOHj~vaMDjCo;js1GT&|ML`ibgVklMQtG#p&}B|0dt^c_m+N z14W67#Zmi3zWSxFt~i~lcKpyzz(y}xAC>y$@Ib+?+DAgUQhKW@1_d=bsd|?9HMVq| ziP}b{MUj}i6cH1f@Y$xX+ss+}22zBL`TWYmeZzL4PHUTB!N9^d@`<YipOh$Vyv_EE zXVX}ea8$p1*|Cp2joMvY)L>rv8eChZ<=ewvQ3Mbfq)b{>3g&pEfNs?|J-Y%K)zp5s zpzT@JfKnyJG!cYd2XvO~OjTCCeadoN@#v)>Ujo7174zm;C9ry?-A8Iw{QHIN>D!;@ zDdJc@eb*Jyi*4nrt1nV)&Tz<l!H_A)D<&^g+S7dSLd}4d0-Cd!7NJ^Kbmg_bSBqAL zM~<u^YFAV@Qp#8#bDXX-nagXP$gjDHBwNjDtdp24CNqGP4lCPX*ima*m!L%-l5KPa za$NLSZMU{DKj#fneVq_*TB%G?2{jKWso_jax7pi!FPINo8=w5KIdUU}D`p@lG0(2t z<zuMqFQEr9!yDh|Ht2Q<zGH~jG5k<<{NQKARMNF@^-#swx=ucSl=vIAHSAz;2Y*5( z%9!02H*Kq7K@vJ6VR5DADl-uS=CaYMqZXk8`tdk~>UHwhkf-W=zUh%8$K~Y8QQs&` z%3{Swm}>)dwsC}5LvOtXw#oLkX<|sFgimdB$p~zFG}OAOXqB+0jw6Q|;y$PXUs+Fj z#QQ{LN48GrBh6Z2h#``H5K_eB1`tWds3uWm;WX<YO0xFxKN0ylSH_D|`D=71ho-0R zl`dLj>Gn&T%r=W#vOcZSi1$8ejC1jq7lmGF@u%veT|`KBuBNNw0}?7~gqiY;T+BUI zTzqiA!X-KHHO2NeTa1!yuQb#Tv*lM|RBDbn$MOs{?0UD|6HYt=k9Fp-EW@r#KhBCj z+ovK!s3%<|UxK`swiagGju5s~qq6H=NZ*TONr;O#us;s>-k4U>U|M1eRSYg9<r`9h zZA@Hg?(sQaMpMrkHDofb_TJFJcQFfC<w(B4p5HDfW}6UTwp;zNJ;U;5hYBR6ngu{x z0D>CNLEc|(-e=2!v<+ulf5uL93bT3{Wl#NtgoJp`{&+!)G+yt!4Tp9t?U+O7RSU+Y zeWgBs{*1C}S$5burP4(xh8`#p4fSggr6QDA*$Sp2l~Kp`oHWYKTZ~+%jM-p+^mNvU zTEk)sV`HjgbQ>*VBK2!1x^xs?I^8ERS9STQ23@yIdmY~ZB<mb@?ERN}Gxgt8(a2zj zvuH>1omV8vv#b-|njCh|4z{rcI<ZdmeSFyV$iAE_q=NwGfuy2>&+ksBw$9n&)B_t9 zvypbR)*-h-5t1b}b~knFqWvNZPW9Ksi116>YLbuV%qwj&2!#$wJ||_eX6GlJs%}du zC@Q`=ky}#gKpWaE^Gh?@7rHe+>vmJsIo}u=DalOGkk}bv`L;Sa<$w0&EqDe;?|HvX zA01YD;(!3l6;xjP`}=R93AI<s^@#gLY5k>r11L65OiTd%JYAd^$gznYeHavhe5sTj z_4g7zGzB`dZLyUwlW?Z8GaUomM+|Y|b}!>dqQm}ry$IlJO)8o6^fSrg*2mB7*=sq4 zl5lac$q$n=6EL|IZe64{I2q`e=?SY#d{zoCd#C2+2G36~++8Xgthbqu)kcs<T`~K} z70zGruQSZ-=VGVYYB~-foZbZVhiDCqyz>Q7D)lYjTzo0@(l8NPRPr}l=UV~hG|><; z>j11i2x~KJT5T*YzvokbyDAdN4SpMaaG;#+@NB&6%hp5WsiHLy;^mN4)J$Gpv0{tK zRoL%ZBgK7LP@>$#4V{s$?;VQs-sjxi-^1<5H8&yNtd8cJ`TBt*!t&+lh(Wz$g|iGB zi^KB4(xzbYf?7e+u5rdwkjY@*@yI}x4lOYA`}d}0>vKxWi=*|lv^1!E;`UhM78MIi z<RVm_m|DEBPviQmPFzbXh*>V%=v9a2TkGwix+Pcs6V3>#fkk8R_D`i)VrGv)sxHxT z)FqPmhs4SD%C%)Wc`>gTVhfNUyi@61FCh}6<kqdJ4Z|*>B`7&T8Z}N%zLTeCVv!V1 zM#gp$ommuiFN;}G*~<$QV1cy@fr;JEypB}}A|fG;f~KiD1wWjM*qF(I=n-5#3WG9c zBbnqb1sNsTxB3Gd%%>>GDN)LCGDll=z%z$cb04cBTIxfggd~LOmx>~Fgg=7&R|YPN zPrnh`+p<vhohZ$o_)fQW{jiMdSPpM1QpFl=d?ScgOC}njLd4CZ#UKcg@2)DGT&IAu zMj!PVMyeJ>&)4-qM?lRQCRM<Gkt~iH@-dc#%fP5MHHUTX4SCO6M|7kzarvyaogQ71 zWI;zUwaaXU32~(89L<|7_L$%$67>We*3;wD0OX}sy;62VJ9)*%XRyz}szCXtD~j^5 z?-sDferBH{az1fKeDnO)OBo^-$IgA7sq`4;;^-ekmyn1jP0!}t;s*^~4|`c?H5#T< z4rH>#&X0>cAcKa#Joh9<=fix+oe`INfO~9G#4iki(>c`5*ezIDx&xI6EXxx?j!!u# z4V5Ia$#zwowpHQP@}k87i9`hk^?vwshCO{uo6P1rTUn%|ZN#7GUO2-{EqnWf)O;1o z)qiTw*_LDr+aHq1g3S<EooNyH`Q^GZdz^Yl_vvqU%NHiJ+A>8uOP+bZ;ty=Zg0yUs z^~cpI|0gI9+kO2GBQ*q%3rOao-Lphnry?ZCB%cO^Gd3{3j+yvYKl!_M8AX5+V+0}+ z^SF#J^B^Vgd6YsflW~?%G?D!NN``1+CLJNKhPKs<{g6y!(M&J@F?C#5)q+6d_v_)F z(Tv8y`rp@)*2jf4B>6jrCmM6F-;8Y>i}>Xhq|+{Vvi^hm7&bnJu@nMY9k6!O@nORI zf)e2aYH1af%UPG*FfCr6wj!!^3@U8)!T7`|B{BZ_W~W2E{;4j~#w+JM;~;$e0O7yr z&z$4gzPaP&&M`5wc;CTecyLNfzrQgxAyAgN{T>+jV-oTKEi-V8oEWf83OStBV9K>x ze5<Oee*B<gkd0JbHu##sfdP?VGL;}E{Hkh@^E)izt27ZxJkqZwc;i$Wi-vQ3XJ=<i zO8}{O4nF!@)=Wd>K5JS%dM_@or6pe{joDcCgH?#Kw$O8G8`y;1iopm%s8uSi&N_J9 zfwosM8km;QiG07)TQq&OSs%)*sbR-wB+@DtHg>V<+i-j7PsJI;>UvW#4OJC3<=E{n zv<+uNA&EsG%DVyDzx1)q^z?K9c?6mMQ$(5{F5exDF%)e1NK~w}$$;WLV@8#KP<>Xc z)A77%ZPmKkJ7^tjim}XQlTgK&)8Pu>k7DHhEhDftuNtQe6C=e@iFJ<W2a4o156583 zJ0HBr3vqTvH!(Df<#J(ufRO|h3ch+Zu&?N739NJHg8=RUOqm1+yXK%lbl5*<Ls`Cz zspP~%UoDBNlvBo<#O}^VmN&+z*;upFl9iGg0Pu`Bj9_+2Nr}1)5fYT90GxfG-I4vL zJ8@zwE>Am@6@}}h35v(b;@;jDuX^JcNjW$YV1guO9+423qCOlhC@6@GM49gg^j^So z)%<W@=cZd2c!!IJ=YF&{ILpZS@~~p#EQX~aW+&`pRn74ec@09QTv>{TbjEf^p+I$q zMyr0c6d?&pf*AXMyYK<(Wd!xrR^zFkDIq@Hr*@;w2bIC5947N=);O`~q@<ZaW6)$l zpMUC22FXtP2TPs0f57sN=eh@=Q{&~86?Y)`{1^kihyG9f^mncdBO_xcjBw`An1+WN zwCb(-+j*yY6%@6DnExp})vMov+MC1sKq{3AQ?O@1ACNzze|u+Lf6zslnwok|#p!W# z<pYNfnhvlfAZP{0re6ZXwsLYK@k}PbUFVVv4T$A{^$;*!y8G4?uq%Ro>*Fb??%qP3 zf<PhexU@YO`xM|*&)386t8g%top+Tc7{XcLkyHxa&jZTcmF49lTc&xpDx!Q(kud>Q zh{Ng5IXie)>hi6G>OWx8+dmY=z3?YAq+m3uQRNPU{w)Y*?d>z@KcJug+YUC+bN*wI z^9Q?w8JO-u|I`$GpIz*A8qxnR-rhQ@s`l*`#>WIyq(Km*yStTcq`SMNrBy<@I|Za+ z6PryVN_Tfk3rKf=_eP%Yyyv{XKhJyC7!DbO&0cHW>t1W!*PQd3a}_&ggd1EdKnEfG zg)bMHr}6N%t~L1ny4fy$#1q*oq7u_pbDy<`%sxE-JS+YKeEioj-YsR^#tzE1d}t{F zE_(K0-m+4=5?HQi`}DSU<G@4<RUBo>v<!<U4VKKZH9FmEi+!P*7Mm|$@%HAyG@QmI zA=5FvN=iK|F&JjQH%-b`%bBBL_6^#l|6&1=a|$`)l5#=H@9JkYOv}pgSF~4PFH3>= zwX2pAmqbYhy$v+JTf-&-zF8Gguqn4Y##vfldhf)KPibi-Mt;j_i<**5=2hubr^822 zFN(XEdON41sk}DWW2}WGl@&}mq7kYwC@-Huzsg-0+@bW+NiVDBEkXLXby#%DEVWV( zc?_aqOp;n`{lzCc2@FB)x-oJH1OgYtCP<<e>bK;lz{;bPf3u<`w_2>3k*i>L52(7- zRE{-qKPe6w($R+N|3k?CwmKNTG-X>ANY*wgXH_c*f?}IsX>u}&OeIApHnw(k8?|Vp zHSX@!OU7X%@mL}sO^TRc%eZfJjoX&haubsSLyiGcER|`hjOyxIv`syKlSRJIkalMW zeG`*UouZwTV@eT`>SoSSVU?2qRY_{gO-<Y6$~#`N-Sn^@ww_XK(VesS`Jb#r%-plN zx8a30mJ((9%6m5P!fM{YTQ-bu0S|~;&sJ0f_pl7#%KkiORxHk1!`k{kM6a)qr(Ez3 zn~WTUd?phb-|M&QJ0jJ^S?5(K>a$jryh|@)Kn>&CDOZlg#9m#)PTqJ)+eD2;Pdi~v z@k+V3W3D1R%UEkR=T8s5A_;sN=~evgki_Z!U!qj2VMR?Xqw;dC1nq>pKKHse6w=aT z)Vzt0q7y^qa*tzoNNVF%R|EWDpyJkoRzEk@R!<a3gViu9Tk(A}=1nZ*P^PezHip1< z7g9qjv$hDD%AWVNa%fl}%0&Jsr5qh~mH_z*`3~KMT)mk^N|<F>@k-I(qMGp8WCCQV zI{O%C<i?geicB>1>@$eutnS5Pq+3-=<S=iXelV`lj!TL~oi5f^dLMKAW#dM*pUik2 ztgLL%iShY2R(c;A;9hJK>+&Q}(~fON^1;eqza{z;01RYbBy%#+53Ars!(6kg#r6se zJS!zADc8MlQY$TJ0(;$O>zb4@bg;su1*`q{%efbRSoOTX!cZs}H69a<k7J3wHG>@~ z%nQ@*o--)_CdTCZVTw;)Y8*2_Z2UV91Jq3ORpGD(hqZoeHwSi*a?t;ROTNNo^gn+8 zjV@_pq_EU?^@Bhe;&_33f82{cg--jRr^colLU17{l#4UGr7OzP4>wdng@X4NJ}_sJ zQ<qLw+j%-i=8SAuvwbhR@MlGS(OWA#V}Xs=D`d}!bTebu8A_BWJ)Cluv{ee5o1wAB zt)p{|TfDYaW%%+1PF=@nLj3VbF-5~7=4g(KRy^w{k~u+J6BHS3{4Nn40TFKIO67g~ z%t@vA{dE%n^^g=5b;1Y>e^yd)sKHU3Qb;Z$a`td+<lBd0(PF6=mc8s6+Mgtq+ds!p z!%8Qn9~J&2lXpHk&BCD04zbnq!;$JJYGN(x3R@O$%WDqUWo7+>sC)#+IPmI+Yn&Gi z@Bny|0xygigZ?(=m4z(vDt+}m6nFgqO2#OyRPo3+h$0_!PWzXL#&e|1Bn4aMG*UQm zhGF$S29E1Vd8Gt}O23iVH(Qi7rOK~DevUvj4A$x_Ex%%v<y(-ESZPFR?5E{a4pnE@ zm)1{^1^0Gorey$49pblqewTrZ$08DLbnOoKsKG7}5#p>8<W@=%!3B2<sn|g?Y6!LS zLkoq2{c_e<?t_+2DXB|jv*eO3FuSy{AaMvJ2@SA2_q*-*&KD8SRL-@Y&DI6MPi5#9 zcK^r}@>lb@#IuOsH`ifbUYdsP+PAQHKh|FIiNQavb=|{EskT+q4c1<ycg8XMtj%Ku z{#Ys!-BHke$8nd3S&0)*g{=HvpeF`12X`H}+KeD6mH;n?#++5?)kWj!BJy#E9i-vN zhv755ohkW_Yi{IOM$oolNy+xt#;y)CqCGSz_s2^nt(CI;?~r6HK9R@1$j#>Du#Kkr z&Z_=4+Bv?^;q6@Klor~<tyHtMz$s0NIkKYAZJf*GHLWJ1BLb1|U?(&UJWh3b<QQM` z;dnQm5%}vg&+nE2)q9#1i^!eW01zgO`w<$3UXY9p%2B34rL|Wo)8<UdQ)W(9oHAc5 zl`u55&Cw4qO@)kOi?y}ByeKlm2-_$g4V{~5HK*$Aq=z`2PrlAlD){GW><gOMIo36W zG>dBMc$})!Q>;EVo8E5rKa~e__eyvk<6}=>1l&-t)0J1b{c-nh*l)!KQmh-GD5<c6 zbgR;*I;PsY(G$#G^g;Xi>nu_O8y)k<b#$wIyf%bsvW!x)yNG=Oe&;OZT3qOYnpWJ? z-7z_3hfrUfN{L9fEmEJPCVkOzZg1rzFMCanEHKJbR<LAd7do1w5tcYT_^W%^CZF9s zmxr#-@>Mw{Opt(}FvmK_9;!WKYWMr#5B}0u|94<5@o^gIcJ-}CoAn=rl&tDX5x@dW zt|+QApj*TeNUV<7$%3?FvODRzQcR=#s4^FqoEFH_K0GPzbK~ld)deMOdFgw^wbz2K zu${MgROB^Zf9PscCUQ$w=JQfCzf7;9w_TCc{JGKN+E)60YH62ooxu_VqqjZRq9FJ% z91oK@XpK|jfRJW&d+xk_-m^{n`;h8J`9+rh-o1MYn_ih`Z`*aidpTo+KRl9}jb=`( zbi>XeQaUFo`DKYC>){-owSQ$-M8m*{JcDF|nQ>H5rntwPf|Sl+lPei8mI-HGUupW# zicw&=2t_nzmZ<ASPpc^BR2D&1nRusG6s27vXQKUF)&0ZJ<~b!%_7EMLQ85-fzpdwo zgm>dVChC<QEir&;6~g@={bdk*0jg@$HL|zP&`XRkkXwZmCjI<n`kq4y?R$s4zgoxy z0wJ+mts_{nx0cffhSb=^n9rG;zIq-`s+AvwqUb`eulcW~3;`PynmRgL?|PtV+c4Tc z3M6Dt{6NK0r?TYm*!Ck7V-s2JYMQJwXmbsb#wwn&34>4691SHTm@<Jtu>qayd_xJC zx|!TkcdWt*!xsn#pc~}2kdDkM!9P8L`*J8%^mvp1OJ2idX4Zq%MdpbSeD9*l{;TN- zcxd2UP!j!qr_B)?$3k7xU(SDk19zRlG)maaR=7|zHg&o=1`0XwkL-%v!_JHmr>nc0 zLiW&WX~q3<$r>xR5tiNhrjD%f6difiBzkOi<QetvwSd6KIlAaP<BLlOii=6OaAr0O z6IhbM_ZP@Z&<~d2kESMojB4rTYW_y%ktjsdMom~1n>HjlzN|J$mO(4A=nV_N;Sg}1 z04M$1Kf4ADG6x3-5KKf;vB~R4K?J7NgOAMWa>XJ+mJbI7#h1)SXsDfRxPEO<?a}3> zH&$o!;l;tNp(Oa9By!T~m`V9*g2Ct<fk_l&XdL_6nw7<GTt@Bh9+MWZzBE3#T>`=k z^<Alv0#q{uydMVIL!327b26zHK9OGL$K!@~8d;vmTY#7mkZWX9HNCXAtJ8F!=tI&X zCJ+YT;7shDZrAPHa>+t}ZQk@+%+1YFnaOVs$n?2A|C!WQTiZQNy7~#UJ(qj!Xo0>P z#Jt}(A$3zzQ$QW)03;FHen7mFuaHknLgMG$2o&iL9zTW`Cm-+qTgFvuLB5+jE>Kj& z9jji?Yd`TwlvdH!o^3}0SvCy5GWhMO{GLD(@`~G0yyF|FZAp8gx>um4{j##M*W=z- zs_-!#5cs#MBqG|dgmCK!(Iyvp2~qk9zCC9hq+w-M`oIg0BP`ISN>e=P`{jN&sIpH5 zu9pOwnwo$WmmQJ$1|@*=5C*>7)p`m1_jBHeW|0<Um(AKDW-CZMlBJFn6%)(N%>~Hm zfw%M4)DiIZ`ZEa?>qua?ns{v_2fsu=1nz3U=OXf`sHoQ_kGe<+>bjS__kKQ<p6y)O z2L>#V%uIu;^CJ_eD{#cRH~|i@VDq}uv;-<BC|m;WLi5V%7)z7z-tg7Qyqte0P*>C% zwm;8c2BxD|yUi%@Qs*PX-ANM@4I?5WlicS3>c5T-apoc~pqHW7tnATa^Z-t@w{<1= zzn=}tjHB0<d}Ic$4O}KzZP$E5uv4>^791K1515*snu2Q}-^ks4KF`<Jw>231q|xKe zn>WBxX1?(a2L=#nQplwVi^0R}stNwqTah$0G?pMZ6sQ&J^*c*TOF8B}9DpUwa!w+b zgUP#h?;IRh!1D>=H{x9hi}~V3L{iduyTG-4*5IMM6&MV-VnRkHo|`hZYivyA_QDR| zm8i*r8I*K%ut_T?CnqpwH;2RLU?_$@y=9u>V$vDTks+xPHZ)uU&!hkC@IQV0h!7jE zQ*tw%zU59W(BBiBipiBYBPbSz&*iY%<AcI44vq=I?QSx5iIXa*|7ze^%3VBv55%3I zsD|Sa%*;{nPaWRbDcKa|<^2Xu-!ni30zakd=fDpK{EtJ@m&yZI;f6k~0%#5Z6}1B5 zbUcqnfBm|>^WVR^djiZRB`3pki~y?iYj>d;_@BXJm~i<oj<+-$+>U_l*E~Qa?}RT1 zr+?SFdP>|)Q~VPyznW159Jx~o!q+a(4g_n3hgosUfx=~ceB684xN17xeY>Wph&E7I z5Pry)ceq=Z)%?|T<JQe?_+!^J7u{M%iH_KS6Vp{+@0;`S#;CXE*49^-2mN?>cwj*k z3>18oRaNkmAE2{`!z6=raHWaOlfRdak|gnscD3X6;zN%0u2{%<D}xs*78Vu;hVbwq zMoHM?rbb|y<XoeL-wuPpw69KpY>7R23kXudS%sFKf&tnJ2nc|?--U;Vi$UP?^XgUt z4tki+1~1nrDn!Rwug*u-ZRxdZM?A;iM$X!G&PNV>89Gm!FQ*%?k|ndzGRf|&c0sn) zE!^=No!9kT6Ys|8{HqnnUINcF)oCVW6&0i#dwjrs;4bLutB6UF{RGn)!1Ms}AUP4d z50E+p9MQYUUWl#hu?K9Q<!x*r-76b&F@eX#;P(hsU7YaAh>$hrc`{q&u+|5cfJc+d z0G;@4YE$*AzudCkNOym~oUE+MtuzYZm-F4|V>!w}!Ls(RF5tB`_^yxBPyQ8|f~ryc zFC+!u#s3-k2p^o=?SB$f`G3BHUpJ;ZI=_;*QEv7GK6D1ginBf?qE69yQXzAm9qjO+ z5_bz}q@+SE<sWP78S>JdmUD(njaFEzbEd9pO3($?dS%<_z3HH#1^gj%Lmqmlk?}Ka zBF8OM)_anyA4*qSkZ&B&l-}4N*&iIFZ3d_CuBEJ{ENks|9`Jtp4^GRE{9q%A2`aak z(!clkBOkW);h}PBtK0GaN^$#CXT*srJzON29JIHuD-9uHpeanKfw3##D<))i)0*C* zPa&VF;D6yCV)*n+2r*2w4JY&|jl8dU^ChQZt6j7Rsj4|EHR!DgGY(m4<EEj=@ENgy zX*Kn$s`ce(k@%2q|5M!7q~IT>R&bod8Jyo_W}p^?756%~<GtQy7)EkKGe%Ko#}Miu zwQYv%%8F>4Bq>rhIc>+Za*McMtA+UqHJ8*Gk&g^&zR9ykDz2~szBLOr%md8uFcrl9 zeU#^k!c)J+HHNqv6<-x+lX;E@L-8k*%3}wOwsY#NC>zm)xEcjkEZD3IDH|)D#|(h6 zCRT2UO2NomdoR`4wY62;Ug4PJwGgM#`jdwCelzBz9(Lk`EKG}xIrtP}#J{}_^l(b0 z^Z9u!H^)4pqbI2ChaDyI*OQTDeAT3?$oXQuS_FMx6mV+CM`{0*yrj(GG(6k?R?nRq zP&`p(nqD^AfD^`*maM5JMrpug1kp*=G0QQ+P)tZG<sKf8^uKS(Utue!;#NENgA*i< z#KcEMRIG8*M3!N?N2%mujM)(z(9FiCsy6GSX1$l&CQ8opVfuaYiWynaJvq1(q<DIq z_^XAS1EuCN7PFv!pT$Hj18g^VD_6zIFWDug8(midv5w%ZBF>b2z5FSAkbz`X&NrLh zIaTTko5c+z#W)HhjAW`HzV8&~u7V~tmf1z6e5rM1Ck>`d-zZ4G?XaK*22jmLSGs9e zB0jsdbnt(COA$Ur$sNZ1DkUQ?6(HI~YGtx)yRN8&CM%N|y>C%u^{pi)m!k`PehP<< zH5XBl(@Db5Z`Ev8foxJyW4K_{?Lb}Hm5Kyev+(um%pZkvRvs1+vlri#1EPlP53)uf zVnS_`oGUc$!_BA0>OmYkh8X=jy&*B^Xc=+YC4IA|YNwKF4;o4a^h`!C+e$++nnW2! zhhkPX{-O2fYv?(uXAdxnPd9?i;Tu+*(<$-bMu#d&SdnLmWNw!{#ImHGNwSo;ox#I% z^t?DNqPyY5-jftX(jClx$<3z^9n`MX{-&P1W#a);qAD+<DJtb|ZXzbeIKxu6G7wDg zWivjiJXy5PCP#~_B}<(jCS@m{(sS$*zP+>j@55IQAN7edM!g^(g-IjDjowO*-<Moo zmq?)%;|wlqBM*MpG4nRKMeY?T6@O=Gx!)jl)QV&R>Dc}fR-y?VT{&iQk!%=MUoSN# zk^@#4Gyym6snieQ&d*I4kcTj4QGcf{ntiB+R)6VMOWTNA>Qw2I*6+MNT=K=_5DzN$ zo^WE-`#Jop`vKzveS*<`%ABDlk<>bt-3%O)27JAuqM6dai`}&>wa+9Zv-Oan`|4Y5 zYb15X?_`4miT0nIc*JNGD|gNm9;k@5pC?iW9pJQCKog6tcxD@3p_#HA)t-=U_)(WT zS!&c$Ih*^677vNSF?s|X@>{^1kyjXUqwPJC)fxs(l}i-Pv{a;u6sO!Go$GqSmcN8f zuF?^|EJbp<`vt}&5&!VreyN)F1HS52_97|<n*IshFWiy2%Nr5GMbJ%jG!bEsA`Rlq zOIB15X(L9c;02jVnbOj@lcigFw_?svhjrUaWWxfxp_j25bY(Al$D&19<jwArHjFsv zp4Y~VgoG5I{b?G3O%XH`*)>VdTy!X#2DV(~4h+6O+XyjW*;6zJQaFati1P{e7+&Us z)cPJXIS2i*+Lg$%{K=I#O}uxv;d^ekn)HvTMEfy{-##xuQN}69@GW6wF}b-EE3ygY zlA+`e(-}m35#^+o(ISPhQWFYZ>0wxeag{r5@;ii+^I}0q-$9u!V$EuAB7aiZCgyg? zp*N~vk7Rw-YEX)->(^sN<JWalA*rCVf6j@WJJx~JP4iSXPNyK^OkyNz(Vjnh@$({P zgO$JUSl-n>3cdir?(%jE;||{DD%B!wrE}Pm``;Yc{mW1V^h*mDFYP5R=cLEGxS|s| zZME#@-NT~_4Tnd|n)Ei*Hx*7WMDr9s`<osfq7Kf@=_o570ZQc}x`2T$w9>!9!H3|< z6GTMU-x*(WWK$*K7giMI_I3|>5;P_}U?rko!}yL(-x4WUMmPiW5gPGaff<|eRJ^8D zMtp%^rC?0DoBj*cH17_+Cv2MW09Ca6R<+8G@hY*5qa?YC*-4{!s708LPtTqh@rj}Y zkjp$8&-BYEi&i+?cIp~Mr5^h_j>q}ke$FR3Sk6`{rlvF|GT(<3(7OMmV7flSD_(Cf zO&3m$cu9y_#@-PBp>y<0L{5T&&x^D&r!V%%xGqu4FT1AszH+q3tj25jN(hPwL-b3l z`A_D!ayZ$D$V&QNwaF8s-rOwww&P?5t6TWu2R;Cx3<Rq<+d_WVdHdsN!*-NL5cDV^ zApzndsc32I9oNy|3u7FzTgHHqIq&Plh_K>$&Ps4)m+%&QW=Q`84v`BVnY?g;$m4x$ z<f^>c{1+naHZx|j^Yw0#a|~}5E4bHBRY&&UvcvY2N<8_d+~4^%z${tNIC5>>JO;xM z1Pq|A{@HELVR6g76w-Ya3WGz!*(Z8yQ3Ed@b_$RND%Tn?YizX>=zTU&E}x?j8k`!D zw$^ou{zArIs8QuJ|ByLRx`tKI5KB}kiqc>SwxUjj_^763nt_;~%}=I(Dk+YvORFGf z@)d4juYO=+$zkP*$%kMx)-TLaaoAnP813aT8<U|@?P3SnZEbDK%gdlK^E*-spS$zs zSn&?fjKjO+%!hN}9zGvY*9Hd%-|BV9#-woBElc;vt@bGYf;@lV&f&piI2Ln?eTj&0 zxY>KikmD{R@nP>dw_3GI1~guHn<D-xj?McbKmDRDVgZqeOK0{<)D0sT7uh|eDfivL z?&4l2OHL`fDGR>Td^u%gp3l~p+&#}qr}&Qv1WK~CS@W(0$bk>+rT5MJO}m)557p%_ zACzrSv=ZYA$UqTnhD>ibT`Il;)ARJlf~GGJ1ox{rN=+HaY#P2G?CN$VFw|ePtv`%} zVhQ&W1I85|I-n;kKj#>6qSr0C{Fa)lW=qQ{vs0!PR`V#daG0W)M#ZW}b~K<T``iO6 zot*dksVwQ-;SW?vLU%cPbcP>!4xIR)rtp!D|B&u=p086PCm}%uzimj64dK>WKh%UD zwwm%1n)GTnQ1VifoqTg!+6*k<f#~-ElTinMeH)YK`*rhIklNbYkdY8tcuDn<X#=!* zz++26(g@;A1|>H$8xM2rO#XLJ%zy^NxwO5f17<L!z)>}eyt1QH+=j5Qu#k|D%S(@^ zZSeOH2_hV7)azNu$_93(9H3RM@nUx8$&b{6uV3M^5)SD&^R)gxjlxJyk&TvdmZjJ< zju=j-jS&ERyn!FS=nME={{yE<ObQfnpbBuQ27xNdK$EsZ6e*pd9N2YYHLEzdZK`P0 zUa;)PW@6f}U;<4HH-oPle~AC}&G?6q4&Lno%G)*0=Ywc(+}+(REiD=3N>}ZnidC%a zf(DB8*>wJ%Sf&iw8yg$&Lifj$+sZYoq`LY8z;Qe(mK~2Ti&A1@gb}uR*ZR|Ut`I3q z*_ec}QEAFhyDZAzH>^l4n|yf$=W20?;j3E4bCcerm8B&Iz(9wEJx4(ahsb$?ULDZh zN>Deeqp9h><S74<U0w{2IqG<{!o489-;4jPHWbz*3cdr7-tZSVj^_eBA#gOGYjE#1 zanRJ9h9!Uz{na-zHFXX*;XPU7j5AKX>i@T!^Y2j`wt`YNB|5s9vBws)2bGsIU$Z#c z3S>NdFSZLrB1cEo78V;!v##aDHxcAaN0_i-9`Ob9u34*%;xmPJ?_S(KVs8DEKtr?n zIc!`9I=uyNV7;*VdGGz>C&<V=?|=Px7H!>jDI;-qzo+mW<=-(&5(Y=!MgTfLKxWs7 z9wWF^qd}N7prDJ@gw;5#d2MO}m=SCwcNMRiBj^NeZE1-}US3!LvxCWLLkV6HGJd`j z6Uc+ER9=~34wlF2FbidI@h7*(rvA6CY@;kwyA0O7{Cu<Xy1PG>hl7hN5&rEVoPWV0 z!*O^+Cs4z{)6}l8(F27y$4dl0?7sfnVv=`PMdrYxH&)_x=U|rcRaGt}4`U>ab#&(7 zX*CNAZ{c4k+Kh2qryA;f_;9Lwy&n&rNdSnrxeBPMtX+4P+E(ms)TEC;!Q_P>UI@<L z-7hr*z+r{{Ri%6GR3GhSUrJ4oIsB&3d-?_&j_}-W|5qAG;NSEgJ5_L;wJcNf`<f#4 zoxZOhlzitGct-^pH85XsyPkwTZB`B6Vv051kbWxl$WK@Bm4nk{QNCIU18ZfJ=1+|c zn<B~h+|97%b>!~HjSOeny5pNF@3DlO-9+`ZX~28w6Gu$R_}3GF(A*+bV75A^z#J{R zRs~Je&~t#XVtg3OP5Fspi$O<#Hb!hjWxJj^n+b)mD4pD-TOP!ePEFB9!@}knP3?Cx zetRl`%=ub+G9lX+3b86eZp#3KA#uVzuP+*jp*0Nu=4bdkyQG_Qq~;TvpJW?`iA@7A z=}j0aUU#Rq^6aC`P_}1_Z~X8t79h1Z)_a!o(~qwhlV_+3>qG@r5+Wl#V(*t69mQPE zte{Zdbs^Vch3T#^yuT5_bnnO4zc3-fN5s#tIJ{})lI4b2R4Qn!s@k&Ud{=kSv&>p- z<`t7zNi>Cco%A^i`o7+|XibZ9Sh7@bHyy2|A!eX$Y)n0Y91B~K26jozVVyxxSl>h{ z#N;U($8=C&F-?{Dh@?Qlj&V0^eKHZ1-N%lxI6<TLk-vi9U;{&VROb_U((PVA7lmQt zN?srrlNOw=8A@D_2?|RiFya8Cc=-0ideDXqH-sEH@?n1S$l+*+KP*8@yMoj{R--ry zIeM~|B-^A$l*E8K<PyC?BkTFTvni>^KZcA+>VbmU?{zk6cgJe?gI+>IqX(&BqGY+8 zZD0FYrmXrDuJD}Q&b_G<oHa$fAf&|3<_VV?b4^UyHIH)HNfs?0Ea`lwCAQ1@+$%TT zTs)ibqD7U8Tg8A`CBX`<cj!Z`g6vUa#w9s!sFUyUk`-3pv~umJl9q+Mna`9=8#i8L zA(n+wOsp>E(&3+S4vk(+4}zFqI{c<g%DZ*lRDbBn=<G_OE!mWn5^%zDL}<HFgU~8} zzVDzMqK#n@B{4+r9JP>nXZlRNaIl98?Y@?jY2v<p212n)ArG>+(jd11E7mHmv7@gn z9;sbP)#W=s#fFGV+*zm5YF#X9N@5-Dgjh|2NK7h#2Vec$BwS5L>;I?tEJR#z^p&{W zfN?FikzE|gIwIow8Mz===y}Bo;<^~^rO}uNC5PqaYVSpeETS>ZZ0$MK$>G?(wDypS zj|{P$%JL59;Ywonwg}Y8m8x*~IFU0kBw>FzP9RN#w|dSzrPquxctk_Q^!u>&*@ZNF zZk>|0e%HP)SoDjOwV#mE$#wk6<xt=8t8q5e-wx|dhK5fB3~MDsR=8<fh&n%%N@?@V zN0gY{t`z#yi3ExYU*0?zFAWtD*+oQI&gC0_dbiX<+n1I-I#t|R1Ja$ODNG#ed%jPF z3SZ#IZa|?!9LA~>!XB+qRf?j`KPl~EN^JPWDj$$xk}?C<bEbLuRI^Z<8uvE-%5L+* zj_$s$i5MqeT>_?+9NI<=0b66=pplp)TF?%Q@K>P%5IdjY^;czku@91nQIRT5wDTw1 z{9Jj|Ge?XePE}Mx9ae=7GzluI2RMBaF@boX5O2!o%z_M<8N>RP&e{WyV?(L*=RC|C z6%jX+w1O*R)>c^xMyiHjmxpGdGo4=;A6fd!Vp~!c>&KHr!Vg!a$IyISGw7KrHEo&3 ztc=9p71jBnv^(m0wQV>(a>;j&G_iITt_Vu6zpIeT_2(K~qBk2#eFFlbqeZ@b+sD*1 zPo~PBNKB|=*N`pq!WuSVX-13n?e~$!f3?%)^L!<a+YovLd8Nk{1%qXRWiRt8fyJoF zhT^DTGBvCvE<#yNf6&Y~b@N;LYy$O&ri^qOUmJIvE{Z9OYK5Dn67!HonVfExxF-#b z2E8NFi0N;cRh8n*lP$E$%vmaTd&AmYNi!o8!`cB>X|jv}j^doaxuch^=m;P0m<a$` zz7?iXP8C9yN8@KJgYh6nJ`ic^+6eJ)Qh>I$F=NG%71fQtAXCh{(n5Vs7S%~<%F_)j znS_?7`sR%09Cv3Dm2(3J4*EJu_kH_R#G77Bv*t5usx#{e&_iZT9Neh6-P5_~X5&j# zI;dy73$hHaq%m7vpBlm@Psyr=J;Q}YT=XMDRH~0Zb|{LvcAd4>)#dNGFOhC8KLMTI zQ_B!93wCIyq?z*C^9ZpVQ!%t5H_~JxM~^pa&ig##vPd#2xuR-M(l4t?2m__biB)n- zvDFp*L^2mnY9q4B%Aff4XbWa{ciE4_`nUEE6(4tU>pn^JWwbK&Vbou5k_FPYE1|ph zVYQ4fhqO5xK&R5;Y?qC|GpcwW%gJ1aG&64-YDY_$93Z^BvAnK0+s_H2bViQWG4__R zo1Z~~@pYvWVB;%Nlqcx;iYebcoJ7kKPxDz{@?)bNRke5_>1sxo+7Zk5e@d}(TER-R z{Ak;^ZnY+yxxKkCHGLL{CV$e(r9DkW%qN9D{!!Km7+kPU8k}v_>-$})#Adq9OX_FK z<~pc(qr5;MC%z*a2v5V4K&S&i@f!v5&xeN0EGKw4et-HH5fSl)zzxm=J<#KQe<ZO) z%*-<E$FGZTI4gFJOiyVmC)a0y-t<i0Z1cFpY74SXY?~NRSXQ>Z?jdUg^czUF7Qc|* z32qXNwH<%=2=*gm)(^<KtEN+Tc^pzYrO(Y99TffQ98h+P9YVivt*=XzsaW!eX(j(; zCifrknx){jekw)E%x_nIq=Q+=_%#}wuq=7Tsoz9O%jnw(%VL&kQm<vKg08ZyEyxrq zn2UG43~DZ*bvTvB#S8Q@$Vo|EY}tBi9(7*CLDTVhyiP+o=ENl>HOh@7^z|3ArQ)Cb z;QV2iGQ4hK)uK+lAB4-l_?u@X;z6DdWlqHe?E}GLwpPM4?wzFV{D6@z+EzbIh<>wX zs9&o|AT{k9=76JMBW6@fV=FD&ZUH0b$7<W#CXgF@X>+f9k%SRdG-q&z268X`?tPza zSgao%w2;s)dz*)3%4C$CV}whKcG9x=at!ufNZ(I6=XusehZ|?`;2(lt$^G5G1D;H& z$g9~b_=Y+7C68tAuvE<pDs$l(F7U3{FH0dX!ow=TW(ZX3mp@UwO1*EczY~80+$S)T zIfC;Pii+s>aHc5*is>~>#J#E6iHNAW^JLWvV{xEE%weo2*T=`t-Pz?q`@35tJF}ZM z0peAOYcyg_2Zhjv>9Apg)Np1KF$uOvG0%2tLYWFr-rKwe%$E;aabzmz;s`UIy*U3W zfl;=n0bwNjRI*ZQr73I?l!Q~6V|;WjW$%dz;pKTF)5no0tra0UsBUfBJ2mpelwKEe zE3<t#^eq*hp%10LK?RgCuq54iaKD#a#6mQ)HXOg#yqKx7t63@aKGnn49CGTAWCHBC zD(?8;iPQSHZOb|<&v6ADJju)r>7NJ)=L`S6be1MT@clEp+Zv*-xTw2JfiO_QG2EE2 z%|^B$f=`Rk@M~xI(CrFj$=U%v@@GsslVKZ!*B&xFF0+jt5?jV?T;UOs@vvQ^h&?jo zIL_lZ+(8Uk1FPiF^|hn_?$hZmH7jBA+y-0X8#6YeqPeb??YYej$}#J+*{(h0DpUc0 zMOeGo6zq<=CU6G_Jl`1O6weE8U6*jtjIt!|9)CBtv#&0d>et~@yQQL!0t;T&J~`@D ziZw(URzO-0X~^GTN@85Gwbkp_k(_Zux%c|ppvS^2hb%)t3hcE7)+f|2ARq>8ZFzu3 z+Xl`B5$kAnN8gsZN%TIKh41IACsWZ!4mY&pq1keqz1xbb-5>ky7)0s`AtGiO!rVSA z^YB{mK~cUvz~5iLrVabnKD9Am=#HMRuGcvpkWqrc1k+9B$!wG|uh0;lKK)BXwdH|= z7*t)!N!j)Fm#HpuL<j>YK{~a2u9(qv?RrE56n*waVwLKt`yQl^^#1wy^UdFGtg_#L zM9s_3U*E!&Yi)WG8dR==^Hg**G>c?sriO)=cc#BT9N`TirK^#NKIx}xJ&K<=F!z(~ z>S|6xLc)IxpWdQnpt~P#mE)dB9Yr@&eR32d4l`Ik&@5NPJ`c2^Tzt<7mrMYX?_(e^ zW!eE@)ULn~r^kflQ`v*R3jMYeK!K_%Da~+dlPZc)TA!_(pP8VQva+zKEUDW4T7FiX z05W1lz^IaE0^WGiiv8L$0rch*0w;@f*<8(1y?dXSKo$x3z>fqtZT(49*S>PvN?6c6 zxFhtQ;$N$!gaB@CZeCvAQvslEXf~BTIyz!8?Ymq+@y5Z#6wy<<vkY)J-7#@b-L6Os zOHS8Lu9bK6m2VLpP|6P=UiK#~$jLFezC3%N_j$-$9HF+$GyF$tpj=X4?Ik$H{;sa` zt&#yD57vLbin1V_(Ar)@stN)%ix;H~+>3Y%F;<7fE9mIx(5Mu>MnIR{G~W(RI&cx- zyux$XYc_eEM`gK$uR0RmL;AkGqqld9$rxzXfhUFMYNF|r5bC>UN|-eH<`rUOx=Fr@ zQO~^ouI9O9&Txg#dNIlD=ZDBqQBhBR_#R9O^u03H!~*e2_cK^P2-KPNvp<tkff6S# zstp~VB?uEos?m<EI~J-GFA}mRF}%RxyQ_Hd1BF2s!F#adG&xRnJo(0`SLuRJOowbo z@!VhKQBhI`fRUEg0!Ouyl|5RhT9-wJgM3#@i`)wH0R%C_STpPKdMp1vDBW*upneFm z8h6FiZP#v2nx>DgJ3eTOlDMT2+R}gx2e55Po#D+^I2#Z`0jL|8D_mS>Kt3T>%lhbU zID-M!0dqUDu#avr*aH?;cc3kKre-x~Q^#GMExM=M?*;k-8uHc0N^}KG`|xi|{qnoR z(+25N1tldruaOya>OTOHz-_JE@;5o=W0@3jWIgbu0KIeo$Jxon5s@#c-<O0Z0)6Ds zR32Rge;qKx)N-Pg!OeYrKHj+;wy?eq8Ud~e@8P)L?ZO<~_&SYVR~w^+a3R3{zI|}3 z{rV8uiK0PSLe4roa9!uS2Ui<llIQ$N=rV|;Og?lykrs{z-r*`|6#p=i-<|K{`uci+ ze+BHWFYZSQjHnP;tEJ_6{GJ9Pi8n6M6f#M?xPb>PBLJI3XuF#jOPv4>XfQoH>-lQZ zdkC-{@cy~~SKk2t`ClDA{OA7xq5sDW2li1<_31zL*Ski+vv2)_ua@IuolL*jx*{<4 z{2d-}i$-wCZbo~hUFHr8J{q|UUmj5Qn!1^NH^VF)IdHa7q}$e@H7o3|KJ0#BSb-;4 zay`ty*H{}L7BiB%mR|1t{<Y1@TxN9NwnGYA{b3^$gQtfSn;m=V2k++ES4?vC_%LR7 z&#}l%^zDwGPK=#!^|NWnrv1njpX*APHDL-F!qS5~Bmn_O_PEwUXxhgXw{rFJhnTc~ ztK-)8Y!4yUpiu%>lALN9hPn6oVWsMsH_iCX>j)1=Mw7uC8y-8ImyT5v9tQn>dLT0J zuP(rJZykc0xU`(hGk+HKpm%S}@{^O8LFREB>-=ettI$}#5B3%Z=kL7S8_kp7(icFI zik01Zbspmr@O#JK@|E$XE@h!pNza~fAF^$1#A8FO9#Vv*mcM`y0s?yMRrhBXob$mK zWi_?seN@jB%o^RRY)v;DzMnUQ(`^0`=B8M#L-BtU;Fdf;sbmVd4AB3h=;^cl{0j2f zx!=;3$>Y@gNJ9UU{5Uq{`;jY>H4c-UGY|POqWwc+?eTyXW!(9lp|J2CnV^S|+`e(I z-LeIAo<u&ei{H&Z6%1~cch1ojv(m1eOWhO0;-ao7{-F4MsZ6CTr*Rah*4!v?4X;U( zt=H8{%W~Hs;+?h{yXmC$bd;AKaLlk9c)Sx|!I$SA6LVQRwbvkGha$666KG)m`y^&+ za{Dn#JFPb-=Z5i>m&3k|-?qH@43F`s-25x*tr@%?_Z`(B!V!Uu;fpzP(`Sv;B;CNY zwYWRSd-uySt-JKku@p0x-^W|kgbY%BQ)So9a+c|7c*G4nt{21p30IrmGT6mO`PZYG z(>+1fBEg@(P^I6;x%Y3C$!G^-dZ>O89er$d@q;SXOS#m>&}?YlZ(BQz!9m3)r2Bk? zNmG)a3nNz@yXt1-+3aM>sJj;d8m#fFO+f<7j<kpRX41I)$#;5-rE`})$!u&I=rsrH z>YHrO(%3=MD}B$Qw2%{79(=*M3Wt@pym$d;@!aY83m=t(My_<u))t+Lm2fVKzPh#d z9{VRDbl#>dzEs!UeJ(iqd<}i0CVidUM(<&G2K<~A3oBQ+7jnpcXCBm>dOtgZzim1G zEP1{mz~#-NeVp@!|CKDLjDAJ;b-id`N8^9(?kI0Ie%Z0N!fsvE?s(EjC~%xCH}AP! zW4=FrID0;pS~BCEDUqRlV>V8%JuIZCTQGx-yO>1!9BKY+!w07B&SA8r+n6hIv9VG8 z8ucEIz+Zt302Tj9-guuvF|Uz$kR&LXLgWl}Zmb!*e%0>&xGM3!OB`wIu6id5!M82E zB`?oR=d+mzh>Qt7oxc#>&wPfB@i{v3$l8_Qpq}B@Wm>|?m>wPd2|Qd{@>;PqQn9Vh z52kMihf`SseZHq&9tNhM>$Y3FF+)s+((=FcZU^XCPC#PzB?hi%tkVa0q!bqad7^&( zI`wehzX+YlU1*VjJI3L+)WyjubKk}yy3E`8K=}YjsA~)UxgmptH3}k;H!FC6aO560 zW1SB^?xa{H{~|cm6T>Q=h1G-F|Gn-P)E1>0ir=dn_h-KGAbMRs*(_r)%HY#B5&r}; zhv$yIF8|c|x1wLm9E+7S-?Z0I=No>$tA2aBX*F1@i$yCwBEVN=+Dy`}p0kjtWXCb9 z8%&CN<ul4N?`}NVoqbQP;T^`7&zKbB_d5^}A)%v1_~m{B7erc^G?fn1VeQq7>ncgx z!p%I9&NlI|Pa}ueB~fz)muK(l#%gdAYYA5dvWY+c29$0yo@q5;ITzauo6=9Z-3D^` zt(Ay<&FV~2{7v<NOpChvBF)9{kj~haS^nj~XjFjok*7Fnld6A|KR<UaG<iCZVXLKm zxA1Bd1ND574ZUu*hZ9TqO0TbykD_2pl1@K}zv8jLtEM&PTXKCv?PYd#-c4TX-hhaY zI>vd{4(rv%(uPUy$R*WMwpz*W5~_%-468{#<8cDF{HExW@6`+Ps{Q<XGkLj$GjV5L z#s|$z(0<j%1G{Kq;+*j8NxAxT2ZgeQ(VB*bLOzzk%N9B!k@*JpsEzTDm!8*lz~ODM zzA!V0OxR%RG`DpQY8{p^#R95?^pCq4ZLL!RuV!|eG&glW&@8DEa&YB*$@#8vv8^~& zi9Q|0K6KKUQlF0ElHwc|P~t~9a`-Dda+5IjcxtB2Zhtt1?&2)F*n2%Z^;NHLR{;0s zfU~^W@QB3nMez=)z|~hJ+Wo6-7r|<|V16!Jj&#O~*;gQHQk~F2;%sIEPlhnT^6X;| z6NSawug=cFKW7v!=Hiz#z6iN>e~0W4-0b@3VmPNT{3&hN8ym6>$PTLhU02fk*4tJd z!+5N5e9?>@yWWNAaQ%xd4)(g$%JqFbopbAbWbb?%9?ck-ceB(5htX9-t!35QJtf_n zeCLCBY44gy=y7TlyNB=^2AQA7Vf(Y46v4I!OUQqh-asd8E<7|zd=ZFM=TprL3ZI1{ zA&)8sHtmZksnHTA8fwqq(|E_8?)M43|J)dl#QDh2Yl^zd9C=bQFTu9?TJd$~s$bd- z5I5)#9<@`{jsA2wcC`D^ys4`_#Zr#=B+&i$uRftw$%3})fgwu$!@c19eJg6aUw29b z=+1UCwKJY2ZY|ZlQQDo$B}><OdQCNJ`+Wpf%WqdgPZMyy<gY8BMtX?kn7H+F<3f(& zX^YiYf|;`hJpU7CEhAZ_`1+HT#+Slx3x;C})3C_hxL|VLxl$R48G}gc&_`E+9jWtm z{9V;oK38^)wKRr1(HRPhr{5f}u*e-ROlGr&Ve5oq4VQ7(8Q9T;O^nivHh;Tz!5Smg z#I0lK#ko=9bu&~L=fm&e6oJ$@FB;NnF*L)H$rmu9n$x1qPvX)tWQ+N}`-5KNU#R%i zmTe+Ow4e)t<Lvb=aWfU`Kg0QdUr4N1E@EZmhb@8oJl?Rp2jVQi0_(oNwexDm`S<3) z3d;F&8~U<FW+kh!9f7Tj1}=rNfNLyk?~;nVNs6v3s9l(%i2HMGLP#Bo2XQ5xldSQW zmuKq1`S<gmF2CiTL+1DME82JGQV&jBw!G{#4`(B9E}$D-*0GPq8(q9{MjO8EWYI7a z@TDL8vD`cvA7SV5`|jm^ZY@1D-<bt@q~h$*R_h}Z4w=K^Q^X0OI$yV*zoK696B_lw z=X(WMpdYtuY=rmikzTfweXya=34~HR*O5DX0t|@_(l=98p6*x*zr9#<?X@-D|Mnjk zaGL5luq%OtB=PaFI&D=x%#CIf*!SdQ<2>{ZZb51Huq3Z>^;nIH=5GIb5x8@;DM+A` z%d;1nAjYGKcSe6Io3}K)wEVOBA<JW)c*n3~A_bRy$iNq0dZ!_Tht{SElSCO%?a+^p zE@<pCBXZN{Ug-6GSh$)h6lo25So^TAc9qLc;CB|2(n0)aSj01d>bge#npOv=3wCT? zz6PUNnv;3-9VXAwtNz6Gku_v5k19r+^KSQ}u-(*Tf^84P7JjA@KFra}_~oLP!qSX7 z9~>PWU*MFZlbOombD<wZb2(rRgIef6bN8r^k$=ei2z@+L#(&XXll71;SoNIu1-H)| zTmH+}>y4iY(unqO|2#d0!k#%ZyUXtW@v1f8+5%}=!Y}a|q0@3blbRO2bkFxvBEFbB zUq9)?H?{25W#igL@-l<${K9s$bO=T4%c&ifSxmcdV5HZ!zb@t#px)5_r@@?6_rpo( zFfy?zT|knEUW@+I+r>M=N1j+mlTybYZDPdcKW2$Iq%oyD1+kuTcW!+7V6rR3;&aR1 zw>^lo9hm+8Kri#{{{-o0bgPqv7iFLMyjLZ~9)Ak_y}u&$K(FYp@)}U7Z#(LRzE_OA zuuq!7)sS<DXK?Cy{vw*kKfJ#R@khe{pJe;_!>>>25j+yt$B&NxWKZO76Cp58{f{Iv z{gqwczDfQgX$gMwKg){#mmR$CR+CB%G+*g3)5nbt)Q77ELYV(n^6v}P$h{q=#+Jqg zB@;A4Yu3#tSBl|xtmydP#XqBSnN#Z|&tba^g|%#CZn|Khlr~=BK74lEW%+@$!wEPi zXB(<v1YuMUpcT(j?zR#Y5=oU2E@azy4FszY7^Hr&8Rg~8Fz>;2N&OmiPNa&WUA}sb z_Dgb?cs*PpJ1%UjR}xaEjosa$A+h2Zk}_%oyT;74hO9BkYKX-O0kyJgv^haY=*{w8 zZ#Nj(bnj&}d0f#3J&A?3nU!-IkEj0%t{TIo2{Ea(+)LAVmbh8@a@|x>$J>wL=RQrx ze01g1>E)a%F%L(=u5#&~d$zy6h&@b{<PUY9f>+0n0&Sj8j{FLuVvhn(8>v$)o>?=+ zC7+!iw3;{V#_FXo$C68v?G)W_B#0htDMhQ1EOwT2YItKGD%!G}tqs-jmaqNu@h6?` zgnZLJl?LblpTQflpryCYZeC{3N>mgTek5_K+W(Gz<ZW?|HRcNBSwywV&6cY=SZMg) z*#);Dz)3h72L}}*p(l#9B*sn(7H(sxG&f%vJ&!hu?DZ3fD(M-Fou>GQMf%m3XLPG_ zipA+xoAC|!V^TG{9Gw*`+18;a$`$C5?>LFf`|VVS4QY-^i)E>b@Y1}jil}(VsIDTE ziS3g1PnP2}A&|+48g8nr3BfaoiKWC^GwsUA64r4W+Md}m*I^Z}W>TfuPlpe*a|*>Y z3z^(!6*ui=ghkaQS)35pTjTIH$2HQDt-q!B_el@zFN6paqaX$MPFw}~cg`A?I>y<R zuX^&>)2KzZs7E%qoz)6@sd6SNTzwy^?JG-60h!0&h1l4=YKw^(cxVi1bW;pM&ksi> z%-54oa8qN7J>(PwlNf0Tukj`1#KrqLP`k`Wa_Qv61q^D=|FNE>jfs0#IaFMjZZEfE z^1ge`Wn|+}kcBMk6q`feDd+73VM?!3LY=9loBy1d9g=2R;<s+*3CbX<*`%miHuv-k zvhUUmG~f;W;*JfN_`yXbvnZ<lqBTEt)mJN-n}Ow14rg{Td8b+Bd;O^W84uCebCq*# zWm8!Xr+Jc~kUxU=(NXa-BI93DC!BtL9aNDdB_r!6yJX7pZR@{DXA^L_mXGO+SI)9k zRt{{lk&wrz%cG-unvBL`(%rQ%>Ug3{8X2`?Vk$}oa7xBrpc3Abz{;^%t^)q1Z3zaf z)10tk*1A$B{nfcc#_yu2l9RPgZVmFu1Y^axik33@kwYfFoq?9Db|}wWrT)bN+C&Gb zH5QA*LL<J#zNCw^UqD<W4Kn4(LFyh1=*a=Z9y@zc`m^`kD`ow;77Y@<J?BbE@=^O2 z7}q}q<yP}wiTa0TQ^ip_C0fqPG_f*IZ_FU&C{L(eKP2vI38kV&Tu@4pS2e3BkF@WU zq1QF(?WZS`g0lFnR68qa*mzZNi@e?A=@KQL>l%9~!I|^E-;RDUHUz`#3+?E@I%0At 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 diff --git a/hatch/docs/assets/workflow.png b/hatch/docs/assets/workflow.png deleted file mode 100644 index b26b1a9f0b086d02bb3d13f0dacf8ea77ec46ba7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55148 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 diff --git a/hatch/internal/scaffold/scaffold.go b/hatch/internal/scaffold/scaffold.go index 1c70939..13ebb2d 100644 --- a/hatch/internal/scaffold/scaffold.go +++ b/hatch/internal/scaffold/scaffold.go @@ -17,7 +17,10 @@ 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"} +var WorkflowTemplates = []string{ + "scrum", "kanban", "spec-first", "lite", + "dual-track", "shape-up", "stage-gate", +} // Options configure an init run. type Options struct { 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/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/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/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/tui/board.go b/hatch/internal/tui/board.go index 0fb2205..4c14efe 100644 --- a/hatch/internal/tui/board.go +++ b/hatch/internal/tui/board.go @@ -14,8 +14,8 @@ import ( ) var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fcaf16")) - laneStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2a2b86")). + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2563eb")) + laneStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7c3aed")). BorderStyle(lipgloss.NormalBorder()).BorderBottom(true) overWIP = lipgloss.NewStyle().Foreground(lipgloss.Color("#d33")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) diff --git a/hatch/spec/workflow.schema.md b/hatch/spec/workflow.schema.md index c0d2c5c..316a156 100644 --- a/hatch/spec/workflow.schema.md +++ b/hatch/spec/workflow.schema.md @@ -81,6 +81,9 @@ spec: | `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 | User chọn một template rồi sửa, hoặc đặt `template: custom` và tự khai báo toàn bộ. From 0f53c887f6e1e73651a81e133ea66c4a1bd95811 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 14:53:12 +0000 Subject: [PATCH 09/60] Add agent communication layer: Slack-style channels, threads, @mentions Agents now talk to each other directly (more than task-only orchestrators like Paperclip), always on the record: - bus: append-only, file-based message store. A conversation file = a channel/DM; replies (--reply-to) form threads within it - @mention: @agent / @role in a message body auto-routes to that teammate's inbox (Slack-style tagging) - inbox = Slack-style notifications (DMs + @mentions + broadcast), with a per-agent read cursor; channels are browsed via `hatch channel show` - synchronous ask/reply: orchestrator relays a question to another agent and records the reply - convene: bounded multi-agent meeting; agents take turns by role, can emit DECISION: to converge - commands: msg, channel (ls/show), inbox, thread, ask, convene - docs/11-communication.md; reconcile coordination-protocol (dialogue via bus vs state via board/ledger) https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 7 + hatch/docs/03-coordination-protocol.md | 4 +- hatch/docs/11-communication.md | 80 ++++++ hatch/internal/bus/bus.go | 243 ++++++++++++++++ hatch/internal/bus/bus_test.go | 103 +++++++ hatch/internal/bus/cursors.go | 30 ++ hatch/internal/bus/mention.go | 30 ++ hatch/internal/bus/parse.go | 71 +++++ hatch/internal/cli/comms.go | 373 +++++++++++++++++++++++++ hatch/internal/cli/root.go | 6 + hatch/internal/orchestrator/prompt.go | 30 ++ 11 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 hatch/docs/11-communication.md create mode 100644 hatch/internal/bus/bus.go create mode 100644 hatch/internal/bus/bus_test.go create mode 100644 hatch/internal/bus/cursors.go create mode 100644 hatch/internal/bus/mention.go create mode 100644 hatch/internal/bus/parse.go create mode 100644 hatch/internal/cli/comms.go diff --git a/hatch/README.md b/hatch/README.md index 1b7cdda..3b09ec9 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -74,6 +74,7 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | Sơ đồ trực quan: [architecture-diagram](docs/architecture-diagram.md) (kiến trúc + workflow + sequence). @@ -103,6 +104,12 @@ bin/hatch run T-001 --claim --dry-run # build invocation; bỏ --dry-run để bin/hatch plan --dry-run # spawn Conductor bẻ việc bin/hatch watch --dry-run --max 3 # gán + chạy backlog (tôn trọng WIP) bin/hatch board # TUI dashboard +# Giao tiếp trực tiếp giữa agent (Slack-style — docs/11) +bin/hatch msg --from codex --to '#design,reviewer' --channel '#design' "Streaming hay buffer?" +bin/hatch inbox claude-code --mark # message gửi tới mình (id + vai + #channel + *) +bin/hatch ask --from codex --to claude-code "Chốt giúp: streaming chứ?" # hỏi-đáp đồng bộ +bin/hatch convene --topic "Thiết kế export API" --agents claude-code,codex,kiro --rounds 2 +bin/hatch channel ls # liệt kê channel/DM/thread ``` ## Trạng thái implement diff --git a/hatch/docs/03-coordination-protocol.md b/hatch/docs/03-coordination-protocol.md index 2d6d7e5..6420c0a 100644 --- a/hatch/docs/03-coordination-protocol.md +++ b/hatch/docs/03-coordination-protocol.md @@ -20,7 +20,9 @@ - **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. -Không có lời gọi trực tiếp agent→agent. Mọi "giao tiếp" là **ghi vào file**, "lắng nghe" là **đọc file**. Đúng như dev không ghi đè não đồng nghiệp mà comment lên Jira/PR. +**Đ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 diff --git a/hatch/docs/11-communication.md b/hatch/docs/11-communication.md new file mode 100644 index 0000000..1c42d68 --- /dev/null +++ b/hatch/docs/11-communication.md @@ -0,0 +1,80 @@ +# 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:<rootId>` (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/<channel>.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: +``` +## <ts> · <from> → <to,...> · <type>[ · re:<rootId>] · {#<id>} +<body, có thể chứa @mention> +``` +`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`. + +## 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 <root> # 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. + +## 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/internal/bus/bus.go b/hatch/internal/bus/bus.go new file mode 100644 index 0000000..0caa2cf --- /dev/null +++ b/hatch/internal/bus/bus.go @@ -0,0 +1,243 @@ +// 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/paths" +) + +// Message types. +const ( + TypeMsg = "msg" // a statement / DM / mention + TypeAsk = "ask" // a question expecting a reply + TypeReply = "reply" // a reply to an ask + TypeDecision = "decision" // a recorded decision / consensus +) + +// Message is one turn in a channel. A reply (InReplyTo set) forms a thread +// within that channel, Slack-style. +type Message struct { + ID string + Channel string // channel / DM / conversation id (file). "#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 +} + +// 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..8725170 --- /dev/null +++ b/hatch/internal/bus/bus_test.go @@ -0,0 +1,103 @@ +package bus + +import ( + "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) + } +} 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/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..96f21c1 --- /dev/null +++ b/hatch/internal/bus/parse.go @@ -0,0 +1,71 @@ +package bus + +import ( + "strings" +) + +// parseThread parses the append-only Markdown of a thread back into messages. +// Heading form: "## <ts> · <from> → <to> · <type>[ · re:<id>] · {#<id>}". +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 strings.HasPrefix(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/cli/comms.go b/hatch/internal/cli/comms.go new file mode 100644 index 0000000..3bdd431 --- /dev/null +++ b/hatch/internal/cli/comms.go @@ -0,0 +1,373 @@ +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" +) + +func newMsgCmd() *cobra.Command { + var from, to, channel, thread, replyTo, typ string + cmd := &cobra.Command{ + Use: "msg <body>", + 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:<name>)") + 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 <channel>", + Short: "Print a channel (or a single thread with --in <rootId>)", + 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") + + cmd.AddCommand(ls, show) + 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 <agent>", + 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 <id>", + 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 +} + +// 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 <question>", + 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 := orchestrator.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:<name>)") + 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 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) + for r := 1; r <= rounds; 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 := orchestrator.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 + } + if _, err := bs.Post(bus.Message{Channel: thread, From: a.ID, To: []string{"*"}, Type: turnType(turn), Body: turn}); err != nil { + return err + } + } + } + fmt.Fprintf(out, "\nmeeting recorded in thread %s\n", thread) + return nil + }, + } + 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 +} + +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/root.go b/hatch/internal/cli/root.go index 71c6243..4ff0e44 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -38,6 +38,12 @@ func NewRoot() *cobra.Command { newBoardCmd(), newSyncCmd(), newHookCmd(), + newMsgCmd(), + newChannelCmd(), + newInboxCmd(), + newThreadCmd(), + newAskCmd(), + newConveneCmd(), ) return root } diff --git a/hatch/internal/orchestrator/prompt.go b/hatch/internal/orchestrator/prompt.go index e8e30da..0231be1 100644 --- a/hatch/internal/orchestrator/prompt.go +++ b/hatch/internal/orchestrator/prompt.go @@ -39,6 +39,36 @@ func BuildPrompt(t model.Ticket, role string) string { 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() +} + // BuildPlanPrompt is the prompt for a Conductor planning pass. func BuildPlanPrompt() string { return strings.Join([]string{ From d4716218b25560093edb8c2acb47c3b0c6d5dbb1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 15:12:01 +0000 Subject: [PATCH 10/60] Add channel subscriptions + search-based recall (token-aware reads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the read model so agents don't process every message: - subscriptions: `hatch channel join/leave/members` — per-agent channel membership (scope, stored in .hatch/bus/.members.json) - search/recall: `hatch search <query> [--agent --channel --from --type --limit --all]` — load only relevant messages into context, newest first and capped; defaults to the agent's subscribed channels. This is L2 on-demand for conversation, not a firehose - inbox stays the must-act subset (DM + @mention + broadcast) - bus.Search + subscription store, with tests; docs/11 read-model section https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/11-communication.md | 18 +++++ hatch/internal/bus/members.go | 74 ++++++++++++++++++++ hatch/internal/bus/search.go | 74 ++++++++++++++++++++ hatch/internal/bus/search_test.go | 53 ++++++++++++++ hatch/internal/cli/comms.go | 112 +++++++++++++++++++++++++++++- hatch/internal/cli/root.go | 1 + 6 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 hatch/internal/bus/members.go create mode 100644 hatch/internal/bus/search.go create mode 100644 hatch/internal/bus/search_test.go diff --git a/hatch/docs/11-communication.md b/hatch/docs/11-communication.md index 1c42d68..39b67f8 100644 --- a/hatch/docs/11-communication.md +++ b/hatch/docs/11-communication.md @@ -40,6 +40,24 @@ Mỗi message là một block append-only; reply tạo **thread** trong channel: `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 <agent>` | 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 <query> --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 +``` + ## Ba kiểu giao tiếp ### 1. Channel · DM · @mention · thread (async) 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/search.go b/hatch/internal/bus/search.go new file mode 100644 index 0000000..16685a7 --- /dev/null +++ b/hatch/internal/bus/search.go @@ -0,0 +1,74 @@ +package bus + +import ( + "sort" + "strings" +) + +// SearchOpts filters a bus query. Empty fields are ignored. This is the +// token-efficient way an agent loads *relevant* conversation into context +// (L2 on-demand) instead of reading every message in every channel. +type SearchOpts struct { + Query string // case-insensitive substring 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 ⇒ 20) +} + +// 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 + } + q := strings.ToLower(o.Query) + + var hits []Message + 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 + } + if q != "" && !strings.Contains(strings.ToLower(m.Body), q) && !strings.Contains(strings.ToLower(m.From), q) { + continue + } + hits = append(hits, m) + } + } + sort.Slice(hits, func(i, j int) bool { return hits[i].TS > hits[j].TS }) + if len(hits) > limit { + hits = hits[:limit] + } + return hits, nil +} 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/cli/comms.go b/hatch/internal/cli/comms.go index 3bdd431..b2559bf 100644 --- a/hatch/internal/cli/comms.go +++ b/hatch/internal/cli/comms.go @@ -120,7 +120,117 @@ func newChannelCmd() *cobra.Command { } show.Flags().StringVar(&in, "in", "", "show only the thread rooted at this message id") - cmd.AddCommand(ls, show) + var joinAgent string + join := &cobra.Command{ + Use: "join <channel>", + 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 <channel>", + 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 <channel>", + 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 } diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 4ff0e44..6399767 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -44,6 +44,7 @@ func NewRoot() *cobra.Command { newThreadCmd(), newAskCmd(), newConveneCmd(), + newSearchCmd(), ) return root } From 892d13d0b6e512ec2fb5dd88e4d5b1f66765905b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 15:42:54 +0000 Subject: [PATCH 11/60] Wire conversation into work: agents "read the room" before a run `hatch run` now prepends an agent's unread inbox + a ticket-relevant conversation recall to its prompt (like a teammate checking Slack before starting), then marks the inbox read. Token-bounded: recall is scoped to the agent's subscribed channels, token-matched and capped; --no-catch-up opts out. - orchestrator.commContext + Run wiring; RunOptions.SkipComms - bus.Search upgraded to token (OR) matching ranked by tokens matched, so recall works on multi-word queries like a ticket title - tests for comm context + token search; docs/11 "read the room" section https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/11-communication.md | 6 +++ hatch/internal/bus/search.go | 59 +++++++++++++++++--- hatch/internal/cli/orchestrate.go | 5 +- hatch/internal/orchestrator/comm_test.go | 49 +++++++++++++++++ hatch/internal/orchestrator/run.go | 69 ++++++++++++++++++++++-- 5 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 hatch/internal/orchestrator/comm_test.go diff --git a/hatch/docs/11-communication.md b/hatch/docs/11-communication.md index 39b67f8..f750d12 100644 --- a/hatch/docs/11-communication.md +++ b/hatch/docs/11-communication.md @@ -58,6 +58,12 @@ hatch search "encoding csv" --agent codex # recall liên quan trong scope 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 <ticket>` 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) diff --git a/hatch/internal/bus/search.go b/hatch/internal/bus/search.go index 16685a7..36bcda4 100644 --- a/hatch/internal/bus/search.go +++ b/hatch/internal/bus/search.go @@ -38,9 +38,13 @@ func (b *Bus) Search(o SearchOpts) ([]Message, error) { for _, c := range scope { allow[safeThread(c)] = true } - q := strings.ToLower(o.Query) + tokens := queryTokens(o.Query) - var hits []Message + type scored struct { + m Message + score int + } + var hits []scored channels, err := b.Channels() if err != nil { return nil, err @@ -60,15 +64,54 @@ func (b *Bus) Search(o SearchOpts) ([]Message, error) { if o.Type != "" && m.Type != o.Type { continue } - if q != "" && !strings.Contains(strings.ToLower(m.Body), q) && !strings.Contains(strings.ToLower(m.From), q) { + score := matchScore(tokens, m) + if len(tokens) > 0 && score == 0 { continue } - hits = append(hits, m) + 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) } } - sort.Slice(hits, func(i, j int) bool { return hits[i].TS > hits[j].TS }) - if len(hits) > limit { - hits = hits[:limit] + 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 hits, nil + return n } diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index 9186277..e02464b 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -38,7 +38,7 @@ func pickAgent(ws *config.Workspace, explicit, role string) (model.Agent, error) func newRunCmd() *cobra.Command { var agentID string - var dryRun, claim, worktree bool + var dryRun, claim, worktree, noCatchUp bool var timeout time.Duration cmd := &cobra.Command{ Use: "run <ticket>", @@ -82,7 +82,7 @@ func newRunCmd() *cobra.Command { } fmt.Fprintf(cmd.OutOrStdout(), "→ %s (%s) on %s as %s\n", agent.ID, agent.Kind, t.ID, t.Role) out, err := orchestrator.Run(ws, agent, t, t.Role, orchestrator.RunOptions{ - DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), + DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), SkipComms: noCatchUp, }) if err != nil { return err @@ -97,6 +97,7 @@ func newRunCmd() *cobra.Command { 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 } diff --git a/hatch/internal/orchestrator/comm_test.go b/hatch/internal/orchestrator/comm_test.go new file mode 100644 index 0000000..c4e9416 --- /dev/null +++ b/hatch/internal/orchestrator/comm_test.go @@ -0,0 +1,49 @@ +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/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 := commContext(ws, 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 := commContext(ws, codex, "anything"); got != "" { + t.Fatalf("expected empty comm context, got: %s", got) + } +} diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go index 5baa758..ce66f0d 100644 --- a/hatch/internal/orchestrator/run.go +++ b/hatch/internal/orchestrator/run.go @@ -9,6 +9,7 @@ import ( "strings" "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/store" @@ -16,9 +17,10 @@ import ( // 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) + 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. @@ -30,9 +32,66 @@ type RunOutcome struct { Err error } -// Run builds the ticket prompt and executes the agent for that ticket. +// 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 Run(ws *config.Workspace, agent model.Agent, t model.Ticket, role string, opt RunOptions) (*RunOutcome, error) { - return Execute(ws, agent, t.ID, BuildPrompt(t, role), opt) + prompt := BuildPrompt(t, role) + if !opt.SkipComms { + if comm := commContext(ws, agent, t.Title); comm != "" { + prompt = comm + "\n\n" + prompt + } + } + out, err := Execute(ws, agent, t.ID, prompt, opt) + if err == nil && out != nil && out.Executed && !opt.SkipComms { + _ = bus.New(ws.Layout).MarkRead(agent.ID) + } + return out, err +} + +// commContext gathers the agent's unread inbox and a query-scoped recall of +// recent conversation, formatted as a compact, token-bounded block. +func commContext(ws *config.Workspace, agent model.Agent, query string) string { + b := bus.New(ws.Layout) + inbox, _ := b.Inbox(agent.ID, agent.Roles) + subs := b.Subscriptions(agent.ID) + recall, _ := b.Search(bus.SearchOpts{Query: query, Channels: subs, Limit: 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 _, m := range capMsgs(inbox, 10) { + fmt.Fprintf(&s, "- [%s] %s · %s: %s\n", m.Type, m.Channel, m.From, snippet(m.Body)) + } + } + 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 _, m := range recall { + fmt.Fprintf(&s, "- %s · %s: %s\n", m.Channel, m.From, snippet(m.Body)) + } + } + 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() +} + +func capMsgs(ms []bus.Message, n int) []bus.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(s) > 120 { + return s[:120] + "…" + } + return s } // Execute builds and (unless DryRun) runs an agent against an arbitrary prompt, From 1d223ecf9d1de13340b1fc4d6d8333a3df4b10a7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 15:50:57 +0000 Subject: [PATCH 12/60] =?UTF-8?q?Add=20live=20ceremonies,=20escalation/on-?= =?UTF-8?q?call,=20and=20decision=E2=86=92ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Hatch behave like a human squad's rhythm, not just a task queue: - ceremonies: `hatch ceremony standup` (per-agent ledger digest + blockers, posts #standup), `retro` (cycle summary + KB→SSOT promotion candidates, --write), `planning` (spawn Conductor). New ledger parser + ceremony pkg - escalation/on-call: `hatch escalate <ticket>` (ledger + #escalations, tags the target) and automatic escalation when a ticket fails a gate >=2 times; target from registry policy.escalate_to → conductor → human:lead - decision→ADR: convene turns starting with DECISION: are recorded as accepted ADRs in kb/decisions/ (new decide pkg) - store.KB.NextID extracted; tests for ceremony/decide/escalate; docs/12 https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 5 + hatch/docs/12-ceremonies-escalation.md | 34 +++++ hatch/internal/ceremony/ceremony.go | 162 +++++++++++++++++++++++ hatch/internal/ceremony/ceremony_test.go | 59 +++++++++ hatch/internal/cli/ceremony.go | 126 ++++++++++++++++++ hatch/internal/cli/comms.go | 11 +- hatch/internal/cli/escalate.go | 36 +++++ hatch/internal/cli/kb.go | 19 +-- hatch/internal/cli/root.go | 2 + hatch/internal/decide/decide.go | 41 ++++++ hatch/internal/decide/decide_test.go | 35 +++++ hatch/internal/model/registry.go | 3 +- hatch/internal/store/kb.go | 18 +++ hatch/internal/store/ledger_read.go | 90 +++++++++++++ hatch/internal/wf/engine.go | 1 + hatch/internal/wf/escalate.go | 72 ++++++++++ hatch/internal/wf/escalate_test.go | 29 ++++ 17 files changed, 723 insertions(+), 20 deletions(-) create mode 100644 hatch/docs/12-ceremonies-escalation.md create mode 100644 hatch/internal/ceremony/ceremony.go create mode 100644 hatch/internal/ceremony/ceremony_test.go create mode 100644 hatch/internal/cli/ceremony.go create mode 100644 hatch/internal/cli/escalate.go create mode 100644 hatch/internal/decide/decide.go create mode 100644 hatch/internal/decide/decide_test.go create mode 100644 hatch/internal/store/ledger_read.go create mode 100644 hatch/internal/wf/escalate.go create mode 100644 hatch/internal/wf/escalate_test.go diff --git a/hatch/README.md b/hatch/README.md index 3b09ec9..90d0c32 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -75,6 +75,7 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | Sơ đồ trực quan: [architecture-diagram](docs/architecture-diagram.md) (kiến trúc + workflow + sequence). @@ -110,6 +111,10 @@ bin/hatch inbox claude-code --mark # message gửi tới mình (id + vai + bin/hatch ask --from codex --to claude-code "Chốt giúp: streaming chứ?" # hỏi-đáp đồng bộ bin/hatch convene --topic "Thiết kế export API" --agents claude-code,codex,kiro --rounds 2 bin/hatch channel ls # liệt kê channel/DM/thread +# Nghi thức squad (docs/12) +bin/hatch ceremony standup # digest theo agent + blockers → #standup +bin/hatch ceremony retro --write # tổng kết chu kỳ + ứng viên đề bạt KB→SSOT +bin/hatch escalate T-001 --why "kẹt gate 2 lần" # gọi senior/on-call (auto khi gate fail ≥2) ``` ## Trạng thái implement diff --git a/hatch/docs/12-ceremonies-escalation.md b/hatch/docs/12-ceremonies-escalation.md new file mode 100644 index 0000000..f2fa307 --- /dev/null +++ b/hatch/docs/12-ceremonies-escalation.md @@ -0,0 +1,34 @@ +# 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-<date>.md`. | +| `hatch ceremony planning [--dry-run]` | sprint planning | Spawn Conductor bẻ việc (như `hatch plan`). | + +Chủ trì (`chair`) lấy từ `workflow.yaml > ceremonies.<name>.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 <ticket> --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`. + +## 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 +``` + +## 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/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go new file mode 100644 index 0000000..218888e --- /dev/null +++ b/hatch/internal/ceremony/ceremony.go @@ -0,0 +1,162 @@ +// 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/bus" + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +// 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 Standup(ws *config.Workspace, days int) (*StandupReport, error) { + if days < 1 { + days = 1 + } + entries, err := store.NewLedger(ws.Layout).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 := 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 Retro(ws *config.Workspace) (*RetroReport, error) { + entries, err := store.NewLedger(ws.Layout).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 := bus.New(ws.Layout) + if decs, err := b.Search(bus.SearchOpts{Type: bus.TypeDecision, Limit: 100}); err == nil { + r.Decisions = len(decs) + } + // KB learnings are candidates to promote into SSOT. + if entriesKB, err := store.NewKB(ws.Layout).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 +} + +func blockedTickets(ws *config.Workspace) ([]model.Ticket, error) { + board := store.NewBoard(ws.Layout) + var out []model.Ticket + for _, l := range ws.Workflow.Lanes { + if !l.Side && l.ID != "blocked" { + continue + } + ts, err := 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..a821b3f --- /dev/null +++ b/hatch/internal/ceremony/ceremony_test.go @@ -0,0 +1,59 @@ +package ceremony + +import ( + "strings" + "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 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 := 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 := 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) + } +} diff --git a/hatch/internal/cli/ceremony.go b/hatch/internal/cli/ceremony.go new file mode 100644 index 0000000..3790cda --- /dev/null +++ b/hatch/internal/cli/ceremony.go @@ -0,0 +1,126 @@ +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/ceremony" + "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()) + 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 := ceremony.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 := ceremony.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 = orchestrator.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/comms.go b/hatch/internal/cli/comms.go index b2559bf..7722bcf 100644 --- a/hatch/internal/cli/comms.go +++ b/hatch/internal/cli/comms.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -448,9 +449,17 @@ func newConveneCmd() *cobra.Command { if turn == "" { continue } - if _, err := bs.Post(bus.Message{Channel: thread, From: a.ID, To: []string{"*"}, Type: turnType(turn), Body: turn}); err != nil { + 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 { + body := strings.TrimSpace(strings.TrimPrefix(turn, "DECISION:")) + if e, err := decide.Record(ws, thread, topic, a.ID, body); err == nil { + fmt.Fprintf(out, " ↳ recorded %s in kb/decisions/\n", e.ID) + } + } } } fmt.Fprintf(out, "\nmeeting recorded in thread %s\n", thread) diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go new file mode 100644 index 0000000..4d2d2d1 --- /dev/null +++ b/hatch/internal/cli/escalate.go @@ -0,0 +1,36 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/store" + "github.com/fioenix/overclaud/hatch/internal/wf" +) + +func newEscalateCmd() *cobra.Command { + var from, why string + cmd := &cobra.Command{ + Use: "escalate <ticket>", + 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") + } + if err := wf.Escalate(ws, store.NewLedger(ws.Layout), args[0], from, why); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "escalated %s → %s\n", args[0], wf.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/kb.go b/hatch/internal/cli/kb.go index 0037028..0fc00ba 100644 --- a/hatch/internal/cli/kb.go +++ b/hatch/internal/cli/kb.go @@ -45,7 +45,7 @@ func newKBAddCmd() *cobra.Command { } } kb := store.NewKB(ws.Layout) - id := nextKBID(kb, typ) + id := kb.NextID(typ) entry := model.KBEntry{ ID: id, Type: typ, @@ -132,23 +132,6 @@ func newKBIndexCmd() *cobra.Command { return cmd } -// nextKBID generates a per-type id: ADR-NNN for decisions, KB-NNN otherwise. -func nextKBID(kb *store.KB, typ string) string { - prefix := "KB" - if typ == model.KBDecision { - prefix = "ADR" - } - entries, _ := kb.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) -} - func splitCSV(s string) []string { if strings.TrimSpace(s) == "" { return nil diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 6399767..e3cf07f 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -45,6 +45,8 @@ func NewRoot() *cobra.Command { newAskCmd(), newConveneCmd(), newSearchCmd(), + newCeremonyCmd(), + newEscalateCmd(), ) return root } diff --git a/hatch/internal/decide/decide.go b/hatch/internal/decide/decide.go new file mode 100644 index 0000000..068b6cb --- /dev/null +++ b/hatch/internal/decide/decide.go @@ -0,0 +1,41 @@ +// 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..51037a0 --- /dev/null +++ b/hatch/internal/decide/decide_test.go @@ -0,0 +1,35 @@ +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/model/registry.go b/hatch/internal/model/registry.go index bc68dcf..c9309f1 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -27,7 +27,8 @@ type Role struct { type Policy struct { NoSelfReview bool `yaml:"no_self_review"` HumanMerge bool `yaml:"human_merge"` - ProtectGlobs []string `yaml:"protect,omitempty"` // paths agents may not touch + ProtectGlobs []string `yaml:"protect,omitempty"` // paths agents may not touch + EscalateTo string `yaml:"escalate_to,omitempty"` // role/agent to escalate to (default conductor) } // WorkflowRef points the registry at the per-project workflow definition. diff --git a/hatch/internal/store/kb.go b/hatch/internal/store/kb.go index ab3785a..f5aeeac 100644 --- a/hatch/internal/store/kb.go +++ b/hatch/internal/store/kb.go @@ -79,6 +79,24 @@ func (k *KB) Query(tags []string) ([]model.KBEntry, error) { 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) 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/wf/engine.go b/hatch/internal/wf/engine.go index a651399..3afbf91 100644 --- a/hatch/internal/wf/engine.go +++ b/hatch/internal/wf/engine.go @@ -86,6 +86,7 @@ func Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID strin } if !o.Human && !o.Passed { _ = lg.Append(gateEntry(opt, t, from, "failed", "gate "+o.Name+" failed: "+o.Detail)) + maybeEscalate(ws, lg, t.ID) return nil, fmt.Errorf("gate %q failed: %s", o.Name, o.Detail) } } diff --git a/hatch/internal/wf/escalate.go b/hatch/internal/wf/escalate.go new file mode 100644 index 0000000..b76eb41 --- /dev/null +++ b/hatch/internal/wf/escalate.go @@ -0,0 +1,72 @@ +package wf + +import ( + "fmt" + "strings" + + "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" +) + +// 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 who escalations go to: registry policy, else the +// first conductor, else a human lead. +func EscalateTarget(ws *config.Workspace) string { + 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 Escalate(ws *config.Workspace, lg *store.Ledger, ticket, from, why string) error { + target := EscalateTarget(ws) + if from == "" { + from = "orchestrator" + } + if err := lg.Append(model.Entry{ + Agent: from, Ticket: ticket, Action: model.ActEscalate, + ToRole: target, Why: why, + }); err != nil { + return err + } + _, err := bus.New(ws.Layout).Post(bus.Message{ + Channel: "#escalations", From: from, To: []string{target}, + Type: bus.TypeMsg, Body: fmt.Sprintf("@%s ESCALATION %s: %s", target, ticket, why), + }) + return err +} + +// maybeEscalate auto-escalates when a ticket has hit the gate-failure threshold +// and hasn't already been escalated. +func maybeEscalate(ws *config.Workspace, lg *store.Ledger, ticket string) { + entries, err := lg.Recent(1) + if err != nil { + return + } + fails, escalated := 0, false + for _, e := range entries { + if e.Ticket != ticket { + continue + } + if e.Action == model.ActGate && strings.HasPrefix(e.Result, "failed") { + fails++ + } + if e.Action == model.ActEscalate { + escalated = true + } + } + if fails >= escalationThreshold && !escalated { + _ = Escalate(ws, lg, 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..5ba233d --- /dev/null +++ b/hatch/internal/wf/escalate_test.go @@ -0,0 +1,29 @@ +package wf + +import ( + "testing" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/scaffold" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +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) + // default target is the first conductor (claude-code in the template). + if got := EscalateTarget(ws); got != "claude-code" { + t.Fatalf("escalate target = %q, want claude-code", got) + } + if err := Escalate(ws, store.NewLedger(l), "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)) + } +} From 3d73c1cb1f10e4d4c2a5de02f72694d3886dd9d9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 15:55:19 +0000 Subject: [PATCH 13/60] Add pairing (driver/navigator) collaboration mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hatch pair <ticket> --driver A --navigator B [--rounds N] [--claim]`: two agents work one ticket like pair programming — the driver implements a small step each round (seeing the navigator's latest feedback), the navigator reviews that turn and suggests the next; both turns recorded in a pair-<ticket> bus thread. Navigator can emit READY to end early. driver != navigator enforced. --dry-run shows the turn structure. orchestrator pair prompt builders + tests; docs/12 pairing section. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 1 + hatch/docs/12-ceremonies-escalation.md | 10 ++ hatch/internal/cli/pair.go | 122 +++++++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/orchestrator/comm_test.go | 17 ++++ hatch/internal/orchestrator/prompt.go | 25 +++++ 6 files changed, 176 insertions(+) create mode 100644 hatch/internal/cli/pair.go diff --git a/hatch/README.md b/hatch/README.md index 90d0c32..b0c1235 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -115,6 +115,7 @@ bin/hatch channel ls # liệt kê channel/DM/thread bin/hatch ceremony standup # digest theo agent + blockers → #standup bin/hatch ceremony retro --write # tổng kết chu kỳ + ứng viên đề bạt KB→SSOT bin/hatch escalate T-001 --why "kẹt gate 2 lần" # gọi senior/on-call (auto khi gate fail ≥2) +bin/hatch pair T-001 --driver codex --navigator claude-code --rounds 3 # pair programming ``` ## Trạng thái implement diff --git a/hatch/docs/12-ceremonies-escalation.md b/hatch/docs/12-ceremonies-escalation.md index f2fa307..649e481 100644 --- a/hatch/docs/12-ceremonies-escalation.md +++ b/hatch/docs/12-ceremonies-escalation.md @@ -29,6 +29,16 @@ Khép vòng từ họp tới tri thức của record: trong `hatch convene`, khi 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-<ticket>`. + +```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/internal/cli/pair.go b/hatch/internal/cli/pair.go new file mode 100644 index 0000000..20d4eb1 --- /dev/null +++ b/hatch/internal/cli/pair.go @@ -0,0 +1,122 @@ +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 <ticket>", + 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 := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 := orchestrator.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 := orchestrator.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/root.go b/hatch/internal/cli/root.go index e3cf07f..25846ac 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -47,6 +47,7 @@ func NewRoot() *cobra.Command { newSearchCmd(), newCeremonyCmd(), newEscalateCmd(), + newPairCmd(), ) return root } diff --git a/hatch/internal/orchestrator/comm_test.go b/hatch/internal/orchestrator/comm_test.go index c4e9416..695cf0e 100644 --- a/hatch/internal/orchestrator/comm_test.go +++ b/hatch/internal/orchestrator/comm_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -47,3 +48,19 @@ func TestCommContextEmptyWhenNothing(t *testing.T) { 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/prompt.go b/hatch/internal/orchestrator/prompt.go index 0231be1..ababfed 100644 --- a/hatch/internal/orchestrator/prompt.go +++ b/hatch/internal/orchestrator/prompt.go @@ -69,6 +69,31 @@ func BuildMeetingPrompt(role, thread, topic, threadRaw string, round, rounds int 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() +} + // BuildPlanPrompt is the prompt for a Conductor planning pass. func BuildPlanPrompt() string { return strings.Join([]string{ From 6509c317c8c4ea9e49f5eac0fbf94cf9a0863494 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:01:17 +0000 Subject: [PATCH 14/60] Add presence/availability + capacity-aware assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - presence pkg + presence.json: per-agent status (available/busy/paused/ offline) + note, Slack-style. `hatch presence` shows status+WIP load; `hatch presence set <agent> --status --note` - pickAgent is now capacity-aware: skips paused/offline agents and prefers the least-loaded under-WIP candidate, so run/watch route to whoever's free — like a lead assigning work - presence test https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/orchestrate.go | 58 +++++++++++++-- hatch/internal/cli/presence.go | 73 +++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/paths/paths.go | 4 ++ hatch/internal/presence/presence.go | 92 ++++++++++++++++++++++++ hatch/internal/presence/presence_test.go | 29 ++++++++ 6 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 hatch/internal/cli/presence.go create mode 100644 hatch/internal/presence/presence.go create mode 100644 hatch/internal/presence/presence_test.go diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index e02464b..46066a2 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "sort" "time" "github.com/spf13/cobra" @@ -9,12 +10,14 @@ import ( "github.com/fioenix/overclaud/hatch/internal/config" "github.com/fioenix/overclaud/hatch/internal/model" "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 -// the first registry agent eligible for the role and available on PATH. +// 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) @@ -27,13 +30,58 @@ func pickAgent(ws *config.Workspace, explicit, role string) (model.Agent, error) 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 orchestrator.Available(a) { - return a, nil + 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 } } - // none installed; return the first so --dry-run still works. - return candidates[0], nil + 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 { diff --git a/hatch/internal/cli/presence.go b/hatch/internal/cli/presence.go new file mode 100644 index 0000000..7f81318 --- /dev/null +++ b/hatch/internal/cli/presence.go @@ -0,0 +1,73 @@ +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 <agent>", + 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/root.go b/hatch/internal/cli/root.go index 25846ac..1f2baba 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -48,6 +48,7 @@ func NewRoot() *cobra.Command { newCeremonyCmd(), newEscalateCmd(), newPairCmd(), + newPresenceCmd(), ) return root } diff --git a/hatch/internal/paths/paths.go b/hatch/internal/paths/paths.go index 81fc630..e493c60 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -16,6 +16,8 @@ const ( CharterFile = "charter.md" RegistryFile = "registry.yaml" WorkflowFile = "workflow.yaml" + PresenceFile = "presence.json" + OncallFile = "oncall.json" RolesDir = "roles" ContextDir = "context" @@ -51,6 +53,8 @@ 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) } // RepoRoot is the directory that contains the .hatch directory. func (l Layout) RepoRoot() string { return filepath.Dir(l.Root) } diff --git a/hatch/internal/presence/presence.go b/hatch/internal/presence/presence.go new file mode 100644 index 0000000..f2f344c --- /dev/null +++ b/hatch/internal/presence/presence.go @@ -0,0 +1,92 @@ +// 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..e909f52 --- /dev/null +++ b/hatch/internal/presence/presence_test.go @@ -0,0 +1,29 @@ +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) + } +} From 8a80142de4fdaa5d00af4eab5704f9e0affcf90a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:02:13 +0000 Subject: [PATCH 15/60] Add demo (sprint review) and backlog grooming ceremonies - `hatch ceremony demo`: showcase work in terminal lanes, posts to #demo - `hatch ceremony grooming`: flag under-specified backlog tickets (missing role/priority/acceptance, leftover TODO); non-zero exit if any need work - ceremony.Demo/Grooming + tests https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/ceremony/ceremony.go | 101 +++++++++++++++++++++++ hatch/internal/ceremony/ceremony_test.go | 23 ++++++ hatch/internal/cli/ceremony.go | 55 +++++++++++- 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/hatch/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go index 218888e..25692d3 100644 --- a/hatch/internal/ceremony/ceremony.go +++ b/hatch/internal/ceremony/ceremony.go @@ -127,6 +127,107 @@ func Retro(ws *config.Workspace) (*RetroReport, error) { 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 Demo(ws *config.Workspace) (string, []model.Ticket, error) { + board := store.NewBoard(ws.Layout) + var done []model.Ticket + for _, id := range terminalLaneIDs(ws) { + ts, err := 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 Grooming(ws *config.Workspace) (string, []GroomItem, error) { + board := store.NewBoard(ws.Layout) + lane := entryLane(ws) + tickets, err := 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 blockedTickets(ws *config.Workspace) ([]model.Ticket, error) { board := store.NewBoard(ws.Layout) var out []model.Ticket diff --git a/hatch/internal/ceremony/ceremony_test.go b/hatch/internal/ceremony/ceremony_test.go index a821b3f..8921dc5 100644 --- a/hatch/internal/ceremony/ceremony_test.go +++ b/hatch/internal/ceremony/ceremony_test.go @@ -57,3 +57,26 @@ func TestRetroCounts(t *testing.T) { 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 := 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 := 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/ceremony.go b/hatch/internal/cli/ceremony.go index 3790cda..708f9b7 100644 --- a/hatch/internal/cli/ceremony.go +++ b/hatch/internal/cli/ceremony.go @@ -20,7 +20,60 @@ func newCeremonyCmd() *cobra.Command { Aliases: []string{"cer"}, Short: "Run squad rituals: standup, retro, planning", } - cmd.AddCommand(newStandupCeremonyCmd(), newRetroCmd(), newPlanningCmd()) + 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 := ceremony.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 := ceremony.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 } From a90d1574e889f66f710f27126d84fa0216435ab6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:04:03 +0000 Subject: [PATCH 16/60] Add mob programming + convene tie-breaker - `hatch mob <ticket> --agents a,b,c --rounds N`: 3+ agents on one ticket with a rotating driver each round, the rest navigate; ends early when a majority of navigators signal READY - convene `--decider <agent>`: if no DECISION is reached after the rounds, the decider makes the final call (recorded as an ADR). convene also stops as soon as a decision is reached https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/comms.go | 42 +++++++-- hatch/internal/cli/mob.go | 130 ++++++++++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/orchestrator/prompt.go | 12 +++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 hatch/internal/cli/mob.go diff --git a/hatch/internal/cli/comms.go b/hatch/internal/cli/comms.go index 7722bcf..b02e3ab 100644 --- a/hatch/internal/cli/comms.go +++ b/hatch/internal/cli/comms.go @@ -391,7 +391,7 @@ func newAskCmd() *cobra.Command { } func newConveneCmd() *cobra.Command { - var thread, topic, agentsCSV, chair string + var thread, topic, agentsCSV, chair, decider string var rounds int var dryRun bool var timeout time.Duration @@ -431,7 +431,15 @@ func newConveneCmd() *cobra.Command { return err } fmt.Fprintf(out, "convene thread=%s rounds=%d agents=%s\n", thread, rounds, agentsCSV) - for r := 1; r <= rounds; r++ { + 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) @@ -455,17 +463,39 @@ func newConveneCmd() *cobra.Command { } // A meeting decision becomes a durable ADR in the KB. if tt == bus.TypeDecision { - body := strings.TrimSpace(strings.TrimPrefix(turn, "DECISION:")) - if e, err := decide.Record(ws, thread, topic, a.ID, body); err == nil { - fmt.Fprintf(out, " ↳ recorded %s in kb/decisions/\n", e.ID) - } + 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 := orchestrator.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 <agent> để 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)") diff --git a/hatch/internal/cli/mob.go b/hatch/internal/cli/mob.go new file mode 100644 index 0000000..a3bf0a2 --- /dev/null +++ b/hatch/internal/cli/mob.go @@ -0,0 +1,130 @@ +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 <ticket>", + 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 := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 := orchestrator.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 := orchestrator.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/root.go b/hatch/internal/cli/root.go index 1f2baba..d0d9b3d 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -48,6 +48,7 @@ func NewRoot() *cobra.Command { newCeremonyCmd(), newEscalateCmd(), newPairCmd(), + newMobCmd(), newPresenceCmd(), ) return root diff --git a/hatch/internal/orchestrator/prompt.go b/hatch/internal/orchestrator/prompt.go index ababfed..eab2c83 100644 --- a/hatch/internal/orchestrator/prompt.go +++ b/hatch/internal/orchestrator/prompt.go @@ -94,6 +94,18 @@ func BuildPairNavigatorPrompt(t model.Ticket, thread, threadRaw, driver string) 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{ From 9930a324d52e3915af2c9d4a3de109dbca192986 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:06:52 +0000 Subject: [PATCH 17/60] Add on-call rotation + incident workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oncall pkg + oncall.json: `hatch oncall` (show), `oncall set --rotation`, `oncall rotate` (announces in #oncall). Escalation now targets the current on-call first, then policy.escalate_to → conductor → human:lead - `incident` workflow template (detected→triage→mitigating→resolved→ postmortem) with fix-verified + postmortem-written gates; now 8 templates - docs/12 sections for on-call/incident/presence/mob; tests for oncall + on-call-aware escalation target https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 6 +- hatch/docs/05-workflow.md | 1 + hatch/docs/12-ceremonies-escalation.md | 16 ++++ hatch/internal/cli/oncall.go | 93 +++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/oncall/oncall.go | 60 ++++++++++++ hatch/internal/oncall/oncall_test.go | 27 ++++++ hatch/internal/scaffold/scaffold.go | 2 +- .../templates/workflows/incident.yaml | 55 +++++++++++ hatch/internal/wf/escalate.go | 8 +- hatch/internal/wf/escalate_test.go | 13 +++ hatch/spec/workflow.schema.md | 1 + 12 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 hatch/internal/cli/oncall.go create mode 100644 hatch/internal/oncall/oncall.go create mode 100644 hatch/internal/oncall/oncall_test.go create mode 100644 hatch/internal/scaffold/templates/workflows/incident.yaml diff --git a/hatch/README.md b/hatch/README.md index b0c1235..4bd756e 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -87,7 +87,7 @@ Hatch là CLI viết bằng Go (single binary). Phase 1+2 đã chạy được: ```bash make build # → bin/hatch -bin/hatch init -w scrum # 7 template: scrum kanban spec-first lite dual-track shape-up stage-gate +bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering bin/hatch compile --check # CI: fail nếu output stale so với SSOT bin/hatch validate # kiểm tra registry + workflow + board @@ -116,6 +116,10 @@ bin/hatch ceremony standup # digest theo agent + blockers → #sta bin/hatch ceremony retro --write # tổng kết chu kỳ + ứng viên đề bạt KB→SSOT bin/hatch escalate T-001 --why "kẹt gate 2 lần" # gọi senior/on-call (auto khi gate fail ≥2) bin/hatch pair T-001 --driver codex --navigator claude-code --rounds 3 # pair programming +bin/hatch mob T-001 --agents codex,claude-code,gemini # mob (driver xoay vòng) +bin/hatch ceremony demo · grooming # sprint review · backlog refinement +bin/hatch presence set kiro --status offline --note PTO # availability → phân việc theo capacity +bin/hatch oncall set --rotation claude-code,codex # lịch trực; escalation nhắm người trực ``` ## Trạng thái implement diff --git a/hatch/docs/05-workflow.md b/hatch/docs/05-workflow.md index 891b6e2..33423fc 100644 --- a/hatch/docs/05-workflow.md +++ b/hatch/docs/05-workflow.md @@ -72,6 +72,7 @@ Lifecycle trên là khung **mặc định**. Người dùng chọn một **templ | **`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/`). diff --git a/hatch/docs/12-ceremonies-escalation.md b/hatch/docs/12-ceremonies-escalation.md index 649e481..644ff38 100644 --- a/hatch/docs/12-ceremonies-escalation.md +++ b/hatch/docs/12-ceremonies-escalation.md @@ -11,6 +11,8 @@ Những nghi thức làm cho Hatch hành xử như một squad người thật, | `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-<date>.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.<name>.by`, mặc định `human:facilitator`. @@ -21,6 +23,20 @@ Như dev kẹt thì gọi senior. Đích escalate = `registry.policy.escalate_to - **Thủ công:** `hatch escalate <ticket> --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 <agent> --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 <ticket> --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)). diff --git a/hatch/internal/cli/oncall.go b/hatch/internal/cli/oncall.go new file mode 100644 index 0000000..9f310d6 --- /dev/null +++ b/hatch/internal/cli/oncall.go @@ -0,0 +1,93 @@ +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/root.go b/hatch/internal/cli/root.go index d0d9b3d..28a283f 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -50,6 +50,7 @@ func NewRoot() *cobra.Command { newPairCmd(), newMobCmd(), newPresenceCmd(), + newOncallCmd(), ) return root } diff --git a/hatch/internal/oncall/oncall.go b/hatch/internal/oncall/oncall.go new file mode 100644 index 0000000..3de837f --- /dev/null +++ b/hatch/internal/oncall/oncall.go @@ -0,0 +1,60 @@ +// 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" +) + +// 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..e8cab74 --- /dev/null +++ b/hatch/internal/oncall/oncall_test.go @@ -0,0 +1,27 @@ +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/scaffold/scaffold.go b/hatch/internal/scaffold/scaffold.go index 13ebb2d..4e17713 100644 --- a/hatch/internal/scaffold/scaffold.go +++ b/hatch/internal/scaffold/scaffold.go @@ -19,7 +19,7 @@ var templates embed.FS // can install (see docs/05-workflow.md). var WorkflowTemplates = []string{ "scrum", "kanban", "spec-first", "lite", - "dual-track", "shape-up", "stage-gate", + "dual-track", "shape-up", "stage-gate", "incident", } // Options configure an init run. 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/wf/escalate.go b/hatch/internal/wf/escalate.go index b76eb41..af8ca0c 100644 --- a/hatch/internal/wf/escalate.go +++ b/hatch/internal/wf/escalate.go @@ -7,6 +7,7 @@ import ( "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/store" ) @@ -14,9 +15,12 @@ import ( // an automatic escalation (like a teammate flagging a senior after retries). const escalationThreshold = 2 -// EscalateTarget resolves who escalations go to: registry policy, else the -// first conductor, else a human lead. +// EscalateTarget resolves who escalations go to: the current on-call, else +// registry policy, else the first conductor, else a human lead. func EscalateTarget(ws *config.Workspace) string { + if oc := oncall.Load(ws.Layout).Now(); oc != "" { + return oc + } if ws.Registry.Policy.EscalateTo != "" { return ws.Registry.Policy.EscalateTo } diff --git a/hatch/internal/wf/escalate_test.go b/hatch/internal/wf/escalate_test.go index 5ba233d..b2a46e7 100644 --- a/hatch/internal/wf/escalate_test.go +++ b/hatch/internal/wf/escalate_test.go @@ -5,6 +5,7 @@ import ( "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/scaffold" "github.com/fioenix/overclaud/hatch/internal/store" ) @@ -27,3 +28,15 @@ func TestEscalatePostsAndTargets(t *testing.T) { 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 := EscalateTarget(ws); got != "gemini" { + t.Fatalf("on-call should win escalation target, got %q", got) + } +} diff --git a/hatch/spec/workflow.schema.md b/hatch/spec/workflow.schema.md index 316a156..81e20dd 100644 --- a/hatch/spec/workflow.schema.md +++ b/hatch/spec/workflow.schema.md @@ -84,6 +84,7 @@ spec: | `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ộ. From 501d0f891836aca42bd0d6273abdcf13eae33573 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:13:39 +0000 Subject: [PATCH 18/60] Design docs: management plane (workload/performance/budget) + org/cadence Brainstorm + design (not yet implemented) for the open operational items, framed for a Founder/CEO/CTO running the squad: - docs/13-management.md: metrics derived from the ledger (cycle time, throughput, rework, gate pass rate); workload view; performance scorecards (with an honest Goodhart caveat); budget & "salary" = per-agent budget with cost capture, hard caps and auto-pause that reuses presence; stakeholder status report - docs/14-org-and-cadence.md: org chart + delegation-of-authority in the registry (escalation up the reporting line), external/cross-team dependencies, heartbeat/cadence via a stateless `hatch tick` for cron/CI, plus estimate/spike notes and a suggested implementation order https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 2 + hatch/docs/13-management.md | 85 ++++++++++++++++++++++++++++++++ hatch/docs/14-org-and-cadence.md | 71 ++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 hatch/docs/13-management.md create mode 100644 hatch/docs/14-org-and-cadence.md diff --git a/hatch/README.md b/hatch/README.md index 4bd756e..eadc638 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -76,6 +76,8 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | Sơ đồ trực quan: [architecture-diagram](docs/architecture-diagram.md) (kiến trúc + workflow + sequence). diff --git a/hatch/docs/13-management.md b/hatch/docs/13-management.md new file mode 100644 index 0000000..fc18aab --- /dev/null +++ b/hatch/docs/13-management.md @@ -0,0 +1,85 @@ +# 13 — Management plane (Founder/CEO/CTO view) + +> **Trạng thái: THIẾT KẾ (chưa implement).** Brainstorm cho 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 [<agent>]`** — 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/<agent>-<cycle>.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 +## <ts> · 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 <ticket>`** — 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 <agent> 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..69787dd --- /dev/null +++ b/hatch/docs/14-org-and-cadence.md @@ -0,0 +1,71 @@ +# 14 — Org chart, delegation & cadence + +> **Trạng thái: THIẾT KẾ (chưa implement).** Brainstorm cho ba mảnh vận hành còn thiếu: **org-chart + uỷ quyền**, **phụ thuộc liên đội/bên ngoài**, và **nhịp/heartbeat**. Nối tiếp [13-management](13-management.md). + +## 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. From 587711a2852da603da0d7412cb80f11f7103409b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:18:59 +0000 Subject: [PATCH 19/60] Add plaintext overview: full Hatch map, command index, day-in-the-squad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/overview.md — one-page big picture with ✓ built vs ◇ designed: the layered system map, CLI command index by area, an end-to-end "a day in the squad" flow, and the human-squad analogue table. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 2 +- hatch/docs/overview.md | 101 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 hatch/docs/overview.md diff --git a/hatch/README.md b/hatch/README.md index eadc638..438ac13 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -79,7 +79,7 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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 | -Sơ đồ trực quan: [architecture-diagram](docs/architecture-diagram.md) (kiến trúc + workflow + sequence). +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) diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md new file mode 100644 index 0000000..c334bb0 --- /dev/null +++ b/hatch/docs/overview.md @@ -0,0 +1,101 @@ +# Overview — bản đồ tổng Hatch (plaintext) + +Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs 13–14). + +## 1. Bức tranh toàn cảnh + +``` +┌─ HATCH ─ một bầy coding-agent vận hành như squad người, 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 (L0+L1+con trỏ) │ +│ │ nạp instruction native │ +│ ▼ │ +│ AGENTS ✓ claude · codex · gemini · kiro (mỗi con 1 process, KHÔNG chung RAM)│ +│ ▲ │ │ +│ │ │ claim/move ──► WORKFLOW ENGINE ✓ (transition · gate · no-self-review · │ +│ │ │ WIP · deps · auto-escalate) │ +│ │ ▼ │ +│ ORCHESTRATOR ✓ run · plan · watch ── spawn headless ─┘ │ +│ (catch-up: đọc inbox + recall trước khi vào việc) │ +│ │ +│ STORES = DATABASE (filesystem + git): │ +│ ✓ board/ (state=thư mục) · ✓ ledger/ (audit) · ✓ kb/ (tri thức) · ✓ bus/ (thoại)│ +│ │ +│ ── lớp "con người" ─────────────────────────────────────────────────────────── │ +│ COMMUNICATION ✓ DM · #channel · thread · @mention · search/recall │ +│ HỎI & HỌP ✓ ask (đồng bộ) · convene (+ tie-breaker) · decision→ADR │ +│ CEREMONIES ✓ standup · retro · planning · demo · grooming │ +│ COLLAB ✓ pairing(driver/navigator) · mob(driver xoay vòng) │ +│ ỨNG CỨU ✓ escalation · on-call rotation · incident workflow │ +│ CAPACITY ✓ presence(available/busy/paused/offline) → phân việc theo tải │ +│ TUI ✓ hatch board │ +│ ── lớp QUẢN TRỊ (Founder/CEO/CTO) ────────────────────────────────────────────── │ +│ ◇ workload · ◇ performance · ◇ budget/lương(+auto-pause) · ◇ org-chart/DoA │ +│ ◇ external deps · ◇ heartbeat(tick) · ◇ stakeholder report │ +└────────────────────────────────────────────────────────────────────────────────┘ + +3 kho tri thức: SSOT = config VÀO · KB = tri thức VÀO+RA · ledger = sự kiện RA + (bus = đối thoại; board = trạng thái) +``` + +## 2. Bản đồ lệnh CLI (theo nhóm) + +``` +SETUP/SSOT init · compile [--check] · validate · sync · hook install +BOARD/WORK ticket new|claim|move|show · status · standup · gate +ORCHESTRATOR run [--claim --worktree --no-catch-up] · plan · watch +KNOWLEDGE kb add|query|index +COMMUNICATION msg · inbox · thread · channel ls|show|join|leave|members · search +DIALOGUE ask · convene [--decider] · escalate +COLLAB pair · mob +CEREMONY ceremony standup|retro|planning|demo|grooming +CAPACITY/OPS presence [set] · oncall [set|rotate] +VIEW board (TUI) +◇ THIẾT KẾ workload · perf · budget · cost · report · org · tick · daemon +``` + +## 3. "Một ngày của squad" (luồng dệt mọi mảnh) + +``` + Founder ─ hatch plan ─► Conductor bẻ epic → tickets (role/priority/deps) [ledger] + │ + ceremony grooming ─► chuốt backlog (thiếu role/acceptance?) + │ + watch/run ─► chọn agent RẢNH & ít WIP (presence+capacity) ─► claim (git push=lock) + │ catch-up: đọc inbox + recall #channel liên quan + ▼ + Implementer code ──┬─ ask reviewer "chốt X?" (đồng bộ qua bus) + ├─ pair/mob nếu cần nhiều đầu + └─ convene "thiết kế Y" → DECISION → ADR vào kb/ + │ move → review [gate: test·lint·handoff] (fail 2 lần → auto escalate → on-call) + ▼ + Reviewer (≠ implementer) ─ gate DoD·no-self-review·human-merge ─► done [ledger] + │ + ceremony standup (digest theo agent + blockers → #standup) + ceremony demo (showcase việc done → #demo) + ceremony retro (done/blocks/gate-fail/decisions + đề bạt KB→SSOT) + │ + ◇ management: workload/perf/budget mỗi chu kỳ → report cho stakeholder +``` + +## 4. Đối ứng squad người (cô đọng) + +``` + onboarding handbook = compiler → CLAUDE.md/AGENTS.md theo vai + Jira board / sprint = board/ + workflow (8 template PDLC) + Slack = bus (DM·channel·thread·@mention·search) + hỏi đồng nghiệp / họp = ask · convene + pair / mob = pair · mob + standup/retro/demo = ceremony + on-call / sự cố = oncall · escalate · incident workflow + wiki / ADR = kb/ + nhật ký / git log = ledger/ + ai rảnh, PTO = presence + ◇ EM/CEO dashboard = workload · perf · budget/lương · org-chart/DoA +``` + +> Chi tiết: kiến trúc [01](01-architecture.md) · workflow [05](05-workflow.md) · KB [09](09-knowledge-base.md) · adapters [10](10-agent-adapters.md) · communication [11](11-communication.md) · ceremonies/escalation [12](12-ceremonies-escalation.md) · management [13](13-management.md) · org/cadence [14](14-org-and-cadence.md). From 350f7378a3216e506f87fafd7fb0a6fb415032cc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:37:19 +0000 Subject: [PATCH 20/60] Design docs: Obsidian KB, document templates, pre-implementation decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/15-obsidian-kb.md: use an Obsidian vault as the primary KB via CLI (vault-as-files; wikilinks/tags/frontmatter/MOC/Dataview compatibility; graph-aware recall; obsidian:// + optional Local REST API), with native MD as fallback. Honest caveat: Obsidian has no official CLI - docs/16-document-templates.md: doc-type → framework → template system (ADR/MADR, RFC, PRD, design, EARS requirements, SRE postmortem, runbook, Diátaxis, Keep a Changelog…), `hatch doc new/lint`, user-overridable like workflow templates; spec-lint can gate DoD - docs/17-pre-implementation.md: decisions to settle before building the rest (cost capture, secrets/redaction, concurrency, bus parser, mock agent for e2e, Obsidian vault location), with a suggested build order https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 3 ++ hatch/docs/15-obsidian-kb.md | 52 ++++++++++++++++++++ hatch/docs/16-document-templates.md | 74 +++++++++++++++++++++++++++++ hatch/docs/17-pre-implementation.md | 37 +++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 hatch/docs/15-obsidian-kb.md create mode 100644 hatch/docs/16-document-templates.md create mode 100644 hatch/docs/17-pre-implementation.md diff --git a/hatch/README.md b/hatch/README.md index 438ac13..68fd8b8 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -78,6 +78,9 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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ộ | 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). 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 <from> <to> # tạo wikilink hai chiều +hatch kb backlinks <note> # 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 <note> # 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 +<!-- vấn đề + ràng buộc --> +## 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 <file> # 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 <type>, dùng `hatch doc new <type>` 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/<type>.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..3f1fa06 --- /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. + +## Câu hỏi mở cho user +- (#2/#6/#7) Chính sách **cost & secrets & redaction** — mức độ chặt tới đâu (liên quan IPO-readiness/compliance)? +- (#1) Có muốn tao dựng **mock agent** để test end-to-end ngay trong remote không? +- (#9) Obsidian vault: trong repo hay ngoài? + +Ngoài các mục trên, mặt thiết kế đã đủ phủ một human squad (vận hành + giao tiếp + quản trị). Có thể bước vào implement; các 🔴 nên chốt trước khi đụng phần cost/secrets. From 0e108e02008d0cd0465c97d37b680dab05cff77d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:45:04 +0000 Subject: [PATCH 21/60] Harden foundation: mock agent + robust bus parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the settled pre-implementation decisions: - mock agent: `cmd/hatch-mock` + `kind: mock` adapter — a deterministic stand-in agent so execute/relay/pair/convene/run are testable end-to-end without a live agent CLI. New e2e test really spawns a process and asserts prompt pass-through, output capture and ledger entries. Makefile builds it. - bus parser robustness: a line is only a message heading when it starts with "## <timestamp> · …", so Markdown "## Section" inside a message body is no longer mis-split. Regression test added. - record decisions (mock=yes, vault=both/in-repo default, cost/secrets= minimal but secrets env-only) in docs/17. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/Makefile | 3 +- hatch/cmd/hatch-mock/main.go | 57 +++++++++++++++++++++++++ hatch/docs/10-agent-adapters.md | 1 + hatch/docs/17-pre-implementation.md | 10 ++--- hatch/internal/bus/bus_test.go | 17 ++++++++ hatch/internal/bus/parse.go | 23 +++++++++- hatch/internal/orchestrator/adapter.go | 1 + hatch/internal/orchestrator/adapters.go | 12 ++++++ hatch/internal/orchestrator/e2e_test.go | 56 ++++++++++++++++++++++++ 9 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 hatch/cmd/hatch-mock/main.go create mode 100644 hatch/internal/orchestrator/e2e_test.go diff --git a/hatch/Makefile b/hatch/Makefile index 65ef497..410cbc4 100644 --- a/hatch/Makefile +++ b/hatch/Makefile @@ -5,9 +5,10 @@ LDFLAGS := -X github.com/fioenix/overclaud/hatch/internal/cli.Version=$(VERSION) .PHONY: build install test lint vet fmt tidy clean -build: ## Build the hatch binary into ./bin +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 diff --git a/hatch/cmd/hatch-mock/main.go b/hatch/cmd/hatch-mock/main.go new file mode 100644 index 0000000..a6c1573 --- /dev/null +++ b/hatch/cmd/hatch-mock/main.go @@ -0,0 +1,57 @@ +// 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)) +} + +// 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/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md index ce6add0..7b05643 100644 --- a/hatch/docs/10-agent-adapters.md +++ b/hatch/docs/10-agent-adapters.md @@ -11,6 +11,7 @@ Tài liệu tham chiếu cho **compiler** (ghi instruction ra đâu) và **orche | Kiro | `kiro` | `.kiro/steering/*.md` | `kiro-cli chat --no-interactive` | | Gemini CLI | `gemini` | `GEMINI.md` | `gemini -p` | | Antigravity | `antigravity` | `AGENTS.md` (dùng chung convention) | IDE (chưa có CLI chính thức xác nhận) | +| (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 | `AGENTS.md` là convention dùng chung — **một** file phục vụ Codex + Antigravity (+ Gemini nếu khai `context.fileName`). diff --git a/hatch/docs/17-pre-implementation.md b/hatch/docs/17-pre-implementation.md index 3f1fa06..cd14109 100644 --- a/hatch/docs/17-pre-implementation.md +++ b/hatch/docs/17-pre-implementation.md @@ -29,9 +29,9 @@ 6. `tick` (cadence) + `report` + parallel `watch`. 12. 🟢 **Versioning/migration.** registry/workflow có `version`; thêm migration nhẹ khi đổi schema. -## Câu hỏi mở cho user -- (#2/#6/#7) Chính sách **cost & secrets & redaction** — mức độ chặt tới đâu (liên quan IPO-readiness/compliance)? -- (#1) Có muốn tao dựng **mock agent** để test end-to-end ngay trong remote không? -- (#9) Obsidian vault: trong repo hay ngoài? +## 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"). -Ngoài các mục trên, mặt thiết kế đã đủ phủ một human squad (vận hành + giao tiếp + quản trị). Có thể bước vào implement; các 🔴 nên chốt trước khi đụng phần cost/secrets. +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/internal/bus/bus_test.go b/hatch/internal/bus/bus_test.go index 8725170..0410739 100644 --- a/hatch/internal/bus/bus_test.go +++ b/hatch/internal/bus/bus_test.go @@ -1,6 +1,7 @@ package bus import ( + "strings" "testing" "github.com/fioenix/overclaud/hatch/internal/paths" @@ -101,3 +102,19 @@ func TestMentionsExtract(t *testing.T) { 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/parse.go b/hatch/internal/bus/parse.go index 96f21c1..09de78f 100644 --- a/hatch/internal/bus/parse.go +++ b/hatch/internal/bus/parse.go @@ -2,8 +2,29 @@ package bus import ( "strings" + "time" ) +// isMsgHeading reports whether a line is a message heading (not arbitrary +// Markdown in a body). Headings always start with "## <timestamp> · …", 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: "## <ts> · <from> → <to> · <type>[ · re:<id>] · {#<id>}". func parseThread(channel, raw string) []Message { @@ -23,7 +44,7 @@ func parseThread(channel, raw string) []Message { } for _, ln := range lines { - if strings.HasPrefix(ln, "## ") { + if isMsgHeading(ln) { flush() m := parseHeading(strings.TrimPrefix(ln, "## ")) m.Channel = channel diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go index 3a3043e..897671e 100644 --- a/hatch/internal/orchestrator/adapter.go +++ b/hatch/internal/orchestrator/adapter.go @@ -51,6 +51,7 @@ var adapters = map[string]Adapter{ "gemini": geminiAdapter{}, "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"}, } diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go index 1b6e9db..a0e7c2c 100644 --- a/hatch/internal/orchestrator/adapters.go +++ b/hatch/internal/orchestrator/adapters.go @@ -64,6 +64,18 @@ func (kiroAdapter) Build(req RunRequest) Invocation { 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 { diff --git a/hatch/internal/orchestrator/e2e_test.go b/hatch/internal/orchestrator/e2e_test.go new file mode 100644 index 0000000..c96a856 --- /dev/null +++ b/hatch/internal/orchestrator/e2e_test.go @@ -0,0 +1,56 @@ +package orchestrator + +import ( + "os" + "path/filepath" + "strings" + "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" +) + +// 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} + + out, err := 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 } From 3c4721141c79028d0d9cc9320da8e49ea0815fbf Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:49:54 +0000 Subject: [PATCH 22/60] Add cost tracking (track-only) + hatch cost / budget Per the minimal cost decision (track, no enforcement): - ledger entries carry cost_usd/tokens; orchestrator scrapes usage from agent output (provider-agnostic regex; estimates USD from rate_per_mtok when only tokens are given). hatch-mock emits fake usage for e2e. - store.ScanCosts parses the ledger; `hatch cost <ticket>` sums a ticket, `hatch budget` shows per-agent + team spend vs budget_usd/team_budget_usd (warns at 80%, never blocks) - registry: agent budget_usd/rate_per_mtok + policy.team_budget_usd - usage parsing test; verified e2e with the mock agent https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/cmd/hatch-mock/main.go | 3 + hatch/internal/cli/cost.go | 90 +++++++++++++++++++++++ hatch/internal/cli/root.go | 2 + hatch/internal/model/ledger.go | 2 + hatch/internal/model/registry.go | 12 ++- hatch/internal/orchestrator/run.go | 2 + hatch/internal/orchestrator/usage.go | 30 ++++++++ hatch/internal/orchestrator/usage_test.go | 18 +++++ hatch/internal/store/cost.go | 73 ++++++++++++++++++ hatch/internal/store/ledger.go | 6 ++ 10 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 hatch/internal/cli/cost.go create mode 100644 hatch/internal/orchestrator/usage.go create mode 100644 hatch/internal/orchestrator/usage_test.go create mode 100644 hatch/internal/store/cost.go diff --git a/hatch/cmd/hatch-mock/main.go b/hatch/cmd/hatch-mock/main.go index a6c1573..38012ad 100644 --- a/hatch/cmd/hatch-mock/main.go +++ b/hatch/cmd/hatch-mock/main.go @@ -23,6 +23,9 @@ func main() { } } 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. diff --git a/hatch/internal/cli/cost.go b/hatch/internal/cli/cost.go new file mode 100644 index 0000000..ce96078 --- /dev/null +++ b/hatch/internal/cli/cost.go @@ -0,0 +1,90 @@ +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 <ticket>", + 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/root.go b/hatch/internal/cli/root.go index 28a283f..c622b93 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -51,6 +51,8 @@ func NewRoot() *cobra.Command { newMobCmd(), newPresenceCmd(), newOncallCmd(), + newCostCmd(), + newBudgetCmd(), ) return root } diff --git a/hatch/internal/model/ledger.go b/hatch/internal/model/ledger.go index 973d0c0..e72ca31 100644 --- a/hatch/internal/model/ledger.go +++ b/hatch/internal/model/ledger.go @@ -29,4 +29,6 @@ type Entry struct { 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/registry.go b/hatch/internal/model/registry.go index c9309f1..f053c3d 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -12,6 +12,9 @@ type Agent struct { 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 + + 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 } // Role is a bundle of responsibilities + boundaries + the L1 context to load. @@ -25,10 +28,11 @@ type Role struct { // 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) + 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. diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go index ce66f0d..e2a6163 100644 --- a/hatch/internal/orchestrator/run.go +++ b/hatch/internal/orchestrator/run.go @@ -165,9 +165,11 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o if runErr != nil { result = "failed: " + runErr.Error() } + usd, tokens := extractUsage(buf.String(), agent.RatePerMTok) _ = lg.Append(model.Entry{ Agent: agent.ID, Ticket: t.ID, Action: model.ActProgress, Result: result, Why: fmt.Sprintf("%s finished (exit %d)", agent.ID, outcome.ExitCode), + CostUSD: usd, Tokens: tokens, }) return outcome, nil } diff --git a/hatch/internal/orchestrator/usage.go b/hatch/internal/orchestrator/usage.go new file mode 100644 index 0000000..03ee574 --- /dev/null +++ b/hatch/internal/orchestrator/usage.go @@ -0,0 +1,30 @@ +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..44a892b --- /dev/null +++ b/hatch/internal/orchestrator/usage_test.go @@ -0,0 +1,18 @@ +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/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/ledger.go b/hatch/internal/store/ledger.go index 721d8d6..ef65491 100644 --- a/hatch/internal/store/ledger.go +++ b/hatch/internal/store/ledger.go @@ -39,6 +39,12 @@ func render(e model.Entry) string { 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) } From ec8926be92427fbdadaf0108f7d5a230eebad5c9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:51:32 +0000 Subject: [PATCH 23/60] Add hatch workload + perf (metrics derived from the ledger) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metrics pkg: parse ledger into per-agent scorecards (claims, done, reviews, gate-fails, escalations, cost/tokens) + team throughput and avg cycle time (first claim → done). Nothing tracked separately. - store.Ledger.Entries(): full ledger parser reused by metrics - `hatch workload`: presence + WIP + done + idle/overloaded flags - `hatch perf [agent]`: scorecard, with the Goodhart caveat printed - metrics test https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/metrics.go | 90 ++++++++++++++++++++++++ hatch/internal/cli/root.go | 2 + hatch/internal/metrics/metrics.go | 96 ++++++++++++++++++++++++++ hatch/internal/metrics/metrics_test.go | 34 +++++++++ hatch/internal/store/entries.go | 72 +++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 hatch/internal/cli/metrics.go create mode 100644 hatch/internal/metrics/metrics.go create mode 100644 hatch/internal/metrics/metrics_test.go create mode 100644 hatch/internal/store/entries.go diff --git a/hatch/internal/cli/metrics.go b/hatch/internal/cli/metrics.go new file mode 100644 index 0000000..e7761d1 --- /dev/null +++ b/hatch/internal/cli/metrics.go @@ -0,0 +1,90 @@ +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/root.go b/hatch/internal/cli/root.go index c622b93..b0ba544 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -53,6 +53,8 @@ func NewRoot() *cobra.Command { newOncallCmd(), newCostCmd(), newBudgetCmd(), + newWorkloadCmd(), + newPerfCmd(), ) return root } diff --git a/hatch/internal/metrics/metrics.go b/hatch/internal/metrics/metrics.go new file mode 100644 index 0000000..6326bdd --- /dev/null +++ b/hatch/internal/metrics/metrics.go @@ -0,0 +1,96 @@ +// 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/store" +) + +// 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 *store.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..f7aa813 --- /dev/null +++ b/hatch/internal/metrics/metrics_test.go @@ -0,0 +1,34 @@ +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/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 +} From 3627fc0a112e4aff40713bbaff53161def2e0919 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:54:00 +0000 Subject: [PATCH 24/60] Add document template/spec system (hatch doc new/types/lint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs pkg + 5 default templates (adr/MADR, design, postmortem/SRE, rfc, prd) shipped via scaffold into .hatch/templates/docs/, user-editable - `hatch doc types` lists type→framework→required sections - `hatch doc new <type> --title` scaffolds (substitutes {{title}}, seeds required frontmatter: date/status/id) - `hatch doc lint <file>|--all` checks required sections + frontmatter per the document's declared doc-type; non-zero exit on problems (DoD-gate-able) - docs round-trip test https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/doc.go | 154 +++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/docs/docs.go | 159 ++++++++++++++++++ hatch/internal/docs/docs_test.go | 49 ++++++ hatch/internal/paths/paths.go | 1 + .../templates/base/templates/docs/adr.md | 19 +++ .../templates/base/templates/docs/design.md | 15 ++ .../base/templates/docs/postmortem.md | 18 ++ .../templates/base/templates/docs/prd.md | 15 ++ .../templates/base/templates/docs/rfc.md | 14 ++ 10 files changed, 445 insertions(+) create mode 100644 hatch/internal/cli/doc.go create mode 100644 hatch/internal/docs/docs.go create mode 100644 hatch/internal/docs/docs_test.go create mode 100644 hatch/internal/scaffold/templates/base/templates/docs/adr.md create mode 100644 hatch/internal/scaffold/templates/base/templates/docs/design.md create mode 100644 hatch/internal/scaffold/templates/base/templates/docs/postmortem.md create mode 100644 hatch/internal/scaffold/templates/base/templates/docs/prd.md create mode 100644 hatch/internal/scaffold/templates/base/templates/docs/rfc.md 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 <type>", + 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: <type>-<slug>.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/root.go b/hatch/internal/cli/root.go index b0ba544..6e050c9 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -55,6 +55,7 @@ func NewRoot() *cobra.Command { newBudgetCmd(), newWorkloadCmd(), newPerfCmd(), + newDocCmd(), ) return root } 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/paths/paths.go b/hatch/internal/paths/paths.go index e493c60..869fa0d 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -55,6 +55,7 @@ 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") } // RepoRoot is the directory that contains the .hatch directory. func (l Layout) RepoRoot() string { return filepath.Dir(l.Root) } 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 From 1eb0bb82faa490b3ba715017224a77708095210a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 16:55:19 +0000 Subject: [PATCH 25/60] Design doc: observability (transcripts, single-process TUI, tmux/Zellij) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/18: two distinct things to observe — squad state (board/ledger/bus, Hatch renders) vs live per-agent output (a stream). Two non-exclusive layers: (A) a single-process Bubble Tea mission-control TUI with internal panes (no deps, works over SSH/remote), and (B) optional tmux/Zellij driver (`--mux`) giving each spawned agent a real pane. Both build on a per-run transcript (.hatch/runs/) + `hatch logs --follow`; remote/CI observes via transcripts + artifacts. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 1 + hatch/docs/18-observability.md | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 hatch/docs/18-observability.md diff --git a/hatch/README.md b/hatch/README.md index 68fd8b8..21991e3 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -81,6 +81,7 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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) | (thiết kế) Quan sát agents: transcript · TUI · tmux/Zellij | 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). diff --git a/hatch/docs/18-observability.md b/hatch/docs/18-observability.md new file mode 100644 index 0000000..a2f1780 --- /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: THIẾT KẾ (chưa implement).** Làm sao "nhìn" được các agent CLI mà Hatch điều khiển? + +## 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, nhiều pane nội bộ**: +``` +┌ 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 <ticket>` vào pane đó; layout tự chia. +- `hatch run <ticket> --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/<ticket>/<ts>-<agent>.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 <ticket> [--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. From 2556aedc2e2ac948c437e167ec8d4663da618108 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:02:04 +0000 Subject: [PATCH 26/60] Add Obsidian KB mode: vault config, wikilinks, backlinks, graph, link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry `kb:` block (mode native|obsidian, vault path, wikilinks); KB store gains a configurable vault Root (in-repo default, external vault supported) + wikilink-aware index - `hatch kb link/backlinks/graph/open`: link notes (related), compute backlinks + link graph (from related + [[wikilinks]] in bodies), emit obsidian:// URI. Index renders [[wikilinks]] when enabled - verified e2e: link → backlinks/graph, wikilink index, URI https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/kb.go | 115 ++++++++++++++++++++++-- hatch/internal/model/registry.go | 8 ++ hatch/internal/store/kb.go | 148 +++++++++++++++++++++++++++++-- 3 files changed, 259 insertions(+), 12 deletions(-) diff --git a/hatch/internal/cli/kb.go b/hatch/internal/cli/kb.go index 0fc00ba..adb5000 100644 --- a/hatch/internal/cli/kb.go +++ b/hatch/internal/cli/kb.go @@ -3,11 +3,13 @@ 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" ) @@ -15,12 +17,29 @@ import ( func newKBCmd() *cobra.Command { cmd := &cobra.Command{ Use: "kb", - Short: "Shared Knowledge Base: add, query, reindex", + Short: "Shared Knowledge Base: add, query, reindex, link (Obsidian-aware)", } - cmd.AddCommand(newKBAddCmd(), newKBQueryCmd(), newKBIndexCmd()) + 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{ @@ -44,7 +63,7 @@ func newKBAddCmd() *cobra.Command { body = string(in) } } - kb := store.NewKB(ws.Layout) + kb := kbFor(ws) id := kb.NextID(typ) entry := model.KBEntry{ ID: id, @@ -92,7 +111,7 @@ func newKBQueryCmd() *cobra.Command { if err != nil { return err } - entries, err := store.NewKB(ws.Layout).Query(args) + entries, err := kbFor(ws).Query(args) if err != nil { return err } @@ -122,7 +141,7 @@ func newKBIndexCmd() *cobra.Command { if err != nil { return err } - if err := store.NewKB(ws.Layout).RebuildIndex(); err != nil { + if err := kbFor(ws).RebuildIndex(); err != nil { return err } fmt.Fprintln(cmd.OutOrStdout(), "rebuilt kb/index.md") @@ -152,3 +171,89 @@ func orHuman(a string) string { } return a } + +func newKBLinkCmd() *cobra.Command { + return &cobra.Command{ + Use: "link <from-id> <to-id>", + 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 <note>", + 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 <note>", + 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/model/registry.go b/hatch/internal/model/registry.go index f053c3d..7e2741d 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -40,6 +40,13 @@ 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"` @@ -48,6 +55,7 @@ type Registry struct { 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. diff --git a/hatch/internal/store/kb.go b/hatch/internal/store/kb.go index f5aeeac..8d39753 100644 --- a/hatch/internal/store/kb.go +++ b/hatch/internal/store/kb.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" @@ -12,15 +13,35 @@ import ( "github.com/fioenix/overclaud/hatch/internal/paths" ) -// KB reads and writes shared Knowledge Base entries. -type KB struct{ L paths.Layout } +// 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. +// 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.L.KB() + root := k.dir() var out []model.KBEntry for _, sub := range []string{"decisions", "domain", "learnings"} { dir := filepath.Join(root, sub) @@ -100,7 +121,7 @@ func (k *KB) NextID(typ string) string { // 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.L.KB(), sub) + dir := filepath.Join(k.dir(), sub) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } @@ -163,9 +184,122 @@ func (k *KB) RebuildIndex() error { if len(e.Tags) > 0 { tags = " — `" + strings.Join(e.Tags, "` `") + "`" } - fmt.Fprintf(&b, "- [%s](%s) %s%s\n", e.ID, e.Path, e.Title, 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.L.KBIndex(), []byte(b.String()), 0o644) + 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) } From 7302fb70d961fff6b9794be2145e3f3241e5f6d8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:03:37 +0000 Subject: [PATCH 27/60] Add per-run transcripts + hatch logs (foundation for live views) - orchestrator writes raw stdout+stderr of each run to .hatch/runs/<ticket>/<ts>-<agent>.log (in addition to terminal + ledger summary + cost) - `hatch logs <ticket>` prints the latest transcript; --follow tails live (kubectl-logs style), --all prints every run. This is the substrate the TUI's live-output panes read. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/logs.go | 100 ++++++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/orchestrator/run.go | 11 ++- hatch/internal/orchestrator/transcript.go | 33 +++++++ hatch/internal/paths/paths.go | 6 ++ 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 hatch/internal/cli/logs.go create mode 100644 hatch/internal/orchestrator/transcript.go 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 <ticket>", + 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/root.go b/hatch/internal/cli/root.go index 6e050c9..3222f18 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -56,6 +56,7 @@ func NewRoot() *cobra.Command { newWorkloadCmd(), newPerfCmd(), newDocCmd(), + newLogsCmd(), ) return root } diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go index e2a6163..5bb373a 100644 --- a/hatch/internal/orchestrator/run.go +++ b/hatch/internal/orchestrator/run.go @@ -147,8 +147,15 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o cmd.Stdin = strings.NewReader(inv.StdinStr) } var buf strings.Builder - cmd.Stdout = io.MultiWriter(out, &buf) - cmd.Stderr = io.MultiWriter(out, &buf) + 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 _ = lg.Append(model.Entry{ Agent: agent.ID, Ticket: t.ID, Action: model.ActStart, diff --git a/hatch/internal/orchestrator/transcript.go b/hatch/internal/orchestrator/transcript.go new file mode 100644 index 0000000..12f3b6b --- /dev/null +++ b/hatch/internal/orchestrator/transcript.go @@ -0,0 +1,33 @@ +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 { + if ticket == "" || ticket == "-" { + ticket = "system" + } + return filepath.Join(l.Root, "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+"-"+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/paths/paths.go b/hatch/internal/paths/paths.go index 869fa0d..90ed767 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -56,6 +56,12 @@ 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") } +func (l Layout) Runs(ticket string) string { + if ticket == "" || ticket == "-" { + ticket = "system" + } + return l.path("runs", ticket) +} // RepoRoot is the directory that contains the .hatch directory. func (l Layout) RepoRoot() string { return filepath.Dir(l.Root) } From 6327a2ec72c5523bf8849cd0133a07e0e69adc6c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:05:35 +0000 Subject: [PATCH 28/60] Add mission-control TUI (multi-pane: board + live output + activity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hatch board` is now a Bubble Tea dashboard with three panes in one process: BOARD (selectable tickets per lane), LIVE (tails the selected ticket's run transcript — live agent output), and ACTIVITY (ledger feed). Auto-refreshes every second. Keys: tab switches pane, ↑/↓ move/scroll, f follow a ticket's output, r launch a capacity-aware run on the selected ticket (control from the TUI), g refresh, q quit. No tmux needed; the optional tmux/Zellij multi-pane driver remains future work (docs/18). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/go.mod | 18 ++- hatch/go.sum | 41 +++-- hatch/internal/tui/board.go | 314 ++++++++++++++++++++++++++++-------- 3 files changed, 283 insertions(+), 90 deletions(-) diff --git a/hatch/go.mod b/hatch/go.mod index a3e1131..560bf5d 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -3,6 +3,7 @@ module github.com/fioenix/overclaud/hatch go 1.24.7 require ( + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.8.1 @@ -11,22 +12,25 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.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/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.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.16 // 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/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/hatch/go.sum b/hatch/go.sum index 78707d7..d823c34 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -1,37 +1,44 @@ 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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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/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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -41,12 +48,12 @@ 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= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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/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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/hatch/internal/tui/board.go b/hatch/internal/tui/board.go index 4c14efe..83da89f 100644 --- a/hatch/internal/tui/board.go +++ b/hatch/internal/tui/board.go @@ -1,112 +1,294 @@ -// Package tui renders an interactive board dashboard with Bubble Tea. +// Package tui renders an interactive mission-control dashboard with Bubble Tea: +// the board, live agent output (run transcripts) and an activity feed in one +// multi-pane view, plus the ability to launch a run on the selected ticket. package tui import ( "fmt" + "io" + "os" + "path/filepath" + "sort" "strings" + "time" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/fioenix/overclaud/hatch/internal/config" "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/orchestrator" + "github.com/fioenix/overclaud/hatch/internal/presence" "github.com/fioenix/overclaud/hatch/internal/store" ) +type pane int + +const ( + paneBoard pane = iota + paneLive + paneActivity +) + var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2563eb")) - laneStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7c3aed")). - BorderStyle(lipgloss.NormalBorder()).BorderBottom(true) - overWIP = lipgloss.NewStyle().Foreground(lipgloss.Color("#d33")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - helpStyle = dimStyle.Copy().MarginTop(1) + hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2563eb")) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + selSty = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7c3aed")) + laneSty = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0ea5e9")) + focused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#7c3aed")) + blurred = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")) ) -type model_ struct { - ws *config.Workspace - b *store.Board - err error - cols []laneCol +type tref struct { + lane string + t model.Ticket +} + +type m struct { + ws *config.Workspace + board *store.Board + ledger *store.Ledger + refs []tref + sel int + focus pane + live viewport.Model + activity viewport.Model + liveTick string + w, h int + status string + ready bool +} + +type tickMsg time.Time +type ranMsg struct { + ticket string + err error } -type laneCol struct { - lane model.Lane - tickets []model.Ticket +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } -// New returns a Bubble Tea program for the board. +// New returns a Bubble Tea program for the mission-control dashboard. func New(ws *config.Workspace) *tea.Program { - m := model_{ws: ws, b: store.NewBoard(ws.Layout)} - m.reload() - return tea.NewProgram(m, tea.WithAltScreen()) -} - -func (m *model_) reload() { - m.cols = nil - for _, lane := range m.ws.Workflow.Lanes { - ts, err := m.b.ListLane(lane.ID) - if err != nil { - m.err = err - return + mm := m{ws: ws, board: store.NewBoard(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() { + var refs []tref + for _, lane := range mm.ws.Workflow.Lanes { + ts, _ := mm.board.ListLane(lane.ID) + for _, t := range ts { + refs = append(refs, tref{lane.ID, t}) } - m.cols = append(m.cols, laneCol{lane: lane, tickets: ts}) } + mm.refs = refs + if mm.sel >= len(refs) { + mm.sel = max(0, len(refs)-1) + } + if mm.liveTick == "" && len(refs) > 0 { + mm.liveTick = refs[mm.sel].t.ID + } + mm.live.SetContent(mm.transcript(mm.liveTick)) + mm.activity.SetContent(mm.activityFeed()) } -func (m model_) Init() tea.Cmd { return nil } +func (mm *m) transcript(ticket string) string { + if ticket == "" { + return dim.Render("(chọn ticket, nhấn f để theo dõi live output)") + } + dir := mm.ws.Layout.Runs(ticket) + ents, err := os.ReadDir(dir) + if err != nil || len(ents) == 0 { + return dim.Render("(chưa có run nào cho " + ticket + ")") + } + var logs []string + for _, e := range ents { + if strings.HasSuffix(e.Name(), ".log") { + logs = append(logs, e.Name()) + } + } + if len(logs) == 0 { + return dim.Render("(chưa có transcript)") + } + sort.Strings(logs) + raw, _ := os.ReadFile(filepath.Join(dir, logs[len(logs)-1])) + return string(raw) +} -func (m model_) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +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) layout() { + leftW := mm.w/2 - 2 + rightW := mm.w - leftW - 4 + vpH := (mm.h - 6) / 2 + mm.live = 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 ranMsg: + if msg.err != nil { + mm.status = "run " + msg.ticket + " lỗi: " + msg.err.Error() + } else { + mm.status = "run " + msg.ticket + " xong" + } case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c", "esc": - return m, tea.Quit + return mm, tea.Quit + case "tab": + mm.focus = (mm.focus + 1) % 3 + case "up", "k": + if mm.focus == paneBoard && mm.sel > 0 { + mm.sel-- + } else if mm.focus == paneLive { + mm.live.LineUp(1) + } else if mm.focus == paneActivity { + mm.activity.LineUp(1) + } + case "down", "j": + if mm.focus == paneBoard && mm.sel < len(mm.refs)-1 { + mm.sel++ + } else if mm.focus == paneLive { + mm.live.LineDown(1) + } else if mm.focus == paneActivity { + mm.activity.LineDown(1) + } + case "f": + if mm.focus == paneBoard && mm.sel < len(mm.refs) { + mm.liveTick = mm.refs[mm.sel].t.ID + mm.status = "theo dõi " + mm.liveTick + } case "r": - m.reload() + if mm.sel < len(mm.refs) { + return mm, mm.runSelected() + } + case "g": + mm.reload() } } - return m, nil + return mm, nil +} + +// runSelected launches an agent on the selected ticket in the background. +func (mm *m) runSelected() tea.Cmd { + ref := mm.refs[mm.sel] + mm.liveTick = ref.t.ID + agent, ok := pick(mm.ws, ref.t.Role) + if !ok { + mm.status = "không có agent rảnh cho vai " + ref.t.Role + return nil + } + mm.status = "đang chạy " + agent.ID + " trên " + ref.t.ID + "…" + ws, t, role := mm.ws, ref.t, ref.t.Role + return func() tea.Msg { + _, err := orchestrator.Run(ws, agent, t, role, orchestrator.RunOptions{Stdout: io.Discard}) + return ranMsg{ticket: t.ID, err: err} + } } -func (m model_) View() string { - if m.err != nil { - return "error: " + m.err.Error() + "\n" +func (mm *m) View() string { + if !mm.ready { + return "loading…" } - var b strings.Builder - project := m.ws.Registry.Project + project := mm.ws.Registry.Project if project == "" { project = "Hatch" } - b.WriteString(titleStyle.Render(project+" — board") + " " + dimStyle.Render("("+m.ws.Workflow.Template+")") + "\n\n") + header := hdr.Render(project+" — mission control") + " " + + dim.Render(fmt.Sprintf("(%s · %d tickets)", mm.ws.Workflow.Template, len(mm.refs))) - for _, c := range m.cols { - head := fmt.Sprintf("%s %d", c.lane.ID, len(c.tickets)) - if c.lane.WIPLimit > 0 { - wip := fmt.Sprintf(" (WIP %d/%d)", len(c.tickets), c.lane.WIPLimit) - if len(c.tickets) > c.lane.WIPLimit { - head += overWIP.Render(wip) - } else { - head += dimStyle.Render(wip) - } + // Board pane. + var bd strings.Builder + curLane := "" + for i, r := range mm.refs { + if r.lane != curLane { + curLane = r.lane + bd.WriteString(laneSty.Render(curLane) + "\n") } - b.WriteString(laneStyle.Render(head) + "\n") - if len(c.tickets) == 0 { - b.WriteString(dimStyle.Render(" —") + "\n") + line := fmt.Sprintf(" %-7s %-11s %s", r.t.ID, r.t.Assignee, trunc(r.t.Title, 22)) + if i == mm.sel { + line = selSty.Render("▸ " + strings.TrimLeft(line, " ")) } - for _, t := range c.tickets { - assignee := t.Assignee - if assignee == "" { - assignee = "-" - } - prio := t.Priority - if prio == "" { - prio = "--" - } - line := fmt.Sprintf(" %-7s %-3s %-12s %-12s %s", t.ID, prio, t.Role, assignee, t.Title) - b.WriteString(line + "\n") + bd.WriteString(line + "\n") + } + boardBox := paneBox(mm.focus == paneBoard, "BOARD", bd.String(), mm.w/2-2, mm.h-6) + + mm.live.SetContent(mm.transcript(mm.liveTick)) + liveBox := paneBox(mm.focus == paneLive, "LIVE · "+mm.liveTick, mm.live.View(), 0, 0) + actBox := paneBox(mm.focus == paneActivity, "ACTIVITY (ledger)", mm.activity.View(), 0, 0) + right := lipgloss.JoinVertical(lipgloss.Left, liveBox, actBox) + body := lipgloss.JoinHorizontal(lipgloss.Top, boardBox, right) + + foot := dim.Render("tab pane · ↑/↓ move·scroll · f follow · r run · 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) +} + +// pick chooses an available agent for a role (presence-aware, first match). +func pick(ws *config.Workspace, role string) (model.Agent, bool) { + pres := presence.Load(ws.Layout) + for _, a := range ws.Registry.AgentsForRole(role) { + if pres.CanTakeWork(a.ID) { + return a, true } - b.WriteString("\n") } - b.WriteString(helpStyle.Render("r reload · q quit")) - return b.String() + return model.Agent{}, false +} + +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 } From 802f889d88d7e112d8e5a09f67130c06f80b374a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:07:16 +0000 Subject: [PATCH 29/60] Add local onboarding script (build + runnable demo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./scripts/onboard.sh` (or `make onboard`): verifies Go, builds hatch + hatch-mock, optionally `--install`s them on PATH, and spins up a demo workspace in .hatch-demo (mock agent wired in) that seeds a ticket, runs it, and shows board/logs/cost/perf — so you can try Hatch locally with no real agent CLI. Flags: --install, --no-demo, --demo DIR. README quick-start updated; .hatch-demo gitignored. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/.gitignore | 1 + hatch/Makefile | 5 ++- hatch/README.md | 3 ++ hatch/scripts/onboard.sh | 90 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100755 hatch/scripts/onboard.sh diff --git a/hatch/.gitignore b/hatch/.gitignore index 355714e..7368ac2 100644 --- a/hatch/.gitignore +++ b/hatch/.gitignore @@ -2,5 +2,6 @@ # cmd/hatch source directory. /bin/ /hatch +/.hatch-demo/ *.out *.test diff --git a/hatch/Makefile b/hatch/Makefile index 410cbc4..bc531d9 100644 --- a/hatch/Makefile +++ b/hatch/Makefile @@ -3,7 +3,10 @@ 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 +.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 diff --git a/hatch/README.md b/hatch/README.md index 21991e3..9116a4b 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -92,6 +92,9 @@ Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.s Hatch là CLI viết bằng Go (single binary). Phase 1+2 đã chạy được: ```bash +./scripts/onboard.sh # build + dựng demo workspace (mock agent) để thử ngay — hoặc `make onboard` +./scripts/onboard.sh --install # thêm: go install hatch + hatch-mock lên PATH +# hoặc thủ công: make build # → bin/hatch bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering diff --git a/hatch/scripts/onboard.sh b/hatch/scripts/onboard.sh new file mode 100755 index 0000000..769deee --- /dev/null +++ b/hatch/scripts/onboard.sh @@ -0,0 +1,90 @@ +#!/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 ./.hatch-demo +# ./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/.hatch-demo" +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 + hatch-mock" +cd "$HATCH_DIR" +make build +BIN="$HATCH_DIR/bin" +echo "built: $BIN/hatch, $BIN/hatch-mock" + +if [ "$DO_INSTALL" = "1" ]; then + step "Installing to GOPATH/bin (go install)" + make install + go build -o "$(go env GOPATH)/bin/hatch-mock" ./cmd/hatch-mock + echo "installed hatch + hatch-mock 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 -w scrum >/dev/null + # Point the implementer agent at the mock adapter so runs work without a real CLI. + sed -i.bak -E 's/kind: codex/kind: mock/' .hatch/registry.yaml && rm -f .hatch/registry.yaml.bak + hatch compile >/dev/null 2>&1 + + step "Seeding a ticket and running the mock agent" + hatch ticket new --title "Export báo cáo ra CSV" --role implementer --priority P1 >/dev/null + hatch ticket claim T-001 --agent codex --why "demo" >/dev/null + hatch run T-001 --agent codex >/dev/null 2>&1 || true + + step "Board" + hatch status + step "Run transcript (hatch logs)" + hatch logs T-001 | sed 's/^/ /' + step "Cost + perf" + hatch cost T-001 + hatch perf 2>/dev/null | sed 's/^/ /' | head -4 + + printf '\n\033[1;32m✔ Demo ready.\033[0m Workspace: %s\n' "$DEMO_DIR" + cat <<EOF + +Try next (from that dir, with $BIN on PATH): + hatch board # the multi-pane TUI (board + live output + activity) + hatch ticket move T-001 --to review --by implementer --agent codex --why done --skip-gates + hatch msg --from codex -c '#design' "@claude-code chốt streaming nhé" + hatch convene --topic "Thiết kế export" --agents codex,claude-code --rounds 1 + hatch workload ; hatch budget + +Docs: $HATCH_DIR/docs/overview.md +EOF +fi From 0fcf7b7d988988a2501e3a7b63e93f2f080f145e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:14:02 +0000 Subject: [PATCH 30/60] Add org chart / delegation-of-authority + external dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry roles gain reports_to + authority (can_approve, budget_authority_usd, decision_scope); validated (unknown parent + reporting cycles). `hatch org` prints the tree + authority matrix. - escalation climbs the org chart: EscalateTargetForRole resolves on-call → manager of the ticket's role → policy → conductor → human:lead - tickets gain blocked_by_external (what/owner/eta/status); claim is blocked while any are open; `hatch ticket extdep <id> --add/--resolve`; `hatch status` surfaces them as risk - org-escalation test https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/org.go | 86 ++++++++++++++++++++++++++++++ hatch/internal/cli/root.go | 1 + hatch/internal/cli/status.go | 5 ++ hatch/internal/cli/ticket.go | 66 ++++++++++++++++++++++- hatch/internal/config/validate.go | 31 +++++++++++ hatch/internal/model/registry.go | 17 ++++-- hatch/internal/model/ticket.go | 26 ++++++++- hatch/internal/wf/engine.go | 5 +- hatch/internal/wf/escalate.go | 23 ++++++-- hatch/internal/wf/escalate_test.go | 18 +++++++ 10 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 hatch/internal/cli/org.go 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/root.go b/hatch/internal/cli/root.go index 3222f18..3b378ea 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -57,6 +57,7 @@ func NewRoot() *cobra.Command { newPerfCmd(), newDocCmd(), newLogsCmd(), + newOrgCmd(), ) return root } diff --git a/hatch/internal/cli/status.go b/hatch/internal/cli/status.go index e09d9eb..7177b78 100644 --- a/hatch/internal/cli/status.go +++ b/hatch/internal/cli/status.go @@ -48,6 +48,11 @@ func newStatusCmd() *cobra.Command { fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", t.ID, prio, t.Role, assignee, t.Title) } tw.Flush() + for _, t := range tickets { + for _, e := range t.OpenExternal() { + fmt.Fprintf(out, " ⚠ %s blocked-external: %s (owner %s, eta %s)\n", t.ID, e.What, e.Owner, e.ETA) + } + } } fmt.Fprintln(out) printWIPWarnings(cmd, ws, b) diff --git a/hatch/internal/cli/ticket.go b/hatch/internal/cli/ticket.go index f75799f..6c38005 100644 --- a/hatch/internal/cli/ticket.go +++ b/hatch/internal/cli/ticket.go @@ -19,7 +19,71 @@ func newTicketCmd() *cobra.Command { Aliases: []string{"t"}, Short: "Create and move tickets on the board", } - cmd.AddCommand(newTicketNewCmd(), newTicketClaimCmd(), newTicketMoveCmd(), newTicketShowCmd()) + cmd.AddCommand(newTicketNewCmd(), newTicketClaimCmd(), newTicketMoveCmd(), newTicketShowCmd(), newTicketExtdepCmd()) + return cmd +} + +func newTicketExtdepCmd() *cobra.Command { + var add, owner, eta, resolve string + cmd := &cobra.Command{ + Use: "extdep <id>", + 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 } diff --git a/hatch/internal/config/validate.go b/hatch/internal/config/validate.go index 89e0fcf..227f0d1 100644 --- a/hatch/internal/config/validate.go +++ b/hatch/internal/config/validate.go @@ -33,6 +33,19 @@ func ValidateRegistry(r *model.Registry) []Problem { } 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 == "" { @@ -55,6 +68,24 @@ func ValidateRegistry(r *model.Registry) []Problem { 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 { diff --git a/hatch/internal/model/registry.go b/hatch/internal/model/registry.go index 7e2741d..deb4943 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -17,13 +17,22 @@ type Agent struct { 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 + 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. diff --git a/hatch/internal/model/ticket.go b/hatch/internal/model/ticket.go index 6ab0d53..772bd78 100644 --- a/hatch/internal/model/ticket.go +++ b/hatch/internal/model/ticket.go @@ -14,6 +14,15 @@ type Claim struct { 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 { @@ -29,8 +38,10 @@ type Ticket struct { ContextRefs []string `yaml:"context_refs,omitempty"` Claim *Claim `yaml:"claim,omitempty"` DoD []string `yaml:"dod,omitempty"` - Created string `yaml:"created,omitempty"` - Updated string `yaml:"updated,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:"-"` @@ -39,3 +50,14 @@ type Ticket struct { // 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/wf/engine.go b/hatch/internal/wf/engine.go index 3afbf91..fdc982b 100644 --- a/hatch/internal/wf/engine.go +++ b/hatch/internal/wf/engine.go @@ -62,11 +62,14 @@ func Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID strin opt.ByRole, from, opt.To, strings.Join(tr.By, ", ")) } - // depends_on must be done before claiming into an active lane. + // 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 diff --git a/hatch/internal/wf/escalate.go b/hatch/internal/wf/escalate.go index af8ca0c..d4c326b 100644 --- a/hatch/internal/wf/escalate.go +++ b/hatch/internal/wf/escalate.go @@ -15,12 +15,23 @@ import ( // an automatic escalation (like a teammate flagging a senior after retries). const escalationThreshold = 2 -// EscalateTarget resolves who escalations go to: the current on-call, else -// registry policy, else the first conductor, else a human lead. -func EscalateTarget(ws *config.Workspace) string { +// EscalateTarget resolves the system-level escalation target (no ticket role). +func EscalateTarget(ws *config.Workspace) string { return 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 EscalateTargetForRole(ws *config.Workspace, role string) string { if oc := oncall.Load(ws.Layout).Now(); 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 } @@ -33,7 +44,11 @@ func EscalateTarget(ws *config.Workspace) string { // Escalate records an escalation in the ledger and notifies the target on the // #escalations channel. func Escalate(ws *config.Workspace, lg *store.Ledger, ticket, from, why string) error { - target := EscalateTarget(ws) + role := "" + if t, ok, _ := store.NewBoard(ws.Layout).Find(ticket, ws.Workflow.LaneIDs()); ok { + role = t.Role + } + target := EscalateTargetForRole(ws, role) if from == "" { from = "orchestrator" } diff --git a/hatch/internal/wf/escalate_test.go b/hatch/internal/wf/escalate_test.go index b2a46e7..fe9ae26 100644 --- a/hatch/internal/wf/escalate_test.go +++ b/hatch/internal/wf/escalate_test.go @@ -40,3 +40,21 @@ func TestEscalateTargetPrefersOncall(t *testing.T) { 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) + // implementer reports to conductor (claude-code) in the default registry? + // set it explicitly for the test. + for i := range ws.Registry.Roles { + if ws.Registry.Roles[i].ID == "implementer" { + ws.Registry.Roles[i].ReportsTo = "conductor" + } + } + if got := EscalateTargetForRole(ws, "implementer"); got != "claude-code" { + t.Fatalf("escalation should climb to conductor agent claude-code, got %q", got) + } +} From fd2cc97ea901282f892832929590a49a72695d08 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:16:49 +0000 Subject: [PATCH 31/60] Add hatch report, hatch tick (cadence), and parallel watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `hatch report`: executive summary (board counts, throughput+cycle, budget burn, open external-blocker/escalation risks, recent ADRs); --post to #leadership - `hatch tick`: one heartbeat for cron/CI — posts standup digest, dispatches claimable work, warns on budget; --dry-run/--max/--parallel - watch refactored to a shared dispatchBacklog: claims serially (ordered), then runs serially or via a bounded worker pool (`--parallel N`); parallel runs stream to per-run transcripts to avoid garbled output - verified e2e: parallel tick dispatched 3 tickets to 3 agents; report renders https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/orchestrate.go | 136 +++++++++++++++++++++--------- hatch/internal/cli/report.go | 124 +++++++++++++++++++++++++++ hatch/internal/cli/root.go | 2 + hatch/internal/cli/tick.go | 64 ++++++++++++++ 4 files changed, 287 insertions(+), 39 deletions(-) create mode 100644 hatch/internal/cli/report.go create mode 100644 hatch/internal/cli/tick.go diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index 46066a2..9643d18 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -2,7 +2,9 @@ package cli import ( "fmt" + "io" "sort" + "sync" "time" "github.com/spf13/cobra" @@ -181,59 +183,115 @@ func newPlanCmd() *cobra.Command { func newWatchCmd() *cobra.Command { var dryRun bool - var max int + var max, parallel int cmd := &cobra.Command{ Use: "watch", - Short: "Assign and run claimable backlog tickets (one pass, respects WIP)", + 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 } - b := store.NewBoard(ws.Layout) - lane := firstLane(ws) - tickets, err := b.ListLane(lane) + n, err := dispatchBacklog(ws, cmd.OutOrStdout(), dispatchOpts{DryRun: dryRun, Max: max, Parallel: parallel}) if err != nil { return err } - out := cmd.OutOrStdout() - ran := 0 - for _, t := range tickets { - if max > 0 && ran >= 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 dryRun { - ran++ - continue - } - to := claimTarget(ws, t.Lane) - if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 - } - t2, _, _ := b.Find(t.ID, ws.Workflow.LaneIDs()) - if _, err := orchestrator.Run(ws, agent, t2, t2.Role, orchestrator.RunOptions{Stdout: out}); err != nil { - fmt.Fprintf(out, " run failed: %v\n", err) - } - ran++ - } - fmt.Fprintf(out, "watch: %d ticket(s) dispatched\n", ran) + 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 := wf.Move(ws, b, store.NewLedger(ws.Layout), 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 := orchestrator.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 := orchestrator.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/report.go b/hatch/internal/cli/report.go new file mode 100644 index 0000000..311b83d --- /dev/null +++ b/hatch/internal/cli/report.go @@ -0,0 +1,124 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/metrics" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +func newReportCmd() *cobra.Command { + var post bool + cmd := &cobra.Command{ + Use: "report", + Short: "Executive status summary (board, throughput, budget, risks, decisions)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + rep, err := buildReport(ws) + if err != nil { + return err + } + fmt.Fprint(cmd.OutOrStdout(), rep) + if post { + if _, err := bus.New(ws.Layout).Post(bus.Message{ + Channel: "#leadership", From: "human:facilitator", To: []string{"*"}, Body: rep, + }); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "\n(posted to #leadership)") + } + return nil + }, + } + cmd.Flags().BoolVar(&post, "post", false, "post the report to #leadership") + return cmd +} + +func buildReport(ws *config.Workspace) (string, error) { + b := store.NewBoard(ws.Layout) + var sb strings.Builder + project := ws.Registry.Project + if project == "" { + project = "Hatch" + } + fmt.Fprintf(&sb, "# Status report — %s — %s\n\n", project, time.Now().Format("2006-01-02")) + + // Board. + sb.WriteString("## Board\n") + for _, lane := range ws.Workflow.Lanes { + ts, err := b.ListLane(lane.ID) + if err != nil { + return "", err + } + fmt.Fprintf(&sb, "- %s: %d\n", lane.ID, len(ts)) + } + + // Throughput. + m, err := metrics.Compute(store.NewLedger(ws.Layout)) + if err != nil { + return "", err + } + fmt.Fprintf(&sb, "\n## Throughput\n- done: %d · avg cycle: %s\n", m.Throughput, m.CycleAvg.Round(time.Second)) + + // Budget. + recs, _ := store.NewLedger(ws.Layout).ScanCosts() + var total float64 + for _, r := range recs { + total += r.USD + } + sb.WriteString("\n## Budget\n") + if cap := ws.Registry.Policy.TeamBudgetUSD; cap > 0 { + fmt.Fprintf(&sb, "- spend: $%.2f / $%.2f (%.0f%%)\n", total, cap, total/cap*100) + } else { + fmt.Fprintf(&sb, "- spend: $%.2f\n", total) + } + + // Risks: open external blockers + escalations. + sb.WriteString("\n## Risks\n") + risk := 0 + for _, lane := range ws.Workflow.LaneIDs() { + ts, _ := b.ListLane(lane) + for _, t := range ts { + for _, e := range t.OpenExternal() { + fmt.Fprintf(&sb, "- %s blocked-external: %s (owner %s, eta %s)\n", t.ID, e.What, e.Owner, e.ETA) + risk++ + } + } + } + escal := 0 + for _, s := range m.Agents { + escal += s.Escalations + } + if escal > 0 { + fmt.Fprintf(&sb, "- escalations: %d\n", escal) + risk++ + } + if risk == 0 { + sb.WriteString("- none\n") + } + + // Recent decisions (ADRs). + sb.WriteString("\n## Recent decisions\n") + entries, _ := store.NewKB(ws.Layout).List() + n := 0 + for i := len(entries) - 1; i >= 0 && n < 5; i-- { + if entries[i].Type == model.KBDecision { + fmt.Fprintf(&sb, "- %s %s\n", entries[i].ID, entries[i].Title) + n++ + } + } + if n == 0 { + sb.WriteString("- none\n") + } + return sb.String(), nil +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 3b378ea..3b31dc9 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -58,6 +58,8 @@ func NewRoot() *cobra.Command { newDocCmd(), newLogsCmd(), newOrgCmd(), + newReportCmd(), + newTickCmd(), ) return root } diff --git a/hatch/internal/cli/tick.go b/hatch/internal/cli/tick.go new file mode 100644 index 0000000..48ac969 --- /dev/null +++ b/hatch/internal/cli/tick.go @@ -0,0 +1,64 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/ceremony" + "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 := ceremony.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 +} From 915146458cebca9001a2374ce362c7a4bbd3f4e8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:21:28 +0000 Subject: [PATCH 32/60] Readiness: tmux/Zellij mux, concurrent-safe ledger, E2E test, docs refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - observability tier B: `hatch run --mux=tmux|zellij` opens the run in a real multiplexer pane (re-invokes hatch run there); mux pkg + tests - concurrency safety: serialize ledger appends (process mutex) so parallel watch/tick runs don't interleave entry blocks - end-to-end CLI integration test: init → compile → validate → ticket → claim → run (mock agent) → logs → status → report, all via the real command tree (proves it runs for real) - docs: mark 13/14/18 implemented; refresh overview map (✓ vs remaining) https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/13-management.md | 2 +- hatch/docs/14-org-and-cadence.md | 2 +- hatch/docs/18-observability.md | 2 +- hatch/docs/overview.md | 20 +++--- hatch/internal/cli/integration_test.go | 89 ++++++++++++++++++++++++++ hatch/internal/cli/orchestrate.go | 18 +++++- hatch/internal/mux/mux.go | 69 ++++++++++++++++++++ hatch/internal/mux/mux_test.go | 30 +++++++++ hatch/internal/store/ledger.go | 7 ++ 9 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 hatch/internal/cli/integration_test.go create mode 100644 hatch/internal/mux/mux.go create mode 100644 hatch/internal/mux/mux_test.go diff --git a/hatch/docs/13-management.md b/hatch/docs/13-management.md index fc18aab..4cbaac3 100644 --- a/hatch/docs/13-management.md +++ b/hatch/docs/13-management.md @@ -1,6 +1,6 @@ # 13 — Management plane (Founder/CEO/CTO view) -> **Trạng thái: THIẾT KẾ (chưa implement).** Brainstorm cho 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). +> **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 diff --git a/hatch/docs/14-org-and-cadence.md b/hatch/docs/14-org-and-cadence.md index 69787dd..a9e62c9 100644 --- a/hatch/docs/14-org-and-cadence.md +++ b/hatch/docs/14-org-and-cadence.md @@ -1,6 +1,6 @@ # 14 — Org chart, delegation & cadence -> **Trạng thái: THIẾT KẾ (chưa implement).** Brainstorm cho ba mảnh vận hành còn thiếu: **org-chart + uỷ quyền**, **phụ thuộc liên đội/bên ngoài**, và **nhịp/heartbeat**. Nối tiếp [13-management](13-management.md). +> **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) diff --git a/hatch/docs/18-observability.md b/hatch/docs/18-observability.md index a2f1780..91da00e 100644 --- a/hatch/docs/18-observability.md +++ b/hatch/docs/18-observability.md @@ -1,6 +1,6 @@ # 18 — Quan sát agents đang làm việc (observability) -> **Trạng thái: THIẾT KẾ (chưa implement).** Làm sao "nhìn" được các agent CLI mà Hatch điều khiển? +> **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 diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md index c334bb0..9668615 100644 --- a/hatch/docs/overview.md +++ b/hatch/docs/overview.md @@ -32,10 +32,13 @@ Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs │ COLLAB ✓ pairing(driver/navigator) · mob(driver xoay vòng) │ │ ỨNG CỨU ✓ escalation · on-call rotation · incident workflow │ │ CAPACITY ✓ presence(available/busy/paused/offline) → phân việc theo tải │ -│ TUI ✓ hatch board │ +│ OBSERVE ✓ transcript mỗi run + hatch logs -f · TUI board (board+live+feed) │ +│ ✓ --mux=tmux|zellij (mỗi run một pane thật) │ +│ DOCS/KB ✓ doc templates (new/lint) · Obsidian KB (link/backlinks/graph) │ │ ── lớp QUẢN TRỊ (Founder/CEO/CTO) ────────────────────────────────────────────── │ -│ ◇ workload · ◇ performance · ◇ budget/lương(+auto-pause) · ◇ org-chart/DoA │ -│ ◇ external deps · ◇ heartbeat(tick) · ◇ stakeholder report │ +│ ✓ workload · ✓ performance · ✓ budget/lương(track) · ✓ org-chart/DoA │ +│ ✓ external deps · ✓ heartbeat(hatch tick) · ✓ stakeholder report(hatch report) │ +│ ◇ còn lại: auto-pause khi vượt budget (đã chốt track-only), parallel-watch worktree│ └────────────────────────────────────────────────────────────────────────────────┘ 3 kho tri thức: SSOT = config VÀO · KB = tri thức VÀO+RA · ledger = sự kiện RA @@ -46,16 +49,17 @@ Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs ``` SETUP/SSOT init · compile [--check] · validate · sync · hook install -BOARD/WORK ticket new|claim|move|show · status · standup · gate -ORCHESTRATOR run [--claim --worktree --no-catch-up] · plan · watch -KNOWLEDGE kb add|query|index +BOARD/WORK ticket new|claim|move|show|extdep · status · standup · gate +ORCH/OBSERVE run [--claim --worktree --mux=tmux|zellij] · plan · watch [--parallel N] + tick · logs [-f] · board (TUI) +KNOWLEDGE kb add|query|index|link|backlinks|graph|open · doc types|new|lint COMMUNICATION msg · inbox · thread · channel ls|show|join|leave|members · search DIALOGUE ask · convene [--decider] · escalate COLLAB pair · mob CEREMONY ceremony standup|retro|planning|demo|grooming CAPACITY/OPS presence [set] · oncall [set|rotate] -VIEW board (TUI) -◇ THIẾT KẾ workload · perf · budget · cost · report · org · tick · daemon +MANAGE workload · perf · cost · budget · report · org +◇ còn lại auto-pause budget (chốt track-only) · parallel-watch worktree · daemon ``` ## 3. "Một ngày của squad" (luồng dệt mọi mảnh) diff --git a/hatch/internal/cli/integration_test.go b/hatch/internal/cli/integration_test.go new file mode 100644 index 0000000..ec2358a --- /dev/null +++ b/hatch/internal/cli/integration_test.go @@ -0,0 +1,89 @@ +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", "-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) + } + + status, _ := run(t, "status") + if !strings.Contains(status, "T-001") || !strings.Contains(status, "in-progress") { + t.Fatalf("status wrong:\n%s", status) + } + + 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/orchestrate.go b/hatch/internal/cli/orchestrate.go index 9643d18..da10c50 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "os" "sort" "sync" "time" @@ -11,6 +12,7 @@ import ( "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" @@ -87,7 +89,7 @@ func overWIP(a model.Agent, load map[string]int) bool { } func newRunCmd() *cobra.Command { - var agentID string + var agentID, muxKind string var dryRun, claim, worktree, noCatchUp bool var timeout time.Duration cmd := &cobra.Command{ @@ -130,6 +132,19 @@ func newRunCmd() *cobra.Command { } 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 := orchestrator.Run(ws, agent, t, t.Role, orchestrator.RunOptions{ DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), SkipComms: noCatchUp, @@ -144,6 +159,7 @@ func newRunCmd() *cobra.Command { }, } 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") diff --git a/hatch/internal/mux/mux.go b/hatch/internal/mux/mux.go new file mode 100644 index 0000000..1c464b2 --- /dev/null +++ b/hatch/internal/mux/mux.go @@ -0,0 +1,69 @@ +// 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..25f07ae --- /dev/null +++ b/hatch/internal/mux/mux_test.go @@ -0,0 +1,30 @@ +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/store/ledger.go b/hatch/internal/store/ledger.go index ef65491..23d83f5 100644 --- a/hatch/internal/store/ledger.go +++ b/hatch/internal/store/ledger.go @@ -5,12 +5,17 @@ import ( "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 } @@ -74,6 +79,8 @@ func (lg *Ledger) Append(e model.Entry) error { 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 } From c1dc7ca3db22cd8791fc252a7f6f420979df0db1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 14 Jun 2026 17:23:13 +0000 Subject: [PATCH 33/60] Add hatch doctor + going-real guide (readiness for real agents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `hatch doctor`: one-shot readiness check — config valid, compiled fresh, and per-agent whether its CLI is on PATH + whether its credential env var is set (mock/manual exempt). Non-zero exit when not ready. - docs/19-going-real.md: switch a workspace from mock to real agent CLIs — install, set kinds/model/sandbox, env credentials, doctor, run + observe (logs/board/--mux), safety (sandbox, human-merge, no-self-review, budget). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 3 +- hatch/docs/19-going-real.md | 63 +++++++++++++++++++++++ hatch/internal/cli/doctor.go | 97 ++++++++++++++++++++++++++++++++++++ hatch/internal/cli/root.go | 1 + 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 hatch/docs/19-going-real.md create mode 100644 hatch/internal/cli/doctor.go diff --git a/hatch/README.md b/hatch/README.md index 9116a4b..69e9248 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -81,7 +81,8 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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) | (thiết kế) Quan sát agents: transcript · TUI · tmux/Zellij | +| 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) | 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). diff --git a/hatch/docs/19-going-real.md b/hatch/docs/19-going-real.md new file mode 100644 index 0000000..f86f579 --- /dev/null +++ b/hatch/docs/19-going-real.md @@ -0,0 +1,63 @@ +# 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 +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)) +- **Gemini CLI** — `gemini` ([github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)) +- **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|gemini|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. Cấp credential qua ENV (không bao giờ trong repo) +```bash +export ANTHROPIC_API_KEY=... # claude +export OPENAI_API_KEY=... # codex +export GEMINI_API_KEY=... # gemini +export KIRO_API_KEY=... # kiro (headless) +``` + +## 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/internal/cli/doctor.go b/hatch/internal/cli/doctor.go new file mode 100644 index 0000000..658f2b9 --- /dev/null +++ b/hatch/internal/cli/doctor.go @@ -0,0 +1,97 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/compile" +) + +// envForKind names the credential env var each agent kind expects (best-effort). +var envForKind = map[string]string{ + "claude": "ANTHROPIC_API_KEY", + "codex": "OPENAI_API_KEY", + "gemini": "GEMINI_API_KEY", + "kiro": "KIRO_API_KEY", +} + +// defaultCmdForKind is the executable each kind drives. +var defaultCmdForKind = map[string]string{ + "claude": "claude", "codex": "codex", "gemini": "gemini", "kiro": "kiro-cli", "mock": "hatch-mock", +} + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check readiness: config, compiled freshness, agent CLIs + credentials", + 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") + + // Config validity. + 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") + } + + // Compiled freshness. + 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: CLI on PATH + credential env. + fmt.Fprintln(out, "\nAgents:") + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, " AGENT\tKIND\tCLI\tCREDENTIAL") + for _, a := range ws.Registry.Agents { + bin := a.Cmd + if bin == "" { + bin = defaultCmdForKind[a.Kind] + } + cli := "n/a" + if bin != "" { + if _, err := exec.LookPath(bin); err == nil { + cli = "✓ " + bin + } else { + cli = "✗ " + bin + " not on PATH" + if a.Kind != "mock" && a.Kind != "manual" { + issues++ + } + } + } + cred := "—" + if ev := envForKind[a.Kind]; ev != "" { + if os.Getenv(ev) != "" { + cred = "✓ " + ev + } else { + cred = "✗ " + ev + " unset" + } + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", a.ID, a.Kind, cli, cred) + } + tw.Flush() + + fmt.Fprintln(out) + if issues == 0 { + fmt.Fprintln(out, "✓ ready") + return nil + } + fmt.Fprintf(out, "%d issue(s) — see above. (mock agents need no CLI/credential.)\n", issues) + return fmt.Errorf("%d readiness issue(s)", issues) + }, + } +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 3b31dc9..86cd65a 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -60,6 +60,7 @@ func NewRoot() *cobra.Command { newOrgCmd(), newReportCmd(), newTickCmd(), + newDoctorCmd(), ) return root } From 8f2a1646c5e146c863d648d8e2b16eb67d8e6932 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 00:48:08 +0000 Subject: [PATCH 34/60] Add hatch chat: Slack-style TUI for agent communication A second TUI alongside `hatch board`: CHANNELS list + live messages pane (auto-refresh, scrollable) + compose line (press i, Enter to send; @mentions auto-tag). Posts as --as identity (default human:operator). This is the "Slack window" for the squad's bus; docs/11 + overview + README updated. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 3 +- hatch/docs/11-communication.md | 4 + hatch/docs/overview.md | 4 +- hatch/go.mod | 1 + hatch/go.sum | 2 + hatch/internal/cli/board.go | 18 +++ hatch/internal/cli/root.go | 1 + hatch/internal/tui/chat.go | 219 +++++++++++++++++++++++++++++++++ 8 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 hatch/internal/tui/chat.go diff --git a/hatch/README.md b/hatch/README.md index 69e9248..91ea3cc 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -114,7 +114,8 @@ bin/hatch kb query export bin/hatch run T-001 --claim --dry-run # build invocation; bỏ --dry-run để chạy thật bin/hatch plan --dry-run # spawn Conductor bẻ việc bin/hatch watch --dry-run --max 3 # gán + chạy backlog (tôn trọng WIP) -bin/hatch board # TUI dashboard +bin/hatch board # TUI dashboard (board + live output + activity) +bin/hatch chat # TUI giao tiếp kiểu Slack (channels + messages + soạn) # Giao tiếp trực tiếp giữa agent (Slack-style — docs/11) bin/hatch msg --from codex --to '#design,reviewer' --channel '#design' "Streaming hay buffer?" bin/hatch inbox claude-code --mark # message gửi tới mình (id + vai + #channel + *) diff --git a/hatch/docs/11-communication.md b/hatch/docs/11-communication.md index f750d12..a4e061a 100644 --- a/hatch/docs/11-communication.md +++ b/hatch/docs/11-communication.md @@ -93,6 +93,10 @@ hatch convene --topic "Thiết kế export API" --agents claude-code,codex,kiro ``` 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ẻ. diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md index 9668615..5d81808 100644 --- a/hatch/docs/overview.md +++ b/hatch/docs/overview.md @@ -32,7 +32,7 @@ Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs │ COLLAB ✓ pairing(driver/navigator) · mob(driver xoay vòng) │ │ ỨNG CỨU ✓ escalation · on-call rotation · incident workflow │ │ CAPACITY ✓ presence(available/busy/paused/offline) → phân việc theo tải │ -│ OBSERVE ✓ transcript mỗi run + hatch logs -f · TUI board (board+live+feed) │ +│ OBSERVE ✓ transcript + hatch logs -f · TUI board(board+live+feed) · chat │ │ ✓ --mux=tmux|zellij (mỗi run một pane thật) │ │ DOCS/KB ✓ doc templates (new/lint) · Obsidian KB (link/backlinks/graph) │ │ ── lớp QUẢN TRỊ (Founder/CEO/CTO) ────────────────────────────────────────────── │ @@ -53,7 +53,7 @@ BOARD/WORK ticket new|claim|move|show|extdep · status · standup · gate ORCH/OBSERVE run [--claim --worktree --mux=tmux|zellij] · plan · watch [--parallel N] tick · logs [-f] · board (TUI) KNOWLEDGE kb add|query|index|link|backlinks|graph|open · doc types|new|lint -COMMUNICATION msg · inbox · thread · channel ls|show|join|leave|members · search +COMMUNICATION msg · inbox · thread · channel ls|show|join|leave|members · search · chat(TUI) DIALOGUE ask · convene [--decider] · escalate COLLAB pair · mob CEREMONY ceremony standup|retro|planning|demo|grooming diff --git a/hatch/go.mod b/hatch/go.mod index 560bf5d..bbb6d0e 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -11,6 +11,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect 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 diff --git a/hatch/go.sum b/hatch/go.sum index d823c34..f79cba2 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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= diff --git a/hatch/internal/cli/board.go b/hatch/internal/cli/board.go index 829b7d5..17d40c8 100644 --- a/hatch/internal/cli/board.go +++ b/hatch/internal/cli/board.go @@ -21,3 +21,21 @@ func newBoardCmd() *cobra.Command { } return cmd } + +func newChatCmd() *cobra.Command { + var as string + cmd := &cobra.Command{ + Use: "chat", + Short: "Slack-style TUI for agent communication (channels, messages, compose)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + _, err = tui.NewChat(ws, as).Run() + return err + }, + } + cmd.Flags().StringVar(&as, "as", "human:operator", "identity to post messages as") + return cmd +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 86cd65a..51eab0a 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -36,6 +36,7 @@ func NewRoot() *cobra.Command { newPlanCmd(), newWatchCmd(), newBoardCmd(), + newChatCmd(), newSyncCmd(), newHookCmd(), newMsgCmd(), diff --git a/hatch/internal/tui/chat.go b/hatch/internal/tui/chat.go new file mode 100644 index 0000000..3c8c9d1 --- /dev/null +++ b/hatch/internal/tui/chat.go @@ -0,0 +1,219 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + "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 + input textinput.Model + as string + composing bool + w, h int + status string + ready bool +} + +// NewChat returns a Slack-style TUI for the agent communication bus. +func NewChat(ws *config.Workspace, as string) *tea.Program { + if as == "" { + as = "human:operator" + } + ti := textinput.New() + ti.Placeholder = "message (@mention để tag) — Enter gửi, Esc huỷ" + ti.CharLimit = 2000 + c := &chat{ws: ws, bus: bus.New(ws.Layout), as: as, input: ti} + 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ó channel — gửi message để tạo)") + } + msgs, err := c.bus.Messages(ch) + if err != nil || len(msgs) == 0 { + return dim.Render("(channel 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-7) + c.input.Width = mw - 4 + 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.composing { + c.reload() + c.msgs.GotoBottom() + } + return c, tick() + case tea.KeyMsg: + if c.composing { + switch msg.String() { + case "esc": + c.composing = false + c.input.Blur() + c.input.SetValue("") + case "enter": + if v := strings.TrimSpace(c.input.Value()); v != "" && c.current() != "" { + _, err := c.bus.Post(bus.Message{Channel: c.current(), From: c.as, To: []string{c.current()}, Body: v}) + if err != nil { + c.status = "lỗi gửi: " + err.Error() + } else { + c.status = "đã gửi tới " + c.current() + } + } + c.composing = false + c.input.Blur() + c.input.SetValue("") + c.reload() + c.msgs.GotoBottom() + default: + var cmd tea.Cmd + c.input, cmd = c.input.Update(msg) + return c, cmd + } + return c, nil + } + switch msg.String() { + case "q", "ctrl+c": + 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 "i": + if c.current() != "" { + c.composing = true + c.input.Focus() + } + 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("as "+c.as) + + // 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) + + var foot string + if c.composing { + foot = c.input.View() + } else { + foot = dim.Render("tab pane · ↑/↓ channel·scroll · i soạn · g refresh · q quit") + if c.status != "" { + foot = selSty.Render(c.status) + " " + foot + } + } + return header + "\n" + body + "\n" + foot +} + +func maxi(a, b int) int { + if a > b { + return a + } + return b +} From 6b5dbb8ad5419180d86700154d5efff14944161d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 00:59:09 +0000 Subject: [PATCH 35/60] Merge chat into board: unified 4-pane mission-control TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hatch board` now shows BOARD + LIVE (run transcript) + ACTIVITY (ledger) + CHAT (the communication bus) in one process. Keys: tab switch pane, ↑/↓ move/scroll, f follow ticket, r run, c cycle channel, i compose+send (@mentions auto-tag), g refresh, q quit. `hatch chat` stays as a focused chat-only view. Docs/overview/README updated. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 4 +- hatch/docs/18-observability.md | 2 +- hatch/docs/overview.md | 2 +- hatch/internal/cli/board.go | 2 +- hatch/internal/tui/board.go | 197 +++++++++++++++++++++++++++------ 5 files changed, 166 insertions(+), 41 deletions(-) diff --git a/hatch/README.md b/hatch/README.md index 91ea3cc..32094b7 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -114,8 +114,8 @@ bin/hatch kb query export bin/hatch run T-001 --claim --dry-run # build invocation; bỏ --dry-run để chạy thật bin/hatch plan --dry-run # spawn Conductor bẻ việc bin/hatch watch --dry-run --max 3 # gán + chạy backlog (tôn trọng WIP) -bin/hatch board # TUI dashboard (board + live output + activity) -bin/hatch chat # TUI giao tiếp kiểu Slack (channels + messages + soạn) +bin/hatch board # mission-control TUI 4 pane: board + live output + ledger + chat +bin/hatch chat # (tuỳ chọn) TUI chat-only kiểu Slack # Giao tiếp trực tiếp giữa agent (Slack-style — docs/11) bin/hatch msg --from codex --to '#design,reviewer' --channel '#design' "Streaming hay buffer?" bin/hatch inbox claude-code --mark # message gửi tới mình (id + vai + #channel + *) diff --git a/hatch/docs/18-observability.md b/hatch/docs/18-observability.md index 91da00e..4b06653 100644 --- a/hatch/docs/18-observability.md +++ b/hatch/docs/18-observability.md @@ -11,7 +11,7 @@ Hai cái này cần cơ chế khác nhau → có **hai tầng quan sát**, khôn ## 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, nhiều pane nội bộ**: +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│ diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md index 5d81808..ff9d0ac 100644 --- a/hatch/docs/overview.md +++ b/hatch/docs/overview.md @@ -32,7 +32,7 @@ Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs │ COLLAB ✓ pairing(driver/navigator) · mob(driver xoay vòng) │ │ ỨNG CỨU ✓ escalation · on-call rotation · incident workflow │ │ CAPACITY ✓ presence(available/busy/paused/offline) → phân việc theo tải │ -│ OBSERVE ✓ transcript + hatch logs -f · TUI board(board+live+feed) · chat │ +│ OBSERVE ✓ transcript + hatch logs -f · TUI board (4 pane: board+live+ledger+chat)│ │ ✓ --mux=tmux|zellij (mỗi run một pane thật) │ │ DOCS/KB ✓ doc templates (new/lint) · Obsidian KB (link/backlinks/graph) │ │ ── lớp QUẢN TRỊ (Founder/CEO/CTO) ────────────────────────────────────────────── │ diff --git a/hatch/internal/cli/board.go b/hatch/internal/cli/board.go index 17d40c8..5637f78 100644 --- a/hatch/internal/cli/board.go +++ b/hatch/internal/cli/board.go @@ -9,7 +9,7 @@ import ( func newBoardCmd() *cobra.Command { cmd := &cobra.Command{ Use: "board", - Short: "Interactive TUI board dashboard", + Short: "Unified mission-control TUI (board + live output + ledger + chat)", RunE: func(cmd *cobra.Command, args []string) error { ws, err := loadWorkspace() if err != nil { diff --git a/hatch/internal/tui/board.go b/hatch/internal/tui/board.go index 83da89f..9f683e6 100644 --- a/hatch/internal/tui/board.go +++ b/hatch/internal/tui/board.go @@ -1,6 +1,7 @@ -// Package tui renders an interactive mission-control dashboard with Bubble Tea: -// the board, live agent output (run transcripts) and an activity feed in one -// multi-pane view, plus the ability to launch a run on the selected ticket. +// Package tui renders interactive dashboards with Bubble Tea. `hatch board` is +// the unified mission control: BOARD + LIVE agent output + ACTIVITY (ledger) + +// CHAT (the communication bus), in one process. `hatch chat` (chat.go) is a +// focused stand-alone chat view sharing the same widgets. package tui import ( @@ -12,10 +13,12 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/textinput" "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/model" "github.com/fioenix/overclaud/hatch/internal/orchestrator" @@ -29,6 +32,8 @@ const ( paneBoard pane = iota paneLive paneActivity + paneChat + numPanes ) var ( @@ -46,18 +51,28 @@ type tref struct { } type m struct { - ws *config.Workspace - board *store.Board - ledger *store.Ledger + ws *config.Workspace + board *store.Board + ledger *store.Ledger + bus *bus.Bus + refs []tref sel int focus pane live viewport.Model activity viewport.Model + chat viewport.Model liveTick string - w, h int - status string - ready bool + + channels []string + chatSel int + input textinput.Model + composing bool + chatAs string + + w, h int + status string + ready bool } type tickMsg time.Time @@ -70,9 +85,15 @@ func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } -// New returns a Bubble Tea program for the mission-control dashboard. +// New returns the unified mission-control dashboard program. func New(ws *config.Workspace) *tea.Program { - mm := m{ws: ws, board: store.NewBoard(ws.Layout), ledger: store.NewLedger(ws.Layout)} + ti := textinput.New() + ti.Placeholder = "message (@mention để tag) — Enter gửi, Esc huỷ" + ti.CharLimit = 2000 + mm := m{ + ws: ws, board: store.NewBoard(ws.Layout), ledger: store.NewLedger(ws.Layout), + bus: bus.New(ws.Layout), input: ti, chatAs: "human:operator", + } return tea.NewProgram(&mm, tea.WithAltScreen()) } @@ -93,8 +114,22 @@ func (mm *m) reload() { if mm.liveTick == "" && len(refs) > 0 { mm.liveTick = refs[mm.sel].t.ID } + chs, _ := mm.bus.Channels() + sort.Strings(chs) + mm.channels = chs + if mm.chatSel >= len(chs) { + mm.chatSel = max(0, len(chs)-1) + } mm.live.SetContent(mm.transcript(mm.liveTick)) mm.activity.SetContent(mm.activityFeed()) + mm.chat.SetContent(mm.chatFeed()) +} + +func (mm *m) curChannel() string { + if mm.chatSel < len(mm.channels) { + return mm.channels[mm.chatSel] + } + return "" } func (mm *m) transcript(ticket string) string { @@ -133,12 +168,40 @@ func (mm *m) activityFeed() string { return strings.Join(lines, "\n") } +func (mm *m) chatFeed() string { + ch := mm.curChannel() + if ch == "" { + return dim.Render("(chưa có channel — nhấn i để gửi, c để đổi channel)") + } + msgs, err := mm.bus.Messages(ch) + if err != nil || len(msgs) == 0 { + return dim.Render("(channel 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() { - leftW := mm.w/2 - 2 - rightW := mm.w - leftW - 4 - vpH := (mm.h - 6) / 2 + rightW := mm.w - mm.w/2 - 4 + vpH := (mm.h - 9) / 3 + if vpH < 3 { + vpH = 3 + } mm.live = viewport.New(rightW, vpH) mm.activity = viewport.New(rightW, vpH) + mm.chat = viewport.New(rightW, vpH) + mm.input.Width = rightW - 4 mm.ready = true } @@ -149,8 +212,9 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mm.layout() mm.reload() case tickMsg: - if mm.ready { + if mm.ready && !mm.composing { mm.reload() + mm.chat.GotoBottom() } return mm, tick() case ranMsg: @@ -160,26 +224,27 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mm.status = "run " + msg.ticket + " xong" } case tea.KeyMsg: + if mm.composing { + return mm.updateCompose(msg) + } switch msg.String() { case "q", "ctrl+c", "esc": return mm, tea.Quit case "tab": - mm.focus = (mm.focus + 1) % 3 + mm.focus = (mm.focus + 1) % numPanes case "up", "k": - if mm.focus == paneBoard && mm.sel > 0 { - mm.sel-- - } else if mm.focus == paneLive { - mm.live.LineUp(1) - } else if mm.focus == paneActivity { - mm.activity.LineUp(1) - } + mm.scrollUp() case "down", "j": - if mm.focus == paneBoard && mm.sel < len(mm.refs)-1 { - mm.sel++ - } else if mm.focus == paneLive { - mm.live.LineDown(1) - } else if mm.focus == paneActivity { - mm.activity.LineDown(1) + mm.scrollDown() + case "c": + if mm.focus == paneChat && len(mm.channels) > 0 { + mm.chatSel = (mm.chatSel + 1) % len(mm.channels) + mm.chat.SetContent(mm.chatFeed()) + } + case "i": + if mm.focus == paneChat && mm.curChannel() != "" { + mm.composing = true + mm.input.Focus() } case "f": if mm.focus == paneBoard && mm.sel < len(mm.refs) { @@ -197,7 +262,63 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return mm, nil } -// runSelected launches an agent on the selected ticket in the background. +func (mm *m) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + mm.composing = false + mm.input.Blur() + mm.input.SetValue("") + case "enter": + if v := strings.TrimSpace(mm.input.Value()); v != "" && mm.curChannel() != "" { + if _, err := mm.bus.Post(bus.Message{Channel: mm.curChannel(), From: mm.chatAs, To: []string{mm.curChannel()}, Body: v}); err != nil { + mm.status = "lỗi gửi: " + err.Error() + } else { + mm.status = "đã gửi tới " + mm.curChannel() + } + } + mm.composing = false + mm.input.Blur() + mm.input.SetValue("") + mm.chat.SetContent(mm.chatFeed()) + mm.chat.GotoBottom() + default: + var cmd tea.Cmd + mm.input, cmd = mm.input.Update(msg) + return mm, cmd + } + return mm, nil +} + +func (mm *m) scrollUp() { + switch mm.focus { + case paneBoard: + if mm.sel > 0 { + mm.sel-- + } + case paneLive: + mm.live.LineUp(1) + case paneActivity: + mm.activity.LineUp(1) + case paneChat: + mm.chat.LineUp(1) + } +} + +func (mm *m) scrollDown() { + switch mm.focus { + case paneBoard: + if mm.sel < len(mm.refs)-1 { + mm.sel++ + } + case paneLive: + mm.live.LineDown(1) + case paneActivity: + mm.activity.LineDown(1) + case paneChat: + mm.chat.LineDown(1) + } +} + func (mm *m) runSelected() tea.Cmd { ref := mm.refs[mm.sel] mm.liveTick = ref.t.ID @@ -233,21 +354,26 @@ func (mm *m) View() string { curLane = r.lane bd.WriteString(laneSty.Render(curLane) + "\n") } - line := fmt.Sprintf(" %-7s %-11s %s", r.t.ID, r.t.Assignee, trunc(r.t.Title, 22)) + line := fmt.Sprintf(" %-7s %-11s %s", r.t.ID, r.t.Assignee, trunc(r.t.Title, 20)) if i == mm.sel { line = selSty.Render("▸ " + strings.TrimLeft(line, " ")) } bd.WriteString(line + "\n") } - boardBox := paneBox(mm.focus == paneBoard, "BOARD", bd.String(), mm.w/2-2, mm.h-6) + boardBox := paneBox(mm.focus == paneBoard, "BOARD", bd.String(), mm.w/2-2, mm.h-4) - mm.live.SetContent(mm.transcript(mm.liveTick)) liveBox := paneBox(mm.focus == paneLive, "LIVE · "+mm.liveTick, mm.live.View(), 0, 0) actBox := paneBox(mm.focus == paneActivity, "ACTIVITY (ledger)", mm.activity.View(), 0, 0) - right := lipgloss.JoinVertical(lipgloss.Left, liveBox, actBox) + chatTitle := "CHAT · #" + mm.curChannel() + chatBody := mm.chat.View() + if mm.composing { + chatBody += "\n" + mm.input.View() + } + chatBox := paneBox(mm.focus == paneChat, chatTitle, chatBody, 0, 0) + right := lipgloss.JoinVertical(lipgloss.Left, liveBox, actBox, chatBox) body := lipgloss.JoinHorizontal(lipgloss.Top, boardBox, right) - foot := dim.Render("tab pane · ↑/↓ move·scroll · f follow · r run · g refresh · q quit") + foot := dim.Render("tab pane · ↑/↓ move·scroll · f follow · r run · c channel · i chat · g refresh · q quit") if mm.status != "" { foot = selSty.Render(mm.status) + " " + foot } @@ -268,7 +394,6 @@ func paneBox(focus bool, title, content string, w, h int) string { return style.Render(laneSty.Render(title) + "\n" + content) } -// pick chooses an available agent for a role (presence-aware, first match). func pick(ws *config.Workspace, role string) (model.Agent, bool) { pres := presence.Load(ws.Layout) for _, a := range ws.Registry.AgentsForRole(role) { From 75b117644369cd7003f71d24a9c99be6a2c468c6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 03:16:37 +0000 Subject: [PATCH 36/60] Security review fixes + open-source standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Fix path traversal: paths.SafeSegment sanitizes any string used as a path segment; Runs()/run transcripts no longer let `hatch logs ../../x` (or a crafted ticket/agent id) escape .hatch. Tests added. - hatch validate now rejects unsafe ticket ids (chars outside [A-Za-z0-9._-]), which also keeps branches/worktrees/transcript paths safe. Test added. - Document trust boundaries in SECURITY.md: gate commands (sh -c from workflow.yaml), agent exec (no shell — args passed directly), credentials (env-only, never persisted), transcript capture caveat, path safety. Open-source standards: - Add CODE_OF_CONDUCT.md (Contributor Covenant 2.1) - CONTRIBUTING.md: add a Hatch (Go) dev + PR-standards section - SECURITY.md: add a Hatch section + reporting channel Audit notes (no code change needed): agent spawning uses exec without a shell; no secrets in code/config (grep-clean); credentials read from env at spawn; ledger appends are mutex-serialized for parallel runs. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- CODE_OF_CONDUCT.md | 45 +++++++++++++++++++++++ CONTRIBUTING.md | 25 ++++++++++++- SECURITY.md | 28 ++++++++++++++ hatch/internal/orchestrator/transcript.go | 7 +--- hatch/internal/paths/paths.go | 23 +++++++++++- hatch/internal/paths/paths_test.go | 33 +++++++++++++++++ hatch/internal/validate/board.go | 8 ++++ hatch/internal/validate/board_test.go | 33 +++++++++++++++++ 8 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 hatch/internal/paths/paths_test.go create mode 100644 hatch/internal/validate/board_test.go 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/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/internal/orchestrator/transcript.go b/hatch/internal/orchestrator/transcript.go index 12f3b6b..3f23c2b 100644 --- a/hatch/internal/orchestrator/transcript.go +++ b/hatch/internal/orchestrator/transcript.go @@ -10,10 +10,7 @@ import ( // transcriptDir is where per-run logs live, under .hatch/runs/<ticket>/. func transcriptDir(l paths.Layout, ticket string) string { - if ticket == "" || ticket == "-" { - ticket = "system" - } - return filepath.Join(l.Root, "runs", ticket) + return l.Runs(ticket) } // openTranscript creates an append log file for a run and writes a header. @@ -23,7 +20,7 @@ func openTranscript(l paths.Layout, ticket, agent string) (*os.File, error) { return nil, err } ts := time.Now().Format("20060102-150405") - f, err := os.OpenFile(filepath.Join(dir, ts+"-"+agent+".log"), + 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 diff --git a/hatch/internal/paths/paths.go b/hatch/internal/paths/paths.go index 90ed767..b2563e9 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -56,11 +56,32 @@ 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", ticket) + return l.path("runs", SafeSegment(ticket)) } // RepoRoot is the directory that contains the .hatch directory. diff --git a/hatch/internal/paths/paths_test.go b/hatch/internal/paths/paths_test.go new file mode 100644 index 0000000..30ac991 --- /dev/null +++ b/hatch/internal/paths/paths_test.go @@ -0,0 +1,33 @@ +package paths + +import ( + "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) + } +} diff --git a/hatch/internal/validate/board.go b/hatch/internal/validate/board.go index 61277ac..5b71ba0 100644 --- a/hatch/internal/validate/board.go +++ b/hatch/internal/validate/board.go @@ -4,11 +4,16 @@ 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 @@ -27,6 +32,9 @@ func Board(ws *config.Workspace, b *store.Board) []config.Problem { 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)) } 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) + } +} From 1c838a6646fa21439bbad7c1cd4d0efd0a2de368 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 03:17:12 +0000 Subject: [PATCH 37/60] Harden worktree path against traversal (SafeSegment ticket id) https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/orchestrator/worktree.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/hatch/internal/orchestrator/worktree.go b/hatch/internal/orchestrator/worktree.go index 34b2928..bea1ede 100644 --- a/hatch/internal/orchestrator/worktree.go +++ b/hatch/internal/orchestrator/worktree.go @@ -5,6 +5,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/fioenix/overclaud/hatch/internal/paths" ) // WorktreeDir is where per-ticket git worktrees are created (under .hatch). @@ -13,9 +15,10 @@ 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) { - path := filepath.Join(repoRoot, WorktreeDir, ticketID) + safe := paths.SafeSegment(ticketID) // defense-in-depth against path traversal + path := filepath.Join(repoRoot, WorktreeDir, safe) if branch == "" { - branch = "hatch/" + ticketID + branch = "hatch/" + safe } // Create the branch if it does not exist, then add the worktree. args := []string{"worktree", "add"} From b92378a5881b1033aae16e3f2e9f6168c6d850f3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 05:00:50 +0000 Subject: [PATCH 38/60] Refactor to Lean Hexagonal: ports for the gate runner and workflow store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert dependencies at the key infrastructure seams so the use-case core depends on ports, not concrete infra: - gate.Runner port + ShellRunner adapter; Evaluator injects it. Command execution is now swappable/testable (fake-runner test added). Package-level Evaluate/EvaluateAll stay as thin wrappers over the Default evaluator. - wf engine depends on wf.Board / wf.Ledger interfaces instead of *store.Board / *store.Ledger; `internal/wf` no longer imports `internal/store`. Escalate takes the board via the port (no internal store construction). - ARCHITECTURE.md documents the layering (domain → use cases → ports → adapters → driving adapters), the dependency rule, and the deliberate Lean non-ports (filesystem-as-DB is the product). All 18 packages pass; orchestrator.Adapter (+mock) already realised ports & adapters at the agent boundary. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/ARCHITECTURE.md | 72 ++++++++++++++++++++++++++++++ hatch/README.md | 2 +- hatch/internal/cli/escalate.go | 2 +- hatch/internal/gate/gate.go | 50 ++++++++++++++++----- hatch/internal/gate/gate_test.go | 34 ++++++++++++++ hatch/internal/wf/engine.go | 7 ++- hatch/internal/wf/escalate.go | 9 ++-- hatch/internal/wf/escalate_test.go | 2 +- hatch/internal/wf/ports.go | 21 +++++++++ 9 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 hatch/ARCHITECTURE.md create mode 100644 hatch/internal/gate/gate_test.go create mode 100644 hatch/internal/wf/ports.go diff --git a/hatch/ARCHITECTURE.md b/hatch/ARCHITECTURE.md new file mode 100644 index 0000000..7587d0d --- /dev/null +++ b/hatch/ARCHITECTURE.md @@ -0,0 +1,72 @@ +# 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. 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** | `wf.Board`, `wf.Ledger`, `gate.Runner`, `orchestrator.Adapter` | Interfaces declared at the consumer (Go idiom). | +| **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 + +- **`wf.Board` / `wf.Ledger`** — the workflow engine (the core use case) operates + on these interfaces, not on `*store.Board`/`*store.Ledger`. `internal/store` + satisfies them structurally. The engine package imports **no infrastructure** + (`internal/wf` does not import `internal/store`). +- **`gate.Runner`** — gate command execution sits behind a port; `ShellRunner` + is the production adapter, fakes are used in tests (no real `sh`). +- **`orchestrator.Adapter`** — each agent CLI (Claude/Codex/Gemini/Kiro) is an + adapter that builds a headless invocation; `mock` is the test/demo adapter. + This is textbook ports & adapters at the agent boundary. + +## 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. +- `bus`, `presence`, `oncall` — small filesystem adapters used directly by + use-case services acting as coordinators. + +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/README.md b/hatch/README.md index 32094b7..4d21de9 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -138,4 +138,4 @@ bin/hatch oncall set --rotation claude-code,codex # lịch trực; escala - **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/Gemini/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. - **Phase 3 — xong (cơ chế):** orchestrator + adapter cho từng agent (`hatch run`/`plan`/`watch`) dựng invocation headless đúng theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard (`hatch board`). Adapter `kiro`/`antigravity`/`manual` rơi về handoff khi không có headless. `--dry-run` cho phép kiểm tra invocation mà không cần cài agent. -Có unit + integration test, CI, Makefile. Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. +Có unit + integration test, CI, Makefile. Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. Kiến trúc (Lean Hexagonal, ports & adapters): [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go index 4d2d2d1..cbcf131 100644 --- a/hatch/internal/cli/escalate.go +++ b/hatch/internal/cli/escalate.go @@ -23,7 +23,7 @@ func newEscalateCmd() *cobra.Command { if why == "" { return fmt.Errorf("--why is required") } - if err := wf.Escalate(ws, store.NewLedger(ws.Layout), args[0], from, why); err != nil { + if err := wf.Escalate(ws, store.NewBoard(ws.Layout), store.NewLedger(ws.Layout), args[0], from, why); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "escalated %s → %s\n", args[0], wf.EscalateTarget(ws)) diff --git a/hatch/internal/gate/gate.go b/hatch/internal/gate/gate.go index 6fb0931..b388662 100644 --- a/hatch/internal/gate/gate.go +++ b/hatch/internal/gate/gate.go @@ -1,5 +1,8 @@ // 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 ( @@ -11,6 +14,25 @@ import ( "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 @@ -21,8 +43,8 @@ type Outcome struct { } // Evaluate runs a single named gate for a ticket. Command gates execute in -// repoRoot. Human gates never auto-pass; they return Human=true. -func Evaluate(ws *config.Workspace, name string, t model.Ticket, repoRoot string) Outcome { +// 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"} @@ -30,7 +52,7 @@ func Evaluate(ws *config.Workspace, name string, t model.Ticket, repoRoot string o := Outcome{Name: name, Type: g.Type} switch g.Type { case model.GateCommand: - out, err := runShell(g.Run, repoRoot) + out, err := e.Runner.Run(g.Run, repoRoot) o.Passed = err == nil o.Detail = tail(out, 400) if err != nil && o.Detail == "" { @@ -57,20 +79,26 @@ func Evaluate(ws *config.Workspace, name string, t model.Ticket, repoRoot string return o } -// EvaluateAll runs the gates a transition declares and returns the outcomes. -func EvaluateAll(ws *config.Workspace, names []string, t model.Ticket, repoRoot string) []Outcome { +// 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, Evaluate(ws, n, t, repoRoot)) + outs = append(outs, e.Evaluate(ws, n, t, repoRoot)) } return outs } -func runShell(cmdline, dir string) (string, error) { - c := exec.Command("sh", "-c", cmdline) - c.Dir = dir - out, err := c.CombinedOutput() - return string(out), err +// 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 { diff --git a/hatch/internal/gate/gate_test.go b/hatch/internal/gate/gate_test.go new file mode 100644 index 0000000..27b6143 --- /dev/null +++ b/hatch/internal/gate/gate_test.go @@ -0,0 +1,34 @@ +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/wf/engine.go b/hatch/internal/wf/engine.go index fdc982b..0dc0bc0 100644 --- a/hatch/internal/wf/engine.go +++ b/hatch/internal/wf/engine.go @@ -11,7 +11,6 @@ import ( "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/store" ) // MoveOptions parameterise a transition. @@ -35,7 +34,7 @@ type Result struct { } // Move validates and performs a transition for ticketID. -func Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID string, opt MoveOptions) (*Result, error) { +func Move(ws *config.Workspace, b Board, lg Ledger, ticketID string, opt MoveOptions) (*Result, error) { if strings.TrimSpace(opt.Why) == "" { return nil, fmt.Errorf("a reason (--why) is required") } @@ -89,7 +88,7 @@ func Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID strin } if !o.Human && !o.Passed { _ = lg.Append(gateEntry(opt, t, from, "failed", "gate "+o.Name+" failed: "+o.Detail)) - maybeEscalate(ws, lg, t.ID) + maybeEscalate(ws, b, lg, t.ID) return nil, fmt.Errorf("gate %q failed: %s", o.Name, o.Detail) } } @@ -148,7 +147,7 @@ func Move(ws *config.Workspace, b *store.Board, lg *store.Ledger, ticketID strin return &Result{Ticket: t, From: from, To: opt.To, Action: action, Outcomes: outcomes}, nil } -func checkDependencies(ws *config.Workspace, b *store.Board, t model.Ticket) error { +func checkDependencies(ws *config.Workspace, b Board, t model.Ticket) error { if len(t.DependsOn) == 0 { return nil } diff --git a/hatch/internal/wf/escalate.go b/hatch/internal/wf/escalate.go index d4c326b..94d88f3 100644 --- a/hatch/internal/wf/escalate.go +++ b/hatch/internal/wf/escalate.go @@ -8,7 +8,6 @@ import ( "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/store" ) // escalationThreshold is the number of gate failures on a ticket that triggers @@ -43,9 +42,9 @@ func EscalateTargetForRole(ws *config.Workspace, role string) string { // Escalate records an escalation in the ledger and notifies the target on the // #escalations channel. -func Escalate(ws *config.Workspace, lg *store.Ledger, ticket, from, why string) error { +func Escalate(ws *config.Workspace, b Board, lg Ledger, ticket, from, why string) error { role := "" - if t, ok, _ := store.NewBoard(ws.Layout).Find(ticket, ws.Workflow.LaneIDs()); ok { + if t, ok, _ := b.Find(ticket, ws.Workflow.LaneIDs()); ok { role = t.Role } target := EscalateTargetForRole(ws, role) @@ -67,7 +66,7 @@ func Escalate(ws *config.Workspace, lg *store.Ledger, ticket, from, why string) // maybeEscalate auto-escalates when a ticket has hit the gate-failure threshold // and hasn't already been escalated. -func maybeEscalate(ws *config.Workspace, lg *store.Ledger, ticket string) { +func maybeEscalate(ws *config.Workspace, b Board, lg Ledger, ticket string) { entries, err := lg.Recent(1) if err != nil { return @@ -85,7 +84,7 @@ func maybeEscalate(ws *config.Workspace, lg *store.Ledger, ticket string) { } } if fails >= escalationThreshold && !escalated { - _ = Escalate(ws, lg, ticket, "orchestrator", + _ = Escalate(ws, b, lg, 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 index fe9ae26..82a0d8f 100644 --- a/hatch/internal/wf/escalate_test.go +++ b/hatch/internal/wf/escalate_test.go @@ -20,7 +20,7 @@ func TestEscalatePostsAndTargets(t *testing.T) { if got := EscalateTarget(ws); got != "claude-code" { t.Fatalf("escalate target = %q, want claude-code", got) } - if err := Escalate(ws, store.NewLedger(l), "T-001", "codex", "stuck"); err != nil { + if err := Escalate(ws, store.NewBoard(l), store.NewLedger(l), "T-001", "codex", "stuck"); err != nil { t.Fatal(err) } msgs, _ := bus.New(l).Messages("#escalations") diff --git a/hatch/internal/wf/ports.go b/hatch/internal/wf/ports.go new file mode 100644 index 0000000..0c7b86b --- /dev/null +++ b/hatch/internal/wf/ports.go @@ -0,0 +1,21 @@ +package wf + +import "github.com/fioenix/overclaud/hatch/internal/model" + +// Ports: the workflow engine (an application/use-case service) depends on these +// interfaces, not on concrete infrastructure. The filesystem store implements +// them; tests or alternative backends can provide their own. This is the +// dependency-inversion boundary that keeps the engine free of IO concerns. + +// Board is the ticket-store port the engine needs. +type Board interface { + Find(id string, lanes []string) (model.Ticket, bool, error) + Path(t model.Ticket) string + Write(t model.Ticket) (string, error) +} + +// Ledger is the audit-log port the engine needs. +type Ledger interface { + Append(e model.Entry) error + Recent(days int) ([]model.Entry, error) +} From 702efef484337f8cf4c7783b0d0db0044878db49 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 06:28:19 +0000 Subject: [PATCH 39/60] Add internal/port + adapter methods (Bus.Notify/CatchUp, oncall.Service) Central ports package for the use-case layer (Board/Ledger/Bus/OnCall) with compile-time assertions that store/bus/oncall adapters satisfy them. Bus gains Notify + CatchUp (read-the-room formatting moved into the adapter). Additive only; no behavior change yet. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/bus/adapter.go | 48 +++++++++++++++++++++++++++++ hatch/internal/oncall/oncall.go | 9 ++++++ hatch/internal/port/port.go | 37 ++++++++++++++++++++++ hatch/internal/store/port_assert.go | 10 ++++++ 4 files changed, 104 insertions(+) create mode 100644 hatch/internal/bus/adapter.go create mode 100644 hatch/internal/port/port.go create mode 100644 hatch/internal/store/port_assert.go 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/oncall/oncall.go b/hatch/internal/oncall/oncall.go index 3de837f..4f98c15 100644 --- a/hatch/internal/oncall/oncall.go +++ b/hatch/internal/oncall/oncall.go @@ -8,8 +8,17 @@ import ( "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 { diff --git a/hatch/internal/port/port.go b/hatch/internal/port/port.go new file mode 100644 index 0000000..a928114 --- /dev/null +++ b/hatch/internal/port/port.go @@ -0,0 +1,37 @@ +// 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, +// and advance an agent's read cursor. +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 +} + +// OnCall reports who currently holds the pager. +type OnCall interface { + Current() string +} diff --git a/hatch/internal/store/port_assert.go b/hatch/internal/store/port_assert.go new file mode 100644 index 0000000..245b09c --- /dev/null +++ b/hatch/internal/store/port_assert.go @@ -0,0 +1,10 @@ +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) +) From 5cf2f3b935e66017f4da11dc67c6bbc4342edd3f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 06:32:53 +0000 Subject: [PATCH 40/60] Port the workflow engine: wf.Engine depends only on ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wf is now an Engine struct holding port.Board/Ledger/Bus/OnCall; Move and Escalate are methods. The engine core imports no infrastructure (no store, bus, or oncall) — only port + model + config + gate. The CLI composition root (engineFor) wires concrete adapters. Tests use the same wiring. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/cli/deps.go | 20 ++++++++++++++ hatch/internal/cli/escalate.go | 8 +++--- hatch/internal/cli/mob.go | 2 +- hatch/internal/cli/orchestrate.go | 4 +-- hatch/internal/cli/pair.go | 2 +- hatch/internal/cli/ticket.go | 4 +-- hatch/internal/wf/engine.go | 8 +++--- hatch/internal/wf/engine_test.go | 33 ++++++++++++----------- hatch/internal/wf/escalate.go | 43 +++++++++++++++--------------- hatch/internal/wf/escalate_test.go | 21 ++++++++++----- hatch/internal/wf/ports.go | 28 +++++++------------ 11 files changed, 98 insertions(+), 75 deletions(-) create mode 100644 hatch/internal/cli/deps.go diff --git a/hatch/internal/cli/deps.go b/hatch/internal/cli/deps.go new file mode 100644 index 0000000..ebdc77a --- /dev/null +++ b/hatch/internal/cli/deps.go @@ -0,0 +1,20 @@ +package cli + +import ( + "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/store" + "github.com/fioenix/overclaud/hatch/internal/wf" +) + +// engineFor is the composition root for the workflow engine: it wires the +// concrete filesystem/bus/on-call adapters into the port-based wf.Engine. +func engineFor(ws *config.Workspace) wf.Engine { + return wf.Engine{ + Board: store.NewBoard(ws.Layout), + Ledger: store.NewLedger(ws.Layout), + Bus: bus.New(ws.Layout), + OnCall: oncall.Service{L: ws.Layout}, + } +} diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go index cbcf131..78dfd3b 100644 --- a/hatch/internal/cli/escalate.go +++ b/hatch/internal/cli/escalate.go @@ -4,9 +4,6 @@ import ( "fmt" "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" ) func newEscalateCmd() *cobra.Command { @@ -23,10 +20,11 @@ func newEscalateCmd() *cobra.Command { if why == "" { return fmt.Errorf("--why is required") } - if err := wf.Escalate(ws, store.NewBoard(ws.Layout), store.NewLedger(ws.Layout), args[0], from, why); err != nil { + 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], wf.EscalateTarget(ws)) + fmt.Fprintf(cmd.OutOrStdout(), "escalated %s → %s\n", args[0], eng.EscalateTarget(ws)) return nil }, } diff --git a/hatch/internal/cli/mob.go b/hatch/internal/cli/mob.go index a3bf0a2..4f11df7 100644 --- a/hatch/internal/cli/mob.go +++ b/hatch/internal/cli/mob.go @@ -55,7 +55,7 @@ func newMobCmd() *cobra.Command { if why == "" { why = "mob claim" } - if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), t.ID, wf.MoveOptions{ + 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 diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index da10c50..729ca80 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -118,7 +118,7 @@ func newRunCmd() *cobra.Command { if to == "" { return fmt.Errorf("no claim transition from %q", t.Lane) } - if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), t.ID, wf.MoveOptions{ + 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 @@ -262,7 +262,7 @@ func dispatchBacklog(ws *config.Workspace, out io.Writer, opt dispatchOpts) (int continue } to := claimTarget(ws, t.Lane) - if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), t.ID, wf.MoveOptions{ + 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) diff --git a/hatch/internal/cli/pair.go b/hatch/internal/cli/pair.go index 20d4eb1..8a1cde4 100644 --- a/hatch/internal/cli/pair.go +++ b/hatch/internal/cli/pair.go @@ -57,7 +57,7 @@ func newPairCmd() *cobra.Command { if why == "" { why = "pairing claim" } - if _, err := wf.Move(ws, b, store.NewLedger(ws.Layout), t.ID, wf.MoveOptions{ + if _, err := engineFor(ws).Move(ws, t.ID, wf.MoveOptions{ To: to, ByRole: t.Role, Agent: drv.ID, Why: why, }); err != nil { return err diff --git a/hatch/internal/cli/ticket.go b/hatch/internal/cli/ticket.go index 6c38005..2bfce3e 100644 --- a/hatch/internal/cli/ticket.go +++ b/hatch/internal/cli/ticket.go @@ -185,7 +185,7 @@ func newTicketClaimCmd() *cobra.Command { if why == "" { why = "claim by " + agent } - res, err := wf.Move(ws, b, store.NewLedger(ws.Layout), args[0], wf.MoveOptions{ + res, err := engineFor(ws).Move(ws, args[0], wf.MoveOptions{ To: to, ByRole: role, Agent: agent, Why: why, }) if err != nil { @@ -226,7 +226,7 @@ func newTicketMoveCmd() *cobra.Command { if to == "" { return fmt.Errorf("--to is required") } - res, err := wf.Move(ws, store.NewBoard(ws.Layout), store.NewLedger(ws.Layout), args[0], wf.MoveOptions{ + res, err := engineFor(ws).Move(ws, args[0], wf.MoveOptions{ To: to, ByRole: by, Agent: agent, Why: why, Handoff: handoff, HumanApproved: approve, SkipGates: skipGates, }) diff --git a/hatch/internal/wf/engine.go b/hatch/internal/wf/engine.go index 0dc0bc0..63fb4e9 100644 --- a/hatch/internal/wf/engine.go +++ b/hatch/internal/wf/engine.go @@ -11,6 +11,7 @@ import ( "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. @@ -34,7 +35,8 @@ type Result struct { } // Move validates and performs a transition for ticketID. -func Move(ws *config.Workspace, b Board, lg Ledger, ticketID string, opt MoveOptions) (*Result, error) { +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") } @@ -88,7 +90,7 @@ func Move(ws *config.Workspace, b Board, lg Ledger, ticketID string, opt MoveOpt } if !o.Human && !o.Passed { _ = lg.Append(gateEntry(opt, t, from, "failed", "gate "+o.Name+" failed: "+o.Detail)) - maybeEscalate(ws, b, lg, t.ID) + e.maybeEscalate(ws, t.ID) return nil, fmt.Errorf("gate %q failed: %s", o.Name, o.Detail) } } @@ -147,7 +149,7 @@ func Move(ws *config.Workspace, b Board, lg Ledger, ticketID string, opt MoveOpt return &Result{Ticket: t, From: from, To: opt.To, Action: action, Outcomes: outcomes}, nil } -func checkDependencies(ws *config.Workspace, b Board, t model.Ticket) error { +func checkDependencies(ws *config.Workspace, b port.Board, t model.Ticket) error { if len(t.DependsOn) == 0 { return nil } diff --git a/hatch/internal/wf/engine_test.go b/hatch/internal/wf/engine_test.go index cd959cd..60879de 100644 --- a/hatch/internal/wf/engine_test.go +++ b/hatch/internal/wf/engine_test.go @@ -4,13 +4,15 @@ 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, *store.Board, *store.Ledger) { +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"}) @@ -21,7 +23,8 @@ func setup(t *testing.T) (*config.Workspace, *store.Board, *store.Ledger) { if err != nil { t.Fatal(err) } - return ws, store.NewBoard(l), store.NewLedger(l) + 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) { @@ -35,21 +38,21 @@ func writeTicket(t *testing.T, b *store.Board, tk model.Ticket) { } func TestClaimBlockedByDependency(t *testing.T) { - ws, b, lg := setup(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 := Move(ws, b, lg, "T-002", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "go"}) + _, 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, b, lg := setup(t) + ws, eng, b := setup(t) writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - res, err := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}) + res, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}) if err != nil { t.Fatal(err) } @@ -62,39 +65,39 @@ func TestClaimSetsAssignee(t *testing.T) { } func TestRoleNotAllowed(t *testing.T) { - ws, b, lg := setup(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 := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "reviewer", Agent: "claude-code", Why: "x"}) + _, 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, b, lg := setup(t) + ws, eng, b := setup(t) writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - if _, err := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}); err != nil { + if _, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}); err != nil { t.Fatal(err) } - if _, err := Move(ws, b, lg, "T-001", MoveOptions{To: "review", ByRole: "implementer", Agent: "codex", Why: "done", Handoff: "h", SkipGates: true}); err != nil { + 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 := Move(ws, b, lg, "T-001", MoveOptions{To: "done", ByRole: "reviewer", Agent: "codex", Why: "lgtm", SkipGates: true}) + _, 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 := Move(ws, b, lg, "T-001", MoveOptions{To: "done", ByRole: "reviewer", Agent: "claude-code", Why: "approved", SkipGates: true}); err != nil { + 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, b, lg := setup(t) + ws, eng, b := setup(t) writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - if _, err := Move(ws, b, lg, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex"}); err == nil { + 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 index 94d88f3..079a0b4 100644 --- a/hatch/internal/wf/escalate.go +++ b/hatch/internal/wf/escalate.go @@ -4,10 +4,8 @@ import ( "fmt" "strings" - "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" ) // escalationThreshold is the number of gate failures on a ticket that triggers @@ -15,14 +13,18 @@ import ( const escalationThreshold = 2 // EscalateTarget resolves the system-level escalation target (no ticket role). -func EscalateTarget(ws *config.Workspace) string { return EscalateTargetForRole(ws, "") } +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 EscalateTargetForRole(ws *config.Workspace, role string) string { - if oc := oncall.Load(ws.Layout).Now(); oc != "" { - return oc +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 != "" { @@ -42,49 +44,46 @@ func EscalateTargetForRole(ws *config.Workspace, role string) string { // Escalate records an escalation in the ledger and notifies the target on the // #escalations channel. -func Escalate(ws *config.Workspace, b Board, lg Ledger, ticket, from, why string) error { +func (e Engine) Escalate(ws *config.Workspace, ticket, from, why string) error { role := "" - if t, ok, _ := b.Find(ticket, ws.Workflow.LaneIDs()); ok { + if t, ok, _ := e.Board.Find(ticket, ws.Workflow.LaneIDs()); ok { role = t.Role } - target := EscalateTargetForRole(ws, role) + target := e.EscalateTargetForRole(ws, role) if from == "" { from = "orchestrator" } - if err := lg.Append(model.Entry{ + if err := e.Ledger.Append(model.Entry{ Agent: from, Ticket: ticket, Action: model.ActEscalate, ToRole: target, Why: why, }); err != nil { return err } - _, err := bus.New(ws.Layout).Post(bus.Message{ - Channel: "#escalations", From: from, To: []string{target}, - Type: bus.TypeMsg, Body: fmt.Sprintf("@%s ESCALATION %s: %s", target, ticket, why), - }) - 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 maybeEscalate(ws *config.Workspace, b Board, lg Ledger, ticket string) { - entries, err := lg.Recent(1) +func (e Engine) maybeEscalate(ws *config.Workspace, ticket string) { + entries, err := e.Ledger.Recent(1) if err != nil { return } fails, escalated := 0, false - for _, e := range entries { - if e.Ticket != ticket { + for _, en := range entries { + if en.Ticket != ticket { continue } - if e.Action == model.ActGate && strings.HasPrefix(e.Result, "failed") { + if en.Action == model.ActGate && strings.HasPrefix(en.Result, "failed") { fails++ } - if e.Action == model.ActEscalate { + if en.Action == model.ActEscalate { escalated = true } } if fails >= escalationThreshold && !escalated { - _ = Escalate(ws, b, lg, ticket, "orchestrator", + _ = 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 index 82a0d8f..2e6becd 100644 --- a/hatch/internal/wf/escalate_test.go +++ b/hatch/internal/wf/escalate_test.go @@ -6,21 +6,32 @@ import ( "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 := EscalateTarget(ws); got != "claude-code" { + if got := eng.EscalateTarget(ws); got != "claude-code" { t.Fatalf("escalate target = %q, want claude-code", got) } - if err := Escalate(ws, store.NewBoard(l), store.NewLedger(l), "T-001", "codex", "stuck"); err != nil { + if err := eng.Escalate(ws, "T-001", "codex", "stuck"); err != nil { t.Fatal(err) } msgs, _ := bus.New(l).Messages("#escalations") @@ -36,7 +47,7 @@ func TestEscalateTargetPrefersOncall(t *testing.T) { } ws, _ := config.Load(l) (oncall.Rotation{Order: []string{"gemini"}}).Save(l) - if got := EscalateTarget(ws); got != "gemini" { + if got := engine(l).EscalateTarget(ws); got != "gemini" { t.Fatalf("on-call should win escalation target, got %q", got) } } @@ -47,14 +58,12 @@ func TestEscalateClimbsOrgChart(t *testing.T) { t.Fatal(err) } ws, _ := config.Load(l) - // implementer reports to conductor (claude-code) in the default registry? - // set it explicitly for the test. for i := range ws.Registry.Roles { if ws.Registry.Roles[i].ID == "implementer" { ws.Registry.Roles[i].ReportsTo = "conductor" } } - if got := EscalateTargetForRole(ws, "implementer"); got != "claude-code" { + 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 index 0c7b86b..371e09d 100644 --- a/hatch/internal/wf/ports.go +++ b/hatch/internal/wf/ports.go @@ -1,21 +1,13 @@ package wf -import "github.com/fioenix/overclaud/hatch/internal/model" - -// Ports: the workflow engine (an application/use-case service) depends on these -// interfaces, not on concrete infrastructure. The filesystem store implements -// them; tests or alternative backends can provide their own. This is the -// dependency-inversion boundary that keeps the engine free of IO concerns. - -// Board is the ticket-store port the engine needs. -type Board interface { - Find(id string, lanes []string) (model.Ticket, bool, error) - Path(t model.Ticket) string - Write(t model.Ticket) (string, error) -} - -// Ledger is the audit-log port the engine needs. -type Ledger interface { - Append(e model.Entry) error - Recent(days int) ([]model.Entry, error) +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 } From 56f8454d40c4e8ab9b95ab3853e21721c581126a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 06:37:59 +0000 Subject: [PATCH 41/60] Port the orchestrator + metrics; document the command/projection boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orchestrator is now an Orchestrator struct holding port.Ledger + port.Bus; Run/Execute are methods. Catch-up formatting moved into the bus adapter (port.Bus.CatchUp). The CLI/TUI composition root (orch(ws)) injects adapters. - metrics.Compute now takes port.Ledger. - internal/port gains Bus.CatchUp/MarkRead; bus implements them. - ARCHITECTURE.md: ports now live in internal/port; document the deliberate Lean line — command/mutating use cases (wf, orchestrator, gate) go through ports; read-only reporting projections (ceremony, report/cost/budget views) are composed at the root rather than leaking bus.Message into a port. All 18 packages pass; wf and orchestrator cores import no infrastructure. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/ARCHITECTURE.md | 34 ++++++++--- hatch/internal/cli/ceremony.go | 2 +- hatch/internal/cli/comms.go | 6 +- hatch/internal/cli/deps.go | 10 ++++ hatch/internal/cli/mob.go | 4 +- hatch/internal/cli/orchestrate.go | 8 +-- hatch/internal/cli/pair.go | 4 +- hatch/internal/metrics/metrics.go | 4 +- hatch/internal/orchestrator/comm_test.go | 4 +- hatch/internal/orchestrator/e2e_test.go | 4 +- hatch/internal/orchestrator/run.go | 73 ++++++++++-------------- hatch/internal/tui/board.go | 3 +- 12 files changed, 86 insertions(+), 70 deletions(-) diff --git a/hatch/ARCHITECTURE.md b/hatch/ARCHITECTURE.md index 7587d0d..b3c560c 100644 --- a/hatch/ARCHITECTURE.md +++ b/hatch/ARCHITECTURE.md @@ -33,21 +33,39 @@ Dependencies point **inward**. Inner layers never import outer ones. |---|---|---| | **Domain** | `internal/model` | Pure data + invariants. 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** | `wf.Board`, `wf.Ledger`, `gate.Runner`, `orchestrator.Adapter` | Interfaces declared at the consumer (Go idiom). | +| **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 -- **`wf.Board` / `wf.Ledger`** — the workflow engine (the core use case) operates - on these interfaces, not on `*store.Board`/`*store.Ledger`. `internal/store` - satisfies them structurally. The engine package imports **no infrastructure** - (`internal/wf` does not import `internal/store`). +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/Gemini/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`). -- **`orchestrator.Adapter`** — each agent CLI (Claude/Codex/Gemini/Kiro) is an - adapter that builds a headless invocation; `mock` is the test/demo adapter. - This is textbook ports & adapters at the agent boundary. +- **`metrics.Compute(port.Ledger)`** — reads through the ledger port. + +### The command/projection boundary (a deliberate Lean line) + +Use cases that **mutate** state or **spawn** agents go through ports +(`wf`, `orchestrator`, `gate`). **Read-only reporting projections** that +aggregate several sources at once (`ceremony`, plus the `report`/`cost`/`budget` +CLI views) are composed at the root and read concrete adapters directly. Forcing +them through ports would require leaking the messaging value types (`bus.Message`) +into a port or relocating domain types — ceremony alone reads ledger **and** +board **and** bus decisions **and** KB. That is abstraction for its own sake; we +draw the line at the command path, where decoupling and testability actually pay +off. If a second backend ever appears, the projections move behind ports then. ## Where we stay Lean (intentional non-ports) diff --git a/hatch/internal/cli/ceremony.go b/hatch/internal/cli/ceremony.go index 708f9b7..b32e725 100644 --- a/hatch/internal/cli/ceremony.go +++ b/hatch/internal/cli/ceremony.go @@ -158,7 +158,7 @@ func newPlanningCmd() *cobra.Command { return err } fmt.Fprintf(cmd.OutOrStdout(), "→ planning with %s (%s)\n", agent.ID, agent.Kind) - _, err = orchestrator.Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ + _, err = orch(ws).Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ DryRun: dryRun, Stdout: cmd.OutOrStdout(), }) return err diff --git a/hatch/internal/cli/comms.go b/hatch/internal/cli/comms.go index b02e3ab..f4e2dfb 100644 --- a/hatch/internal/cli/comms.go +++ b/hatch/internal/cli/comms.go @@ -362,7 +362,7 @@ func newAskCmd() *cobra.Command { 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 := orchestrator.Execute(ws, target, thread, prompt, orchestrator.RunOptions{ + outcome, err := orch(ws).Execute(ws, target, thread, prompt, orchestrator.RunOptions{ DryRun: dryRun, Timeout: timeout, Stdout: out, }) if err != nil { @@ -444,7 +444,7 @@ func newConveneCmd() *cobra.Command { 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 := orchestrator.Execute(ws, a, thread, prompt, orchestrator.RunOptions{ + outcome, err := orch(ws).Execute(ws, a, thread, prompt, orchestrator.RunOptions{ DryRun: dryRun, Timeout: timeout, Stdout: out, }) if err != nil { @@ -476,7 +476,7 @@ func newConveneCmd() *cobra.Command { } raw, _ := bs.Raw(thread) fmt.Fprintf(out, "\n# tie-break · %s (%s)\n", d.ID, roleOf(d)) - outcome, err := orchestrator.Execute(ws, d, thread, + 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 { diff --git a/hatch/internal/cli/deps.go b/hatch/internal/cli/deps.go index ebdc77a..28508ca 100644 --- a/hatch/internal/cli/deps.go +++ b/hatch/internal/cli/deps.go @@ -4,10 +4,20 @@ import ( "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/orchestrator" "github.com/fioenix/overclaud/hatch/internal/store" "github.com/fioenix/overclaud/hatch/internal/wf" ) +// 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 { diff --git a/hatch/internal/cli/mob.go b/hatch/internal/cli/mob.go index 4f11df7..e2f9a58 100644 --- a/hatch/internal/cli/mob.go +++ b/hatch/internal/cli/mob.go @@ -75,7 +75,7 @@ func newMobCmd() *cobra.Command { // Driver implements. raw, _ := bs.Raw(thread) fmt.Fprintf(out, "\n# round %d · DRIVER %s\n", r, driver.ID) - dOut, err := orchestrator.Execute(ws, driver, t.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 { @@ -96,7 +96,7 @@ func newMobCmd() *cobra.Command { navs++ raw, _ = bs.Raw(thread) fmt.Fprintf(out, "\n# round %d · NAVIGATOR %s\n", r, a.ID) - nOut, err := orchestrator.Execute(ws, a, t.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 { diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index 729ca80..1346eac 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -146,7 +146,7 @@ func newRunCmd() *cobra.Command { return nil } fmt.Fprintf(cmd.OutOrStdout(), "→ %s (%s) on %s as %s\n", agent.ID, agent.Kind, t.ID, t.Role) - out, err := orchestrator.Run(ws, agent, t, t.Role, orchestrator.RunOptions{ + out, err := orch(ws).Run(ws, agent, t, t.Role, orchestrator.RunOptions{ DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), SkipComms: noCatchUp, }) if err != nil { @@ -185,7 +185,7 @@ func newPlanCmd() *cobra.Command { return err } fmt.Fprintf(cmd.OutOrStdout(), "→ planning with %s (%s)\n", agent.ID, agent.Kind) - _, err = orchestrator.Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ + _, err = orch(ws).Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), }) return err @@ -282,7 +282,7 @@ func dispatchBacklog(ws *config.Workspace, out io.Writer, opt dispatchOpts) (int func runJobs(ws *config.Workspace, out io.Writer, jobs []job, parallel int) { if parallel <= 1 { for _, j := range jobs { - if _, err := orchestrator.Run(ws, j.agent, j.ticket, j.ticket.Role, orchestrator.RunOptions{Stdout: out}); err != nil { + 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) } } @@ -299,7 +299,7 @@ func runJobs(ws *config.Workspace, out io.Writer, jobs []job, parallel int) { go func(j job) { defer wg.Done() defer func() { <-sem }() - _, err := orchestrator.Run(ws, j.agent, j.ticket, j.ticket.Role, orchestrator.RunOptions{Stdout: io.Discard}) + _, 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) diff --git a/hatch/internal/cli/pair.go b/hatch/internal/cli/pair.go index 8a1cde4..507ffd4 100644 --- a/hatch/internal/cli/pair.go +++ b/hatch/internal/cli/pair.go @@ -76,7 +76,7 @@ func newPairCmd() *cobra.Command { // Driver turn. raw, _ := bs.Raw(thread) fmt.Fprintf(out, "\n# round %d · DRIVER %s\n", r, drv.ID) - dOut, err := orchestrator.Execute(ws, drv, t.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 { @@ -91,7 +91,7 @@ func newPairCmd() *cobra.Command { // Navigator turn. raw, _ = bs.Raw(thread) fmt.Fprintf(out, "\n# round %d · NAVIGATOR %s\n", r, nav.ID) - nOut, err := orchestrator.Execute(ws, nav, t.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 { diff --git a/hatch/internal/metrics/metrics.go b/hatch/internal/metrics/metrics.go index 6326bdd..ca126f4 100644 --- a/hatch/internal/metrics/metrics.go +++ b/hatch/internal/metrics/metrics.go @@ -8,7 +8,7 @@ import ( "time" "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/store" + "github.com/fioenix/overclaud/hatch/internal/port" ) // AgentStat is one agent's operational scorecard. @@ -31,7 +31,7 @@ type Report struct { } // Compute scans the ledger into a Report. -func Compute(lg *store.Ledger) (*Report, error) { +func Compute(lg port.Ledger) (*Report, error) { entries, err := lg.Entries() if err != nil { return nil, err diff --git a/hatch/internal/orchestrator/comm_test.go b/hatch/internal/orchestrator/comm_test.go index 695cf0e..e9e2d00 100644 --- a/hatch/internal/orchestrator/comm_test.go +++ b/hatch/internal/orchestrator/comm_test.go @@ -27,7 +27,7 @@ func TestCommContextGathersInboxAndRecall(t *testing.T) { 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 := commContext(ws, codex, "Export CSV") + got := Orchestrator{Bus: b}.commContext(codex, "Export CSV") if got == "" { t.Fatal("expected non-empty comm context") } @@ -44,7 +44,7 @@ func TestCommContextEmptyWhenNothing(t *testing.T) { l, _, _ := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) ws, _ := config.Load(l) codex, _ := ws.Registry.AgentByID("codex") - if got := commContext(ws, codex, "anything"); got != "" { + if got := (Orchestrator{Bus: bus.New(l)}).commContext(codex, "anything"); got != "" { t.Fatalf("expected empty comm context, got: %s", got) } } diff --git a/hatch/internal/orchestrator/e2e_test.go b/hatch/internal/orchestrator/e2e_test.go index c96a856..9d23686 100644 --- a/hatch/internal/orchestrator/e2e_test.go +++ b/hatch/internal/orchestrator/e2e_test.go @@ -6,6 +6,7 @@ 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" @@ -30,7 +31,8 @@ func TestExecuteRunsMockAgentEndToEnd(t *testing.T) { } agent := model.Agent{ID: "codex", Kind: "mock", Cmd: script} - out, err := Execute(ws, agent, "T-1", "implement the export", RunOptions{Stdout: io_discard{}}) + 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) } diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go index 5bb373a..7465d97 100644 --- a/hatch/internal/orchestrator/run.go +++ b/hatch/internal/orchestrator/run.go @@ -9,12 +9,19 @@ import ( "strings" "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/store" + "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 @@ -36,28 +43,24 @@ type RunOutcome struct { // 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 Run(ws *config.Workspace, agent model.Agent, t model.Ticket, role string, opt RunOptions) (*RunOutcome, error) { +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 := commContext(ws, agent, t.Title); comm != "" { + if comm := o.commContext(agent, t.Title); comm != "" { prompt = comm + "\n\n" + prompt } } - out, err := Execute(ws, agent, t.ID, prompt, opt) + out, err := o.Execute(ws, agent, t.ID, prompt, opt) if err == nil && out != nil && out.Executed && !opt.SkipComms { - _ = bus.New(ws.Layout).MarkRead(agent.ID) + _ = o.Bus.MarkRead(agent.ID) } return out, err } -// commContext gathers the agent's unread inbox and a query-scoped recall of -// recent conversation, formatted as a compact, token-bounded block. -func commContext(ws *config.Workspace, agent model.Agent, query string) string { - b := bus.New(ws.Layout) - inbox, _ := b.Inbox(agent.ID, agent.Roles) - subs := b.Subscriptions(agent.ID) - recall, _ := b.Search(bus.SearchOpts{Query: query, Channels: subs, Limit: 5}) - +// 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 "" } @@ -65,38 +68,23 @@ func commContext(ws *config.Workspace, agent model.Agent, query string) string { 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 _, m := range capMsgs(inbox, 10) { - fmt.Fprintf(&s, "- [%s] %s · %s: %s\n", m.Type, m.Channel, m.From, snippet(m.Body)) + 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 _, m := range recall { - fmt.Fprintf(&s, "- %s · %s: %s\n", m.Channel, m.From, snippet(m.Body)) + 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() } -func capMsgs(ms []bus.Message, n int) []bus.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(s) > 120 { - return s[:120] + "…" - } - return s -} - // Execute builds and (unless DryRun) runs an agent against an arbitrary prompt, // recording the outcome in the ledger under ticketID ("-" for system tasks). -func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, opt RunOptions) (*RunOutcome, error) { +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, @@ -105,20 +93,18 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o RepoRoot: repoRoot, } inv := AdapterFor(agent.Kind).Build(req) - t := model.Ticket{ID: ticketID} out := opt.Stdout if out == nil { out = os.Stdout } - lg := store.NewLedger(ws.Layout) // 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) - _ = lg.Append(model.Entry{ - Agent: agent.ID, Ticket: t.ID, Action: model.ActNote, + _ = 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 @@ -133,7 +119,6 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o return &RunOutcome{Invocation: inv, Executed: false}, nil } - // Execute. ctx := context.Background() if opt.Timeout > 0 { var cancel context.CancelFunc @@ -157,9 +142,9 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o cmd.Stdout = mw cmd.Stderr = mw - _ = lg.Append(model.Entry{ - Agent: agent.ID, Ticket: t.ID, Action: model.ActStart, - Why: fmt.Sprintf("orchestrator spawned %s for %s", agent.ID, t.ID), + _ = 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() @@ -173,8 +158,8 @@ func Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, o result = "failed: " + runErr.Error() } usd, tokens := extractUsage(buf.String(), agent.RatePerMTok) - _ = lg.Append(model.Entry{ - Agent: agent.ID, Ticket: t.ID, Action: model.ActProgress, + _ = 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, }) diff --git a/hatch/internal/tui/board.go b/hatch/internal/tui/board.go index 9f683e6..b3d9916 100644 --- a/hatch/internal/tui/board.go +++ b/hatch/internal/tui/board.go @@ -329,8 +329,9 @@ func (mm *m) runSelected() tea.Cmd { } mm.status = "đang chạy " + agent.ID + " trên " + ref.t.ID + "…" ws, t, role := mm.ws, ref.t, ref.t.Role + o := orchestrator.Orchestrator{Ledger: store.NewLedger(ws.Layout), Bus: bus.New(ws.Layout)} return func() tea.Msg { - _, err := orchestrator.Run(ws, agent, t, role, orchestrator.RunOptions{Stdout: io.Discard}) + _, err := o.Run(ws, agent, t, role, orchestrator.RunOptions{Stdout: io.Discard}) return ranMsg{ticket: t.ID, err: err} } } From 6c03dee256588505af90c546b111254cbd339979 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 07:13:18 +0000 Subject: [PATCH 42/60] =?UTF-8?q?Port=20the=20reporting=20use=20cases=20to?= =?UTF-8?q?o=20(100%=20hexagonal):=20Message=20=E2=86=92=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move the messaging value types (Message, SearchOpts, message kinds) into the domain (internal/model); bus re-exports them as type aliases so call sites are unchanged. Ports can now return domain types with no adapter leak. - port.Bus gains Search; new port.KB (List). store/bus satisfy them (asserted). - ceremony is now a Service{port.Board, Ledger, Bus, KB}; metrics already on port.Ledger. The entire use-case layer (wf, orchestrator, gate, metrics, ceremony) imports only port + model + config — no store/bus. - composition root gains ceremonyService(ws); ARCHITECTURE.md updated. All 18 packages pass; onboard e2e green. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/ARCHITECTURE.md | 37 ++++++++++---------- hatch/internal/bus/bus.go | 28 ++++++--------- hatch/internal/bus/search.go | 15 +++------ hatch/internal/ceremony/ceremony.go | 43 +++++++++++++----------- hatch/internal/ceremony/ceremony_test.go | 18 +++++++--- hatch/internal/cli/ceremony.go | 9 +++-- hatch/internal/cli/deps.go | 11 ++++++ hatch/internal/cli/tick.go | 3 +- hatch/internal/model/message.go | 32 ++++++++++++++++++ hatch/internal/port/port.go | 8 ++++- hatch/internal/store/port_assert.go | 1 + 11 files changed, 129 insertions(+), 76 deletions(-) create mode 100644 hatch/internal/model/message.go diff --git a/hatch/ARCHITECTURE.md b/hatch/ARCHITECTURE.md index b3c560c..49404f7 100644 --- a/hatch/ARCHITECTURE.md +++ b/hatch/ARCHITECTURE.md @@ -31,7 +31,7 @@ Dependencies point **inward**. Inner layers never import outer ones. | Layer | Packages | Rule | |---|---|---| -| **Domain** | `internal/model` | Pure data + invariants. Imports nothing from the project. | +| **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. | @@ -53,19 +53,22 @@ them, asserted at compile time (`var _ port.Board = (*store.Board)(nil)`). (Claude/Codex/Gemini/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)`** — reads through the ledger port. - -### The command/projection boundary (a deliberate Lean line) - -Use cases that **mutate** state or **spawn** agents go through ports -(`wf`, `orchestrator`, `gate`). **Read-only reporting projections** that -aggregate several sources at once (`ceremony`, plus the `report`/`cost`/`budget` -CLI views) are composed at the root and read concrete adapters directly. Forcing -them through ports would require leaking the messaging value types (`bus.Message`) -into a port or relocating domain types — ceremony alone reads ledger **and** -board **and** bus decisions **and** KB. That is abstraction for its own sake; we -draw the line at the command path, where decoupling and testability actually pay -off. If a second backend ever appears, the projections move behind ports then. +- **`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) @@ -73,9 +76,9 @@ 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. -- `bus`, `presence`, `oncall` — small filesystem adapters used directly by - use-case services acting as coordinators. + 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 diff --git a/hatch/internal/bus/bus.go b/hatch/internal/bus/bus.go index 0caa2cf..c9ee440 100644 --- a/hatch/internal/bus/bus.go +++ b/hatch/internal/bus/bus.go @@ -14,29 +14,23 @@ import ( "strings" "time" + "github.com/fioenix/overclaud/hatch/internal/model" "github.com/fioenix/overclaud/hatch/internal/paths" ) -// Message types. +// Message types (aliases of the domain constants). const ( - TypeMsg = "msg" // a statement / DM / mention - TypeAsk = "ask" // a question expecting a reply - TypeReply = "reply" // a reply to an ask - TypeDecision = "decision" // a recorded decision / consensus + TypeMsg = model.MsgText + TypeAsk = model.MsgAsk + TypeReply = model.MsgReply + TypeDecision = model.MsgDecision ) -// Message is one turn in a channel. A reply (InReplyTo set) forms a thread -// within that channel, Slack-style. -type Message struct { - ID string - Channel string // channel / DM / conversation id (file). "#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 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 } diff --git a/hatch/internal/bus/search.go b/hatch/internal/bus/search.go index 36bcda4..b0c3d4a 100644 --- a/hatch/internal/bus/search.go +++ b/hatch/internal/bus/search.go @@ -3,19 +3,12 @@ package bus import ( "sort" "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" ) -// SearchOpts filters a bus query. Empty fields are ignored. This is the -// token-efficient way an agent loads *relevant* conversation into context -// (L2 on-demand) instead of reading every message in every channel. -type SearchOpts struct { - Query string // case-insensitive substring 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 ⇒ 20) -} +// 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) { diff --git a/hatch/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go index 25692d3..2a8c226 100644 --- a/hatch/internal/ceremony/ceremony.go +++ b/hatch/internal/ceremony/ceremony.go @@ -8,12 +8,20 @@ import ( "sort" "strings" - "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" + "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 @@ -24,11 +32,11 @@ type StandupReport struct { // 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 Standup(ws *config.Workspace, days int) (*StandupReport, error) { +func (s Service) Standup(ws *config.Workspace, days int) (*StandupReport, error) { if days < 1 { days = 1 } - entries, err := store.NewLedger(ws.Layout).Recent(days) + entries, err := s.Ledger.Recent(days) if err != nil { return nil, err } @@ -47,7 +55,7 @@ func Standup(ws *config.Workspace, days int) (*StandupReport, error) { perAgent[e.Agent] = appendUnique(perAgent[e.Agent], line) } - blockers, err := blockedTickets(ws) + blockers, err := s.blockedTickets(ws) if err != nil { return nil, err } @@ -81,8 +89,8 @@ type RetroReport struct { // Retro summarises the whole ledger plus bus decisions and surfaces KB // learnings as SSOT-promotion candidates. -func Retro(ws *config.Workspace) (*RetroReport, error) { - entries, err := store.NewLedger(ws.Layout).Recent(0) +func (s Service) Retro(ws *config.Workspace) (*RetroReport, error) { + entries, err := s.Ledger.Recent(0) if err != nil { return nil, err } @@ -100,12 +108,12 @@ func Retro(ws *config.Workspace) (*RetroReport, error) { } } // Decisions recorded on the bus. - b := bus.New(ws.Layout) - if decs, err := b.Search(bus.SearchOpts{Type: bus.TypeDecision, Limit: 100}); err == nil { + 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 := store.NewKB(ws.Layout).List(); err == nil { + if entriesKB, err := s.KB.List(); err == nil { for _, e := range entriesKB { if e.Type == model.KBLearning { r.PromotionCands = append(r.PromotionCands, e) @@ -129,11 +137,10 @@ func Retro(ws *config.Workspace) (*RetroReport, error) { // Demo lists work in terminal (done-like) lanes — the showcase for a sprint // review / demo. Returns a report and the tickets shown. -func Demo(ws *config.Workspace) (string, []model.Ticket, error) { - board := store.NewBoard(ws.Layout) +func (s Service) Demo(ws *config.Workspace) (string, []model.Ticket, error) { var done []model.Ticket for _, id := range terminalLaneIDs(ws) { - ts, err := board.ListLane(id) + ts, err := s.Board.ListLane(id) if err != nil { return "", nil, err } @@ -166,10 +173,9 @@ type GroomItem struct { // Grooming scans the entry (backlog) lane for under-specified tickets — the // backlog refinement ritual: flag missing role/priority/acceptance. -func Grooming(ws *config.Workspace) (string, []GroomItem, error) { - board := store.NewBoard(ws.Layout) +func (s Service) Grooming(ws *config.Workspace) (string, []GroomItem, error) { lane := entryLane(ws) - tickets, err := board.ListLane(lane) + tickets, err := s.Board.ListLane(lane) if err != nil { return "", nil, err } @@ -228,14 +234,13 @@ func terminalLaneIDs(ws *config.Workspace) []string { return out } -func blockedTickets(ws *config.Workspace) ([]model.Ticket, error) { - board := store.NewBoard(ws.Layout) +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 := board.ListLane(l.ID) + ts, err := s.Board.ListLane(l.ID) if err != nil { return nil, err } diff --git a/hatch/internal/ceremony/ceremony_test.go b/hatch/internal/ceremony/ceremony_test.go index 8921dc5..8a03b1a 100644 --- a/hatch/internal/ceremony/ceremony_test.go +++ b/hatch/internal/ceremony/ceremony_test.go @@ -4,12 +4,22 @@ 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"}) @@ -30,7 +40,7 @@ func TestStandupDigestsByAgent(t *testing.T) { 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 := Standup(w, 1) + rep, err := svc(w).Standup(w, 1) if err != nil { t.Fatal(err) } @@ -49,7 +59,7 @@ func TestRetroCounts(t *testing.T) { 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 := Retro(w) + r, err := svc(w).Retro(w) if err != nil { t.Fatal(err) } @@ -63,7 +73,7 @@ func TestDemoAndGrooming(t *testing.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 := Grooming(w) + _, items, err := svc(w).Grooming(w) if err != nil { t.Fatal(err) } @@ -72,7 +82,7 @@ func TestDemoAndGrooming(t *testing.T) { } // 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 := Demo(w) + _, shown, err := svc(w).Demo(w) if err != nil { t.Fatal(err) } diff --git a/hatch/internal/cli/ceremony.go b/hatch/internal/cli/ceremony.go index b32e725..8e5f6c7 100644 --- a/hatch/internal/cli/ceremony.go +++ b/hatch/internal/cli/ceremony.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" "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/orchestrator" ) @@ -34,7 +33,7 @@ func newDemoCmd() *cobra.Command { if err != nil { return err } - report, _, err := ceremony.Demo(ws) + report, _, err := ceremonyService(ws).Demo(ws) if err != nil { return err } @@ -63,7 +62,7 @@ func newGroomingCmd() *cobra.Command { if err != nil { return err } - report, items, err := ceremony.Grooming(ws) + report, items, err := ceremonyService(ws).Grooming(ws) if err != nil { return err } @@ -88,7 +87,7 @@ func newStandupCeremonyCmd() *cobra.Command { if err != nil { return err } - rep, err := ceremony.Standup(ws, days) + rep, err := ceremonyService(ws).Standup(ws, days) if err != nil { return err } @@ -121,7 +120,7 @@ func newRetroCmd() *cobra.Command { if err != nil { return err } - rep, err := ceremony.Retro(ws) + rep, err := ceremonyService(ws).Retro(ws) if err != nil { return err } diff --git a/hatch/internal/cli/deps.go b/hatch/internal/cli/deps.go index 28508ca..724dc6f 100644 --- a/hatch/internal/cli/deps.go +++ b/hatch/internal/cli/deps.go @@ -2,6 +2,7 @@ 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" @@ -9,6 +10,16 @@ import ( "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 { diff --git a/hatch/internal/cli/tick.go b/hatch/internal/cli/tick.go index 48ac969..330f8eb 100644 --- a/hatch/internal/cli/tick.go +++ b/hatch/internal/cli/tick.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/ceremony" "github.com/fioenix/overclaud/hatch/internal/store" ) @@ -24,7 +23,7 @@ func newTickCmd() *cobra.Command { out := cmd.OutOrStdout() // 1. Standup digest → #standup. - sr, err := ceremony.Standup(ws, 1) + sr, err := ceremonyService(ws).Standup(ws, 1) if err != nil { return err } 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/port/port.go b/hatch/internal/port/port.go index a928114..b925ff7 100644 --- a/hatch/internal/port/port.go +++ b/hatch/internal/port/port.go @@ -24,11 +24,17 @@ type Ledger interface { // 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, -// and advance an agent's read cursor. +// 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. diff --git a/hatch/internal/store/port_assert.go b/hatch/internal/store/port_assert.go index 245b09c..29ae4c0 100644 --- a/hatch/internal/store/port_assert.go +++ b/hatch/internal/store/port_assert.go @@ -7,4 +7,5 @@ import "github.com/fioenix/overclaud/hatch/internal/port" var ( _ port.Board = (*Board)(nil) _ port.Ledger = (*Ledger)(nil) + _ port.KB = (*KB)(nil) ) From 6aa6b1a2b26e3466e15e21e50cc7efe76fcf9d05 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 08:10:56 +0000 Subject: [PATCH 43/60] Add Antigravity CLI (agy); doctor: OAuth-aware, CLI-driven, CLIs optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new `agy` adapter (Google Antigravity CLI, successor to Gemini CLI): `agy -p … --output-format json [-m] [--approval-mode/--yolo] [--sandbox]`, surface GEMINI.md. Now the default in the scaffolded registry; `kind: gemini` kept for the legacy Gemini CLI. - doctor no longer scans credential folders (security). It checks auth by running the CLI's own non-mutating command (e.g. `codex login status`) or an agent-configured `auth_check`, or honours an env key; otherwise reports "?". Agent CLIs are not mandatory — readiness needs only ≥1 present; missing CLIs are shown, not failed. - model.Agent.auth_check; envForKind adds ANTIGRAVITY_API_KEY. - docs 10/19 updated (agy default, OAuth login, optional CLIs). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/10-agent-adapters.md | 11 +- hatch/docs/19-going-real.md | 16 +-- hatch/internal/cli/doctor.go | 100 ++++++++++++++---- hatch/internal/compile/surface.go | 2 +- hatch/internal/model/registry.go | 4 + hatch/internal/orchestrator/adapter.go | 1 + hatch/internal/orchestrator/adapters.go | 25 +++++ .../scaffold/templates/base/registry.yaml | 4 +- 8 files changed, 128 insertions(+), 35 deletions(-) diff --git a/hatch/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md index 7b05643..8a142b2 100644 --- a/hatch/docs/10-agent-adapters.md +++ b/hatch/docs/10-agent-adapters.md @@ -9,12 +9,17 @@ Tài liệu tham chiếu cho **compiler** (ghi instruction ra đâu) và **orche | 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` | -| Gemini CLI | `gemini` | `GEMINI.md` | `gemini -p` | -| Antigravity | `antigravity` | `AGENTS.md` (dùng chung convention) | IDE (chưa có CLI chính thức xác nhận) | +| **Antigravity CLI** | `agy` | `GEMINI.md` (cũng đọc `AGENTS.md`) | `agy -p … --output-format json` | +| Gemini CLI *(legacy)* | `gemini` | `GEMINI.md` | `gemini -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 | -`AGENTS.md` là convention dùng chung — **một** file phục vụ Codex + Antigravity (+ Gemini nếu khai `context.fileName`). +> **`agy`** (Antigravity CLI) là **kế nhiệm Gemini CLI** của Google — mặc định mới của Hatch. `kind: gemini` vẫn dùng được cho ai còn xài Gemini CLI cũ. Auth qua **OAuth/OS-keyring** (browser, hoặc device-code khi headless) hoặc env `ANTIGRAVITY_API_KEY`; token ở `~/.gemini/antigravity-cli/`. Cờ: `-p`, `--output-format json`, `-m`, `--approval-mode`/`--yolo`, `--sandbox`. + +`AGENTS.md` là convention dùng chung — **một** file phục vụ Codex + Antigravity. + +> **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` kiểm auth bằng cách **gọi chính lệnh của CLI** (vd `codex login status`) hoặc đọc env key — **không scan thư mục credential** (an toàn). CLI nào chưa có lệnh status non-interactive thì khai `auth_check` trong registry, hoặc doctor để "?". --- diff --git a/hatch/docs/19-going-real.md b/hatch/docs/19-going-real.md index f86f579..c7c8970 100644 --- a/hatch/docs/19-going-real.md +++ b/hatch/docs/19-going-real.md @@ -2,11 +2,11 @@ Hướng dẫn chuyển một workspace từ **mock agent** (demo) sang **agent CLI thật**. -## 1. Cài agent CLI +## 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)) -- **Gemini CLI** — `gemini` ([github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)) +- **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 @@ -26,13 +26,15 @@ agents: rate_per_mtok: 15 ``` -## 3. Cấp credential qua ENV (không bao giờ trong repo) +## 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 -export ANTHROPIC_API_KEY=... # claude -export OPENAI_API_KEY=... # codex -export GEMINI_API_KEY=... # gemini -export KIRO_API_KEY=... # kiro (headless) +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) — hoặc env ANTIGRAVITY_API_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 diff --git a/hatch/internal/cli/doctor.go b/hatch/internal/cli/doctor.go index 658f2b9..36fe418 100644 --- a/hatch/internal/cli/doctor.go +++ b/hatch/internal/cli/doctor.go @@ -1,33 +1,87 @@ 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 expects (best-effort). +// envForKind names the credential env var each agent kind accepts (besides +// interactive OAuth/login). var envForKind = map[string]string{ "claude": "ANTHROPIC_API_KEY", "codex": "OPENAI_API_KEY", "gemini": "GEMINI_API_KEY", + "agy": "ANTIGRAVITY_API_KEY", "kiro": "KIRO_API_KEY", } // defaultCmdForKind is the executable each kind drives. var defaultCmdForKind = map[string]string{ - "claude": "claude", "codex": "codex", "gemini": "gemini", "kiro": "kiro-cli", "mock": "hatch-mock", + "claude": "claude", "codex": "codex", "gemini": "gemini", + "agy": "agy", "kiro": "kiro-cli", "mock": "hatch-mock", +} + +// defaultAuthCheck is a non-mutating, scriptable command (argv) per kind that +// exits 0 when authenticated. Only commands known to be safe + non-interactive +// are listed; for others we don't guess (the user can set `auth_check`). +var defaultAuthCheck = map[string][]string{ + "codex": {"login", "status"}, +} + +// authStatus reports how an agent authenticates, WITHOUT touching credential +// files: it honours an env key (which the user set), else runs the agent CLI's +// own auth-check command (configurable), else reports "unknown". +func authStatus(a model.Agent, bin string, cliPresent bool) string { + if ev := envForKind[a.Kind]; ev != "" && os.Getenv(ev) != "" { + return "✓ env " + ev + } + check := a.AuthCheck + if len(check) == 0 { + check = defaultAuthCheck[a.Kind] + } + if len(check) == 0 { + if envForKind[a.Kind] == "" { + return "—" + } + return "? set auth_check or run `" + bin + " login`" + } + 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 let the check block on interactive input + if err := c.Run(); err == nil { + return "✓ login (`" + bin + " " + joinArgs(check) + "`)" + } + return "✗ chưa login (`" + bin + " login`)" +} + +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 + credentials", + Short: "Check readiness: config, compiled freshness, agent CLIs + auth", RunE: func(cmd *cobra.Command, args []string) error { ws, err := loadWorkspace() if err != nil { @@ -37,7 +91,6 @@ func newDoctorCmd() *cobra.Command { issues := 0 fmt.Fprintln(out, "Hatch doctor") - // Config validity. if probs := ws.Validate(); len(probs) > 0 { issues += len(probs) fmt.Fprintf(out, "✗ config: %d problem(s) (run `hatch validate`)\n", len(probs)) @@ -45,7 +98,6 @@ func newDoctorCmd() *cobra.Command { fmt.Fprintln(out, "✓ config valid") } - // Compiled freshness. if reason, _ := compile.StaleReason(ws.Layout, ws.Layout.RepoRoot()); reason != "" { issues++ fmt.Fprintf(out, "✗ compiled stale: %s (run `hatch compile`)\n", reason) @@ -53,44 +105,48 @@ func newDoctorCmd() *cobra.Command { fmt.Fprintln(out, "✓ compiled up to date") } - // Agents: CLI on PATH + credential env. - fmt.Fprintln(out, "\nAgents:") + // 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\tCREDENTIAL") + 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 = "✓ " + bin - } else { - cli = "✗ " + bin + " not on PATH" - if a.Kind != "mock" && a.Kind != "manual" { - issues++ + cli, cliPresent = "✓ "+bin, true + present++ + if a.Kind != "mock" { + presentReal++ } - } - } - cred := "—" - if ev := envForKind[a.Kind]; ev != "" { - if os.Getenv(ev) != "" { - cred = "✓ " + ev } else { - cred = "✗ " + ev + " unset" + cli = "✗ " + bin } } - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", a.ID, a.Kind, cli, cred) + 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. (mock agents need no CLI/credential.)\n", issues) + fmt.Fprintf(out, "%d issue(s) — see above.\n", issues) return fmt.Errorf("%d readiness issue(s)", issues) }, } diff --git a/hatch/internal/compile/surface.go b/hatch/internal/compile/surface.go index 2475571..44a8df5 100644 --- a/hatch/internal/compile/surface.go +++ b/hatch/internal/compile/surface.go @@ -30,7 +30,7 @@ func surfaceForKind(kind string) (Surface, bool) { return SurfaceClaude, true case "codex", "antigravity": return SurfaceAgents, true - case "gemini": + case "gemini", "agy": return SurfaceGemini, true case "kiro": return SurfaceKiro, true diff --git a/hatch/internal/model/registry.go b/hatch/internal/model/registry.go index deb4943..ac9fe14 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -12,6 +12,10 @@ type Agent struct { 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 diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go index 897671e..703543c 100644 --- a/hatch/internal/orchestrator/adapter.go +++ b/hatch/internal/orchestrator/adapter.go @@ -49,6 +49,7 @@ var adapters = map[string]Adapter{ "claude": claudeAdapter{}, "codex": codexAdapter{}, "gemini": geminiAdapter{}, + "agy": agyAdapter{}, "kiro": kiroAdapter{}, "antigravity": manualAdapter{kind: "antigravity", reason: "Antigravity is IDE-driven; no confirmed headless CLI"}, "mock": mockAdapter{}, diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go index a0e7c2c..4c8ed64 100644 --- a/hatch/internal/orchestrator/adapters.go +++ b/hatch/internal/orchestrator/adapters.go @@ -54,6 +54,31 @@ func (geminiAdapter) Build(req RunRequest) Invocation { return Invocation{Args: args, Headless: true} } +// agyAdapter drives Google's Antigravity CLI (`agy`, successor to Gemini CLI) +// headlessly: `agy -p`. Auth is via OAuth/OS-keyring (or ANTIGRAVITY_API_KEY); +// it reads GEMINI.md / AGENTS.md for project context. +type agyAdapter struct{} + +func (agyAdapter) Kind() string { return "agy" } +func (agyAdapter) Build(req RunRequest) Invocation { + args := []string{program(req.Agent, "agy"), "-p", req.Prompt, "--output-format", "json"} + if req.Agent.Model != "" { + args = append(args, "-m", req.Agent.Model) + } + switch req.Agent.Approval { + case "yolo": + args = append(args, "--yolo") + case "": + // default: let agy use its configured approval mode + default: + args = append(args, "--approval-mode", req.Agent.Approval) + } + if req.Agent.Sandbox != "" { + args = append(args, "--sandbox", req.Agent.Sandbox) + } + return Invocation{Args: args, Headless: true} +} + // 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{} diff --git a/hatch/internal/scaffold/templates/base/registry.yaml b/hatch/internal/scaffold/templates/base/registry.yaml index dfe6da7..d15b6a2 100644 --- a/hatch/internal/scaffold/templates/base/registry.yaml +++ b/hatch/internal/scaffold/templates/base/registry.yaml @@ -39,8 +39,8 @@ agents: kind: kiro roles: [architect, implementer] wip: 1 - - id: gemini - kind: gemini + - id: agy + kind: agy # Google Antigravity CLI (kế nhiệm gemini); dùng `kind: gemini` nếu vẫn xài Gemini CLI cũ roles: [implementer, tech-writer] wip: 1 From 3ca799c3a1ced07e91c70aaa7d4562fe88cc7dec Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 08:29:15 +0000 Subject: [PATCH 44/60] doctor: verified per-CLI auth-check commands; fix agy flags/auth from docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched each agent CLI's official docs for a non-interactive auth-status command (doctor runs it; never scans credential files): - claude → `claude auth status` (exit 0/1, JSON) - codex → `codex login status` (exit 0/1) - kiro → `kiro-cli whoami` (exit 0 = authed) - gemini (legacy) → no command; env-only - agy → no auth-status command and no API-key env (OAuth/keyring only) → doctor reports "?" rather than guessing Correct the agy adapter to the real flags (README/CHANGELOG): `-p`, `-m`, `--dangerously-skip-permissions` (former --yolo), `--sandbox`; drop the nonexistent `--output-format json`; note upstream #76 (stdout dropped on non-TTY). Remove the unconfirmed ANTIGRAVITY_API_KEY. docs/10 + 19 updated. Verified live: `hatch doctor` actually runs `claude auth status` (exit 0) on the installed claude CLI → ✓; absent CLIs show ?/✗ correctly. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/10-agent-adapters.md | 16 +++++++- hatch/docs/19-going-real.md | 2 +- hatch/internal/cli/doctor.go | 52 +++++++++++++++---------- hatch/internal/orchestrator/adapters.go | 27 +++++++------ 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/hatch/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md index 8a142b2..50176ea 100644 --- a/hatch/docs/10-agent-adapters.md +++ b/hatch/docs/10-agent-adapters.md @@ -15,11 +15,23 @@ Tài liệu tham chiếu cho **compiler** (ghi instruction ra đâu) và **orche | (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 — mặc định mới của Hatch. `kind: gemini` vẫn dùng được cho ai còn xài Gemini CLI cũ. Auth qua **OAuth/OS-keyring** (browser, hoặc device-code khi headless) hoặc env `ANTIGRAVITY_API_KEY`; token ở `~/.gemini/antigravity-cli/`. Cờ: `-p`, `--output-format json`, `-m`, `--approval-mode`/`--yolo`, `--sandbox`. +> **`agy`** (Antigravity CLI) là **kế nhiệm Gemini CLI** của Google — mặc định mới của Hatch. `kind: gemini` vẫn dùng cho Gemini CLI cũ. 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. -> **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` kiểm auth bằng cách **gọi chính lệnh của CLI** (vd `codex login status`) hoặc đọc env key — **không scan thư mục credential** (an toàn). CLI nào chưa có lệnh status non-interactive thì khai `auth_check` trong registry, hoặc doctor để "?". +## 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` | +| `gemini` | `GEMINI_API_KEY`/`GOOGLE_API_KEY` | *(không có lệnh — chỉ env)* | +| `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). --- diff --git a/hatch/docs/19-going-real.md b/hatch/docs/19-going-real.md index c7c8970..7f7577f 100644 --- a/hatch/docs/19-going-real.md +++ b/hatch/docs/19-going-real.md @@ -31,7 +31,7 @@ agents: ```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) — hoặc env ANTIGRAVITY_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). diff --git a/hatch/internal/cli/doctor.go b/hatch/internal/cli/doctor.go index 36fe418..6096611 100644 --- a/hatch/internal/cli/doctor.go +++ b/hatch/internal/cli/doctor.go @@ -15,12 +15,11 @@ import ( ) // envForKind names the credential env var each agent kind accepts (besides -// interactive OAuth/login). +// 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", "gemini": "GEMINI_API_KEY", - "agy": "ANTIGRAVITY_API_KEY", "kiro": "KIRO_API_KEY", } @@ -31,40 +30,51 @@ var defaultCmdForKind = map[string]string{ } // defaultAuthCheck is a non-mutating, scriptable command (argv) per kind that -// exits 0 when authenticated. Only commands known to be safe + non-interactive -// are listed; for others we don't guess (the user can set `auth_check`). +// exits 0 when authenticated — verified from each CLI's docs (see +// docs/10-agent-adapters.md). gemini (legacy) and agy have no such command. var defaultAuthCheck = map[string][]string{ - "codex": {"login", "status"}, + "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, "gemini": true, "agy": true, "kiro": true} + // authStatus reports how an agent authenticates, WITHOUT touching credential -// files: it honours an env key (which the user set), else runs the agent CLI's -// own auth-check command (configurable), else reports "unknown". +// 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, gemini) — reports unknown rather than guessing. func authStatus(a model.Agent, bin string, cliPresent bool) string { - if ev := envForKind[a.Kind]; ev != "" && os.Getenv(ev) != "" { + 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 envForKind[a.Kind] == "" { - return "—" + 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 "? set auth_check or run `" + bin + " login`" + return "✗ chưa login (`" + bin + " login`)" } - if !cliPresent { - return "? (CLI vắng)" + if !authKinds[a.Kind] { + return "—" // mock/manual/shell: no credential needed } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - c := exec.CommandContext(ctx, bin, check...) - c.Stdin = nil // never let the check block on interactive input - if err := c.Run(); err == nil { - return "✓ login (`" + bin + " " + joinArgs(check) + "`)" + // Needs auth but exposes no scriptable check (OAuth/keyring only). + if ev != "" { + return "? OAuth/keyring (hoặc set " + ev + ")" } - return "✗ chưa login (`" + bin + " login`)" + return "? OAuth/keyring (login bằng `" + bin + "`)" } func joinArgs(a []string) string { diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go index 4c8ed64..f219f81 100644 --- a/hatch/internal/orchestrator/adapters.go +++ b/hatch/internal/orchestrator/adapters.go @@ -55,28 +55,33 @@ func (geminiAdapter) Build(req RunRequest) Invocation { } // agyAdapter drives Google's Antigravity CLI (`agy`, successor to Gemini CLI) -// headlessly: `agy -p`. Auth is via OAuth/OS-keyring (or ANTIGRAVITY_API_KEY); -// it reads GEMINI.md / AGENTS.md for project context. +// 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, "--output-format", "json"} + 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": - args = append(args, "--yolo") - case "": - // default: let agy use its configured approval mode - default: - args = append(args, "--approval-mode", req.Agent.Approval) + case "yolo", "dangerous", "skip": + args = append(args, "--dangerously-skip-permissions") } if req.Agent.Sandbox != "" { - args = append(args, "--sandbox", 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", } - return Invocation{Args: args, Headless: true} } // kiroAdapter drives Kiro CLI headlessly: `kiro-cli chat --no-interactive`. From 95d57fc5721fa213bad6758d869cebe8b38b35ae Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 08:51:58 +0000 Subject: [PATCH 45/60] Remove the legacy gemini adapter (agy is the sole Google CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete geminiAdapter + its registry/surface/doctor entries and the gemini test; agy (Antigravity CLI) is the only supported Google agent. The GEMINI.md compile surface stays (agy reads GEMINI.md/AGENTS.md). - scrub gemini agent-kind references in docs/README/comments → agy. 18 packages pass; agy dry-run = `agy -p <prompt>` (verified). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/ARCHITECTURE.md | 2 +- hatch/README.md | 4 ++-- hatch/docs/08-roadmap.md | 2 +- hatch/docs/10-agent-adapters.md | 18 ++++++++---------- hatch/docs/19-going-real.md | 2 +- hatch/docs/overview.md | 2 +- hatch/internal/cli/doctor.go | 9 ++++----- hatch/internal/compile/compile_test.go | 2 +- hatch/internal/compile/surface.go | 4 ++-- hatch/internal/model/registry.go | 2 +- hatch/internal/orchestrator/adapter.go | 1 - hatch/internal/orchestrator/adapter_test.go | 12 ++++++++---- hatch/internal/orchestrator/adapters.go | 17 ----------------- .../scaffold/templates/base/registry.yaml | 2 +- 14 files changed, 31 insertions(+), 48 deletions(-) diff --git a/hatch/ARCHITECTURE.md b/hatch/ARCHITECTURE.md index 49404f7..16b0b08 100644 --- a/hatch/ARCHITECTURE.md +++ b/hatch/ARCHITECTURE.md @@ -50,7 +50,7 @@ them, asserted at compile time (`var _ port.Board = (*store.Board)(nil)`). the concrete adapters. - **`orchestrator.Orchestrator`** — holds `port.Ledger` + `port.Bus`; `Run`/ `Execute` are methods. The agent boundary itself is `orchestrator.Adapter` - (Claude/Codex/Gemini/Kiro + `mock` test adapter) — textbook ports & adapters. + (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, diff --git a/hatch/README.md b/hatch/README.md index 4d21de9..480c6b7 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -127,7 +127,7 @@ bin/hatch ceremony standup # digest theo agent + blockers → #sta bin/hatch ceremony retro --write # tổng kết chu kỳ + ứng viên đề bạt KB→SSOT bin/hatch escalate T-001 --why "kẹt gate 2 lần" # gọi senior/on-call (auto khi gate fail ≥2) bin/hatch pair T-001 --driver codex --navigator claude-code --rounds 3 # pair programming -bin/hatch mob T-001 --agents codex,claude-code,gemini # mob (driver xoay vòng) +bin/hatch mob T-001 --agents codex,claude-code,agy # mob (driver xoay vòng) bin/hatch ceremony demo · grooming # sprint review · backlog refinement bin/hatch presence set kiro --status offline --note PTO # availability → phân việc theo capacity bin/hatch oncall set --rotation claude-code,codex # lịch trực; escalation nhắm người trực @@ -135,7 +135,7 @@ bin/hatch oncall set --rotation claude-code,codex # lịch trực; escala ## Trạng thái implement -- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/Gemini/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. +- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/agy/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. - **Phase 3 — xong (cơ chế):** orchestrator + adapter cho từng agent (`hatch run`/`plan`/`watch`) dựng invocation headless đúng theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard (`hatch board`). Adapter `kiro`/`antigravity`/`manual` rơi về handoff khi không có headless. `--dry-run` cho phép kiểm tra invocation mà không cần cài agent. Có unit + integration test, CI, Makefile. Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc trong `docs/`. Kiến trúc (Lean Hexagonal, ports & adapters): [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/hatch/docs/08-roadmap.md b/hatch/docs/08-roadmap.md index 74eeef1..eeabbff 100644 --- a/hatch/docs/08-roadmap.md +++ b/hatch/docs/08-roadmap.md @@ -42,7 +42,7 @@ Orchestrator tự spawn đúng agent cho đúng ticket (xem [orchestrator](07-or **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/gemini/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. +> **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 diff --git a/hatch/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md index 50176ea..ee3a877 100644 --- a/hatch/docs/10-agent-adapters.md +++ b/hatch/docs/10-agent-adapters.md @@ -9,13 +9,12 @@ Tài liệu tham chiếu cho **compiler** (ghi instruction ra đâu) và **orche | 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 … --output-format json` | -| Gemini CLI *(legacy)* | `gemini` | `GEMINI.md` | `gemini -p` | +| **Antigravity CLI** | `agy` | `GEMINI.md` (cũng đọc `AGENTS.md`) | `agy -p <prompt>` | | 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 — mặc định mới của Hatch. `kind: gemini` vẫn dùng cho Gemini CLI cũ. 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. +> **`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. @@ -28,7 +27,6 @@ Agent CLI **không bắt buộc** — cài cái nào dùng cái nấy, chỉ c | `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` | -| `gemini` | `GEMINI_API_KEY`/`GOOGLE_API_KEY` | *(không có lệnh — chỉ env)* | | `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). @@ -69,17 +67,17 @@ CLI nào chưa có lệnh status non-interactive thì khai `auth_check: [...]` t **Headless.** `kiro-cli chat --no-interactive "<prompt>"`; 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+.) -## Gemini CLI (`kind: gemini`) +## Antigravity CLI (`kind: agy`) -**Instruction file.** `GEMINI.md` (global `~/.gemini/GEMINI.md` + project + nested, most-specific wins). Import `@path`. Có thể đọc `AGENTS.md` qua `settings.json`: `"context": { "fileName": ["AGENTS.md","GEMINI.md"] }`. +**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/`. -**Config.** `.gemini/settings.json` (project) > `~/.gemini/settings.json` (user) > system. `mcpServers` block hoặc `gemini mcp add`. +**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.** `gemini -p "<prompt>" -m <auto|pro|flash|flash-lite> --approval-mode <default|auto_edit|yolo|plan>` (hoặc `-y/--yolo`), `--include-directories`, `-s/--sandbox`, `-o/--output-format text|json|stream-json`. JSON: `{response, stats, error?}`. +**Headless.** `agy -p "<prompt>"` (alias `--print`); `-m`/`--model`; `--dangerously-skip-permissions` (thay `--yolo` đã bỏ); `--sandbox`; `--print-timeout <dur>`. **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 (`kind: antigravity`) +## Antigravity IDE (`kind: antigravity`) -IDE agent-first (Agent Manager; surfaces editor/terminal/browser) ra mắt ~11/2025, quanh Gemini 3. Đọc `GEMINI.md` + `AGENTS.md` + thư mục rules workspace (**`.agent/rules/`** — chưa chắc) + Skills. Global rules `~/.gemini/AGENTS.md`. +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. --- diff --git a/hatch/docs/19-going-real.md b/hatch/docs/19-going-real.md index 7f7577f..8e2218f 100644 --- a/hatch/docs/19-going-real.md +++ b/hatch/docs/19-going-real.md @@ -10,7 +10,7 @@ Cài cái nào bạn dùng (xem [10-agent-adapters](10-agent-adapters.md)): - **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|gemini|kiro`, khai thêm: +Trong `.hatch/registry.yaml`, đổi `kind: mock` về `claude|codex|agy|kiro`, khai thêm: ```yaml agents: - id: codex diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md index ff9d0ac..72877aa 100644 --- a/hatch/docs/overview.md +++ b/hatch/docs/overview.md @@ -14,7 +14,7 @@ Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs │ SURFACES ✓ CLAUDE.md · AGENTS.md · GEMINI.md · .kiro/steering (L0+L1+con trỏ) │ │ │ nạp instruction native │ │ ▼ │ -│ AGENTS ✓ claude · codex · gemini · kiro (mỗi con 1 process, KHÔNG chung RAM)│ +│ AGENTS ✓ claude · codex · agy · kiro (mỗi con 1 process, KHÔNG chung RAM)│ │ ▲ │ │ │ │ │ claim/move ──► WORKFLOW ENGINE ✓ (transition · gate · no-self-review · │ │ │ │ WIP · deps · auto-escalate) │ diff --git a/hatch/internal/cli/doctor.go b/hatch/internal/cli/doctor.go index 6096611..bc63c4b 100644 --- a/hatch/internal/cli/doctor.go +++ b/hatch/internal/cli/doctor.go @@ -19,19 +19,18 @@ import ( var envForKind = map[string]string{ "claude": "ANTHROPIC_API_KEY", "codex": "OPENAI_API_KEY", - "gemini": "GEMINI_API_KEY", "kiro": "KIRO_API_KEY", } // defaultCmdForKind is the executable each kind drives. var defaultCmdForKind = map[string]string{ - "claude": "claude", "codex": "codex", "gemini": "gemini", + "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). gemini (legacy) and agy have no such command. +// 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 @@ -39,12 +38,12 @@ var defaultAuthCheck = map[string][]string{ } // authKinds are agent kinds that require authentication (vs mock/manual/shell). -var authKinds = map[string]bool{"claude": true, "codex": true, "gemini": true, "agy": true, "kiro": true} +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, gemini) — reports unknown rather than guessing. +// (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) != "" { diff --git a/hatch/internal/compile/compile_test.go b/hatch/internal/compile/compile_test.go index 23e8f50..8ac90a0 100644 --- a/hatch/internal/compile/compile_test.go +++ b/hatch/internal/compile/compile_test.go @@ -27,7 +27,7 @@ func TestRunProducesSurfaces(t *testing.T) { if len(warnings) != 0 { t.Fatalf("unexpected warnings: %v", warnings) } - // default roster touches claude, codex, gemini, kiro → 4 surfaces. + // default roster touches claude, codex, agy, kiro → 4 surfaces. if len(res.Bundles) != 4 { t.Fatalf("expected 4 surfaces, got %d", len(res.Bundles)) } diff --git a/hatch/internal/compile/surface.go b/hatch/internal/compile/surface.go index 44a8df5..b37b0a4 100644 --- a/hatch/internal/compile/surface.go +++ b/hatch/internal/compile/surface.go @@ -18,7 +18,7 @@ type Surface struct { var ( SurfaceClaude = Surface{"claude", "Claude Code — CLAUDE.md"} SurfaceAgents = Surface{"agents", "AGENTS.md (Codex / Antigravity / Gemini-compat)"} - SurfaceGemini = Surface{"gemini", "Gemini CLI — GEMINI.md"} + SurfaceGemini = Surface{"gemini", "GEMINI.md (Antigravity / agy)"} SurfaceKiro = Surface{"kiro", "Kiro — .kiro/steering/"} ) @@ -30,7 +30,7 @@ func surfaceForKind(kind string) (Surface, bool) { return SurfaceClaude, true case "codex", "antigravity": return SurfaceAgents, true - case "gemini", "agy": + case "agy": return SurfaceGemini, true case "kiro": return SurfaceKiro, true diff --git a/hatch/internal/model/registry.go b/hatch/internal/model/registry.go index ac9fe14..e799d17 100644 --- a/hatch/internal/model/registry.go +++ b/hatch/internal/model/registry.go @@ -4,7 +4,7 @@ package model // 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 | gemini | kiro | shell | manual + 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 diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go index 703543c..c3742fe 100644 --- a/hatch/internal/orchestrator/adapter.go +++ b/hatch/internal/orchestrator/adapter.go @@ -48,7 +48,6 @@ func program(a model.Agent, def string) string { var adapters = map[string]Adapter{ "claude": claudeAdapter{}, "codex": codexAdapter{}, - "gemini": geminiAdapter{}, "agy": agyAdapter{}, "kiro": kiroAdapter{}, "antigravity": manualAdapter{kind: "antigravity", reason: "Antigravity is IDE-driven; no confirmed headless CLI"}, diff --git a/hatch/internal/orchestrator/adapter_test.go b/hatch/internal/orchestrator/adapter_test.go index a3031ae..a50c6a7 100644 --- a/hatch/internal/orchestrator/adapter_test.go +++ b/hatch/internal/orchestrator/adapter_test.go @@ -36,10 +36,14 @@ func TestCodexDefaultsSandbox(t *testing.T) { } } -func TestGeminiAndKiro(t *testing.T) { - g := strings.Join(build("gemini", nil).Args, " ") - if !strings.Contains(g, "gemini -p") || !strings.Contains(g, "--approval-mode auto_edit") { - t.Errorf("gemini invocation wrong: %q", g) +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") { diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go index f219f81..aa3effb 100644 --- a/hatch/internal/orchestrator/adapters.go +++ b/hatch/internal/orchestrator/adapters.go @@ -37,23 +37,6 @@ func (codexAdapter) Build(req RunRequest) Invocation { return Invocation{Args: args, Headless: true} } -// geminiAdapter drives Gemini CLI headlessly: `gemini -p`. -type geminiAdapter struct{} - -func (geminiAdapter) Kind() string { return "gemini" } -func (geminiAdapter) Build(req RunRequest) Invocation { - mode := req.Agent.Approval - if mode == "" { - mode = "auto_edit" - } - args := []string{program(req.Agent, "gemini"), "-p", req.Prompt, - "--approval-mode", mode, "--output-format", "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. diff --git a/hatch/internal/scaffold/templates/base/registry.yaml b/hatch/internal/scaffold/templates/base/registry.yaml index d15b6a2..0ea6b78 100644 --- a/hatch/internal/scaffold/templates/base/registry.yaml +++ b/hatch/internal/scaffold/templates/base/registry.yaml @@ -40,7 +40,7 @@ agents: roles: [architect, implementer] wip: 1 - id: agy - kind: agy # Google Antigravity CLI (kế nhiệm gemini); dùng `kind: gemini` nếu vẫn xài Gemini CLI cũ + kind: agy # Google Antigravity CLI (`agy`, kế nhiệm Gemini CLI). Auth: chạy `agy` để login (OAuth) roles: [implementer, tech-writer] wip: 1 From e06a2f59c3e28f2cb57917459541dfcd7df8bd45 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 09:09:15 +0000 Subject: [PATCH 46/60] =?UTF-8?q?onboard:=20stop=20forcing=20codex?= =?UTF-8?q?=E2=86=92mock;=20add=20a=20separate=20mock=20agent=20for=20the?= =?UTF-8?q?=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo previously rewrote `kind: codex` → `kind: mock`, which made `hatch doctor` show codex as "mock" and confused real-vs-demo. Now onboard inserts a dedicated `mock` agent (id: mock) and runs the demo with `--agent mock`, leaving the real agents (claude/codex/agy/kiro) exactly as scaffolded — so doctor reflects the real CLIs. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/scripts/onboard.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/hatch/scripts/onboard.sh b/hatch/scripts/onboard.sh index 769deee..093f822 100755 --- a/hatch/scripts/onboard.sh +++ b/hatch/scripts/onboard.sh @@ -58,14 +58,18 @@ if [ "$DO_DEMO" = "1" ]; then cd "$DEMO_DIR" git init -q hatch init -w scrum >/dev/null - # Point the implementer agent at the mock adapter so runs work without a real CLI. - sed -i.bak -E 's/kind: codex/kind: mock/' .hatch/registry.yaml && rm -f .hatch/registry.yaml.bak + # Add a dedicated `mock` agent so the demo runs at ZERO token cost — WITHOUT + # changing the real agents (claude/codex/agy/kiro stay exactly as scaffolded). + awk ' + /^agents:/ && !ins { print; print " - id: mock"; print " kind: mock"; print " roles: [implementer, tester]"; ins=1; next } + { print } + ' .hatch/registry.yaml > .hatch/registry.yaml.tmp && mv .hatch/registry.yaml.tmp .hatch/registry.yaml hatch compile >/dev/null 2>&1 - step "Seeding a ticket and running the mock agent" + step "Seeding a ticket and running the mock agent (real agents untouched)" hatch ticket new --title "Export báo cáo ra CSV" --role implementer --priority P1 >/dev/null - hatch ticket claim T-001 --agent codex --why "demo" >/dev/null - hatch run T-001 --agent codex >/dev/null 2>&1 || true + hatch ticket claim T-001 --agent mock --why "demo" >/dev/null + hatch run T-001 --agent mock >/dev/null 2>&1 || true step "Board" hatch status From 6febc153c5f832d2d520a5911945b9c4d82c5e01 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:20:04 +0000 Subject: [PATCH 47/60] Design doc: pivot to embedded harness (chat = comms + backlog, MCP-first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/20: correct the core direction. Hatch was built as an orchestrator that DRIVES agents and as the entrypoint TUI; the right model is the inverse — the coding agent is the entrypoint and drives itself; Hatch is the shared comms+memory it reaches via an MCP server (embedded harness). Chat is both the communication channel and the backlog (thread = task); `hatch board`/`chat` are read-only views. Documents what went wrong, what survives (bus/KB/ledger/clean arch/compile-repurposed), the MCP-vs-skill-vs-plugin analysis (MCP primary, all four agents support it), a proposed MCP tool surface, and a phased plan with an open decision on how aggressively to retire the operator features. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/20-embedded-harness-pivot.md | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 hatch/docs/20-embedded-harness-pivot.md diff --git a/hatch/docs/20-embedded-harness-pivot.md b/hatch/docs/20-embedded-harness-pivot.md new file mode 100644 index 0000000..2a1edc2 --- /dev/null +++ b/hatch/docs/20-embedded-harness-pivot.md @@ -0,0 +1,70 @@ +# 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 cần chốt +Mức độ "dọn" phần operator (orchestrator spawn · board control · workflow engine · ceremonies · cost/presence/oncall): +- **(A) Lean pivot:** xóa hẳn phần tự-lái; Hatch = chat/comms+backlog qua MCP + KB + ledger + chat/board read-only. Gọn, đúng tầm nhìn. +- **(B) Optional ops mode:** giữ phần tự-lái sau một namespace `hatch ops …` cho ai muốn Hatch chủ động chạy; mặc định là embedded chat. +- **(C) Giữ tất cả:** chỉ thêm MCP + chat read-only lên trên; phần cũ vẫn còn (rủi ro: lẫn lộn hai mô hình). From c02f1b95a450a1c9b6c90563f26e421499f39066 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:26:23 +0000 Subject: [PATCH 48/60] =?UTF-8?q?Finalize=20pivot=20decisions:=20MCP+plugi?= =?UTF-8?q?n,=20lean=20runtime,=20workflow=E2=86=92compiled=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/20-embedded-harness-pivot.md | 38 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/hatch/docs/20-embedded-harness-pivot.md b/hatch/docs/20-embedded-harness-pivot.md index 2a1edc2..de0b877 100644 --- a/hatch/docs/20-embedded-harness-pivot.md +++ b/hatch/docs/20-embedded-harness-pivot.md @@ -63,8 +63,36 @@ Mỗi agent chạy MCP server với danh tính của nó (`hatch mcp --as claude 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 cần chốt -Mức độ "dọn" phần operator (orchestrator spawn · board control · workflow engine · ceremonies · cost/presence/oncall): -- **(A) Lean pivot:** xóa hẳn phần tự-lái; Hatch = chat/comms+backlog qua MCP + KB + ledger + chat/board read-only. Gọn, đúng tầm nhìn. -- **(B) Optional ops mode:** giữ phần tự-lái sau một namespace `hatch ops …` cho ai muốn Hatch chủ động chạy; mặc định là embedded chat. -- **(C) Giữ tất cả:** chỉ thêm MCP + chat read-only lên trên; phần cũ vẫn còn (rủi ro: lẫn lộn hai mô hình). +## 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) +1. **`hatch mcp`** (stdio MCP server) trên bus/KB — tool: open_thread/post/reply/read/inbox/search/threads + kb.* + whoami(`--as`). *(lõi mới)* +2. **compile đổi mục đích**: sinh đăng ký MCP per-agent + tiêm protocol (charter+roles+workflow-prose+DoD+chat-etiquette) vào CLAUDE.md/AGENTS.md/GEMINI.md/.kiro. Lead agent nhận thêm khối "orchestrator". +3. **Claude plugin**: gói MCP + skill + slash (`/hatch`…). +4. **board/chat read-only**: bỏ control khỏi board. +5. **Prune runtime operator**: gỡ orchestrator-spawn/pickAgent/mux/workflow-engine/ceremonies-runtime/cost/presence/oncall (hoặc archive). Pair/mob/convene → mô tả trong protocol. +6. Giữ: bus · KB · ledger · clean arch · compile · registry/workflow/charter/roles/DoD as SSOT. From 9c1f7e837a98dd5eedeb34e42812c52b0d86154a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:36:10 +0000 Subject: [PATCH 49/60] hatch: add embedded MCP server (chat + KB) for coding agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `hatch mcp --as <agent>`, a stdio MCP server that exposes the shared chat bus and knowledge base as tools so any MCP-capable coding agent (Claude Code, Codex, agy, Kiro) can drive itself against the same comms + memory. This is the first step of the embedded-harness pivot (docs/20): the coding agent is the entrypoint; Hatch is the shared layer it reaches into. Chat doubles as the backlog — a thread is a task. Tools: whoami, chat_open, chat_post, chat_read, chat_inbox, chat_search, chat_channels, kb_add, kb_search. Posts are attributed to the --as identity, so each agent runs its own instance. Verified in-process via the SDK in-memory transport (round-trip, cross-agent @mention inbox, KB add/search). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/go.mod | 10 +- hatch/go.sum | 22 ++- hatch/internal/cli/mcp.go | 40 +++++ hatch/internal/cli/root.go | 1 + hatch/internal/mcpserver/server.go | 189 +++++++++++++++++++++++ hatch/internal/mcpserver/server_test.go | 194 ++++++++++++++++++++++++ hatch/internal/mcpserver/types.go | 76 ++++++++++ 7 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 hatch/internal/cli/mcp.go create mode 100644 hatch/internal/mcpserver/server.go create mode 100644 hatch/internal/mcpserver/server_test.go create mode 100644 hatch/internal/mcpserver/types.go diff --git a/hatch/go.mod b/hatch/go.mod index bbb6d0e..03a6acc 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -1,11 +1,12 @@ module github.com/fioenix/overclaud/hatch -go 1.24.7 +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 ) @@ -21,6 +22,7 @@ require ( 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 @@ -30,8 +32,12 @@ require ( 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 - golang.org/x/sys v0.38.0 // 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 index f79cba2..ebfa057 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -25,6 +25,12 @@ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV 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= @@ -35,6 +41,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J 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= @@ -44,20 +52,30 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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= diff --git a/hatch/internal/cli/mcp.go b/hatch/internal/cli/mcp.go new file mode 100644 index 0000000..7656503 --- /dev/null +++ b/hatch/internal/cli/mcp.go @@ -0,0 +1,40 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + + "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" + + "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 == "" { + return fmt.Errorf("--as <agent-id> is required (the identity this MCP server posts as)") + } + 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 (required)") + return cmd +} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 51eab0a..21cab55 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -62,6 +62,7 @@ func NewRoot() *cobra.Command { newReportCmd(), newTickCmd(), newDoctorCmd(), + newMCPCmd(), ) return root } 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"` +} From c16cb9cfb27993c0896f52545372cba42ab420d4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:42:03 +0000 Subject: [PATCH 50/60] hatch: compile injects chat protocol + registers MCP per agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repurpose `hatch compile` for the embedded-harness model (docs/20 step 2). Instead of operator instructions, each agent's surface now carries the protocol it self-follows: - Workflow rendered as prose (stages, who-does-what, gates, ceremonies) from workflow.yaml — guidance, not a Go engine. - Chat protocol: Slack-style etiquette over the Hatch MCP server (open a thread per task, brief in-thread, @mention, inbox, search, kb). - Definition of Done: a self-check derived from workflow command-gates + policy (no-self-review / human-merge) the agent runs before reporting. - Orchestrator block on the lead agent's surface only (the conductor). And it registers the `hatch mcp --as <agent>` server with each agent: merges into repo-local `.mcp.json` (Claude) and `.kiro/settings/mcp.json` (Kiro) preserving existing servers; writes paste-ready snippets under .hatch/mcp/ for Codex and agy whose configs live outside the repo. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/compile/bundle.go | 38 +++++- hatch/internal/compile/compile.go | 8 ++ hatch/internal/compile/compile_test.go | 86 ++++++++++++ hatch/internal/compile/mcp.go | 121 +++++++++++++++++ hatch/internal/compile/render.go | 173 +++++++++++++++++++++++-- 5 files changed, 412 insertions(+), 14 deletions(-) create mode 100644 hatch/internal/compile/mcp.go diff --git a/hatch/internal/compile/bundle.go b/hatch/internal/compile/bundle.go index 02f0c03..1434894 100644 --- a/hatch/internal/compile/bundle.go +++ b/hatch/internal/compile/bundle.go @@ -18,7 +18,8 @@ type RoleContent struct { } // Bundle is everything needed to render a surface: L0 charter, the L1 roles -// served on that surface, and the L2 pointers (not the content). +// 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 @@ -26,6 +27,10 @@ type Bundle struct { 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 @@ -54,6 +59,23 @@ func readRoleBody(l paths.Layout, role model.Role) string { 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 := "" @@ -77,6 +99,17 @@ func buildBundle(ws *config.Workspace, surf Surface, agents []model.Agent, roleI } } } + // 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, @@ -84,5 +117,8 @@ func buildBundle(ws *config.Workspace, surf Surface, agents []model.Agent, roleI 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 index 05d4a64..f82d086 100644 --- a/hatch/internal/compile/compile.go +++ b/hatch/internal/compile/compile.go @@ -104,6 +104,14 @@ func Run(ws *config.Workspace) (*Result, []string, error) { 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 } diff --git a/hatch/internal/compile/compile_test.go b/hatch/internal/compile/compile_test.go index 8ac90a0..68c7a0e 100644 --- a/hatch/internal/compile/compile_test.go +++ b/hatch/internal/compile/compile_test.go @@ -1,6 +1,7 @@ package compile import ( + "encoding/json" "os" "path/filepath" "strings" @@ -45,6 +46,91 @@ func TestRunProducesSurfaces(t *testing.T) { } } +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"}) diff --git a/hatch/internal/compile/mcp.go b/hatch/internal/compile/mcp.go new file mode 100644 index 0000000..94fc911 --- /dev/null +++ b/hatch/internal/compile/mcp.go @@ -0,0 +1,121 @@ +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 <agent>` 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": + p := filepath.Join(repoRoot, ".hatch", "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(repoRoot, ".hatch", "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)\n\n"+ + "agy đọc cấu hình MCP trong home (vd `~/.gemini/settings.json`), ngoài repo — "+ + "Hatch không tự sửa. Trộn khối `mcpServers` dưới đây vào file đó:\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 index 7a9a48c..1c856c5 100644 --- a/hatch/internal/compile/render.go +++ b/hatch/internal/compile/render.go @@ -2,7 +2,10 @@ 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. @@ -46,7 +49,7 @@ func Render(b Bundle) string { // 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 do ticket bạn claim quyết định** — đọc frontmatter `role` của ticket.\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 == "" { @@ -60,25 +63,31 @@ func Render(b Bundle) string { } } - // L2 — pointers only; the agent reads these on demand when it claims work. + // 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 claim ticket, đọc `context_refs` của ticket + các nguồn liên quan dưới đây. Tra Knowledge Base trước khi suy diễn lại.\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("- Board: `.hatch/board/<lane>/` — ticket bạn đang làm.\n") - w.WriteString("- Knowledge Base: `.hatch/kb/index.md` — quyết định & bài học chung.\n") - w.WriteString("- Protocol: `.hatch/protocol/` — claim/lock, handoff, DoD.\n\n") - - // Operating rules shared by every surface. - w.WriteString("## Operating protocol\n\n") - w.WriteString("- Một ticket một branch; claim = `git mv` vào `in-progress/` + push ngay (push thắng = lock thắng).\n") - w.WriteString("- Mọi chuyển trạng thái để lại entry ledger (`why` bắt buộc) trong cùng commit.\n") - w.WriteString("- Không sửa file ngoài `context_refs` đã khai; mở rộng scope thì cập nhật ticket trước.\n") - w.WriteString("- Đổi assignee/role ⇒ ghi Handoff notes. Tri thức đáng giữ ⇒ ghi KB.\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") { @@ -86,3 +95,141 @@ func Render(b Bundle) string { } 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=<id gốc>)` — 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") +} From 86685c8f70854376620b0bba10803c6700d57dd1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:45:29 +0000 Subject: [PATCH 51/60] hatch: add Claude Code plugin bundling MCP + skill + slash command Step 3 of the embedded-harness pivot (docs/20): a loadable/installable Claude Code plugin under hatch/plugin/ that wires Hatch into Claude: - .mcp.json launches `hatch mcp` (the shared chat + KB tools). - skills/hatch-chat teaches the squad etiquette (thread = task, @mention, recall-before-rederive, DoD self-check). - /hatch slash command syncs with the squad (inbox + open threads). - marketplace.json at repo root so `/plugin install hatch@hatch` works. To make the plugin workspace-agnostic, `hatch mcp` now resolves its identity when --as is omitted: $HATCH_AGENT, else the first claude-kind agent in the registry. So the plugin needs no per-project config. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- .claude-plugin/marketplace.json | 14 +++++++ hatch/internal/cli/mcp.go | 26 +++++++++++- hatch/plugin/.claude-plugin/plugin.json | 11 +++++ hatch/plugin/.mcp.json | 8 ++++ hatch/plugin/README.md | 46 +++++++++++++++++++++ hatch/plugin/commands/hatch.md | 20 ++++++++++ hatch/plugin/skills/hatch-chat/SKILL.md | 53 +++++++++++++++++++++++++ 7 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 hatch/plugin/.claude-plugin/plugin.json create mode 100644 hatch/plugin/.mcp.json create mode 100644 hatch/plugin/README.md create mode 100644 hatch/plugin/commands/hatch.md create mode 100644 hatch/plugin/skills/hatch-chat/SKILL.md 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/hatch/internal/cli/mcp.go b/hatch/internal/cli/mcp.go index 7656503..99c1c66 100644 --- a/hatch/internal/cli/mcp.go +++ b/hatch/internal/cli/mcp.go @@ -3,10 +3,12 @@ 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" ) @@ -17,6 +19,8 @@ func newMCPCmd() *cobra.Command { 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() @@ -24,7 +28,10 @@ func newMCPCmd() *cobra.Command { return err } if as == "" { - return fmt.Errorf("--as <agent-id> is required (the identity this MCP server posts as)") + as = resolveIdentity(ws) + } + if as == "" { + return fmt.Errorf("could not resolve identity: pass --as <agent-id>, 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) @@ -35,6 +42,21 @@ func newMCPCmd() *cobra.Command { return srv.Run(context.Background(), &mcp.StdioTransport{}) }, } - cmd.Flags().StringVar(&as, "as", "", "agent id this server acts as (required)") + 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/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`. From a75865fe24b5c2407539ac91935cf4842fa6ff1e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 10:59:14 +0000 Subject: [PATCH 52/60] hatch: read-only board/chat + archive the self-driving operator Steps 4-5 of the embedded-harness pivot (docs/20). Hatch no longer drives agents or enforces workflow in code; the coding agent drives itself through the MCP server, and Hatch is comms + memory + read-only views. Read-only views: - Rewrote the board TUI as mission control over THREADS (chat = tasks) + CHAT + ACTIVITY (ledger). Dropped the orchestrator LIVE pane, ticket lanes, presence, run-control and message composing. - Made the chat TUI a pure viewer (no compose/post). - Rewrote `status` to summarize chat threads + roster instead of board lanes/tickets. Archived behind the `hatch_legacy` build tag (recoverable, excluded from the default binary): run/plan/watch/tick, the orchestrator, the workflow engine (gate/escalate/ticket), ceremonies, ask/convene, pair/mob, presence, oncall, cost/budget, workload/perf, report. Default `hatch` keeps init/compile/validate/status/kb/board/chat/sync/hook/msg/channel/ inbox/thread/search/doc/logs/org/doctor/mcp. root.go registers the lean set and calls addLegacyCommands, which is a no-op by default and wires the archived commands under -tags hatch_legacy. Updated onboard.sh to demo the new flow; split the integration test into a default (embedded-harness) flow and the legacy one. Both build tags build, vet and test green. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/internal/ceremony/ceremony.go | 2 + hatch/internal/ceremony/ceremony_test.go | 2 + hatch/internal/cli/board.go | 8 +- hatch/internal/cli/ceremony.go | 2 + hatch/internal/cli/comms.go | 195 ------------- hatch/internal/cli/comms_legacy.go | 211 ++++++++++++++ hatch/internal/cli/cost.go | 2 + hatch/internal/cli/deps.go | 2 + hatch/internal/cli/escalate.go | 2 + hatch/internal/cli/gate.go | 2 + .../internal/cli/integration_default_test.go | 81 ++++++ hatch/internal/cli/integration_test.go | 7 +- hatch/internal/cli/metrics.go | 2 + hatch/internal/cli/mob.go | 2 + hatch/internal/cli/oncall.go | 2 + hatch/internal/cli/orchestrate.go | 2 + hatch/internal/cli/pair.go | 2 + hatch/internal/cli/presence.go | 2 + hatch/internal/cli/report.go | 2 + hatch/internal/cli/root.go | 25 +- hatch/internal/cli/root_default.go | 9 + hatch/internal/cli/root_legacy.go | 35 +++ hatch/internal/cli/standup.go | 2 + hatch/internal/cli/status.go | 83 +++--- hatch/internal/cli/tick.go | 2 + hatch/internal/cli/ticket.go | 2 + hatch/internal/decide/decide.go | 2 + hatch/internal/decide/decide_test.go | 2 + hatch/internal/gate/gate.go | 2 + hatch/internal/gate/gate_test.go | 2 + hatch/internal/metrics/metrics.go | 2 + hatch/internal/metrics/metrics_test.go | 2 + hatch/internal/mux/mux.go | 2 + hatch/internal/mux/mux_test.go | 2 + hatch/internal/oncall/oncall.go | 2 + hatch/internal/oncall/oncall_test.go | 2 + hatch/internal/orchestrator/adapter.go | 2 + hatch/internal/orchestrator/adapter_test.go | 2 + hatch/internal/orchestrator/adapters.go | 2 + hatch/internal/orchestrator/comm_test.go | 2 + hatch/internal/orchestrator/e2e_test.go | 2 + hatch/internal/orchestrator/prompt.go | 2 + hatch/internal/orchestrator/run.go | 2 + hatch/internal/orchestrator/transcript.go | 2 + hatch/internal/orchestrator/usage.go | 2 + hatch/internal/orchestrator/usage_test.go | 2 + hatch/internal/orchestrator/worktree.go | 2 + hatch/internal/presence/presence.go | 2 + hatch/internal/presence/presence_test.go | 2 + hatch/internal/tui/board.go | 271 +++++------------- hatch/internal/tui/chat.go | 90 ++---- hatch/internal/wf/engine.go | 2 + hatch/internal/wf/engine_test.go | 2 + hatch/internal/wf/escalate.go | 2 + hatch/internal/wf/escalate_test.go | 2 + hatch/internal/wf/ports.go | 2 + hatch/scripts/onboard.sh | 61 ++-- 57 files changed, 585 insertions(+), 581 deletions(-) create mode 100644 hatch/internal/cli/comms_legacy.go create mode 100644 hatch/internal/cli/integration_default_test.go create mode 100644 hatch/internal/cli/root_default.go create mode 100644 hatch/internal/cli/root_legacy.go diff --git a/hatch/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go index 2a8c226..d90d2bd 100644 --- a/hatch/internal/ceremony/ceremony.go +++ b/hatch/internal/ceremony/ceremony.go @@ -1,3 +1,5 @@ +//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). diff --git a/hatch/internal/ceremony/ceremony_test.go b/hatch/internal/ceremony/ceremony_test.go index 8a03b1a..cd6ee8e 100644 --- a/hatch/internal/ceremony/ceremony_test.go +++ b/hatch/internal/ceremony/ceremony_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package ceremony import ( diff --git a/hatch/internal/cli/board.go b/hatch/internal/cli/board.go index 5637f78..52f9077 100644 --- a/hatch/internal/cli/board.go +++ b/hatch/internal/cli/board.go @@ -9,7 +9,7 @@ import ( func newBoardCmd() *cobra.Command { cmd := &cobra.Command{ Use: "board", - Short: "Unified mission-control TUI (board + live output + ledger + chat)", + Short: "Read-only mission-control TUI (threads + chat + ledger)", RunE: func(cmd *cobra.Command, args []string) error { ws, err := loadWorkspace() if err != nil { @@ -23,19 +23,17 @@ func newBoardCmd() *cobra.Command { } func newChatCmd() *cobra.Command { - var as string cmd := &cobra.Command{ Use: "chat", - Short: "Slack-style TUI for agent communication (channels, messages, compose)", + 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, as).Run() + _, err = tui.NewChat(ws).Run() return err }, } - cmd.Flags().StringVar(&as, "as", "human:operator", "identity to post messages as") return cmd } diff --git a/hatch/internal/cli/ceremony.go b/hatch/internal/cli/ceremony.go index 8e5f6c7..b8c752d 100644 --- a/hatch/internal/cli/ceremony.go +++ b/hatch/internal/cli/ceremony.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/comms.go b/hatch/internal/cli/comms.go index f4e2dfb..657885a 100644 --- a/hatch/internal/cli/comms.go +++ b/hatch/internal/cli/comms.go @@ -3,14 +3,10 @@ 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" ) func newMsgCmd() *cobra.Command { @@ -322,197 +318,6 @@ func newThreadCmd() *cobra.Command { return cmd } -// 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 <question>", - 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:<name>)") - 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 <agent> để 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 -} - func oneLine(s string) string { s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") if len(s) > 100 { 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 <question>", + 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:<name>)") + 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 <agent> để phân xử)") + } + fmt.Fprintf(out, "\nmeeting recorded in thread %s\n", thread) + return nil + }, + } + cmd.Flags().StringVar(&decider, "decider", "", "agent that breaks a tie if no DECISION is reached") + cmd.Flags().StringVar(&thread, "thread", "", "thread id (default: generated)") + cmd.Flags().StringVar(&topic, "topic", "", "meeting topic (required)") + cmd.Flags().StringVar(&agentsCSV, "agents", "", "participant agent ids, comma-separated (required)") + cmd.Flags().StringVar(&chair, "chair", "", "who convenes (default human:facilitator)") + cmd.Flags().IntVar(&rounds, "rounds", 1, "number of discussion rounds") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show turn order/invocations without running agents") + cmd.Flags().DurationVar(&timeout, "timeout", 0, "per-turn timeout") + return cmd +} + +func turnType(body string) string { + if strings.HasPrefix(strings.TrimSpace(body), "DECISION:") { + return bus.TypeDecision + } + return bus.TypeMsg +} diff --git a/hatch/internal/cli/cost.go b/hatch/internal/cli/cost.go index ce96078..79f973d 100644 --- a/hatch/internal/cli/cost.go +++ b/hatch/internal/cli/cost.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/deps.go b/hatch/internal/cli/deps.go index 724dc6f..c34a08b 100644 --- a/hatch/internal/cli/deps.go +++ b/hatch/internal/cli/deps.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go index 78dfd3b..38f1111 100644 --- a/hatch/internal/cli/escalate.go +++ b/hatch/internal/cli/escalate.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/gate.go b/hatch/internal/cli/gate.go index 1b588d8..3c97b54 100644 --- a/hatch/internal/cli/gate.go +++ b/hatch/internal/cli/gate.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/integration_default_test.go b/hatch/internal/cli/integration_default_test.go new file mode 100644 index 0000000..16b1706 --- /dev/null +++ b/hatch/internal/cli/integration_default_test.go @@ -0,0 +1,81 @@ +//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) + + if out, err := run(t, "init", "-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 index ec2358a..7d3e3c3 100644 --- a/hatch/internal/cli/integration_test.go +++ b/hatch/internal/cli/integration_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( @@ -74,11 +76,6 @@ func TestLifecycleEndToEnd(t *testing.T) { t.Fatalf("transcript missing agent output:\n%s", logs) } - status, _ := run(t, "status") - if !strings.Contains(status, "T-001") || !strings.Contains(status, "in-progress") { - t.Fatalf("status wrong:\n%s", status) - } - report, err := run(t, "report") if err != nil { t.Fatalf("report: %v", err) diff --git a/hatch/internal/cli/metrics.go b/hatch/internal/cli/metrics.go index e7761d1..df60597 100644 --- a/hatch/internal/cli/metrics.go +++ b/hatch/internal/cli/metrics.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/mob.go b/hatch/internal/cli/mob.go index e2f9a58..a1babd8 100644 --- a/hatch/internal/cli/mob.go +++ b/hatch/internal/cli/mob.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/oncall.go b/hatch/internal/cli/oncall.go index 9f310d6..c8bf754 100644 --- a/hatch/internal/cli/oncall.go +++ b/hatch/internal/cli/oncall.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go index 1346eac..ffd9e3f 100644 --- a/hatch/internal/cli/orchestrate.go +++ b/hatch/internal/cli/orchestrate.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/pair.go b/hatch/internal/cli/pair.go index 507ffd4..1e8b569 100644 --- a/hatch/internal/cli/pair.go +++ b/hatch/internal/cli/pair.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/presence.go b/hatch/internal/cli/presence.go index 7f81318..0806a63 100644 --- a/hatch/internal/cli/presence.go +++ b/hatch/internal/cli/presence.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/report.go b/hatch/internal/cli/report.go index 311b83d..70d8483 100644 --- a/hatch/internal/cli/report.go +++ b/hatch/internal/cli/report.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 21cab55..952de27 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -23,18 +23,16 @@ func NewRoot() *cobra.Command { 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(), - newStandupCmd(), - newTicketCmd(), newKBCmd(), - newGateCmd(), - newRunCmd(), - newPlanCmd(), - newWatchCmd(), newBoardCmd(), newChatCmd(), newSyncCmd(), @@ -43,27 +41,14 @@ func NewRoot() *cobra.Command { newChannelCmd(), newInboxCmd(), newThreadCmd(), - newAskCmd(), - newConveneCmd(), newSearchCmd(), - newCeremonyCmd(), - newEscalateCmd(), - newPairCmd(), - newMobCmd(), - newPresenceCmd(), - newOncallCmd(), - newCostCmd(), - newBudgetCmd(), - newWorkloadCmd(), - newPerfCmd(), newDocCmd(), newLogsCmd(), newOrgCmd(), - newReportCmd(), - newTickCmd(), newDoctorCmd(), newMCPCmd(), ) + addLegacyCommands(root) return root } 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 index 0e678b3..10397fc 100644 --- a/hatch/internal/cli/standup.go +++ b/hatch/internal/cli/standup.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/status.go b/hatch/internal/cli/status.go index 7177b78..33a8273 100644 --- a/hatch/internal/cli/status.go +++ b/hatch/internal/cli/status.go @@ -3,80 +3,63 @@ package cli import ( "fmt" "text/tabwriter" + "time" "github.com/spf13/cobra" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/store" + "github.com/fioenix/overclaud/hatch/internal/bus" ) func newStatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "status", - Short: "Show the board: tickets per lane, assignees, WIP", + 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 := store.NewBoard(ws.Layout) + b := bus.New(ws.Layout) out := cmd.OutOrStdout() - for _, lane := range ws.Workflow.Lanes { - tickets, err := b.ListLane(lane.ID) - if err != nil { - return err - } - limit := "" - if lane.WIPLimit > 0 { - limit = fmt.Sprintf(" (WIP %d/%d)", len(tickets), lane.WIPLimit) - } - fmt.Fprintf(out, "\n## %s — %d%s\n", lane.ID, len(tickets), limit) - if len(tickets) == 0 { - continue - } + 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) - for _, t := range tickets { - assignee := t.Assignee - if assignee == "" { - assignee = "-" + 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") + } } - prio := t.Priority - if prio == "" { - prio = "--" - } - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", t.ID, prio, t.Role, assignee, t.Title) + fmt.Fprintf(tw, " #%s\t%d\t%s\t%s\n", ch, len(msgs), last, who) } tw.Flush() - for _, t := range tickets { - for _, e := range t.OpenExternal() { - fmt.Fprintf(out, " ⚠ %s blocked-external: %s (owner %s, eta %s)\n", t.ID, e.What, e.Owner, e.ETA) - } + } + + // 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) - printWIPWarnings(cmd, ws, b) return nil }, } return cmd } - -// printWIPWarnings flags agents holding more in-flight tickets than their limit. -func printWIPWarnings(cmd *cobra.Command, ws *config.Workspace, b *store.Board) { - tickets, err := b.List(ws.Workflow.LaneIDs()) - if err != nil { - return - } - perAgent := map[string]int{} - for _, t := range tickets { - if t.Assignee != "" && t.Lane != "done" { - perAgent[t.Assignee]++ - } - } - for _, a := range ws.Registry.Agents { - if a.WIP > 0 && perAgent[a.ID] > a.WIP { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %s over WIP (%d/%d)\n", a.ID, perAgent[a.ID], a.WIP) - } - } -} diff --git a/hatch/internal/cli/tick.go b/hatch/internal/cli/tick.go index 330f8eb..dbf0cfc 100644 --- a/hatch/internal/cli/tick.go +++ b/hatch/internal/cli/tick.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/cli/ticket.go b/hatch/internal/cli/ticket.go index 2bfce3e..f29c7c8 100644 --- a/hatch/internal/cli/ticket.go +++ b/hatch/internal/cli/ticket.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package cli import ( diff --git a/hatch/internal/decide/decide.go b/hatch/internal/decide/decide.go index 068b6cb..404adf1 100644 --- a/hatch/internal/decide/decide.go +++ b/hatch/internal/decide/decide.go @@ -1,3 +1,5 @@ +//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 diff --git a/hatch/internal/decide/decide_test.go b/hatch/internal/decide/decide_test.go index 51037a0..c2589ff 100644 --- a/hatch/internal/decide/decide_test.go +++ b/hatch/internal/decide/decide_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package decide import ( diff --git a/hatch/internal/gate/gate.go b/hatch/internal/gate/gate.go index b388662..53666a0 100644 --- a/hatch/internal/gate/gate.go +++ b/hatch/internal/gate/gate.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + // Package gate evaluates workflow gates: shell commands, checklists, required // fields, registry policies and human approvals. // diff --git a/hatch/internal/gate/gate_test.go b/hatch/internal/gate/gate_test.go index 27b6143..73e973b 100644 --- a/hatch/internal/gate/gate_test.go +++ b/hatch/internal/gate/gate_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package gate import ( diff --git a/hatch/internal/metrics/metrics.go b/hatch/internal/metrics/metrics.go index ca126f4..8e66d71 100644 --- a/hatch/internal/metrics/metrics.go +++ b/hatch/internal/metrics/metrics.go @@ -1,3 +1,5 @@ +//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). diff --git a/hatch/internal/metrics/metrics_test.go b/hatch/internal/metrics/metrics_test.go index f7aa813..00973a1 100644 --- a/hatch/internal/metrics/metrics_test.go +++ b/hatch/internal/metrics/metrics_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package metrics import ( diff --git a/hatch/internal/mux/mux.go b/hatch/internal/mux/mux.go index 1c464b2..60d8822 100644 --- a/hatch/internal/mux/mux.go +++ b/hatch/internal/mux/mux.go @@ -1,3 +1,5 @@ +//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 diff --git a/hatch/internal/mux/mux_test.go b/hatch/internal/mux/mux_test.go index 25f07ae..b8f9660 100644 --- a/hatch/internal/mux/mux_test.go +++ b/hatch/internal/mux/mux_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package mux import ( diff --git a/hatch/internal/oncall/oncall.go b/hatch/internal/oncall/oncall.go index 4f98c15..bee002d 100644 --- a/hatch/internal/oncall/oncall.go +++ b/hatch/internal/oncall/oncall.go @@ -1,3 +1,5 @@ +//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 diff --git a/hatch/internal/oncall/oncall_test.go b/hatch/internal/oncall/oncall_test.go index e8cab74..26aed3a 100644 --- a/hatch/internal/oncall/oncall_test.go +++ b/hatch/internal/oncall/oncall_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package oncall import ( diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go index c3742fe..9e14dd7 100644 --- a/hatch/internal/orchestrator/adapter.go +++ b/hatch/internal/orchestrator/adapter.go @@ -1,3 +1,5 @@ +//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 diff --git a/hatch/internal/orchestrator/adapter_test.go b/hatch/internal/orchestrator/adapter_test.go index a50c6a7..e8ed88f 100644 --- a/hatch/internal/orchestrator/adapter_test.go +++ b/hatch/internal/orchestrator/adapter_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go index aa3effb..b84faf9 100644 --- a/hatch/internal/orchestrator/adapters.go +++ b/hatch/internal/orchestrator/adapters.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator // Per-kind adapters. Flags mirror docs/10-agent-adapters.md. Capability is diff --git a/hatch/internal/orchestrator/comm_test.go b/hatch/internal/orchestrator/comm_test.go index e9e2d00..e02bc24 100644 --- a/hatch/internal/orchestrator/comm_test.go +++ b/hatch/internal/orchestrator/comm_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/e2e_test.go b/hatch/internal/orchestrator/e2e_test.go index 9d23686..0c054cd 100644 --- a/hatch/internal/orchestrator/e2e_test.go +++ b/hatch/internal/orchestrator/e2e_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/prompt.go b/hatch/internal/orchestrator/prompt.go index eab2c83..c4d0192 100644 --- a/hatch/internal/orchestrator/prompt.go +++ b/hatch/internal/orchestrator/prompt.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go index 7465d97..6d7f1ef 100644 --- a/hatch/internal/orchestrator/run.go +++ b/hatch/internal/orchestrator/run.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/transcript.go b/hatch/internal/orchestrator/transcript.go index 3f23c2b..67e0460 100644 --- a/hatch/internal/orchestrator/transcript.go +++ b/hatch/internal/orchestrator/transcript.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/usage.go b/hatch/internal/orchestrator/usage.go index 03ee574..9e0ad62 100644 --- a/hatch/internal/orchestrator/usage.go +++ b/hatch/internal/orchestrator/usage.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/orchestrator/usage_test.go b/hatch/internal/orchestrator/usage_test.go index 44a892b..0721a61 100644 --- a/hatch/internal/orchestrator/usage_test.go +++ b/hatch/internal/orchestrator/usage_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import "testing" diff --git a/hatch/internal/orchestrator/worktree.go b/hatch/internal/orchestrator/worktree.go index bea1ede..ecda3b0 100644 --- a/hatch/internal/orchestrator/worktree.go +++ b/hatch/internal/orchestrator/worktree.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package orchestrator import ( diff --git a/hatch/internal/presence/presence.go b/hatch/internal/presence/presence.go index f2f344c..f8ff01a 100644 --- a/hatch/internal/presence/presence.go +++ b/hatch/internal/presence/presence.go @@ -1,3 +1,5 @@ +//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. diff --git a/hatch/internal/presence/presence_test.go b/hatch/internal/presence/presence_test.go index e909f52..47f4979 100644 --- a/hatch/internal/presence/presence_test.go +++ b/hatch/internal/presence/presence_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package presence import ( diff --git a/hatch/internal/tui/board.go b/hatch/internal/tui/board.go index b3d9916..9f07df9 100644 --- a/hatch/internal/tui/board.go +++ b/hatch/internal/tui/board.go @@ -1,38 +1,32 @@ -// Package tui renders interactive dashboards with Bubble Tea. `hatch board` is -// the unified mission control: BOARD + LIVE agent output + ACTIVITY (ledger) + -// CHAT (the communication bus), in one process. `hatch chat` (chat.go) is a -// focused stand-alone chat view sharing the same widgets. +// 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" - "io" "os" - "path/filepath" "sort" "strings" "time" - "github.com/charmbracelet/bubbles/textinput" "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/model" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" - "github.com/fioenix/overclaud/hatch/internal/presence" "github.com/fioenix/overclaud/hatch/internal/store" ) type pane int const ( - paneBoard pane = iota - paneLive - paneActivity + paneThreads pane = iota paneChat + paneActivity numPanes ) @@ -45,30 +39,23 @@ var ( blurred = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")) ) -type tref struct { - lane string - t model.Ticket +// 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 - board *store.Board - ledger *store.Ledger bus *bus.Bus + ledger *store.Ledger - refs []tref + threads []threadStat sel int focus pane - live viewport.Model - activity viewport.Model chat viewport.Model - liveTick string - - channels []string - chatSel int - input textinput.Model - composing bool - chatAs string + activity viewport.Model w, h int status string @@ -76,23 +63,17 @@ type m struct { } type tickMsg time.Time -type ranMsg struct { - ticket string - err error -} func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } -// New returns the unified mission-control dashboard program. +// New returns the read-only mission-control dashboard program. func New(ws *config.Workspace) *tea.Program { - ti := textinput.New() - ti.Placeholder = "message (@mention để tag) — Enter gửi, Esc huỷ" - ti.CharLimit = 2000 mm := m{ - ws: ws, board: store.NewBoard(ws.Layout), ledger: store.NewLedger(ws.Layout), - bus: bus.New(ws.Layout), input: ti, chatAs: "human:operator", + ws: ws, + bus: bus.New(ws.Layout), + ledger: store.NewLedger(ws.Layout), } return tea.NewProgram(&mm, tea.WithAltScreen()) } @@ -100,61 +81,34 @@ func New(ws *config.Workspace) *tea.Program { func (mm *m) Init() tea.Cmd { return tick() } func (mm *m) reload() { - var refs []tref - for _, lane := range mm.ws.Workflow.Lanes { - ts, _ := mm.board.ListLane(lane.ID) - for _, t := range ts { - refs = append(refs, tref{lane.ID, t}) - } - } - mm.refs = refs - if mm.sel >= len(refs) { - mm.sel = max(0, len(refs)-1) - } - if mm.liveTick == "" && len(refs) > 0 { - mm.liveTick = refs[mm.sel].t.ID - } chs, _ := mm.bus.Channels() sort.Strings(chs) - mm.channels = chs - if mm.chatSel >= len(chs) { - mm.chatSel = max(0, len(chs)-1) + 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.live.SetContent(mm.transcript(mm.liveTick)) - mm.activity.SetContent(mm.activityFeed()) mm.chat.SetContent(mm.chatFeed()) + mm.activity.SetContent(mm.activityFeed()) } func (mm *m) curChannel() string { - if mm.chatSel < len(mm.channels) { - return mm.channels[mm.chatSel] + if mm.sel < len(mm.threads) { + return mm.threads[mm.sel].name } return "" } -func (mm *m) transcript(ticket string) string { - if ticket == "" { - return dim.Render("(chọn ticket, nhấn f để theo dõi live output)") - } - dir := mm.ws.Layout.Runs(ticket) - ents, err := os.ReadDir(dir) - if err != nil || len(ents) == 0 { - return dim.Render("(chưa có run nào cho " + ticket + ")") - } - var logs []string - for _, e := range ents { - if strings.HasSuffix(e.Name(), ".log") { - logs = append(logs, e.Name()) - } - } - if len(logs) == 0 { - return dim.Render("(chưa có transcript)") - } - sort.Strings(logs) - raw, _ := os.ReadFile(filepath.Join(dir, logs[len(logs)-1])) - return string(raw) -} - func (mm *m) activityFeed() string { files, _ := mm.ledger.Files() if len(files) == 0 { @@ -171,11 +125,11 @@ func (mm *m) activityFeed() string { func (mm *m) chatFeed() string { ch := mm.curChannel() if ch == "" { - return dim.Render("(chưa có channel — nhấn i để gửi, c để đổi channel)") + return dim.Render("(chưa có thread nào)") } msgs, err := mm.bus.Messages(ch) if err != nil || len(msgs) == 0 { - return dim.Render("(channel trống)") + return dim.Render("(thread trống)") } var b strings.Builder for _, msg := range msgs { @@ -194,14 +148,12 @@ func (mm *m) chatFeed() string { func (mm *m) layout() { rightW := mm.w - mm.w/2 - 4 - vpH := (mm.h - 9) / 3 + vpH := (mm.h - 6) / 2 if vpH < 3 { vpH = 3 } - mm.live = viewport.New(rightW, vpH) - mm.activity = viewport.New(rightW, vpH) mm.chat = viewport.New(rightW, vpH) - mm.input.Width = rightW - 4 + mm.activity = viewport.New(rightW, vpH) mm.ready = true } @@ -212,21 +164,11 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mm.layout() mm.reload() case tickMsg: - if mm.ready && !mm.composing { + if mm.ready { mm.reload() - mm.chat.GotoBottom() } return mm, tick() - case ranMsg: - if msg.err != nil { - mm.status = "run " + msg.ticket + " lỗi: " + msg.err.Error() - } else { - mm.status = "run " + msg.ticket + " xong" - } case tea.KeyMsg: - if mm.composing { - return mm.updateCompose(msg) - } switch msg.String() { case "q", "ctrl+c", "esc": return mm, tea.Quit @@ -236,25 +178,6 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mm.scrollUp() case "down", "j": mm.scrollDown() - case "c": - if mm.focus == paneChat && len(mm.channels) > 0 { - mm.chatSel = (mm.chatSel + 1) % len(mm.channels) - mm.chat.SetContent(mm.chatFeed()) - } - case "i": - if mm.focus == paneChat && mm.curChannel() != "" { - mm.composing = true - mm.input.Focus() - } - case "f": - if mm.focus == paneBoard && mm.sel < len(mm.refs) { - mm.liveTick = mm.refs[mm.sel].t.ID - mm.status = "theo dõi " + mm.liveTick - } - case "r": - if mm.sel < len(mm.refs) { - return mm, mm.runSelected() - } case "g": mm.reload() } @@ -262,77 +185,31 @@ func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return mm, nil } -func (mm *m) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": - mm.composing = false - mm.input.Blur() - mm.input.SetValue("") - case "enter": - if v := strings.TrimSpace(mm.input.Value()); v != "" && mm.curChannel() != "" { - if _, err := mm.bus.Post(bus.Message{Channel: mm.curChannel(), From: mm.chatAs, To: []string{mm.curChannel()}, Body: v}); err != nil { - mm.status = "lỗi gửi: " + err.Error() - } else { - mm.status = "đã gửi tới " + mm.curChannel() - } - } - mm.composing = false - mm.input.Blur() - mm.input.SetValue("") - mm.chat.SetContent(mm.chatFeed()) - mm.chat.GotoBottom() - default: - var cmd tea.Cmd - mm.input, cmd = mm.input.Update(msg) - return mm, cmd - } - return mm, nil -} - func (mm *m) scrollUp() { switch mm.focus { - case paneBoard: + case paneThreads: if mm.sel > 0 { mm.sel-- + mm.chat.SetContent(mm.chatFeed()) } - case paneLive: - mm.live.LineUp(1) - case paneActivity: - mm.activity.LineUp(1) case paneChat: mm.chat.LineUp(1) + case paneActivity: + mm.activity.LineUp(1) } } func (mm *m) scrollDown() { switch mm.focus { - case paneBoard: - if mm.sel < len(mm.refs)-1 { + case paneThreads: + if mm.sel < len(mm.threads)-1 { mm.sel++ + mm.chat.SetContent(mm.chatFeed()) } - case paneLive: - mm.live.LineDown(1) - case paneActivity: - mm.activity.LineDown(1) case paneChat: mm.chat.LineDown(1) - } -} - -func (mm *m) runSelected() tea.Cmd { - ref := mm.refs[mm.sel] - mm.liveTick = ref.t.ID - agent, ok := pick(mm.ws, ref.t.Role) - if !ok { - mm.status = "không có agent rảnh cho vai " + ref.t.Role - return nil - } - mm.status = "đang chạy " + agent.ID + " trên " + ref.t.ID + "…" - ws, t, role := mm.ws, ref.t, ref.t.Role - o := orchestrator.Orchestrator{Ledger: store.NewLedger(ws.Layout), Bus: bus.New(ws.Layout)} - return func() tea.Msg { - _, err := o.Run(ws, agent, t, role, orchestrator.RunOptions{Stdout: io.Discard}) - return ranMsg{ticket: t.ID, err: err} + case paneActivity: + mm.activity.LineDown(1) } } @@ -345,36 +222,28 @@ func (mm *m) View() string { project = "Hatch" } header := hdr.Render(project+" — mission control") + " " + - dim.Render(fmt.Sprintf("(%s · %d tickets)", mm.ws.Workflow.Template, len(mm.refs))) + dim.Render(fmt.Sprintf("(read-only · %d threads)", len(mm.threads))) - // Board pane. - var bd strings.Builder - curLane := "" - for i, r := range mm.refs { - if r.lane != curLane { - curLane = r.lane - bd.WriteString(laneSty.Render(curLane) + "\n") - } - line := fmt.Sprintf(" %-7s %-11s %s", r.t.ID, r.t.Assignee, trunc(r.t.Title, 20)) + // 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("▸ " + strings.TrimLeft(line, " ")) + line = selSty.Render("▸ #" + trunc(t.name, 20) + fmt.Sprintf(" %d %s", t.count, t.last)) } - bd.WriteString(line + "\n") + th.WriteString(line + "\n") } - boardBox := paneBox(mm.focus == paneBoard, "BOARD", bd.String(), mm.w/2-2, mm.h-4) + threadsBox := paneBox(mm.focus == paneThreads, "THREADS (tasks)", th.String(), mm.w/2-2, mm.h-4) - liveBox := paneBox(mm.focus == paneLive, "LIVE · "+mm.liveTick, mm.live.View(), 0, 0) + 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) - chatTitle := "CHAT · #" + mm.curChannel() - chatBody := mm.chat.View() - if mm.composing { - chatBody += "\n" + mm.input.View() - } - chatBox := paneBox(mm.focus == paneChat, chatTitle, chatBody, 0, 0) - right := lipgloss.JoinVertical(lipgloss.Left, liveBox, actBox, chatBox) - body := lipgloss.JoinHorizontal(lipgloss.Top, boardBox, right) + right := lipgloss.JoinVertical(lipgloss.Left, chatBox, actBox) + body := lipgloss.JoinHorizontal(lipgloss.Top, threadsBox, right) - foot := dim.Render("tab pane · ↑/↓ move·scroll · f follow · r run · c channel · i chat · g refresh · q quit") + foot := dim.Render("tab pane · ↑/↓ move·scroll · g refresh · q quit") if mm.status != "" { foot = selSty.Render(mm.status) + " " + foot } @@ -395,16 +264,6 @@ func paneBox(focus bool, title, content string, w, h int) string { return style.Render(laneSty.Render(title) + "\n" + content) } -func pick(ws *config.Workspace, role string) (model.Agent, bool) { - pres := presence.Load(ws.Layout) - for _, a := range ws.Registry.AgentsForRole(role) { - if pres.CanTakeWork(a.ID) { - return a, true - } - } - return model.Agent{}, false -} - func trunc(s string, n int) string { if len([]rune(s)) <= n { return s diff --git a/hatch/internal/tui/chat.go b/hatch/internal/tui/chat.go index 3c8c9d1..e32e03d 100644 --- a/hatch/internal/tui/chat.go +++ b/hatch/internal/tui/chat.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -23,29 +22,21 @@ const ( ) type chat struct { - ws *config.Workspace - bus *bus.Bus - channels []string - sel int - focus chatFocus - msgs viewport.Model - input textinput.Model - as string - composing bool - w, h int - status string - ready bool + ws *config.Workspace + bus *bus.Bus + channels []string + sel int + focus chatFocus + msgs viewport.Model + w, h int + ready bool } -// NewChat returns a Slack-style TUI for the agent communication bus. -func NewChat(ws *config.Workspace, as string) *tea.Program { - if as == "" { - as = "human:operator" - } - ti := textinput.New() - ti.Placeholder = "message (@mention để tag) — Enter gửi, Esc huỷ" - ti.CharLimit = 2000 - c := &chat{ws: ws, bus: bus.New(ws.Layout), as: as, input: ti} +// 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()) } @@ -71,11 +62,11 @@ func (c *chat) current() string { func (c *chat) renderMessages() string { ch := c.current() if ch == "" { - return dim.Render("(chưa có channel — gửi message để tạo)") + 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("(channel trống)") + return dim.Render("(thread trống)") } var b strings.Builder for _, m := range msgs { @@ -97,8 +88,7 @@ func (c *chat) renderMessages() string { func (c *chat) layout() { mw := c.w - c.w/4 - 4 - c.msgs = viewport.New(mw, c.h-7) - c.input.Width = mw - 4 + c.msgs = viewport.New(mw, c.h-5) c.ready = true } @@ -109,41 +99,14 @@ func (c *chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.layout() c.reload() case tickMsg: - if c.ready && !c.composing { + if c.ready { c.reload() c.msgs.GotoBottom() } return c, tick() case tea.KeyMsg: - if c.composing { - switch msg.String() { - case "esc": - c.composing = false - c.input.Blur() - c.input.SetValue("") - case "enter": - if v := strings.TrimSpace(c.input.Value()); v != "" && c.current() != "" { - _, err := c.bus.Post(bus.Message{Channel: c.current(), From: c.as, To: []string{c.current()}, Body: v}) - if err != nil { - c.status = "lỗi gửi: " + err.Error() - } else { - c.status = "đã gửi tới " + c.current() - } - } - c.composing = false - c.input.Blur() - c.input.SetValue("") - c.reload() - c.msgs.GotoBottom() - default: - var cmd tea.Cmd - c.input, cmd = c.input.Update(msg) - return c, cmd - } - return c, nil - } switch msg.String() { - case "q", "ctrl+c": + case "q", "ctrl+c", "esc": return c, tea.Quit case "tab": c.focus = (c.focus + 1) % 2 @@ -161,11 +124,6 @@ func (c *chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { c.msgs.LineDown(1) } - case "i": - if c.current() != "" { - c.composing = true - c.input.Focus() - } case "g": c.reload() } @@ -181,7 +139,7 @@ func (c *chat) View() string { if project == "" { project = "Hatch" } - header := hdr.Render(project+" — chat") + " " + dim.Render("as "+c.as) + header := hdr.Render(project+" — chat") + " " + dim.Render("(read-only)") // Channel list. var cl strings.Builder @@ -199,15 +157,7 @@ func (c *chat) View() string { msgBox := paneBox(c.focus == focusMessages, "#"+c.current(), c.msgs.View(), 0, 0) body := lipgloss.JoinHorizontal(lipgloss.Top, chanBox, msgBox) - var foot string - if c.composing { - foot = c.input.View() - } else { - foot = dim.Render("tab pane · ↑/↓ channel·scroll · i soạn · g refresh · q quit") - if c.status != "" { - foot = selSty.Render(c.status) + " " + foot - } - } + foot := dim.Render("tab pane · ↑/↓ channel·scroll · g refresh · q quit") return header + "\n" + body + "\n" + foot } diff --git a/hatch/internal/wf/engine.go b/hatch/internal/wf/engine.go index 63fb4e9..dfadcff 100644 --- a/hatch/internal/wf/engine.go +++ b/hatch/internal/wf/engine.go @@ -1,3 +1,5 @@ +//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 diff --git a/hatch/internal/wf/engine_test.go b/hatch/internal/wf/engine_test.go index 60879de..82c413a 100644 --- a/hatch/internal/wf/engine_test.go +++ b/hatch/internal/wf/engine_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package wf import ( diff --git a/hatch/internal/wf/escalate.go b/hatch/internal/wf/escalate.go index 079a0b4..5073638 100644 --- a/hatch/internal/wf/escalate.go +++ b/hatch/internal/wf/escalate.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package wf import ( diff --git a/hatch/internal/wf/escalate_test.go b/hatch/internal/wf/escalate_test.go index 2e6becd..8a60387 100644 --- a/hatch/internal/wf/escalate_test.go +++ b/hatch/internal/wf/escalate_test.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package wf import ( diff --git a/hatch/internal/wf/ports.go b/hatch/internal/wf/ports.go index 371e09d..a0699bf 100644 --- a/hatch/internal/wf/ports.go +++ b/hatch/internal/wf/ports.go @@ -1,3 +1,5 @@ +//go:build hatch_legacy + package wf import "github.com/fioenix/overclaud/hatch/internal/port" diff --git a/hatch/scripts/onboard.sh b/hatch/scripts/onboard.sh index 093f822..46909d4 100755 --- a/hatch/scripts/onboard.sh +++ b/hatch/scripts/onboard.sh @@ -34,17 +34,16 @@ if ! command -v go >/dev/null 2>&1; then fi go version -step "Building hatch + hatch-mock" +step "Building hatch" cd "$HATCH_DIR" make build BIN="$HATCH_DIR/bin" -echo "built: $BIN/hatch, $BIN/hatch-mock" +echo "built: $BIN/hatch" if [ "$DO_INSTALL" = "1" ]; then step "Installing to GOPATH/bin (go install)" make install - go build -o "$(go env GOPATH)/bin/hatch-mock" ./cmd/hatch-mock - echo "installed hatch + hatch-mock to $(go env GOPATH)/bin" + echo "installed hatch to $(go env GOPATH)/bin" echo "ensure that dir is on your PATH: export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" fi @@ -58,37 +57,39 @@ if [ "$DO_DEMO" = "1" ]; then cd "$DEMO_DIR" git init -q hatch init -w scrum >/dev/null - # Add a dedicated `mock` agent so the demo runs at ZERO token cost — WITHOUT - # changing the real agents (claude/codex/agy/kiro stay exactly as scaffolded). - awk ' - /^agents:/ && !ins { print; print " - id: mock"; print " kind: mock"; print " roles: [implementer, tester]"; ins=1; next } - { print } - ' .hatch/registry.yaml > .hatch/registry.yaml.tmp && mv .hatch/registry.yaml.tmp .hatch/registry.yaml - hatch compile >/dev/null 2>&1 - - step "Seeding a ticket and running the mock agent (real agents untouched)" - hatch ticket new --title "Export báo cáo ra CSV" --role implementer --priority P1 >/dev/null - hatch ticket claim T-001 --agent mock --why "demo" >/dev/null - hatch run T-001 --agent mock >/dev/null 2>&1 || true - - step "Board" + + 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 "Run transcript (hatch logs)" - hatch logs T-001 | sed 's/^/ /' - step "Cost + perf" - hatch cost T-001 - hatch perf 2>/dev/null | sed 's/^/ /' | head -4 + + 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 -Try next (from that dir, with $BIN on PATH): - hatch board # the multi-pane TUI (board + live output + activity) - hatch ticket move T-001 --to review --by implementer --agent codex --why done --skip-gates - hatch msg --from codex -c '#design' "@claude-code chốt streaming nhé" - hatch convene --topic "Thiết kế export" --agents codex,claude-code --rounds 1 - hatch workload ; hatch budget +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 +Docs: $HATCH_DIR/docs/overview.md, $HATCH_DIR/docs/20-embedded-harness-pivot.md EOF fi From 0d641d13e97af2b39a78086c59e4f41fea8e542b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:00:44 +0000 Subject: [PATCH 53/60] hatch: tidy go.mod (drop clipboard) + mark pivot roadmap done The read-only TUI rewrite removed the last textinput usage, so clipboard is no longer needed; `go mod tidy` prunes it (fixes the CI tidiness check). Also mark docs/20 roadmap steps 1-6 complete. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/20-embedded-harness-pivot.md | 17 ++++++++++------- hatch/go.mod | 1 - hatch/go.sum | 2 -- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hatch/docs/20-embedded-harness-pivot.md b/hatch/docs/20-embedded-harness-pivot.md index de0b877..ec58e64 100644 --- a/hatch/docs/20-embedded-harness-pivot.md +++ b/hatch/docs/20-embedded-harness-pivot.md @@ -89,10 +89,13 @@ Workflow templates (scrum/kanban/spec-first/…) **vẫn giữ**, nhưng từ "c - 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) -1. **`hatch mcp`** (stdio MCP server) trên bus/KB — tool: open_thread/post/reply/read/inbox/search/threads + kb.* + whoami(`--as`). *(lõi mới)* -2. **compile đổi mục đích**: sinh đăng ký MCP per-agent + tiêm protocol (charter+roles+workflow-prose+DoD+chat-etiquette) vào CLAUDE.md/AGENTS.md/GEMINI.md/.kiro. Lead agent nhận thêm khối "orchestrator". -3. **Claude plugin**: gói MCP + skill + slash (`/hatch`…). -4. **board/chat read-only**: bỏ control khỏi board. -5. **Prune runtime operator**: gỡ orchestrator-spawn/pickAgent/mux/workflow-engine/ceremonies-runtime/cost/presence/oncall (hoặc archive). Pair/mob/convene → mô tả trong protocol. -6. Giữ: bus · KB · ledger · clean arch · compile · registry/workflow/charter/roles/DoD as SSOT. +### Lộ trình implement (đã chốt) — TRẠNG THÁI + +1. ✅ **`hatch mcp --as <agent>`** (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/go.mod b/hatch/go.mod index 03a6acc..18c3eb9 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -12,7 +12,6 @@ require ( ) require ( - github.com/atotto/clipboard v0.1.4 // indirect 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 diff --git a/hatch/go.sum b/hatch/go.sum index ebfa057..8cd5170 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -1,5 +1,3 @@ -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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= From 16c74a9b2f801a17abd6920a6b73460d5cc7c190 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:06:15 +0000 Subject: [PATCH 54/60] hatch: rewrite README for the embedded-harness model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the README away from the pre-pivot orchestrator story: Hatch is an embedded harness (shared chat = comms + backlog, KB, over MCP) that agents drive themselves through — not a tool that spawns/drives agents. Correct the CLI section to the actual default command set, mark the self-driving operator as archived behind -tags hatch_legacy, and note that docs 00-19 describe the original design while doc 20 is current. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 130 +++++++++++++++++++++++++++--------------------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/hatch/README.md b/hatch/README.md index 480c6b7..e03a9b1 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -2,15 +2,23 @@ > **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 + **điều phối** 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. +> 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. Nghĩa kép: orchestrator `hatch run <ticket>` chính là *nở một agent ra làm việc*. Đặ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. +**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 <ticket>` 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 biến một repo thành **không gian làm việc đa-agent có điều phối**. Cùng một codebase, nhiều coding agent (Claude Code, Codex, Kiro, Antigravity CLI) tham gia với những vai trò khác nhau, làm việc 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. +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 @@ -26,8 +34,10 @@ Mọi thành phần trong Hatch đều có một đối ứng trong một squad | `board/` | Bảng sprint (Jira) | | `ledger/` | Sổ standup / nhật ký hoạt động / git log | | `protocol/` | Working agreements của đội | -| `compiler` | Bộ sinh tài liệu onboarding cho từng người mới | -| `orchestrator` | Engineering Manager / Scrum Master | +| `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). @@ -44,23 +54,26 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d ## Bảy trụ thiết kế -1. **Single Source of Truth → compile xuống từng agent.** Một nguồn canonical (`context/`), một compiler sinh ra `CLAUDE.md` / `AGENTS.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. 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ó + ticket đ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. -5. **Coordination protocol** — điều phối qua artifact trong repo (board + ledger), async, không agent nào gọi trực tiếp agent khác. -6. **Workflow = template sửa được** — mặc định Agile ghép spec-driven (`Charter → Spec → Backlog → Sprint → In-Progress → Review → Done`); user có quyền thiết kế lại hoàn toàn ở mỗi project qua `workflow.yaml`. -7. **Governance & audit** — ledger append-only, gate trước merge, giới hạn thẩm quyền agent. +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 điều phối: Hybrid +## Mô hình phối hợp: chat async, agent tự lái -- **Conductor** (CC) chạy planning: bẻ epic → ticket, gán vai/agent, đặt ưu tiên, ghi dependency. -- **Workers** tự **claim** ticket trong lane của mình, thực thi, để lại ledger, đẩy ticket sang review. -- **Reviewer** gate → done. -- Tất cả qua board + ledger. Không có lời gọi trực tiếp giữa agent — y như một đội người: manager lập kế hoạch, dev pull ticket, reviewer duyệt. +- **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 | @@ -83,6 +96,7 @@ Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người d | 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). @@ -90,52 +104,52 @@ Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.s ## CLI (`hatch`) -Hatch là CLI viết bằng Go (single binary). Phase 1+2 đã chạy được: +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 (mock agent) để thử ngay — hoặc `make onboard` -./scripts/onboard.sh --install # thêm: go install hatch + hatch-mock lên PATH -# hoặc thủ công: -make build # → bin/hatch -bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident -bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering -bin/hatch compile --check # CI: fail nếu output stale so với SSOT -bin/hatch validate # kiểm tra registry + workflow + board -bin/hatch ticket new --title "Export CSV" --role implementer --priority P1 -bin/hatch ticket claim T-001 --agent codex --why "sprint S1" -bin/hatch ticket move T-001 --to review --by implementer --agent codex \ - --why "code xong" --handoff "endpoint ở export.go" -bin/hatch gate T-001 --to review # đánh giá gate mà không di chuyển -bin/hatch status # board + cảnh báo WIP -bin/hatch standup --days 1 # digest ledger +./scripts/onboard.sh # build + dựng demo workspace để thử ngay — hoặc `make onboard` +make build # → bin/hatch +bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident +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 -# Phase 3 — orchestrator (spawn agent headless theo docs/10) -bin/hatch run T-001 --claim --dry-run # build invocation; bỏ --dry-run để chạy thật -bin/hatch plan --dry-run # spawn Conductor bẻ việc -bin/hatch watch --dry-run --max 3 # gán + chạy backlog (tôn trọng WIP) -bin/hatch board # mission-control TUI 4 pane: board + live output + ledger + chat -bin/hatch chat # (tuỳ chọn) TUI chat-only kiểu Slack -# Giao tiếp trực tiếp giữa agent (Slack-style — docs/11) -bin/hatch msg --from codex --to '#design,reviewer' --channel '#design' "Streaming hay buffer?" -bin/hatch inbox claude-code --mark # message gửi tới mình (id + vai + #channel + *) -bin/hatch ask --from codex --to claude-code "Chốt giúp: streaming chứ?" # hỏi-đáp đồng bộ -bin/hatch convene --topic "Thiết kế export API" --agents claude-code,codex,kiro --rounds 2 -bin/hatch channel ls # liệt kê channel/DM/thread -# Nghi thức squad (docs/12) -bin/hatch ceremony standup # digest theo agent + blockers → #standup -bin/hatch ceremony retro --write # tổng kết chu kỳ + ứng viên đề bạt KB→SSOT -bin/hatch escalate T-001 --why "kẹt gate 2 lần" # gọi senior/on-call (auto khi gate fail ≥2) -bin/hatch pair T-001 --driver codex --navigator claude-code --rounds 3 # pair programming -bin/hatch mob T-001 --agents codex,claude-code,agy # mob (driver xoay vòng) -bin/hatch ceremony demo · grooming # sprint review · backlog refinement -bin/hatch presence set kiro --status offline --note PTO # availability → phân việc theo capacity -bin/hatch oncall set --rotation claude-code,codex # lịch trực; escalation nhắm người trực +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 <id> # 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 -- **Phase 1+2 — xong:** model + filesystem store, `init` (scaffold + 4 workflow template), compiler đa surface (Claude/Codex/agy/Kiro) + manifest stale-detection, workflow engine (transition + gate + no-self-review + dependency), ledger append-only, Knowledge Base, các lệnh `status/standup/validate/gate/ticket/kb`. -- **Phase 3 — xong (cơ chế):** orchestrator + adapter cho từng agent (`hatch run`/`plan`/`watch`) dựng invocation headless đúng theo [adapters](docs/10-agent-adapters.md), worktree isolation, TUI dashboard (`hatch board`). Adapter `kiro`/`antigravity`/`manual` rơi về handoff khi không có headless. `--dry-run` cho phép kiểm tra invocation mà không cần cài agent. +Pivot **embedded-harness** đã implement (xem [doc 20](docs/20-embedded-harness-pivot.md)): + +- **MCP server** (`hatch mcp --as <agent>`, 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 trong `docs/`. Kiến trúc (Lean Hexagonal, ports & adapters): [ARCHITECTURE.md](ARCHITECTURE.md). +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). From 63dbeaf11e5cb9973ffca9e44e2e629adf53506c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:07:10 +0000 Subject: [PATCH 55/60] hatch: rewrite docs/overview for the embedded-harness model Replace the pre-pivot overview (orchestrator spawning agents, workflow engine enforcing gates, board as control panel, management dashboard) with the current model: agents are the entrypoint and self-drive, reaching the shared chat (= comms + backlog, thread = task) and KB via the hatch MCP server; compile emits protocol prose + MCP registration; board/chat/status are read-only; the self-driving operator is archived behind -tags hatch_legacy. Adds a doc-20-wins banner. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/docs/overview.md | 128 ++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 67 deletions(-) diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md index 72877aa..b5b0070 100644 --- a/hatch/docs/overview.md +++ b/hatch/docs/overview.md @@ -1,105 +1,99 @@ # Overview — bản đồ tổng Hatch (plaintext) -Legend: **✓** đã implement (code + test) · **◇** mới thiết kế (docs 13–14). +> **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 ─ một bầy coding-agent vận hành như squad người, trên file + git ──────────┐ +┌─ 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 (L0+L1+con trỏ) │ -│ │ nạp instruction native │ +│ 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 (mỗi con 1 process, KHÔNG chung RAM)│ -│ ▲ │ │ -│ │ │ claim/move ──► WORKFLOW ENGINE ✓ (transition · gate · no-self-review · │ -│ │ │ WIP · deps · auto-escalate) │ -│ │ ▼ │ -│ ORCHESTRATOR ✓ run · plan · watch ── spawn headless ─┘ │ -│ (catch-up: đọc inbox + recall trước khi vào việc) │ +│ AGENTS ✓ claude · codex · agy · kiro (entrypoint; TỰ LÁI; mỗi con 1 process) │ +│ │ │ +│ │ với tới chat + KB qua ── hatch mcp --as <agent> (stdio) ──┐ │ +│ ▼ ▼ │ +│ MCP TOOLS ✓ whoami · chat_open · chat_post · chat_read · chat_inbox · │ +│ chat_search · chat_channels · kb_add · kb_search │ │ │ │ STORES = DATABASE (filesystem + git): │ -│ ✓ board/ (state=thư mục) · ✓ ledger/ (audit) · ✓ kb/ (tri thức) · ✓ bus/ (thoại)│ +│ ✓ 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 │ │ │ -│ ── lớp "con người" ─────────────────────────────────────────────────────────── │ -│ COMMUNICATION ✓ DM · #channel · thread · @mention · search/recall │ -│ HỎI & HỌP ✓ ask (đồng bộ) · convene (+ tie-breaker) · decision→ADR │ -│ CEREMONIES ✓ standup · retro · planning · demo · grooming │ -│ COLLAB ✓ pairing(driver/navigator) · mob(driver xoay vòng) │ -│ ỨNG CỨU ✓ escalation · on-call rotation · incident workflow │ -│ CAPACITY ✓ presence(available/busy/paused/offline) → phân việc theo tải │ -│ OBSERVE ✓ transcript + hatch logs -f · TUI board (4 pane: board+live+ledger+chat)│ -│ ✓ --mux=tmux|zellij (mỗi run một pane thật) │ -│ DOCS/KB ✓ doc templates (new/lint) · Obsidian KB (link/backlinks/graph) │ -│ ── lớp QUẢN TRỊ (Founder/CEO/CTO) ────────────────────────────────────────────── │ -│ ✓ workload · ✓ performance · ✓ budget/lương(track) · ✓ org-chart/DoA │ -│ ✓ external deps · ✓ heartbeat(hatch tick) · ✓ stakeholder report(hatch report) │ -│ ◇ còn lại: auto-pause khi vượt budget (đã chốt track-only), parallel-watch worktree│ +│ ── 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 = đối thoại; board = trạng thái) + (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 install -BOARD/WORK ticket new|claim|move|show|extdep · status · standup · gate -ORCH/OBSERVE run [--claim --worktree --mux=tmux|zellij] · plan · watch [--parallel N] - tick · logs [-f] · board (TUI) -KNOWLEDGE kb add|query|index|link|backlinks|graph|open · doc types|new|lint -COMMUNICATION msg · inbox · thread · channel ls|show|join|leave|members · search · chat(TUI) -DIALOGUE ask · convene [--decider] · escalate -COLLAB pair · mob -CEREMONY ceremony standup|retro|planning|demo|grooming -CAPACITY/OPS presence [set] · oncall [set|rotate] -MANAGE workload · perf · cost · budget · report · org -◇ còn lại auto-pause budget (chốt track-only) · parallel-watch worktree · daemon +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 <agent> (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) ``` - Founder ─ hatch plan ─► Conductor bẻ epic → tickets (role/priority/deps) [ledger] - │ - ceremony grooming ─► chuốt backlog (thiếu role/acceptance?) - │ - watch/run ─► chọn agent RẢNH & ít WIP (presence+capacity) ─► claim (git push=lock) - │ catch-up: đọc inbox + recall #channel liên quan + 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 ▼ - Implementer code ──┬─ ask reviewer "chốt X?" (đồng bộ qua bus) - ├─ pair/mob nếu cần nhiều đầu - └─ convene "thiết kế Y" → DECISION → ADR vào kb/ - │ move → review [gate: test·lint·handoff] (fail 2 lần → auto escalate → on-call) + 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) ─ gate DoD·no-self-review·human-merge ─► done [ledger] - │ - ceremony standup (digest theo agent + blockers → #standup) - ceremony demo (showcase việc done → #demo) - ceremony retro (done/blocks/gate-fail/decisions + đề bạt KB→SSOT) + 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. │ - ◇ management: workload/perf/budget mỗi chu kỳ → report cho stakeholder + 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 = compiler → CLAUDE.md/AGENTS.md theo vai - Jira board / sprint = board/ + workflow (8 template PDLC) - Slack = bus (DM·channel·thread·@mention·search) - hỏi đồng nghiệp / họp = ask · convene - pair / mob = pair · mob - standup/retro/demo = ceremony - on-call / sự cố = oncall · escalate · incident workflow - wiki / ADR = kb/ - nhật ký / git log = ledger/ - ai rảnh, PTO = presence - ◇ EM/CEO dashboard = workload · perf · budget/lương · org-chart/DoA + 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 <agent>) + 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: kiến trúc [01](01-architecture.md) · workflow [05](05-workflow.md) · KB [09](09-knowledge-base.md) · adapters [10](10-agent-adapters.md) · communication [11](11-communication.md) · ceremonies/escalation [12](12-ceremonies-escalation.md) · management [13](13-management.md) · org/cadence [14](14-org-and-cadence.md). +> 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). From adbf3ac8f0e1061f41870d4ab7d780689c624be7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:15:16 +0000 Subject: [PATCH 56/60] hatch: redraw architecture diagrams + add install/onboarding guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redraw docs/architecture-diagram.md for the embedded-harness model: SSOT → compile → per-agent surfaces (protocol prose) + MCP registration; agents are the entrypoint and self-drive, reaching the shared chat (= comms + backlog, thread = task) + KB + ledger via the hatch MCP server; read-only observe layer; archived-operator note. Replaces the old orchestrator-spawn / workflow-engine diagrams. Plaintext-first plus refreshed Mermaid. Adds a task-as-thread lifecycle and an inter-agent message-round diagram. - README: add a step-by-step 'Cài đặt & Onboarding (user mới)' section (prerequisites → install hatch → init → compile → optional Claude plugin → work & observe), with the correct module path and the demo fallback for environments without a real agent CLI. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 67 ++++++++++ hatch/docs/architecture-diagram.md | 189 +++++++++++++++++------------ 2 files changed, 177 insertions(+), 79 deletions(-) diff --git a/hatch/README.md b/hatch/README.md index e03a9b1..c205701 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -102,6 +102,73 @@ Sơ đồ trực quan: [overview](docs/overview.md) (bản đồ tổng plaintex 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 trong repo của bạn + +```bash +cd /đường/dẫn/repo-của-bạn +hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident +# → tạo .hatch/ (SSOT): charter.md · roles/ · registry.yaml · workflow.yaml · kb/ · ledger/ +``` + +Sửa `.hatch/charter.md` (sản phẩm là gì) và `.hatch/registry.yaml` (agent nào giữ vai gì) cho đúng đội của bạn. + +### 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 — (Tùy chọn) Cài plugin cho Claude Code + +```bash +# Dev: trỏ thẳng Claude Code vào plugin +claude --plugin-dir /đường/dẫn/overclaud/hatch/plugin +# hoặc qua marketplace: +# /plugin marketplace add fioenix/overclaud +# /plugin install hatch@hatch +``` +Plugin gói sẵn MCP server + skill `hatch-chat` + slash `/hatch`. Không cài plugin cũng được — `hatch compile` đã ghi `.mcp.json` để Claude Code tự nạp MCP server. + +### 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`): diff --git a/hatch/docs/architecture-diagram.md b/hatch/docs/architecture-diagram.md index 5a03a7f..310a3e1 100644 --- a/hatch/docs/architecture-diagram.md +++ b/hatch/docs/architecture-diagram.md @@ -1,69 +1,91 @@ # 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 │ workflow.yaml - ▼ ▼ - ┌──────────────────────────────┐ ┌────────────────────────────┐ - │ SURFACES (per-agent, sinh ra) │ │ WORKFLOW ENGINE + GATES │ - │ CLAUDE.md AGENTS.md │ │ transition · WIP · deps │ - │ GEMINI.md .kiro/steering/ │ │ no-self-review · gates │ - │ (+ compiled/.manifest.json) │ └──────────────┬──────────────┘ - └───────────────┬───────────────┘ │ authorise - │ nạp L0+L1+con trỏ L2 │ - ▼ │ - ┌──────────────────────────────┐ claim/move │ - │ AGENTS (mỗi con 1 process, │◀──────────────────┘ - │ KHÔNG chung RAM) │ - │ Claude · Codex · Gemini · Kiro│ - └───┬───────────────┬───────────┘ - spawn ▲ │ claim/move │ tra cứu + đóng góp -headless │ ▼ ▼ - ┌───────┴──────┐ ┌───────────────────────────────────────────────┐ - │ ORCHESTRATOR │ │ HỆ THỐNG FILE = DATABASE │ - │ run·plan· │ │ board/ (vị trí thư mục = trạng thái ticket) │ - │ watch │ │ ledger/ (append-only audit: who/what/why) │ - └──────────────┘ │ kb/ (Knowledge Base — đọc & GHI chung) │ - └───────────────────────────────────────────────┘ - kb/ ──promote khi chín (retro)──► context/ (SSOT) + .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 <id>" + └───────────────────┬──────────────────┘ │ + │ 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 <id> │ + │ 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. Workflow — vòng đời ticket (plaintext, template `scrum`) +## 2. Vòng đời một task = một thread chat (quy ước, KHÔNG phải engine) ``` - ┌─────────┐ claim ┌─────────────┐ handoff ┌────────┐ done ┌──────┐ - │ backlog │ ───────► │ in-progress │ ─────────► │ review │ ──────► │ done │ - └─────────┘ impl/test└─────────────┘ gates: └────────┘ reviewer└──────┘ - ▲ │ │ tests·lint· │ gates: dod· - unblock │ block │ │ handoff-note │ no-self-review· - │ ▼ │ │ human-merge - ┌─────────┐ │ └───────────────────────┘ - │ blocked │◀─────┘ changes-requested (review → in-progress) - └─────────┘ - (lane = thư mục trong board/ · mỗi mũi tên = transition trong workflow.yaml) + 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. Vòng đời một ticket qua các agent (plaintext) +## 3. Một vòng trao đổi giữa agent (qua MCP + bus) ``` - Human ── hatch plan ─► Conductor(Claude) ── ticket new T-001 ─► board/ + ledger(note) - │ - Implementer(Codex) ── hatch run --claim T-001 ─► claim (git push = lock) ─► ledger(claim) - │ đọc kb/ (ADR/learnings, L2) ──► code + test trong scope - └─ move → review [gates: tests·lint·handoff] ─► ledger(handoff: đã làm/còn/cần) - │ - Reviewer(Claude ≠ Codex) ── move → done [no-self-review · human-merge] ─► ledger(approved) - └─ ghi learning mới ─► kb/ + 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) ``` --- @@ -76,6 +98,7 @@ flowchart TB 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 @@ -90,48 +113,56 @@ flowchart TB RO --> COMPILER CX --> COMPILER REG --> COMPILER - COMPILER -. "manifest → stale detect" .-> MAN[("compiled/.manifest.json")]:::store - COMPILER --> S1["CLAUDE.md"]:::surf - COMPILER --> S2["AGENTS.md"]:::surf - COMPILER --> S3["GEMINI.md"]:::surf - COMPILER --> S4[".kiro/steering/"]:::surf - subgraph AGENTS["Agents — mỗi con 1 process"] + WF --> COMPILER + COMPILER --> SURF["Surfaces per-agent<br/>CLAUDE.md · AGENTS.md · GEMINI.md · .kiro<br/>(protocol prose + DoD + orchestrator)"]:::surf + COMPILER --> MCPREG["MCP registration<br/>.mcp.json · .kiro/settings/mcp.json · .hatch/mcp/*"]:::surf + + subgraph AGENTS["Coding agents — ENTRYPOINT, tự lái (1 process/con)"] direction LR - A1["Claude"] + A1["Claude Code<br/>(lead = Conductor)"] A2["Codex"] - A3["Gemini"] + A3["agy"] A4["Kiro"] end - S1 --> A1 - S2 --> A2 - S3 --> A3 - S4 --> A4 - ORCH{{"orchestrator<br/>run · plan · watch"}}:::eng - ORCH == "spawn headless" ==> AGENTS - ENGINE{{"workflow engine + gates"}}:::eng - WF --> ENGINE - subgraph STORE["Hệ thống file = database"] + SURF --> AGENTS + MCPREG -. "hatch mcp --as id" .-> MCP + + MCP{{"Hatch MCP server<br/>(stdio, per agent)"}}:::eng + AGENTS == "MCP tools" ==> MCP + + subgraph STORE["Hệ thống file = database (git)"] direction LR - BOARD[("board/")]:::store - LEDGER[("ledger/")]:::store + BUS[("bus/ — CHAT = comms + backlog<br/>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 - AGENTS == "claim / move" ==> ENGINE - ENGINE --> BOARD - ENGINE --> LEDGER - AGENTS -- "tra cứu + đóng góp" --> KB - KB -. "promote (retro)" .-> CX + BUS --> OBS + LEDGER --> OBS ``` ```mermaid stateDiagram-v2 direction LR - [*] --> backlog - backlog --> in_progress: claim · impl/test (deps done) - in_progress --> review: handoff — tests/lint/handoff-note - review --> done: done · reviewer — dod/no-self-review/human-merge + [*] --> 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: block - blocked --> in_progress: unblock + 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 ``` From aa15f30bb264d251dcf67a83b7aabb78eeed9afe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:33:54 +0000 Subject: [PATCH 57/60] hatch: `hatch init --client cc|codex|agy|kiro` one-shot MCP setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-client MCP wiring so any of the four CLIs can be the orchestrator, not just Claude. `hatch init --client <x>` scaffolds (or reuses) the workspace, compiles, then registers the hatch MCP server where that client actually reads it — using the agent's own identity (--as <id> from registry.yaml): - cc → project .mcp.json + prints the /plugin marketplace install steps - codex → shells out to `codex mcp add hatch -- hatch mcp --as codex` (Codex owns ~/.codex/config.toml); falls back to printing the snippet - agy → merges the HOME-level ~/.gemini/config/mcp_config.json that the Antigravity CLI loads, plus a .agents/ workspace copy - kiro → .kiro/settings/mcp.json Works in-place on an existing .hatch; --dry-run previews without writing. /root-level writes happen only on the explicit --client invocation and merge-preserve other servers. README onboarding updated. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 26 +++-- hatch/internal/cli/client.go | 163 ++++++++++++++++++++++++++++++ hatch/internal/cli/client_test.go | 72 +++++++++++++ hatch/internal/cli/init.go | 71 +++++++++++-- 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 hatch/internal/cli/client.go create mode 100644 hatch/internal/cli/client_test.go diff --git a/hatch/README.md b/hatch/README.md index c205701..e80a86a 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -142,16 +142,27 @@ 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 — (Tùy chọn) Cài plugin cho Claude Code +### 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 -# Dev: trỏ thẳng Claude Code vào plugin -claude --plugin-dir /đường/dẫn/overclaud/hatch/plugin -# hoặc qua marketplace: -# /plugin marketplace add fioenix/overclaud -# /plugin install hatch@hatch +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: merge ~/.gemini/config/mcp_config.json (+ .agents/mcp_config.json) +hatch init --client kiro # Kiro: ghi .kiro/settings/mcp.json +hatch init --client cc,codex # nhiều client một lần ``` -Plugin gói sẵn MCP server + skill `hatch-chat` + slash `/hatch`. Không cài plugin cũng được — `hatch compile` đã ghi `.mcp.json` để Claude Code tự nạp MCP server. + +- **`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 nạp MCP từ file ở `$HOME`; `--client agy` chỉ ghi khi bạn chủ động chạy nó. Merge giữ nguyên server khác. +- Mọi client đều dùng đúng danh tính `--as <agent-id>` 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 @@ -177,6 +188,7 @@ Hatch là CLI viết bằng Go (single binary). Đây là **embedded harness**: ./scripts/onboard.sh # build + dựng demo workspace để thử ngay — hoặc `make onboard` make build # → bin/hatch bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident +bin/hatch init --client codex # set up MCP cho 1 client: cc|codex|agy|kiro (compile + ghi config đúng chỗ; --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; diff --git a/hatch/internal/cli/client.go b/hatch/internal/cli/client.go new file mode 100644 index 0000000..6666cd8 --- /dev/null +++ b/hatch/internal/cli/client.go @@ -0,0 +1,163 @@ +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 <id>`). 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 <x>` 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. + 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", rel(repoRoot, filepath.Join(repoRoot, ".hatch", "mcp", id+".codex.toml"))) + } 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", rel(repoRoot, filepath.Join(repoRoot, ".hatch", "mcp", id+".codex.toml"))) + } + + case "agy": + // Antigravity CLI loads MCP from a HOME-level JSON (mcpServers shape). + 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: home MCP config %s → server 'hatch' (--as %s)", homeCfg, id) + // Workspace-scope copy too (some agy versions read .agents/mcp_config.json). + wsCfg := filepath.Join(repoRoot, ".agents", "mcp_config.json") + if err := writeServerJSON(wsCfg, id, dryRun); err != nil { + return err + } + say(out, dryRun, "agy: workspace MCP config %s (dự phòng)", rel(repoRoot, wsCfg)) + } + 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/init.go b/hatch/internal/cli/init.go index f1c03a1..d519d94 100644 --- a/hatch/internal/cli/init.go +++ b/hatch/internal/cli/init.go @@ -2,40 +2,95 @@ package cli import ( "fmt" + "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 clients []string + var dryRun bool cmd := &cobra.Command{ Use: "init [dir]", - Short: "Create a new .hatch/ workspace from templates", + Short: "Create a new .hatch/ workspace; optionally set up a client (--client cc|codex|agy|kiro)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dir := "." if len(args) == 1 { dir = args[0] } - l, written, err := scaffold.Init(scaffold.Options{ - Dir: dir, - Workflow: workflow, - Force: force, - }) + out := cmd.OutOrStdout() + + // Scaffold unless a workspace already exists and we're only wiring a + // client into it (so `hatch init --client codex` works in-place). + existing := false + if _, err := paths.Find(dir); err == nil { + existing = true + } + if existing && len(clients) > 0 && !force { + fmt.Fprintf(out, "Workspace .hatch đã tồn tại — bỏ qua scaffold, chỉ set up client.\n") + } else { + l, written, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: workflow, Force: force}) + if err != nil { + return err + } + fmt.Fprintf(out, "Created %s (%d files, workflow=%s)\n", l.Root, len(written), workflow) + if len(clients) == 0 { + fmt.Fprintln(out, "Next: edit charter.md + registry.yaml, then `hatch compile`.") + } + } + + if len(clients) == 0 { + return nil + } + + // Load the workspace and compile so each client has its instruction + // surface + base MCP registration, then wire the requested clients. + absDir, _ := filepath.Abs(dir) + ws, err := config.Load(paths.At(absDir)) if err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%d files, workflow=%s)\n", l.Root, len(written), workflow) - fmt.Fprintln(cmd.OutOrStdout(), "Next: edit charter.md + registry.yaml, then `hatch compile`.") + if !dryRun { + if _, _, err := compile.Run(ws); err != nil { + return fmt.Errorf("compile: %w", err) + } + fmt.Fprintln(out, "Compiled surfaces + MCP registration.") + } + for _, c := range splitClients(clients) { + if err := setupClient(cmd, ws, absDir, 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().StringSliceVar(&clients, "client", nil, + "set up MCP for a client and exit-wire it: 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 +} From 7f077dd6526a7cdb0b8697d5b0dfe1fb30ac785a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 15 Jun 2026 11:41:02 +0000 Subject: [PATCH 58/60] hatch: fix agy MCP path for Antigravity CLI runtime (not Gemini legacy) Antigravity CLI loads MCP servers ONLY from a HOME-level mcp_config.json (a separate file, not inline in settings like legacy Gemini CLI). Current runtime path is ~/.gemini/config/mcp_config.json; older installs use ~/.gemini/antigravity-cli/mcp_config.json (now a symlink). Project-local .antigravitycli/mcp_config.json is detected but silently ignored (google-antigravity/antigravity-cli#60). Fixes for --client agy: - Write ~/.gemini/config/mcp_config.json (current), and also the legacy ~/.gemini/antigravity-cli/mcp_config.json only when that dir already exists and isn't a symlink (covers pre-migration installs). - Drop the bogus .agents/mcp_config.json workspace write (wrong path AND ignored by the runtime); print a note that project-local isn't loaded. - Fix the compile-generated agy snippet that pointed at the legacy ~/.gemini/settings.json. README updated. Verified against issue #60 and Antigravity CLI docs (Arm Learning Paths, Google Cloud Community). https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/README.md | 4 ++-- hatch/internal/cli/client.go | 24 +++++++++++++++++------- hatch/internal/compile/mcp.go | 9 ++++++--- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/hatch/README.md b/hatch/README.md index e80a86a..e14c956 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -149,7 +149,7 @@ Thay vì dán config thủ công, để Hatch set up đúng cho từng client. L ```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: merge ~/.gemini/config/mcp_config.json (+ .agents/mcp_config.json) +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 ``` @@ -161,7 +161,7 @@ hatch init --client cc,codex # nhiều client một lần ``` (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 nạp MCP từ file ở `$HOME`; `--client agy` chỉ ghi khi bạn chủ động chạy nó. Merge giữ nguyên server khác. +- **`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 <agent-id>` 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 diff --git a/hatch/internal/cli/client.go b/hatch/internal/cli/client.go index 6666cd8..49a9cee 100644 --- a/hatch/internal/cli/client.go +++ b/hatch/internal/cli/client.go @@ -94,7 +94,12 @@ func setupClient(cmd *cobra.Command, ws *config.Workspace, repoRoot, alias strin } case "agy": - // Antigravity CLI loads MCP from a HOME-level JSON (mcpServers shape). + // 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) @@ -103,13 +108,18 @@ func setupClient(cmd *cobra.Command, ws *config.Workspace, repoRoot, alias strin if err := writeServerJSON(homeCfg, id, dryRun); err != nil { return err } - say(out, dryRun, "agy: home MCP config %s → server 'hatch' (--as %s)", homeCfg, id) - // Workspace-scope copy too (some agy versions read .agents/mcp_config.json). - wsCfg := filepath.Join(repoRoot, ".agents", "mcp_config.json") - if err := writeServerJSON(wsCfg, 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) } - say(out, dryRun, "agy: workspace MCP config %s (dự phòng)", rel(repoRoot, wsCfg)) + fmt.Fprintf(out, " (agy chỉ nạp MCP ở HOME-level; project-local .antigravitycli/mcp_config.json bị bỏ qua — issue #60)\n") } return nil } diff --git a/hatch/internal/compile/mcp.go b/hatch/internal/compile/mcp.go index 94fc911..9b33718 100644 --- a/hatch/internal/compile/mcp.go +++ b/hatch/internal/compile/mcp.go @@ -106,9 +106,12 @@ func agySnippet(agentID string) string { "mcpServers": map[string]any{mcpServerName: serverEntry(agentID)}, } body, _ := json.MarshalIndent(entry, "", " ") - return fmt.Sprintf("# Hatch MCP cho agy (Antigravity)\n\n"+ - "agy đọc cấu hình MCP trong home (vd `~/.gemini/settings.json`), ngoài repo — "+ - "Hatch không tự sửa. Trộn khối `mcpServers` dưới đây vào file đó:\n\n"+ + 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)) } From e0045d15fbc1530841f3fa013e776cfec233787a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 16 Jun 2026 03:05:24 +0000 Subject: [PATCH 59/60] hatch: global ~/.hatch default + local .hatch override (like ~/.claude) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace resolution is now layered: a local .hatch (nearest ancestor of cwd) overrides the global ~/.hatch, which is the default used in every repo. `hatch init` creates the global ~/.hatch; `hatch init --local` creates a project .hatch that overrides it. $HATCH_HOME relocates the global root (and sandboxes tests). - paths: GlobalRoot(), FindLocal() (local-only), Find() now falls back to the global workspace when there's no local one. - config.Workspace gains OutputRoot/Out(): compiled surfaces + MCP registration always target the working repo (cwd), even when the SSOT is the global ~/.hatch — so the repo gets its CLAUDE.md/.mcp.json while the repo is NOT polluted with a stray .hatch. compile + --client honor it. - compile keeps codex/agy snippets with the SSOT (.hatch/mcp/) instead of scattering them into the working repo. - loadWorkspace does the local→global resolution for all commands. Also rename the onboarding demo dir .hatch-demo → demo-workspace (it was a throwaway sandbox, confusingly named like .hatch) and make the demo use --local. Tests cover local-overrides-global and the not-found fallback. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/.gitignore | 2 +- hatch/README.md | 18 +++-- hatch/internal/cli/client.go | 5 +- hatch/internal/cli/init.go | 68 ++++++++++++------- .../internal/cli/integration_default_test.go | 4 +- hatch/internal/cli/integration_test.go | 2 +- hatch/internal/cli/root.go | 27 ++++++-- hatch/internal/compile/compile.go | 6 +- hatch/internal/compile/mcp.go | 6 +- hatch/internal/config/config.go | 13 ++++ hatch/internal/paths/paths.go | 36 +++++++++- hatch/internal/paths/paths_test.go | 40 +++++++++++ hatch/scripts/onboard.sh | 6 +- 13 files changed, 184 insertions(+), 49 deletions(-) diff --git a/hatch/.gitignore b/hatch/.gitignore index 7368ac2..dbbf2e3 100644 --- a/hatch/.gitignore +++ b/hatch/.gitignore @@ -2,6 +2,6 @@ # cmd/hatch source directory. /bin/ /hatch -/.hatch-demo/ +/demo-workspace/ *.out *.test diff --git a/hatch/README.md b/hatch/README.md index e14c956..6b1671f 100644 --- a/hatch/README.md +++ b/hatch/README.md @@ -122,15 +122,21 @@ export PATH="$(go env GOPATH)/bin:$PATH" # nếu chưa có hatch --version ``` -### Bước 2 — Khởi tạo trong repo của bạn +### 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 -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident -# → tạo .hatch/ (SSOT): charter.md · roles/ · registry.yaml · workflow.yaml · kb/ · ledger/ +hatch init --local -w scrum # tạo ./.hatch trong repo này, đè lên global ``` -Sửa `.hatch/charter.md` (sản phẩm là gì) và `.hatch/registry.yaml` (agent nào giữ vai gì) cho đúng đội của bạn. +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 @@ -187,8 +193,8 @@ Hatch là CLI viết bằng Go (single binary). Đây là **embedded harness**: ```bash ./scripts/onboard.sh # build + dựng demo workspace để thử ngay — hoặc `make onboard` make build # → bin/hatch -bin/hatch init -w scrum # 8 template: scrum kanban spec-first lite dual-track shape-up stage-gate incident -bin/hatch init --client codex # set up MCP cho 1 client: cc|codex|agy|kiro (compile + ghi config đúng chỗ; --dry-run xem trước) +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; diff --git a/hatch/internal/cli/client.go b/hatch/internal/cli/client.go index 49a9cee..1bb6e13 100644 --- a/hatch/internal/cli/client.go +++ b/hatch/internal/cli/client.go @@ -79,18 +79,19 @@ func setupClient(cmd *cobra.Command, ws *config.Workspace, repoRoot, alias strin case "codex": // Codex owns ~/.codex/config.toml; let its own CLI edit it. + snippet := filepath.Join(ws.Layout.Root, "mcp", id+".codex.toml") 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", rel(repoRoot, filepath.Join(repoRoot, ".hatch", "mcp", id+".codex.toml"))) + 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", rel(repoRoot, filepath.Join(repoRoot, ".hatch", "mcp", id+".codex.toml"))) + fmt.Fprintf(out, " (hoặc dán %s vào ~/.codex/config.toml)\n", snippet) } case "agy": diff --git a/hatch/internal/cli/init.go b/hatch/internal/cli/init.go index d519d94..50848a8 100644 --- a/hatch/internal/cli/init.go +++ b/hatch/internal/cli/init.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "path/filepath" "strings" @@ -16,35 +17,55 @@ import ( 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 a new .hatch/ workspace; optionally set up a client (--client cc|codex|agy|kiro)", - Args: cobra.MaximumNArgs(1), + 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 { - dir := "." - if len(args) == 1 { - dir = args[0] - } out := cmd.OutOrStdout() + cwd, err := os.Getwd() + if err != nil { + return err + } - // Scaffold unless a workspace already exists and we're only wiring a - // client into it (so `hatch init --client codex` works in-place). - existing := false - if _, err := paths.Find(dir); err == nil { - existing = true + // 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" } - if existing && len(clients) > 0 && !force { - fmt.Fprintf(out, "Workspace .hatch đã tồn tại — bỏ qua scaffold, chỉ set up client.\n") + + // 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: dir, Workflow: workflow, Force: force}) + l, written, err := scaffold.Init(scaffold.Options{Dir: absScaffold, Workflow: workflow, Force: force}) if err != nil { return err } - fmt.Fprintf(out, "Created %s (%d files, workflow=%s)\n", l.Root, len(written), workflow) + 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`.") + fmt.Fprintln(out, "Next: edit charter.md + registry.yaml, then `hatch compile` (hoặc `hatch init --client cc`).") } } @@ -52,21 +73,21 @@ func newInitCmd() *cobra.Command { return nil } - // Load the workspace and compile so each client has its instruction - // surface + base MCP registration, then wire the requested clients. - absDir, _ := filepath.Abs(dir) - ws, err := config.Load(paths.At(absDir)) + // 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.Fprintln(out, "Compiled surfaces + MCP registration.") + fmt.Fprintf(out, "Compiled surfaces + MCP registration vào %s.\n", cwd) } for _, c := range splitClients(clients) { - if err := setupClient(cmd, ws, absDir, c, dryRun); err != nil { + if err := setupClient(cmd, ws, cwd, c, dryRun); err != nil { return err } } @@ -76,8 +97,9 @@ func newInitCmd() *cobra.Command { 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 exit-wire it: cc | codex | agy | kiro (repeatable / comma-separated)") + "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 } diff --git a/hatch/internal/cli/integration_default_test.go b/hatch/internal/cli/integration_default_test.go index 16b1706..0047c7a 100644 --- a/hatch/internal/cli/integration_default_test.go +++ b/hatch/internal/cli/integration_default_test.go @@ -30,7 +30,9 @@ func TestEmbeddedHarnessFlow(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - if out, err := run(t, "init", "-w", "scrum"); err != nil { + // --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) } diff --git a/hatch/internal/cli/integration_test.go b/hatch/internal/cli/integration_test.go index 7d3e3c3..80ac335 100644 --- a/hatch/internal/cli/integration_test.go +++ b/hatch/internal/cli/integration_test.go @@ -30,7 +30,7 @@ func TestLifecycleEndToEnd(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - if out, err := run(t, "init", "-w", "scrum"); err != nil { + if out, err := run(t, "init", "--local", "-w", "scrum"); err != nil { t.Fatalf("init: %v\n%s", err, out) } diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 952de27..d031474 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -52,15 +52,32 @@ func NewRoot() *cobra.Command { return root } -// loadWorkspace finds the .hatch workspace from cwd and loads its config. +// 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 } - l, err := paths.Find(cwd) - 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 config.Load(l) + return nil, paths.ErrNotFound } diff --git a/hatch/internal/compile/compile.go b/hatch/internal/compile/compile.go index f82d086..5fc81a2 100644 --- a/hatch/internal/compile/compile.go +++ b/hatch/internal/compile/compile.go @@ -75,9 +75,11 @@ func plan(ws *config.Workspace) ([]surfacePlan, []string) { return plans, warnings } -// Run compiles the SSOT to every surface and updates the manifest. +// 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.Layout.RepoRoot() + repoRoot := ws.Out() plans, warnings := plan(ws) m := &Manifest{ diff --git a/hatch/internal/compile/mcp.go b/hatch/internal/compile/mcp.go index 9b33718..093c6cb 100644 --- a/hatch/internal/compile/mcp.go +++ b/hatch/internal/compile/mcp.go @@ -42,13 +42,15 @@ func writeMCPConfigs(ws *config.Workspace, repoRoot string) ([]string, error) { } written = append(written, p) case "codex": - p := filepath.Join(repoRoot, ".hatch", "mcp", a.ID+".codex.toml") + // 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(repoRoot, ".hatch", "mcp", a.ID+".agy.md") + p := filepath.Join(ws.Layout.Root, "mcp", a.ID+".agy.md") if err := writeFile(p, agySnippet(a.ID)); err != nil { return written, err } diff --git a/hatch/internal/config/config.go b/hatch/internal/config/config.go index 2b6e9ae..6490f7c 100644 --- a/hatch/internal/config/config.go +++ b/hatch/internal/config/config.go @@ -17,6 +17,19 @@ 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. diff --git a/hatch/internal/paths/paths.go b/hatch/internal/paths/paths.go index b2563e9..47be900 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -90,9 +90,24 @@ 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`)") -// Find walks up from start looking for a .hatch directory and returns its -// Layout. It stops at the filesystem root. -func Find(start string) (Layout, error) { +// 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 @@ -110,6 +125,21 @@ func Find(start string) (Layout, error) { } } +// 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 index 30ac991..9555e41 100644 --- a/hatch/internal/paths/paths_test.go +++ b/hatch/internal/paths/paths_test.go @@ -1,6 +1,7 @@ package paths import ( + "os" "strings" "testing" ) @@ -31,3 +32,42 @@ func TestRunsStaysInsideWorkspace(t *testing.T) { 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/scripts/onboard.sh b/hatch/scripts/onboard.sh index 46909d4..c1d2034 100755 --- a/hatch/scripts/onboard.sh +++ b/hatch/scripts/onboard.sh @@ -3,14 +3,14 @@ # 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 ./.hatch-demo +# ./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/.hatch-demo" +DEMO_DIR="$HATCH_DIR/demo-workspace" DO_INSTALL=0 DO_DEMO=1 @@ -56,7 +56,7 @@ if [ "$DO_DEMO" = "1" ]; then mkdir -p "$DEMO_DIR" cd "$DEMO_DIR" git init -q - hatch init -w scrum >/dev/null + 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 From 401cb186d01d0fbffb79dafbbfe80637af85f12b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 16 Jun 2026 03:16:57 +0000 Subject: [PATCH 60/60] hatch: add HANDOFF.md for continuing local development A practical bring-up doc: what Hatch is post-pivot, build/test/run (both build tags), current state, what must be verified locally (real MCP handshake with claude/codex/agy/kiro, codex mcp add, agy runtime path, doctor), architecture pointers, known limitations, and git conventions. https://claude.ai/code/session_01LJ6kqJhxDKCymkFoLTTFqQ --- hatch/HANDOFF.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 hatch/HANDOFF.md 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 <agent>` (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).