Compare commits

..

36 Commits

Author SHA1 Message Date
bee9455935
ticket: close tui ticket role actions 2026-06-06 05:10:01 +09:00
5d3209d8e4
merge: add tui ticket role actions 2026-06-06 05:07:44 +09:00
dab2478120
review: approve tui ticket role actions 2026-06-06 05:07:30 +09:00
d288fa5901
fix: require TUI ticket intake context 2026-06-06 05:04:27 +09:00
e125ebb374
feat: add TUI ticket role commands 2026-06-06 04:57:10 +09:00
eb95722bd0
ticket: preflight tui ticket role actions 2026-06-06 04:36:53 +09:00
549cbabbb8
ticket: close ticket role launcher 2026-06-06 04:34:23 +09:00
3d6c1abf8c
merge: add ticket role launcher 2026-06-06 04:33:16 +09:00
0407ad024e
review: approve ticket role launcher 2026-06-06 04:33:03 +09:00
dd70517f96
fix: harden ticket role launch execution 2026-06-06 04:29:47 +09:00
4bf0e2715c
feat: add ticket role pod launcher 2026-06-06 04:18:36 +09:00
ecd4a37d94
ticket: preflight ticket role launcher 2026-06-06 04:07:13 +09:00
3d4a76cda5
ticket: plan ticket role launcher ui 2026-06-06 04:06:26 +09:00
e1215c79be
ticket: close ticket config roles 2026-06-06 03:48:27 +09:00
9910df4e24
merge: add ticket config roles 2026-06-06 03:46:08 +09:00
d704aea774
review: approve ticket config roles 2026-06-06 03:45:50 +09:00
8fab67b5fc
ticket: reject nix profile selectors 2026-06-06 03:43:37 +09:00
767870a4fb
ticket: add workspace ticket config 2026-06-06 03:33:40 +09:00
3edd68558f
ticket: refine ticket config role prompts 2026-06-06 03:15:20 +09:00
956394b4ac
ticket: plan ticket config roles 2026-06-06 02:35:34 +09:00
4fb6a3a688
workflow: retire auto maintain 2026-06-06 00:56:51 +09:00
44e6367a9c
ticket: close ticket intake routing umbrella 2026-06-05 15:42:54 +09:00
d02516d37d
ticket: close ticket orchestrator routing 2026-06-05 15:42:12 +09:00
af17f8b11d
workflow: add ticket orchestrator routing 2026-06-05 15:41:46 +09:00
afbfc21d57
ticket: close ticket intake workflow 2026-06-05 15:11:07 +09:00
12338332d6
workflow: add ticket intake 2026-06-05 15:10:42 +09:00
78120f5360
ticket: close ticket feature tools 2026-06-05 15:00:33 +09:00
4486a8133e
merge: add ticket feature tools 2026-06-05 14:59:08 +09:00
8db9e2ebef
review: approve ticket feature tools 2026-06-05 14:58:53 +09:00
afd7f04ff6
feat: add built-in ticket tools 2026-06-05 14:52:39 +09:00
a06d007202
ticket: preflight ticket feature tools 2026-06-05 14:37:54 +09:00
229b8e4ee0
docs: expand readme overview 2026-06-05 14:30:27 +09:00
03e04d5333
ticket: close feature authority separation 2026-06-05 14:12:06 +09:00
b46ea65fdd
merge: clarify feature host authorities 2026-06-05 14:09:52 +09:00
b96df5ac21
review: approve feature authority separation 2026-06-05 14:09:44 +09:00
4fc361fba0
refactor: name feature host authorities explicitly 2026-06-05 14:03:37 +09:00
89 changed files with 8926 additions and 366 deletions

View File

@ -1,148 +0,0 @@
---
description: TODO / tickets / docs / git history から次の作業候補を見繕い、課題発見や方針決定を半自動でイテレーションする WIP maintainer workflow
model_invokation: false
user_invocable: true
requires: []
---
# Auto Maintain Workflow (WIP)
yoi を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきか、下位 orchestrator にどの intent packet を渡すべきかを整理する。
参照:
- `docs/plan/ai-maintainer.md`
- `tickets/auto-maintain-workflow.md`
## 位置づけ
AI maintainer の目的は、コードを書くこと自体ではなく、プロジェクト状態を前に進めることである。
この Workflow は WIP として、以下を行う。
- TODO / tickets / docs / git history を読んで現在地を把握する。
- 実装可能な ticket と、方針決定が必要な ticket を分ける。
- 小さく実装できる候補を提案する。
- 複数 ticket からなる作業群は、下位 orchestrator に任せる単位として整理する。
- 設計相談が必要な論点を人間に戻す。
- 運用上の問題や繰り返し発生する詰まりを report / ticket / workflow 改訂候補として整理する。
## 非目標
現時点では以下をしない。
- 常駐 scheduler として自動実行する。
- 人間の合意なしに新規 ticket を作る。
- 人間の合意なしに既存 ticket を大幅変更する。
- 人間の合意なしに ticket 完了削除を行う。
- push する。
- Workflow を自律生成・自律改訂する。
- scope / permission / history persistence / prompt context 加工原則に関わる判断を勝手に決める。
## 入力として読むもの
必要に応じて以下を読む。
1. `TODO.md`
2. `tickets/*.md`
3. `docs/plan/`
4. `docs/report/`
5. `git log --oneline` / ticket file の git history
6. 既存 worktree / branch 状態
7. 最近の失敗や通知、ユーザーからの観測
TODO と ticket の不整合を見つけたら、勝手に修正せず、まず報告する。ただしユーザーが明示的に「直して」と言った場合は Mode 1 として整理してよい。
## 分類
候補を以下に分ける。
### A. 実装委譲可能
- 要件と完了条件が具体的。
- 影響範囲が限定的。
- test / build で確認できる。
- 大きな設計判断が不要。
- scope を狭く切れる。
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。複数 ticket や連続した作業群では、最上位 Pod が直接 coder を抱えず、下位 orchestrator に intent packet を渡して coder / reviewer sibling loop を管理させる。
### B. 方針決定が必要
- 複数の設計方針が自然に導ける。
- protocol / permission / scope / persistence / prompt context に触れる。
- UX の仕様が未確定。
- 既存 ticket の要件が古い。
この場合は、実装せず、決めるべき問いを短く提示する。
### C. ticket 整理が必要
- TODO にあるが ticket がない。
- ticket があるが TODO にない。
- 完了済みに見えるが残っている。
- ticket の前提が変わっている。
この場合は、不整合と修正案を提示する。修正は人間の許可後に行う。
### D. report / workflow 改善候補
- 同じ tool 問題が繰り返し出る。
- Workflow の指示が曖昧で実装 Pod が迷った。
- coder / reviewer / orchestrator の責務が混ざり、親 Pod が細かい code review に戻ってしまった。
- AI が過剰に Task tool を使うなど、運用上の癖が出た。
- 通知や Pod completion tracking など、開発基盤の不足が観測された。
この場合は、すぐ ticket 化するか、`docs/report/` に観測として残すか、人間に確認する。
## 半自動 iteration
1. 状態把握
- TODO / tickets / git status を読む。
- 最近完了した流れや未完了 branch を確認する。
2. 候補抽出
- 実装可能そうな ticket を 2〜5 件挙げる。
- correctness / developer experience / user-visible UX / cleanup で分類する。
3. 推奨順位
- blocking correctness を最優先。
- 実害が出ている運用問題を次点。
- 小さく完了できる UX / cleanup を次点。
- 大きな設計変更は方針相談に回す。
4. 人間への提示
- 「次に進めるなら X」を1つ推奨する。
- 理由を短く述べる。
- 実装委譲する場合の scope / test 方針を添える。
- 複数 ticket の作業群なら、下位 orchestrator に任せる単位として提示する。
5. 実行への接続
- 人間が「進めて」と言ったら `$user/multi-agent-workflow` に接続する。
- 単発 ticket か、下位 orchestrator に任せる ticket 群かを明示する。
- 下位 orchestrator に渡す intent / requirements / invariants / non-goals / escalation 条件を短くまとめる。
- worktree 作成は `$user/worktree-workflow` に従う。
## エスカレーション基準
以下では実装に進まず、人間へ戻す。
- ticket の要件から複数の設計方針が自然に導ける。
- 長期構造、crate boundary、protocol、permission、scope、history persistence に触れる。
- prompt context 加工原則に関わる。
- 新 ticket の作成、既存 ticket の大幅変更、ticket 完了削除について合意がない。
- test 不能、再現不能、または作業範囲外の不具合に遭遇した。
- WorkItem / Thread / Lease / maintainer state など、まだ設計中の概念が必要になる。
- 下位 orchestrator に委譲するには intent / invariant / escalation 条件が曖昧すぎる。
## まだ固定しないもの
以下は `docs/plan/ai-maintainer.md` の上位設計に残し、本 Workflow では詳細を固定しない。
- WorkItemStore / LeaseStore。
- operation inbox / trial log。
- QA feedback を ticket / review / report のどれに落とすか。
- AI 自身の feedback を Knowledge / report / ticket / workflow 改訂のどれにするか。
- maintainer doctor。
- reviewer Pod の評価基準の機械化。

View File

@ -8,7 +8,7 @@ requires: []
yoi を yoi で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
worktree の機械的作成手順は `$user/worktree-workflow`、実装前の要件同期・反証 preflight は `$user/ticket-preflight-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
worktree の機械的作成手順は `$user/worktree-workflow`ユーザー依頼の Ticket 化は `$user/ticket-intake-workflow`、Ticket の next action 分類は `$user/ticket-orchestrator-routing`実装前の要件同期・反証 preflight は `$user/ticket-preflight-workflow` に分ける。
この Workflow は、対象 ticket が implementation-ready であることを前提にする。設計境界・仕様・authority boundary が未同期の場合は、worktree 作成や coder Pod 起動の前に `ticket-preflight-workflow` を通す。
@ -297,10 +297,10 @@ Dirty state:
## この Workflow で扱わないもの
以下は `$user/auto-maintain` または別の設計相談で扱う。
以下は `$user/ticket-intake-workflow`、`$user/ticket-orchestrator-routing`、または別の設計相談で扱う。
- ticket 候補を見繕うこと。
- 新規 ticket 作成判断
- QA feedback / AI feedback を ticket / report / workflow に落とす判断。
- 長期 maintainer loop / WorkItemStore / LeaseStore の設計。
- ユーザー依頼を Ticket 化すること。
- Ticket の next action を分類すること
- QA feedback / AI feedback を Ticket / report / workflow に落とす判断。
- 長期 maintainer loop / scheduler / LeaseStore の設計。
- reviewer Pod の品質評価を機械的に採点する仕組み。

View File

@ -0,0 +1,271 @@
---
description: ユーザーの曖昧な依頼を要件同期し、合意済みの Ticket として作成・更新する Intake workflow
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Intake Workflow
Yoi の multi-agent 運用で、ユーザーの依頼をいきなり実装委譲せず、まず **合意済み Ticket** に変換するための Workflow。
Intake の目的は、ユーザーの意図・要件・受け入れ条件・未決定点を明確にし、Orchestrator が次の routing を判断できる Ticket を作ることである。Intake は scheduler ではなく、coder / reviewer Pod を起動しない。
## 位置づけ
```text
User request / conversation
-> Ticket Intake Workflow
-> TicketCreate / TicketComment
-> Orchestrator routing
-> preflight / spike / implementation / review / blocked / close
```
- `Ticket` は durable orchestration record。
- `Task` は session-local progress tracking。
- `Assignment` は Orchestrator から coder / reviewer / investigator Pod への具体的委譲。
- `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。
## Intake の責務
Intake は以下を行う。
- ユーザー依頼の主語と目的を確認する。
- 既存 Ticket を確認し、duplicate / related work を探す。
- 必要に応じて関連 docs / code / workflow / history を読む。
- 不足している要件を質問する。
- Ticket の title / slug / kind / priority / labels を提案する。
- background / requirements / acceptance criteria / non-goals / escalation conditions を整理する。
- readiness / needs_preflight / risk flags を明示する。
- ユーザー合意後に Ticket を作成する。
- 既存 Ticket の refinement を求められた場合は、TicketComment で経緯を残す。
## Intake がしないこと
- coder / reviewer / investigator Pod を起動しない。
- worktree を作らない。
- merge / close / branch cleanup をしない。
- implementation-ready でない Ticket を実装に投げない。
- unattended scheduler として自動実行しない。
- ユーザー合意なしに official Ticket を作らない。
- secrets / private context を Ticket body / thread / artifacts / diagnostics に保存しない。
- arbitrary filesystem write で `work-items/` を編集しない。Ticket 操作は Ticket tools を使う。
## 使用する Ticket tools
利用可能なら、以下の typed Ticket tools を使う。
- `TicketList`: 既存 Ticket の一覧・重複確認。
- `TicketShow`: 関連 Ticket の詳細確認。
- `TicketCreate`: 合意済み Ticket の作成。
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
- `TicketDoctor`: 必要に応じた整合性確認。
Intake は `TicketReview`, `TicketStatus`, `TicketClose` を通常使わない。review / status transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `tickets.sh` を使う人間/親 workflow に戻す。
## 手順
### 1. 依頼を受け取る
ユーザー依頼を短く言い換え、以下を分ける。
- 何を変えたいか。
- なぜ必要か。
- 影響を受けるユーザー / Pod / workflow / crate / docs。
- 既に決まっていること。
- まだ未決定のこと。
この段階では Ticket を作らない。
### 2. 既存 Ticket を確認する
`TicketList` / `TicketShow` で duplicate / related work を探す。
確認観点:
- 同じ目的の open / pending Ticket がないか。
- closed Ticket の判断・resolution と矛盾しないか。
- umbrella Ticket / follow-up Ticket が既にあるか。
- 既存 Ticket の refinement で足りるか、新規 Ticket が必要か。
既存 Ticket の更新で足りる場合、新規 Ticket を作らず、ユーザーに更新案を提示する。
### 3. 要件を同期する
最低限、以下を確認する。
- observable な完了条件は何か。
- Ticket の種類は何か: feature / bug / cleanup / design / spike / workflow / docs / release / orchestration。
- 受け入れ条件は何か。
- やらないことは何か。
- 後方互換が必要か。
- authority boundary / scope / permission / history / prompt context に触れるか。
- validation は何で確認できるか。
- 人間判断が必要な論点は何か。
不足がある場合は、Ticket 作成前に質問する。質問は多すぎず、Ticket 作成に必要な最小限に絞る。
### 4. readiness を分類する
Ticket 作成または更新前に、readiness を明示する。
```text
implementation_ready:
- 要件・受け入れ条件・non-goals / invariants が明確。
- 実装方針が一意または十分絞れている。
- validation が書ける。
requirements_sync_needed:
- 目的は見えているが、仕様・用語・UX・責務境界・受け入れ条件が未同期。
spike_needed:
- 技術調査、依存関係、性能、license、diagnostics、現在コード map が先に必要。
blocked:
- 人間判断、外部イベント、別 Ticket の完了が必要。
unspecified:
- どうしても分類不能な時だけ使う。理由を Ticket に書く。
```
### 5. needs_preflight / risk flags を付ける
以下に触れる Ticket は `needs_preflight: true` 相当として扱い、Ticket body に明記する。
- profile / manifest / scope / permission。
- session / history / Pod metadata / persistence。
- prompt context 加工原則。
- public API / plugin / feature boundary。
- security / secret / credential handling。
- storage migration / backward compatibility。
- 複数の自然な設計方針があるもの。
- reviewer が diff だけでは見落としやすい設計リスク。
risk flags は短い語でよい。
例:
```text
risk_flags: [authority-boundary, persistence, prompt-context, public-api]
```
### 6. Ticket draft を提示する
ユーザーに作成前 draft を提示する。
標準 draft:
```text
Title:
Kind:
Priority:
Labels:
Readiness:
Needs preflight:
Risk flags:
Background:
Requirements:
Acceptance criteria:
Non-goals:
Escalation conditions:
Validation:
Related tickets/docs:
```
この時点ではまだ Ticket を作らない。
### 7. ユーザー合意を取る
以下のどちらかがあるまで official Ticket を作らない。
- ユーザーが draft を明示的に承認する。
- ユーザーが「作って」「切って」「記録して」など、作成を明示する。
未決定のまま記録する場合は、`requirements_sync_needed` / `spike_needed` / `blocked` として未決定点を明示する。
### 8. Ticket を作成または更新する
新規 Ticket の場合:
- `TicketCreate` を使う。
- title / slug / kind / priority / labels / body を指定する。
- body に readiness / needs_preflight / risk flags を Markdown で明記する。
既存 Ticket refinement の場合:
- `TicketComment` を使う。
- role は内容に応じて `comment`, `plan`, `decision` を選ぶ。
- 既存 `item.md` の大幅変更が必要なら、Orchestrator / maintainer に戻す。
### 9. 作成後の報告
ユーザーへ以下を返す。
- 作成/更新した Ticket id / slug / title。
- readiness。
- needs_preflight / risk flags。
- 次に Orchestrator が取るべき routing 候補。
- 未決定点があれば、そのまま明示する。
Intake はここで止まる。implementation / worktree / coder / reviewer 起動は Orchestrator routing の責務である。
## Ticket body の推奨形
```markdown
## Background
## Requirements
## Acceptance criteria
## Non-goals
## Readiness
- readiness: implementation_ready | requirements_sync_needed | spike_needed | blocked | unspecified
- needs_preflight: true | false
- risk_flags: [...]
## Escalation conditions
## Validation
## Related work
```
Ticket の body は Markdown/freeform を維持する。すべてを strict schema に押し込まない。
## secret / private context policy
以下は Ticket に保存しない。
- API keys / tokens / credentials。
- secret file contents。
- private prompts / model responses that should not become project records。
- user private context not needed for project history。
- raw logs containing secrets。
必要なら、保存せずに「secret/config が必要」とだけ書く。
## 完了条件
この Workflow の完了条件は次のいずれかである。
- ユーザー合意済みの新規 Ticket が作成され、Orchestrator が routing できる情報が揃っている。
- 既存 Ticket に refinement / decision / plan が記録され、次の routing が明確である。
- Ticket 化しない判断がユーザーに説明され、未決定の問いまたは関連 Ticket が明確になっている。
## 他 Workflow への接続
- `ticket-preflight-workflow`: needs_preflight が true、または implementation_ready か不安な場合に接続する。
- `multi-agent-workflow`: Orchestrator が implementation_ready と判断した後に接続する。
- `ticket-orchestrator-routing`: この Workflow が作った Ticket を routing する後続 Workflow。

View File

@ -0,0 +1,340 @@
---
description: Ticket を読み、Orchestrator が preflight / spike / implementation / review / blocked / close へ明示的に routing する workflow
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Orchestrator Routing Workflow
Yoi の multi-agent 運用で、Intake や人間が作成した Ticket を Orchestrator が読み、次に取るべき action を明示的に分類・記録するための Workflow。
これは scheduler ではない。目的は、Ticket の fields / body / thread / artifacts / 現在の repository/Pod 状態を明示的に確認し、隠れた会話状態ではなく Ticket に基づいて routing 判断を残すことである。
## 位置づけ
```text
TicketCreate / TicketComment
-> Ticket Orchestrator Routing Workflow
-> requirements sync / preflight / spike / implementation / review / blocked / close / pending
-> 必要に応じて他 Workflow へ接続
```
- Intake は Ticket の materialization を担当する。
- Orchestrator は Ticket の next action を分類する。
- `ticket-preflight-workflow` は実装前の設計・要件 gate。
- `multi-agent-workflow` は coder / reviewer Pod と worktree を使う実装・レビュー loop。
- この Workflow は自動 scheduler / lease / unattended maintainer ではない。
## Orchestrator の責務
Orchestrator は以下を行う。
- Ticket を `TicketShow` で読む。
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
- next action を routing classification として決める。
- routing decision を `TicketComment` で Ticket thread に記録する。
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
- preflight-needed の場合は coder Pod に直投げせず、`ticket-preflight-workflow` に接続する。
## Orchestrator がしないこと
- 自動 scheduler として unattended に実行しない。
- 人間/上位 Orchestrator の許可なしに coder / reviewer / investigator Pod を起動しない。
- 設計境界の未決定を勝手に implementation-ready として固定しない。
- merge / close / cleanup 権限を持たない場面で勝手に完了処理しない。
- Ticket tools があるからといって arbitrary filesystem write を行わない。
- Notification だけを完了証拠にしない。Pod output / diff / validation / Ticket evidence を確認する。
## 使用する Ticket tools
利用可能なら、以下を使う。
- `TicketList`: routing 候補や関連 Ticket の確認。
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution 確認。
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
- `TicketDoctor`: routing 前後の整合性確認。
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
## Routing classification
Orchestrator は対象 Ticket を以下のいずれかに分類する。
### `requirements_sync_needed`
仕様・用語・UX・責務境界・受け入れ条件が未同期。
条件:
- Ticket の目的は見えるが、observable な完了条件が書けない。
- ユーザー判断がないと product/API/UX を固定してしまう。
- acceptance criteria または non-goals が不足している。
- existing decisions / docs / closed Tickets と矛盾する可能性がある。
Action:
- Intake / human に戻す。
- `TicketComment` で不足情報と質問を記録する。
- coder Pod は起動しない。
### `preflight_needed`
実装前に設計境界・要件・反証観点を同期すべき状態。
条件:
- profile / manifest / scope / permission / session / history / Pod metadata / prompt context に触れる。
- public API / plugin / feature boundary / storage migration / security / secrets に触れる。
- 複数の自然な実装方針がある。
- implementation-ready に見えるが、reviewer が diff だけでは見落としやすい設計リスクがある。
- `needs_preflight: true` または同等の記述が Ticket にある。
Action:
- `ticket-preflight-workflow` に接続する。
- `TicketComment` で preflight reason を記録する。
- preflight が implementation-ready にするまで coder Pod は起動しない。
### `spike_needed`
実装前に read-only 調査が必要。
条件:
- 技術的実現性が不明。
- dependency / license / packaging / portability / performance / diagnostics の確認が必要。
- current code map が不足している。
- bug の再現条件や観測 evidence が不足している。
Action:
- read-only investigation を提案する。
- 許可があれば investigator Pod を read-only scope で起動できる。
- `TicketComment` に調査問い・scope・完了条件を記録する。
- 実装 worktree はまだ作らない。
### `implementation_ready`
既存 multi-agent workflow に渡せる状態。
条件:
- background / requirements / acceptance criteria が明確。
- non-goals / invariants / escalation conditions が必要十分。
- validation が書ける。
- design / authority boundary の未決定がない、または preflight 済み。
- IntentPacket を短く書ける。
Action:
- `IntentPacket` を作る。
- `TicketComment` に routing decision と IntentPacket を記録する。
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。
### `review_needed`
実装はあるが、review / acceptance が未完了。
条件:
- implementation report / branch / commit / diff がある。
- external review がない、または reviewer blocker が未解決。
- validation evidence が足りない。
Action:
- reviewer Pod 起動または追加 validation を提案する。
- `TicketComment` に review target と確認観点を記録する。
- blocker 未解決のまま merge-ready としない。
### `blocked_action_required`
人間判断または外部イベント待ち。
条件:
- design/product/security 判断が必要。
- credential / secret / environment / external service が必要。
- 別 Ticket / branch / upstream change の完了待ち。
- scope/permission が不足している。
Action:
- 必要な判断・外部 action を短く書く。
- `TicketComment` に blocked reason と next question を記録する。
- 必要なら `TicketStatus` で pending に移す(許可がある場合だけ)。
### `close_ready`
完了処理に進める状態。
条件:
- requirements / acceptance criteria が満たされている。
- review / validation / merge / cleanup evidence が揃っている。
- resolution を書ける。
- close 権限がある。
Action:
- `TicketClose` または既存 close workflow で resolution を記録する。
- close 権限がない場合は merge-ready / close-ready dossier を親/人間に提出する。
### `defer_pending`
今は進めないが blocked ではない。
条件:
- 優先度・タイミングの理由で後回し。
- 依存はあるが active blocker として扱うほどではない。
- umbrella / roadmap 的に保持する。
Action:
- defer reason を `TicketComment` に記録する。
- 必要なら `TicketStatus` で pending に移す(許可がある場合だけ)。
### `closed_or_noop`
routing 不要。
条件:
- closed Ticket。
- duplicate として既存 Ticket に統合済み。
- Ticket 自体が不要と判断済み。
Action:
- 追加 action なし。
- 必要なら related Ticket へ comment する。
## Routing 手順
### 1. 状態確認
- `git status --short --branch`
- `TicketShow <target>`
- 関連 Ticket の `TicketList` / `TicketShow`
- 必要に応じて docs/code/workflow/history
- 必要に応じて visible Pods / worktrees / branches
この段階で unrelated dirty changes がある場合、実装/merge には進まず、routing decision の記録だけに留めるかユーザーに確認する。
### 2. Ticket evidence を読む
最低限、以下を確認する。
- Background
- Requirements
- Acceptance criteria
- Non-goals / invariants
- Readiness / needs_preflight / risk flags
- Escalation conditions
- Validation
- Thread の plan / decision / implementation_report / review
- Artifacts / branch / commit references
### 3. Classification を決める
1つに決める。複数に見える場合は、次に必要な action が最も早いものを選ぶ。
例:
- implementation-ready だが authority boundary に触れる → `preflight_needed`
- 実装済みだが review がない → `review_needed`
- 要件が曖昧で spike も必要そう → `requirements_sync_needed` を優先し、調査問いを明確化する
- 完了しているが close 権限がない → `close_ready` として dossier を返す
### 4. Routing decision を Ticket に記録する
`TicketComment` の role は通常 `decision` または `plan` を使う。
標準形:
```markdown
Routing decision: <classification>
Reason:
- ...
Evidence checked:
- Ticket body / thread / artifacts
- related Ticket(s)
- code/docs/workflow paths
- branch/worktree/Pod state if relevant
Next action:
- ...
Escalate if:
- ...
```
### 5. IntentPacket を作るimplementation_ready の場合)
`multi-agent-workflow` に渡す前に、以下を短くまとめる。
```text
Intent:
- 何を実現するか。
Requirements:
- 完了時に満たす observable な要件。
Invariants:
- 壊してはいけない design / authority boundary。
Non-goals:
- 今回やらないこと。
Escalate if:
- 親/人間に戻す判断条件。
Validation:
- 実行すべき format / build / test / doctor。
Current code map:
- 実装対象と触ってはいけない場所。
Critical risks:
- reviewer にも見てほしい失敗パターン。
```
IntentPacket が短く書けない場合、`implementation_ready` ではなく `preflight_needed` または `requirements_sync_needed` に戻す。
### 6. 後続 Workflow へ接続する
- `requirements_sync_needed``ticket-intake-workflow` / human
- `preflight_needed``ticket-preflight-workflow`
- `spike_needed` → read-only investigation plan / Pod許可後
- `implementation_ready``multi-agent-workflow`
- `review_needed` → reviewer Pod / review workflow
- `blocked_action_required` → human / parent Orchestrator
- `close_ready` → close workflow / maintainer decision
- `defer_pending` → pending / roadmap management
## 完了条件
この Workflow の完了条件は次のいずれかである。
- routing decision が Ticket に記録され、次に接続する Workflow / human action が明確である。
- implementation-ready Ticket について IntentPacket が Ticket に記録され、`multi-agent-workflow` に渡せる。
- requirements-sync / preflight / spike / blocked / review / close-ready の理由と次 action が Ticket に記録されている。
- routing 不要と判断され、その理由が明確である。
## この Workflow で固定しないもの
- unattended scheduler。
- LeaseStore / queue persistence。
- action-required dashboard UI。
- automatic Pod spawning policy。
- TicketUpdate tool の導入。
- external tracker integration。
これらは routing decision を人間/Orchestrator が明示的に扱えるようになった後の follow-up とする。

11
Cargo.lock generated
View File

@ -335,6 +335,9 @@ dependencies = [
"manifest",
"protocol",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"uuid",
]
@ -2350,6 +2353,7 @@ dependencies = [
"session-store",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"toml",
"tools",
@ -3621,10 +3625,17 @@ dependencies = [
name = "ticket"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"fs4",
"llm-worker",
"schemars",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]

View File

@ -2,30 +2,90 @@
Yoi is an agent runtime for building, running, and orchestrating LLM Pods while preserving explicit history, scoped capabilities, and developer-controlled workflows.
## 1. Yoi agent
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Pod can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Pods, and be inspected or restored through CLI/TUI surfaces.
Main highlights:
- Named long-running **Pods** with durable session and metadata records.
- Explicit tool permissions and filesystem scopes.
- Multi-agent orchestration with scoped coder/reviewer Pods.
- Profile, Manifest, and prompt-based runtime configuration.
- Local Tickets and workflow files for auditable project coordination.
- TUI and CLI entry points, including a multi-Pod dashboard.
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
## 2. Quick Install
From source:
```sh
cargo build --release -p yoi
./target/release/yoi --help
```
With Nix:
```sh
nix build .#yoi
./result/bin/yoi --help
```
## 3. Getting Started
```sh
yoi --help
yoi
yoi --multi
yoi --pod <name>
yoi pod --help
```
Typical flow:
1. Configure providers, models, profiles, prompts, and scopes.
2. Start or attach to a named Pod from the CLI/TUI.
3. Use explicit tools and scoped delegation for multi-agent work.
4. Record project work through Tickets, workflow files, and git history.
Runtime surfaces use `yoi`, `.yoi`, `~/.yoi`, and `YOI_*`.
## Documentation map
## 4. Documentation
Start with [`docs/README.md`](docs/README.md). The repository documentation is intentionally small: it should explain current design intent and development workflow, not preserve every old plan or external research note.
Start with [`docs/README.md`](docs/README.md).
Important entry points:
Key docs:
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
- [`docs/design/context-history.md`](docs/design/context-history.md) — the rules for history, context, and prompt-cache-safe inputs.
- [`docs/design/pod-session-state.md`](docs/design/pod-session-state.md) — why Pod metadata, session logs, and live runtime hints are separate.
- [`docs/development/work-items.md`](docs/development/work-items.md) — work item and ticket workflow.
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
- [`docs/design/pod-session-state.md`](docs/design/pod-session-state.md) — Pod identity, metadata, and session logs.
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
- [`docs/development/validation.md`](docs/development/validation.md) — validation expectations.
## Development
## 5. Development
This repository dogfoods Yoi to develop Yoi. Work is tracked through `work-items/` and `./tickets.sh`; git history plus work item files are the authoritative record of state changes.
This repository dogfoods Yoi to develop Yoi. Work is tracked through `work-items/` and `./tickets.sh`; git history plus Ticket files are the authoritative project record.
Common local checks:
Common checks:
```sh
./tickets.sh doctor
git diff --check
cargo fmt --check
cargo check --workspace --all-targets
cargo test --workspace
```
E2E testing with real spawned processes is not yet designed. Avoid adding compatibility layers or broad formatting churn only to reduce short-term edit size; prefer clear boundaries and type safety.
Optional Nix validation:
```sh
nix build .#yoi --no-link
```
E2E testing with real spawned processes is not yet designed. Keep changes scoped, preserve durable authority boundaries, and prefer clear type-safe structure over short-term compatibility layers.
License: MIT. See [`LICENSE`](LICENSE).

View File

@ -7,6 +7,11 @@ license.workspace = true
[dependencies]
protocol = { workspace = true }
manifest = { workspace = true }
ticket = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
uuid = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@ -11,8 +11,14 @@
mod pod_client;
pub mod runtime_command;
pub mod spawn;
pub mod ticket_role;
pub use runtime_command::PodRuntimeCommand;
pub use pod_client::PodClient;
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
pub use ticket_role::{
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchPlan,
TicketRoleLaunchResult, launch_ticket_role_pod, plan_ticket_role_launch,
plan_ticket_role_launch_with_config,
};

View File

@ -22,7 +22,7 @@ use uuid::Uuid;
const READY_PREFIX: &str = "YOI-READY\t";
const READY_TIMEOUT: Duration = Duration::from_secs(20);
/// `spawn_pod` の入力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnConfig {
pub runtime_command: PodRuntimeCommand,
/// `pod.name` として使う識別子。runtime ディレクトリ
@ -43,6 +43,7 @@ pub struct SpawnConfig {
pub resume_by_pod_name: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnReady {
pub pod_name: String,
pub socket_path: PathBuf,

View File

@ -0,0 +1,606 @@
//! Ticket-role Pod launch planning and execution.
//!
//! This module keeps Ticket role configuration, generated first-run input, and
//! host-side Pod spawning behind the `client` crate so UI callers do not need to
//! depend on `pod` internals.
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use protocol::{ErrorCode, Event, InvokeKind, Method, Segment};
use thiserror::Error;
pub use ticket::config::TicketRole;
use ticket::config::{TicketConfig, TicketConfigError};
use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, spawn_pod};
const MAX_FIELD_CHARS: usize = 8_000;
const MAX_POD_NAME_CHARS: usize = 80;
const RUN_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(10);
/// Ticket identifier carried by a role launch request.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TicketRef {
pub id: Option<String>,
pub slug: Option<String>,
}
impl TicketRef {
pub fn id(id: impl Into<String>) -> Self {
Self {
id: Some(id.into()),
slug: None,
}
}
pub fn slug(slug: impl Into<String>) -> Self {
Self {
id: None,
slug: Some(slug.into()),
}
}
pub fn id_slug(id: impl Into<String>, slug: impl Into<String>) -> Self {
Self {
id: Some(id.into()),
slug: Some(slug.into()),
}
}
fn pod_name_seed(&self) -> Option<&str> {
non_empty(self.slug.as_deref()).or_else(|| non_empty(self.id.as_deref()))
}
fn append_prompt_lines(&self, out: &mut String) {
match (
non_empty(self.id.as_deref()),
non_empty(self.slug.as_deref()),
) {
(None, None) => out.push_str("Target Ticket: not specified\n"),
(id, slug) => {
out.push_str("Target Ticket:\n");
if let Some(id) = id {
push_bounded_bullet(out, "id", id);
}
if let Some(slug) = slug {
push_bounded_bullet(out, "slug", slug);
}
}
}
}
}
/// Typed input for constructing a Ticket role launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleLaunchContext {
pub workspace_root: PathBuf,
pub role: TicketRole,
pub pod_name: Option<String>,
pub ticket: Option<TicketRef>,
pub user_instruction: Option<String>,
pub intent_packet: Option<String>,
pub worktree_path: Option<PathBuf>,
pub branch: Option<String>,
pub validation: Vec<String>,
pub report_expectations: Vec<String>,
}
impl TicketRoleLaunchContext {
pub fn new(workspace_root: impl Into<PathBuf>, role: TicketRole) -> Self {
Self {
workspace_root: workspace_root.into(),
role,
pod_name: None,
ticket: None,
user_instruction: None,
intent_packet: None,
worktree_path: None,
branch: None,
validation: Vec::new(),
report_expectations: Vec::new(),
}
}
}
/// Pure launch plan usable by TUI/CLI surfaces before executing the launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleLaunchPlan {
pub workspace_root: PathBuf,
pub role: TicketRole,
pub pod_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
pub run_segments: Vec<Segment>,
}
impl TicketRoleLaunchPlan {
pub fn run_method(&self) -> Method {
Method::Run {
input: self.run_segments.clone(),
}
}
pub fn spawn_config(
&self,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnConfig, TicketRoleLaunchError> {
if self.profile == "inherit" {
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
}
Ok(SpawnConfig {
runtime_command,
pod_name: self.pod_name.clone(),
profile: Some(self.profile.clone()),
cwd: self.workspace_root.clone(),
resume_from: None,
resume_by_pod_name: false,
})
}
}
/// Result of executing a Ticket role launch.
#[derive(Debug, Clone)]
pub struct TicketRoleLaunchResult {
pub plan: TicketRoleLaunchPlan,
pub ready: SpawnReady,
}
#[derive(Debug, Error)]
pub enum TicketRoleLaunchError {
#[error(transparent)]
Config(#[from] TicketConfigError),
#[error("Ticket role Pod name must not be empty")]
EmptyPodName,
#[error(
"Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector"
)]
UnsupportedInheritProfile,
#[error(transparent)]
Spawn(#[from] SpawnError),
#[error("failed to connect to spawned Ticket role Pod at {}: {source}", .socket_path.display())]
Connect {
socket_path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to send first run input to spawned Ticket role Pod: {source}")]
SendRun {
#[source]
source: io::Error,
},
#[error("Ticket role Pod rejected first run input with {code:?}: {message}")]
RunRejected { code: ErrorCode, message: String },
#[error("Ticket role Pod closed before confirming first run acceptance")]
RunAcceptanceClosed,
#[error("timed out waiting for Ticket role Pod to confirm first run acceptance")]
RunAcceptanceTimeout,
}
/// Load `.yoi/ticket.config.toml` from the workspace and construct a launch plan.
pub fn plan_ticket_role_launch(
context: TicketRoleLaunchContext,
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
let config = TicketConfig::load_workspace(&context.workspace_root)?;
plan_ticket_role_launch_with_config(context, &config)
}
/// Construct a launch plan from an already-loaded Ticket config.
pub fn plan_ticket_role_launch_with_config(
context: TicketRoleLaunchContext,
config: &TicketConfig,
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
let role_config = config.role(context.role);
let profile = role_config.profile.as_str().to_string();
let workflow = role_config.workflow.as_str().to_string();
let launch_prompt_ref = role_config
.launch_prompt
.as_ref()
.map(|prompt| prompt.as_str().to_string());
let pod_name = match context.pod_name.as_deref().map(str::trim) {
Some("") => return Err(TicketRoleLaunchError::EmptyPodName),
Some(name) => name.to_string(),
None => default_pod_name(context.role, context.ticket.as_ref()),
};
let prompt = build_launch_prompt(&context, &profile, &workflow, launch_prompt_ref.as_deref());
Ok(TicketRoleLaunchPlan {
workspace_root: context.workspace_root,
role: context.role,
pod_name,
profile,
workflow: workflow.clone(),
launch_prompt_ref,
run_segments: vec![
Segment::WorkflowInvoke { slug: workflow },
Segment::Text {
content: format!("\n\n{prompt}"),
},
],
})
}
/// Spawn the Pod, connect to its socket, send the first `Method::Run` input,
/// and wait for bounded acceptance evidence from the Pod event stream.
pub async fn launch_ticket_role_pod<F>(
context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand,
progress: F,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
where
F: FnMut(&str),
{
let plan = plan_ticket_role_launch(context)?;
let ready = spawn_pod(plan.spawn_config(runtime_command)?, progress).await?;
let mut client = PodClient::connect(&ready.socket_path)
.await
.map_err(|source| TicketRoleLaunchError::Connect {
socket_path: ready.socket_path.clone(),
source,
})?;
client
.send(&plan.run_method())
.await
.map_err(|source| TicketRoleLaunchError::SendRun { source })?;
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
Ok(TicketRoleLaunchResult { plan, ready })
}
async fn wait_for_run_acceptance(
client: &mut PodClient,
expected_segments: &[Segment],
timeout: Duration,
) -> Result<(), TicketRoleLaunchError> {
let wait = async {
loop {
let Some(event) = client.next_event().await else {
return Err(TicketRoleLaunchError::RunAcceptanceClosed);
};
match event {
Event::UserMessage { segments } if segments == expected_segments => return Ok(()),
Event::InvokeStart {
kind: InvokeKind::UserSend,
}
| Event::TurnStart { .. } => return Ok(()),
Event::Error { code, message } => {
return Err(TicketRoleLaunchError::RunRejected { code, message });
}
_ => {}
}
}
};
tokio::time::timeout(timeout, wait)
.await
.map_err(|_| TicketRoleLaunchError::RunAcceptanceTimeout)?
}
fn build_launch_prompt(
context: &TicketRoleLaunchContext,
profile: &str,
workflow: &str,
launch_prompt_ref: Option<&str>,
) -> String {
let mut out = String::new();
out.push_str("# Ticket role launch\n\n");
out.push_str("Profile supplies durable system/role behavior. The workflow segment supplies the procedural flow. This generated launch prompt supplies only the concrete Ticket/action context for the first committed user task.\n\n");
push_bounded_field(&mut out, "Role", context.role.as_str());
push_bounded_field(&mut out, "Profile selector", profile);
push_bounded_field(&mut out, "Workflow", workflow);
match launch_prompt_ref {
Some(prompt_ref) => push_bounded_field(
&mut out,
"Configured launch_prompt ref (unresolved)",
prompt_ref,
),
None => out.push_str("Configured launch_prompt ref: none\n"),
}
out.push('\n');
if let Some(ticket) = &context.ticket {
ticket.append_prompt_lines(&mut out);
} else {
out.push_str("Target Ticket: not specified\n");
}
match non_empty(context.user_instruction.as_deref()) {
Some(instruction) => push_bounded_section(&mut out, "User/action instruction", instruction),
None => out.push_str("\nUser/action instruction: not specified\n"),
}
if let Some(intent_packet) = non_empty(context.intent_packet.as_deref()) {
push_bounded_section(&mut out, "Intent packet", intent_packet);
}
if context.worktree_path.is_some() || non_empty(context.branch.as_deref()).is_some() {
out.push_str("\nWorktree context:\n");
if let Some(path) = &context.worktree_path {
push_bounded_bullet(&mut out, "path", &path.display().to_string());
}
if let Some(branch) = non_empty(context.branch.as_deref()) {
push_bounded_bullet(&mut out, "branch", branch);
}
}
if !context.validation.is_empty() {
push_bounded_list(&mut out, "Validation expectations", &context.validation);
}
if !context.report_expectations.is_empty() {
push_bounded_list(
&mut out,
"Report expectations",
&context.report_expectations,
);
}
out
}
fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
let mut name = format!("ticket-{}", role.as_str());
if let Some(seed) = ticket.and_then(TicketRef::pod_name_seed) {
let suffix = sanitise_pod_name_component(seed);
if !suffix.is_empty() {
name.push('-');
name.push_str(&suffix);
}
}
name.chars().take(MAX_POD_NAME_CHARS).collect()
}
fn sanitise_pod_name_component(value: &str) -> String {
let mut out = String::new();
let mut last_was_dash = false;
for ch in value.trim().chars() {
let mapped = if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.') {
Some(ch.to_ascii_lowercase())
} else if ch == '-' || ch.is_whitespace() {
Some('-')
} else {
Some('-')
};
if let Some(ch) = mapped {
if ch == '-' {
if !last_was_dash && !out.is_empty() {
out.push(ch);
}
last_was_dash = true;
} else {
out.push(ch);
last_was_dash = false;
}
}
}
out.trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
.chars()
.take(MAX_POD_NAME_CHARS)
.collect()
}
fn push_bounded_field(out: &mut String, label: &str, value: &str) {
out.push_str(label);
out.push_str(": ");
out.push_str(&bounded(value));
out.push('\n');
}
fn push_bounded_bullet(out: &mut String, label: &str, value: &str) {
out.push_str("- ");
out.push_str(label);
out.push_str(": ");
out.push_str(&bounded(value));
out.push('\n');
}
fn push_bounded_section(out: &mut String, label: &str, value: &str) {
out.push('\n');
out.push_str(label);
out.push_str(":\n");
out.push_str(&bounded(value));
out.push('\n');
}
fn push_bounded_list(out: &mut String, label: &str, values: &[String]) {
out.push('\n');
out.push_str(label);
out.push_str(":\n");
for value in values
.iter()
.filter_map(|value| non_empty(Some(value.as_str())))
{
out.push_str("- ");
out.push_str(&bounded(value));
out.push('\n');
}
}
fn bounded(value: &str) -> String {
let trimmed = value.trim();
let mut out: String = trimmed.chars().take(MAX_FIELD_CHARS).collect();
if trimmed.chars().count() > MAX_FIELD_CHARS {
out.push_str("\n[truncated]");
}
out
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_config(workspace: &std::path::Path, content: &str) {
let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
}
fn text_segment(plan: &TicketRoleLaunchPlan) -> &str {
match &plan.run_segments[1] {
Segment::Text { content } => content,
other => panic!("expected text segment, got {other:?}"),
}
}
#[test]
fn default_config_role_launch_plan_uses_defaults() {
let temp = TempDir::new().unwrap();
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
context.ticket = Some(TicketRef::slug("Ticket Role Pod Launcher"));
let plan = plan_ticket_role_launch(context).unwrap();
assert_eq!(plan.role, TicketRole::Coder);
assert_eq!(plan.pod_name, "ticket-coder-ticket-role-pod-launcher");
assert_eq!(plan.profile, "inherit");
assert_eq!(plan.workflow, "multi-agent-workflow");
assert_eq!(plan.launch_prompt_ref, None);
assert!(matches!(
&plan.run_segments[0],
Segment::WorkflowInvoke { slug } if slug == "multi-agent-workflow"
));
assert!(text_segment(&plan).contains("Profile selector: inherit"));
let err = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
.unwrap_err();
assert!(matches!(
err,
TicketRoleLaunchError::UnsupportedInheritProfile
));
assert!(err.to_string().contains("'inherit' cannot be used"));
}
#[test]
fn configured_role_refs_are_exposed_in_plan_and_prompt() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow"
"#,
);
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
context.pod_name = Some("reviewer-fixed".to_string());
context.ticket = Some(TicketRef::id_slug(
"20260605-190330-ticket-role-pod-launcher",
"ticket-role-pod-launcher",
));
context.user_instruction = Some("Review the submitted implementation.".to_string());
let plan = plan_ticket_role_launch(context).unwrap();
let text = text_segment(&plan);
assert_eq!(plan.pod_name, "reviewer-fixed");
assert_eq!(plan.profile, "project:reviewer");
assert_eq!(plan.workflow, "ticket-review-workflow");
assert_eq!(
plan.launch_prompt_ref.as_deref(),
Some("$workspace/ticket/reviewer/launch")
);
assert!(matches!(
&plan.run_segments[0],
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
));
assert!(text.contains(
"Configured launch_prompt ref (unresolved): $workspace/ticket/reviewer/launch"
));
assert!(text.contains("Workflow: ticket-review-workflow"));
assert!(text.contains("Profile selector: project:reviewer"));
assert!(!text.contains("system_instruction"));
let spawn = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
.unwrap();
assert_eq!(spawn.pod_name, "reviewer-fixed");
assert_eq!(spawn.profile.as_deref(), Some("project:reviewer"));
assert_eq!(spawn.cwd, temp.path());
}
#[test]
fn generated_prompt_covers_intake_orchestrator_and_reviewer_context() {
let temp = TempDir::new().unwrap();
let mut intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
intake.user_instruction = Some("Clarify and materialize this request as a Ticket.".into());
let intake_plan = plan_ticket_role_launch(intake).unwrap();
let intake_text = text_segment(&intake_plan);
assert!(intake_text.contains("Role: intake"));
assert!(intake_text.contains("Clarify and materialize"));
assert!(intake_text.contains("Workflow: ticket-intake-workflow"));
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
orchestrator.ticket = Some(TicketRef::slug("launcher"));
orchestrator.intent_packet = Some("Route to implementation after preflight.".into());
orchestrator.validation = vec!["cargo check --workspace --all-targets".into()];
let orchestrator_plan = plan_ticket_role_launch(orchestrator).unwrap();
let orchestrator_text = text_segment(&orchestrator_plan);
assert!(orchestrator_text.contains("Role: orchestrator"));
assert!(orchestrator_text.contains("Route to implementation after preflight."));
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review"));
reviewer.branch = Some("work/ticket-role-pod-launcher".into());
reviewer.report_expectations = vec!["approve or request changes".into()];
let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap();
let reviewer_text = text_segment(&reviewer_plan);
assert!(reviewer_text.contains("Role: reviewer"));
assert!(reviewer_text.contains("path: /tmp/yoi-review"));
assert!(reviewer_text.contains("branch: work/ticket-role-pod-launcher"));
assert!(reviewer_text.contains("approve or request changes"));
}
#[test]
fn caller_provided_pod_name_is_used_exactly() {
let temp = TempDir::new().unwrap();
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
context.pod_name = Some("custom-intake-pod".into());
let plan = plan_ticket_role_launch(context).unwrap();
assert_eq!(plan.pod_name, "custom-intake-pod");
}
#[test]
fn malformed_ticket_config_surfaces_error() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "./coder.lua"
"#,
);
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
let err = plan_ticket_role_launch(context).unwrap_err();
assert!(err.to_string().contains("path selectors are not supported"));
}
#[test]
fn system_instruction_is_not_a_launch_config_field() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
"#,
);
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
let err = plan_ticket_role_launch(context).unwrap_err();
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("system_instruction"));
}
}

View File

@ -29,6 +29,7 @@ include_dir = "0.7.4"
fs4 = { workspace = true, features = ["sync"] }
libc = { workspace = true }
schemars = { workspace = true }
ticket = { workspace = true }
memory = { workspace = true }
workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] }

View File

@ -523,6 +523,7 @@ where
let mut feature_registry = FeatureRegistryBuilder::new();
feature_registry.add_module(task_feature);
feature_registry.add_module(crate::feature::builtin::ticket_tools_feature(&pwd));
let _feature_install_report = pod.install_features(feature_registry);
let worker = pod.worker_mut();

View File

@ -1,7 +1,7 @@
//! Feature contribution registry for Pod-hosted builtin/plugin modules.
//!
//! This module defines the Pod-side feature boundary used to collect
//! descriptor metadata, authority requests, tool contributions, safe hook
//! descriptor metadata, host authority requests, tool contributions, safe hook
//! contributions, background task declarations, and service declarations before
//! installing them into the existing Worker/HookRegistry host surfaces.
//!
@ -74,8 +74,8 @@ pub enum FeatureRuntimeKind {
///
/// Contribution declarations such as tools, hooks, background tasks, and
/// services are descriptor/package-approved host-visible contributions, not
/// sandbox authorities. Grants are additive and do not replace manifest/tool
/// permission checks.
/// host authorities. Host authority grants are additive and do not replace
/// manifest/tool permission checks.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostAuthority {
@ -85,6 +85,7 @@ pub enum HostAuthority {
ModelNotification,
PodManagement,
StateStore { name: String },
TicketBackend { root: String },
ServiceAccess { service: ServiceId },
}
@ -98,15 +99,15 @@ pub enum FeatureHookPoint {
TurnEnd,
}
/// Authority request declared by a feature descriptor.
/// Host authority request declared by a feature descriptor.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityRequest {
pub struct HostAuthorityRequest {
pub authority: HostAuthority,
pub required: bool,
pub reason: String,
}
impl AuthorityRequest {
impl HostAuthorityRequest {
pub fn required(authority: HostAuthority, reason: impl Into<String>) -> Self {
Self {
authority,
@ -124,15 +125,15 @@ impl AuthorityRequest {
}
}
/// Authority grants resolved by the host for one feature installation.
/// Host authority grants resolved by the host for one feature installation.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityGrantSet {
pub struct HostAuthorityGrantSet {
granted: HashSet<HostAuthority>,
denied: Vec<AuthorityDenial>,
denied: Vec<HostAuthorityDenial>,
}
impl AuthorityGrantSet {
pub fn grant_all(requests: &[AuthorityRequest]) -> Self {
impl HostAuthorityGrantSet {
pub fn grant_all(requests: &[HostAuthorityRequest]) -> Self {
Self {
granted: requests
.iter()
@ -150,7 +151,7 @@ impl AuthorityGrantSet {
self.granted.contains(authority)
}
pub fn denied(&self) -> &[AuthorityDenial] {
pub fn denied(&self) -> &[HostAuthorityDenial] {
&self.denied
}
@ -160,16 +161,16 @@ impl AuthorityGrantSet {
pub fn deny(&mut self, authority: HostAuthority, reason: impl Into<String>) {
self.granted.remove(&authority);
self.denied.push(AuthorityDenial {
self.denied.push(HostAuthorityDenial {
authority,
reason: reason.into(),
});
}
}
/// Host-side denial of a requested feature authority.
/// Host-side denial of a requested feature host authority.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityDenial {
pub struct HostAuthorityDenial {
pub authority: HostAuthority,
pub reason: String,
}
@ -191,11 +192,12 @@ impl ToolDeclaration {
}
}
/// Executable tool contribution wrapper.
/// Executable tool contribution wrapper. Host-authority requirements are optional
/// per-tool gates for privileged host APIs, not permission to contribute a tool.
pub struct ToolContribution {
name: String,
definition: ToolDefinition,
required_authorities: Vec<HostAuthority>,
required_host_authorities: Vec<HostAuthority>,
}
impl ToolContribution {
@ -203,12 +205,15 @@ impl ToolContribution {
Self {
name: name.into(),
definition,
required_authorities: Vec::new(),
required_host_authorities: Vec::new(),
}
}
pub fn with_required_authorities(mut self, required_authorities: Vec<HostAuthority>) -> Self {
self.required_authorities = required_authorities;
pub fn with_required_host_authorities(
mut self,
required_host_authorities: Vec<HostAuthority>,
) -> Self {
self.required_host_authorities = required_host_authorities;
self
}
@ -323,7 +328,7 @@ impl ServiceDeclaration {
}
}
/// Feature service requirement used for host-mediated dependency resolution.
/// Feature service requirement used for contribution dependency resolution.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceRequirement {
pub id: ServiceId,
@ -352,7 +357,7 @@ impl ServiceRequirement {
}
}
/// Host-mediated service registry skeleton used during feature installation.
/// Contribution service registry skeleton used during feature installation.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureServiceRegistry {
providers: HashMap<ServiceId, FeatureServiceProvider>,
@ -405,7 +410,7 @@ pub struct FeatureDescriptor {
pub display_name: String,
pub version: String,
pub description: String,
pub requested_authorities: Vec<AuthorityRequest>,
pub requested_host_authorities: Vec<HostAuthorityRequest>,
pub tools: Vec<ToolDeclaration>,
pub hooks: Vec<HookDeclaration>,
pub background_tasks: Vec<BackgroundTaskDeclaration>,
@ -421,7 +426,7 @@ impl FeatureDescriptor {
display_name: display_name.into(),
version: env!("CARGO_PKG_VERSION").into(),
description: String::new(),
requested_authorities: Vec::new(),
requested_host_authorities: Vec::new(),
tools: Vec::new(),
hooks: Vec::new(),
background_tasks: Vec::new(),
@ -435,8 +440,8 @@ impl FeatureDescriptor {
self
}
pub fn with_authority(mut self, request: AuthorityRequest) -> Self {
self.requested_authorities.push(request);
pub fn with_host_authority(mut self, request: HostAuthorityRequest) -> Self {
self.requested_host_authorities.push(request);
self
}
@ -538,7 +543,7 @@ pub struct FeatureInstallReport {
pub feature_id: FeatureId,
pub runtime: FeatureRuntimeKind,
pub installed: bool,
pub granted_authorities: AuthorityGrantSet,
pub host_authority_grants: HostAuthorityGrantSet,
pub installed_tools: Vec<String>,
pub installed_hooks: Vec<HookDeclaration>,
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
@ -549,12 +554,12 @@ pub struct FeatureInstallReport {
}
impl FeatureInstallReport {
fn new(descriptor: &FeatureDescriptor, granted_authorities: AuthorityGrantSet) -> Self {
fn new(descriptor: &FeatureDescriptor, host_authority_grants: HostAuthorityGrantSet) -> Self {
Self {
feature_id: descriptor.id.clone(),
runtime: descriptor.runtime.clone(),
installed: false,
granted_authorities,
host_authority_grants,
installed_tools: Vec::new(),
installed_hooks: Vec::new(),
declared_background_tasks: Vec::new(),
@ -648,33 +653,33 @@ fn reject_undeclared_contribution(
error
}
fn require_authority(
grants: &AuthorityGrantSet,
fn require_host_authority(
host_authority_grants: &HostAuthorityGrantSet,
report: &mut FeatureInstallReport,
kind: FeatureContributionKind,
name: impl Into<String>,
authority: &HostAuthority,
) -> Result<(), FeatureInstallError> {
if grants.contains(authority) {
if host_authority_grants.contains(authority) {
return Ok(());
}
let reason = format!("required authority was not granted: {authority:?}");
let reason = format!("required host authority was not granted: {authority:?}");
report.mark_skipped(kind, name, reason.clone());
Err(FeatureInstallError::AuthorityDenied(reason))
Err(FeatureInstallError::HostAuthorityDenied(reason))
}
/// Model-visible durable notification sink skeleton. The first slice exposes
/// the boundary without implementing a new event channel.
pub struct FeatureNotificationSink<'a> {
grants: &'a AuthorityGrantSet,
host_authority_grants: &'a HostAuthorityGrantSet,
report: &'a mut FeatureInstallReport,
}
impl FeatureNotificationSink<'_> {
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
require_authority(
self.grants,
require_host_authority(
self.host_authority_grants,
self.report,
FeatureContributionKind::Notification,
"notify_model",
@ -739,7 +744,7 @@ impl FeatureDiagnosticSink<'_> {
pub struct ToolContributionRegistrar<'a> {
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
grants: &'a AuthorityGrantSet,
host_authority_grants: &'a HostAuthorityGrantSet,
pending_tools: &'a mut Vec<ToolDefinition>,
installed_tool_names: &'a mut HashMap<String, FeatureId>,
report: &'a mut FeatureInstallReport,
@ -771,9 +776,9 @@ impl ToolContributionRegistrar<'_> {
));
}
for authority in &contribution.required_authorities {
require_authority(
self.grants,
for authority in &contribution.required_host_authorities {
require_host_authority(
self.host_authority_grants,
self.report,
FeatureContributionKind::Tool,
model_visible_name.clone(),
@ -946,7 +951,7 @@ impl FeatureServiceRegistrar<'_> {
pub struct FeatureInstallContext<'a> {
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
grants: &'a AuthorityGrantSet,
host_authority_grants: &'a HostAuthorityGrantSet,
pending_tools: &'a mut Vec<ToolDefinition>,
installed_tool_names: &'a mut HashMap<String, FeatureId>,
hook_builder: &'a mut HookRegistryBuilder,
@ -959,15 +964,15 @@ impl FeatureInstallContext<'_> {
self.feature_id
}
pub fn grants(&self) -> &AuthorityGrantSet {
self.grants
pub fn host_authority_grants(&self) -> &HostAuthorityGrantSet {
self.host_authority_grants
}
pub fn tools(&mut self) -> ToolContributionRegistrar<'_> {
ToolContributionRegistrar {
feature_id: self.feature_id,
declarations: self.declarations,
grants: self.grants,
host_authority_grants: self.host_authority_grants,
pending_tools: self.pending_tools,
installed_tool_names: self.installed_tool_names,
report: self.report,
@ -1002,7 +1007,7 @@ impl FeatureInstallContext<'_> {
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
FeatureNotificationSink {
grants: self.grants,
host_authority_grants: self.host_authority_grants,
report: self.report,
}
}
@ -1102,9 +1107,10 @@ impl FeatureRegistryBuilder {
let mut seen_features = HashSet::new();
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
let grants = AuthorityGrantSet::grant_all(&descriptor.requested_authorities);
let host_authority_grants =
HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities);
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor, grants.clone());
let mut report = FeatureInstallReport::new(&descriptor, host_authority_grants.clone());
if !seen_features.insert(descriptor.id.clone()) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
@ -1120,9 +1126,9 @@ impl FeatureRegistryBuilder {
continue;
}
for authority in grants.denied() {
for authority in host_authority_grants.denied() {
report.diagnostics.push(FeatureDiagnostic::warning(format!(
"authority denied: {:?}: {}",
"host authority denied: {:?}: {}",
authority.authority, authority.reason
)));
}
@ -1186,7 +1192,7 @@ impl FeatureRegistryBuilder {
let mut context = FeatureInstallContext {
feature_id: &descriptor.id,
declarations: &declarations,
grants: &grants,
host_authority_grants: &host_authority_grants,
pending_tools,
installed_tool_names: &mut installed_tool_names,
hook_builder,
@ -1250,8 +1256,8 @@ pub enum FeatureInstallError {
first_feature: String,
duplicate_feature: String,
},
#[error("feature authority denied: {0}")]
AuthorityDenied(String),
#[error("feature host authority denied: {0}")]
HostAuthorityDenied(String),
#[error("feature install failed: {0}")]
Install(String),
}
@ -1325,7 +1331,7 @@ mod tests {
}
#[test]
fn descriptor_authorities_and_install_report_are_recorded() {
fn descriptor_contributions_and_empty_host_authority_grants_are_recorded() {
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
@ -1348,7 +1354,7 @@ mod tests {
assert!(feature_report.installed);
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
assert!(feature_report.granted_authorities.denied().is_empty());
assert!(feature_report.host_authority_grants.denied().is_empty());
}
#[test]
@ -1411,6 +1417,79 @@ mod tests {
assert_eq!(report.reports[0].skipped[0].name, "Actual");
}
#[test]
fn tool_host_authority_requirements_use_host_authority_grants_not_contribution_declarations() {
struct HostAuthorityToolFeature {
descriptor: FeatureDescriptor,
required_host_authorities: Vec<HostAuthority>,
}
impl FeatureModule for HostAuthorityToolFeature {
fn descriptor(&self) -> FeatureDescriptor {
self.descriptor.clone()
}
fn install(
&self,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
context.tools().register(
ToolContribution::new("NetworkTool", dummy_tool("NetworkTool"))
.with_required_host_authorities(self.required_host_authorities.clone()),
)
}
}
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let missing_grant = FeatureDescriptor::builtin("missing-host-authority", "Missing")
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
let missing_report = FeatureRegistryBuilder::new()
.with_module(HostAuthorityToolFeature {
descriptor: missing_grant,
required_host_authorities: vec![HostAuthority::Network],
})
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(pending_tools.is_empty());
assert!(!missing_report.reports[0].installed);
assert!(
missing_report.reports[0]
.diagnostics
.iter()
.any(|diagnostic| {
diagnostic
.message
.contains("required host authority was not granted")
})
);
assert_eq!(
missing_report.reports[0].skipped[0].kind,
FeatureContributionKind::Tool
);
let granted = FeatureDescriptor::builtin("granted-host-authority", "Granted")
.with_host_authority(HostAuthorityRequest::required(
HostAuthority::Network,
"uses a host network API",
))
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
let granted_report = FeatureRegistryBuilder::new()
.with_module(HostAuthorityToolFeature {
descriptor: granted,
required_host_authorities: vec![HostAuthority::Network],
})
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(granted_report.reports[0].installed);
assert!(
granted_report.reports[0]
.host_authority_grants
.contains(&HostAuthority::Network)
);
assert_eq!(pending_tools.len(), 1);
}
#[test]
fn stateful_tool_definition_is_materialized_once_for_report_and_worker() {
struct StatefulToolFeature {
@ -1707,7 +1786,7 @@ mod tests {
}
#[test]
fn background_task_declaration_is_not_sandbox_authority_gated() {
fn background_task_declaration_is_not_host_authority_gated() {
let descriptor = FeatureDescriptor::builtin("background", "Background")
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
"declared-task",
@ -1728,7 +1807,7 @@ mod tests {
}
#[test]
fn service_provider_declaration_is_not_sandbox_authority_gated() {
fn service_provider_declaration_is_not_host_authority_gated() {
let service = ServiceId::builtin("declared-service");
let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service(
ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"),
@ -1746,7 +1825,7 @@ mod tests {
}
#[test]
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks_and_no_authorities() {
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks_and_no_host_authorities() {
let descriptor = builtin::task_tools_feature().descriptor();
let tool_names: Vec<_> = descriptor
.tools
@ -1762,7 +1841,7 @@ mod tests {
assert_eq!(descriptor.id.as_str(), "builtin:task-tools");
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
assert!(descriptor.requested_authorities.is_empty());
assert!(descriptor.requested_host_authorities.is_empty());
assert_eq!(
hook_points,
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
@ -1800,8 +1879,8 @@ mod tests {
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert_eq!(
report.reports[0].granted_authorities,
AuthorityGrantSet::empty()
report.reports[0].host_authority_grants,
HostAuthorityGrantSet::empty()
);
assert!(report.reports[0].skipped.is_empty());
assert!(report.reports[0].diagnostics.is_empty());

View File

@ -5,5 +5,7 @@
//! an external plugin-loading surface.
pub mod task;
pub mod ticket;
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{TicketFeature, ticket_tools_feature};

View File

@ -0,0 +1,302 @@
//! Built-in Ticket feature adapter.
//!
//! The ticket crate owns Ticket domain logic and Tool implementations. This
//! module only resolves the local backend root, declares the built-in feature,
//! and contributes those tools through the normal feature registry path.
use std::path::{Path, PathBuf};
use ticket::{
LocalTicketBackend, config::TicketConfig, tool::TICKET_TOOL_NAMES, tool::ticket_tools,
};
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration,
};
const FEATURE_ID: &str = "ticket";
const FEATURE_NAME: &str = "Ticket tools";
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority.";
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend_root: PathBuf,
config_error: Option<String>,
}
impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self {
backend_root: backend_root.into(),
config_error: None,
}
}
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) {
Ok(config) => Self::new(config.backend.root),
Err(error) => Self {
backend_root: workspace.join("work-items"),
config_error: Some(error.to_string()),
},
}
}
pub fn backend_root(&self) -> &Path {
&self.backend_root
}
fn authority(&self) -> HostAuthority {
HostAuthority::TicketBackend {
root: self.backend_root.display().to_string(),
}
}
fn usable_backend_root(&self) -> Result<PathBuf, String> {
let root = self
.backend_root
.canonicalize()
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
if !root.is_dir() {
return Err("ticket backend root is not a directory".to_string());
}
for status_dir in ["open", "pending", "closed"] {
let dir = root.join(status_dir);
if !dir.is_dir() {
return Err(format!(
"ticket backend root is missing required {status_dir}/ directory"
));
}
}
Ok(root)
}
}
impl FeatureModule for TicketFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION)
.with_host_authority(HostAuthorityRequest::required(
self.authority(),
AUTHORITY_REASON,
));
for name in TICKET_TOOL_NAMES {
descriptor = descriptor.with_tool(ToolDeclaration::new(name, tool_description(name)));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(error) = &self.config_error {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {error}"
)));
return Ok(());
}
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {reason}; root={} ",
self.backend_root.display()
)));
return Ok(());
}
};
let authority = self.authority();
let backend = LocalTicketBackend::new(usable_root);
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
let name = meta.name.clone();
tools.register(
ToolContribution::new(name, definition)
.with_required_host_authorities(vec![authority.clone()]),
)?;
}
Ok(())
}
}
fn tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => "Create a Ticket through the typed local Ticket backend.",
"TicketList" => "List Tickets through the typed local Ticket backend with bounded output.",
"TicketShow" => {
"Show one Ticket through the typed local Ticket backend with bounded output."
}
"TicketComment" => {
"Append a comment/plan/decision/implementation_report event to a Ticket."
}
"TicketReview" => "Append an approve/request_changes review event to a Ticket.",
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.",
"TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.",
"TicketDoctor" => "Run typed local Ticket backend consistency checks.",
_ => "Typed Ticket backend tool.",
}
}
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
TicketFeature::for_workspace(workspace)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind};
use crate::hook::HookRegistryBuilder;
use tempfile::TempDir;
fn make_work_items(root: &Path) {
std::fs::create_dir_all(root.join("open")).unwrap();
std::fs::create_dir_all(root.join("pending")).unwrap();
std::fs::create_dir_all(root.join("closed")).unwrap();
}
fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap();
}
#[test]
fn descriptor_declares_ticket_tools_and_backend_authority() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature(temp.path());
let descriptor = feature.descriptor();
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len());
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
TICKET_TOOL_NAMES
);
assert_eq!(descriptor.requested_host_authorities.len(), 1);
assert!(matches!(
descriptor.requested_host_authorities[0].authority,
HostAuthority::TicketBackend { .. }
));
}
#[test]
fn installs_ticket_tools_when_work_items_root_is_usable() {
let temp = TempDir::new().unwrap();
make_work_items(&temp.path().join("work-items"));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
assert!(report.reports[0].skipped.is_empty());
}
#[test]
fn installs_ticket_tools_with_configured_backend_root() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[backend]
root = "tickets"
[roles.coder]
profile = "project:coder"
"#,
);
make_work_items(&temp.path().join("tickets"));
let feature = ticket_tools_feature(temp.path());
assert_eq!(feature.backend_root(), temp.path().join("tickets"));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
assert!(report.reports[0].diagnostics.is_empty());
}
#[test]
fn malformed_ticket_config_fails_closed() {
let temp = TempDir::new().unwrap();
make_work_items(&temp.path().join("work-items"));
write_ticket_config(
temp.path(),
r#"
[roles.operator]
profile = "inherit"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unknown Ticket role `operator`"));
}
#[test]
fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
assert!(
report.reports[0].diagnostics[0]
.message
.contains("Ticket tools not registered")
);
}
#[test]
fn does_not_register_ticket_tools_when_root_lacks_status_dirs() {
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("work-items")).unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert!(
report.reports[0].diagnostics[0]
.message
.contains("missing required open/ directory")
);
}
}

View File

@ -5,9 +5,16 @@ edition.workspace = true
license.workspace = true
[dependencies]
async-trait = { workspace = true }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
fs4 = { workspace = true, features = ["sync"] }
llm-worker = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror.workspace = true
toml = { workspace = true }
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

646
crates/ticket/src/config.rs Normal file
View File

@ -0,0 +1,646 @@
//! Workspace-local Ticket orchestration configuration.
//!
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
//! It intentionally stores lightweight string references for Profile selectors,
//! launch prompts, and workflows so this crate remains independent from `pod`
//! and `manifest` runtime resolution.
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
#[derive(Debug, Error)]
pub enum TicketConfigError {
#[error("failed to read Ticket config {}: {source}", .path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse Ticket config {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid Ticket config {}: {message}", .path.display())]
Invalid { path: PathBuf, message: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketConfig {
pub backend: TicketBackendConfig,
pub roles: TicketRoleProfiles,
}
impl TicketConfig {
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
let workspace_root = workspace_root.as_ref();
Self {
backend: TicketBackendConfig::default_for_workspace(workspace_root),
roles: TicketRoleProfiles::default(),
}
}
pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref();
let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default_for_workspace(workspace_root));
}
Err(source) => return Err(TicketConfigError::Read { path, source }),
};
Self::from_toml(workspace_root, &path, &content)
}
pub fn from_toml(
workspace_root: impl AsRef<Path>,
path: impl AsRef<Path>,
content: &str,
) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref();
let path = path.as_ref();
let raw: RawTicketConfig =
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
raw.resolve(workspace_root, path)
}
pub fn backend_root(&self) -> &Path {
self.backend.root.as_path()
}
pub fn role(&self, role: TicketRole) -> &TicketRoleConfig {
self.roles.get(role)
}
pub fn profile_for(&self, role: TicketRole) -> &ProfileSelectorRef {
&self.role(role).profile
}
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
self.role(role).launch_prompt.as_ref()
}
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
&self.role(role).workflow
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketBackendConfig {
pub kind: TicketBackendKind,
pub root: PathBuf,
}
impl TicketBackendConfig {
pub fn default_for_workspace(workspace_root: &Path) -> Self {
Self {
kind: TicketBackendKind::Local,
root: workspace_root.join("work-items"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketBackendKind {
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketRole {
Intake,
Orchestrator,
Coder,
Reviewer,
Investigator,
}
impl TicketRole {
pub const ALL: [TicketRole; 5] = [
TicketRole::Intake,
TicketRole::Orchestrator,
TicketRole::Coder,
TicketRole::Reviewer,
TicketRole::Investigator,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Intake => "intake",
Self::Orchestrator => "orchestrator",
Self::Coder => "coder",
Self::Reviewer => "reviewer",
Self::Investigator => "investigator",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"intake" => Some(Self::Intake),
"orchestrator" => Some(Self::Orchestrator),
"coder" => Some(Self::Coder),
"reviewer" => Some(Self::Reviewer),
"investigator" => Some(Self::Investigator),
_ => None,
}
}
pub fn default_workflow(self) -> &'static str {
match self {
Self::Intake => "ticket-intake-workflow",
Self::Orchestrator => "ticket-orchestrator-routing",
Self::Coder | Self::Reviewer => "multi-agent-workflow",
Self::Investigator => "ticket-orchestrator-routing",
}
}
}
impl fmt::Display for TicketRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleProfiles {
inner: BTreeMap<TicketRole, TicketRoleConfig>,
}
impl TicketRoleProfiles {
pub fn get(&self, role: TicketRole) -> &TicketRoleConfig {
self.inner
.get(&role)
.expect("TicketRoleProfiles always contains all fixed roles")
}
pub fn iter(&self) -> impl Iterator<Item = (TicketRole, &TicketRoleConfig)> {
TicketRole::ALL
.into_iter()
.map(|role| (role, self.get(role)))
}
}
impl Default for TicketRoleProfiles {
fn default() -> Self {
let inner = TicketRole::ALL
.into_iter()
.map(|role| (role, TicketRoleConfig::default_for_role(role)))
.collect();
Self { inner }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleConfig {
pub profile: ProfileSelectorRef,
pub launch_prompt: Option<PromptRef>,
pub workflow: WorkflowRef,
}
impl TicketRoleConfig {
pub fn default_for_role(role: TicketRole) -> Self {
Self {
profile: ProfileSelectorRef::inherit(),
launch_prompt: None,
workflow: WorkflowRef::from_static(role.default_workflow()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct ProfileSelectorRef(String);
impl ProfileSelectorRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = normalized_non_empty(value, "profile selector")?;
if value.starts_with("path:")
|| value.starts_with('.')
|| value.contains('/')
|| value.ends_with(".lua")
|| value.ends_with(".nix")
{
return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string());
}
if let Some((source, name)) = value.split_once(':') {
if !matches!(source, "builtin" | "user" | "project") {
return Err(
"profile selector source must be one of `builtin`, `user`, or `project`"
.to_string(),
);
}
if name.trim().is_empty() {
return Err("profile selector registry name must not be empty".to_string());
}
}
Ok(Self(value))
}
pub fn inherit() -> Self {
Self("inherit".to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for ProfileSelectorRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for ProfileSelectorRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for ProfileSelectorRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct PromptRef(String);
impl PromptRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "launch prompt ref").map(Self)
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for PromptRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for PromptRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for PromptRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct WorkflowRef(String);
impl WorkflowRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "workflow ref").map(Self)
}
pub fn from_static(value: &'static str) -> Self {
Self(value.to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for WorkflowRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for WorkflowRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for WorkflowRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
let value = value.into();
let trimmed = value.trim();
if trimmed.is_empty() {
Err(format!("{label} must not be empty"))
} else {
Ok(trimmed.to_string())
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketConfig {
#[serde(default)]
backend: RawBackendConfig,
#[serde(default)]
roles: BTreeMap<String, RawTicketRoleConfig>,
}
impl RawTicketConfig {
fn resolve(
self,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
let mut roles = TicketRoleProfiles::default();
for (name, raw_role) in self.roles {
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
path: path.to_path_buf(),
message: format!("unknown Ticket role `{name}`"),
})?;
roles.inner.insert(role, raw_role.resolve(role));
}
Ok(TicketConfig {
backend: self.backend.resolve(workspace_root),
roles,
})
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBackendConfig {
#[serde(default)]
kind: Option<TicketBackendKind>,
#[serde(default)]
root: Option<PathBuf>,
}
impl RawBackendConfig {
fn resolve(self, workspace_root: &Path) -> TicketBackendConfig {
let root = self.root.unwrap_or_else(|| PathBuf::from("work-items"));
TicketBackendConfig {
kind: self.kind.unwrap_or(TicketBackendKind::Local),
root: join_if_relative(workspace_root, &root),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketRoleConfig {
profile: ProfileSelectorRef,
#[serde(default)]
launch_prompt: Option<PromptRef>,
#[serde(default)]
workflow: Option<WorkflowRef>,
}
impl RawTicketRoleConfig {
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
TicketRoleConfig {
profile: self.profile,
launch_prompt: self.launch_prompt,
workflow: self
.workflow
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
}
}
}
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_config(workspace: &Path, content: &str) {
let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
}
#[test]
fn missing_config_returns_documented_defaults() {
let temp = TempDir::new().unwrap();
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.kind, TicketBackendKind::Local);
assert_eq!(config.backend.root, temp.path().join("work-items"));
for role in TicketRole::ALL {
let role_config = config.role(role);
assert_eq!(role_config.profile.as_str(), "inherit");
assert!(role_config.launch_prompt.is_none());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
#[test]
fn full_config_parses_fixed_role_refs() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
kind = "local"
root = "custom-work-items"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
[roles.investigator]
profile = "default"
launch_prompt = "$workspace/ticket/investigator/launch"
workflow = "ticket-orchestrator-routing"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.root, temp.path().join("custom-work-items"));
assert_eq!(
config.profile_for(TicketRole::Intake).as_str(),
"project:intake"
);
assert_eq!(
config
.launch_prompt_for(TicketRole::Reviewer)
.unwrap()
.as_str(),
"$workspace/ticket/reviewer/launch"
);
assert_eq!(
config.workflow_for(TicketRole::Investigator).as_str(),
"ticket-orchestrator-routing"
);
}
#[test]
fn partial_role_config_keeps_role_defaults() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "project:coder"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
let coder = config.role(TicketRole::Coder);
assert_eq!(coder.profile.as_str(), "project:coder");
assert!(coder.launch_prompt.is_none());
assert_eq!(coder.workflow.as_str(), "multi-agent-workflow");
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
}
#[test]
fn unknown_roles_are_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.operator]
profile = "inherit"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown Ticket role `operator`"));
}
#[test]
fn unknown_fields_are_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown field"));
assert!(error.to_string().contains("system_instruction"));
}
#[test]
fn unsupported_backend_kind_is_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
kind = "github"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown variant"));
assert!(error.to_string().contains("github"));
}
#[test]
fn relative_backend_root_resolves_against_workspace() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
root = "nested/work-items"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend_root(), temp.path().join("nested/work-items"));
}
#[test]
fn nix_profile_selector_refs_are_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "legacy.nix"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(
error
.to_string()
.contains("path selectors are not supported")
);
}
#[test]
fn malformed_refs_are_reported() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "./coder.lua"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(
error
.to_string()
.contains("path selectors are not supported")
);
}
}

View File

@ -14,6 +14,9 @@ use chrono::Utc;
use fs4::fs_std::FileExt;
use thiserror::Error;
pub mod config;
pub mod tool;
const STATUSES: [TicketStatus; 3] = [
TicketStatus::Open,
TicketStatus::Pending,
@ -853,10 +856,16 @@ impl TicketBackend for LocalTicketBackend {
}
fs::rename(&old_dir, &new_dir).map_err(|e| io_err(&new_dir, e))?;
}
let at = now_utc();
self.set_frontmatter_fields(
&new_dir.join("item.md"),
&[("status", status.as_str()), ("updated_at", &at)],
self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?;
let author = default_author();
let body = MarkdownText::new(format!("Status changed to `{}`.\n", status.as_str()));
self.append_thread_event(
&new_dir,
"status_changed",
"Status changed",
&author,
Some(status.as_str()),
&body,
)
}

959
crates/ticket/src/tool.rs Normal file
View File

@ -0,0 +1,959 @@
//! LLM tool implementations for typed Ticket backend operations.
//!
//! These tools are intentionally owned by the `ticket` crate so Pod features can
//! install Ticket behavior without reimplementing domain/backend logic or
//! granting generic filesystem write authority.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket,
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
TicketEventKind, TicketIdOrSlug, TicketRef, TicketReview, TicketReviewResult, TicketStatus,
TicketSummary,
};
const DEFAULT_LIST_LIMIT: usize = 100;
const MAX_LIST_LIMIT: usize = 200;
const DEFAULT_EVENT_LIMIT: usize = 20;
const MAX_EVENT_LIMIT: usize = 100;
const DEFAULT_ARTIFACT_LIMIT: usize = 50;
const MAX_ARTIFACT_LIMIT: usize = 200;
const DEFAULT_BODY_MAX_BYTES: usize = 16 * 1024;
const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 8] = [
"TicketCreate",
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketStatus",
"TicketClose",
"TicketDoctor",
];
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
Inputs mirror the work-items item.md fields; `title` is required, `body` is Markdown, and the \
backend assigns the id and writes tickets.sh-compatible files under the configured backend root.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend. Filter by \
status (`open`, `pending`, `closed`, or `all`) and optionally kind/priority/label. Output is a \
bounded JSON summary list, not full ticket bodies.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id, slug, or exact query through the configured \
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
artifact metadata.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` must be `comment`, \
`plan`, `decision`, or `implementation_report`; `body` is Markdown. Writes stay inside the \
configured Ticket backend root.";
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `tickets.sh doctor`.";
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
a close event.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics. This does not shell out to tickets.sh.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCreateParams {
/// Ticket title. Must not be empty.
title: String,
/// Optional slug seed. The local backend slugifies this value.
#[serde(default)]
slug: Option<String>,
/// Ticket kind. Defaults to `task`.
#[serde(default)]
kind: Option<String>,
/// Ticket priority. Defaults to `P2`.
#[serde(default)]
priority: Option<String>,
/// Ticket labels.
#[serde(default)]
labels: Vec<String>,
/// Markdown body for item.md. If omitted, a small default body is used.
#[serde(default)]
body: Option<String>,
/// Optional thread author for the create event.
#[serde(default)]
author: Option<String>,
/// Optional assignee frontmatter value.
#[serde(default)]
assignee: Option<String>,
/// Optional legacy ticket reference frontmatter value.
#[serde(default)]
legacy_ticket: Option<String>,
/// Optional readiness frontmatter value.
#[serde(default)]
readiness: Option<String>,
/// Optional preflight flag frontmatter value.
#[serde(default)]
needs_preflight: Option<bool>,
/// Optional risk flag frontmatter values.
#[serde(default)]
risk_flags: Vec<String>,
/// Optional action-required frontmatter value.
#[serde(default)]
action_required: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketListStatusParam {
Open,
Pending,
Closed,
All,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams {
/// Status filter. Defaults to `open`; use `all` to include closed and pending Tickets.
#[serde(default)]
status: Option<TicketListStatusParam>,
/// Maximum number of summaries to return. Defaults to 100, max 200.
#[serde(default)]
limit: Option<usize>,
/// Optional exact kind filter.
#[serde(default)]
kind: Option<String>,
/// Optional exact priority filter.
#[serde(default)]
priority: Option<String>,
/// Optional label that must be present.
#[serde(default)]
label: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams {
/// Ticket id. Exactly one of `id`, `slug`, or `query` must be provided.
#[serde(default)]
id: Option<String>,
/// Ticket slug. Exactly one of `id`, `slug`, or `query` must be provided.
#[serde(default)]
slug: Option<String>,
/// Exact id-or-slug query. Exactly one of `id`, `slug`, or `query` must be provided.
#[serde(default)]
query: Option<String>,
/// Maximum number of most-recent thread events to return. Defaults to 20, max 100.
#[serde(default)]
event_limit: Option<usize>,
/// Maximum number of artifact metadata entries to return. Defaults to 50, max 200.
#[serde(default)]
artifact_limit: Option<usize>,
/// Maximum bytes for each Markdown body field before adding a truncation marker. Defaults to 16 KiB, max 64 KiB.
#[serde(default)]
body_max_bytes: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketCommentRoleParam {
Comment,
Plan,
Decision,
ImplementationReport,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCommentParams {
/// Ticket id or slug.
ticket: String,
/// Thread event role: `comment`, `plan`, `decision`, or `implementation_report`.
role: TicketCommentRoleParam,
/// Markdown event body.
body: String,
/// Optional thread author.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketReviewResultParam {
Approve,
RequestChanges,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketReviewParams {
/// Ticket id or slug.
ticket: String,
/// Review result: `approve` or `request_changes`.
result: TicketReviewResultParam,
/// Markdown review body.
body: String,
/// Optional thread author.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketStatusParam {
Open,
Pending,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketStatusParams {
/// Ticket id or slug.
ticket: String,
/// New status. Use `TicketClose` for `closed`.
status: TicketStatusParam,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams {
/// Ticket id or slug.
ticket: String,
/// Markdown resolution written to resolution.md and thread.md.
resolution: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDoctorParams {
/// Maximum diagnostics to return. Defaults to 100, max 500.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct TicketRefOutput {
id: String,
slug: String,
status: String,
}
#[derive(Debug, Serialize)]
struct TicketListOutput {
status_filter: String,
count: usize,
returned: usize,
truncated: bool,
tickets: Vec<Value>,
}
#[derive(Debug, Serialize)]
struct TicketDoctorOutput {
ok: bool,
error_count: usize,
diagnostic_count: usize,
returned: usize,
truncated: bool,
diagnostics: Vec<Value>,
}
#[derive(Clone)]
struct TicketCreateTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketListTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketShowTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketCommentTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketReviewTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketStatusTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketCloseTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketDoctorTool {
backend: LocalTicketBackend,
}
#[async_trait]
impl Tool for TicketCreateTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
let mut input = NewTicket::new(params.title);
input.slug = params.slug;
if let Some(kind) = params.kind {
input.kind = kind;
}
if let Some(priority) = params.priority {
input.priority = priority;
}
input.labels = params.labels;
if let Some(body) = params.body {
input.body = MarkdownText::new(body);
}
input.author = params.author;
input.assignee = params.assignee;
input.legacy_ticket = params.legacy_ticket;
input.readiness = params.readiness;
input.needs_preflight = params.needs_preflight;
input.risk_flags = params.risk_flags;
input.action_required = params.action_required;
let created = self
.backend
.create(input)
.map_err(|error| backend_error("TicketCreate", error))?;
Ok(json_output(
format!(
"Created ticket {} ({}) status {}",
created.id,
created.slug,
created.status.as_str()
),
ticket_ref_output(created),
))
}
}
#[async_trait]
impl Tool for TicketListTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?;
let status = params.status.unwrap_or(TicketListStatusParam::Open);
let filter = match status {
TicketListStatusParam::Open => crate::TicketFilter::status(TicketStatus::Open),
TicketListStatusParam::Pending => crate::TicketFilter::status(TicketStatus::Pending),
TicketListStatusParam::Closed => crate::TicketFilter::status(TicketStatus::Closed),
TicketListStatusParam::All => crate::TicketFilter::all(),
};
let status_filter = match status {
TicketListStatusParam::Open => "open",
TicketListStatusParam::Pending => "pending",
TicketListStatusParam::Closed => "closed",
TicketListStatusParam::All => "all",
};
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let mut tickets = self
.backend
.list(filter)
.map_err(|error| backend_error("TicketList", error))?;
tickets.retain(|ticket| {
params.kind.as_ref().is_none_or(|kind| ticket.kind == *kind)
&& params
.priority
.as_ref()
.is_none_or(|priority| ticket.priority == *priority)
&& params
.label
.as_ref()
.is_none_or(|label| ticket.labels.iter().any(|item| item == label))
});
let count = tickets.len();
let returned_tickets: Vec<_> = tickets
.into_iter()
.take(limit)
.map(ticket_summary_json)
.collect();
let output = TicketListOutput {
status_filter: status_filter.to_string(),
count,
returned: returned_tickets.len(),
truncated: count > returned_tickets.len(),
tickets: returned_tickets,
};
Ok(json_output(
format!(
"Listed {} ticket(s) for status {status_filter}{}",
output.returned,
if output.truncated { " (truncated)" } else { "" }
),
output,
))
}
}
#[async_trait]
impl Tool for TicketShowTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketShowParams = parse_input("TicketShow", input_json)?;
let query = id_or_slug(params.id, params.slug, params.query)?;
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
let artifact_limit = bounded(
params.artifact_limit,
DEFAULT_ARTIFACT_LIMIT,
MAX_ARTIFACT_LIMIT,
);
let body_max_bytes = bounded(
params.body_max_bytes,
DEFAULT_BODY_MAX_BYTES,
MAX_BODY_MAX_BYTES,
);
let ticket = self
.backend
.show(query)
.map_err(|error| backend_error("TicketShow", error))?;
let summary = format!(
"Ticket {} ({}) status {}",
ticket.meta.id,
ticket.meta.slug,
status_as_str(&ticket.meta.status)
);
Ok(json_output(
summary,
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes),
))
}
}
#[async_trait]
impl Tool for TicketCommentTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketCommentParams = parse_input("TicketComment", input_json)?;
let kind = match params.role {
TicketCommentRoleParam::Comment => TicketEventKind::Comment,
TicketCommentRoleParam::Plan => TicketEventKind::Plan,
TicketCommentRoleParam::Decision => TicketEventKind::Decision,
TicketCommentRoleParam::ImplementationReport => TicketEventKind::ImplementationReport,
};
let role = kind.as_str().to_string();
let mut event = NewTicketEvent::new(kind, params.body);
event.author = params.author;
self.backend
.add_event(TicketIdOrSlug::Query(params.ticket.clone()), event)
.map_err(|error| backend_error("TicketComment", error))?;
Ok(json_output(
format!("Appended {role} event to ticket {}", params.ticket),
json!({ "ticket": params.ticket, "event": role, "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketReviewTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketReviewParams = parse_input("TicketReview", input_json)?;
let result = match params.result {
TicketReviewResultParam::Approve => TicketReviewResult::Approve,
TicketReviewResultParam::RequestChanges => TicketReviewResult::RequestChanges,
};
let result_str = result.as_str().to_string();
let review = TicketReview {
result,
author: params.author,
body: MarkdownText::new(params.body),
};
self.backend
.review(TicketIdOrSlug::Query(params.ticket.clone()), review)
.map_err(|error| backend_error("TicketReview", error))?;
Ok(json_output(
format!("Appended {result_str} review to ticket {}", params.ticket),
json!({ "ticket": params.ticket, "review": result_str, "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketStatusTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketStatusParams = parse_input("TicketStatus", input_json)?;
let status = match params.status {
TicketStatusParam::Open => TicketStatus::Open,
TicketStatusParam::Pending => TicketStatus::Pending,
};
self.backend
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), status)
.map_err(|error| backend_error("TicketStatus", error))?;
Ok(json_output(
format!("Moved ticket {} to {}", params.ticket, status.as_str()),
json!({ "ticket": params.ticket, "status": status.as_str(), "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketCloseTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketCloseParams = parse_input("TicketClose", input_json)?;
self.backend
.close(
TicketIdOrSlug::Query(params.ticket.clone()),
MarkdownText::new(params.resolution),
)
.map_err(|error| backend_error("TicketClose", error))?;
Ok(json_output(
format!("Closed ticket {}", params.ticket),
json!({ "ticket": params.ticket, "status": "closed", "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketDoctorTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketDoctorParams = parse_input("TicketDoctor", input_json)?;
let limit = bounded(params.limit, DEFAULT_DIAGNOSTIC_LIMIT, MAX_DIAGNOSTIC_LIMIT);
let report = self
.backend
.doctor()
.map_err(|error| backend_error("TicketDoctor", error))?;
let output = doctor_output(report, limit);
Ok(json_output(
format!(
"Ticket doctor: {} error(s), {} diagnostic(s){}",
output.error_count,
output.diagnostic_count,
if output.truncated { " (truncated)" } else { "" }
),
output,
))
}
}
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
}
fn backend_error(tool: &str, error: TicketError) -> ToolError {
ToolError::ExecutionFailed(format!("{tool} failed: {error}"))
}
fn bounded(value: Option<usize>, default: usize, max: usize) -> usize {
value.unwrap_or(default).clamp(1, max)
}
fn id_or_slug(
id: Option<String>,
slug: Option<String>,
query: Option<String>,
) -> Result<TicketIdOrSlug, ToolError> {
let provided = id.iter().chain(slug.iter()).chain(query.iter()).count();
if provided != 1 {
return Err(ToolError::InvalidArgument(
"exactly one of id, slug, or query must be provided".to_string(),
));
}
if let Some(id) = id {
Ok(TicketIdOrSlug::Id(id))
} else if let Some(slug) = slug {
Ok(TicketIdOrSlug::Slug(slug))
} else {
Ok(TicketIdOrSlug::Query(
query.expect("provided count checked"),
))
}
}
fn status_as_str(status: &ExtensibleTicketStatus) -> &str {
status.as_str()
}
fn ticket_ref_output(ticket: TicketRef) -> TicketRefOutput {
TicketRefOutput {
id: ticket.id,
slug: ticket.slug,
status: ticket.status.as_str().to_string(),
}
}
fn ticket_summary_json(ticket: TicketSummary) -> Value {
json!({
"id": ticket.id,
"slug": ticket.slug,
"title": ticket.title,
"status": status_as_str(&ticket.status),
"kind": ticket.kind,
"priority": ticket.priority,
"labels": ticket.labels,
"readiness": ticket.readiness,
"needs_preflight": ticket.needs_preflight,
"action_required": ticket.action_required,
"updated_at": ticket.updated_at,
})
}
fn ticket_json(
ticket: &Ticket,
event_limit: usize,
artifact_limit: usize,
body_max_bytes: usize,
) -> Value {
let event_count = ticket.events.len();
let events: Vec<_> = ticket
.events
.iter()
.skip(event_count.saturating_sub(event_limit))
.map(|event| {
json!({
"kind": event.kind.as_str(),
"author": event.author,
"at": event.at,
"status": event.status,
"heading": event.heading,
"body": truncate_text(event.body.as_str(), body_max_bytes),
})
})
.collect();
let artifact_count = ticket.artifacts.len();
let artifacts: Vec<_> = ticket
.artifacts
.iter()
.take(artifact_limit)
.map(|artifact| artifact.relative_path.display().to_string())
.collect();
json!({
"meta": {
"id": ticket.meta.id,
"slug": ticket.meta.slug,
"title": ticket.meta.title,
"status": status_as_str(&ticket.meta.status),
"kind": ticket.meta.kind,
"priority": ticket.meta.priority,
"labels": ticket.meta.labels,
"created_at": ticket.meta.created_at,
"updated_at": ticket.meta.updated_at,
"assignee": ticket.meta.assignee,
"legacy_ticket": ticket.meta.legacy_ticket,
"readiness": ticket.meta.readiness,
"needs_preflight": ticket.meta.needs_preflight,
"risk_flags": ticket.meta.risk_flags,
"action_required": ticket.meta.action_required,
},
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
"events": {
"count": event_count,
"returned": events.len(),
"truncated": event_count > events.len(),
"items": events,
},
"artifacts": {
"count": artifact_count,
"returned": artifacts.len(),
"truncated": artifact_count > artifacts.len(),
"items": artifacts,
},
"resolution": ticket.resolution.as_ref().map(|resolution| truncate_text(resolution.as_str(), body_max_bytes)),
})
}
fn doctor_output(report: TicketDoctorReport, limit: usize) -> TicketDoctorOutput {
let diagnostic_count = report.diagnostics.len();
let error_count = report.error_count();
let diagnostics = report
.diagnostics
.into_iter()
.take(limit)
.map(diagnostic_json)
.collect::<Vec<_>>();
TicketDoctorOutput {
ok: error_count == 0,
error_count,
diagnostic_count,
returned: diagnostics.len(),
truncated: diagnostic_count > diagnostics.len(),
diagnostics,
}
}
fn diagnostic_json(diagnostic: TicketDoctorDiagnostic) -> Value {
let severity = match diagnostic.severity {
TicketDoctorSeverity::Error => "error",
TicketDoctorSeverity::Warning => "warning",
};
json!({
"severity": severity,
"message": diagnostic.message,
"path": diagnostic.path.map(|path| path.display().to_string()),
})
}
fn truncate_text(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let marker = format!("\n\n[truncated: {} bytes dropped]", text.len() - max_bytes);
let mut cut = max_bytes.saturating_sub(marker.len());
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let mut out = text[..cut].to_string();
out.push_str(&marker);
out
}
fn json_output(summary: String, value: impl Serialize) -> ToolOutput {
ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())),
}
}
fn tool_definition<T>(
name: &'static str,
description: &'static str,
backend: LocalTicketBackend,
) -> ToolDefinition
where
T: Tool + From<LocalTicketBackend> + 'static,
{
Arc::new(move || {
let schema_value = input_schema(name);
let meta = ToolMeta::new(name)
.description(description)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(T::from(backend.clone()));
(meta, tool)
})
}
fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
_ => Ok(json!({})),
}
.unwrap_or_else(|_| json!({}))
}
macro_rules! impl_from_backend {
($tool:ident) => {
impl From<LocalTicketBackend> for $tool {
fn from(backend: LocalTicketBackend) -> Self {
Self { backend }
}
}
};
}
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool);
impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketDoctorTool);
/// Build all MVP Ticket tool definitions over one local backend root.
pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
vec![
tool_definition::<TicketCreateTool>("TicketCreate", CREATE_DESCRIPTION, backend.clone()),
tool_definition::<TicketListTool>("TicketList", LIST_DESCRIPTION, backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", SHOW_DESCRIPTION, backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", COMMENT_DESCRIPTION, backend.clone()),
tool_definition::<TicketReviewTool>("TicketReview", REVIEW_DESCRIPTION, backend.clone()),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
]
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn backend(temp: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(temp.path().join("work-items"))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
let (_, tool) = definition();
tool
}
fn tool_by_name(backend: LocalTicketBackend, name: &str) -> Arc<dyn Tool> {
ticket_tools(backend)
.into_iter()
.find_map(|definition| {
let (meta, tool) = definition();
(meta.name == name).then_some(tool)
})
.expect("tool exists")
}
#[tokio::test]
async fn ticket_tools_create_list_show_and_doctor() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate");
let list = tool_by_name(backend.clone(), "TicketList");
let show = tool_by_name(backend.clone(), "TicketShow");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
let created = create
.execute(
&json!({
"title": "Tool Created",
"slug": "tool-created",
"labels": ["ticket", "tool"],
"body": "## Background\n\nCreated by tool.\n"
})
.to_string(),
)
.await
.unwrap();
assert!(created.summary.contains("Created ticket"));
let created_json: Value = serde_json::from_str(&created.content.unwrap()).unwrap();
let id = created_json["id"].as_str().unwrap().to_string();
let listed = list
.execute(&json!({ "status": "open", "label": "tool" }).to_string())
.await
.unwrap();
assert!(listed.summary.contains("Listed 1 ticket"));
assert!(listed.content.unwrap().contains("Tool Created"));
let shown = show
.execute(&json!({ "id": id, "event_limit": 10 }).to_string())
.await
.unwrap();
assert!(shown.summary.contains("tool-created"));
assert!(shown.content.unwrap().contains("Created by tool"));
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
assert!(report.summary.contains("0 error(s)"));
}
#[tokio::test]
async fn ticket_tools_comment_review_status_and_close_are_doctor_clean() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
let comment = tool_by_name(backend.clone(), "TicketComment");
let review = tool_by_name(backend.clone(), "TicketReview");
let status = tool_by_name(backend.clone(), "TicketStatus");
let close = tool_by_name(backend.clone(), "TicketClose");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
comment
.execute(
&json!({
"ticket": created.slug,
"role": "implementation_report",
"body": "Implemented."
})
.to_string(),
)
.await
.unwrap();
review
.execute(
&json!({
"ticket": created.id,
"result": "approve",
"body": "Looks good."
})
.to_string(),
)
.await
.unwrap();
status
.execute(&json!({ "ticket": created.slug, "status": "pending" }).to_string())
.await
.unwrap();
close
.execute(
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
.to_string(),
)
.await
.unwrap();
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
assert!(report.summary.contains("0 error(s)"));
let closed = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert!(closed.resolution.is_some());
assert!(
closed
.events
.iter()
.any(|event| event.kind == TicketEventKind::ImplementationReport)
);
assert!(
closed
.events
.iter()
.any(|event| event.kind == TicketEventKind::Review)
);
assert!(
closed
.events
.iter()
.any(|event| event.kind == TicketEventKind::StatusChanged)
);
}
#[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap();
let show = tool_by_name(backend(&temp), "TicketShow");
let error = show
.execute(&json!({ "id": "a", "slug": "b" }).to_string())
.await
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn ticket_create_slug_path_traversal_is_sanitized_under_backend_root() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate");
create
.execute(&json!({ "title": "Escape", "slug": "../escape" }).to_string())
.await
.unwrap();
assert!(!temp.path().join("escape").exists());
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
}
#[test]
fn ticket_tool_definitions_have_expected_names_and_schemas() {
let temp = TempDir::new().unwrap();
let names = ticket_tools(backend(&temp))
.into_iter()
.map(|definition| definition().0)
.map(|meta| {
assert_eq!(meta.input_schema["type"], "object");
meta.name
})
.collect::<Vec<_>>();
assert_eq!(names, TICKET_TOOL_NAMES);
}
#[test]
fn individual_tool_definition_factory_is_callable() {
let temp = TempDir::new().unwrap();
let create = tool(tool_definition::<TicketCreateTool>(
"TicketCreate",
CREATE_DESCRIPTION,
backend(&temp),
));
let _ = create;
}
}

View File

@ -11,7 +11,8 @@ use crate::block::{
};
use crate::cache::FileCache;
use crate::command::{
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
CommandAction, CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode,
CommandRegistry,
};
use crate::input::InputBuffer;
use crate::scroll::Scroll;
@ -246,6 +247,7 @@ pub struct App {
pub input_mode: CommandInputMode,
pub command_registry: CommandRegistry,
command_completion_selected: Option<usize>,
pending_command_action: Option<CommandAction>,
pub quit: bool,
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
/// records the instant; a second press within the timeout exits the
@ -314,6 +316,7 @@ impl App {
input_mode: CommandInputMode::Composer,
command_registry: CommandRegistry::default(),
command_completion_selected: None,
pending_command_action: None,
quit: false,
quit_confirm: None,
blocks: Vec::new(),
@ -1626,9 +1629,14 @@ impl App {
self.rewind_picker = None;
self.rewind_request_pending = true;
}
self.pending_command_action = result.action;
result.method
}
pub fn take_pending_command_action(&mut self) -> Option<CommandAction> {
self.pending_command_action.take()
}
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,

View File

@ -1,3 +1,4 @@
use client::ticket_role::TicketRole;
use protocol::Method;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -49,9 +50,22 @@ pub struct CommandEnvironment {
pub paused: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandAction {
TicketRole(TicketRoleCommand),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleCommand {
pub role: TicketRole,
pub ticket: Option<String>,
pub instruction: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub method: Option<Method>,
pub action: Option<CommandAction>,
pub diagnostics: Vec<CommandDiagnostic>,
pub exit_command_mode: bool,
pub clear_input: bool,
@ -61,6 +75,7 @@ impl CommandExecution {
pub fn diagnostic(message: impl Into<String>) -> Self {
Self {
method: None,
action: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: false,
clear_input: false,
@ -70,6 +85,17 @@ impl CommandExecution {
pub fn notice(message: impl Into<String>) -> Self {
Self {
method: None,
action: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true,
clear_input: true,
}
}
pub fn local_action(message: impl Into<String>, action: CommandAction) -> Self {
Self {
method: None,
action: Some(action),
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true,
clear_input: true,
@ -165,6 +191,15 @@ impl CommandRegistry {
can_execute: peer_available,
executor: peer_command,
});
registry.register(CommandSpec {
name: "ticket",
aliases: &[],
usage: "ticket <intake|route|investigate|implement|review> ...",
description: "Launch a fixed Ticket role Pod using .yoi/ticket.config.toml.",
argument_parser: ticket_args,
can_execute: always_available,
executor: ticket_command,
});
registry
}
@ -322,6 +357,37 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
}
}
fn ticket_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
let args = CommandArgs::parse_whitespace(raw);
let Some(action) = args.argv().first().map(String::as_str) else {
return Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket <intake|route|investigate|implement|review> ...",
));
};
match action {
"intake" if args.argv().len() >= 2 => Ok(args),
"intake" => Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket intake <context...>",
)),
"route" | "investigate" | "implement" | "review" if args.argv().len() >= 2 => Ok(args),
"route" => Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket route <ticket-id-or-slug> [instruction...]",
)),
"investigate" => Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket investigate <ticket-id-or-slug> [instruction...]",
)),
"implement" => Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket implement <ticket-id-or-slug> [instruction...]",
)),
"review" => Err(CommandDiagnostic::new(
"Invalid arguments. Usage: ticket review <ticket-id-or-slug> [instruction...]",
)),
_ => Err(CommandDiagnostic::new(format!(
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
))),
}
}
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
if !environment.connected {
return Err(CommandDiagnostic::new(
@ -410,6 +476,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::Compact),
action: None,
diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true,
clear_input: true,
@ -422,6 +489,7 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::ListRewindTargets),
action: None,
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
exit_command_mode: true,
clear_input: true,
@ -434,6 +502,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let name = invocation.args.argv()[0].clone();
CommandExecution {
method: Some(Method::RegisterPeer { name: name.clone() }),
action: None,
diagnostics: vec![CommandDiagnostic::new(format!(
"peer metadata registration requested with `{name}`"
))],
@ -442,6 +511,81 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
}
}
fn ticket_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.command;
let _ = invocation.environment;
let Some((action, rest)) = split_first_word(invocation.args.raw()) else {
return CommandExecution::diagnostic(
"Invalid arguments. Usage: ticket <intake|route|investigate|implement|review> ...",
);
};
let Some(role) = ticket_role_for_action(action) else {
return CommandExecution::diagnostic(format!(
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
));
};
let (ticket, instruction) = if action == "intake" {
let Some(instruction) = non_empty_string(rest) else {
return CommandExecution::diagnostic(
"Invalid arguments. Usage: ticket intake <context...>",
);
};
(None, Some(instruction))
} else {
let Some((ticket, rest)) = split_first_word(rest) else {
return CommandExecution::diagnostic(format!(
"Invalid arguments. Usage: ticket {action} <ticket-id-or-slug> [instruction...]"
));
};
(Some(ticket.to_owned()), non_empty_string(rest))
};
CommandExecution::local_action(
format!("ticket {action} launch requested"),
CommandAction::TicketRole(TicketRoleCommand {
role,
ticket,
instruction,
}),
)
}
fn ticket_role_for_action(action: &str) -> Option<TicketRole> {
match action {
"intake" => Some(TicketRole::Intake),
"route" => Some(TicketRole::Orchestrator),
"investigate" => Some(TicketRole::Investigator),
"implement" => Some(TicketRole::Coder),
"review" => Some(TicketRole::Reviewer),
_ => None,
}
}
fn split_first_word(raw: &str) -> Option<(&str, &str)> {
let trimmed = raw.trim_start();
if trimmed.is_empty() {
return None;
}
match trimmed.find(char::is_whitespace) {
Some(idx) => {
let (word, rest) = trimmed.split_at(idx);
Some((word, rest.trim_start()))
}
None => Some((trimmed, "")),
}
}
fn non_empty_string(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -578,6 +722,90 @@ mod tests {
}
}
#[test]
fn ticket_intake_command_returns_local_ticket_action() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("ticket intake add role shortcuts", &env());
assert!(result.method.is_none());
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("ticket intake"));
assert!(matches!(
result.action,
Some(CommandAction::TicketRole(TicketRoleCommand {
role: TicketRole::Intake,
ticket: None,
instruction: Some(ref instruction),
})) if instruction == "add role shortcuts"
));
}
#[test]
fn ticket_intake_requires_context() {
let registry = CommandRegistry::builtins();
for command in ["ticket intake", "ticket intake "] {
let result = registry.dispatch(command, &env());
assert!(result.method.is_none());
assert!(result.action.is_none());
assert!(!result.exit_command_mode);
assert_eq!(
result.diagnostics[0].message,
"Invalid arguments. Usage: ticket intake <context...>"
);
}
}
#[test]
fn ticket_role_commands_map_to_fixed_roles() {
let registry = CommandRegistry::builtins();
for (command, role) in [
("route", TicketRole::Orchestrator),
("investigate", TicketRole::Investigator),
("implement", TicketRole::Coder),
("review", TicketRole::Reviewer),
] {
let result =
registry.dispatch(&format!("ticket {command} abc-123 extra context"), &env());
assert!(result.method.is_none());
assert!(matches!(
result.action,
Some(CommandAction::TicketRole(TicketRoleCommand {
role: actual_role,
ticket: Some(ref ticket),
instruction: Some(ref instruction),
})) if actual_role == role && ticket == "abc-123" && instruction == "extra context"
));
}
}
#[test]
fn ticket_non_intake_requires_ticket_reference() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("ticket implement", &env());
assert!(result.method.is_none());
assert!(result.action.is_none());
assert!(!result.exit_command_mode);
assert!(
result.diagnostics[0]
.message
.contains("ticket implement <ticket-id-or-slug>")
);
}
#[test]
fn ticket_unknown_action_is_local_diagnostic() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("ticket close abc-123", &env());
assert!(result.method.is_none());
assert!(result.action.is_none());
assert!(!result.exit_command_mode);
assert!(
result.diagnostics[0]
.message
.contains("Unknown ticket action")
);
}
#[test]
fn peer_help_mentions_metadata_registration() {
let registry = CommandRegistry::builtins();

View File

@ -20,9 +20,14 @@ use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use tokio::sync::mpsc;
use client::{PodClient, PodRuntimeCommand};
use client::ticket_role::TicketRef;
use client::{
PodClient, PodRuntimeCommand, TicketRoleLaunchContext, TicketRoleLaunchError,
launch_ticket_role_pod,
};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::command::{CommandAction, TicketRoleCommand};
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{multi_pod, picker, spawn, ui};
@ -48,17 +53,17 @@ pub(crate) async fn run_pod_name(
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
let mut terminal = enter_fullscreen()?;
run_connected_pod(&mut terminal, pod_name, client).await?;
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
return Ok(());
}
let ready = match spawn::run_pod_name(pod_name, runtime_command).await? {
let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
let mut terminal = enter_fullscreen()?;
terminal.clear()?;
let result = run_ready_pod(&mut terminal, ready).await;
let result = run_ready_pod(&mut terminal, ready, runtime_command).await;
let _ = leave_fullscreen(&mut terminal);
result
}
@ -67,10 +72,11 @@ async fn run_connected_pod(
terminal: &mut FullscreenTerminal,
pod_name: String,
client: PodClient,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(pod_name);
app.connected = true;
run_loop(terminal, &mut app, client).await
run_loop(terminal, &mut app, client, runtime_command).await
}
async fn run_pod_name_nested(
@ -84,11 +90,12 @@ async fn run_pod_name_nested(
} = request;
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
return run_connected_pod(terminal, pod_name, client).await;
return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await;
}
let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?;
run_ready_pod(terminal, ready).await
let ready =
spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?;
run_ready_pod(terminal, ready, runtime_command).await
}
async fn spawn_pod_name_from_fullscreen(
@ -131,12 +138,13 @@ impl std::error::Error for NestedOpenCancelled {}
async fn run_ready_pod(
terminal: &mut FullscreenTerminal,
ready: SpawnReady,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let SpawnReady {
pod_name,
socket_path,
} = ready;
run(terminal, pod_name, &socket_path).await
run(terminal, pod_name, &socket_path, runtime_command).await
}
async fn connect_live_pod(
@ -215,7 +223,7 @@ pub(crate) async fn run_spawn(
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile, runtime_command).await? {
let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@ -226,7 +234,7 @@ pub(crate) async fn run_spawn(
} = ready;
let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path).await;
let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await;
// Leave alt-screen explicitly before `main`'s terminal restore path.
let _ = execute!(
@ -268,6 +276,7 @@ async fn run(
terminal: &mut FullscreenTerminal,
pod_name: String,
socket_path: &std::path::Path,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(pod_name);
@ -276,7 +285,7 @@ async fn run(
app.connected = true;
// The Pod sends `Event::Snapshot` automatically on connect;
// no explicit method call is required to fetch history.
run_loop(terminal, &mut app, client).await?;
run_loop(terminal, &mut app, client, runtime_command).await?;
}
Err(e) => {
app.push_error(format!(
@ -295,6 +304,7 @@ type TerminalEventResult = io::Result<TermEvent>;
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64;
const POD_EVENT_DRAIN_LIMIT: usize = 32;
const TICKET_ROLE_NOTICE_DURATION: Duration = Duration::from_secs(5);
struct TerminalEventReader {
stop: Arc<AtomicBool>,
@ -380,13 +390,14 @@ async fn drain_terminal_events(
app: &mut App,
client: &mut PodClient,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: &PodRuntimeCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
match term_rx.try_recv() {
Ok(event) => {
handled = true;
handle_terminal_event(app, client, event?).await?;
handle_terminal_event(app, client, event?, runtime_command).await?;
if app.quit {
break;
}
@ -426,6 +437,7 @@ async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
mut client: PodClient,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
@ -436,7 +448,8 @@ async fn run_loop(
break;
}
let handled_term_event = drain_terminal_events(app, &mut client, &mut term_rx).await?;
let handled_term_event =
drain_terminal_events(app, &mut client, &mut term_rx, &runtime_command).await?;
if app.quit {
break;
}
@ -448,7 +461,7 @@ async fn run_loop(
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
LoopInput::Terminal(term_event) => {
handle_terminal_event(app, &mut client, term_event?).await?;
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
}
LoopInput::Pod(event) => match event {
Some(ev) => {
@ -474,11 +487,14 @@ async fn handle_terminal_event(
app: &mut App,
client: &mut PodClient,
event: TermEvent,
runtime_command: &PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
} else if let Some(action) = app.take_pending_command_action() {
handle_command_action(app, action, runtime_command).await;
}
}
TermEvent::Mouse(mouse) => {
@ -527,6 +543,96 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent) {
}
}
async fn handle_command_action(
app: &mut App,
action: CommandAction,
runtime_command: &PodRuntimeCommand,
) {
match action {
CommandAction::TicketRole(command) => {
handle_ticket_role_command(app, command, runtime_command).await;
}
}
}
async fn handle_ticket_role_command(
app: &mut App,
command: TicketRoleCommand,
runtime_command: &PodRuntimeCommand,
) {
let role_label = command.role.as_str();
app.flash_actionbar_notice(
format!("Launching ticket {role_label} Pod..."),
ActionbarNoticeLevel::Info,
ActionbarNoticeSource::Tui,
TICKET_ROLE_NOTICE_DURATION,
);
let workspace_root = match std::env::current_dir() {
Ok(path) => path,
Err(err) => {
app.flash_actionbar_notice(
format!("Ticket role launch failed: could not resolve current directory: {err}"),
ActionbarNoticeLevel::Error,
ActionbarNoticeSource::Tui,
TICKET_ROLE_NOTICE_DURATION,
);
return;
}
};
let context = ticket_role_launch_context(workspace_root, command);
let mut progress = Vec::new();
match launch_ticket_role_pod(context, runtime_command.clone(), |message| {
progress.push(message.to_owned());
})
.await
{
Ok(result) => {
let profile = result.plan.profile;
app.flash_actionbar_notice(
format!(
"Launched ticket {role_label} Pod `{}` with profile `{profile}`",
result.ready.pod_name
),
ActionbarNoticeLevel::Info,
ActionbarNoticeSource::Tui,
TICKET_ROLE_NOTICE_DURATION,
);
}
Err(err) => {
app.flash_actionbar_notice(
format_ticket_role_launch_error(&err),
ActionbarNoticeLevel::Error,
ActionbarNoticeSource::Tui,
TICKET_ROLE_NOTICE_DURATION,
);
}
}
}
fn ticket_role_launch_context(
workspace_root: std::path::PathBuf,
command: TicketRoleCommand,
) -> TicketRoleLaunchContext {
let mut context = TicketRoleLaunchContext::new(workspace_root, command.role);
context.ticket = command.ticket.map(TicketRef::slug);
context.user_instruction = command.instruction;
context
}
fn format_ticket_role_launch_error(error: &TicketRoleLaunchError) -> String {
match error {
TicketRoleLaunchError::UnsupportedInheritProfile => concat!(
"Ticket role launch failed: role profile is `inherit`. ",
"Top-level TUI ticket launches require concrete role profiles in ",
".yoi/ticket.config.toml until an inheritance-aware launch path exists."
)
.to_owned(),
_ => format!("Ticket role launch failed: {error}"),
}
}
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
@ -1820,6 +1926,40 @@ mod tests {
}
}
#[test]
fn ticket_role_launch_context_uses_slug_reference_and_instruction() {
let context = ticket_role_launch_context(
PathBuf::from("/tmp/workspace"),
TicketRoleCommand {
role: client::ticket_role::TicketRole::Coder,
ticket: Some("abc-123".to_owned()),
instruction: Some("focus parser tests".to_owned()),
},
);
assert_eq!(context.role, client::ticket_role::TicketRole::Coder);
assert_eq!(context.workspace_root, PathBuf::from("/tmp/workspace"));
assert_eq!(
context
.ticket
.as_ref()
.and_then(|ticket| ticket.slug.as_deref()),
Some("abc-123")
);
assert_eq!(
context.user_instruction.as_deref(),
Some("focus parser tests")
);
assert!(context.pod_name.is_none());
}
#[test]
fn unsupported_inherit_profile_message_explains_tui_boundary() {
let message =
format_ticket_role_launch_error(&TicketRoleLaunchError::UnsupportedInheritProfile);
assert!(message.contains("Top-level TUI ticket launches require concrete role profiles"));
assert!(message.contains(".yoi/ticket.config.toml"));
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}

View File

@ -6,11 +6,13 @@ Project-authored workflows live under `.yoi/workflow/`. Generated memory lives u
## Workflow role
A workflow should define how to coordinate work. It should not become a private implementation branch, an unreviewed design decision, or a replacement for work items.
A workflow should define how to coordinate work. It should not become a private implementation branch, an unreviewed design decision, or a replacement for Tickets.
Current workflow themes include:
- preflight before delegating uncertain ticket work
- Intake clarification before materializing user requests as Tickets
- Orchestrator routing from Tickets to the next workflow/action
- preflight before delegating uncertain Ticket work
- worktree setup and cleanup
- sibling coder/reviewer Pod orchestration
- human-gated maintenance and merge readiness
@ -25,7 +27,7 @@ A parent/orchestrator must verify:
- live/restorable state via Pod tools when relevant
- worktree status and diff
- validation command output
- work item requirements and acceptance criteria
- Ticket requirements and acceptance criteria
Notifications are hints to inspect state. They are not proof of completion.

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-zf8YS4d/ia/nGTH7MbkWO8ipqjc1ZNnUsnKlS5rH2pQ=";
cargoHash = "sha256-yk3cLEqIfLfjRpLM3Iaa7jJyV4inigD994QdUn/3iXY=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -2,12 +2,12 @@
id: 20260527-000001-auto-maintain-workflow
slug: auto-maintain-workflow
title: 半自動開発運用 Workflow
status: open
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:01Z
updated_at: 2026-05-27T00:00:01Z
updated_at: 2026-06-05T15:56:29Z
assignee: null
legacy_ticket: tickets/auto-maintain-workflow.md
---

View File

@ -0,0 +1,25 @@
The old Auto Maintain workflow is retired and removed.
Resolution:
- Deleted `.yoi/workflow/auto-maintain.md`.
- Closed this Ticket as superseded by the newer Ticket-based orchestration workflow split:
- `ticket-intake-workflow`
- `ticket-orchestrator-routing`
- `ticket-preflight-workflow`
- `multi-agent-workflow`
- Updated `multi-agent-workflow` to point to Ticket Intake / Orchestrator Routing / Preflight instead of `$user/auto-maintain`.
- Updated `ticket-intake-workflow` to remove the obsolete auto-maintain connection.
- Updated `prompt-eval-metrics` so future prompt/workflow evaluation targets the current Ticket workflows or worktree workflow instead of `/auto-maintain`.
Rationale:
`auto-maintain` had become a broad and unstable WIP workflow with old assumptions around TODO/tickets and maintenance loops. Keeping it resident risks encouraging large implicit automation and bypassing the clearer gates now provided by Ticket Intake, Ticket Orchestrator Routing, Ticket Preflight, and Multi-agent Worktree Workflow.
Future maintainer/scheduler/lease behavior should be designed as explicit follow-up work, not revived through the deleted auto-maintain workflow.
Validation:
- `git diff --check`
- `./tickets.sh doctor`
- open workflow/docs search no longer finds `auto-maintain` references outside this closed historical Ticket context.

View File

@ -0,0 +1,40 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:01Z -->
## Migrated
Migrated from tickets/auto-maintain-workflow.md. No legacy review file was present at migration time.
---
<!-- event: close author: hare at: 2026-06-05T15:56:29Z status: closed -->
## Closed
The old Auto Maintain workflow is retired and removed.
Resolution:
- Deleted `.yoi/workflow/auto-maintain.md`.
- Closed this Ticket as superseded by the newer Ticket-based orchestration workflow split:
- `ticket-intake-workflow`
- `ticket-orchestrator-routing`
- `ticket-preflight-workflow`
- `multi-agent-workflow`
- Updated `multi-agent-workflow` to point to Ticket Intake / Orchestrator Routing / Preflight instead of `$user/auto-maintain`.
- Updated `ticket-intake-workflow` to remove the obsolete auto-maintain connection.
- Updated `prompt-eval-metrics` so future prompt/workflow evaluation targets the current Ticket workflows or worktree workflow instead of `/auto-maintain`.
Rationale:
`auto-maintain` had become a broad and unstable WIP workflow with old assumptions around TODO/tickets and maintenance loops. Keeping it resident risks encouraging large implicit automation and bypassing the clearer gates now provided by Ticket Intake, Ticket Orchestrator Routing, Ticket Preflight, and Multi-agent Worktree Workflow.
Future maintainer/scheduler/lease behavior should be designed as explicit follow-up work, not revived through the deleted auto-maintain workflow.
Validation:
- `git diff --check`
- `./tickets.sh doctor`
- open workflow/docs search no longer finds `auto-maintain` references outside this closed historical Ticket context.
---

View File

@ -2,12 +2,12 @@
id: 20260601-031252-builtin-work-item-intake-routing
slug: builtin-work-item-intake-routing
title: Built-in Ticket intake and orchestration routing
status: open
status: closed
kind: task
priority: P1
labels: [ticket, intake, orchestration]
created_at: 2026-06-01T03:12:52Z
updated_at: 2026-06-05T04:04:42Z
updated_at: 2026-06-05T06:42:40Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,43 @@
Built-in Ticket intake and orchestration routing umbrella is complete.
This umbrella was split into four implementation-sized child tickets, all of which are now closed:
1. `ticket-local-files-backend`
- Added the low-level `ticket` crate, typed Ticket domain/backend, and `LocalTicketBackend` over current `work-items/` files.
2. `ticket-built-in-feature-tools`
- Added built-in Ticket tools through typed backend authority and the Pod feature contribution path.
- Kept Ticket tool behavior in `crates/ticket` and Pod as a thin feature adapter.
3. `ticket-intake-workflow`
- Added `.yoi/workflow/ticket-intake-workflow.md` for clarifying user requests and materializing agreed Tickets after user approval.
4. `ticket-orchestrator-routing`
- Added `.yoi/workflow/ticket-orchestrator-routing.md` for classifying Tickets into requirements sync, preflight, spike, implementation, review, blocked/action-required, close-ready, defer/pending, or no-op paths.
Terminology decision:
- `Ticket` is the durable orchestration record concept.
- `Task` remains session-local progress tracking.
- `Assignment` is concrete Pod delegation.
- `IntentPacket` is the implementation/review contract derived from a Ticket.
- `work-items/` remains the current LocalTicketBackend storage path for now.
Scope preserved:
- The storage directory was not renamed.
- `tickets.sh` remains compatible and in use.
- Ticket authority is separate from delegated filesystem write scope.
- Intake does not schedule implementation.
- Orchestrator routing does not introduce an unattended scheduler/lease/queue system.
- TUI spawned-Pod panel work was deprioritized and is not part of this path.
Validation:
- Child tickets were independently reviewed and validated.
- Final child workflow validation passed `git diff --check` and `./tickets.sh doctor`.
- `./tickets.sh doctor` passes for the umbrella close.
Historical note:
Older thread entries and artifacts may contain superseded `WorkItem` or old system-name wording as historical context. The current terminology and implementation use `Ticket`.

View File

@ -317,4 +317,55 @@ Added current terminology/design artifact:
The earlier `workitem-definition-and-api-shape-20260601.md` is now explicitly marked superseded and retained only as historical context.
---
<!-- event: close author: hare at: 2026-06-05T06:42:40Z status: closed -->
## Closed
Built-in Ticket intake and orchestration routing umbrella is complete.
This umbrella was split into four implementation-sized child tickets, all of which are now closed:
1. `ticket-local-files-backend`
- Added the low-level `ticket` crate, typed Ticket domain/backend, and `LocalTicketBackend` over current `work-items/` files.
2. `ticket-built-in-feature-tools`
- Added built-in Ticket tools through typed backend authority and the Pod feature contribution path.
- Kept Ticket tool behavior in `crates/ticket` and Pod as a thin feature adapter.
3. `ticket-intake-workflow`
- Added `.yoi/workflow/ticket-intake-workflow.md` for clarifying user requests and materializing agreed Tickets after user approval.
4. `ticket-orchestrator-routing`
- Added `.yoi/workflow/ticket-orchestrator-routing.md` for classifying Tickets into requirements sync, preflight, spike, implementation, review, blocked/action-required, close-ready, defer/pending, or no-op paths.
Terminology decision:
- `Ticket` is the durable orchestration record concept.
- `Task` remains session-local progress tracking.
- `Assignment` is concrete Pod delegation.
- `IntentPacket` is the implementation/review contract derived from a Ticket.
- `work-items/` remains the current LocalTicketBackend storage path for now.
Scope preserved:
- The storage directory was not renamed.
- `tickets.sh` remains compatible and in use.
- Ticket authority is separate from delegated filesystem write scope.
- Intake does not schedule implementation.
- Orchestrator routing does not introduce an unattended scheduler/lease/queue system.
- TUI spawned-Pod panel work was deprioritized and is not part of this path.
Validation:
- Child tickets were independently reviewed and validated.
- Final child workflow validation passed `git diff --check` and `./tickets.sh doctor`.
- `./tickets.sh doctor` passes for the umbrella close.
Historical note:
Older thread entries and artifacts may contain superseded `WorkItem` or old system-name wording as historical context. The current terminology and implementation use `Ticket`.
---

View File

@ -0,0 +1,76 @@
# Implementation report: feature-api-authority-separation
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/feature-api-authority-separation`
- Branch: `work/feature-api-authority-separation`
## Commit
- `4fc361f refactor: name feature host authorities explicitly`
## Summary
Clarified the `pod::feature` authority boundary by renaming the generic authority API surface to explicit host-authority terminology. This keeps feature contribution declarations separate from host-mediated capability grants and prepares the API for later Ticket built-in tools without framing internal built-ins as external plugin package grants.
## Exact renames
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `FeatureDescriptor::requested_authorities` -> `requested_host_authorities`
- `FeatureDescriptor::with_authority` -> `with_host_authority`
- `ToolContribution::required_authorities` -> `required_host_authorities`
- `ToolContribution::with_required_authorities` -> `with_required_host_authorities`
- `FeatureInstallReport::granted_authorities` -> `host_authority_grants`
- `FeatureInstallContext::grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
- Internal helpers/diagnostics now use host-authority terminology where applicable.
## Changed files
- `crates/pod/src/feature.rs`
## Behavior
Preserved:
- descriptor-first validation;
- duplicate tool rejection;
- undeclared contribution rejection;
- missing required host authority install failure;
- built-in Task feature behavior;
- contribution-only built-in feature installation without host authority grants.
Added/updated tests and comments to make explicit that contributing a tool/hook/background/service descriptor is not itself a host authority grant, while per-tool host authority requirements still require a corresponding granted requested host authority.
## Validation
Coder-reported validation passed:
- `cargo test -p pod feature --lib`
- `cargo test -p pod task --lib`
- `cargo test -p pod --lib`
- `cargo test -p llm-worker --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check develop...HEAD`
- `cargo test -p pod feature --lib`
## Review status
External sibling reviewer approved with no blockers and no required non-blockers before merge.
## Unresolved risks / follow-ups
The existing `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` behavior remains a builtin-only scaffold, not a real external plugin approval resolver. This is unchanged and explicitly outside this ticket's scope.
## Ready for merge
Yes. This clears the API naming prerequisite for `ticket-built-in-feature-tools`.

View File

@ -0,0 +1,69 @@
# External review: feature-api-authority-separation
## 1. Result
approve
## 2. Summary of implementation
The implementation is a focused `pod::feature` API cleanup in `crates/pod/src/feature.rs` only. It renames the generic authority API surface to explicit host-authority naming:
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `FeatureDescriptor::requested_authorities` -> `requested_host_authorities`
- `FeatureDescriptor::with_authority` -> `with_host_authority`
- `ToolContribution::required_authorities` / builder -> `required_host_authorities` / `with_required_host_authorities`
- `FeatureInstallReport::granted_authorities` -> `host_authority_grants`
- `FeatureInstallContext::grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
The code comments and tests now describe contribution declarations as descriptor-approved contributions, not host authority grants. A new focused test covers a tool that needs a host authority: contribution declaration alone is not enough, while an explicit requested host authority grants the install-time host-authority requirement under the current grant-all scaffold.
## 3. Requirement-by-requirement assessment
- Generic authority ambiguity removed or clarified: satisfied. The changed source no longer exposes the generic `AuthorityRequest`, `AuthorityGrantSet`, `AuthorityDenial`, `requested_authorities`, `required_authorities`, `granted_authorities`, or `grants()` names in the live Rust API; all are explicitly host-authority named.
- Contribution declarations remain separate from host authority grants: satisfied. Tool/hook/background/service declarations remain descriptor contribution data, while host authorities are carried through `requested_host_authorities` and `host_authority_grants`.
- Built-in contribution registration does not require host authority merely to contribute descriptors: satisfied. `ToolContribution::new` defaults to no `required_host_authorities`, background tasks/services remain descriptor/report contributions, and Task installs with empty host-authority grants.
- Missing required host authority still fails feature installation where appropriate: satisfied for the current install surface. `ToolContribution::with_required_host_authorities` still rejects install when the corresponding host authority is absent from the grant set, and the new test covers this path.
- Descriptor-first validation and duplicate tool rejection preserved: satisfied. The existing undeclared contribution, tool-name mismatch, duplicate tool, background/service declaration, and worker materialization tests remain in place and passed in focused validation.
- Built-in Task feature behavior unchanged: satisfied by diff scope and focused tests. Task descriptor/install behavior still reports no host authorities and installs the same Task tool/hook set.
- No unrelated scope expansion: satisfied. The diff is limited to `crates/pod/src/feature.rs`; it does not introduce Ticket tools, Ticket backend authority grants, plugin loading, MCP, WASM/sandbox runtime, approval protocol, crate extraction, Hook behavior changes, Task behavior changes, or broad refactors.
- Tests/docs/comments reflect the distinction: satisfied. Public comments and test names/assertions now consistently use host-authority terminology for this API surface, and tests explicitly cover contribution-only built-ins and host-authority-gated tools.
- Validation sufficient: satisfied for this review. I reran focused validation and inspected the diff/source for the listed requirements.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
None required before merge.
Notes for later external-plugin work:
- `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` remains the existing builtin-only scaffold, not a real host policy/user approval resolver. This is unchanged and within this ticket's non-goals, but it must be replaced before enabling untrusted external plugins.
- `HostAuthorityRequest::required` versus `optional` remains future-facing under the current grant-all scaffold. No behavior change is introduced here.
## 6. Validation assessed or rerun
Rerun from `/home/hare/Projects/yoi/.worktree/feature-api-authority-separation`:
- `git diff --check develop...HEAD` — passed.
- `cargo test -p pod feature --lib` — passed: 33 passed, 0 failed, 252 filtered out.
Additional inspection:
- Reviewed ticket and delegation intent.
- Reviewed `git diff develop...HEAD`.
- Confirmed the implementation diff touches only `crates/pod/src/feature.rs`.
- Searched Rust sources for the old generic authority names and found no live-code API leftovers, only new host-authority names or historical work-item text outside source.
- Checked worktree status after validation; no tracked changes.
Not rerun:
- Full workspace checks, broader package tests, `./tickets.sh doctor`, and `nix build .#yoi --no-link` were not rerun for this focused external review.
## 7. Residual risk
Residual risk is low for this ticket. The change is mostly API naming plus tests, and focused validation passed. The main remaining risk is pre-existing: the feature registry still automatically grants requested host authorities, so it is not yet a real external-plugin authority enforcement layer. That risk is explicitly outside this ticket's implementation scope and does not block this host-authority naming clarification.

View File

@ -2,12 +2,12 @@
id: 20260604-234844-feature-api-authority-separation
slug: feature-api-authority-separation
title: Feature API: separate internal modules from external-plugin authority model
status: open
status: closed
kind: task
priority: P1
labels: [plugin, feature-registry, permissions, architecture]
created_at: 2026-06-04T23:48:44Z
updated_at: 2026-06-05T04:54:33Z
updated_at: 2026-06-05T05:11:56Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,40 @@
Feature API authority separation is complete and merged.
Implementation:
- `4fc361f refactor: name feature host authorities explicitly`
- merge commit: `b46ea65 merge: clarify feature host authorities`
Summary:
- Renamed the generic feature authority API surface to explicit host-authority terminology:
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `requested_authorities` -> `requested_host_authorities`
- `required_authorities` -> `required_host_authorities`
- `granted_authorities` -> `host_authority_grants`
- `grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
- Preserved descriptor-first validation, duplicate tool rejection, undeclared contribution rejection, missing host-authority install failure, and built-in Task feature behavior.
- Added/updated tests/comments to make contribution declarations separate from host authority grants.
- Did not implement Ticket tools, external plugin loading, approval/resume protocol, MCP, WASM/sandbox runtime, feature crate extraction, Hook behavior changes, or Task behavior changes.
Review:
- External sibling reviewer approved with no blockers and no required non-blockers.
- Residual note: `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` remains the existing builtin-only scaffold, not a real external-plugin approval resolver. This is unchanged and remains future work.
Post-merge validation passed:
- `cargo test -p pod feature --lib`
- `cargo test -p pod task --lib`
- `cargo test -p pod --lib`
- `cargo test -p llm-worker --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `cargo check --workspace --all-targets`
- `nix build .#yoi --no-link`
This clears the API naming prerequisite for `ticket-built-in-feature-tools`.

View File

@ -0,0 +1,283 @@
<!-- event: create author: tickets.sh at: 2026-06-04T23:48:44Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-06-04T23:50:15Z -->
## Decision
# Decision: separate internal feature modules from external-plugin authority
Internal modules extracted from Pod implementation files should not be treated as if they require the external-plugin permission model.
For an internal built-in module such as Task tools:
- the feature registry is an API/registration boundary;
- descriptor-declared contributions are reconciled at install time;
- normal ToolRegistry and PreToolCall permission behavior remains authoritative;
- host state such as `TaskStore` can be passed by the Pod host constructor;
- requested host authorities should normally be empty.
The external-plugin authority model remains necessary for sandbox/object-capability grants when plugin code receives dangerous host APIs such as filesystem, network, secrets, model-visible durable notification/history append, Pod-management façade, persistent state, or authority-bearing service access.
This split should be implemented separately from the Task tools extraction. The Task tools extraction should validate the contribution-only built-in module path without solving external plugin approval.
---
<!-- event: decision author: hare at: 2026-06-05T00:49:53Z -->
## Decision
# Decision: authority handles live in Hook contexts, not Hook return effects
The internal-module and external-plugin authority split should treat host-authority APIs as handles supplied by the host, including inside Hooks.
Implications:
- Hook return values remain per-hook-point flow-control actions.
- Side effects such as durable model-visible SystemItem append are performed through typed host handles on event-specific Hook contexts.
- Built-in internal modules may receive handles according to host policy without user-facing external-plugin approval.
- Future external plugins receive only the handles allowed by their approved host authorities.
- The main API should not be “return an effect and let the host reject it at runtime.” Rejection remains defense-in-depth for malformed calls, missing handles, bounds, and policy violations.
- Do not model every authority combination as a distinct Hook context type. Use event-specific context types with authority-specific handles whose constructors are host-owned.
This preserves the clean distinction: contribution declarations are descriptor-locked; dangerous host APIs are represented by host-created handles; normal tool permission remains the per-call execution gate.
---
<!-- event: plan author: hare at: 2026-06-05T04:54:33Z -->
## Plan
Preflight result: `implementation-ready`.
`ticket-built-in-feature-tools` should not be implemented until this boundary is clarified, because Ticket tools need to be internal built-in feature contributions while Ticket backend operations remain typed host authority, not arbitrary filesystem scope or external plugin package approval.
Implementation intent:
- Clarify `pod::feature` naming around host authority grants.
- Keep contribution declarations and descriptor reconciliation separate from host authority requests/grants.
- Preserve built-in Task feature behavior as the current contribution-only example.
- Avoid external plugin loading, real approval protocol, Ticket tool implementation, Hook behavior changes, or broader crate moves.
Detailed delegation intent is in `artifacts/delegation-intent.md`.
Note: main workspace currently has an unrelated dirty `README.md` change. This feature work must not touch or commit that file.
---
<!-- event: review author: hare at: 2026-06-05T05:09:36Z status: approve -->
## Review: approve
# External review: feature-api-authority-separation
## 1. Result
approve
## 2. Summary of implementation
The implementation is a focused `pod::feature` API cleanup in `crates/pod/src/feature.rs` only. It renames the generic authority API surface to explicit host-authority naming:
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `FeatureDescriptor::requested_authorities` -> `requested_host_authorities`
- `FeatureDescriptor::with_authority` -> `with_host_authority`
- `ToolContribution::required_authorities` / builder -> `required_host_authorities` / `with_required_host_authorities`
- `FeatureInstallReport::granted_authorities` -> `host_authority_grants`
- `FeatureInstallContext::grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
The code comments and tests now describe contribution declarations as descriptor-approved contributions, not host authority grants. A new focused test covers a tool that needs a host authority: contribution declaration alone is not enough, while an explicit requested host authority grants the install-time host-authority requirement under the current grant-all scaffold.
## 3. Requirement-by-requirement assessment
- Generic authority ambiguity removed or clarified: satisfied. The changed source no longer exposes the generic `AuthorityRequest`, `AuthorityGrantSet`, `AuthorityDenial`, `requested_authorities`, `required_authorities`, `granted_authorities`, or `grants()` names in the live Rust API; all are explicitly host-authority named.
- Contribution declarations remain separate from host authority grants: satisfied. Tool/hook/background/service declarations remain descriptor contribution data, while host authorities are carried through `requested_host_authorities` and `host_authority_grants`.
- Built-in contribution registration does not require host authority merely to contribute descriptors: satisfied. `ToolContribution::new` defaults to no `required_host_authorities`, background tasks/services remain descriptor/report contributions, and Task installs with empty host-authority grants.
- Missing required host authority still fails feature installation where appropriate: satisfied for the current install surface. `ToolContribution::with_required_host_authorities` still rejects install when the corresponding host authority is absent from the grant set, and the new test covers this path.
- Descriptor-first validation and duplicate tool rejection preserved: satisfied. The existing undeclared contribution, tool-name mismatch, duplicate tool, background/service declaration, and worker materialization tests remain in place and passed in focused validation.
- Built-in Task feature behavior unchanged: satisfied by diff scope and focused tests. Task descriptor/install behavior still reports no host authorities and installs the same Task tool/hook set.
- No unrelated scope expansion: satisfied. The diff is limited to `crates/pod/src/feature.rs`; it does not introduce Ticket tools, Ticket backend authority grants, plugin loading, MCP, WASM/sandbox runtime, approval protocol, crate extraction, Hook behavior changes, Task behavior changes, or broad refactors.
- Tests/docs/comments reflect the distinction: satisfied. Public comments and test names/assertions now consistently use host-authority terminology for this API surface, and tests explicitly cover contribution-only built-ins and host-authority-gated tools.
- Validation sufficient: satisfied for this review. I reran focused validation and inspected the diff/source for the listed requirements.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
None required before merge.
Notes for later external-plugin work:
- `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` remains the existing builtin-only scaffold, not a real host policy/user approval resolver. This is unchanged and within this ticket's non-goals, but it must be replaced before enabling untrusted external plugins.
- `HostAuthorityRequest::required` versus `optional` remains future-facing under the current grant-all scaffold. No behavior change is introduced here.
## 6. Validation assessed or rerun
Rerun from `/home/hare/Projects/yoi/.worktree/feature-api-authority-separation`:
- `git diff --check develop...HEAD` — passed.
- `cargo test -p pod feature --lib` — passed: 33 passed, 0 failed, 252 filtered out.
Additional inspection:
- Reviewed ticket and delegation intent.
- Reviewed `git diff develop...HEAD`.
- Confirmed the implementation diff touches only `crates/pod/src/feature.rs`.
- Searched Rust sources for the old generic authority names and found no live-code API leftovers, only new host-authority names or historical work-item text outside source.
- Checked worktree status after validation; no tracked changes.
Not rerun:
- Full workspace checks, broader package tests, `./tickets.sh doctor`, and `nix build .#yoi --no-link` were not rerun for this focused external review.
## 7. Residual risk
Residual risk is low for this ticket. The change is mostly API naming plus tests, and focused validation passed. The main remaining risk is pre-existing: the feature registry still automatically grants requested host authorities, so it is not yet a real external-plugin authority enforcement layer. That risk is explicitly outside this ticket's implementation scope and does not block this host-authority naming clarification.
---
<!-- event: implementation_report author: hare at: 2026-06-05T05:09:37Z -->
## Implementation report
# Implementation report: feature-api-authority-separation
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/feature-api-authority-separation`
- Branch: `work/feature-api-authority-separation`
## Commit
- `4fc361f refactor: name feature host authorities explicitly`
## Summary
Clarified the `pod::feature` authority boundary by renaming the generic authority API surface to explicit host-authority terminology. This keeps feature contribution declarations separate from host-mediated capability grants and prepares the API for later Ticket built-in tools without framing internal built-ins as external plugin package grants.
## Exact renames
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `FeatureDescriptor::requested_authorities` -> `requested_host_authorities`
- `FeatureDescriptor::with_authority` -> `with_host_authority`
- `ToolContribution::required_authorities` -> `required_host_authorities`
- `ToolContribution::with_required_authorities` -> `with_required_host_authorities`
- `FeatureInstallReport::granted_authorities` -> `host_authority_grants`
- `FeatureInstallContext::grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
- Internal helpers/diagnostics now use host-authority terminology where applicable.
## Changed files
- `crates/pod/src/feature.rs`
## Behavior
Preserved:
- descriptor-first validation;
- duplicate tool rejection;
- undeclared contribution rejection;
- missing required host authority install failure;
- built-in Task feature behavior;
- contribution-only built-in feature installation without host authority grants.
Added/updated tests and comments to make explicit that contributing a tool/hook/background/service descriptor is not itself a host authority grant, while per-tool host authority requirements still require a corresponding granted requested host authority.
## Validation
Coder-reported validation passed:
- `cargo test -p pod feature --lib`
- `cargo test -p pod task --lib`
- `cargo test -p pod --lib`
- `cargo test -p llm-worker --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check develop...HEAD`
- `cargo test -p pod feature --lib`
## Review status
External sibling reviewer approved with no blockers and no required non-blockers before merge.
## Unresolved risks / follow-ups
The existing `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` behavior remains a builtin-only scaffold, not a real external plugin approval resolver. This is unchanged and explicitly outside this ticket's scope.
## Ready for merge
Yes. This clears the API naming prerequisite for `ticket-built-in-feature-tools`.
---
<!-- event: close author: hare at: 2026-06-05T05:11:56Z status: closed -->
## Closed
Feature API authority separation is complete and merged.
Implementation:
- `4fc361f refactor: name feature host authorities explicitly`
- merge commit: `b46ea65 merge: clarify feature host authorities`
Summary:
- Renamed the generic feature authority API surface to explicit host-authority terminology:
- `AuthorityRequest` -> `HostAuthorityRequest`
- `AuthorityGrantSet` -> `HostAuthorityGrantSet`
- `AuthorityDenial` -> `HostAuthorityDenial`
- `requested_authorities` -> `requested_host_authorities`
- `required_authorities` -> `required_host_authorities`
- `granted_authorities` -> `host_authority_grants`
- `grants()` -> `host_authority_grants()`
- `FeatureInstallError::AuthorityDenied` -> `HostAuthorityDenied`
- Preserved descriptor-first validation, duplicate tool rejection, undeclared contribution rejection, missing host-authority install failure, and built-in Task feature behavior.
- Added/updated tests/comments to make contribution declarations separate from host authority grants.
- Did not implement Ticket tools, external plugin loading, approval/resume protocol, MCP, WASM/sandbox runtime, feature crate extraction, Hook behavior changes, or Task behavior changes.
Review:
- External sibling reviewer approved with no blockers and no required non-blockers.
- Residual note: `HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities)` remains the existing builtin-only scaffold, not a real external-plugin approval resolver. This is unchanged and remains future work.
Post-merge validation passed:
- `cargo test -p pod feature --lib`
- `cargo test -p pod task --lib`
- `cargo test -p pod --lib`
- `cargo test -p llm-worker --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `cargo check --workspace --all-targets`
- `nix build .#yoi --no-link`
This clears the API naming prerequisite for `ticket-built-in-feature-tools`.
---

View File

@ -0,0 +1,187 @@
# Delegation intent: Ticket built-in feature tools
## Classification
`implementation-ready` with one important architectural constraint:
- Ticket domain/backend and Ticket tool behavior should live in the `ticket` crate as much as possible.
- `pod` should contain only the thin built-in feature adapter: descriptor, host-authority/backend-root wiring, and feature contribution registration.
This avoids repeating the earlier Task split problem where stateful feature behavior lived in the generic `tools` crate while lifecycle/state ownership lived in `pod`.
## Intent
Expose typed Ticket operations as built-in Pod tools without granting arbitrary filesystem write scope over `work-items/`.
The tools must operate through the `ticket` crate's `TicketBackend` / `LocalTicketBackend`, not through `tickets.sh` shell execution and not through generic file write tools.
## Worktree / branch
- worktree: `/home/hare/Projects/yoi/.worktree/ticket-built-in-feature-tools`
- branch: `work/ticket-built-in-feature-tools`
## Architecture decision
Use this dependency/ownership shape:
```text
crates/ticket
- Ticket domain/backend
- Ticket tool input/output types
- Ticket tool implementations as llm-worker Tool impls
- ticket_tools(...) factory
crates/pod
- built-in Ticket feature descriptor
- local backend root resolution, initially <workspace>/work-items
- host authority declaration/grant wiring
- FeatureContribution registration
crates/tools
- unchanged; do not add Ticket tools here
```
Allowed dependency direction:
```text
ticket -> llm-worker # for Tool/ToolDefinition only
ticket -> serde/schemars/etc.
pod -> ticket
```
Forbidden dependency direction:
```text
ticket -> pod
```
Do not extract a lower `feature-api` crate in this ticket. A fully crate-contained feature contribution can be revisited later if/when feature API is moved below `pod`.
## Requirements
- Add Ticket tool behavior to the `ticket` crate.
- Add a thin `pod::feature::builtin::ticket` adapter that installs those tools through the existing FeatureContribution/ToolRegistry path.
- Do not put Ticket tools in `crates/tools`.
- Do not shell out to `tickets.sh`.
- Use `LocalTicketBackend` over the configured/local backend root.
- Initial backend root may be resolved as `<workspace>/work-items` by the Pod adapter.
- Register Ticket tools only when the built-in Ticket feature is explicitly installed by the Pod host adapter.
- It is acceptable for the current Pod controller to install it when `<workspace>/work-items` exists or when the project config path is otherwise clearly resolved.
- If the root is missing/unusable, fail closed or do not register tools; do not create arbitrary directories silently.
- Treat Ticket operations as typed backend authority, not generic filesystem write scope.
- If adding a new `HostAuthority` variant is appropriate, prefer an explicit Ticket/backend authority over reusing generic `Filesystem` in a way that implies arbitrary FS access.
- Preserve existing PreToolCall/tool permission path: registered tools still go through normal tool-call policy.
- Keep outputs bounded and model-readable.
- Keep write paths compatible with `./tickets.sh doctor`.
- Add focused tests for both the `ticket` crate tool behavior and the Pod feature adapter.
## Tool surface
Implement the MVP tool set unless a clear blocker appears:
- `TicketCreate`
- `TicketList`
- `TicketShow`
- `TicketComment`
- `TicketReview`
- `TicketStatus`
- `TicketClose`
- `TicketDoctor`
Optional follow-up, not required for MVP:
- `TicketArtifactWrite`
- `TicketArtifactRead`
- `TicketSearch`
## Suggested tool semantics
Keep exact schemas practical, but preserve these intentions:
- `TicketList`
- filter by status (`open`, `pending`, `closed`, `all`) and optionally label/kind/priority if easy.
- output summaries, not full bodies by default.
- `TicketShow`
- read one ticket by id/slug/query and return bounded item/thread/resolution/artifact metadata.
- `TicketCreate`
- create a Ticket with title, optional slug/kind/priority/labels, and Markdown body sections.
- do not create unresolved drafts unless the caller asks for an explicit requirements-sync/spike-style Ticket body.
- `TicketComment`
- append a typed event role: `comment`, `plan`, `decision`, or `implementation_report`.
- `TicketReview`
- append approve/request-changes review.
- `TicketStatus`
- move among open/pending/closed where supported; prefer `TicketClose` for close-with-resolution.
- `TicketClose`
- close with Markdown resolution.
- `TicketDoctor`
- report backend doctor diagnostics in bounded form.
## Non-goals
- Implementing Intake workflow/profile.
- Implementing Orchestrator routing/scheduling.
- Renaming `work-items/`.
- Removing `tickets.sh`.
- External tracker integration.
- MCP/plugin loading.
- Scheduler/lease/queue automation.
- TUI changes.
- Moving feature API into a new crate.
- Adding Ticket tools to `crates/tools`.
## Current code map
- `crates/ticket/src/lib.rs`
- Ticket domain/backend, `TicketBackend`, `LocalTicketBackend`, local tests.
- Add `tools` module or submodule here for Ticket tool behavior if the file is getting large.
- `crates/ticket/Cargo.toml`
- May need `llm-worker`, `serde`, `serde_json`, `schemars`, `async-trait`, and possibly `tokio` depending on tool implementation style.
- `crates/pod/src/feature/builtin.rs`
- Add/export built-in Ticket module.
- `crates/pod/src/feature/builtin/ticket.rs` or `crates/pod/src/feature/builtin/ticket/mod.rs`
- Thin adapter that contributes Ticket tools.
- `crates/pod/src/controller.rs`
- Installs built-in features. Add Ticket feature installation only through an explicit, bounded backend-root resolution path.
- `crates/pod/src/feature.rs`
- Host authority definitions if a Ticket/backend authority variant or tests are needed.
- `crates/pod/Cargo.toml`
- Add dependency on `ticket`.
## Critical risks
- Putting Ticket tool implementations in `pod` would undercut the purpose of extracting the Ticket backend and make the feature harder to reuse.
- Putting Ticket tools in `tools` would repeat the Task-tools responsibility problem.
- Making Ticket tools equivalent to generic filesystem write scope would violate the authority model.
- Auto-creating `work-items/` in arbitrary workspaces would surprise users; fail closed or do not register when no backend root is configured/found.
- Returning unbounded full ticket/thread/artifact content would make tool output too large.
## Validation
Run at least:
- `cargo test -p ticket`
- focused Pod feature tests, e.g. `cargo test -p pod ticket --lib` if test names allow it
- `cargo test -p pod feature --lib`
- `cargo test -p pod --lib`
- `cargo test -p tools --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
Run `nix build .#yoi --no-link` if feasible.
## Completion report
Report:
- worktree path / branch;
- commit hash;
- final module layout;
- exact tool names/schemas/output style;
- how backend root/authority is wired;
- evidence Ticket tools are not in `crates/tools`;
- validation results;
- unresolved risks/follow-ups;
- whether ready for external review.

View File

@ -0,0 +1,126 @@
# Implementation report: ticket-built-in-feature-tools
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-built-in-feature-tools`
- Branch: `work/ticket-built-in-feature-tools`
## Commit
- `afd7f04 feat: add built-in ticket tools`
## Summary
Added the MVP Ticket tool surface as built-in Pod tools while preserving the agreed ownership split:
- `crates/ticket` owns Ticket tool behavior.
- `crates/pod` owns only the built-in feature adapter and backend-root/host-authority wiring.
- `crates/tools` remains unchanged and does not contain Ticket tools.
The implementation exposes Ticket operations through typed `LocalTicketBackend` calls rather than shelling out to `tickets.sh` or granting generic filesystem write access.
## Final module layout
- `crates/ticket/src/tool.rs`
- Ticket tool input/output types.
- JSON-schema-backed tool definitions.
- Bounded JSON output shaping.
- `llm_worker::Tool` implementations.
- `ticket_tools(...)` factory.
- `crates/ticket/src/lib.rs`
- Exports `pub mod tool`.
- Updates `set_status` to append a `status_changed` event so status moves remain doctor-clean.
- `crates/pod/src/feature/builtin/ticket.rs`
- Thin built-in Ticket feature adapter.
- Resolves and validates `<workspace>/work-items`.
- Declares `HostAuthority::TicketBackend { root }`.
- Registers Ticket tools through `FeatureContribution`.
- `crates/pod/src/feature/builtin.rs`
- Exports the built-in Ticket feature adapter.
- `crates/pod/src/controller.rs`
- Installs the Ticket built-in feature alongside existing built-ins.
- `crates/pod/src/feature.rs`
- Adds explicit `HostAuthority::TicketBackend { root }`.
## Tool surface
Implemented tools:
- `TicketCreate`
- `TicketList`
- `TicketShow`
- `TicketComment`
- `TicketReview`
- `TicketStatus`
- `TicketClose`
- `TicketDoctor`
Input schemas are generated from typed serde/schemars structs. Outputs are bounded JSON in `ToolOutput.content` with concise summaries.
Bounds include:
- `TicketList`: default/max `100` / `200`.
- `TicketShow`: recent events default/max `20` / `100`; artifacts default/max `50` / `200`; Markdown body bytes default/max `16 KiB` / `64 KiB`.
- `TicketDoctor`: diagnostics default/max `100` / `500`.
`TicketStatus` intentionally handles `open` and `pending`; `closed` requires `TicketClose` so `resolution.md` is written.
## Backend root / authority wiring
- Pod adapter resolves the local backend root as `<workspace>/work-items`.
- The root is canonicalized and validated to contain `open/`, `pending/`, and `closed/`.
- Missing/unusable roots register no Ticket tools and emit a warning diagnostic; arbitrary directories are not silently created.
- Registered tools require typed `HostAuthority::TicketBackend { root }`, not generic filesystem authority.
- Tool calls still use the normal feature contribution / ToolRegistry / PreToolCall path.
## Changed files
- `Cargo.lock`
- `crates/pod/Cargo.toml`
- `crates/pod/src/controller.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/feature/builtin.rs`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/ticket/Cargo.toml`
- `crates/ticket/src/lib.rs`
- `crates/ticket/src/tool.rs`
- `package.nix`
## Evidence Ticket tools are not in `crates/tools`
- No files under `crates/tools` were modified.
- Ticket tool implementation is in `crates/ticket/src/tool.rs`.
- Pod only adapts/registers the tools through the feature layer.
## Validation
Coder-reported validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo test -p pod --lib`
- `cargo test -p tools --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check develop...HEAD`
- `./tickets.sh doctor`
## Review status
External sibling reviewer approved with no blockers.
## Non-blocker follow-ups
- Feature install report currently records Ticket as installed even when backend root is unusable and no tools are registered. Fail-closed behavior is correct, but diagnostics may deserve more visible surfacing later.
- `HostAuthority::TicketBackend { root }` is declared from the pre-canonical backend path while the backend uses the canonicalized root. This is acceptable with the current built-in grant flow, but future explicit grant comparisons should normalize consistently.
## Ready for merge
Yes.

View File

@ -0,0 +1,67 @@
# External review: Ticket built-in feature tools
## 1. Result
approve
## 2. Summary of implementation
The implementation adds Ticket tool behavior in `crates/ticket/src/tool.rs`, exposes the eight MVP tools (`TicketCreate`, `TicketList`, `TicketShow`, `TicketComment`, `TicketReview`, `TicketStatus`, `TicketClose`, `TicketDoctor`) over `LocalTicketBackend`, and wires them into `pod` through a thin built-in feature adapter in `crates/pod/src/feature/builtin/ticket.rs`.
The Pod side declares `HostAuthority::TicketBackend { root }`, validates/canonicalizes `<workspace>/work-items` before registering tools, and contributes the tool definitions through the existing feature contribution path. No Ticket implementation was added to `crates/tools`, and the implementation does not shell out to `tickets.sh`.
## 3. Requirement-by-requirement assessment
- Ticket crate owns tool types and `llm-worker::tool::Tool` implementations: satisfied. `crates/ticket/src/tool.rs` contains the input/output structs, schemas, tool definitions, and backend calls; `ticket` does not depend on `pod`.
- Pod adapter is thin: satisfied. `crates/pod/src/feature/builtin/ticket.rs` resolves the local backend root, declares feature metadata/authority, and registers `ticket::tool::ticket_tools(...)`.
- No Ticket tools in `crates/tools`: satisfied. Focused search found no Ticket tool code under `crates/tools`.
- Tool names/schemas: satisfied. The implemented names match the requested MVP surface, and schemas are generated from typed serde/schemars input structs.
- Tools use `LocalTicketBackend`, not `tickets.sh` or generic filesystem writes: satisfied. Tool execution calls typed backend methods directly.
- Backend root resolution: satisfied for this slice. The adapter resolves `<workspace>/work-items`, canonicalizes it, requires it to be a directory with `open/`, `pending/`, and `closed/`, and registers no tools when unusable.
- `HostAuthority::TicketBackend { root }`: satisfied. A Ticket-specific authority is used rather than generic filesystem authority, and each registered tool contribution requires that authority.
- Normal PreToolCall/tool permission path: satisfied. Tools are registered through the normal Worker/ToolRegistry path; no bypass was introduced.
- Bounded, model-readable outputs: satisfied. List/doctor/show outputs are JSON with explicit limits and truncation metadata; show truncates item/thread/resolution bodies and bounds returned event/artifact metadata.
- `tickets.sh doctor` compatibility: satisfied by review and tests present in the implementation. Create/comment/review/status/close paths write the existing work-item layout and thread event format; `TicketStatus` now appends a status event while preserving doctor compatibility.
- Existing backend behavior: no regression found in the touched backend code. The only backend behavior change in this commit is status-change thread event emission plus `updated_at` via the shared append path.
- `Cargo.lock` / `package.nix`: changes appear necessary and safe for the new `ticket -> llm-worker/schemars/serde/...` and `pod -> ticket` dependencies; `cargoHash` was updated accordingly.
- Out-of-scope work: no Intake workflow, Orchestrator routing, TUI changes, scheduler, external tracker, MCP/plugin loading, feature-api extraction, or unrelated refactor was found.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
- The feature install report records the Ticket feature as installed even when the backend root is unusable and no tools are registered. This is fail-closed for tool availability, but surfacing the diagnostic more visibly may be useful later if users expect Ticket tools and the root is missing.
- `HostAuthority::TicketBackend { root }` is declared from the pre-canonical backend path while the actual backend uses the canonicalized root. This is acceptable in the current grant-all built-in host flow, but future explicit grant/config comparisons should use a normalized representation consistently.
## 6. Validation assessed or rerun
Reran focused read-only validation:
```text
cd /home/hare/Projects/yoi/.worktree/ticket-built-in-feature-tools && git diff --check develop...HEAD && ./tickets.sh doctor
```
Result:
```text
doctor: ok
```
Also inspected:
- `git diff develop...HEAD` / changed file list
- `crates/ticket/src/lib.rs`
- `crates/ticket/src/tool.rs`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/feature.rs`
- relevant Cargo/package changes
- absence of Ticket tool code in `crates/tools`
I did not rerun `cargo test`, `cargo check`, or `nix build` because this external review was constrained to not modify source/worktree state; those commands normally write build artifacts.
## 7. Residual risk
The remaining risk is mainly operational: full compile/test/Nix validation was not rerun by this reviewer. Based on code inspection and the focused checks above, the implementation matches the agreed architecture and is ready to merge after the normal build/test evidence is accepted.

View File

@ -2,12 +2,12 @@
id: 20260605-040104-ticket-built-in-feature-tools
slug: ticket-built-in-feature-tools
title: Ticket built-in feature tools
status: open
status: closed
kind: task
priority: P1
labels: [ticket, feature, tool, orchestration]
created_at: 2026-06-05T04:01:04Z
updated_at: 2026-06-05T04:01:25Z
updated_at: 2026-06-05T06:00:22Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,48 @@
Ticket built-in feature tools are complete and merged.
Implementation:
- `afd7f04 feat: add built-in ticket tools`
- merge commit: `4486a81 merge: add ticket feature tools`
Summary:
- Added the MVP Ticket tool surface:
- `TicketCreate`
- `TicketList`
- `TicketShow`
- `TicketComment`
- `TicketReview`
- `TicketStatus`
- `TicketClose`
- `TicketDoctor`
- Kept Ticket tool behavior in `crates/ticket/src/tool.rs`.
- Kept `pod` as a thin adapter in `crates/pod/src/feature/builtin/ticket.rs`.
- Added `HostAuthority::TicketBackend { root }` to distinguish typed Ticket backend authority from generic filesystem access.
- Wired Ticket tools through the existing built-in feature contribution / ToolRegistry path.
- Resolved local backend root as `<workspace>/work-items`; unusable roots register no Ticket tools and emit a warning diagnostic.
- Did not add Ticket tools to `crates/tools`.
- Did not shell out to `tickets.sh`.
- Did not implement Intake workflow, Orchestrator routing, TUI changes, external tracker integration, MCP/plugin loading, scheduler/lease behavior, or feature-api extraction.
Review:
- External sibling reviewer approved with no blockers.
- Non-blocker follow-ups:
- Feature install report currently records Ticket as installed even when backend root is unusable and no tools are registered; diagnostics may deserve more visible surfacing later.
- `HostAuthority::TicketBackend { root }` currently uses the pre-canonical backend path while backend use is canonicalized; future explicit grant comparisons should normalize consistently.
Post-merge validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo test -p pod --lib`
- `cargo test -p tools --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `cargo check --workspace --all-targets`
- `nix build .#yoi --no-link`
This clears the tool-surface prerequisite for `ticket-intake-workflow` and `ticket-orchestrator-routing`.

View File

@ -0,0 +1,329 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-06-05T05:37:41Z -->
## Decision
Decision: keep Ticket tool behavior mostly in the `ticket` crate and use `pod` only as a thin built-in feature adapter.
Rationale:
- `ticket` should own the Ticket domain/backend and the behavior of Ticket operations.
- `pod::feature` is still owned by the `pod` crate, so feature installation/authority wiring cannot be fully crate-contained without extracting a lower `feature-api` crate.
- Extracting `feature-api` is explicitly out of scope for this ticket.
- Putting Ticket tools in `crates/tools` would repeat the Task tool responsibility problem.
Dependency shape:
```text
ticket -> llm-worker # Tool/ToolDefinition implementation only
pod -> ticket # built-in feature adapter and backend-root wiring
```
Forbidden:
```text
ticket -> pod
```
Implementation direction:
- `crates/ticket` may add Ticket tool input/output types and Tool implementations.
- `crates/pod/src/feature/builtin/ticket.*` should only install/register those tools through `FeatureContribution` and resolve host/backend authority.
- Ticket tools must operate through typed `TicketBackend`/`LocalTicketBackend`, not through `tickets.sh` or generic filesystem writes.
---
<!-- event: plan author: hare at: 2026-06-05T05:37:42Z -->
## Plan
Preflight result: `implementation-ready`.
Prerequisites are complete:
- `ticket-local-files-backend` added the typed Ticket backend and `LocalTicketBackend`.
- `feature-api-authority-separation` clarified host-authority naming in `pod::feature`.
Scope for implementation:
- Add the MVP Ticket tool surface: create/list/show/comment/review/status/close/doctor.
- Keep Ticket tool behavior in `crates/ticket` and use a thin `pod` adapter for built-in feature registration and backend-root/authority wiring.
- Do not implement Intake workflow, Orchestrator routing, TUI changes, external trackers, MCP/plugin loading, scheduler/lease behavior, or `work-items/` rename.
Detailed delegation intent is recorded in `artifacts/delegation-intent.md`.
---
<!-- event: review author: hare at: 2026-06-05T05:58:38Z status: approve -->
## Review: approve
# External review: Ticket built-in feature tools
## 1. Result
approve
## 2. Summary of implementation
The implementation adds Ticket tool behavior in `crates/ticket/src/tool.rs`, exposes the eight MVP tools (`TicketCreate`, `TicketList`, `TicketShow`, `TicketComment`, `TicketReview`, `TicketStatus`, `TicketClose`, `TicketDoctor`) over `LocalTicketBackend`, and wires them into `pod` through a thin built-in feature adapter in `crates/pod/src/feature/builtin/ticket.rs`.
The Pod side declares `HostAuthority::TicketBackend { root }`, validates/canonicalizes `<workspace>/work-items` before registering tools, and contributes the tool definitions through the existing feature contribution path. No Ticket implementation was added to `crates/tools`, and the implementation does not shell out to `tickets.sh`.
## 3. Requirement-by-requirement assessment
- Ticket crate owns tool types and `llm-worker::tool::Tool` implementations: satisfied. `crates/ticket/src/tool.rs` contains the input/output structs, schemas, tool definitions, and backend calls; `ticket` does not depend on `pod`.
- Pod adapter is thin: satisfied. `crates/pod/src/feature/builtin/ticket.rs` resolves the local backend root, declares feature metadata/authority, and registers `ticket::tool::ticket_tools(...)`.
- No Ticket tools in `crates/tools`: satisfied. Focused search found no Ticket tool code under `crates/tools`.
- Tool names/schemas: satisfied. The implemented names match the requested MVP surface, and schemas are generated from typed serde/schemars input structs.
- Tools use `LocalTicketBackend`, not `tickets.sh` or generic filesystem writes: satisfied. Tool execution calls typed backend methods directly.
- Backend root resolution: satisfied for this slice. The adapter resolves `<workspace>/work-items`, canonicalizes it, requires it to be a directory with `open/`, `pending/`, and `closed/`, and registers no tools when unusable.
- `HostAuthority::TicketBackend { root }`: satisfied. A Ticket-specific authority is used rather than generic filesystem authority, and each registered tool contribution requires that authority.
- Normal PreToolCall/tool permission path: satisfied. Tools are registered through the normal Worker/ToolRegistry path; no bypass was introduced.
- Bounded, model-readable outputs: satisfied. List/doctor/show outputs are JSON with explicit limits and truncation metadata; show truncates item/thread/resolution bodies and bounds returned event/artifact metadata.
- `tickets.sh doctor` compatibility: satisfied by review and tests present in the implementation. Create/comment/review/status/close paths write the existing work-item layout and thread event format; `TicketStatus` now appends a status event while preserving doctor compatibility.
- Existing backend behavior: no regression found in the touched backend code. The only backend behavior change in this commit is status-change thread event emission plus `updated_at` via the shared append path.
- `Cargo.lock` / `package.nix`: changes appear necessary and safe for the new `ticket -> llm-worker/schemars/serde/...` and `pod -> ticket` dependencies; `cargoHash` was updated accordingly.
- Out-of-scope work: no Intake workflow, Orchestrator routing, TUI changes, scheduler, external tracker, MCP/plugin loading, feature-api extraction, or unrelated refactor was found.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
- The feature install report records the Ticket feature as installed even when the backend root is unusable and no tools are registered. This is fail-closed for tool availability, but surfacing the diagnostic more visibly may be useful later if users expect Ticket tools and the root is missing.
- `HostAuthority::TicketBackend { root }` is declared from the pre-canonical backend path while the actual backend uses the canonicalized root. This is acceptable in the current grant-all built-in host flow, but future explicit grant/config comparisons should use a normalized representation consistently.
## 6. Validation assessed or rerun
Reran focused read-only validation:
```text
cd /home/hare/Projects/yoi/.worktree/ticket-built-in-feature-tools && git diff --check develop...HEAD && ./tickets.sh doctor
```
Result:
```text
doctor: ok
```
Also inspected:
- `git diff develop...HEAD` / changed file list
- `crates/ticket/src/lib.rs`
- `crates/ticket/src/tool.rs`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/feature.rs`
- relevant Cargo/package changes
- absence of Ticket tool code in `crates/tools`
I did not rerun `cargo test`, `cargo check`, or `nix build` because this external review was constrained to not modify source/worktree state; those commands normally write build artifacts.
## 7. Residual risk
The remaining risk is mainly operational: full compile/test/Nix validation was not rerun by this reviewer. Based on code inspection and the focused checks above, the implementation matches the agreed architecture and is ready to merge after the normal build/test evidence is accepted.
---
<!-- event: implementation_report author: hare at: 2026-06-05T05:58:39Z -->
## Implementation report
# Implementation report: ticket-built-in-feature-tools
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-built-in-feature-tools`
- Branch: `work/ticket-built-in-feature-tools`
## Commit
- `afd7f04 feat: add built-in ticket tools`
## Summary
Added the MVP Ticket tool surface as built-in Pod tools while preserving the agreed ownership split:
- `crates/ticket` owns Ticket tool behavior.
- `crates/pod` owns only the built-in feature adapter and backend-root/host-authority wiring.
- `crates/tools` remains unchanged and does not contain Ticket tools.
The implementation exposes Ticket operations through typed `LocalTicketBackend` calls rather than shelling out to `tickets.sh` or granting generic filesystem write access.
## Final module layout
- `crates/ticket/src/tool.rs`
- Ticket tool input/output types.
- JSON-schema-backed tool definitions.
- Bounded JSON output shaping.
- `llm_worker::Tool` implementations.
- `ticket_tools(...)` factory.
- `crates/ticket/src/lib.rs`
- Exports `pub mod tool`.
- Updates `set_status` to append a `status_changed` event so status moves remain doctor-clean.
- `crates/pod/src/feature/builtin/ticket.rs`
- Thin built-in Ticket feature adapter.
- Resolves and validates `<workspace>/work-items`.
- Declares `HostAuthority::TicketBackend { root }`.
- Registers Ticket tools through `FeatureContribution`.
- `crates/pod/src/feature/builtin.rs`
- Exports the built-in Ticket feature adapter.
- `crates/pod/src/controller.rs`
- Installs the Ticket built-in feature alongside existing built-ins.
- `crates/pod/src/feature.rs`
- Adds explicit `HostAuthority::TicketBackend { root }`.
## Tool surface
Implemented tools:
- `TicketCreate`
- `TicketList`
- `TicketShow`
- `TicketComment`
- `TicketReview`
- `TicketStatus`
- `TicketClose`
- `TicketDoctor`
Input schemas are generated from typed serde/schemars structs. Outputs are bounded JSON in `ToolOutput.content` with concise summaries.
Bounds include:
- `TicketList`: default/max `100` / `200`.
- `TicketShow`: recent events default/max `20` / `100`; artifacts default/max `50` / `200`; Markdown body bytes default/max `16 KiB` / `64 KiB`.
- `TicketDoctor`: diagnostics default/max `100` / `500`.
`TicketStatus` intentionally handles `open` and `pending`; `closed` requires `TicketClose` so `resolution.md` is written.
## Backend root / authority wiring
- Pod adapter resolves the local backend root as `<workspace>/work-items`.
- The root is canonicalized and validated to contain `open/`, `pending/`, and `closed/`.
- Missing/unusable roots register no Ticket tools and emit a warning diagnostic; arbitrary directories are not silently created.
- Registered tools require typed `HostAuthority::TicketBackend { root }`, not generic filesystem authority.
- Tool calls still use the normal feature contribution / ToolRegistry / PreToolCall path.
## Changed files
- `Cargo.lock`
- `crates/pod/Cargo.toml`
- `crates/pod/src/controller.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/feature/builtin.rs`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/ticket/Cargo.toml`
- `crates/ticket/src/lib.rs`
- `crates/ticket/src/tool.rs`
- `package.nix`
## Evidence Ticket tools are not in `crates/tools`
- No files under `crates/tools` were modified.
- Ticket tool implementation is in `crates/ticket/src/tool.rs`.
- Pod only adapts/registers the tools through the feature layer.
## Validation
Coder-reported validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo test -p pod --lib`
- `cargo test -p tools --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check develop...HEAD`
- `./tickets.sh doctor`
## Review status
External sibling reviewer approved with no blockers.
## Non-blocker follow-ups
- Feature install report currently records Ticket as installed even when backend root is unusable and no tools are registered. Fail-closed behavior is correct, but diagnostics may deserve more visible surfacing later.
- `HostAuthority::TicketBackend { root }` is declared from the pre-canonical backend path while the backend uses the canonicalized root. This is acceptable with the current built-in grant flow, but future explicit grant comparisons should normalize consistently.
## Ready for merge
Yes.
---
<!-- event: close author: hare at: 2026-06-05T06:00:22Z status: closed -->
## Closed
Ticket built-in feature tools are complete and merged.
Implementation:
- `afd7f04 feat: add built-in ticket tools`
- merge commit: `4486a81 merge: add ticket feature tools`
Summary:
- Added the MVP Ticket tool surface:
- `TicketCreate`
- `TicketList`
- `TicketShow`
- `TicketComment`
- `TicketReview`
- `TicketStatus`
- `TicketClose`
- `TicketDoctor`
- Kept Ticket tool behavior in `crates/ticket/src/tool.rs`.
- Kept `pod` as a thin adapter in `crates/pod/src/feature/builtin/ticket.rs`.
- Added `HostAuthority::TicketBackend { root }` to distinguish typed Ticket backend authority from generic filesystem access.
- Wired Ticket tools through the existing built-in feature contribution / ToolRegistry path.
- Resolved local backend root as `<workspace>/work-items`; unusable roots register no Ticket tools and emit a warning diagnostic.
- Did not add Ticket tools to `crates/tools`.
- Did not shell out to `tickets.sh`.
- Did not implement Intake workflow, Orchestrator routing, TUI changes, external tracker integration, MCP/plugin loading, scheduler/lease behavior, or feature-api extraction.
Review:
- External sibling reviewer approved with no blockers.
- Non-blocker follow-ups:
- Feature install report currently records Ticket as installed even when backend root is unusable and no tools are registered; diagnostics may deserve more visible surfacing later.
- `HostAuthority::TicketBackend { root }` currently uses the pre-canonical backend path while backend use is canonicalized; future explicit grant comparisons should normalize consistently.
Post-merge validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo test -p pod --lib`
- `cargo test -p tools --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `cargo check --workspace --all-targets`
- `nix build .#yoi --no-link`
This clears the tool-surface prerequisite for `ticket-intake-workflow` and `ticket-orchestrator-routing`.
---

View File

@ -0,0 +1,65 @@
# Implementation report: ticket-intake-workflow
## Summary
Added the user-invocable Ticket Intake workflow and lightly updated workflow development documentation.
The workflow defines Intake as the clarification/materialization boundary between a user's request and Orchestrator routing. Intake clarifies the request, checks duplicate/related Tickets, prepares a draft, obtains user agreement, and creates/updates Tickets through typed Ticket tools.
## Changed files
- `.yoi/workflow/ticket-intake-workflow.md`
- `docs/development/workflows.md`
## Workflow behavior
The new workflow covers:
- user intent clarification;
- duplicate/related Ticket checks with `TicketList` / `TicketShow`;
- requirements, acceptance criteria, non-goals, escalation conditions, validation, and related-work capture;
- explicit readiness classification:
- `implementation_ready`;
- `requirements_sync_needed`;
- `spike_needed`;
- `blocked`;
- `unspecified` only with reason;
- `needs_preflight` and risk flag handling;
- user agreement before official Ticket creation;
- `TicketCreate` for new Tickets;
- `TicketComment` for existing Ticket refinement;
- fail-closed behavior when typed Ticket tools are unavailable;
- secret/private-context non-persistence rules;
- handoff to `ticket-preflight-workflow`, `multi-agent-workflow`, `auto-maintain`, and future `ticket-orchestrator-routing`.
## Non-goals preserved
The workflow explicitly avoids:
- scheduling implementation;
- spawning coder/reviewer/investigator Pods;
- creating worktrees;
- merge/close/branch cleanup;
- unattended automation;
- user-agreement-free Ticket creation;
- arbitrary filesystem writes to `work-items/`.
## Review status
External sibling reviewer approved with no blockers.
Reviewer non-blocker about an old `work items` phrase in `docs/development/workflows.md` was fixed before commit.
Remaining follow-up:
- If a future first-class `TicketUpdate` tool is added, the existing-Ticket refinement path should decide when to use it versus `TicketComment`.
## Validation
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so `cargo check`, `cargo fmt`, and `nix build` were not necessary for this workflow/docs-only change.

View File

@ -0,0 +1,55 @@
# Review: ticket-intake-workflow
## 1. Result: approve
Approve. I found no blockers for commit/close.
## 2. Summary
The new `ticket-intake-workflow` satisfies the ticket requirements and fits the current multi-agent direction. It defines Intake as a clarification/materialization boundary before Orchestrator routing, keeps implementation scheduling out of scope, requires user agreement before official Ticket creation, and uses typed Ticket tools instead of arbitrary `work-items/` file writes.
The `docs/development/workflows.md` update is small and accurate for the new workflow theme and child-Pod review responsibilities.
## 3. Requirement assessment
- Ticket terminology: pass. The new workflow consistently uses `Ticket` terminology and does not introduce WorkItem or insomnia wording. The only `work-items/` mention is the explicit prohibition against arbitrary filesystem edits, which matches the ticket authority-boundary requirement.
- Intake responsibilities: pass. The workflow covers intent clarification, duplicate/related Ticket checks, relevant repository reading when needed and permitted, clarifying questions, draft title/kind/priority/labels, and Ticket creation/update reporting.
- Intake non-goals: pass. The workflow explicitly forbids coder/reviewer/investigator Pod spawning, worktree creation, merge/close/branch cleanup, unattended scheduling, and implementation routing from Intake.
- User agreement: pass. It requires a pre-creation draft and official Ticket creation only after explicit approval or an explicit create/record request.
- Typed Ticket tools: pass. It directs agents to use `TicketList`, `TicketShow`, `TicketCreate`, `TicketComment`, and `TicketDoctor`, and says not to fall back to filesystem writes when tools are unavailable.
- Routing fields: pass. Readiness classification, `needs_preflight`, risk flags, acceptance criteria, escalation conditions, validation, and related work are represented in both the draft and recommended Ticket body.
- Secret/private handling: pass. It explicitly forbids persisting API keys, tokens, credentials, secret file contents, private prompts/responses, unnecessary private context, and raw secret-bearing logs.
- Workflow connections: pass. It connects to `ticket-preflight-workflow` for risk/uncertainty, `multi-agent-workflow` after implementation readiness, `auto-maintain` as an intake source, and future `ticket-orchestrator-routing` as the next routing layer.
- Docs update: pass. The docs change adds Intake to current workflow themes and updates child-Pod verification language from old ticket/work-item wording to Ticket requirements and acceptance criteria.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
- Note: `docs/development/workflows.md` still contains the pre-existing phrase "replacement for work items" in the general workflow-role paragraph. The implementation diff did not introduce it and the changed lines use Ticket terminology, so I do not consider it a blocker. If the documentation pass is intended to eliminate all old wording globally, that line can be normalized in a follow-up.
- Future follow-up: if a first-class `TicketUpdate` tool becomes part of the typed Ticket surface, the existing-Ticket refinement path should decide when to use it versus `TicketComment`. The current workflow is acceptable because it avoids arbitrary file writes and records refinements through typed Ticket comments.
## 6. Validation assessed
Reviewed:
- `work-items/open/20260605-040104-ticket-intake-workflow/item.md`
- `.yoi/workflow/ticket-intake-workflow.md`
- `docs/development/workflows.md`
- `.yoi/workflow/ticket-preflight-workflow.md`
- `.yoi/workflow/multi-agent-workflow.md`
Checks run:
- `git diff -- .yoi/workflow/ticket-intake-workflow.md docs/development/workflows.md work-items/open/20260605-040104-ticket-intake-workflow/item.md`
- `git diff --check -- .yoi/workflow/ticket-intake-workflow.md docs/development/workflows.md work-items/open/20260605-040104-ticket-intake-workflow/item.md` — passed
- `./tickets.sh doctor` — passed
- Targeted terminology grep for `WorkItem`, `insomnia`, `work item`, `work-items`, and `Ticket`
I did not run `cargo check`, `cargo fmt`, or `nix build` because this review found only workflow/docs changes and no code/package changes to validate.
## 7. Residual risk
The main residual risk is dependency timing: the workflow assumes typed Ticket tools are available, while the ticket states those tools/backend are dependencies. The workflow handles unavailable tools by failing closed and returning to a human/parent workflow, so this is an integration dependency rather than a blocker in the workflow text.

View File

@ -2,12 +2,12 @@
id: 20260605-040104-ticket-intake-workflow
slug: ticket-intake-workflow
title: Ticket intake workflow
status: open
status: closed
kind: task
priority: P1
labels: [ticket, intake, workflow, orchestration]
created_at: 2026-06-05T04:01:04Z
updated_at: 2026-06-05T04:01:25Z
updated_at: 2026-06-05T06:10:56Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,29 @@
Ticket Intake Workflow is complete.
Implementation:
- commit: `1233833 workflow: add ticket intake`
Summary:
- Added `.yoi/workflow/ticket-intake-workflow.md` as a user-invocable/model-invocable workflow.
- Updated `docs/development/workflows.md` to include Intake clarification and Ticket terminology.
- The workflow defines Intake as the clarification/materialization boundary before Orchestrator routing.
- It requires duplicate/related Ticket checks, readiness classification, needs-preflight/risk flags, draft presentation, and explicit user agreement before official Ticket creation.
- It uses typed Ticket tools (`TicketList`, `TicketShow`, `TicketCreate`, `TicketComment`, `TicketDoctor`) and forbids arbitrary filesystem edits to `work-items/`.
- It explicitly excludes scheduling, Pod spawning, worktree creation, merge/close, unattended automation, and user-agreement-free Ticket creation.
- It documents secret/private-context non-persistence rules.
Review:
- External sibling reviewer approved with no blockers.
- Reviewer non-blocker about old `work items` wording in `docs/development/workflows.md` was fixed before commit.
- Follow-up: if a future `TicketUpdate` tool is added, refine the existing-Ticket update path to choose between `TicketUpdate` and `TicketComment`.
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.

View File

@ -0,0 +1,180 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-06-05T06:10:31Z status: approve -->
## Review: approve
# Review: ticket-intake-workflow
## 1. Result: approve
Approve. I found no blockers for commit/close.
## 2. Summary
The new `ticket-intake-workflow` satisfies the ticket requirements and fits the current multi-agent direction. It defines Intake as a clarification/materialization boundary before Orchestrator routing, keeps implementation scheduling out of scope, requires user agreement before official Ticket creation, and uses typed Ticket tools instead of arbitrary `work-items/` file writes.
The `docs/development/workflows.md` update is small and accurate for the new workflow theme and child-Pod review responsibilities.
## 3. Requirement assessment
- Ticket terminology: pass. The new workflow consistently uses `Ticket` terminology and does not introduce WorkItem or insomnia wording. The only `work-items/` mention is the explicit prohibition against arbitrary filesystem edits, which matches the ticket authority-boundary requirement.
- Intake responsibilities: pass. The workflow covers intent clarification, duplicate/related Ticket checks, relevant repository reading when needed and permitted, clarifying questions, draft title/kind/priority/labels, and Ticket creation/update reporting.
- Intake non-goals: pass. The workflow explicitly forbids coder/reviewer/investigator Pod spawning, worktree creation, merge/close/branch cleanup, unattended scheduling, and implementation routing from Intake.
- User agreement: pass. It requires a pre-creation draft and official Ticket creation only after explicit approval or an explicit create/record request.
- Typed Ticket tools: pass. It directs agents to use `TicketList`, `TicketShow`, `TicketCreate`, `TicketComment`, and `TicketDoctor`, and says not to fall back to filesystem writes when tools are unavailable.
- Routing fields: pass. Readiness classification, `needs_preflight`, risk flags, acceptance criteria, escalation conditions, validation, and related work are represented in both the draft and recommended Ticket body.
- Secret/private handling: pass. It explicitly forbids persisting API keys, tokens, credentials, secret file contents, private prompts/responses, unnecessary private context, and raw secret-bearing logs.
- Workflow connections: pass. It connects to `ticket-preflight-workflow` for risk/uncertainty, `multi-agent-workflow` after implementation readiness, `auto-maintain` as an intake source, and future `ticket-orchestrator-routing` as the next routing layer.
- Docs update: pass. The docs change adds Intake to current workflow themes and updates child-Pod verification language from old ticket/work-item wording to Ticket requirements and acceptance criteria.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
- Note: `docs/development/workflows.md` still contains the pre-existing phrase "replacement for work items" in the general workflow-role paragraph. The implementation diff did not introduce it and the changed lines use Ticket terminology, so I do not consider it a blocker. If the documentation pass is intended to eliminate all old wording globally, that line can be normalized in a follow-up.
- Future follow-up: if a first-class `TicketUpdate` tool becomes part of the typed Ticket surface, the existing-Ticket refinement path should decide when to use it versus `TicketComment`. The current workflow is acceptable because it avoids arbitrary file writes and records refinements through typed Ticket comments.
## 6. Validation assessed
Reviewed:
- `work-items/open/20260605-040104-ticket-intake-workflow/item.md`
- `.yoi/workflow/ticket-intake-workflow.md`
- `docs/development/workflows.md`
- `.yoi/workflow/ticket-preflight-workflow.md`
- `.yoi/workflow/multi-agent-workflow.md`
Checks run:
- `git diff -- .yoi/workflow/ticket-intake-workflow.md docs/development/workflows.md work-items/open/20260605-040104-ticket-intake-workflow/item.md`
- `git diff --check -- .yoi/workflow/ticket-intake-workflow.md docs/development/workflows.md work-items/open/20260605-040104-ticket-intake-workflow/item.md` — passed
- `./tickets.sh doctor` — passed
- Targeted terminology grep for `WorkItem`, `insomnia`, `work item`, `work-items`, and `Ticket`
I did not run `cargo check`, `cargo fmt`, or `nix build` because this review found only workflow/docs changes and no code/package changes to validate.
## 7. Residual risk
The main residual risk is dependency timing: the workflow assumes typed Ticket tools are available, while the ticket states those tools/backend are dependencies. The workflow handles unavailable tools by failing closed and returning to a human/parent workflow, so this is an integration dependency rather than a blocker in the workflow text.
---
<!-- event: implementation_report author: hare at: 2026-06-05T06:10:32Z -->
## Implementation report
# Implementation report: ticket-intake-workflow
## Summary
Added the user-invocable Ticket Intake workflow and lightly updated workflow development documentation.
The workflow defines Intake as the clarification/materialization boundary between a user's request and Orchestrator routing. Intake clarifies the request, checks duplicate/related Tickets, prepares a draft, obtains user agreement, and creates/updates Tickets through typed Ticket tools.
## Changed files
- `.yoi/workflow/ticket-intake-workflow.md`
- `docs/development/workflows.md`
## Workflow behavior
The new workflow covers:
- user intent clarification;
- duplicate/related Ticket checks with `TicketList` / `TicketShow`;
- requirements, acceptance criteria, non-goals, escalation conditions, validation, and related-work capture;
- explicit readiness classification:
- `implementation_ready`;
- `requirements_sync_needed`;
- `spike_needed`;
- `blocked`;
- `unspecified` only with reason;
- `needs_preflight` and risk flag handling;
- user agreement before official Ticket creation;
- `TicketCreate` for new Tickets;
- `TicketComment` for existing Ticket refinement;
- fail-closed behavior when typed Ticket tools are unavailable;
- secret/private-context non-persistence rules;
- handoff to `ticket-preflight-workflow`, `multi-agent-workflow`, `auto-maintain`, and future `ticket-orchestrator-routing`.
## Non-goals preserved
The workflow explicitly avoids:
- scheduling implementation;
- spawning coder/reviewer/investigator Pods;
- creating worktrees;
- merge/close/branch cleanup;
- unattended automation;
- user-agreement-free Ticket creation;
- arbitrary filesystem writes to `work-items/`.
## Review status
External sibling reviewer approved with no blockers.
Reviewer non-blocker about an old `work items` phrase in `docs/development/workflows.md` was fixed before commit.
Remaining follow-up:
- If a future first-class `TicketUpdate` tool is added, the existing-Ticket refinement path should decide when to use it versus `TicketComment`.
## Validation
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so `cargo check`, `cargo fmt`, and `nix build` were not necessary for this workflow/docs-only change.
---
<!-- event: close author: hare at: 2026-06-05T06:10:56Z status: closed -->
## Closed
Ticket Intake Workflow is complete.
Implementation:
- commit: `1233833 workflow: add ticket intake`
Summary:
- Added `.yoi/workflow/ticket-intake-workflow.md` as a user-invocable/model-invocable workflow.
- Updated `docs/development/workflows.md` to include Intake clarification and Ticket terminology.
- The workflow defines Intake as the clarification/materialization boundary before Orchestrator routing.
- It requires duplicate/related Ticket checks, readiness classification, needs-preflight/risk flags, draft presentation, and explicit user agreement before official Ticket creation.
- It uses typed Ticket tools (`TicketList`, `TicketShow`, `TicketCreate`, `TicketComment`, `TicketDoctor`) and forbids arbitrary filesystem edits to `work-items/`.
- It explicitly excludes scheduling, Pod spawning, worktree creation, merge/close, unattended automation, and user-agreement-free Ticket creation.
- It documents secret/private-context non-persistence rules.
Review:
- External sibling reviewer approved with no blockers.
- Reviewer non-blocker about old `work items` wording in `docs/development/workflows.md` was fixed before commit.
- Follow-up: if a future `TicketUpdate` tool is added, refine the existing-Ticket update path to choose between `TicketUpdate` and `TicketComment`.
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.
---

View File

@ -0,0 +1,68 @@
# Implementation report: ticket-orchestrator-routing
## Summary
Added the Ticket Orchestrator Routing workflow and lightly updated workflow development documentation.
The workflow defines Orchestrator as the routing authority after Intake materializes a Ticket. It requires Orchestrator to inspect Ticket fields/events/artifacts and relevant repository/Pod state explicitly, classify the next action, and record the routing decision back to the Ticket.
## Changed files
- `.yoi/workflow/ticket-orchestrator-routing.md`
- `docs/development/workflows.md`
## Routing classifications
The workflow defines the following routing outcomes:
- `requirements_sync_needed`
- `preflight_needed`
- `spike_needed`
- `implementation_ready`
- `review_needed`
- `blocked_action_required`
- `close_ready`
- `defer_pending`
- `closed_or_noop`
Each classification includes conditions and the expected next action.
## Key behavior
- Routing decisions are based on Ticket fields/events/artifacts plus explicitly inspected repository/Pod state, not hidden conversation state.
- `TicketComment` is used to record routing decisions.
- `implementation_ready` requires a concise `IntentPacket` before connecting to `multi-agent-workflow`.
- `preflight_needed` Tickets are not sent directly to coder Pods.
- `spike_needed` uses read-only investigation only after authorization.
- `review_needed` requires review/validation evidence before merge-ready handling.
- `close_ready` still requires close authority and a resolution.
## Non-goals preserved
The workflow explicitly does not introduce:
- unattended scheduler;
- LeaseStore / queue persistence;
- automatic Pod spawning policy;
- action-required dashboard UI;
- TicketUpdate tool;
- external tracker integration;
- arbitrary filesystem writes.
## Review status
External sibling reviewer approved with no blockers and no required non-blockers.
Reviewer follow-up note:
- A future pure classifier/test fixture may be useful after repeated routing decisions stabilize, but it is explicitly out of scope for this ticket.
## Validation
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.

View File

@ -0,0 +1,51 @@
# Review: ticket-orchestrator-routing
## 1. Result: approve
Approve. I found no blockers.
## 2. Summary
The new `ticket-orchestrator-routing` workflow satisfies the ticket scope as a workflow/resource change. It defines Orchestrator-owned routing responsibilities, keeps routing authority tied to Ticket records plus explicitly inspected repository/Pod state, and avoids introducing scheduler, lease, queue, dashboard, automatic spawning, or arbitrary filesystem-write behavior.
The implementation fits the current multi-agent direction: routing produces a recorded decision and, for implementation-ready Tickets, an `IntentPacket` that is handed to `multi-agent-workflow` rather than directly spawning coder Pods.
## 3. Requirement assessment
- **Ticket terminology:** Pass. The new workflow and docs use `Ticket` terminology and do not revive `WorkItem` / old-system wording.
- **Orchestrator responsibilities:** Pass. The workflow requires reading the Ticket body/thread/artifacts/status, inspecting relevant repository state and visible Pod state, classifying the next action, recording a routing decision through `TicketComment`, and creating an `IntentPacket` only for implementation-ready Tickets.
- **Non-goals:** Pass. The workflow explicitly excludes unattended scheduling, automatic Pod spawning, lease/queue persistence, TUI dashboard/action dashboard behavior, TicketUpdate mutation, external tracker integration, and arbitrary filesystem writes.
- **Routing classifications:** Pass. The workflow covers `requirements_sync_needed`, `preflight_needed`, `spike_needed`, `implementation_ready`, `review_needed`, `blocked_action_required`, `close_ready`, `defer_pending`, and `closed_or_noop`.
- **Conditions and actions:** Pass. Each classification has usable conditions and required next actions, including when to route back to intake/preflight, when to request spike work, when to review, when to block for human action, and when close/no-op is appropriate.
- **Preflight gate:** Pass. Preflight-needed Tickets are explicitly prohibited from going directly to coder Pods.
- **Implementation-ready handoff:** Pass. The implementation-ready path requires an `IntentPacket` and explicitly connects to `multi-agent-workflow` / worktree-based implementation.
- **Decision recording:** Pass. The `TicketComment` decision format is concrete and includes classification, evidence, inspected state, next workflow, blocking questions, and optional `IntentPacket` content.
- **Authority model:** Pass. The workflow bases routing on Ticket fields/events/artifacts plus explicitly inspected repository/Pod state, and rejects hidden conversation state as routing authority.
- **Docs update:** Pass. `docs/development/workflows.md` is small and accurately describes where the routing workflow fits among intake, preflight, worktree, multi-agent, and close/review flows.
- **Code scope:** Pass. No source-code change is needed for this ticket scope.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
None required for this ticket.
A future follow-up may be useful after the workflow is exercised: if repeated routing decisions stabilize, extract a pure classifier/test fixture. That is explicitly outside this ticket and should not block approval.
## 6. Validation assessed
- Read and assessed the Ticket record at `work-items/open/20260605-040104-ticket-orchestrator-routing/item.md`.
- Read and assessed the new workflow at `.yoi/workflow/ticket-orchestrator-routing.md`.
- Read and assessed the docs update at `docs/development/workflows.md`.
- Cross-checked related workflow boundaries against:
- `.yoi/workflow/ticket-intake-workflow.md`
- `.yoi/workflow/ticket-preflight-workflow.md`
- `.yoi/workflow/multi-agent-workflow.md`
- Checked repository status/diff shape sufficiently to confirm this review concerns workflow/docs/ticket material and does not require code validation.
- Did not run `nix build .#yoi` because this is a docs/workflow-only review with no code, packaging, runtime-resource, or prompt changes requiring a package build.
## 7. Residual risk
The main residual risk is operational, not a blocker: the workflow depends on Orchestrator discipline to record decisions and inspect state explicitly until any future classifier/tooling support exists. The written workflow is clear enough for that current manual/orchestrated use.

View File

@ -2,12 +2,12 @@
id: 20260605-040104-ticket-orchestrator-routing
slug: ticket-orchestrator-routing
title: Ticket orchestrator routing
status: open
status: closed
kind: task
priority: P1
labels: [ticket, orchestrator, routing, orchestration]
created_at: 2026-06-05T04:01:04Z
updated_at: 2026-06-05T04:01:25Z
updated_at: 2026-06-05T06:42:00Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,38 @@
Ticket Orchestrator Routing Workflow is complete.
Implementation:
- commit: `af17f8b workflow: add ticket orchestrator routing`
Summary:
- Added `.yoi/workflow/ticket-orchestrator-routing.md` as a user-invocable/model-invocable workflow.
- Updated `docs/development/workflows.md` to include Orchestrator routing from Tickets to next workflow/action.
- Defined routing classifications:
- `requirements_sync_needed`
- `preflight_needed`
- `spike_needed`
- `implementation_ready`
- `review_needed`
- `blocked_action_required`
- `close_ready`
- `defer_pending`
- `closed_or_noop`
- Defined conditions and actions for each classification.
- Required routing decisions to be recorded through `TicketComment`.
- Required `IntentPacket` creation before connecting implementation-ready Tickets to `multi-agent-workflow`.
- Kept preflight-needed Tickets from being sent directly to coder Pods.
- Preserved non-goals: no scheduler, no lease/queue persistence, no automatic Pod spawning policy, no TUI dashboard, no TicketUpdate tool, no external tracker integration, and no arbitrary filesystem writes.
Review:
- External sibling reviewer approved with no blockers and no required non-blockers.
- Follow-up note: after repeated routing decisions stabilize, a pure classifier/test fixture may be useful, but it is out of scope for this workflow ticket.
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.

View File

@ -0,0 +1,215 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-06-05T06:37:59Z -->
## Plan
Preflight result: `implementation-ready` as a workflow/resource implementation.
Scope chosen for this ticket:
- Add a project workflow that makes Orchestrator routing explicit and repeatable.
- Define routing classifications, required evidence, TicketComment decision format, and IntentPacket format.
- Connect routing outcomes to existing workflows: Ticket Intake, Ticket Preflight, Multi-agent Worktree Workflow, review, blocked/human action, and close-ready handling.
- Update workflow development docs to include Orchestrator routing.
Non-goals preserved:
- No code classifier in this ticket.
- No scheduler/lease/queue persistence.
- No automatic Pod spawning policy.
- No TUI/action-required dashboard.
- No TicketUpdate tool.
- No external tracker integration.
The routing workflow is intentionally testable as a decision table/checklist now; a pure Rust classifier can be split later if the workflow proves stable.
---
<!-- event: review author: hare at: 2026-06-05T06:41:33Z status: approve -->
## Review: approve
# Review: ticket-orchestrator-routing
## 1. Result: approve
Approve. I found no blockers.
## 2. Summary
The new `ticket-orchestrator-routing` workflow satisfies the ticket scope as a workflow/resource change. It defines Orchestrator-owned routing responsibilities, keeps routing authority tied to Ticket records plus explicitly inspected repository/Pod state, and avoids introducing scheduler, lease, queue, dashboard, automatic spawning, or arbitrary filesystem-write behavior.
The implementation fits the current multi-agent direction: routing produces a recorded decision and, for implementation-ready Tickets, an `IntentPacket` that is handed to `multi-agent-workflow` rather than directly spawning coder Pods.
## 3. Requirement assessment
- **Ticket terminology:** Pass. The new workflow and docs use `Ticket` terminology and do not revive `WorkItem` / old-system wording.
- **Orchestrator responsibilities:** Pass. The workflow requires reading the Ticket body/thread/artifacts/status, inspecting relevant repository state and visible Pod state, classifying the next action, recording a routing decision through `TicketComment`, and creating an `IntentPacket` only for implementation-ready Tickets.
- **Non-goals:** Pass. The workflow explicitly excludes unattended scheduling, automatic Pod spawning, lease/queue persistence, TUI dashboard/action dashboard behavior, TicketUpdate mutation, external tracker integration, and arbitrary filesystem writes.
- **Routing classifications:** Pass. The workflow covers `requirements_sync_needed`, `preflight_needed`, `spike_needed`, `implementation_ready`, `review_needed`, `blocked_action_required`, `close_ready`, `defer_pending`, and `closed_or_noop`.
- **Conditions and actions:** Pass. Each classification has usable conditions and required next actions, including when to route back to intake/preflight, when to request spike work, when to review, when to block for human action, and when close/no-op is appropriate.
- **Preflight gate:** Pass. Preflight-needed Tickets are explicitly prohibited from going directly to coder Pods.
- **Implementation-ready handoff:** Pass. The implementation-ready path requires an `IntentPacket` and explicitly connects to `multi-agent-workflow` / worktree-based implementation.
- **Decision recording:** Pass. The `TicketComment` decision format is concrete and includes classification, evidence, inspected state, next workflow, blocking questions, and optional `IntentPacket` content.
- **Authority model:** Pass. The workflow bases routing on Ticket fields/events/artifacts plus explicitly inspected repository/Pod state, and rejects hidden conversation state as routing authority.
- **Docs update:** Pass. `docs/development/workflows.md` is small and accurately describes where the routing workflow fits among intake, preflight, worktree, multi-agent, and close/review flows.
- **Code scope:** Pass. No source-code change is needed for this ticket scope.
## 4. Blockers
None.
## 5. Non-blockers / follow-ups
None required for this ticket.
A future follow-up may be useful after the workflow is exercised: if repeated routing decisions stabilize, extract a pure classifier/test fixture. That is explicitly outside this ticket and should not block approval.
## 6. Validation assessed
- Read and assessed the Ticket record at `work-items/open/20260605-040104-ticket-orchestrator-routing/item.md`.
- Read and assessed the new workflow at `.yoi/workflow/ticket-orchestrator-routing.md`.
- Read and assessed the docs update at `docs/development/workflows.md`.
- Cross-checked related workflow boundaries against:
- `.yoi/workflow/ticket-intake-workflow.md`
- `.yoi/workflow/ticket-preflight-workflow.md`
- `.yoi/workflow/multi-agent-workflow.md`
- Checked repository status/diff shape sufficiently to confirm this review concerns workflow/docs/ticket material and does not require code validation.
- Did not run `nix build .#yoi` because this is a docs/workflow-only review with no code, packaging, runtime-resource, or prompt changes requiring a package build.
## 7. Residual risk
The main residual risk is operational, not a blocker: the workflow depends on Orchestrator discipline to record decisions and inspect state explicitly until any future classifier/tooling support exists. The written workflow is clear enough for that current manual/orchestrated use.
---
<!-- event: implementation_report author: hare at: 2026-06-05T06:41:34Z -->
## Implementation report
# Implementation report: ticket-orchestrator-routing
## Summary
Added the Ticket Orchestrator Routing workflow and lightly updated workflow development documentation.
The workflow defines Orchestrator as the routing authority after Intake materializes a Ticket. It requires Orchestrator to inspect Ticket fields/events/artifacts and relevant repository/Pod state explicitly, classify the next action, and record the routing decision back to the Ticket.
## Changed files
- `.yoi/workflow/ticket-orchestrator-routing.md`
- `docs/development/workflows.md`
## Routing classifications
The workflow defines the following routing outcomes:
- `requirements_sync_needed`
- `preflight_needed`
- `spike_needed`
- `implementation_ready`
- `review_needed`
- `blocked_action_required`
- `close_ready`
- `defer_pending`
- `closed_or_noop`
Each classification includes conditions and the expected next action.
## Key behavior
- Routing decisions are based on Ticket fields/events/artifacts plus explicitly inspected repository/Pod state, not hidden conversation state.
- `TicketComment` is used to record routing decisions.
- `implementation_ready` requires a concise `IntentPacket` before connecting to `multi-agent-workflow`.
- `preflight_needed` Tickets are not sent directly to coder Pods.
- `spike_needed` uses read-only investigation only after authorization.
- `review_needed` requires review/validation evidence before merge-ready handling.
- `close_ready` still requires close authority and a resolution.
## Non-goals preserved
The workflow explicitly does not introduce:
- unattended scheduler;
- LeaseStore / queue persistence;
- automatic Pod spawning policy;
- action-required dashboard UI;
- TicketUpdate tool;
- external tracker integration;
- arbitrary filesystem writes.
## Review status
External sibling reviewer approved with no blockers and no required non-blockers.
Reviewer follow-up note:
- A future pure classifier/test fixture may be useful after repeated routing decisions stabilize, but it is explicitly out of scope for this ticket.
## Validation
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.
---
<!-- event: close author: hare at: 2026-06-05T06:42:00Z status: closed -->
## Closed
Ticket Orchestrator Routing Workflow is complete.
Implementation:
- commit: `af17f8b workflow: add ticket orchestrator routing`
Summary:
- Added `.yoi/workflow/ticket-orchestrator-routing.md` as a user-invocable/model-invocable workflow.
- Updated `docs/development/workflows.md` to include Orchestrator routing from Tickets to next workflow/action.
- Defined routing classifications:
- `requirements_sync_needed`
- `preflight_needed`
- `spike_needed`
- `implementation_ready`
- `review_needed`
- `blocked_action_required`
- `close_ready`
- `defer_pending`
- `closed_or_noop`
- Defined conditions and actions for each classification.
- Required routing decisions to be recorded through `TicketComment`.
- Required `IntentPacket` creation before connecting implementation-ready Tickets to `multi-agent-workflow`.
- Kept preflight-needed Tickets from being sent directly to coder Pods.
- Preserved non-goals: no scheduler, no lease/queue persistence, no automatic Pod spawning policy, no TUI dashboard, no TicketUpdate tool, no external tracker integration, and no arbitrary filesystem writes.
Review:
- External sibling reviewer approved with no blockers and no required non-blockers.
- Follow-up note: after repeated routing decisions stabilize, a pure classifier/test fixture may be useful, but it is out of scope for this workflow ticket.
Validation passed:
- `git diff --check`
- `./tickets.sh doctor`
- targeted grep found no `WorkItem` / old system-name wording in the new workflow.
No code/package changes were made, so cargo/nix validation was not required.
---

View File

@ -0,0 +1,144 @@
# Implementation report: ticket-config-role-profile-mapping
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping`
- Branch: `work/ticket-config-role-profile-mapping`
## Commits
- `767870a ticket: add workspace ticket config`
- `8fab67b ticket: reject nix profile selectors`
## Summary
Implemented `.yoi/ticket.config.toml` as workspace-local Ticket orchestration configuration with fixed Ticket role slots and wired the configured backend root into the existing Ticket built-in feature adapter.
The implementation keeps Ticket role configuration narrow:
- fixed roles only: `intake`, `orchestrator`, `coder`, `reviewer`, `investigator`;
- role fields: `profile`, optional `launch_prompt`, optional `workflow`;
- no `system_instruction` role field;
- durable role/system behavior remains owned by the selected Profile.
## Final module/API layout
Added `crates/ticket/src/config.rs`, exported as `ticket::config`.
Main public API:
- `TicketConfig`
- `load_workspace(workspace_root)`
- `default_for_workspace(workspace_root)`
- `backend_root()`
- `role(role)`
- `profile_for(role)`
- `launch_prompt_for(role)`
- `workflow_for(role)`
- `TicketBackendConfig`
- `TicketBackendKind`
- `TicketRole`
- `ProfileSelectorRef`
- `PromptRef`
- `WorkflowRef`
- `TicketConfigError`
- `TICKET_CONFIG_RELATIVE_PATH = ".yoi/ticket.config.toml"`
The `ticket` crate keeps lightweight string refs and does not depend on `pod` or `manifest`.
## Schema/defaults implemented
Config path:
```toml
.yoi/ticket.config.toml
```
Backend:
```toml
[backend]
kind = "local"
root = "work-items"
```
Role example:
```toml
[roles.coder]
profile = "project:coder"
launch_prompt = "$workspace/prompts/ticket-coder"
workflow = "multi-agent-workflow"
```
Defaults when the config file is missing:
- backend: local `<workspace>/work-items`;
- all role profiles: `inherit`;
- launch prompts: none;
- workflows:
- intake: `ticket-intake-workflow`;
- orchestrator: `ticket-orchestrator-routing`;
- coder: `multi-agent-workflow`;
- reviewer: `multi-agent-workflow`;
- investigator: `ticket-orchestrator-routing`.
Validation rejects unknown top-level fields, unknown backend fields, unknown role fields, unknown roles, unsupported backend kinds, malformed/empty refs, path-like profile selector values, `.lua`, and `.nix` profile selector values.
## Pod Ticket feature adapter wiring
Updated `crates/pod/src/feature/builtin/ticket.rs` so `TicketFeature::for_workspace(...)` loads `ticket::config::TicketConfig`.
Behavior:
- missing config uses documented defaults, preserving previous `<workspace>/work-items` behavior;
- valid config uses configured `[backend].root`;
- malformed config fails closed: Ticket tools are not registered and a feature diagnostic is emitted;
- missing/unusable backend root preserves existing no-register behavior;
- tool authority continues to use `HostAuthority::TicketBackend { root }` for the configured backend root.
## Changed files
- `Cargo.lock`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/ticket/Cargo.toml`
- `crates/ticket/src/config.rs`
- `crates/ticket/src/lib.rs`
- `package.nix`
## Review status
External sibling review initially requested one blocker fix:
- `ProfileSelectorRef` accepted `*.nix` profile selectors while existing `SpawnPod.profile` validation rejects them.
The blocker was fixed by commit `8fab67b` and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- `HostAuthority::TicketBackend { root }` is derived from the configured path while the actual backend uses a canonicalized usable root; future explicit grant/audit comparisons should normalize consistently.
- Pod adapter root usage could be strengthened with an execution-level tool test against the configured root.
## Validation
Coder-reported validation for the main implementation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Coder-reported validation for the blocker fix passed:
- `cargo test -p ticket config`
- `cargo test -p ticket`
- `cargo fmt --check`
- `git diff --check`
## Ready for merge
Yes.

View File

@ -0,0 +1,292 @@
# Investigation and plan: Ticket config role profile mapping
## Conclusion
Implement `.yoi/ticket.config.toml` as Ticket orchestration configuration with fixed Ticket role slots. Do not build a generic Role registry.
The initial implementation should parse/validate the config, provide defaults, expose role-to-profile and prompt/workflow references, and wire the configured backend root into the existing Ticket built-in feature adapter. Pod spawning, TUI actions, and workflow state should remain follow-up work.
## Design position
Use fixed Ticket roles:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
These are not arbitrary user-defined roles. They are the roles required by the Ticket feature/workflows.
Keep the boundary:
- Profile: Pod runtime recipe, including durable role behavior/system instruction when using role-specific profiles.
- Ticket role config: binds a fixed Ticket role to a Profile selector and optional launch prompt/workflow refs.
- Launch prompt: first committed task/user message for a concrete Ticket/action.
- Workflow: procedural flow, later possibly stateful.
## Current code map
### Ticket backend/tools
- `crates/ticket/src/lib.rs`
- Owns Ticket domain/backend and `LocalTicketBackend`.
- Current backend root is supplied by callers.
- `crates/ticket/src/tool.rs`
- Owns Ticket tool input/output and `llm_worker::Tool` implementations.
- `crates/pod/src/feature/builtin/ticket.rs`
- Thin built-in feature adapter.
- Currently resolves `<workspace>/work-items` directly.
- This is the first integration point for `.yoi/ticket.config.toml` backend root.
### Feature/host authority
- `crates/pod/src/feature.rs`
- Defines `HostAuthority::TicketBackend { root }`.
- Feature descriptor/install path already supports requested host authority and tool contribution wiring.
### Profile selection
- `crates/manifest/src/profile.rs`
- Owns Profile registry/selector resolution.
- `SpawnPod.profile` already accepts selectors such as `inherit`, default/source-qualified/unambiguous registry names.
- `crates/pod/src/spawn/tool.rs`
- Implements `SpawnPod` tool input with optional profile selector and `inherit` semantics.
- This should remain the actual spawning boundary; config should not duplicate full profile resolution behavior.
### Workflow resources
- `.yoi/workflow/*.md`
- Current workflow files are project-authored resources.
- `ticket-intake-workflow.md`, `ticket-orchestrator-routing.md`, `ticket-preflight-workflow.md`, and `multi-agent-workflow.md` are the relevant workflow refs.
- `crates/workflow/src/workflow.rs`
- Parses workflow frontmatter/body records.
- No stateful workflow runner exists yet.
### Prompt resources / launch prompts
- `crates/pod/src/prompt/loader.rs`
- Resolves instruction-file references like `$yoi/...` and `$user/...` for current startup/instruction use.
- Prompt catalog/resources are currently separate from workflow state.
- There is no implemented role-specific launch prompt engine yet.
- Role-specific durable system behavior should remain in the selected Profile for the MVP; this config should not override Profile system instruction.
## Important constraint
Do not make `ticket` depend on `pod`.
Possible dependency choices:
1. Put config parsing in `ticket` crate with raw profile/prompt/workflow string refs.
- Pros: Ticket config is close to Ticket backend concept.
- Cons: `ticket` learns about profile/prompt/workflow reference strings, but not their runtime resolution.
2. Put config parsing in `pod`.
- Pros: avoids exposing prompt/profile concepts from `ticket`.
- Cons: Ticket config becomes less reusable by future CLI/TUI code unless those crates also depend on `pod`.
Recommended MVP:
- Add config domain/parser to `crates/ticket`, using lightweight string wrapper types such as `ProfileSelectorRef`, `PromptRef`, and `WorkflowRef` without depending on `manifest` or `pod`.
- In the MVP, `PromptRef` is for launch prompts only. Do not add role-level `system_instruction` here; the selected Profile owns durable role system behavior.
- `pod` consumes this config and performs runtime interpretation where needed.
This preserves:
```text
ticket -> llm-worker / serde / toml only
pod -> ticket
```
and avoids:
```text
ticket -> pod
```
## Proposed schema
```toml
[backend]
kind = "local"
root = "work-items"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
[roles.investigator]
profile = "inherit"
launch_prompt = "$workspace/ticket/investigator/launch"
workflow = "ticket-orchestrator-routing"
```
The specific prompt ref syntax should be accepted as opaque strings in this ticket. Runtime prompt resolution belongs to the later role launcher.
## Defaults
When `.yoi/ticket.config.toml` is missing:
- backend kind: `local`
- backend root: `work-items`
- role profiles: `inherit`
- workflow defaults:
- intake: `ticket-intake-workflow`
- orchestrator: `ticket-orchestrator-routing`
- coder: `multi-agent-workflow`
- reviewer: `multi-agent-workflow`
- investigator: `ticket-orchestrator-routing`
- launch prompt: none
When a role section exists but omits optional prompt/workflow refs:
- keep configured profile;
- fill workflow default for the fixed role;
- leave prompt refs as none.
## Implementation plan
### Phase 1: Config model/parser in `ticket`
Add a module such as `crates/ticket/src/config.rs`.
Types:
```rust
pub struct TicketConfig {
pub backend: TicketBackendConfig,
pub roles: TicketRoleProfiles,
}
pub struct TicketBackendConfig {
pub kind: TicketBackendKind,
pub root: PathBuf,
}
pub enum TicketBackendKind {
Local,
}
pub enum TicketRole {
Intake,
Orchestrator,
Coder,
Reviewer,
Investigator,
}
pub struct TicketRoleProfile {
pub profile: ProfileSelectorRef,
pub launch_prompt: Option<PromptRef>,
pub workflow: WorkflowRef,
}
```
Use string wrapper types for selectors/refs to avoid depending on `manifest`/`pod`.
Parsing behavior:
- `TicketConfig::load_workspace(workspace_root: &Path)` reads `.yoi/ticket.config.toml` if present.
- Missing file returns defaults.
- Relative backend root resolves against workspace root.
- Unknown roles are errors.
- Unknown top-level fields should be diagnostics/errors rather than silently ignored.
- Backend kind supports only `local` for now.
### Phase 2: Wire backend root into Pod Ticket feature adapter
Update `crates/pod/src/feature/builtin/ticket.rs`:
- Load `TicketConfig` from workspace root.
- Use `config.backend.root` instead of hard-coded `workspace/work-items`.
- Preserve current fail-closed behavior if root is missing/unusable.
- Keep `HostAuthority::TicketBackend { root }` consistent with the validated/canonical root where practical.
This directly improves existing Ticket tools without introducing role spawning yet.
### Phase 3: Tests
Ticket crate tests:
- missing config -> defaults;
- full config parses;
- partial role config uses role workflow defaults;
- unknown role rejects;
- unsupported backend kind rejects;
- relative backend root resolves against workspace;
- malformed profile/ref diagnostics are bounded.
Pod tests:
- Ticket built-in feature uses configured backend root;
- missing/unusable configured backend root does not register tools;
- default missing config still uses `<workspace>/work-items`.
### Phase 4: Documentation/example
Add one of:
- a short `.yoi/ticket.config.example.toml`, or
- a documented snippet under the ticket implementation report / docs if adding tracked config now is too early.
For this repository, adding actual `.yoi/ticket.config.toml` should be considered carefully. If added, defaults should likely use `inherit` profiles until dedicated profiles exist.
## Deferred follow-ups
### `ticket-role-pod-launcher`
- Take TicketRole + Ticket context + role config.
- Build `SpawnPod` requests.
- Resolve selected Profile using existing Profile registry; role-specific system behavior comes from that Profile.
- Resolve launch prompt separately from the selected Profile's system instruction.
- Commit launch prompt as the first user/task message, not hidden context.
- Include workflow ref in launch/task context.
### `tui-ticket-role-actions`
- Add TUI actions for fixed Ticket roles:
- Intake/refine Ticket;
- Route Ticket;
- Investigate;
- Implement;
- Review.
- Use the launcher rather than building SpawnPod requests inside UI code.
### Stateful workflow engine
- Persist workflow phase/state.
- Gate allowed tools by phase.
- Inject phase prompts only by committing them to history first.
- Keep Profile/SystemInstruction role-stable and task/phase prompts dynamic.
## Validation for implementation
Required:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
Optional if feasible:
- `nix build .#yoi --no-link`

View File

@ -0,0 +1,91 @@
# External review: ticket-config-role-profile-mapping
## 1. Result: request changes
Request changes. The implementation is otherwise close to the ticket, but one validation gap fails the requested alignment with existing `SpawnPod.profile` selector rules and should be fixed before merge.
## 2. Summary of implementation
The coder commit `767870a4fbf12f942a8b270e1cc316d7f35d3ef6` adds `crates/ticket/src/config.rs` and exports it from the `ticket` crate. The new parser reads `.yoi/ticket.config.toml`, defaults missing config to `<workspace>/work-items` plus fixed role defaults, models the fixed roles `intake`, `orchestrator`, `coder`, `reviewer`, and `investigator`, and stores profile / launch prompt / workflow references as lightweight strings without introducing `pod` or `manifest` dependencies.
The Pod built-in Ticket feature now loads `TicketConfig` from the Pod working directory, uses the configured backend root for `LocalTicketBackend`, and refuses to register Ticket tools when the config is malformed or the backend root is unusable. The implementation does not add Pod spawning, TUI actions, workflow state, system-instruction overlays, role registries, external trackers, or scheduler behavior.
## 3. Requirement-by-requirement assessment
- `.yoi/ticket.config.toml` path and schema: mostly satisfied. The parser uses the fixed path `.yoi/ticket.config.toml`, supports `[backend] kind/root`, and uses fixed `[roles.<role>]` sections with `profile`, optional `launch_prompt`, and optional `workflow`.
- Fixed roles only: satisfied. Unknown role names are rejected during config resolution.
- No `system_instruction` role field: satisfied. `deny_unknown_fields` rejects it and a test checks this.
- Missing config defaults: satisfied. Missing file returns local backend `<workspace>/work-items`, all role profiles `inherit`, no launch prompts, and the documented workflow defaults.
- Relative backend roots: satisfied. Relative roots are joined to the workspace root.
- Backend directories not auto-created: satisfied in the Pod adapter path. The adapter canonicalizes/checks the root and required `open/`, `pending/`, and `closed/` directories before registering tools.
- Unknown roles/fields and malformed refs: mostly satisfied, but see blocker below for an accepted path-like profile selector that `SpawnPod.profile` rejects.
- Crate dependency boundary: satisfied. `ticket` adds `toml` but does not depend on `pod` or `manifest`; profile/prompt/workflow refs remain string wrappers.
- Pod adapter configured root / fail-closed behavior: satisfied. Config parse errors and unusable roots produce diagnostics and no Ticket tools are registered.
- HostAuthority root consistency: acceptable but imperfect. The backend uses the canonicalized usable root, while `HostAuthority::TicketBackend { root }` is built from the pre-canonicalized configured path; see follow-up.
- Explicit non-goals: satisfied. I found no added Pod spawning, TUI action, workflow engine, prompt injection, Profile semantic change, `system_instruction` overlay, arbitrary role registry, storage rename, external tracker, or scheduler work.
- `Cargo.lock` / `package.nix`: changes are limited to adding the existing workspace `toml` dependency to `ticket` and updating the Nix cargo hash. That is necessary and looks safe.
- Tests: broadly cover missing/full/partial config, unknown role/field, relative root, unsupported backend kind, malformed profile path, and Pod adapter root/no-register behavior. They do not cover the blocker case below.
## 4. Blockers
1. `ProfileSelectorRef` accepts `legacy.nix`/`*.nix` as a valid role profile selector, but `SpawnPod.profile` explicitly rejects `*.nix` as path-like.
The ticket requires role profile selector syntax to stay aligned with existing `SpawnPod/profile` selectors where possible, and the review checklist asks that malformed refs be rejected or clearly reported. `crates/pod/src/spawn/tool.rs` rejects path-like profile values including `legacy.nix`, while `crates/ticket/src/config.rs` currently rejects `path:`, dot-prefixed values, values containing `/`, and `*.lua`, but not `*.nix`. Because role config values are meant to be later usable by role launch code, accepting a selector that the existing launch boundary rejects is a config-validation failure.
Expected fix: reject `*.nix` in `ProfileSelectorRef::new` and add a focused test alongside the existing malformed ref test.
## 5. Non-blockers / follow-ups
- `HostAuthority::TicketBackend { root }` is derived from `self.backend_root.display()` before canonicalization, while the actual `LocalTicketBackend` is built from `usable_root` after `canonicalize()`. This can make the granted/audited authority root differ from the root used by tools when the configured path includes `..` components or symlinks. The current implementation still requires matching host authority on the contributed tools and fail-closes on unusable roots, so I am not blocking on it, but the adapter should prefer a validated/canonical authority root where practical.
- The Pod adapter test for configured backend root checks feature root selection and tool registration count. It does not execute a tool against the configured root. The code path is straightforward (`LocalTicketBackend::new(usable_root)`), so this is acceptable, but an execution-level regression test would be stronger.
## 6. Validation assessed or rerun
Rerun/read-only checks:
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --stat develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --name-status develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --check develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline --decorate 767870a4fbf12f942a8b270e1cc316d7f35d3ef6`
Assessed by inspection:
- Ticket requirements, investigation/plan, and thread.
- `crates/ticket/src/config.rs`
- `crates/ticket/src/lib.rs`
- `crates/ticket/Cargo.toml`
- `crates/pod/src/feature/builtin/ticket.rs`
- `Cargo.lock`
- `package.nix`
- Relevant existing `SpawnPod.profile` selector validation in `crates/pod/src/spawn/tool.rs`.
Not rerun: `cargo test`, `cargo check`, `cargo fmt --check`, `./tickets.sh doctor`, or `nix build`. The review request allowed focused read-only validation, and rerunning these would write build/test artifacts outside the review artifact path in this scoped sibling review.
## 7. Residual risk
After the `*.nix` selector rejection is fixed, residual risk is mainly around future launch integration: prompt/workflow refs are intentionally lightweight strings and will need runtime validation when the role launcher resolves them. The configured backend root is wired into the current Ticket tools, but authority-root canonicalization should be tightened before relying on HostAuthority root strings for security/audit semantics beyond this feature gate.
---
## Re-review of blocker fix: 8fab67b
### Result: approve
The blocker is resolved, and I found no new blocker in the focused fix commit.
### Assessment
- `ProfileSelectorRef::new` now rejects values ending in `.nix` alongside other path-like selectors (`path:`, dot-prefixed selectors, slash-containing selectors, and `.lua`). This aligns the Ticket role profile config validation with the existing `SpawnPod.profile` path-selector rejection boundary for the reported case.
- A focused test, `nix_profile_selector_refs_are_rejected`, was added for `profile = "legacy.nix"` and asserts that the config load fails with the path-selector rejection message.
- The fix is limited to `crates/ticket/src/config.rs` and does not introduce source-boundary, runtime behavior, dependency, or scope expansion changes.
### Validation assessed
Rerun/read-only checks:
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline HEAD && git diff develop...HEAD -- crates/ticket/src/config.rs`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline HEAD && git show --unified=8 -- crates/ticket/src/config.rs`
### Blockers
None.

View File

@ -0,0 +1,153 @@
---
id: 20260605-173322-ticket-config-role-profile-mapping
slug: ticket-config-role-profile-mapping
title: Ticket config role profile mapping
status: closed
kind: task
priority: P1
labels: [ticket, config, profile, orchestration]
created_at: 2026-06-05T17:33:22Z
updated_at: 2026-06-05T18:48:15Z
assignee: null
legacy_ticket: null
---
## Background
Ticket orchestration now has typed Ticket backend/tools and workflows for Intake and Orchestrator routing. The next step before TUI role actions is to make the workspace's Ticket orchestration configuration explicit.
The project should not introduce an arbitrary Role registry. The roles needed here are fixed by the Ticket feature/workflows:
- intake
- orchestrator
- coder
- reviewer
- investigator
Each fixed role needs to select a Profile and, later, a first launch prompt and workflow binding. Role-specific durable behavior should live in the selected Profile, not in this config file. This is Ticket orchestration configuration, not a generic Profile replacement.
## Goal
Add workspace-local Ticket configuration at `.yoi/ticket.config.toml` and a typed parser/resolver for fixed Ticket role profile mappings.
The MVP should establish the configuration file, fixed role schema, backend root configuration, validation, and role-to-profile selector resolution. It should not yet spawn Pods or add TUI actions.
## Requirements
- Add typed Ticket orchestration config support for `.yoi/ticket.config.toml`.
- Keep roles fixed, not arbitrary:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Support backend configuration:
- local backend kind;
- root path, defaulting to `work-items` relative to the workspace.
- Support per-role configuration:
- `profile` selector string;
- optional launch/initial prompt reference;
- optional workflow slug/reference.
- Keep `profile` selector syntax aligned with existing SpawnPod/profile selectors where possible:
- `inherit`
- `default`
- `builtin:<name>`
- `user:<name>`
- `project:<name>`
- unqualified registry selector when accepted by existing profile resolution.
- Preserve the conceptual separation:
- Profile = Pod runtime recipe, including durable role behavior/system instruction when using role-specific profiles.
- launch prompt = first committed task/user message for a specific Ticket/action.
- workflow = procedural flow, later potentially stateful.
- Validate known fields and reject/diagnose unknown roles or malformed fields.
- Resolve relative backend roots against workspace root.
- Do not auto-create backend directories in this ticket.
- Update the existing Ticket built-in feature adapter to use the configured backend root when available, falling back to `work-items`.
- Expose a reusable resolver API for later Pod launch/TUI code:
- role -> profile selector;
- role -> optional launch prompt ref;
- role -> optional workflow slug;
- backend root.
## Non-goals
- Arbitrary role registry.
- Pod spawning or role launcher implementation.
- TUI action implementation.
- Stateful workflow engine.
- Per-phase workflow prompt injection.
- Changing Profile authoring/resolution semantics.
- Replacing `profiles.toml`.
- Renaming `work-items/`.
- External tracker integration.
- Scheduler/lease/queue automation.
## Suggested schema
```toml
[backend]
kind = "local"
root = "work-items"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
[roles.investigator]
profile = "inherit"
launch_prompt = "$workspace/ticket/investigator/launch"
workflow = "ticket-orchestrator-routing"
```
MVP may make all role fields optional except `profile` when a role section is present. Missing file and missing role sections should fall back to builtin defaults.
## Default behavior
When `.yoi/ticket.config.toml` is absent:
- backend kind: local
- backend root: `<workspace>/work-items`
- all role profiles: `inherit`
- workflow defaults:
- intake: `ticket-intake-workflow`
- orchestrator: `ticket-orchestrator-routing`
- coder: `multi-agent-workflow`
- reviewer: `multi-agent-workflow`
- investigator: `ticket-orchestrator-routing`
- launch prompt refs: none
## Acceptance criteria
- `.yoi/ticket.config.toml` can be parsed from a workspace root.
- Missing config falls back to documented defaults.
- Fixed role sections parse correctly.
- Unknown roles are rejected or reported as configuration errors.
- Relative backend root resolves against workspace root.
- Backend root from config is used by the Ticket built-in feature adapter.
- Role profile selector strings are retained/parsed in a form later usable by role launching code.
- Optional `launch_prompt` and `workflow` refs are parsed and exposed without trying to run a workflow engine.
- Tests cover missing config, full config, partial role config, unknown role, relative backend root, and adapter backend-root usage.
- `cargo test -p ticket` and focused `cargo test -p pod ticket --lib` pass.
- `cargo check --workspace --all-targets`, `cargo fmt --check`, `git diff --check`, and `./tickets.sh doctor` pass.
## Follow-up tickets
- `ticket-role-pod-launcher`: construct role-specific `SpawnPod` requests from Ticket context, role config, selected Profile, launch prompt, workflow binding, and scope policy.
- `tui-ticket-role-actions`: expose fixed Ticket role actions in TUI using the launcher.
- Later workflow-state engine: persisted workflow phase/state, phase-specific allowed tools, and phase prompts committed to history before model use.

View File

@ -0,0 +1,53 @@
Ticket config role profile mapping is complete and merged.
Implementation:
- `767870a ticket: add workspace ticket config`
- `8fab67b ticket: reject nix profile selectors`
- merge commit: `9910df4 merge: add ticket config roles`
Summary:
- Added `.yoi/ticket.config.toml` support through `crates/ticket/src/config.rs`.
- Added fixed Ticket roles only:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Added role config fields:
- `profile`
- optional `launch_prompt`
- optional `workflow`
- Did not add role-level `system_instruction`; durable role/system behavior remains owned by the selected Profile.
- Added backend config for local Ticket storage:
- `kind = "local"`
- `root = "work-items"`
- Missing config defaults to local `<workspace>/work-items`, all role profiles `inherit`, no launch prompts, and documented workflow defaults.
- Unknown roles/fields, unsupported backend kinds, malformed refs, path-like profile refs, `.lua`, and `.nix` profile refs are rejected.
- Wired the configured backend root into `crates/pod/src/feature/builtin/ticket.rs`.
- Preserved fail-closed/no-register behavior for malformed config or unusable backend roots.
- Kept dependency direction clean: `pod -> ticket`; `ticket` does not depend on `pod` or `manifest`.
Review:
- External sibling review initially requested one blocker fix: reject `*.nix` profile selectors to align with `SpawnPod.profile` validation.
- Coder fixed it in `8fab67b` and added a focused `legacy.nix` rejection test.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Normalize `HostAuthority::TicketBackend { root }` and canonical backend root consistently before relying on root strings for stricter security/audit comparison.
- Add an execution-level Pod adapter test that runs a Ticket tool against the configured backend root.
- Later role launcher should resolve Profile selectors/prompt refs/workflow refs at runtime and commit launch prompts as user/task messages.
Post-merge validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`

View File

@ -0,0 +1,382 @@
<!-- event: create author: tickets.sh at: 2026-06-05T17:33:22Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-06-05T17:35:08Z -->
## Decision
Decision: implement `.yoi/ticket.config.toml` as Ticket orchestration configuration with fixed Ticket role slots.
Use fixed roles, not an arbitrary Role registry:
- intake
- orchestrator
- coder
- reviewer
- investigator
The config maps these fixed Ticket roles to Profile selector strings and optional role system instruction / launch prompt / workflow refs. This keeps Profile as the Pod runtime recipe while Ticket orchestration owns the role-to-profile binding.
The first implementation should parse/validate config and wire the configured backend root into Ticket tools. It should not spawn Pods, add TUI actions, or implement a stateful workflow engine yet.
Detailed investigation and implementation plan: `artifacts/investigation-plan.md`.
---
<!-- event: plan author: hare at: 2026-06-05T17:35:08Z -->
## Plan
Plan:
1. Add Ticket config model/parser, probably in `crates/ticket/src/config.rs`, using lightweight string wrapper types for Profile/prompt/workflow refs so `ticket` does not depend on `pod`.
2. Parse `.yoi/ticket.config.toml` from a workspace root, with defaults when missing.
3. Support `[backend]` local root and fixed `[roles.*]` sections.
4. Wire the configured backend root into `crates/pod/src/feature/builtin/ticket.rs` so Ticket tools no longer hard-code `<workspace>/work-items`.
5. Add focused tests in `ticket` and `pod`.
6. Defer Pod role launching, TUI actions, and workflow-state/prompt sequencing to follow-up tickets.
---
<!-- event: decision author: hare at: 2026-06-05T18:15:02Z -->
## Decision
Decision update: remove `system_instruction` from the MVP `.yoi/ticket.config.toml` role schema.
Rationale:
- The selected Profile already owns durable role behavior and system/instruction configuration.
- Adding `system_instruction` to Ticket role config would create an unclear override/merge boundary with Profile.
- Ticket role config should bind fixed Ticket roles to Profile selectors, workflow refs, and launch prompts only.
- Launch prompts remain useful here because they are per-action first committed task/user messages, unlike role-stable system behavior.
Updated MVP role fields:
- `profile`
- optional `launch_prompt`
- optional `workflow`
If system-level role overlays are needed later, design them explicitly as a separate prompt/context ticket, likely with an overlay name and clear history/cache semantics. Do not add an implicit system instruction override in this ticket.
---
<!-- event: review author: hare at: 2026-06-05T18:45:41Z status: approve -->
## Review: approve
# External review: ticket-config-role-profile-mapping
## 1. Result: request changes
Request changes. The implementation is otherwise close to the ticket, but one validation gap fails the requested alignment with existing `SpawnPod.profile` selector rules and should be fixed before merge.
## 2. Summary of implementation
The coder commit `767870a4fbf12f942a8b270e1cc316d7f35d3ef6` adds `crates/ticket/src/config.rs` and exports it from the `ticket` crate. The new parser reads `.yoi/ticket.config.toml`, defaults missing config to `<workspace>/work-items` plus fixed role defaults, models the fixed roles `intake`, `orchestrator`, `coder`, `reviewer`, and `investigator`, and stores profile / launch prompt / workflow references as lightweight strings without introducing `pod` or `manifest` dependencies.
The Pod built-in Ticket feature now loads `TicketConfig` from the Pod working directory, uses the configured backend root for `LocalTicketBackend`, and refuses to register Ticket tools when the config is malformed or the backend root is unusable. The implementation does not add Pod spawning, TUI actions, workflow state, system-instruction overlays, role registries, external trackers, or scheduler behavior.
## 3. Requirement-by-requirement assessment
- `.yoi/ticket.config.toml` path and schema: mostly satisfied. The parser uses the fixed path `.yoi/ticket.config.toml`, supports `[backend] kind/root`, and uses fixed `[roles.<role>]` sections with `profile`, optional `launch_prompt`, and optional `workflow`.
- Fixed roles only: satisfied. Unknown role names are rejected during config resolution.
- No `system_instruction` role field: satisfied. `deny_unknown_fields` rejects it and a test checks this.
- Missing config defaults: satisfied. Missing file returns local backend `<workspace>/work-items`, all role profiles `inherit`, no launch prompts, and the documented workflow defaults.
- Relative backend roots: satisfied. Relative roots are joined to the workspace root.
- Backend directories not auto-created: satisfied in the Pod adapter path. The adapter canonicalizes/checks the root and required `open/`, `pending/`, and `closed/` directories before registering tools.
- Unknown roles/fields and malformed refs: mostly satisfied, but see blocker below for an accepted path-like profile selector that `SpawnPod.profile` rejects.
- Crate dependency boundary: satisfied. `ticket` adds `toml` but does not depend on `pod` or `manifest`; profile/prompt/workflow refs remain string wrappers.
- Pod adapter configured root / fail-closed behavior: satisfied. Config parse errors and unusable roots produce diagnostics and no Ticket tools are registered.
- HostAuthority root consistency: acceptable but imperfect. The backend uses the canonicalized usable root, while `HostAuthority::TicketBackend { root }` is built from the pre-canonicalized configured path; see follow-up.
- Explicit non-goals: satisfied. I found no added Pod spawning, TUI action, workflow engine, prompt injection, Profile semantic change, `system_instruction` overlay, arbitrary role registry, storage rename, external tracker, or scheduler work.
- `Cargo.lock` / `package.nix`: changes are limited to adding the existing workspace `toml` dependency to `ticket` and updating the Nix cargo hash. That is necessary and looks safe.
- Tests: broadly cover missing/full/partial config, unknown role/field, relative root, unsupported backend kind, malformed profile path, and Pod adapter root/no-register behavior. They do not cover the blocker case below.
## 4. Blockers
1. `ProfileSelectorRef` accepts `legacy.nix`/`*.nix` as a valid role profile selector, but `SpawnPod.profile` explicitly rejects `*.nix` as path-like.
The ticket requires role profile selector syntax to stay aligned with existing `SpawnPod/profile` selectors where possible, and the review checklist asks that malformed refs be rejected or clearly reported. `crates/pod/src/spawn/tool.rs` rejects path-like profile values including `legacy.nix`, while `crates/ticket/src/config.rs` currently rejects `path:`, dot-prefixed values, values containing `/`, and `*.lua`, but not `*.nix`. Because role config values are meant to be later usable by role launch code, accepting a selector that the existing launch boundary rejects is a config-validation failure.
Expected fix: reject `*.nix` in `ProfileSelectorRef::new` and add a focused test alongside the existing malformed ref test.
## 5. Non-blockers / follow-ups
- `HostAuthority::TicketBackend { root }` is derived from `self.backend_root.display()` before canonicalization, while the actual `LocalTicketBackend` is built from `usable_root` after `canonicalize()`. This can make the granted/audited authority root differ from the root used by tools when the configured path includes `..` components or symlinks. The current implementation still requires matching host authority on the contributed tools and fail-closes on unusable roots, so I am not blocking on it, but the adapter should prefer a validated/canonical authority root where practical.
- The Pod adapter test for configured backend root checks feature root selection and tool registration count. It does not execute a tool against the configured root. The code path is straightforward (`LocalTicketBackend::new(usable_root)`), so this is acceptable, but an execution-level regression test would be stronger.
## 6. Validation assessed or rerun
Rerun/read-only checks:
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --stat develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --name-status develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git diff --check develop...HEAD`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline --decorate 767870a4fbf12f942a8b270e1cc316d7f35d3ef6`
Assessed by inspection:
- Ticket requirements, investigation/plan, and thread.
- `crates/ticket/src/config.rs`
- `crates/ticket/src/lib.rs`
- `crates/ticket/Cargo.toml`
- `crates/pod/src/feature/builtin/ticket.rs`
- `Cargo.lock`
- `package.nix`
- Relevant existing `SpawnPod.profile` selector validation in `crates/pod/src/spawn/tool.rs`.
Not rerun: `cargo test`, `cargo check`, `cargo fmt --check`, `./tickets.sh doctor`, or `nix build`. The review request allowed focused read-only validation, and rerunning these would write build/test artifacts outside the review artifact path in this scoped sibling review.
## 7. Residual risk
After the `*.nix` selector rejection is fixed, residual risk is mainly around future launch integration: prompt/workflow refs are intentionally lightweight strings and will need runtime validation when the role launcher resolves them. The configured backend root is wired into the current Ticket tools, but authority-root canonicalization should be tightened before relying on HostAuthority root strings for security/audit semantics beyond this feature gate.
---
## Re-review of blocker fix: 8fab67b
### Result: approve
The blocker is resolved, and I found no new blocker in the focused fix commit.
### Assessment
- `ProfileSelectorRef::new` now rejects values ending in `.nix` alongside other path-like selectors (`path:`, dot-prefixed selectors, slash-containing selectors, and `.lua`). This aligns the Ticket role profile config validation with the existing `SpawnPod.profile` path-selector rejection boundary for the reported case.
- A focused test, `nix_profile_selector_refs_are_rejected`, was added for `profile = "legacy.nix"` and asserts that the config load fails with the path-selector rejection message.
- The fix is limited to `crates/ticket/src/config.rs` and does not introduce source-boundary, runtime behavior, dependency, or scope expansion changes.
### Validation assessed
Rerun/read-only checks:
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline HEAD && git diff develop...HEAD -- crates/ticket/src/config.rs`
- `cd /home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping && git show --stat --oneline HEAD && git show --unified=8 -- crates/ticket/src/config.rs`
### Blockers
None.
---
<!-- event: implementation_report author: hare at: 2026-06-05T18:45:41Z -->
## Implementation report
# Implementation report: ticket-config-role-profile-mapping
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-config-role-profile-mapping`
- Branch: `work/ticket-config-role-profile-mapping`
## Commits
- `767870a ticket: add workspace ticket config`
- `8fab67b ticket: reject nix profile selectors`
## Summary
Implemented `.yoi/ticket.config.toml` as workspace-local Ticket orchestration configuration with fixed Ticket role slots and wired the configured backend root into the existing Ticket built-in feature adapter.
The implementation keeps Ticket role configuration narrow:
- fixed roles only: `intake`, `orchestrator`, `coder`, `reviewer`, `investigator`;
- role fields: `profile`, optional `launch_prompt`, optional `workflow`;
- no `system_instruction` role field;
- durable role/system behavior remains owned by the selected Profile.
## Final module/API layout
Added `crates/ticket/src/config.rs`, exported as `ticket::config`.
Main public API:
- `TicketConfig`
- `load_workspace(workspace_root)`
- `default_for_workspace(workspace_root)`
- `backend_root()`
- `role(role)`
- `profile_for(role)`
- `launch_prompt_for(role)`
- `workflow_for(role)`
- `TicketBackendConfig`
- `TicketBackendKind`
- `TicketRole`
- `ProfileSelectorRef`
- `PromptRef`
- `WorkflowRef`
- `TicketConfigError`
- `TICKET_CONFIG_RELATIVE_PATH = ".yoi/ticket.config.toml"`
The `ticket` crate keeps lightweight string refs and does not depend on `pod` or `manifest`.
## Schema/defaults implemented
Config path:
```toml
.yoi/ticket.config.toml
```
Backend:
```toml
[backend]
kind = "local"
root = "work-items"
```
Role example:
```toml
[roles.coder]
profile = "project:coder"
launch_prompt = "$workspace/prompts/ticket-coder"
workflow = "multi-agent-workflow"
```
Defaults when the config file is missing:
- backend: local `<workspace>/work-items`;
- all role profiles: `inherit`;
- launch prompts: none;
- workflows:
- intake: `ticket-intake-workflow`;
- orchestrator: `ticket-orchestrator-routing`;
- coder: `multi-agent-workflow`;
- reviewer: `multi-agent-workflow`;
- investigator: `ticket-orchestrator-routing`.
Validation rejects unknown top-level fields, unknown backend fields, unknown role fields, unknown roles, unsupported backend kinds, malformed/empty refs, path-like profile selector values, `.lua`, and `.nix` profile selector values.
## Pod Ticket feature adapter wiring
Updated `crates/pod/src/feature/builtin/ticket.rs` so `TicketFeature::for_workspace(...)` loads `ticket::config::TicketConfig`.
Behavior:
- missing config uses documented defaults, preserving previous `<workspace>/work-items` behavior;
- valid config uses configured `[backend].root`;
- malformed config fails closed: Ticket tools are not registered and a feature diagnostic is emitted;
- missing/unusable backend root preserves existing no-register behavior;
- tool authority continues to use `HostAuthority::TicketBackend { root }` for the configured backend root.
## Changed files
- `Cargo.lock`
- `crates/pod/src/feature/builtin/ticket.rs`
- `crates/ticket/Cargo.toml`
- `crates/ticket/src/config.rs`
- `crates/ticket/src/lib.rs`
- `package.nix`
## Review status
External sibling review initially requested one blocker fix:
- `ProfileSelectorRef` accepted `*.nix` profile selectors while existing `SpawnPod.profile` validation rejects them.
The blocker was fixed by commit `8fab67b` and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- `HostAuthority::TicketBackend { root }` is derived from the configured path while the actual backend uses a canonicalized usable root; future explicit grant/audit comparisons should normalize consistently.
- Pod adapter root usage could be strengthened with an execution-level tool test against the configured root.
## Validation
Coder-reported validation for the main implementation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Coder-reported validation for the blocker fix passed:
- `cargo test -p ticket config`
- `cargo test -p ticket`
- `cargo fmt --check`
- `git diff --check`
## Ready for merge
Yes.
---
<!-- event: close author: hare at: 2026-06-05T18:48:15Z status: closed -->
## Closed
Ticket config role profile mapping is complete and merged.
Implementation:
- `767870a ticket: add workspace ticket config`
- `8fab67b ticket: reject nix profile selectors`
- merge commit: `9910df4 merge: add ticket config roles`
Summary:
- Added `.yoi/ticket.config.toml` support through `crates/ticket/src/config.rs`.
- Added fixed Ticket roles only:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Added role config fields:
- `profile`
- optional `launch_prompt`
- optional `workflow`
- Did not add role-level `system_instruction`; durable role/system behavior remains owned by the selected Profile.
- Added backend config for local Ticket storage:
- `kind = "local"`
- `root = "work-items"`
- Missing config defaults to local `<workspace>/work-items`, all role profiles `inherit`, no launch prompts, and documented workflow defaults.
- Unknown roles/fields, unsupported backend kinds, malformed refs, path-like profile refs, `.lua`, and `.nix` profile refs are rejected.
- Wired the configured backend root into `crates/pod/src/feature/builtin/ticket.rs`.
- Preserved fail-closed/no-register behavior for malformed config or unusable backend roots.
- Kept dependency direction clean: `pod -> ticket`; `ticket` does not depend on `pod` or `manifest`.
Review:
- External sibling review initially requested one blocker fix: reject `*.nix` profile selectors to align with `SpawnPod.profile` validation.
- Coder fixed it in `8fab67b` and added a focused `legacy.nix` rejection test.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Normalize `HostAuthority::TicketBackend { root }` and canonical backend root consistently before relying on root strings for stricter security/audit comparison.
- Add an execution-level Pod adapter test that runs a Ticket tool against the configured backend root.
- Later role launcher should resolve Profile selectors/prompt refs/workflow refs at runtime and commit launch prompts as user/task messages.
Post-merge validation passed:
- `cargo test -p ticket`
- `cargo test -p pod ticket --lib`
- `cargo test -p pod feature --lib`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
---

View File

@ -0,0 +1,138 @@
# Delegation intent: Ticket role Pod launcher
## Intent
Implement a reusable Ticket role Pod launcher so TUI and later CLI/orchestrator surfaces can launch fixed Ticket-role Pods without duplicating profile/config/workflow/prompt construction logic.
The launcher should use `.yoi/ticket.config.toml` fixed role configuration, generate first-run task content, and keep dynamic Ticket/action context in `Method::Run` input rather than hidden context injection.
## Worktree / branch
- worktree: `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`
- branch: `work/ticket-role-pod-launcher`
## Requirements
- Support fixed Ticket roles only:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Load `.yoi/ticket.config.toml` through `ticket::config::TicketConfig`.
- Use role `profile` selector as the child Pod profile selector.
- Use role `workflow` ref as model-visible workflow input in the first run.
- Generate first committed user/task message content from a typed launch context.
- Keep selected Profile responsible for durable system/role behavior; do not add `system_instruction` support.
- Do not inject dynamic instructions into context outside history; first-run prompt/task content must go through `Method::Run`.
- Prefer a client-level API so TUI can use it without depending on `pod` crate internals.
- Avoid duplicating current runtime spawn internals if existing `client::spawn_pod`, `PodClient`, and `protocol::Method::Run` can be used cleanly.
- Expose a launch planning API even if full execution is constrained, so TUI work has a stable boundary.
## Suggested module placement
Preferred:
- `crates/client/src/ticket_role.rs`
- exports from `crates/client/src/lib.rs`
Rationale:
- `tui` already depends on `client`.
- `client` can depend on `ticket` without introducing `tui -> pod`.
- `client` owns host-side spawn/socket mechanics.
If current crate boundaries make full execution awkward, implement the pure planning API in `client` first and clearly report the execution gap.
## Suggested API shape
Exact names can change, but keep the surface typed:
```rust
pub enum TicketRoleLaunchKind {
Intake,
Orchestrator,
Coder,
Reviewer,
Investigator,
}
pub struct TicketRoleLaunchContext {
pub workspace_root: PathBuf,
pub role: TicketRole,
pub pod_name: Option<String>,
pub ticket: Option<TicketRefLike>,
pub user_instruction: Option<String>,
pub intent_packet: Option<String>,
pub worktree_path: Option<PathBuf>,
pub branch: Option<String>,
pub validation: Vec<String>,
pub report_expectations: Vec<String>,
}
pub struct TicketRoleLaunchPlan {
pub role: TicketRole,
pub pod_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
pub run_segments: Vec<protocol::Segment>,
}
```
Use existing `ticket::config::TicketRole` if practical rather than duplicating role enum. Avoid exposing pod internals.
## Prompt generation expectations
Generated first-run text should include:
- role name;
- Ticket id/slug if present;
- user/action instruction;
- workflow slug;
- launch_prompt ref if configured but unresolved;
- intent packet if provided;
- worktree path / branch if provided;
- validation/report expectations if provided;
- reminder that Profile supplies system/role behavior and the Workflow supplies process.
Prefer typed `Segment::WorkflowInvoke` plus text if current protocol/client path supports it. If not, include workflow slug in text and document the limitation.
## Non-goals
- TUI command/action UI.
- Stateful workflow engine.
- Phase-specific prompts/tool gating.
- Role-level `system_instruction` support.
- Prompt resource resolution if it requires moving prompt loader APIs across crates.
- Changing Profile resolution semantics.
- Changing `SpawnPod` tool semantics in the `pod` crate.
- Scheduler/lease/queue automation.
- Worktree creation automation.
## Validation
Run at least:
- `cargo test -p client ticket` or focused client tests;
- `cargo test -p ticket` if touched;
- `cargo check --workspace --all-targets`;
- `cargo fmt --check`;
- `git diff --check`;
- `./tickets.sh doctor`.
Run `nix build .#yoi --no-link` if feasible.
## Completion report
Report:
- worktree path / branch;
- commit hash;
- final module/API layout;
- whether launch execution is implemented or only planning;
- generated prompt / workflow segment behavior;
- how role profile config is used;
- validation results;
- unresolved risks/follow-ups;
- whether `tui-ticket-role-actions` can proceed.

View File

@ -0,0 +1,110 @@
# Implementation report: ticket-role-pod-launcher
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`
- Branch: `work/ticket-role-pod-launcher`
## Commits
- `4bf0e27 feat: add ticket role pod launcher`
- `dd70517 fix: harden ticket role launch execution`
## Summary
Added a reusable Ticket role Pod launcher in the `client` crate so TUI/future CLI surfaces can build and execute fixed Ticket-role Pod launches without depending on `pod` internals or duplicating role/profile/workflow prompt construction.
The launcher uses `.yoi/ticket.config.toml` role configuration, preserves Profile ownership of durable system/role behavior, and sends concrete Ticket/action context as the first committed `Method::Run` input.
## Final module/API layout
- `crates/client/src/ticket_role.rs`
- `TicketRef`
- `TicketRoleLaunchContext`
- `TicketRoleLaunchPlan`
- `TicketRoleLaunchResult`
- `TicketRoleLaunchError`
- `plan_ticket_role_launch(...)`
- `plan_ticket_role_launch_with_config(...)`
- `launch_ticket_role_pod(...)`
- `crates/client/src/lib.rs`
- re-exports the Ticket role launcher API.
## Behavior
Supported roles come from `ticket::config::TicketRole`:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
The launcher:
- loads `.yoi/ticket.config.toml` with `TicketConfig`;
- reads role `profile`, `workflow`, and optional `launch_prompt`;
- creates a deterministic launch plan;
- generates first-run input as `Segment::WorkflowInvoke { slug }` plus `Segment::Text { content }`;
- exposes unresolved `launch_prompt` refs as launch-plan/text metadata rather than treating them as system instruction;
- can execute concrete top-level profile launches with `spawn_pod`, `PodClient`, and `Method::Run`.
`profile = "inherit"` remains valid in launch planning but is rejected for top-level client execution with a bounded `UnsupportedInheritProfile` error, because top-level `--profile inherit` does not have child SpawnPod inheritance semantics.
`launch_ticket_role_pod(...)` waits for first-run acceptance evidence after sending `Method::Run`:
- accepts matching `Event::UserMessage`;
- accepts `Event::InvokeStart { kind: UserSend }`;
- accepts `Event::TurnStart`;
- reports `Event::Error`, stream close, and timeout as errors.
## Changed files
- `Cargo.lock`
- `crates/client/Cargo.toml`
- `crates/client/src/lib.rs`
- `crates/client/src/spawn.rs`
- `crates/client/src/ticket_role.rs`
- `package.nix`
## Review status
External sibling review initially requested changes with two blockers:
1. Default `profile = "inherit"` was being passed through top-level `--profile inherit`.
2. `launch_ticket_role_pod` returned success after writing `Method::Run` without acceptance evidence.
Both blockers were fixed in `dd70517`, and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- Add fake-socket/client execution tests for acceptance/rejection/close/timeout behavior.
- Add an aggregate prompt/list cap; current implementation bounds individual fields but not the number of validation/report list entries.
- Future TUI/CLI integration must surface `UnsupportedInheritProfile` clearly or require concrete role profiles until an inheritance-aware launch path exists.
## Validation
Coder-reported validation for the initial implementation passed:
- `cargo test -p client ticket`
- `cargo test -p ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Coder-reported validation for blocker fixes passed:
- `cargo test -p client ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
## Ready for merge
Yes.
This clears the launcher prerequisite for `tui-ticket-role-actions`.

View File

@ -0,0 +1,149 @@
# External review: Ticket role Pod launcher
## 1. Result
request changes
## 2. Summary of implementation
The implementation adds `crates/client/src/ticket_role.rs` and re-exports a client-level Ticket role launch API. The new module builds a `TicketRoleLaunchPlan` from `ticket::config::TicketConfig`, fixed `TicketRole` values, role profile/workflow/launch_prompt refs, a generated first-run prompt, and `Segment::WorkflowInvoke` + `Segment::Text` run input. It also adds `launch_ticket_role_pod`, which calls `client::spawn_pod`, connects with `PodClient`, and writes `Method::Run`.
The implementation is small and mostly stays on the intended boundary: no TUI UI, no scheduler/queue, no workflow engine, no worktree automation, no `pod` SpawnPod-tool changes, and no role-level system-instruction support were introduced.
## 3. Requirement-by-requirement assessment
- Appropriate crate / TUI boundary: mostly satisfied. The launcher lives in `client`, and the diff does not make TUI depend on `pod`.
- Fixed Ticket roles only: satisfied. It uses `ticket::config::TicketRole` rather than adding an arbitrary registry.
- `.yoi/ticket.config.toml` loading: satisfied for planning. `plan_ticket_role_launch` calls `TicketConfig::load_workspace`.
- Role profile selector as child profile selector: not satisfied for execution. The plan preserves the string, but execution passes `inherit` through top-level `--profile`, where it is not the SpawnPod child-profile special selector.
- Profile semantics unchanged: not satisfied for execution. `inherit` only has child/inherited-manifest semantics in `pod::spawn::tool`; top-level profile parsing treats it as a named registry profile.
- No role-level `system_instruction`: satisfied. Unknown `system_instruction` remains rejected by config parsing, and the launcher does not add overlay support.
- Dynamic content through `Method::Run`: satisfied in planning and mostly in execution shape. The launch content is represented as `Method::Run` segments, not hidden context injection.
- First-run input uses `Segment::WorkflowInvoke` plus `Segment::Text`: satisfied.
- `launch_prompt` refs unresolved/exposed: satisfied. The plan exposes `launch_prompt_ref` and the generated text labels it as unresolved.
- Prompt text bounded/deterministic/useful: partially satisfied. Individual fields are trimmed and capped, and the included context is useful. The total prompt is not globally bounded because validation/report vectors are unbounded.
- Actual launch execution safe/consistent: not satisfied. The function returns after writing to the socket, without waiting for run acceptance/commit evidence or surfacing rejection events.
- Error handling/diagnostics: partially satisfied. Config/spawn/connect/write errors are typed, but run rejection/AlreadyRunning/commit failure cannot be reported by `launch_ticket_role_pod` because it never reads acknowledgement events.
- Dependencies: acceptable. `ticket` and `thiserror` are justified; `tempfile` is test-only; `package.nix` hash was updated. No suspicious dependency expansion was observed.
- Non-goals: satisfied. I did not see TUI UI, scheduler/lease/queue, workflow engine, worktree automation, `pod` SpawnPod-tool changes, or broad refactors.
- Tests: mostly satisfied for planning requirements. Tests cover default config planning, configured refs, prompt content for intake/orchestrator/reviewer, caller-provided Pod name, malformed config, and no `system_instruction`. They do not cover execution acknowledgement/failure behavior.
## 4. Blockers
### Blocker 1: Default `inherit` role profile cannot be executed correctly through `client::spawn_pod`
`TicketConfig` defaults every role to `profile = "inherit"` (`crates/ticket/src/config.rs:214-217`). The launcher preserves that value and always converts it into `SpawnConfig { profile: Some(self.profile.clone()), ... }` (`crates/client/src/ticket_role.rs:122-127`). `client::spawn_pod` then renders this as top-level CLI args `--profile inherit --profile-pod-name ...` (`crates/client/src/spawn.rs:132-137`).
That does not invoke the child-profile `inherit` semantics. Top-level profile parsing only treats `default` specially; `inherit` falls through to `ProfileSelector::Named { name: "inherit" }` (`crates/manifest/src/profile.rs:93-108`, via `crates/pod/src/entrypoint.rs:106-108`). The special `inherit` behavior exists in `pod::spawn::tool`'s SpawnPod-profile parser, not in the client top-level spawn path.
As a result, `launch_ticket_role_pod` with the default Ticket role config is expected to fail profile resolution unless a registry profile named `inherit` happens to exist, and if such a profile exists it would use different semantics. This breaks the MVP execution path and violates the requirement not to change Profile semantics.
A fix should make this boundary explicit. Either implement execution through a path that really supports child-profile `inherit`, or make execution fail closed / remain planning-only for `inherit` with a bounded diagnostic until a correct inheritance source is available. Do not reinterpret `inherit` as `default` in the client launcher.
### Blocker 2: `launch_ticket_role_pod` does not confirm that the first `Method::Run` was accepted/committed
`launch_ticket_role_pod` spawns the Pod, connects, writes `Method::Run`, and returns success immediately after `PodClient::send` succeeds (`crates/client/src/ticket_role.rs:214-226`). `PodClient::send` only writes the JSON line (`crates/client/src/pod_client.rs:34-35`); it does not wait for `Event::UserMessage`, `Event::InvokeStart { kind: UserSend }`, `Event::TurnStart`, or `Event::Error`.
The ticket requires a first committed user/task message, and the review objective asks whether actual execution is safe/consistent with existing client behavior. Existing one-shot spawn delivery in `pod::spawn::comm_tools::send_run_and_confirm` explicitly drains initial socket events and waits for acceptance or rejection evidence (`crates/pod/src/spawn/comm_tools.rs:337-408`). The new launcher lacks equivalent confirmation and therefore can report a successful launch even when the run is rejected after the write, the Pod is already running, or the connection closes before acceptance evidence.
A fix should either add a client-level `send_run_and_confirm`-style path that supports typed `Vec<Segment>`, with bounded timeouts and useful rejection diagnostics, or downgrade the execution API so it does not claim the first run was launched/committed.
## 5. Non-blockers / follow-ups
- The generated prompt caps individual string fields at 8,000 chars, but `validation` and `report_expectations` list lengths are unbounded. Consider an aggregate prompt cap or per-list item-count cap before wiring this to UI surfaces.
- The public client re-exports do not re-export `ticket::config::TicketRole`; TUI can still add/use `ticket`, but a client-side re-export may keep the launcher API easier to consume.
- Execution-path tests should be added with a fake socket once Blocker 2 is addressed, especially for acceptance, rejection, and already-running diagnostics.
## 6. Validation assessed or rerun
Read/inspected:
- Ticket item and delegation intent.
- `crates/client/src/ticket_role.rs`, `spawn.rs`, `pod_client.rs`.
- `crates/protocol/src/lib.rs` for `Segment::WorkflowInvoke` support.
- `crates/ticket/src/config.rs` for fixed roles/default profile behavior.
- Relevant profile/spawn parsing paths in `manifest` and `pod`.
- Diff against `develop`.
Commands run, all from `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`:
- `git status --short`
- `git diff --stat develop...HEAD`
- `git diff --name-status develop...HEAD`
- `git diff --check develop...HEAD` — no whitespace diagnostics observed.
- `git rev-parse HEAD`
- `git merge-base develop HEAD`
- `git diff --name-only develop...HEAD`
I did not rerun `cargo test`, `cargo check`, `cargo fmt`, `tickets.sh doctor`, or `nix build`, because this external review was constrained to focused read-only validation commands and those commands would write build/check artifacts.
## 7. Residual risk
After the blockers are fixed, the main residual risk is deciding the correct ownership of role Pod execution semantics: a client/TUI launcher can plan the request cleanly, but `inherit` and confirmed first-run delivery are child-Pod semantics that need a deliberate bridge rather than accidental top-level profile spawning. Once that bridge is explicit and tested, the rest of the implementation looks like a suitable foundation for TUI/future CLI role actions.
---
# Re-review: blocker fixes in `dd70517f967424887daf3f30e5aed5b1e6f459c8`
## 1. Result
approve
## 2. Summary of fix
The follow-up commit hardens the execution path without changing the planning model. `TicketRoleLaunchPlan::spawn_config(...)` now returns `Result<SpawnConfig, TicketRoleLaunchError>` and rejects `profile == "inherit"` with a clear fail-closed diagnostic before top-level `client::spawn_pod` can reinterpret it as a normal `--profile inherit` selector. `launch_ticket_role_pod(...)` now sends the first `Method::Run` and then waits, with a 10 second timeout, for run acceptance evidence from the Pod event stream.
## 3. Blocker reassessment
### Previous Blocker 1: default `inherit` profile executed through top-level `--profile`
Resolved.
Planning still preserves the configured/default profile string, including `inherit`, so pure launch planning remains usable. Execution now calls `plan.spawn_config(runtime_command)?`, and `spawn_config` returns `TicketRoleLaunchError::UnsupportedInheritProfile` when `self.profile == "inherit"` before constructing `SpawnConfig`. The error message is bounded and explicit: `Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector`.
This satisfies the requested fix and avoids changing Profile semantics. It leaves default-role execution unavailable until a concrete role profile is configured or a correct inheritance-capable launch path exists, which is preferable to accidental top-level reinterpretation.
### Previous Blocker 2: first `Method::Run` write was not confirmed
Resolved for the requested acceptance boundary.
After `PodClient::send(&plan.run_method())`, `launch_ticket_role_pod` now calls `wait_for_run_acceptance`. That helper waits for:
- `Event::UserMessage { segments }` matching the sent segments;
- `Event::InvokeStart { kind: InvokeKind::UserSend }`;
- `Event::TurnStart { .. }`.
It treats `Event::Error { code, message }` as `RunRejected`, stream close as `RunAcceptanceClosed`, and timeout as `RunAcceptanceTimeout`. This is bounded and provides useful TUI-facing diagnostics.
## 4. New blockers
None found in the focused re-review.
## 5. Non-blockers / follow-ups
- Execution acknowledgement behavior is not covered by new fake-socket/client tests. I do not consider that a merge blocker for this fix because the implementation is small and directly follows the protocol events, but adding acceptance/rejection/close/timeout tests would reduce regression risk.
- The earlier non-blocker about aggregate prompt/list bounds remains: individual fields are capped, but list lengths are not globally bounded.
## 6. Validation assessed or rerun
Read/inspected:
- `crates/client/src/ticket_role.rs`
- `crates/client/src/pod_client.rs`
- `crates/client/src/spawn.rs`
- `crates/protocol/src/lib.rs`
- diff from `4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d` to `dd70517f967424887daf3f30e5aed5b1e6f459c8`
Commands run, all from `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`:
- `git rev-parse HEAD` — confirmed `dd70517f967424887daf3f30e5aed5b1e6f459c8`
- `git status --short` — no source changes reported
- `git diff --stat 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8`
- `git diff --check 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8` — no whitespace diagnostics observed
- `git diff --color=never 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8 -- crates/client/src/ticket_role.rs crates/client/src/spawn.rs`
I did not run cargo/nix validation because this re-review was focused on blocker fixes and I avoided build commands that would write artifacts.
## 7. Residual risk
The implementation is now suitable to merge for the launcher layer. The remaining execution limitation is intentional and explicit: `inherit` can be planned but not top-level executed through this client path. Future TUI/CLI integration should surface that diagnostic clearly or require concrete role profiles until a proper inheritance-aware launch path exists.

View File

@ -0,0 +1,111 @@
---
id: 20260605-190330-ticket-role-pod-launcher
slug: ticket-role-pod-launcher
title: Ticket role Pod launcher
status: closed
kind: task
priority: P1
labels: [ticket, pod, role, orchestration]
created_at: 2026-06-05T19:03:30Z
updated_at: 2026-06-05T19:34:06Z
assignee: null
legacy_ticket: null
---
## Background
Ticket orchestration now has:
- typed Ticket backend and tools;
- Ticket Intake and Orchestrator Routing workflows;
- fixed Ticket role config in `.yoi/ticket.config.toml`.
TUI and future CLI/orchestration UI should not construct role-specific Pod launch requests by hand. Before adding TUI Ticket role actions, introduce a reusable launcher layer that turns a fixed Ticket role plus context into a Pod spawn/run request.
This layer should keep the Profile/SystemInstruction boundary intact: the selected Profile owns durable role/system behavior, while the launcher creates the first committed user/task message for a concrete Ticket/action and includes the workflow reference as explicit model-visible input.
## Goal
Add a reusable Ticket role Pod launcher API that can be used by TUI and later CLI/orchestrator surfaces.
MVP should construct and optionally execute host-side Pod launches for fixed Ticket roles using `.yoi/ticket.config.toml`, selected Profile selector, workflow ref, and generated initial task prompt.
## Requirements
- Support fixed Ticket roles only:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Load `.yoi/ticket.config.toml` through `ticket::config::TicketConfig`.
- Use role `profile` selector as the child Pod profile selector.
- Use role `workflow` ref as a model-visible workflow invocation / workflow instruction in the first `Method::Run`.
- Generate a first committed user/task message from a typed launch context.
- Keep Profile-owned system instruction separate from the generated launch prompt.
- Do not add a role-level `system_instruction` override.
- Do not perform hidden context injection; everything dynamic must be sent as `Method::Run` input.
- Provide a launch planning API usable by TUI without depending on `pod` crate internals.
- Prefer placing the launcher in `client` if that keeps TUI dependency direction clean:
- `tui -> client -> ticket/protocol/manifest`;
- avoid `tui -> pod`.
- If implementing actual launch execution, use existing `client::spawn_pod` and `client::PodClient` / `protocol::Method::Run` where appropriate.
- Generate stable, testable Pod names or accept caller-provided names.
- Keep launch prompt text bounded and deterministic enough for tests.
- Include enough context for each role without over-scoping:
- target Ticket id/slug;
- user instruction or routing/action summary;
- workflow slug;
- optional intent packet;
- optional worktree path / branch;
- optional validation/report expectations.
## Launch prompt / workflow semantics
The first run should separate content like this:
- Profile supplies the system/role behavior.
- Workflow ref supplies the procedural flow to follow.
- Generated launch prompt supplies this specific Ticket/action context.
Prefer `Method::Run` with typed `Segment::WorkflowInvoke { slug }` plus `Segment::Text { content }` when practical. If a workflow segment is not viable in the immediate integration point, include the workflow slug explicitly in the generated text and document the limitation.
Configured `launch_prompt` refs may remain unresolved in the MVP if prompt-resource resolution is not available below `pod`; they should be exposed in the launch plan for future resolution and not silently treated as system instruction.
## Non-goals
- TUI action UI.
- Arbitrary role registry.
- Scheduler/lease/queue automation.
- Stateful workflow engine.
- Phase-specific tool gating.
- Role-level `system_instruction` support.
- Changing Profile resolution semantics.
- Changing `SpawnPod` tool semantics inside the `pod` crate.
- Implementing coder/reviewer worktree creation policy.
- Broad Pod registry/metadata redesign.
## Acceptance criteria
- A reusable launch plan/API exists for fixed Ticket roles.
- The launcher reads `.yoi/ticket.config.toml` defaults and configured role profile/workflow/launch_prompt refs.
- Profile selector selected for the role is available for spawn config.
- Dynamic task content is represented as first-run input, not hidden system/context injection.
- Workflow slug is included as a typed workflow segment or explicit model-visible instruction.
- TUI can consume the API without depending on `pod` internals.
- Tests cover:
- default config role launch plan;
- configured role profile/workflow/launch_prompt refs;
- generated prompt content for at least intake/orchestrator/reviewer;
- caller-provided Pod name;
- missing/malformed Ticket config error surfacing;
- no `system_instruction` handling.
- `cargo test -p client` or chosen crate tests pass.
- `cargo test -p ticket` passes if touched.
- `cargo check --workspace --all-targets`, `cargo fmt --check`, `git diff --check`, and `./tickets.sh doctor` pass.
## Follow-up tickets
- `tui-ticket-role-actions`: expose fixed Ticket role actions in TUI using this launcher.
- Prompt resolution follow-up: resolve `launch_prompt` refs into first-run content once a suitable prompt-resource API exists below/available to the launcher.
- Workflow-state follow-up: persist phase/state and commit phase prompts to history before model use.

View File

@ -0,0 +1,51 @@
Ticket role Pod launcher is complete and merged.
Implementation:
- `4bf0e27 feat: add ticket role pod launcher`
- `dd70517 fix: harden ticket role launch execution`
- merge commit: `3d6c1ab merge: add ticket role launcher`
Summary:
- Added `crates/client/src/ticket_role.rs` as a reusable client-level Ticket role launch layer.
- Added launch planning for fixed Ticket roles using `.yoi/ticket.config.toml`:
- intake
- orchestrator
- coder
- reviewer
- investigator
- Kept TUI free from `pod` internals; TUI can use `client`.
- Generated first-run input as `Segment::WorkflowInvoke` plus `Segment::Text`.
- Kept Profile responsible for durable system/role behavior.
- Did not add role-level `system_instruction` support.
- Exposed unresolved `launch_prompt` refs in plans/text without treating them as system instructions.
- Added execution API using `spawn_pod`, `PodClient`, and `Method::Run` with acceptance confirmation.
- Top-level execution now rejects `profile = "inherit"` with `UnsupportedInheritProfile` rather than passing invalid `--profile inherit` semantics.
- Run delivery waits for acceptance evidence (`UserMessage`, `InvokeStart UserSend`, or `TurnStart`) and reports error/close/timeout.
Review:
- External sibling review initially requested changes for two blockers:
1. invalid top-level execution of `inherit` profile;
2. no first-run acceptance confirmation.
- Both blockers were fixed in `dd70517`.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Add fake-socket/client execution tests for acceptance/rejection/close/timeout behavior.
- Add aggregate prompt/list caps; current implementation bounds individual fields but not list length globally.
- TUI/CLI integration should surface `UnsupportedInheritProfile` clearly or require concrete role profiles until an inheritance-aware launch path exists.
Post-merge validation passed:
- `cargo test -p client ticket`
- `cargo test -p ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
This clears the prerequisite for `tui-ticket-role-actions`.

View File

@ -0,0 +1,510 @@
<!-- event: create author: tickets.sh at: 2026-06-05T19:03:30Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-06-05T19:06:16Z -->
## Plan
Preflight result: `implementation-ready`.
Implement `ticket-role-pod-launcher` before TUI actions so TUI does not own role/profile/prompt/workflow launch construction.
Design decisions:
- Fixed Ticket roles only: intake, orchestrator, coder, reviewer, investigator.
- Role profile/workflow/launch_prompt refs come from `.yoi/ticket.config.toml`.
- Profile owns durable role/system behavior.
- Launcher generates the first committed user/task message for a concrete Ticket/action.
- Dynamic content must be sent through `Method::Run`, not hidden context injection.
- Prefer a client-level API so TUI can use it without depending on `pod` internals.
- Configured `launch_prompt` refs may be exposed but not resolved if no suitable prompt-resource API exists below `pod`; do not treat them as system instruction.
Follow-up after this lands: implement `tui-ticket-role-actions` using the launcher.
---
<!-- event: plan author: hare at: 2026-06-05T19:07:09Z -->
## Plan
# Delegation intent: Ticket role Pod launcher
## Intent
Implement a reusable Ticket role Pod launcher so TUI and later CLI/orchestrator surfaces can launch fixed Ticket-role Pods without duplicating profile/config/workflow/prompt construction logic.
The launcher should use `.yoi/ticket.config.toml` fixed role configuration, generate first-run task content, and keep dynamic Ticket/action context in `Method::Run` input rather than hidden context injection.
## Worktree / branch
- worktree: `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`
- branch: `work/ticket-role-pod-launcher`
## Requirements
- Support fixed Ticket roles only:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
- Load `.yoi/ticket.config.toml` through `ticket::config::TicketConfig`.
- Use role `profile` selector as the child Pod profile selector.
- Use role `workflow` ref as model-visible workflow input in the first run.
- Generate first committed user/task message content from a typed launch context.
- Keep selected Profile responsible for durable system/role behavior; do not add `system_instruction` support.
- Do not inject dynamic instructions into context outside history; first-run prompt/task content must go through `Method::Run`.
- Prefer a client-level API so TUI can use it without depending on `pod` crate internals.
- Avoid duplicating current runtime spawn internals if existing `client::spawn_pod`, `PodClient`, and `protocol::Method::Run` can be used cleanly.
- Expose a launch planning API even if full execution is constrained, so TUI work has a stable boundary.
## Suggested module placement
Preferred:
- `crates/client/src/ticket_role.rs`
- exports from `crates/client/src/lib.rs`
Rationale:
- `tui` already depends on `client`.
- `client` can depend on `ticket` without introducing `tui -> pod`.
- `client` owns host-side spawn/socket mechanics.
If current crate boundaries make full execution awkward, implement the pure planning API in `client` first and clearly report the execution gap.
## Suggested API shape
Exact names can change, but keep the surface typed:
```rust
pub enum TicketRoleLaunchKind {
Intake,
Orchestrator,
Coder,
Reviewer,
Investigator,
}
pub struct TicketRoleLaunchContext {
pub workspace_root: PathBuf,
pub role: TicketRole,
pub pod_name: Option<String>,
pub ticket: Option<TicketRefLike>,
pub user_instruction: Option<String>,
pub intent_packet: Option<String>,
pub worktree_path: Option<PathBuf>,
pub branch: Option<String>,
pub validation: Vec<String>,
pub report_expectations: Vec<String>,
}
pub struct TicketRoleLaunchPlan {
pub role: TicketRole,
pub pod_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
pub run_segments: Vec<protocol::Segment>,
}
```
Use existing `ticket::config::TicketRole` if practical rather than duplicating role enum. Avoid exposing pod internals.
## Prompt generation expectations
Generated first-run text should include:
- role name;
- Ticket id/slug if present;
- user/action instruction;
- workflow slug;
- launch_prompt ref if configured but unresolved;
- intent packet if provided;
- worktree path / branch if provided;
- validation/report expectations if provided;
- reminder that Profile supplies system/role behavior and the Workflow supplies process.
Prefer typed `Segment::WorkflowInvoke` plus text if current protocol/client path supports it. If not, include workflow slug in text and document the limitation.
## Non-goals
- TUI command/action UI.
- Stateful workflow engine.
- Phase-specific prompts/tool gating.
- Role-level `system_instruction` support.
- Prompt resource resolution if it requires moving prompt loader APIs across crates.
- Changing Profile resolution semantics.
- Changing `SpawnPod` tool semantics in the `pod` crate.
- Scheduler/lease/queue automation.
- Worktree creation automation.
## Validation
Run at least:
- `cargo test -p client ticket` or focused client tests;
- `cargo test -p ticket` if touched;
- `cargo check --workspace --all-targets`;
- `cargo fmt --check`;
- `git diff --check`;
- `./tickets.sh doctor`.
Run `nix build .#yoi --no-link` if feasible.
## Completion report
Report:
- worktree path / branch;
- commit hash;
- final module/API layout;
- whether launch execution is implemented or only planning;
- generated prompt / workflow segment behavior;
- how role profile config is used;
- validation results;
- unresolved risks/follow-ups;
- whether `tui-ticket-role-actions` can proceed.
---
<!-- event: review author: hare at: 2026-06-05T19:32:52Z status: approve -->
## Review: approve
# External review: Ticket role Pod launcher
## 1. Result
request changes
## 2. Summary of implementation
The implementation adds `crates/client/src/ticket_role.rs` and re-exports a client-level Ticket role launch API. The new module builds a `TicketRoleLaunchPlan` from `ticket::config::TicketConfig`, fixed `TicketRole` values, role profile/workflow/launch_prompt refs, a generated first-run prompt, and `Segment::WorkflowInvoke` + `Segment::Text` run input. It also adds `launch_ticket_role_pod`, which calls `client::spawn_pod`, connects with `PodClient`, and writes `Method::Run`.
The implementation is small and mostly stays on the intended boundary: no TUI UI, no scheduler/queue, no workflow engine, no worktree automation, no `pod` SpawnPod-tool changes, and no role-level system-instruction support were introduced.
## 3. Requirement-by-requirement assessment
- Appropriate crate / TUI boundary: mostly satisfied. The launcher lives in `client`, and the diff does not make TUI depend on `pod`.
- Fixed Ticket roles only: satisfied. It uses `ticket::config::TicketRole` rather than adding an arbitrary registry.
- `.yoi/ticket.config.toml` loading: satisfied for planning. `plan_ticket_role_launch` calls `TicketConfig::load_workspace`.
- Role profile selector as child profile selector: not satisfied for execution. The plan preserves the string, but execution passes `inherit` through top-level `--profile`, where it is not the SpawnPod child-profile special selector.
- Profile semantics unchanged: not satisfied for execution. `inherit` only has child/inherited-manifest semantics in `pod::spawn::tool`; top-level profile parsing treats it as a named registry profile.
- No role-level `system_instruction`: satisfied. Unknown `system_instruction` remains rejected by config parsing, and the launcher does not add overlay support.
- Dynamic content through `Method::Run`: satisfied in planning and mostly in execution shape. The launch content is represented as `Method::Run` segments, not hidden context injection.
- First-run input uses `Segment::WorkflowInvoke` plus `Segment::Text`: satisfied.
- `launch_prompt` refs unresolved/exposed: satisfied. The plan exposes `launch_prompt_ref` and the generated text labels it as unresolved.
- Prompt text bounded/deterministic/useful: partially satisfied. Individual fields are trimmed and capped, and the included context is useful. The total prompt is not globally bounded because validation/report vectors are unbounded.
- Actual launch execution safe/consistent: not satisfied. The function returns after writing to the socket, without waiting for run acceptance/commit evidence or surfacing rejection events.
- Error handling/diagnostics: partially satisfied. Config/spawn/connect/write errors are typed, but run rejection/AlreadyRunning/commit failure cannot be reported by `launch_ticket_role_pod` because it never reads acknowledgement events.
- Dependencies: acceptable. `ticket` and `thiserror` are justified; `tempfile` is test-only; `package.nix` hash was updated. No suspicious dependency expansion was observed.
- Non-goals: satisfied. I did not see TUI UI, scheduler/lease/queue, workflow engine, worktree automation, `pod` SpawnPod-tool changes, or broad refactors.
- Tests: mostly satisfied for planning requirements. Tests cover default config planning, configured refs, prompt content for intake/orchestrator/reviewer, caller-provided Pod name, malformed config, and no `system_instruction`. They do not cover execution acknowledgement/failure behavior.
## 4. Blockers
### Blocker 1: Default `inherit` role profile cannot be executed correctly through `client::spawn_pod`
`TicketConfig` defaults every role to `profile = "inherit"` (`crates/ticket/src/config.rs:214-217`). The launcher preserves that value and always converts it into `SpawnConfig { profile: Some(self.profile.clone()), ... }` (`crates/client/src/ticket_role.rs:122-127`). `client::spawn_pod` then renders this as top-level CLI args `--profile inherit --profile-pod-name ...` (`crates/client/src/spawn.rs:132-137`).
That does not invoke the child-profile `inherit` semantics. Top-level profile parsing only treats `default` specially; `inherit` falls through to `ProfileSelector::Named { name: "inherit" }` (`crates/manifest/src/profile.rs:93-108`, via `crates/pod/src/entrypoint.rs:106-108`). The special `inherit` behavior exists in `pod::spawn::tool`'s SpawnPod-profile parser, not in the client top-level spawn path.
As a result, `launch_ticket_role_pod` with the default Ticket role config is expected to fail profile resolution unless a registry profile named `inherit` happens to exist, and if such a profile exists it would use different semantics. This breaks the MVP execution path and violates the requirement not to change Profile semantics.
A fix should make this boundary explicit. Either implement execution through a path that really supports child-profile `inherit`, or make execution fail closed / remain planning-only for `inherit` with a bounded diagnostic until a correct inheritance source is available. Do not reinterpret `inherit` as `default` in the client launcher.
### Blocker 2: `launch_ticket_role_pod` does not confirm that the first `Method::Run` was accepted/committed
`launch_ticket_role_pod` spawns the Pod, connects, writes `Method::Run`, and returns success immediately after `PodClient::send` succeeds (`crates/client/src/ticket_role.rs:214-226`). `PodClient::send` only writes the JSON line (`crates/client/src/pod_client.rs:34-35`); it does not wait for `Event::UserMessage`, `Event::InvokeStart { kind: UserSend }`, `Event::TurnStart`, or `Event::Error`.
The ticket requires a first committed user/task message, and the review objective asks whether actual execution is safe/consistent with existing client behavior. Existing one-shot spawn delivery in `pod::spawn::comm_tools::send_run_and_confirm` explicitly drains initial socket events and waits for acceptance or rejection evidence (`crates/pod/src/spawn/comm_tools.rs:337-408`). The new launcher lacks equivalent confirmation and therefore can report a successful launch even when the run is rejected after the write, the Pod is already running, or the connection closes before acceptance evidence.
A fix should either add a client-level `send_run_and_confirm`-style path that supports typed `Vec<Segment>`, with bounded timeouts and useful rejection diagnostics, or downgrade the execution API so it does not claim the first run was launched/committed.
## 5. Non-blockers / follow-ups
- The generated prompt caps individual string fields at 8,000 chars, but `validation` and `report_expectations` list lengths are unbounded. Consider an aggregate prompt cap or per-list item-count cap before wiring this to UI surfaces.
- The public client re-exports do not re-export `ticket::config::TicketRole`; TUI can still add/use `ticket`, but a client-side re-export may keep the launcher API easier to consume.
- Execution-path tests should be added with a fake socket once Blocker 2 is addressed, especially for acceptance, rejection, and already-running diagnostics.
## 6. Validation assessed or rerun
Read/inspected:
- Ticket item and delegation intent.
- `crates/client/src/ticket_role.rs`, `spawn.rs`, `pod_client.rs`.
- `crates/protocol/src/lib.rs` for `Segment::WorkflowInvoke` support.
- `crates/ticket/src/config.rs` for fixed roles/default profile behavior.
- Relevant profile/spawn parsing paths in `manifest` and `pod`.
- Diff against `develop`.
Commands run, all from `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`:
- `git status --short`
- `git diff --stat develop...HEAD`
- `git diff --name-status develop...HEAD`
- `git diff --check develop...HEAD` — no whitespace diagnostics observed.
- `git rev-parse HEAD`
- `git merge-base develop HEAD`
- `git diff --name-only develop...HEAD`
I did not rerun `cargo test`, `cargo check`, `cargo fmt`, `tickets.sh doctor`, or `nix build`, because this external review was constrained to focused read-only validation commands and those commands would write build/check artifacts.
## 7. Residual risk
After the blockers are fixed, the main residual risk is deciding the correct ownership of role Pod execution semantics: a client/TUI launcher can plan the request cleanly, but `inherit` and confirmed first-run delivery are child-Pod semantics that need a deliberate bridge rather than accidental top-level profile spawning. Once that bridge is explicit and tested, the rest of the implementation looks like a suitable foundation for TUI/future CLI role actions.
---
# Re-review: blocker fixes in `dd70517f967424887daf3f30e5aed5b1e6f459c8`
## 1. Result
approve
## 2. Summary of fix
The follow-up commit hardens the execution path without changing the planning model. `TicketRoleLaunchPlan::spawn_config(...)` now returns `Result<SpawnConfig, TicketRoleLaunchError>` and rejects `profile == "inherit"` with a clear fail-closed diagnostic before top-level `client::spawn_pod` can reinterpret it as a normal `--profile inherit` selector. `launch_ticket_role_pod(...)` now sends the first `Method::Run` and then waits, with a 10 second timeout, for run acceptance evidence from the Pod event stream.
## 3. Blocker reassessment
### Previous Blocker 1: default `inherit` profile executed through top-level `--profile`
Resolved.
Planning still preserves the configured/default profile string, including `inherit`, so pure launch planning remains usable. Execution now calls `plan.spawn_config(runtime_command)?`, and `spawn_config` returns `TicketRoleLaunchError::UnsupportedInheritProfile` when `self.profile == "inherit"` before constructing `SpawnConfig`. The error message is bounded and explicit: `Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector`.
This satisfies the requested fix and avoids changing Profile semantics. It leaves default-role execution unavailable until a concrete role profile is configured or a correct inheritance-capable launch path exists, which is preferable to accidental top-level reinterpretation.
### Previous Blocker 2: first `Method::Run` write was not confirmed
Resolved for the requested acceptance boundary.
After `PodClient::send(&plan.run_method())`, `launch_ticket_role_pod` now calls `wait_for_run_acceptance`. That helper waits for:
- `Event::UserMessage { segments }` matching the sent segments;
- `Event::InvokeStart { kind: InvokeKind::UserSend }`;
- `Event::TurnStart { .. }`.
It treats `Event::Error { code, message }` as `RunRejected`, stream close as `RunAcceptanceClosed`, and timeout as `RunAcceptanceTimeout`. This is bounded and provides useful TUI-facing diagnostics.
## 4. New blockers
None found in the focused re-review.
## 5. Non-blockers / follow-ups
- Execution acknowledgement behavior is not covered by new fake-socket/client tests. I do not consider that a merge blocker for this fix because the implementation is small and directly follows the protocol events, but adding acceptance/rejection/close/timeout tests would reduce regression risk.
- The earlier non-blocker about aggregate prompt/list bounds remains: individual fields are capped, but list lengths are not globally bounded.
## 6. Validation assessed or rerun
Read/inspected:
- `crates/client/src/ticket_role.rs`
- `crates/client/src/pod_client.rs`
- `crates/client/src/spawn.rs`
- `crates/protocol/src/lib.rs`
- diff from `4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d` to `dd70517f967424887daf3f30e5aed5b1e6f459c8`
Commands run, all from `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`:
- `git rev-parse HEAD` — confirmed `dd70517f967424887daf3f30e5aed5b1e6f459c8`
- `git status --short` — no source changes reported
- `git diff --stat 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8`
- `git diff --check 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8` — no whitespace diagnostics observed
- `git diff --color=never 4bf0e2715c50bfe3adb0e42385fbe86470e3eb3d..dd70517f967424887daf3f30e5aed5b1e6f459c8 -- crates/client/src/ticket_role.rs crates/client/src/spawn.rs`
I did not run cargo/nix validation because this re-review was focused on blocker fixes and I avoided build commands that would write artifacts.
## 7. Residual risk
The implementation is now suitable to merge for the launcher layer. The remaining execution limitation is intentional and explicit: `inherit` can be planned but not top-level executed through this client path. Future TUI/CLI integration should surface that diagnostic clearly or require concrete role profiles until a proper inheritance-aware launch path exists.
---
<!-- event: implementation_report author: hare at: 2026-06-05T19:32:53Z -->
## Implementation report
# Implementation report: ticket-role-pod-launcher
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/ticket-role-pod-launcher`
- Branch: `work/ticket-role-pod-launcher`
## Commits
- `4bf0e27 feat: add ticket role pod launcher`
- `dd70517 fix: harden ticket role launch execution`
## Summary
Added a reusable Ticket role Pod launcher in the `client` crate so TUI/future CLI surfaces can build and execute fixed Ticket-role Pod launches without depending on `pod` internals or duplicating role/profile/workflow prompt construction.
The launcher uses `.yoi/ticket.config.toml` role configuration, preserves Profile ownership of durable system/role behavior, and sends concrete Ticket/action context as the first committed `Method::Run` input.
## Final module/API layout
- `crates/client/src/ticket_role.rs`
- `TicketRef`
- `TicketRoleLaunchContext`
- `TicketRoleLaunchPlan`
- `TicketRoleLaunchResult`
- `TicketRoleLaunchError`
- `plan_ticket_role_launch(...)`
- `plan_ticket_role_launch_with_config(...)`
- `launch_ticket_role_pod(...)`
- `crates/client/src/lib.rs`
- re-exports the Ticket role launcher API.
## Behavior
Supported roles come from `ticket::config::TicketRole`:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
- `investigator`
The launcher:
- loads `.yoi/ticket.config.toml` with `TicketConfig`;
- reads role `profile`, `workflow`, and optional `launch_prompt`;
- creates a deterministic launch plan;
- generates first-run input as `Segment::WorkflowInvoke { slug }` plus `Segment::Text { content }`;
- exposes unresolved `launch_prompt` refs as launch-plan/text metadata rather than treating them as system instruction;
- can execute concrete top-level profile launches with `spawn_pod`, `PodClient`, and `Method::Run`.
`profile = "inherit"` remains valid in launch planning but is rejected for top-level client execution with a bounded `UnsupportedInheritProfile` error, because top-level `--profile inherit` does not have child SpawnPod inheritance semantics.
`launch_ticket_role_pod(...)` waits for first-run acceptance evidence after sending `Method::Run`:
- accepts matching `Event::UserMessage`;
- accepts `Event::InvokeStart { kind: UserSend }`;
- accepts `Event::TurnStart`;
- reports `Event::Error`, stream close, and timeout as errors.
## Changed files
- `Cargo.lock`
- `crates/client/Cargo.toml`
- `crates/client/src/lib.rs`
- `crates/client/src/spawn.rs`
- `crates/client/src/ticket_role.rs`
- `package.nix`
## Review status
External sibling review initially requested changes with two blockers:
1. Default `profile = "inherit"` was being passed through top-level `--profile inherit`.
2. `launch_ticket_role_pod` returned success after writing `Method::Run` without acceptance evidence.
Both blockers were fixed in `dd70517`, and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- Add fake-socket/client execution tests for acceptance/rejection/close/timeout behavior.
- Add an aggregate prompt/list cap; current implementation bounds individual fields but not the number of validation/report list entries.
- Future TUI/CLI integration must surface `UnsupportedInheritProfile` clearly or require concrete role profiles until an inheritance-aware launch path exists.
## Validation
Coder-reported validation for the initial implementation passed:
- `cargo test -p client ticket`
- `cargo test -p ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Coder-reported validation for blocker fixes passed:
- `cargo test -p client ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
## Ready for merge
Yes.
This clears the launcher prerequisite for `tui-ticket-role-actions`.
---
<!-- event: close author: hare at: 2026-06-05T19:34:06Z status: closed -->
## Closed
Ticket role Pod launcher is complete and merged.
Implementation:
- `4bf0e27 feat: add ticket role pod launcher`
- `dd70517 fix: harden ticket role launch execution`
- merge commit: `3d6c1ab merge: add ticket role launcher`
Summary:
- Added `crates/client/src/ticket_role.rs` as a reusable client-level Ticket role launch layer.
- Added launch planning for fixed Ticket roles using `.yoi/ticket.config.toml`:
- intake
- orchestrator
- coder
- reviewer
- investigator
- Kept TUI free from `pod` internals; TUI can use `client`.
- Generated first-run input as `Segment::WorkflowInvoke` plus `Segment::Text`.
- Kept Profile responsible for durable system/role behavior.
- Did not add role-level `system_instruction` support.
- Exposed unresolved `launch_prompt` refs in plans/text without treating them as system instructions.
- Added execution API using `spawn_pod`, `PodClient`, and `Method::Run` with acceptance confirmation.
- Top-level execution now rejects `profile = "inherit"` with `UnsupportedInheritProfile` rather than passing invalid `--profile inherit` semantics.
- Run delivery waits for acceptance evidence (`UserMessage`, `InvokeStart UserSend`, or `TurnStart`) and reports error/close/timeout.
Review:
- External sibling review initially requested changes for two blockers:
1. invalid top-level execution of `inherit` profile;
2. no first-run acceptance confirmation.
- Both blockers were fixed in `dd70517`.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Add fake-socket/client execution tests for acceptance/rejection/close/timeout behavior.
- Add aggregate prompt/list caps; current implementation bounds individual fields but not list length globally.
- TUI/CLI integration should surface `UnsupportedInheritProfile` clearly or require concrete role profiles until an inheritance-aware launch path exists.
Post-merge validation passed:
- `cargo test -p client ticket`
- `cargo test -p ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
This clears the prerequisite for `tui-ticket-role-actions`.
---

View File

@ -0,0 +1,118 @@
# Delegation intent: TUI Ticket role actions
## Intent
Add explicit TUI commands/actions that launch fixed Ticket-role Pods through the shared `client` Ticket role launcher.
TUI should not duplicate role/profile/workflow/prompt construction. It should parse user commands, build a typed launcher context, call the client launcher, and surface success/failure diagnostics in the TUI.
## Worktree / branch
- worktree: `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`
- branch: `work/tui-ticket-role-actions`
## Dependencies
`ticket-role-pod-launcher` is complete and merged. Use the client API added there:
- `client::TicketRoleLaunchContext`
- `client::TicketRef`
- `client::launch_ticket_role_pod`
- related launch errors/result types
## Requirements
- Add user-triggered TUI commands for fixed Ticket roles.
- Prefer a single `:ticket <action> ...` command if it fits the current command parser.
- Support at least these actions:
- `:ticket intake <context...>`
- `:ticket route <ticket-id-or-slug> [instruction...]`
- `:ticket investigate <ticket-id-or-slug> [instruction...]`
- `:ticket implement <ticket-id-or-slug> [instruction...]`
- `:ticket review <ticket-id-or-slug> [instruction...]`
- Map actions to roles:
- intake -> `TicketRole::Intake`
- route -> `TicketRole::Orchestrator`
- investigate -> `TicketRole::Investigator`
- implement -> `TicketRole::Coder`
- review -> `TicketRole::Reviewer`
- Use the shared launcher; do not build `SpawnConfig`, Profile selectors, Workflow segments, or first-run prompt content directly in TUI.
- Make command parsing/test behavior deterministic.
- Surface launcher failures as TUI command/actionbar diagnostics without crashing.
- Make `UnsupportedInheritProfile` especially clear: the user must configure a concrete role profile in `.yoi/ticket.config.toml` for top-level TUI launches.
- Keep actions explicit and user-triggered; no scheduler/automation.
- Do not add spawned-Pod panel or dashboard redesign.
- Do not add arbitrary role registry UI.
## Current code map
- `crates/tui/src/command.rs`
- Current command registry is synchronous and returns `CommandExecution { method: Option<Method>, diagnostics, ... }`.
- Existing commands are `help`, `noop`, `compact`, `rewind`, and `peer`.
- Add a command action variant or equivalent so command parsing can request a Ticket role launch without overloading `protocol::Method`.
- `crates/tui/src/app.rs`
- `submit_command` / `apply_command_execution` currently return `Option<Method>`.
- May need a small result enum to carry either a Pod `Method` or a TUI-local action.
- `crates/tui/src/single_pod.rs`
- Has `PodRuntimeCommand` at launch entrypoints, but `run_loop` currently only receives `App` + `PodClient`.
- To execute role launch commands, pass `PodRuntimeCommand` into the loop/handler where needed.
- Command execution can call `client::launch_ticket_role_pod(...)` asynchronously and then show success/error notice.
- `crates/client/src/ticket_role.rs`
- Shared launcher API; use this instead of duplicating launch construction.
## Command semantics
MVP command syntax:
```text
:ticket intake <context...>
:ticket route <ticket-id-or-slug> [instruction...]
:ticket investigate <ticket-id-or-slug> [instruction...]
:ticket implement <ticket-id-or-slug> [instruction...]
:ticket review <ticket-id-or-slug> [instruction...]
```
For non-intake actions, treat the first argument as a Ticket id/slug. It is acceptable to use `TicketRef::slug(value)` in the MVP unless a clear id/slug parser already exists.
For `intake`, allow freeform context and no Ticket id.
Pod name may use the launcher default unless command syntax for `--name` is easy; do not overbuild CLI parsing.
## Non-goals
- Implementing the role launcher.
- Prompt resource resolution.
- Stateful workflow engine.
- Worktree creation automation.
- Multi-Pod dashboard redesign.
- Spawned-Pod panel.
- Scheduler/lease/queue automation.
- Generic role registry/UI.
- Arbitrary filesystem Ticket edits.
## Validation
Run at least:
- focused command parsing/action tests;
- `cargo test -p tui ticket --lib` or relevant focused TUI tests;
- `cargo test -p client ticket`;
- `cargo check --workspace --all-targets`;
- `cargo fmt --check`;
- `git diff --check`;
- `./tickets.sh doctor`.
Run `nix build .#yoi --no-link` if feasible.
## Completion report
Report:
- worktree path / branch;
- commit hash;
- command syntax implemented;
- how command execution calls the shared launcher;
- how diagnostics are surfaced;
- validation results;
- unresolved risks/follow-ups;
- whether ready for external review.

View File

@ -0,0 +1,114 @@
# Implementation report: tui-ticket-role-actions
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`
- Branch: `work/tui-ticket-role-actions`
## Commits
- `e125ebb feat: add TUI ticket role commands`
- `d288fa5 fix: require TUI ticket intake context`
## Summary
Added explicit TUI `:ticket` commands that launch fixed Ticket-role Pods through the shared `client` Ticket role launcher.
TUI now parses role actions, builds a `TicketRoleLaunchContext`, and calls `client::launch_ticket_role_pod(...)`. TUI does not construct `SpawnConfig`, profile selector semantics, workflow invocation segments, or first-run prompt content directly.
## Command syntax
Implemented commands:
```text
:ticket intake <context...>
:ticket route <ticket-id-or-slug> [instruction...]
:ticket investigate <ticket-id-or-slug> [instruction...]
:ticket implement <ticket-id-or-slug> [instruction...]
:ticket review <ticket-id-or-slug> [instruction...]
```
Role mapping:
- `intake` -> `TicketRole::Intake`
- `route` -> `TicketRole::Orchestrator`
- `investigate` -> `TicketRole::Investigator`
- `implement` -> `TicketRole::Coder`
- `review` -> `TicketRole::Reviewer`
`intake` requires non-empty context. Non-intake actions require a Ticket id/slug and preserve remaining text as the instruction.
## Changed files
- `crates/client/src/ticket_role.rs`
- `crates/tui/src/app.rs`
- `crates/tui/src/command.rs`
- `crates/tui/src/single_pod.rs`
## TUI plumbing
- Added `CommandAction::TicketRole(...)` as a TUI-local command action.
- `CommandExecution` can now carry either a Pod protocol method or local command action.
- `App` stores a pending command action after command submission.
- `single_pod.rs` handles the pending Ticket role action asynchronously and calls the shared client launcher.
- `PodRuntimeCommand` is passed narrowly into the single-Pod run loop/command-action handler so the launcher can start the role Pod.
## Diagnostics
- Launch start/success/failure are surfaced through actionbar notices.
- `UnsupportedInheritProfile` has a TUI-specific message explaining that top-level TUI Ticket launches require concrete role profiles in `.yoi/ticket.config.toml` until an inheritance-aware launch path exists.
- Missing non-intake Ticket refs and missing intake context return command diagnostics.
## Review status
External sibling review initially requested one blocker fix:
- `:ticket intake` accepted missing/whitespace-only context.
The blocker was fixed in `d288fa5`, and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- Start-progress actionbar notice may be overwritten by final success/failure before a redraw during slow launches.
- `:help ticket` could describe each role in more detail.
- Execution-path tests stop at context construction/error formatting; a future launcher seam could test success/failure actionbar plumbing without spawning real Pods.
## Validation
Coder-reported validation for the initial implementation passed:
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check`
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo fmt --check`
- `cargo check --workspace --all-targets`
- `./tickets.sh doctor`
- `cargo test -p tui --lib`
- `cargo test -p client`
- `nix build .#yoi`
Coder-reported validation for blocker fix passed:
- `cargo test -p tui ticket --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
Reviewer re-ran focused blocker validation:
- `cargo test -p tui ticket_intake --lib`
- `cargo test -p tui ticket --lib`
## Ready for merge
Yes.

View File

@ -0,0 +1,71 @@
# External review: tui-ticket-role-actions
## 1. Result: approve after blocker fix
Initial review requested changes because `:ticket intake` accepted missing context. Re-review of commit `d288fa590188bb700257e3cfa386b168661d9613` confirms that blocker is resolved and no new blocker was introduced.
## 2. Summary of implementation
The commit adds a new `:ticket <action> ...` TUI command in `crates/tui/src/command.rs`, carries successful parses as a TUI-local `CommandAction::TicketRole`, stores the pending local action on `App`, and handles it from the single-Pod event loop in `crates/tui/src/single_pod.rs`.
Execution builds a `TicketRoleLaunchContext` with the current working directory, fixed `TicketRole`, optional `TicketRef::slug(...)`, and optional freeform instruction, then calls `client::launch_ticket_role_pod(...)`. The TUI does not construct `SpawnConfig`, profile selectors, workflow invocation segments, or prompt contents directly. `crates/client/src/ticket_role.rs` only reexports `TicketRole` for this TUI-facing use.
## 3. Requirement-by-requirement assessment
- **Command syntax**: Met after `d288fa590188bb700257e3cfa386b168661d9613`. Implemented nested syntax close to the requested surface:
- `:ticket intake <context...>` parses when context is present, and `:ticket intake` / whitespace-only context now reject with `Invalid arguments. Usage: ticket intake <context...>`.
- `:ticket route <ticket-id-or-slug> [instruction...]`, `investigate`, `implement`, and `review` require a first ticket reference and preserve remaining text as instruction.
- **Role mapping**: Met. Mappings are `intake -> Intake`, `route -> Orchestrator`, `investigate -> Investigator`, `implement -> Coder`, `review -> Reviewer`.
- **Shared launcher use**: Met. `single_pod.rs` builds only `TicketRoleLaunchContext` and calls `launch_ticket_role_pod(...)`; no TUI-side `SpawnConfig`, profile semantics, workflow segments, or prompt construction were introduced.
- **Command action plumbing locality**: Met. The new `CommandAction` path is local, and existing `compact`, `rewind`, `peer`, help, and completion behavior remains structurally unchanged. Full `cargo test -p tui --lib` also passed.
- **`PodRuntimeCommand` threading**: Met. The value is passed narrowly into the single-Pod run loop and command-action handler so the shared launcher can spawn the role Pod.
- **Diagnostics**: Met for the reviewed command requirements. Success and failure are surfaced through actionbar notices, `UnsupportedInheritProfile` gets a clear TUI-specific message, missing non-intake ticket references are diagnosed, and missing intake context is now diagnosed.
- **Unsupported inherit profile explanation**: Met. The special-case message clearly tells the user to configure concrete role profiles in `.yoi/ticket.config.toml` for top-level TUI ticket launches.
- **Non-goals / scope control**: Met. I did not find scheduler/automation, spawned-Pod panel, dashboard redesign, generic role UI, prompt resolution, worktree automation, or arbitrary Ticket filesystem edits.
- **Tests**: Met for the blocker fix. Parsing and context construction tests exist, an inherit-profile diagnostic formatting test exists, and focused parser coverage now rejects missing/whitespace-only intake context. The launcher execution path is still covered indirectly by context construction rather than by a mocked launcher call/actionbar transition.
- **Validation**: Met for the commands I reran; see section 6.
## 4. Blockers
No blockers remain after `d288fa590188bb700257e3cfa386b168661d9613`.
- Resolved: `:ticket intake` previously accepted an empty context; it now rejects missing or whitespace-only context with `Invalid arguments. Usage: ticket intake <context...>`, and focused parser coverage was added.
## 5. Non-blockers / follow-ups
- The `Launching ticket ... Pod...` actionbar notice is set immediately before awaiting `launch_ticket_role_pod(...)` (`crates/tui/src/single_pod.rs:563-589`). Because the event loop does not redraw between setting that notice and awaiting the launch, users may only see the final success/failure notice for slow launches. This is acceptable for this MVP, but a follow-up could force a draw or move launch work to an async task if start-progress visibility matters.
- Help text for `:ticket` names the fixed actions but does not explain each role individually. The behavior is discoverable enough for MVP, but richer `:help ticket` text would better satisfy the acceptance criterion that command/action help makes clear what each role does.
- Execution-path tests stop at context construction and error formatting. A future small seam around the launcher call would allow direct tests for actionbar success/failure plumbing without spawning real Pods.
## 6. Validation assessed or rerun
Reran from `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`:
- `git diff --check` — passed.
- `cargo test -p tui ticket --lib` — passed, 5 tests.
- `cargo test -p client ticket` — passed, 6 tests.
- `cargo fmt --check` — passed.
- `cargo check --workspace --all-targets` — passed.
- `./tickets.sh doctor` — passed (`doctor: ok`).
- `cargo test -p tui --lib` — passed, 224 tests.
- `cargo test -p client` — passed, 11 tests plus doc-tests.
- `nix build .#yoi` — passed; Nix emitted a dirty-tree warning for the worktree. The transient `result` symlink created by Nix was removed afterward; `git status --short` showed no tracked changes.
## 7. Residual risk
After the intake-context diagnostic is fixed, residual risk is low. The implementation stays within the intended TUI-command surface and delegates launch semantics to the client launcher. The remaining risks are mostly UX/test-depth issues around live launch progress visibility and lack of a direct mocked launcher execution test.
## 8. Re-review of blocker fix (`d288fa590188bb700257e3cfa386b168661d9613`)
### Result: approve
The blocker is resolved. The fix updates `ticket_args` so `intake` now requires at least one non-whitespace argument and returns `Invalid arguments. Usage: ticket intake <context...>` when context is missing. The existing `ticket_command` path then continues to pass the non-empty intake context as `instruction`, with no Ticket reference, preserving the intended Intake launch behavior.
Focused parser coverage was added for both `:ticket intake` and whitespace-only `:ticket intake ` rejection. The change is limited to `crates/tui/src/command.rs`; I did not find any new blocker or source-scope expansion in this fix.
Validation rerun from `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`:
- `cargo test -p tui ticket_intake --lib` — passed.
- `cargo test -p tui ticket --lib` — passed.
No additional full-build validation was rerun for this narrow parser-only fix.

View File

@ -0,0 +1,95 @@
---
id: 20260605-190330-tui-ticket-role-actions
slug: tui-ticket-role-actions
title: TUI Ticket role actions
status: closed
kind: task
priority: P1
labels: [tui, ticket, role, orchestration]
created_at: 2026-06-05T19:03:30Z
updated_at: 2026-06-05T20:09:50Z
assignee: null
legacy_ticket: null
---
## Background
After `ticket-role-pod-launcher` lands, TUI should expose simple Ticket-role actions without embedding launch/profile/prompt/workflow construction logic in UI code.
This ticket is for the TUI surface only. The launcher is responsible for reading `.yoi/ticket.config.toml`, selecting the Profile, constructing first-run input, and launching/sending to a role Pod. TUI should call that layer and present conservative, understandable actions.
## Goal
Add TUI actions for fixed Ticket roles using the shared Ticket role launcher.
The initial UI should favor explicit command/action entry over broad dashboard redesign. It should make it possible to start the relevant role Pod for a Ticket-oriented task while preserving human/orchestrator control.
## Requirements
- Depend on the shared `ticket-role-pod-launcher` API; do not duplicate launch construction in TUI.
- Support fixed Ticket role actions:
- Intake / refine Ticket;
- Route Ticket with Orchestrator;
- Investigate Ticket;
- Implement Ticket;
- Review Ticket.
- Use `.yoi/ticket.config.toml` indirectly through the launcher.
- Show clear diagnostics/notices when:
- Ticket config is malformed;
- backend root is unusable;
- selected Profile is invalid or spawn fails;
- required Ticket id/slug/context is missing.
- Keep actions explicit and user-triggered. Do not introduce automatic scheduling.
- Do not add a spawned-Pod panel in this ticket.
- Do not add a generic arbitrary-role launcher UI.
- Do not bypass Ticket Intake / Orchestrator Routing / Preflight / Multi-agent Workflow gates.
- Prefer command/actionbar integration first if that is less invasive than adding a new full screen.
## Candidate command surface
Exact names may change during implementation, but the MVP should be close to:
```text
:ticket intake <ticket-or-new-context>
:ticket route <ticket-id-or-slug>
:ticket investigate <ticket-id-or-slug>
:ticket implement <ticket-id-or-slug>
:ticket review <ticket-id-or-slug>
```
If current TUI command parsing makes nested subcommands awkward, use a simpler MVP shape such as:
```text
:ticket-intake ...
:ticket-route ...
:ticket-investigate ...
:ticket-implement ...
:ticket-review ...
```
## Non-goals
- Implementing the role launcher.
- TUI spawned child Pod panel.
- Multi-Pod dashboard redesign.
- Scheduler/lease/queue automation.
- Stateful workflow engine.
- TicketUpdate tool.
- Worktree creation automation beyond what the launcher/Multi-agent Workflow already describes.
- Generic Role registry/UI.
- Arbitrary filesystem Ticket edits.
## Acceptance criteria
- TUI has user-triggered Ticket role actions/commands for the fixed roles or a clearly documented MVP subset.
- Actions call the shared launcher rather than building spawn requests directly.
- Role action failures surface as actionbar/command diagnostics without crashing TUI.
- The command/action help text makes clear what each role does.
- TUI tests cover parsing/execution path for actions and at least one failure diagnostic.
- `cargo test -p tui` or focused TUI tests pass.
- `cargo test -p client` passes if the launcher crate is touched by integration.
- `cargo check --workspace --all-targets`, `cargo fmt --check`, `git diff --check`, and `./tickets.sh doctor` pass.
## Dependencies
- Requires `ticket-role-pod-launcher`.

View File

@ -0,0 +1,53 @@
TUI Ticket role actions are complete and merged.
Implementation:
- `e125ebb feat: add TUI ticket role commands`
- `d288fa5 fix: require TUI ticket intake context`
- merge commit: `5d3209d merge: add tui ticket role actions`
Summary:
- Added explicit TUI `:ticket` commands for fixed Ticket roles:
- `:ticket intake <context...>`
- `:ticket route <ticket-id-or-slug> [instruction...]`
- `:ticket investigate <ticket-id-or-slug> [instruction...]`
- `:ticket implement <ticket-id-or-slug> [instruction...]`
- `:ticket review <ticket-id-or-slug> [instruction...]`
- Mapped actions to fixed Ticket roles:
- intake -> Intake
- route -> Orchestrator
- investigate -> Investigator
- implement -> Coder
- review -> Reviewer
- Added TUI-local `CommandAction::TicketRole(...)` plumbing.
- TUI builds `TicketRoleLaunchContext` and calls the shared `client::launch_ticket_role_pod(...)` launcher.
- TUI does not construct `SpawnConfig`, profile selector semantics, workflow segments, or first-run prompt content directly.
- `PodRuntimeCommand` is passed narrowly into the single-Pod command handling path for launching role Pods.
- Success/failure is surfaced through actionbar notices.
- `UnsupportedInheritProfile` receives a clear message explaining that top-level TUI Ticket launches require concrete role profiles in `.yoi/ticket.config.toml` until an inheritance-aware launch path exists.
- No scheduler, spawned-Pod panel, dashboard redesign, generic role UI, prompt resolution, worktree automation, or arbitrary Ticket filesystem edits were introduced.
Review:
- External sibling review initially requested one blocker fix: `:ticket intake` accepted missing/whitespace-only context.
- Coder fixed it in `d288fa5`; `:ticket intake` now requires non-empty context and has focused parser tests.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Launch-start actionbar notice may not be visible before final success/failure during slow launches because the loop awaits launch before redraw.
- `:help ticket` could explain each role in more detail.
- A future launcher seam could test actionbar success/failure plumbing without spawning real Pods.
Post-merge validation passed:
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo test -p tui --lib`
- `cargo test -p client`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`

View File

@ -0,0 +1,406 @@
<!-- event: create author: tickets.sh at: 2026-06-05T19:03:30Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-06-05T19:06:17Z -->
## Plan
Plan: implement after `ticket-role-pod-launcher` lands.
TUI should expose explicit user-triggered Ticket role actions/commands and call the shared launcher. It should not duplicate profile/config/prompt/workflow launch construction, introduce automatic scheduling, or add a spawned-Pod panel in this ticket.
---
<!-- event: plan author: hare at: 2026-06-05T19:36:50Z -->
## Plan
# Delegation intent: TUI Ticket role actions
## Intent
Add explicit TUI commands/actions that launch fixed Ticket-role Pods through the shared `client` Ticket role launcher.
TUI should not duplicate role/profile/workflow/prompt construction. It should parse user commands, build a typed launcher context, call the client launcher, and surface success/failure diagnostics in the TUI.
## Worktree / branch
- worktree: `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`
- branch: `work/tui-ticket-role-actions`
## Dependencies
`ticket-role-pod-launcher` is complete and merged. Use the client API added there:
- `client::TicketRoleLaunchContext`
- `client::TicketRef`
- `client::launch_ticket_role_pod`
- related launch errors/result types
## Requirements
- Add user-triggered TUI commands for fixed Ticket roles.
- Prefer a single `:ticket <action> ...` command if it fits the current command parser.
- Support at least these actions:
- `:ticket intake <context...>`
- `:ticket route <ticket-id-or-slug> [instruction...]`
- `:ticket investigate <ticket-id-or-slug> [instruction...]`
- `:ticket implement <ticket-id-or-slug> [instruction...]`
- `:ticket review <ticket-id-or-slug> [instruction...]`
- Map actions to roles:
- intake -> `TicketRole::Intake`
- route -> `TicketRole::Orchestrator`
- investigate -> `TicketRole::Investigator`
- implement -> `TicketRole::Coder`
- review -> `TicketRole::Reviewer`
- Use the shared launcher; do not build `SpawnConfig`, Profile selectors, Workflow segments, or first-run prompt content directly in TUI.
- Make command parsing/test behavior deterministic.
- Surface launcher failures as TUI command/actionbar diagnostics without crashing.
- Make `UnsupportedInheritProfile` especially clear: the user must configure a concrete role profile in `.yoi/ticket.config.toml` for top-level TUI launches.
- Keep actions explicit and user-triggered; no scheduler/automation.
- Do not add spawned-Pod panel or dashboard redesign.
- Do not add arbitrary role registry UI.
## Current code map
- `crates/tui/src/command.rs`
- Current command registry is synchronous and returns `CommandExecution { method: Option<Method>, diagnostics, ... }`.
- Existing commands are `help`, `noop`, `compact`, `rewind`, and `peer`.
- Add a command action variant or equivalent so command parsing can request a Ticket role launch without overloading `protocol::Method`.
- `crates/tui/src/app.rs`
- `submit_command` / `apply_command_execution` currently return `Option<Method>`.
- May need a small result enum to carry either a Pod `Method` or a TUI-local action.
- `crates/tui/src/single_pod.rs`
- Has `PodRuntimeCommand` at launch entrypoints, but `run_loop` currently only receives `App` + `PodClient`.
- To execute role launch commands, pass `PodRuntimeCommand` into the loop/handler where needed.
- Command execution can call `client::launch_ticket_role_pod(...)` asynchronously and then show success/error notice.
- `crates/client/src/ticket_role.rs`
- Shared launcher API; use this instead of duplicating launch construction.
## Command semantics
MVP command syntax:
```text
:ticket intake <context...>
:ticket route <ticket-id-or-slug> [instruction...]
:ticket investigate <ticket-id-or-slug> [instruction...]
:ticket implement <ticket-id-or-slug> [instruction...]
:ticket review <ticket-id-or-slug> [instruction...]
```
For non-intake actions, treat the first argument as a Ticket id/slug. It is acceptable to use `TicketRef::slug(value)` in the MVP unless a clear id/slug parser already exists.
For `intake`, allow freeform context and no Ticket id.
Pod name may use the launcher default unless command syntax for `--name` is easy; do not overbuild CLI parsing.
## Non-goals
- Implementing the role launcher.
- Prompt resource resolution.
- Stateful workflow engine.
- Worktree creation automation.
- Multi-Pod dashboard redesign.
- Spawned-Pod panel.
- Scheduler/lease/queue automation.
- Generic role registry/UI.
- Arbitrary filesystem Ticket edits.
## Validation
Run at least:
- focused command parsing/action tests;
- `cargo test -p tui ticket --lib` or relevant focused TUI tests;
- `cargo test -p client ticket`;
- `cargo check --workspace --all-targets`;
- `cargo fmt --check`;
- `git diff --check`;
- `./tickets.sh doctor`.
Run `nix build .#yoi --no-link` if feasible.
## Completion report
Report:
- worktree path / branch;
- commit hash;
- command syntax implemented;
- how command execution calls the shared launcher;
- how diagnostics are surfaced;
- validation results;
- unresolved risks/follow-ups;
- whether ready for external review.
---
<!-- event: review author: hare at: 2026-06-05T20:07:21Z status: approve -->
## Review: approve
# External review: tui-ticket-role-actions
## 1. Result: approve after blocker fix
Initial review requested changes because `:ticket intake` accepted missing context. Re-review of commit `d288fa590188bb700257e3cfa386b168661d9613` confirms that blocker is resolved and no new blocker was introduced.
## 2. Summary of implementation
The commit adds a new `:ticket <action> ...` TUI command in `crates/tui/src/command.rs`, carries successful parses as a TUI-local `CommandAction::TicketRole`, stores the pending local action on `App`, and handles it from the single-Pod event loop in `crates/tui/src/single_pod.rs`.
Execution builds a `TicketRoleLaunchContext` with the current working directory, fixed `TicketRole`, optional `TicketRef::slug(...)`, and optional freeform instruction, then calls `client::launch_ticket_role_pod(...)`. The TUI does not construct `SpawnConfig`, profile selectors, workflow invocation segments, or prompt contents directly. `crates/client/src/ticket_role.rs` only reexports `TicketRole` for this TUI-facing use.
## 3. Requirement-by-requirement assessment
- **Command syntax**: Met after `d288fa590188bb700257e3cfa386b168661d9613`. Implemented nested syntax close to the requested surface:
- `:ticket intake <context...>` parses when context is present, and `:ticket intake` / whitespace-only context now reject with `Invalid arguments. Usage: ticket intake <context...>`.
- `:ticket route <ticket-id-or-slug> [instruction...]`, `investigate`, `implement`, and `review` require a first ticket reference and preserve remaining text as instruction.
- **Role mapping**: Met. Mappings are `intake -> Intake`, `route -> Orchestrator`, `investigate -> Investigator`, `implement -> Coder`, `review -> Reviewer`.
- **Shared launcher use**: Met. `single_pod.rs` builds only `TicketRoleLaunchContext` and calls `launch_ticket_role_pod(...)`; no TUI-side `SpawnConfig`, profile semantics, workflow segments, or prompt construction were introduced.
- **Command action plumbing locality**: Met. The new `CommandAction` path is local, and existing `compact`, `rewind`, `peer`, help, and completion behavior remains structurally unchanged. Full `cargo test -p tui --lib` also passed.
- **`PodRuntimeCommand` threading**: Met. The value is passed narrowly into the single-Pod run loop and command-action handler so the shared launcher can spawn the role Pod.
- **Diagnostics**: Met for the reviewed command requirements. Success and failure are surfaced through actionbar notices, `UnsupportedInheritProfile` gets a clear TUI-specific message, missing non-intake ticket references are diagnosed, and missing intake context is now diagnosed.
- **Unsupported inherit profile explanation**: Met. The special-case message clearly tells the user to configure concrete role profiles in `.yoi/ticket.config.toml` for top-level TUI ticket launches.
- **Non-goals / scope control**: Met. I did not find scheduler/automation, spawned-Pod panel, dashboard redesign, generic role UI, prompt resolution, worktree automation, or arbitrary Ticket filesystem edits.
- **Tests**: Met for the blocker fix. Parsing and context construction tests exist, an inherit-profile diagnostic formatting test exists, and focused parser coverage now rejects missing/whitespace-only intake context. The launcher execution path is still covered indirectly by context construction rather than by a mocked launcher call/actionbar transition.
- **Validation**: Met for the commands I reran; see section 6.
## 4. Blockers
No blockers remain after `d288fa590188bb700257e3cfa386b168661d9613`.
- Resolved: `:ticket intake` previously accepted an empty context; it now rejects missing or whitespace-only context with `Invalid arguments. Usage: ticket intake <context...>`, and focused parser coverage was added.
## 5. Non-blockers / follow-ups
- The `Launching ticket ... Pod...` actionbar notice is set immediately before awaiting `launch_ticket_role_pod(...)` (`crates/tui/src/single_pod.rs:563-589`). Because the event loop does not redraw between setting that notice and awaiting the launch, users may only see the final success/failure notice for slow launches. This is acceptable for this MVP, but a follow-up could force a draw or move launch work to an async task if start-progress visibility matters.
- Help text for `:ticket` names the fixed actions but does not explain each role individually. The behavior is discoverable enough for MVP, but richer `:help ticket` text would better satisfy the acceptance criterion that command/action help makes clear what each role does.
- Execution-path tests stop at context construction and error formatting. A future small seam around the launcher call would allow direct tests for actionbar success/failure plumbing without spawning real Pods.
## 6. Validation assessed or rerun
Reran from `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`:
- `git diff --check` — passed.
- `cargo test -p tui ticket --lib` — passed, 5 tests.
- `cargo test -p client ticket` — passed, 6 tests.
- `cargo fmt --check` — passed.
- `cargo check --workspace --all-targets` — passed.
- `./tickets.sh doctor` — passed (`doctor: ok`).
- `cargo test -p tui --lib` — passed, 224 tests.
- `cargo test -p client` — passed, 11 tests plus doc-tests.
- `nix build .#yoi` — passed; Nix emitted a dirty-tree warning for the worktree. The transient `result` symlink created by Nix was removed afterward; `git status --short` showed no tracked changes.
## 7. Residual risk
After the intake-context diagnostic is fixed, residual risk is low. The implementation stays within the intended TUI-command surface and delegates launch semantics to the client launcher. The remaining risks are mostly UX/test-depth issues around live launch progress visibility and lack of a direct mocked launcher execution test.
## 8. Re-review of blocker fix (`d288fa590188bb700257e3cfa386b168661d9613`)
### Result: approve
The blocker is resolved. The fix updates `ticket_args` so `intake` now requires at least one non-whitespace argument and returns `Invalid arguments. Usage: ticket intake <context...>` when context is missing. The existing `ticket_command` path then continues to pass the non-empty intake context as `instruction`, with no Ticket reference, preserving the intended Intake launch behavior.
Focused parser coverage was added for both `:ticket intake` and whitespace-only `:ticket intake ` rejection. The change is limited to `crates/tui/src/command.rs`; I did not find any new blocker or source-scope expansion in this fix.
Validation rerun from `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`:
- `cargo test -p tui ticket_intake --lib` — passed.
- `cargo test -p tui ticket --lib` — passed.
No additional full-build validation was rerun for this narrow parser-only fix.
---
<!-- event: implementation_report author: hare at: 2026-06-05T20:07:22Z -->
## Implementation report
# Implementation report: tui-ticket-role-actions
## Worktree / branch
- Worktree: `/home/hare/Projects/yoi/.worktree/tui-ticket-role-actions`
- Branch: `work/tui-ticket-role-actions`
## Commits
- `e125ebb feat: add TUI ticket role commands`
- `d288fa5 fix: require TUI ticket intake context`
## Summary
Added explicit TUI `:ticket` commands that launch fixed Ticket-role Pods through the shared `client` Ticket role launcher.
TUI now parses role actions, builds a `TicketRoleLaunchContext`, and calls `client::launch_ticket_role_pod(...)`. TUI does not construct `SpawnConfig`, profile selector semantics, workflow invocation segments, or first-run prompt content directly.
## Command syntax
Implemented commands:
```text
:ticket intake <context...>
:ticket route <ticket-id-or-slug> [instruction...]
:ticket investigate <ticket-id-or-slug> [instruction...]
:ticket implement <ticket-id-or-slug> [instruction...]
:ticket review <ticket-id-or-slug> [instruction...]
```
Role mapping:
- `intake` -> `TicketRole::Intake`
- `route` -> `TicketRole::Orchestrator`
- `investigate` -> `TicketRole::Investigator`
- `implement` -> `TicketRole::Coder`
- `review` -> `TicketRole::Reviewer`
`intake` requires non-empty context. Non-intake actions require a Ticket id/slug and preserve remaining text as the instruction.
## Changed files
- `crates/client/src/ticket_role.rs`
- `crates/tui/src/app.rs`
- `crates/tui/src/command.rs`
- `crates/tui/src/single_pod.rs`
## TUI plumbing
- Added `CommandAction::TicketRole(...)` as a TUI-local command action.
- `CommandExecution` can now carry either a Pod protocol method or local command action.
- `App` stores a pending command action after command submission.
- `single_pod.rs` handles the pending Ticket role action asynchronously and calls the shared client launcher.
- `PodRuntimeCommand` is passed narrowly into the single-Pod run loop/command-action handler so the launcher can start the role Pod.
## Diagnostics
- Launch start/success/failure are surfaced through actionbar notices.
- `UnsupportedInheritProfile` has a TUI-specific message explaining that top-level TUI Ticket launches require concrete role profiles in `.yoi/ticket.config.toml` until an inheritance-aware launch path exists.
- Missing non-intake Ticket refs and missing intake context return command diagnostics.
## Review status
External sibling review initially requested one blocker fix:
- `:ticket intake` accepted missing/whitespace-only context.
The blocker was fixed in `d288fa5`, and re-review approved with no blockers.
Remaining non-blocker follow-ups:
- Start-progress actionbar notice may be overwritten by final success/failure before a redraw during slow launches.
- `:help ticket` could describe each role in more detail.
- Execution-path tests stop at context construction/error formatting; a future launcher seam could test success/failure actionbar plumbing without spawning real Pods.
## Validation
Coder-reported validation for the initial implementation passed:
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
Reviewer-rerun validation passed:
- `git diff --check`
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo fmt --check`
- `cargo check --workspace --all-targets`
- `./tickets.sh doctor`
- `cargo test -p tui --lib`
- `cargo test -p client`
- `nix build .#yoi`
Coder-reported validation for blocker fix passed:
- `cargo test -p tui ticket --lib`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
Reviewer re-ran focused blocker validation:
- `cargo test -p tui ticket_intake --lib`
- `cargo test -p tui ticket --lib`
## Ready for merge
Yes.
---
<!-- event: close author: hare at: 2026-06-05T20:09:50Z status: closed -->
## Closed
TUI Ticket role actions are complete and merged.
Implementation:
- `e125ebb feat: add TUI ticket role commands`
- `d288fa5 fix: require TUI ticket intake context`
- merge commit: `5d3209d merge: add tui ticket role actions`
Summary:
- Added explicit TUI `:ticket` commands for fixed Ticket roles:
- `:ticket intake <context...>`
- `:ticket route <ticket-id-or-slug> [instruction...]`
- `:ticket investigate <ticket-id-or-slug> [instruction...]`
- `:ticket implement <ticket-id-or-slug> [instruction...]`
- `:ticket review <ticket-id-or-slug> [instruction...]`
- Mapped actions to fixed Ticket roles:
- intake -> Intake
- route -> Orchestrator
- investigate -> Investigator
- implement -> Coder
- review -> Reviewer
- Added TUI-local `CommandAction::TicketRole(...)` plumbing.
- TUI builds `TicketRoleLaunchContext` and calls the shared `client::launch_ticket_role_pod(...)` launcher.
- TUI does not construct `SpawnConfig`, profile selector semantics, workflow segments, or first-run prompt content directly.
- `PodRuntimeCommand` is passed narrowly into the single-Pod command handling path for launching role Pods.
- Success/failure is surfaced through actionbar notices.
- `UnsupportedInheritProfile` receives a clear message explaining that top-level TUI Ticket launches require concrete role profiles in `.yoi/ticket.config.toml` until an inheritance-aware launch path exists.
- No scheduler, spawned-Pod panel, dashboard redesign, generic role UI, prompt resolution, worktree automation, or arbitrary Ticket filesystem edits were introduced.
Review:
- External sibling review initially requested one blocker fix: `:ticket intake` accepted missing/whitespace-only context.
- Coder fixed it in `d288fa5`; `:ticket intake` now requires non-empty context and has focused parser tests.
- Re-review approved with no blockers.
Non-blocker follow-ups:
- Launch-start actionbar notice may not be visible before final success/failure during slow launches because the loop awaits launch before redraw.
- `:help ticket` could explain each role in more detail.
- A future launcher seam could test actionbar success/failure plumbing without spawning real Pods.
Post-merge validation passed:
- `cargo test -p tui ticket --lib`
- `cargo test -p client ticket`
- `cargo test -p tui --lib`
- `cargo test -p client`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `./tickets.sh doctor`
- `nix build .#yoi --no-link`
---

View File

@ -1,7 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:01Z -->
## Migrated
Migrated from tickets/auto-maintain-workflow.md. No legacy review file was present at migration time.
---

View File

@ -155,7 +155,7 @@ Claude Code 版の `tool_uses` を、insomnia では tool 種別ごとの偏り
- 評価 event schema が docs または ticket 内で定義されている
- eval event を memory consolidation / usage metrics / Workflow improvement offer / `model_invokation` 判断へ接続する方針が文書化されている
- 既存の Workflow 自動生成禁止・history に commit されない context input 禁止・memory consolidation 方針に反していない
- `/auto-maintain` または `/worktree-workflow` のどちらか 1 件を対象に、構造審査または小規模 evaluator Pod 試走を行い、結果を記録している
- `ticket-intake-workflow` / `ticket-orchestrator-routing` / `worktree-workflow` のいずれか 1 件を対象に、構造審査または小規模 evaluator Pod 試走を行い、結果を記録している
## 参照
@ -163,4 +163,4 @@ Claude Code 版の `tool_uses` を、insomnia では tool 種別ごとの偏り
- `docs/plan/workflow.md`
- `docs/plan/memory.md`
- `tickets/memory-usage-metrics.md`
- `tickets/auto-maintain-workflow.md`
- `ticket-intake-workflow.md` / `ticket-orchestrator-routing.md`

View File

@ -1,74 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-06-04T23:48:44Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-06-04T23:50:15Z -->
## Decision
# Decision: separate internal feature modules from external-plugin authority
Internal modules extracted from Pod implementation files should not be treated as if they require the external-plugin permission model.
For an internal built-in module such as Task tools:
- the feature registry is an API/registration boundary;
- descriptor-declared contributions are reconciled at install time;
- normal ToolRegistry and PreToolCall permission behavior remains authoritative;
- host state such as `TaskStore` can be passed by the Pod host constructor;
- requested host authorities should normally be empty.
The external-plugin authority model remains necessary for sandbox/object-capability grants when plugin code receives dangerous host APIs such as filesystem, network, secrets, model-visible durable notification/history append, Pod-management façade, persistent state, or authority-bearing service access.
This split should be implemented separately from the Task tools extraction. The Task tools extraction should validate the contribution-only built-in module path without solving external plugin approval.
---
<!-- event: decision author: hare at: 2026-06-05T00:49:53Z -->
## Decision
# Decision: authority handles live in Hook contexts, not Hook return effects
The internal-module and external-plugin authority split should treat host-authority APIs as handles supplied by the host, including inside Hooks.
Implications:
- Hook return values remain per-hook-point flow-control actions.
- Side effects such as durable model-visible SystemItem append are performed through typed host handles on event-specific Hook contexts.
- Built-in internal modules may receive handles according to host policy without user-facing external-plugin approval.
- Future external plugins receive only the handles allowed by their approved host authorities.
- The main API should not be “return an effect and let the host reject it at runtime.” Rejection remains defense-in-depth for malformed calls, missing handles, bounds, and policy violations.
- Do not model every authority combination as a distinct Hook context type. Use event-specific context types with authority-specific handles whose constructors are host-owned.
This preserves the clean distinction: contribution declarations are descriptor-locked; dangerous host APIs are represented by host-created handles; normal tool permission remains the per-call execution gate.
---
<!-- event: plan author: hare at: 2026-06-05T04:54:33Z -->
## Plan
Preflight result: `implementation-ready`.
`ticket-built-in-feature-tools` should not be implemented until this boundary is clarified, because Ticket tools need to be internal built-in feature contributions while Ticket backend operations remain typed host authority, not arbitrary filesystem scope or external plugin package approval.
Implementation intent:
- Clarify `pod::feature` naming around host authority grants.
- Keep contribution declarations and descriptor reconciliation separate from host authority requests/grants.
- Preserve built-in Task feature behavior as the current contribution-only example.
- Avoid external plugin loading, real approval protocol, Ticket tool implementation, Hook behavior changes, or broader crate moves.
Detailed delegation intent is in `artifacts/delegation-intent.md`.
Note: main workspace currently has an unrelated dirty `README.md` change. This feature work must not touch or commit that file.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-06-05T04:01:04Z -->
## Created
Created by tickets.sh create.
---