merge: workspace ticket settings

This commit is contained in:
Keisuke Hirata 2026-07-16 04:03:53 +09:00
commit f32496286e
No known key found for this signature in database
18 changed files with 1089 additions and 225 deletions

View File

@ -1,22 +0,0 @@
[backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket]
language = "Japanese"
[roles.intake]
profile = "builtin:intake"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "builtin:orchestrator"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "builtin:coder"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "builtin:reviewer"
workflow = "multi-agent-workflow"

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260715-162205-1","ticket_id":"00001KXK9507N","kind":"accepted_plan","accepted_plan":{"summary":"Consolidate durable Ticket configuration from `.yoi/ticket.config.toml` into tracked Workspace project settings. Move Ticket backend provider/root, record language, orchestration defaults, and fixed role launch config under Workspace settings while keeping local backend/runtime operational settings separate. Preserve default `.yoi/tickets` behavior, allow narrow read-only legacy fallback/migration note, and ensure CLI, Worker Ticket tools, workspace-server Ticket endpoint, Panel/role launch paths, and tests resolve the new Workspace settings authority.","branch":"work/00001KXK9507N-ticket-workspace-settings","worktree":"/home/hare/Projects/yoi/.worktree/00001KXK9507N-ticket-workspace-settings","role_plan":"Routing-only pass under explicit workflow instruction: do not spawn role Pods until explicit follow-up. On follow-up, re-check Ticket/relation/plan/worktree state, record `queued -> inprogress`, create a dedicated worktree under `/home/hare/Projects/yoi/.worktree`, then use sibling Coder/Reviewer Pods. Orchestrator retains merge/final-validation/close/worktree-cleanup authority; per current user instruction, do not call StopPod."},"author":"orchestrator","at":"2026-07-15T16:22:05Z"}
{"id":"orch-plan-20260715-162216-2","ticket_id":"00001KXK9507N","kind":"waiting_capacity_note","note":"Routing-only workflow invocation explicitly instructed: “wait for explicit follow-up before spawning role Pods.” Ticket is implementation-ready and no blockers were found, but this pass intentionally does not transition `queued -> inprogress`, create a worktree, or spawn Coder/Reviewer Pods. Next explicit follow-up should re-check state, then accept `queued -> inprogress` before implementation side effects.","author":"orchestrator","at":"2026-07-15T16:22:16Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Consolidate Ticket configuration into Workspace settings'
state: 'queued'
state: 'closed'
created_at: '2026-07-15T16:18:25Z'
updated_at: '2026-07-15T16:19:31Z'
updated_at: '2026-07-15T17:51:30Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-15T16:19:31Z'

View File

@ -0,0 +1,37 @@
Ticket configuration の Workspace settings 統合を実装・レビュー・merge・検証した。
実装内容:
- durable Ticket policy authority を `.yoi/workspace.toml [ticket]` に移動。
- tracked `.yoi/ticket.config.toml` を削除。
- Workspace `[ticket]` がない場合のみ legacy `.yoi/ticket.config.toml` を narrow read-only migration fallback として読むようにした。
- Workspace `[ticket]` が存在する場合は legacy config を無視し、二重 active authority を避ける precedence にした。
- default behavior は維持し、設定なしでは `provider = builtin:yoi_local`, `root = .yoi/tickets` を使う。
- Ticket backend provider/root、record language、orchestration defaults、fixed role launch config を Workspace settings authority に移した。
- Direct Ticket CLI / Objective config consumers / Worker Ticket feature setup / workspace-server Ticket backend endpoint / project record reader / Panel role launch availability / role launch tests を新 authority 解決へ更新。
- Panel orchestration overlay も orchestration worktree の `TicketConfig::load_workspace(worktree_root)` を使い、configured backend root と record language を尊重するよう修正。
- docs で `.yoi/ticket.config.toml` obsolete と新 `[ticket]` workspace settings shape を記録。
Review:
- 初回 review は Panel orchestration overlay が `.yoi/tickets` を hard-code している blocker で `request_changes`
- `40f21145 tui: use configured ticket root for overlay` で overlay read path を configured Ticket root に移行し、non-default root regression test を追加。
- focused re-review は `approve`
Merge / validation:
- Merge commit: `45783b74 merge: workspace ticket settings`
- Final validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p yoi --tests`
- `cargo test -p client --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p tui --lib --tests`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-ticket-settings-final-validation-1784137566.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。

View File

@ -57,4 +57,361 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-15T16:22:50Z -->
## Decision
Routing decision: implementation_ready_waiting_explicit_followup
Reason:
- Ticket は Workspace project settings を Ticket configuration の durable authority にする具体的な移行作業であり、要件・受け入れ条件・migration/fallback 方針が実装可能な粒度で記録されている。
- `TicketRelationQuery(00001KXK9507N)` は 0 件で、blocking relation はない。
- `TicketOrchestrationPlanQuery(00001KXK9507N)` は事前 record なし。今回 accepted plan と waiting-capacity note を記録した。
- `TicketList(inprogress)` は 0 件、`TicketList(queued)` はこの Ticket 1 件のみ。
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` は clean。
- ただし今回の workflow invocation は「explicit follow-up before spawning role Pods」と明示しているため、この routing pass では `queued -> inprogress`、worktree 作成、Coder/Reviewer Pod spawn は行わない。
Evidence checked:
- Ticket body / thread / artifacts。
- `.yoi/workspace.toml` and `.yoi/ticket.config.toml` current files。
- `docs/development/work-items.md` and `docs/design/workflows-public-dogfood-split.md` Ticket config references。
- Code map via grep:
- `crates/yoi/src/ticket_cli.rs`, `crates/yoi/src/objective_cli.rs`
- `crates/tui/src/workspace_panel.rs`
- `crates/worker/src/feature/builtin/ticket.rs`, `crates/worker/src/controller.rs`
- workspace identity/settings references in `crates/worker`, `crates/worker-runtime`, workspace-server related code。
- Current worktrees/branches: no existing `work/00001KXK9507N-*` branch/worktree。
- Visible Pods: no active role Pod needed for this routing-only pass。
IntentPacket:
Intent:
- `.yoi/ticket.config.toml` を長期 active authority から外し、tracked Workspace project settings の namespace に Ticket configuration を統合する。
- Ticket backend provider/root、Ticket record language、orchestration defaults、固定 role launch config を Workspace settings authority から解決する。
Binding decisions / invariants:
- local-only backend/runtime operational settingsDB path、listen address、runtime endpoint/store 等)と tracked project/workspace Ticket policy を混ぜない。
- Workspace settings が Ticket configuration の durable authority になる。
- `.yoi/ticket.config.toml` は必要な場合だけ narrow read-only migration fallback として扱い、長期 active authority として残さない。
- default behavior は保つ: 設定なし/移行後 default では既存 `.yoi/tickets` data に対して CLI と Worker Ticket tools が動く。
- workspace-server Ticket backend endpoint は `.yoi/tickets` hard-code ではなく configured Ticket backend root を尊重する。
- role launch config は fixed role slots を維持し、Profile/workflow selector の authority を Workspace settings 側に移す。
- Ticket record language policy は durable Ticket records に適用し、通常 worker prose language と混同しない。
Requirements / acceptance criteria:
- Workspace settings loading が Ticket backend root/provider、language、orchestration defaults、role slots を読む test を追加/更新する。
- Ticket CLI / Objective CLI / Worker Ticket feature setup / workspace-server Ticket backend endpoint / Panel role launch path が Workspace settings authority から Ticket settings を解決する。
- Workspace settings で non-default Ticket backend root を設定した場合、direct CLI/local path と workspace-server-backed Worker Ticket tools の両方が尊重する。
- `.yoi/ticket.config.toml` obsolete/replaced の docs または migration/in-code note を残す。
- affected crates の cargo tests と `nix build .#yoi` が通る。
Implementation latitude:
- 既存 `.yoi/workspace.toml` を拡張するか、tracked Workspace project settings file を別に導入するかは、既存 Workspace settings architecture に合わせて coder が選んでよい。ただし local-only backend/runtime settings とは分離すること。
- Legacy `.yoi/ticket.config.toml` fallback の exact scoperead-only import / warning / migration helperは bounded local tactic としてよいが、active authority として二重管理しないこと。
- DTO/schema/test layout は existing workspace/ticket config patterns に合わせてよい。
Escalate if:
- Workspace project settings の tracked file 形式自体を新規 product decision として固定する必要があり、既存 `.yoi/workspace.toml` / settings architecture から自然に選べない場合。
- `.yoi/ticket.config.toml` と Workspace settings の二重 authority を長期互換として残す必要が出る場合。
- remote/backend Ticket authority の設計が、local path migration を越えて protocol/API redesign を要求する場合。
- existing Ticket storage migration で data loss risk が出る場合。
Validation:
- `git diff --check`
- `cargo test -p ticket` if ticket config crate/tests are touched。
- `cargo test -p yoi --lib --tests` or targeted CLI tests if CLI paths are touched。
- `cargo test -p worker --lib --tests` if Worker Ticket feature/controller paths are touched。
- `cargo test -p yoi-workspace-server --lib` if workspace-server Ticket endpoint/settings are touched。
- `cargo test -p tui --lib --tests` if Panel role launch/config availability is touched。
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test` if web/API types are touched。
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Current code map / likely touch points:
- `crates/ticket` config model/loading。
- `crates/yoi/src/ticket_cli.rs`, `crates/yoi/src/objective_cli.rs`
- `crates/tui/src/workspace_panel.rs` for Panel config availability/role launch。
- `crates/worker/src/feature/builtin/ticket.rs`, `crates/worker/src/controller.rs` for Worker Ticket tools/setup。
- workspace-server Ticket backend endpoint/settings code。
- `.yoi/workspace.toml` / tracked Workspace settings file and docs under `docs/development/work-items.md`
Critical risks / reviewer focus:
- accidentally keeping `.yoi/ticket.config.toml` as co-equal active authority。
- mixing local-only backend/runtime settings into tracked project settings。
- direct CLI and workspace-server-backed Worker Ticket tools resolving different Ticket roots/languages。
- role launch config losing fixed role semantics or using `inherit` where top-level role launch cannot support it。
- hard-coded `.yoi/tickets` remaining in workspace-server endpoint or Worker Ticket tools。
Next action:
- Wait for explicit follow-up. On follow-up, re-check Ticket/relation/plan/worktree state, then record `queued -> inprogress` before creating `/home/hare/Projects/yoi/.worktree/00001KXK9507N-ticket-workspace-settings` and spawning role Pods。
---
<!-- event: decision author: orchestrator at: 2026-07-15T16:30:32Z -->
## Decision
Implementation follow-up accepted。
Evidence rechecked before side effects:
- Ticket remains `queued` and has prior implementation-ready IntentPacket / accepted plan。
- `TicketRelationQuery(00001KXK9507N)`: 0 blockers。
- `TicketOrchestrationPlanQuery(00001KXK9507N)`: accepted plan exists for branch/worktree `work/00001KXK9507N-ticket-workspace-settings` / `/home/hare/Projects/yoi/.worktree/00001KXK9507N-ticket-workspace-settings`
- `TicketList(inprogress)`: 0 件。
- Orchestrator worktree is clean。
- No existing `00001KXK9507N` worktree/branch was reported in the recheck。
Next action:
- Record `queued -> inprogress` now, then create the dedicated implementation worktree and route implementation to sibling Coder Pod。
- Per current user instruction, do not call `StopPod` during later cleanup。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T16:30:37Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Explicit follow-up authorized implementation start. Ticket/relation/plan/worktree state was rechecked and no blockers were found. Moving to inprogress before worktree creation or role Pod spawn.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T17:20:06Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KXK9507N-ticket-settings` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KXK9507N-ticket-workspace-settings` on branch `work/00001KXK9507N-ticket-workspace-settings`
- Implementation commit: `b1e3a2ad338a551f6e875087c93ecf709a03c1c8 ticket: move ticket settings into workspace config`
- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check c68ed1fd..HEAD`, and changed files. Worktree was clean and diff check passed。
Implementation summary:
- Durable Ticket policy authority を `.yoi/workspace.toml``[ticket]` に移動。
- tracked `.yoi/ticket.config.toml` を削除。
- Workspace settings に `[ticket]` がない場合のみ、legacy `.yoi/ticket.config.toml` を narrow read-only migration fallback として読む。
- Ticket settings がない場合の default behavior は `provider = builtin:yoi_local`, `root = .yoi/tickets` として維持。
- `[ticket]` が存在する場合は legacy `.yoi/ticket.config.toml` を無視する precedence にした。
- Direct CLI / Objective config consumers / Worker Ticket feature setup / workspace-server Ticket backend endpoint / project record reader / Panel role launch availability / role launch tests を新 authority 解決へ更新。
- local-only runtime/backend operational settings は tracked workspace Ticket policy と分離。
- docs で `.yoi/ticket.config.toml` obsolete と新 `[ticket]` workspace settings shape を記録。
Files touched:
- `.yoi/ticket.config.toml` removed。
- `.yoi/workspace.toml` updated。
- `crates/client/src/ticket_role.rs`
- `crates/ticket/src/config.rs`
- `crates/tui/src/dashboard/tests.rs`
- `crates/tui/src/workspace_panel.rs`
- `crates/worker/src/controller.rs`
- `crates/worker/src/feature/builtin/ticket.rs`
- `crates/workspace-server/src/identity.rs`
- `crates/workspace-server/src/records.rs`
- `crates/workspace-server/src/server.rs`
- `crates/yoi/src/ticket_cli.rs`
- `docs/design/workflows-public-dogfood-split.md`
- `docs/development/work-items.md`
Coder-reported validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p yoi --tests`
- `cargo test -p client --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p tui --lib --tests`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Not run:
- `cargo test -p yoi --lib --tests`: `yoi` has no library target。
- web checks: web/API types were not touched。
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T17:26:26Z status: request_changes -->
## Review: request changes
External review result: request_changes
Acceptance blocker:
- Workspace settings が active Panel Ticket reads の全経路で durable authority になっていない。
- `crates/tui/src/workspace_panel.rs:673-688` は orchestration worktree overlay を `worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)` (`.yoi/tickets`) から直接読んでいる。
- そのため orchestration worktree 側の `TicketConfig` / workspace `[ticket.backend].root` を解決しておらず、non-default Ticket root では Panel overlay が “no .yoi/tickets directory” と扱う、または orchestration state を見落とす。
- これは configured backend-root authority と Panel resolution-path acceptance focus に違反する。
Positive evidence:
- `TicketConfig``.yoi/workspace.toml [ticket]` を legacy fallback / precedence / default tests 付きで読むようになっている。
- Direct Ticket CLI、Objective link validation、Worker feature setup、workspace-server record/backend endpoint、queue handoff、role launch は概ね `TicketConfig::load_workspace` 経由になっている。
- `.yoi/workspace.toml` に backend/language/role config が入り、docs は legacy config obsolete を記録している。
Validation performed by reviewer:
- read-only diff/source inspection。
- implementation worktree で `git status --short`, `git rev-parse`, `git diff --check c68ed1fd..HEAD`
- test rerun はしていない。
Non-blocking note:
- non-default-root Panel orchestration-overlay test を追加すると、この経路の regression を防げる。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T17:43:06Z -->
## Implementation report
Follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXK9507N-ticket-settings` completed the Panel overlay configured-root fix in `/home/hare/Projects/yoi/.worktree/00001KXK9507N-ticket-workspace-settings`
- Follow-up commit: `40f21145415106dabf0905d301dd04b73fabd424 tui: use configured ticket root for overlay`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check c68ed1fd..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Panel orchestration overlay loading now uses `TicketConfig::load_workspace(worktree_root)`
- Overlay reads use the orchestration worktrees configured `[ticket.backend].root` and Ticket record language。
- Hard-coded `.yoi/tickets` overlay read path was removed except through config defaults。
- Missing/unusable configured overlay backend root diagnostics are preserved。
- Regression test added: `workspace_panel_orchestration_overlay_uses_configured_backend_root`
- Test verifies non-default orchestration overlay Ticket root is used and `.yoi/tickets` is not required。
Coder-reported validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p tui --lib --tests` (`383 passed`)
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Next action:
- Request focused re-review of the previous Panel overlay blocker and overall acceptance before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T17:45:34Z status: approve -->
## Review: approve
Focused re-review result: approve
Evidence:
- Previous blocker is fixed: `crates/tui/src/workspace_panel.rs:674-694` now loads `TicketConfig::load_workspace(worktree_root)`, uses `overlay_config.backend_root()` and `overlay_config.ticket_record_language()`, and reports a clear diagnostic when the configured root is not a directory。
- Regression `workspace_panel_orchestration_overlay_uses_configured_backend_root` covers non-default overlay roots and reviewer reran it successfully (`1 passed`)。
- No new co-equal legacy authority was found: legacy `.yoi/ticket.config.toml` remains only the documented fallback through `TicketConfig::load_workspace` when workspace `[ticket]` is absent。
- Overall acceptance remains satisfied by inspected paths: direct CLI/Objectives, Worker feature setup, workspace-server records/backend endpoint, Panel/role launch, and queue/overlay paths resolve through workspace Ticket settings/defaults。
- `.yoi/workspace.toml` carries backend/language/fixed role policy, docs/help note legacy obsolete, and operational/runtime settings remain outside tracked Ticket policy。
Validation performed by reviewer:
- clean `git status`
- HEAD `40f21145`
- `git diff --check c68ed1fd..HEAD`
- focused source review。
- focused TUI regression test。
Blockers: none。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T17:51:09Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation after the Panel overlay configured-root fix。
- Merged branch `work/00001KXK9507N-ticket-workspace-settings` into orchestration branch with merge commit `45783b74 merge: workspace ticket settings`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p yoi --tests`
- `cargo test -p client --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p tui --lib --tests`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-ticket-settings-final-validation-1784137566.txt`
Reviewed/merged implementation commits:
- `b1e3a2ad ticket: move ticket settings into workspace config`
- `40f21145 tui: use configured ticket root for overlay`
Notes:
- `.yoi/ticket.config.toml` was removed from tracked active project config。
- `.yoi/workspace.toml [ticket]` is now the durable Ticket settings authority; legacy config remains only as narrow fallback when workspace `[ticket]` is absent。
- Per user instruction, no `StopPod` cleanup was performed。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T17:51:15Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-15T17:51:30Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-15T17:51:30Z status: closed -->
## 完了
Ticket configuration の Workspace settings 統合を実装・レビュー・merge・検証した。
実装内容:
- durable Ticket policy authority を `.yoi/workspace.toml [ticket]` に移動。
- tracked `.yoi/ticket.config.toml` を削除。
- Workspace `[ticket]` がない場合のみ legacy `.yoi/ticket.config.toml` を narrow read-only migration fallback として読むようにした。
- Workspace `[ticket]` が存在する場合は legacy config を無視し、二重 active authority を避ける precedence にした。
- default behavior は維持し、設定なしでは `provider = builtin:yoi_local`, `root = .yoi/tickets` を使う。
- Ticket backend provider/root、record language、orchestration defaults、fixed role launch config を Workspace settings authority に移した。
- Direct Ticket CLI / Objective config consumers / Worker Ticket feature setup / workspace-server Ticket backend endpoint / project record reader / Panel role launch availability / role launch tests を新 authority 解決へ更新。
- Panel orchestration overlay も orchestration worktree の `TicketConfig::load_workspace(worktree_root)` を使い、configured backend root と record language を尊重するよう修正。
- docs で `.yoi/ticket.config.toml` obsolete と新 `[ticket]` workspace settings shape を記録。
Review:
- 初回 review は Panel orchestration overlay が `.yoi/tickets` を hard-code している blocker で `request_changes`
- `40f21145 tui: use configured ticket root for overlay` で overlay read path を configured Ticket root に移行し、non-default root regression test を追加。
- focused re-review は `approve`
Merge / validation:
- Merge commit: `45783b74 merge: workspace ticket settings`
- Final validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p yoi --tests`
- `cargo test -p client --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p tui --lib --tests`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-ticket-settings-final-validation-1784137566.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。
---

View File

@ -1,3 +1,26 @@
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z"
display_name = "yoi"
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket.roles.intake]
profile = "builtin:intake"
workflow = "ticket-intake-workflow"
[ticket.roles.orchestrator]
profile = "builtin:orchestrator"
workflow = "ticket-orchestrator-routing"
[ticket.roles.coder]
profile = "builtin:coder"
workflow = "multi-agent-workflow"
[ticket.roles.reviewer]
profile = "builtin:reviewer"
workflow = "multi-agent-workflow"

View File

@ -253,7 +253,7 @@ pub enum TicketRoleLaunchError {
#[error(transparent)]
LaunchConfig(#[from] TicketRoleLaunchConfigError),
#[error(
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
)]
ProfileResolution {
role: TicketRole,
@ -287,7 +287,7 @@ pub enum TicketRoleLaunchError {
RunAcceptanceTimeout,
}
/// Load `.yoi/ticket.config.toml` from the workspace and construct a launch plan.
/// Load Ticket policy from `.yoi/workspace.toml` and construct a launch plan.
pub fn plan_ticket_role_launch(
context: TicketRoleLaunchContext,
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
@ -700,14 +700,14 @@ mod tests {
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();
std::fs::write(dir.join("workspace.toml"), content).unwrap();
}
fn write_builtin_role_config(workspace: &std::path::Path, roles: &[TicketRole]) {
let mut config = String::new();
let mut config = String::from("[ticket]\n");
for role in roles {
config.push_str(&format!(
"\n[roles.{role}]\nprofile = \"builtin:default\"\n"
"\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n"
));
}
write_config(workspace, &config);
@ -883,7 +883,7 @@ mod tests {
err.to_string()
.contains("Ticket role `coder` is not launch-configured")
);
assert!(err.to_string().contains("[roles.coder]"));
assert!(err.to_string().contains("[ticket.roles.coder]"));
}
#[test]
@ -913,7 +913,7 @@ root = ".yoi/tickets"
write_config(
temp.path(),
r#"
[roles.intake]
[ticket.roles.intake]
profile = "inherit"
"#,
);
@ -931,7 +931,7 @@ profile = "inherit"
write_config(
temp.path(),
r#"
[roles.intake]
[ticket.roles.intake]
profile = "project:no-such-ticket-role-profile"
"#,
);
@ -944,7 +944,7 @@ profile = "project:no-such-ticket-role-profile"
"profile selector `project:no-such-ticket-role-profile` is not resolvable"
)
);
assert!(err.to_string().contains("[roles.intake].profile"));
assert!(err.to_string().contains("[ticket.roles.intake].profile"));
}
#[test]
@ -968,7 +968,7 @@ profile = "project:no-such-ticket-role-profile"
[ticket]
language = "Japanese"
[roles.intake]
[ticket.roles.intake]
profile = "builtin:default"
"#,
);
@ -1035,7 +1035,7 @@ profile = "builtin:default"
write_config(
temp.path(),
r#"
[roles.reviewer]
[ticket.roles.reviewer]
profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow"
@ -1226,7 +1226,7 @@ workflow = "ticket-review-workflow"
write_config(
temp.path(),
r#"
[roles.coder]
[ticket.roles.coder]
profile = "./coder.toml"
"#,
);
@ -1243,7 +1243,7 @@ profile = "./coder.toml"
write_config(
temp.path(),
r#"
[roles.coder]
[ticket.roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
"#,

View File

@ -1,9 +1,12 @@
//! 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 `worker`
//! and `manifest` runtime resolution.
//! Durable Ticket policy lives under the `[ticket]` table in tracked
//! `.yoi/workspace.toml` workspace settings. The legacy
//! `.yoi/ticket.config.toml` file is only a read-only migration fallback when
//! workspace settings do not contain any Ticket policy. The config intentionally
//! stores lightweight string references for Profile selectors, launch prompts,
//! and workflows so this crate remains independent from `worker` and `manifest`
//! runtime resolution.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@ -13,6 +16,8 @@ use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const WORKSPACE_SETTINGS_RELATIVE_PATH: &str = ".yoi/workspace.toml";
/// Legacy Ticket config path. This is kept as a narrow read-only migration fallback only.
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
/// Workspace-relative default root for the built-in local Ticket backend.
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets";
@ -20,13 +25,17 @@ const DEFAULT_ORCHESTRATION_BRANCH: &str = "orchestration";
const DEFAULT_ORCHESTRATION_WORKTREE_DIR: &str = ".worktree";
const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration";
/// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`.
/// Return the explicit Workspace settings Ticket policy scaffold written by `yoi ticket init`.
///
/// The scaffold intentionally configures every fixed Ticket role with a concrete
/// profile so strict role launch planning can validate the config without runtime
/// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`.
/// It intentionally configures every fixed Ticket role with a concrete profile
/// so strict role launch planning can validate the config without runtime
/// fallback.
pub fn ticket_config_scaffold() -> String {
let mut out = String::from("[backend]\n");
let mut out = String::from(
"[ticket]\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# language = \"Japanese\"\n",
);
out.push_str("\n[ticket.backend]\n");
out.push_str(&format!(
"provider = \"{}\"\n",
TicketBackendProvider::BuiltinYoiLocal.as_str()
@ -36,14 +45,11 @@ pub fn ticket_config_scaffold() -> String {
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
));
out.push_str(
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
);
out.push_str(
"\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n",
"\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n",
);
for role in TicketRole::ALL {
out.push_str(&format!(
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
role.default_profile(),
role.default_workflow()
));
@ -222,15 +228,81 @@ impl TicketConfig {
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) {
let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
match fs::read_to_string(&workspace_settings_path) {
Ok(content) => {
if let Some(config) = Self::from_workspace_settings_toml(
workspace_root,
&workspace_settings_path,
&content,
)? {
return Ok(config);
}
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(TicketConfigError::Read {
path: workspace_settings_path,
source,
});
}
}
// Narrow read-only migration fallback for pre-workspace-settings projects.
// As soon as `.yoi/workspace.toml` contains any `[ticket]` table, the
// workspace settings authority wins and this legacy file is ignored.
let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
let legacy_content = match fs::read_to_string(&legacy_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 }),
Err(source) => {
return Err(TicketConfigError::Read {
path: legacy_path,
source,
});
}
};
Self::from_toml(workspace_root, &path, &content)
Self::from_toml(workspace_root, &legacy_path, &legacy_content)
}
pub fn from_workspace_settings_toml(
workspace_root: impl AsRef<Path>,
path: impl AsRef<Path>,
content: &str,
) -> Result<Option<Self>, TicketConfigError> {
let workspace_root = workspace_root.as_ref();
let path = path.as_ref();
let value: toml::Value =
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
let Some(ticket_value) = value.get("ticket").cloned() else {
return Ok(None);
};
let raw: RawWorkspaceTicketConfig =
ticket_value
.try_into()
.map_err(|source| TicketConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
raw.resolve(workspace_root, path).map(Some)
}
pub fn workspace_settings_has_ticket_config(
path: impl AsRef<Path>,
content: &str,
) -> Result<bool, TicketConfigError> {
let path = path.as_ref();
let value: toml::Value =
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
Ok(value.get("ticket").is_some())
}
pub fn from_toml(
@ -487,11 +559,11 @@ impl Default for TicketRoleProfiles {
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TicketRoleLaunchConfigError {
#[error(
"Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with the role builtin profile or another executable concrete profile selector"
"Ticket role `{role}` is not launch-configured; add `[ticket.roles.{role}]` with the role builtin profile or another executable concrete profile selector"
)]
MissingRoleTable { role: TicketRole },
#[error(
"Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to the role builtin profile or another executable concrete profile selector"
"Ticket role `{role}` has no launch profile; set `[ticket.roles.{role}].profile` to the role builtin profile or another executable concrete profile selector"
)]
MissingProfile { role: TicketRole },
#[error(
@ -675,6 +747,19 @@ struct RawTicketConfig {
roles: BTreeMap<String, RawTicketRoleConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawWorkspaceTicketConfig {
#[serde(default)]
backend: RawBackendConfig,
#[serde(default)]
language: Option<TicketRecordLanguage>,
#[serde(default)]
orchestration: RawTicketOrchestrationConfig,
#[serde(default)]
roles: BTreeMap<String, RawTicketRoleConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketOrchestrationConfig {
@ -722,9 +807,47 @@ impl RawTicketConfig {
self,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
resolve_ticket_config_parts(
self.backend,
self.ticket.resolve(),
self.orchestration,
self.roles,
workspace_root,
path,
)
}
}
impl RawWorkspaceTicketConfig {
fn resolve(
self,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
resolve_ticket_config_parts(
self.backend,
TicketRecordConfig {
language: self.language,
},
self.orchestration,
self.roles,
workspace_root,
path,
)
}
}
fn resolve_ticket_config_parts(
backend: RawBackendConfig,
ticket: TicketRecordConfig,
orchestration: RawTicketOrchestrationConfig,
raw_roles: BTreeMap<String, RawTicketRoleConfig>,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
let mut roles = TicketRoleProfiles::default();
for (name, raw_role) in self.roles {
for (name, raw_role) in raw_roles {
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
path: path.to_path_buf(),
message: format!(
@ -740,23 +863,22 @@ impl RawTicketConfig {
}
}
Ok(TicketConfig {
backend: self.backend.resolve(workspace_root).map_err(|message| {
TicketConfigError::Invalid {
backend: backend
.resolve(workspace_root)
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(),
message,
}
})?,
ticket: self.ticket.resolve(),
orchestration: self.orchestration.resolve().map_err(|message| {
TicketConfigError::Invalid {
ticket,
orchestration: orchestration
.resolve()
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(),
message,
}
})?,
roles,
})
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
@ -843,6 +965,12 @@ mod tests {
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
}
fn write_workspace_settings(workspace: &Path, content: &str) {
let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("workspace.toml"), content).unwrap();
}
#[test]
fn missing_config_returns_documented_defaults() {
let temp = TempDir::new().unwrap();
@ -876,39 +1004,123 @@ mod tests {
}
#[test]
fn full_config_parses_fixed_role_refs() {
fn workspace_settings_take_precedence_over_legacy_ticket_config() {
let temp = TempDir::new().unwrap();
write_workspace_settings(
temp.path(),
r#"
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = "workspace-tickets"
"#,
);
write_config(
temp.path(),
r#"
[backend]
provider = "builtin:yoi_local"
root = "custom-tickets"
root = "legacy-tickets"
[ticket]
language = "English"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.root, temp.path().join("workspace-tickets"));
assert_eq!(config.ticket_record_language(), Some("Japanese"));
}
#[test]
fn legacy_ticket_config_is_read_only_migration_fallback() {
let temp = TempDir::new().unwrap();
write_workspace_settings(
temp.path(),
r#"
workspace_id = "00000000-0000-7000-8000-000000000000"
display_name = "legacy-fallback"
"#,
);
write_config(
temp.path(),
r#"
[backend]
provider = "builtin:yoi_local"
root = "legacy-tickets"
[ticket]
language = "Japanese"
"#,
);
[orchestration]
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.root, temp.path().join("legacy-tickets"));
assert_eq!(config.ticket_record_language(), Some("Japanese"));
}
#[test]
fn empty_workspace_ticket_table_uses_workspace_defaults_and_ignores_legacy() {
let temp = TempDir::new().unwrap();
write_workspace_settings(
temp.path(),
r#"
[ticket]
"#,
);
write_config(
temp.path(),
r#"
[backend]
provider = "builtin:yoi_local"
root = "legacy-tickets"
[ticket]
language = "Japanese"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.root, temp.path().join(".yoi/tickets"));
assert_eq!(config.ticket_record_language(), None);
}
#[test]
fn full_config_parses_fixed_role_refs() {
let temp = TempDir::new().unwrap();
write_workspace_settings(
temp.path(),
r#"
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = "custom-tickets"
[ticket.orchestration]
branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees"
worktree_name = "custom-orchestrator"
[roles.intake]
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
[ticket.roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
@ -960,28 +1172,30 @@ workflow = "multi-agent-workflow"
let temp = TempDir::new().unwrap();
let scaffold = ticket_config_scaffold();
assert!(scaffold.contains("[backend]\n"));
assert!(scaffold.contains("[ticket]\n"));
assert!(scaffold.contains("[ticket.backend]\n"));
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
assert!(scaffold.contains("root = \".yoi/tickets\""));
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
assert!(scaffold.contains("# language = \"Japanese\""));
assert!(scaffold.contains(
"# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\""
"# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\""
));
for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[roles.{role}]")));
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
assert!(scaffold.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
)));
}
assert!(!scaffold.contains("[roles.investigator]"));
assert!(!scaffold.contains("[ticket.roles.investigator]"));
let config = TicketConfig::from_toml(
let config = TicketConfig::from_workspace_settings_toml(
temp.path(),
temp.path().join(TICKET_CONFIG_RELATIVE_PATH),
temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH),
&scaffold,
)
.unwrap()
.unwrap();
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
assert_eq!(config.orchestration.branch_name(), None);

View File

@ -94,13 +94,13 @@ fn ensure_and_restore_use_configured_orchestration_layout() {
write_test_ticket_config(
&root,
r#"
[orchestration]
[ticket.orchestration]
branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees"
worktree_name = "panel"
"#,
);
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap();
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let resolved = resolved_orchestration_worktree_layout(&root).unwrap();
@ -126,7 +126,7 @@ fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_opera
write_test_ticket_config(
&root,
r#"
[orchestration]
[ticket.orchestration]
branch = "orchestration/bad:branch"
"#,
);
@ -145,11 +145,11 @@ fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout()
write_test_ticket_config(
&root,
r#"
[orchestration]
[ticket.orchestration]
branch = "orchestration/custom-panel"
"#,
);
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap();
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let layout = resolved_orchestration_worktree_layout(&root).unwrap();
run_test_git(
@ -259,7 +259,7 @@ fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() {
fn write_test_ticket_config(root: &Path, content: &str) {
let config_dir = root.join(".yoi");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap();
std::fs::write(config_dir.join("workspace.toml"), content).unwrap();
}
fn init_test_repo(root: &Path) {
@ -337,8 +337,8 @@ fn ticket_workspace(
)
.unwrap();
fs::write(
temp.path().join(".yoi/ticket.config.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
temp.path().join(".yoi/workspace.toml"),
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
)
.unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@ -712,7 +712,7 @@ async fn ticket_close_action_blocks_non_done_ticket_without_mutation() {
#[tokio::test]
async fn ticket_action_rejects_stale_absent_config_without_mutation() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config");
fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap();
fs::remove_file(temp.path().join(".yoi/workspace.toml")).unwrap();
let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
@ -1059,8 +1059,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write(
temp.path().join(".yoi/ticket.config.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
temp.path().join(".yoi/workspace.toml"),
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
)
.unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));

View File

@ -6,8 +6,8 @@ use std::time::Instant;
use protocol::WorkerStatus;
use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
TicketOrchestrationConfig,
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TicketOrchestrationConfig,
WORKSPACE_SETTINGS_RELATIVE_PATH,
};
use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
@ -567,8 +567,41 @@ pub(crate) fn decide_orchestrator_lifecycle(
}
pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
match config_path.symlink_metadata() {
let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
match settings_path.symlink_metadata() {
Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
"{} exists but is not a regular file",
WORKSPACE_SETTINGS_RELATIVE_PATH
)),
Ok(_) => match std::fs::read_to_string(&settings_path) {
Ok(content) => {
match TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content) {
Ok(true) => match TicketConfig::load_workspace(workspace_root) {
Ok(_) => TicketConfigAvailability::Usable,
Err(error) => TicketConfigAvailability::Unusable(error.to_string()),
},
Ok(false) => legacy_ticket_config_availability(workspace_root),
Err(error) => TicketConfigAvailability::Unusable(error.to_string()),
}
}
Err(error) => TicketConfigAvailability::Unusable(format!(
"could not read {}: {error}",
WORKSPACE_SETTINGS_RELATIVE_PATH
)),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
legacy_ticket_config_availability(workspace_root)
}
Err(error) => TicketConfigAvailability::Unusable(format!(
"could not inspect {}: {error}",
WORKSPACE_SETTINGS_RELATIVE_PATH
)),
}
}
fn legacy_ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
match legacy_path.symlink_metadata() {
Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
"{} exists but is not a regular file",
TICKET_CONFIG_RELATIVE_PATH
@ -608,12 +641,13 @@ fn load_orchestration_ticket_overlay(
}
match validate_orchestration_overlay_source(workspace_root, &layout) {
Ok(()) => {
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language())
.unwrap_or_else(|message| OrchestrationTicketOverlay {
load_orchestration_ticket_overlay_states(&layout.path).unwrap_or_else(|message| {
OrchestrationTicketOverlay {
states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration Ticket overlay unavailable: {message}"
))],
}
})
}
Err(message) => OrchestrationTicketOverlay {
@ -639,19 +673,25 @@ fn orchestration_worktree_layout(
fn load_orchestration_ticket_overlay_states(
worktree_root: &Path,
record_language: Option<&str>,
) -> Result<OrchestrationTicketOverlay, String> {
let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
let overlay_config = TicketConfig::load_workspace(worktree_root).map_err(|error| {
format!(
"orchestration worktree {} has unusable Ticket settings: {error}",
worktree_root.display()
)
})?;
let ticket_root = overlay_config.backend_root();
if !ticket_root.is_dir() {
return Ok(OrchestrationTicketOverlay {
states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration worktree has no {} directory",
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
"Orchestration worktree configured Ticket backend root {} is not a directory",
ticket_root.display()
))],
});
}
let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language);
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
.with_record_language(overlay_config.ticket_record_language());
let partial = backend
.list_partial(TicketFilter::all())
.map_err(|error| error.to_string())?;
@ -1763,11 +1803,17 @@ mod tests {
}
fn write_ticket_config(workspace_root: &Path) {
write_ticket_config_with_root(workspace_root, ".yoi/tickets");
}
fn write_ticket_config_with_root(workspace_root: &Path, root: &str) {
let config_dir = workspace_root.join(".yoi");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("ticket.config.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
config_dir.join("workspace.toml"),
format!(
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"{root}\"\n"
),
)
.unwrap();
}
@ -1815,8 +1861,12 @@ mod tests {
}
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) {
copy_ticket_to_overlay_root(workspace_root, &orchestration_root.join(".yoi/tickets"), id);
}
fn copy_ticket_to_overlay_root(workspace_root: &Path, overlay_root: &Path, id: &str) {
let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id);
let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id);
let overlay_ticket_dir = overlay_root.join(id);
fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap();
fs::create_dir_all(&overlay_ticket_dir).unwrap();
fs::copy(
@ -1987,6 +2037,40 @@ mod tests {
assert!(model.rows.iter().all(|row| row.title != "Overlay Only"));
}
#[test]
fn workspace_panel_orchestration_overlay_uses_configured_backend_root() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
write_ticket_config_with_root(&orchestration_root, "configured-overlay-tickets");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Overlay Custom Root", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
let configured_overlay_root = orchestration_root.join("configured-overlay-tickets");
copy_ticket_to_overlay_root(temp.path(), &configured_overlay_root, &id);
let overlay_backend = LocalTicketBackend::new(&configured_overlay_root);
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
let model = build_workspace_panel(temp.path(), &empty_pods());
assert!(model.header.diagnostics.is_empty());
let row = ticket_row_by_title(&model, "Overlay Custom Root");
assert_eq!(row.status, "q→prog");
assert_eq!(
row.ticket
.as_ref()
.unwrap()
.orchestration_overlay
.as_ref()
.unwrap()
.workflow_state,
TicketWorkflowState::InProgress
);
assert!(!orchestration_root.join(".yoi/tickets").exists());
}
#[test]
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket()
{
@ -2233,8 +2317,8 @@ mod tests {
let config_dir = temp.path().join(".yoi");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("ticket.config.toml"),
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
config_dir.join("workspace.toml"),
"[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
)
.unwrap();
@ -2678,11 +2762,11 @@ mod tests {
}
#[test]
fn existing_non_file_ticket_config_is_unusable_not_absent() {
fn existing_non_file_workspace_settings_is_unusable_not_absent() {
let temp = TempDir::new().unwrap();
let config_parent = temp.path().join(".yoi");
fs::create_dir_all(&config_parent).unwrap();
fs::create_dir(config_parent.join("ticket.config.toml")).unwrap();
fs::create_dir(config_parent.join("workspace.toml")).unwrap();
assert!(matches!(
ticket_config_availability(temp.path()),

View File

@ -736,8 +736,8 @@ where
"ticket tools require local Worker filesystem authority",
)
})?;
crate::feature::builtin::ticket::TicketFeatureBackend::Local {
root: ticket_cwd.clone(),
crate::feature::builtin::ticket::TicketFeatureBackend::LocalWorkspace {
workspace_root: ticket_cwd.clone(),
}
}
};

View File

@ -61,6 +61,9 @@ pub enum TicketFeatureBackend {
Local {
root: PathBuf,
},
LocalWorkspace {
workspace_root: PathBuf,
},
WorkspaceHttp {
workspace_id: String,
base_url: String,
@ -125,6 +128,13 @@ impl TicketFeature {
access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool,
) -> Self {
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
return Self::for_workspace_with_options(
workspace_root,
access,
include_orchestration_tools,
);
}
Self {
backend,
record_language: None,
@ -180,6 +190,7 @@ impl TicketFeature {
pub fn backend_root(&self) -> Option<&Path> {
match &self.backend {
TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root),
TicketFeatureBackend::WorkspaceHttp { .. } => None,
}
}
@ -219,7 +230,8 @@ impl TicketFeature {
}
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend {
TicketFeatureBackend::Local { root: _ } => {
TicketFeatureBackend::Local { root: _ }
| TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => {
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
@ -606,7 +618,7 @@ mod tests {
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();
std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap();
}
fn pending_tool_description(
@ -853,11 +865,11 @@ language = "Japanese"
write_ticket_config(
temp.path(),
r#"
[backend]
[ticket.backend]
provider = "builtin:yoi_local"
root = "tickets"
[roles.coder]
[ticket.roles.coder]
profile = "project:coder"
"#,
);
@ -886,7 +898,7 @@ profile = "project:coder"
write_ticket_config(
temp.path(),
r#"
[roles.operator]
[ticket.roles.operator]
profile = "inherit"
"#,
);
@ -911,7 +923,7 @@ profile = "inherit"
write_ticket_config(
temp.path(),
r#"
[backend]
[ticket.backend]
provider = "github"
"#,
);

View File

@ -12,10 +12,10 @@ pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
/// Stable local Workspace identity persisted as a tracked, safe project record.
///
/// The v0 TOML schema intentionally contains identity metadata only:
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected
/// instead of preserved because this loader cannot safely round-trip future local
/// runtime settings without risking accidental path or secret persistence.
/// The v0 TOML schema contains identity metadata plus optional tracked project
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
/// here because this loader cannot safely round-trip future local runtime settings
/// without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity {
pub workspace_id: String,
@ -29,6 +29,8 @@ struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ticket: Option<toml::Value>,
}
impl WorkspaceIdentity {
@ -112,6 +114,7 @@ impl WorkspaceIdentity {
workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(),
display_name: self.display_name.clone(),
ticket: None,
})
.map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {error}"))

View File

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id;
use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
use crate::{Error, Result};
@ -17,13 +18,16 @@ pub struct LocalProjectRecordReader {
}
impl LocalProjectRecordReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> {
let workspace_root = workspace_root.into();
let ticket_root = workspace_root.join(".yoi/tickets");
Self {
let ticket_config = TicketConfig::load_workspace(&workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf())
.with_record_language(ticket_config.ticket_record_language());
Ok(Self {
workspace_root,
ticket_backend: LocalTicketBackend::new(ticket_root),
}
ticket_backend,
})
}
pub fn workspace_root(&self) -> &Path {
@ -297,7 +301,7 @@ mod tests {
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
let reader = LocalProjectRecordReader::new(dir.path());
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].id, "00000000001J2");
@ -313,9 +317,41 @@ mod tests {
let objective = reader.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body"));
}
#[test]
fn reads_tickets_from_workspace_settings_backend_root() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
dir.path().join(".yoi/workspace.toml"),
r#"
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = "project-records/tickets"
"#,
)
.unwrap();
write_ticket_at(
&dir.path().join("project-records/tickets"),
"00000000001J4",
"Configured root",
"ready",
);
write_ticket(dir.path(), "00000000001J5", "Default root", "ready");
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.items.len(), 1);
assert_eq!(tickets.items[0].id, "00000000001J4");
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = root.join(".yoi/tickets").join(id);
write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
}
fn write_ticket_at(ticket_root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = ticket_root.join(id);
fs::create_dir_all(&ticket_dir).unwrap();
fs::write(
ticket_dir.join("item.md"),

View File

@ -272,7 +272,7 @@ impl WorkspaceApi {
let companion = Arc::new(CompanionConsole::disabled());
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
config,
store,
runtime,
@ -1248,11 +1248,10 @@ async fn scoped_ticket_backend_operation(
Json(operation): Json<TicketBackendOperation>,
) -> ApiResult<Json<TicketBackendHttpResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let backend = LocalTicketBackend::new(
api.config
.workspace_root
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
);
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
let response = match execute_ticket_backend_operation(&backend, operation) {
Ok(result) => TicketBackendHttpResponse::Ok { result },
Err(error) => TicketBackendHttpResponse::Error {
@ -5800,6 +5799,53 @@ mod tests {
}
}
#[tokio::test]
async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
dir.path().join(".yoi/workspace.toml"),
format!(
"workspace_id = \"{TEST_WORKSPACE_ID}\"\ncreated_at = \"{TEST_CREATED_AT}\"\ndisplay_name = \"Endpoint Test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"server-tickets\"\n"
),
)
.unwrap();
let api = test_api(dir.path()).await;
let Json(response) = scoped_ticket_backend_operation(
State(api),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(TicketBackendOperation::Create {
input: ticket::NewTicket::new("Endpoint configured root"),
}),
)
.await
.unwrap_or_else(|error| panic!("ticket backend operation failed: {}", error.error));
let ticket_ref = match response {
TicketBackendHttpResponse::Ok {
result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref),
} => ticket_ref,
other => panic!("unexpected ticket backend response: {other:?}"),
};
assert!(
dir.path()
.join("server-tickets")
.join(&ticket_ref.id)
.join("item.md")
.is_file()
);
assert!(
!dir.path()
.join(".yoi/tickets")
.join(&ticket_ref.id)
.join("item.md")
.exists()
);
}
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let store = SqliteWorkspaceStore::in_memory().unwrap();
WorkspaceApi::new_with_execution_backend(

View File

@ -1,11 +1,13 @@
use std::fmt;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
ticket_config_scaffold,
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
};
use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
@ -252,11 +254,12 @@ fn run_command(
}
fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
if config_path.exists() {
let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
if legacy_config_path.exists() {
return Err(TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.",
config_path.display()
"legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.",
legacy_config_path.display(),
WORKSPACE_SETTINGS_RELATIVE_PATH
)));
}
@ -266,28 +269,96 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
fs::create_dir_all(&tickets_dir)?;
fs::write(tickets_dir.join(".gitkeep"), b"")?;
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
let scaffold = ticket_config_scaffold();
let created_settings = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)?;
if TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content)? {
return Err(TicketCliError::new(format!(
"workspace Ticket settings already exist at {}; refusing to overwrite. Edit the [ticket] table manually before running `yoi ticket init`.",
settings_path.display()
)));
}
let mut file = fs::OpenOptions::new().append(true).open(&settings_path)?;
if !content.ends_with('\n') {
file.write_all(b"\n")?;
}
file.write_all(b"\n")?;
file.write_all(scaffold.as_bytes())?;
false
} else {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.open(&settings_path)
.map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.",
config_path.display()
"workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.",
settings_path.display()
))
} else {
TicketCliError::from(error)
}
})?;
file.write_all(ticket_config_scaffold().as_bytes())?;
let identity = workspace_settings_identity_header(workspace)?;
file.write_all(identity.as_bytes())?;
file.write_all(b"\n")?;
file.write_all(scaffold.as_bytes())?;
true
};
let verb = if created_settings {
"created"
} else {
"updated"
};
Ok(success(format!(
"created\t{}\nensured\t{}\n",
TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
"{verb}\t{}\nensured\t{}\n",
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
)))
}
fn workspace_settings_identity_header(workspace: &Path) -> Result<String, TicketCliError> {
let display_name = workspace
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.trim().is_empty())
.unwrap_or("workspace");
if display_name.contains('\0') || display_name.chars().any(|ch| ch.is_control()) {
return Err(TicketCliError::new(
"workspace display name derived from path must not contain control characters",
));
}
Ok(format!(
"workspace_id = \"{}\"\ncreated_at = \"{}\"\ndisplay_name = \"{}\"\n",
workspace_settings_uuid_v7(workspace),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
display_name.replace('\\', "\\\\").replace('"', "\\\"")
))
}
fn workspace_settings_uuid_v7(workspace: &Path) -> String {
let now = Utc::now();
let timestamp_ms = (now.timestamp_millis().max(0) as u64) & 0xffff_ffff_ffff;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
workspace.hash(&mut hasher);
std::process::id().hash(&mut hasher);
now.timestamp_nanos_opt()
.unwrap_or_default()
.hash(&mut hasher);
let random = hasher.finish();
let time_low = (timestamp_ms >> 16) as u32;
let time_mid = (timestamp_ms & 0xffff) as u16;
let version_and_rand = 0x7000 | (((random >> 52) as u16) & 0x0fff);
let variant_and_rand = 0x8000 | (((random >> 38) as u16) & 0x3fff);
let node = random & 0xffff_ffff_ffff;
format!(
"{time_low:08x}-{time_mid:04x}-{version_and_rand:04x}-{variant_and_rand:04x}-{node:012x}"
)
}
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?;
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
@ -1077,7 +1148,7 @@ fn default_author() -> String {
}
fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n"
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
}
#[cfg(test)]
@ -1106,33 +1177,31 @@ mod tests {
}
#[test]
fn ticket_cli_init_writes_explicit_ticket_config_scaffold() {
fn ticket_cli_init_writes_explicit_workspace_ticket_settings() {
let temp = TempDir::new().unwrap();
let initialized = run(&temp, &["init"]);
assert_eq!(initialized.status, TicketCliStatus::Success);
assert!(
initialized
.stdout
.contains("created\t.yoi/ticket.config.toml")
);
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
assert!(temp.path().join(".yoi/tickets").exists());
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap();
assert!(config.contains("[backend]\n"));
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
assert!(config.contains("workspace_id = \""));
assert!(config.contains("[ticket]\n"));
assert!(config.contains("[ticket.backend]\n"));
assert!(config.contains("provider = \"builtin:yoi_local\""));
assert!(config.contains("root = \".yoi/tickets\""));
assert!(config.contains("# [ticket]\n# language = \"Japanese\""));
assert!(config.contains("# language = \"Japanese\""));
for role in TicketRole::ALL {
assert!(config.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
)));
}
assert!(!config.contains("[roles.investigator]"));
assert!(!config.contains("[ticket.roles.investigator]"));
}
#[test]
@ -1148,9 +1217,9 @@ mod tests {
let cli = parse_ticket_args(&args(&["init"])).unwrap();
let err = run_in_workspace(cli, temp.path()).unwrap_err();
assert!(err.to_string().contains("already exists"));
assert!(err.to_string().contains("refusing to overwrite"));
assert!(err.to_string().contains("yoi ticket init"));
assert!(err.to_string().contains("legacy ticket config exists"));
assert!(err.to_string().contains("obsolete and read-only"));
assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH));
assert_eq!(
fs::read_to_string(config_path).unwrap(),
"[backend]\nprovider = \"builtin:yoi_local\"\n"
@ -1414,8 +1483,8 @@ mod tests {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write(
temp.path().join(".yoi/ticket.config.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
temp.path().join(".yoi/workspace.toml"),
"[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
)
.unwrap();

View File

@ -30,7 +30,7 @@ This document records the design/audit decision for Ticket `00001KTRKZ14C`. It d
| `multi-agent-workflow` | Partly. The sibling coder/reviewer loop, intent packet, branch-local review, and merge-ready dossier are useful product concepts. | Heavy Yoi dogfood specifics: Git worktrees, commits, cargo/nix validation, branch cleanup, docs/report conventions, and parent/child policy. | Split into builtin `multi-agent-workflow` for role loop + dossier, and workspace dogfood extension for Git worktree/cargo/nix mechanics. |
| `worktree-workflow` | Not resident public core. | Almost entirely this repository's Git worktree mechanics and `.yoi` path exclusions. | Keep workspace-local dogfood workflow. Optionally create a non-resident builtin `git-worktree-isolation` later, disabled unless explicitly selected. |
## Slug and `.yoi/ticket.config.toml` migration decision
## Slug and workspace Ticket settings migration decision
Use explicit dogfood slugs for workflows whose semantics differ from the public builtin workflow. Same-slug workspace overrides are allowed for local wording/policy tweaks of the same public contract, but they should not be used to hide Yoi repository Git/worktree/cargo/nix/merge semantics behind a public slug. This avoids accidental shadowing when a user expects `multi-agent-workflow` to mean the generic builtin role loop.
@ -38,10 +38,10 @@ Planned selector mapping for this repository after builtin workflow loading exis
| Role/config surface | Workflow selector | Source intent | Rationale |
| --- | --- | --- | --- |
| `[roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
| `[roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
| `[roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
| `[roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
| `[ticket.roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
| `[ticket.roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
| `[ticket.roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
| `[ticket.roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
| compatibility references | `ticket-preflight-workflow` | builtin compatibility alias or workspace compatibility file, non-resident | Do not keep it as a role default. It should point to planning/requirements sync language only. |
| dogfood worktree helper | `yoi-dogfood-worktree-workflow` | workspace-local helper, not a role default by itself | Referenced from `yoi-dogfood-multi-agent-workflow`; keeps Git worktree mechanics out of generic builtin workflows. |
@ -115,7 +115,7 @@ Builtin workflows and active workspace workflows should remove or replace the fo
3. **Dogfood workflow retention**
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics.
- Ensure `.yoi/ticket.config.toml` role workflow selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
- Ensure `.yoi/workspace.toml` `[ticket.roles.*].workflow` selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
4. **Vocabulary migration**
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language.

View File

@ -107,31 +107,34 @@ The first version intentionally does not implement roadmap scheduling, milestone
## Ticket configuration
Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present.
Workspace Ticket policy is configured by the tracked workspace settings file `.yoi/workspace.toml` under the `[ticket]` table. The old `.yoi/ticket.config.toml` file is obsolete: current code only reads it as a narrow read-only migration fallback when `.yoi/workspace.toml` has no `[ticket]` table. Workspace settings take precedence as soon as `[ticket]` exists.
MVP shape:
```toml
[backend]
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[roles.intake]
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
[ticket.roles.coder]
profile = "project:coder"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
@ -145,10 +148,10 @@ Fixed roles are:
- `reviewer`
This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration.
Stale `[roles.investigator]` config is rejected as an unsupported fixed role; remove it and,
Stale `[ticket.roles.investigator]` config is rejected as an unsupported fixed role; remove it and,
when a spike is useful, let the Orchestrator create an ordinary task-specific read-only helper Worker.
`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. `ticket.config.toml` does not have a role-level `system_instruction` field.
`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. Workspace Ticket settings do not have a role-level `system_instruction` field.
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
@ -156,7 +159,7 @@ when a spike is useful, let the Orchestrator create an ordinary task-specific re
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
If `.yoi/ticket.config.toml` is missing, defaults are:
If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exists, defaults are:
- backend provider: `builtin:yoi_local`
- backend root: `<workspace>/.yoi/tickets`
@ -168,7 +171,7 @@ If `.yoi/ticket.config.toml` is missing, defaults are:
- coder: `multi-agent-workflow`
- reviewer: `multi-agent-workflow`
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/ticket.config.toml` before using `yoi panel` role-launch actions.
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
## Workflow lifecycle
@ -272,7 +275,7 @@ Close with a resolution that summarizes what changed, key commits, validation, r
`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`:
Role actions map to the same fixed roles configured in `.yoi/workspace.toml` under `[ticket.roles]`:
- intake launches the intake role without an existing Ticket and requires freeform context.
- route launches the orchestrator role for an existing Ticket.
@ -288,7 +291,7 @@ The role-launch path is:
```text
User triggers a Ticket action in yoi panel
-> Dashboard builds a TicketRoleLaunchContext
-> client Ticket role launcher reads .yoi/ticket.config.toml
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
-> launcher selects the role Profile and workflow
-> launcher spawns the role Worker
-> launcher sends Method::Run with WorkflowInvoke + Text segments
@ -301,37 +304,37 @@ The launched Worker receives dynamic Ticket/action context as its first committe
The first run input contains:
- the selected fixed role;
- the workflow canonical id from `.yoi/ticket.config.toml`;
- the workflow canonical id from workspace `[ticket.roles.<role>]` settings;
- Ticket id when the action targets an existing Ticket;
- freeform user instruction/context from the action;
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
The selected Profile supplies durable system/role behavior. `ticket.config.toml` does not override system instruction.
The selected Profile supplies durable system/role behavior. Workspace Ticket settings do not override system instruction.
### Dashboard setup
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
```toml
# .yoi/ticket.config.toml
# .yoi/workspace.toml
[backend]
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[roles.intake]
[ticket.roles.intake]
profile = "project:intake"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
[ticket.roles.orchestrator]
profile = "project:orchestrator"
workflow = "ticket-orchestrator-routing"
[roles.coder]
[ticket.roles.coder]
profile = "project:coder"
workflow = "multi-agent-workflow"
[roles.reviewer]
[ticket.roles.reviewer]
profile = "project:reviewer"
workflow = "multi-agent-workflow"
```
@ -340,8 +343,8 @@ If a role still uses `profile = "inherit"`, the Dashboard fails closed with a di
### Dashboard troubleshooting
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`.
- malformed `.yoi/ticket.config.toml`: fix the config and retry.
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`.
- malformed workspace Ticket settings: fix the `[ticket]` table in `.yoi/workspace.toml` and retry.
- missing Ticket id for route, implement, or review actions: provide the target Ticket.
- launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.