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' title: 'Consolidate Ticket configuration into Workspace settings'
state: 'queued' state: 'closed'
created_at: '2026-07-15T16:18:25Z' created_at: '2026-07-15T16:18:25Z'
updated_at: '2026-07-15T16:19:31Z' updated_at: '2026-07-15T17:51:30Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-15T16:19:31Z' 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 にしました。 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" workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z" created_at = "2026-06-23T00:00:00Z"
display_name = "yoi" 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)] #[error(transparent)]
LaunchConfig(#[from] TicketRoleLaunchConfigError), LaunchConfig(#[from] TicketRoleLaunchConfigError),
#[error( #[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 { ProfileResolution {
role: TicketRole, role: TicketRole,
@ -287,7 +287,7 @@ pub enum TicketRoleLaunchError {
RunAcceptanceTimeout, 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( pub fn plan_ticket_role_launch(
context: TicketRoleLaunchContext, context: TicketRoleLaunchContext,
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> { ) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
@ -700,14 +700,14 @@ mod tests {
fn write_config(workspace: &std::path::Path, content: &str) { fn write_config(workspace: &std::path::Path, content: &str) {
let dir = workspace.join(".yoi"); let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap(); 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]) { 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 { for role in roles {
config.push_str(&format!( config.push_str(&format!(
"\n[roles.{role}]\nprofile = \"builtin:default\"\n" "\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n"
)); ));
} }
write_config(workspace, &config); write_config(workspace, &config);
@ -883,7 +883,7 @@ mod tests {
err.to_string() err.to_string()
.contains("Ticket role `coder` is not launch-configured") .contains("Ticket role `coder` is not launch-configured")
); );
assert!(err.to_string().contains("[roles.coder]")); assert!(err.to_string().contains("[ticket.roles.coder]"));
} }
#[test] #[test]
@ -913,7 +913,7 @@ root = ".yoi/tickets"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.intake] [ticket.roles.intake]
profile = "inherit" profile = "inherit"
"#, "#,
); );
@ -931,7 +931,7 @@ profile = "inherit"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.intake] [ticket.roles.intake]
profile = "project:no-such-ticket-role-profile" 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" "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] #[test]
@ -968,7 +968,7 @@ profile = "project:no-such-ticket-role-profile"
[ticket] [ticket]
language = "Japanese" language = "Japanese"
[roles.intake] [ticket.roles.intake]
profile = "builtin:default" profile = "builtin:default"
"#, "#,
); );
@ -1035,7 +1035,7 @@ profile = "builtin:default"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.reviewer] [ticket.roles.reviewer]
profile = "builtin:default" profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow" workflow = "ticket-review-workflow"
@ -1226,7 +1226,7 @@ workflow = "ticket-review-workflow"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.coder] [ticket.roles.coder]
profile = "./coder.toml" profile = "./coder.toml"
"#, "#,
); );
@ -1243,7 +1243,7 @@ profile = "./coder.toml"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.coder] [ticket.roles.coder]
profile = "inherit" profile = "inherit"
system_instruction = "$workspace/not-supported" system_instruction = "$workspace/not-supported"
"#, "#,

View File

@ -1,9 +1,12 @@
//! Workspace-local Ticket orchestration configuration. //! Workspace-local Ticket orchestration configuration.
//! //!
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root. //! Durable Ticket policy lives under the `[ticket]` table in tracked
//! It intentionally stores lightweight string references for Profile selectors, //! `.yoi/workspace.toml` workspace settings. The legacy
//! launch prompts, and workflows so this crate remains independent from `worker` //! `.yoi/ticket.config.toml` file is only a read-only migration fallback when
//! and `manifest` runtime resolution. //! 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::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
@ -13,6 +16,8 @@ use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; 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"; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
/// Workspace-relative default root for the built-in local Ticket backend. /// Workspace-relative default root for the built-in local Ticket backend.
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; 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_DIR: &str = ".worktree";
const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration"; 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 /// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`.
/// profile so strict role launch planning can validate the config without runtime /// It intentionally configures every fixed Ticket role with a concrete profile
/// so strict role launch planning can validate the config without runtime
/// fallback. /// fallback.
pub fn ticket_config_scaffold() -> String { 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!( out.push_str(&format!(
"provider = \"{}\"\n", "provider = \"{}\"\n",
TicketBackendProvider::BuiltinYoiLocal.as_str() TicketBackendProvider::BuiltinYoiLocal.as_str()
@ -36,14 +45,11 @@ pub fn ticket_config_scaffold() -> String {
DEFAULT_TICKET_BACKEND_RELATIVE_PATH DEFAULT_TICKET_BACKEND_RELATIVE_PATH
)); ));
out.push_str( out.push_str(
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\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",
);
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",
); );
for role in TicketRole::ALL { for role in TicketRole::ALL {
out.push_str(&format!( out.push_str(&format!(
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", "\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
role.default_profile(), role.default_profile(),
role.default_workflow() role.default_workflow()
)); ));
@ -222,15 +228,81 @@ impl TicketConfig {
pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> { pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
let content = match fs::read_to_string(&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, Ok(content) => content,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => { Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default_for_workspace(workspace_root)); 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( pub fn from_toml(
@ -487,11 +559,11 @@ impl Default for TicketRoleProfiles {
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TicketRoleLaunchConfigError { pub enum TicketRoleLaunchConfigError {
#[error( #[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 }, MissingRoleTable { role: TicketRole },
#[error( #[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 }, MissingProfile { role: TicketRole },
#[error( #[error(
@ -675,6 +747,19 @@ struct RawTicketConfig {
roles: BTreeMap<String, RawTicketRoleConfig>, 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)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawTicketOrchestrationConfig { struct RawTicketOrchestrationConfig {
@ -722,9 +807,47 @@ impl RawTicketConfig {
self, self,
workspace_root: &Path, workspace_root: &Path,
path: &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> { ) -> Result<TicketConfig, TicketConfigError> {
let mut roles = TicketRoleProfiles::default(); 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 { let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message: format!( message: format!(
@ -740,23 +863,22 @@ impl RawTicketConfig {
} }
} }
Ok(TicketConfig { Ok(TicketConfig {
backend: self.backend.resolve(workspace_root).map_err(|message| { backend: backend
TicketConfigError::Invalid { .resolve(workspace_root)
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message, message,
}
})?, })?,
ticket: self.ticket.resolve(), ticket,
orchestration: self.orchestration.resolve().map_err(|message| { orchestration: orchestration
TicketConfigError::Invalid { .resolve()
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message, message,
}
})?, })?,
roles, roles,
}) })
} }
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -843,6 +965,12 @@ mod tests {
std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); 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] #[test]
fn missing_config_returns_documented_defaults() { fn missing_config_returns_documented_defaults() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -876,39 +1004,123 @@ mod tests {
} }
#[test] #[test]
fn full_config_parses_fixed_role_refs() { fn workspace_settings_take_precedence_over_legacy_ticket_config() {
let temp = TempDir::new().unwrap(); 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( write_config(
temp.path(), temp.path(),
r#" r#"
[backend] [backend]
provider = "builtin:yoi_local" 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] [ticket]
language = "Japanese" 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" branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees" worktree_dir = "custom-worktrees"
worktree_name = "custom-orchestrator" worktree_name = "custom-orchestrator"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch" launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch" launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "inherit" profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch" launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
@ -960,28 +1172,30 @@ workflow = "multi-agent-workflow"
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let scaffold = ticket_config_scaffold(); 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("provider = \"builtin:yoi_local\""));
assert!(scaffold.contains("root = \".yoi/tickets\"")); assert!(scaffold.contains("root = \".yoi/tickets\""));
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); assert!(scaffold.contains("# language = \"Japanese\""));
assert!(scaffold.contains( 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 { for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
assert!(scaffold.contains(&format!( assert!(scaffold.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(), role.default_profile(),
role.default_workflow() 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(),
temp.path().join(TICKET_CONFIG_RELATIVE_PATH), temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH),
&scaffold, &scaffold,
) )
.unwrap()
.unwrap(); .unwrap();
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
assert_eq!(config.orchestration.branch_name(), None); 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( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/custom-panel" branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees" worktree_dir = "custom-worktrees"
worktree_name = "panel" 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(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let resolved = resolved_orchestration_worktree_layout(&root).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( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/bad:branch" branch = "orchestration/bad:branch"
"#, "#,
); );
@ -145,11 +145,11 @@ fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout()
write_test_ticket_config( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/custom-panel" 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(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let layout = resolved_orchestration_worktree_layout(&root).unwrap(); let layout = resolved_orchestration_worktree_layout(&root).unwrap();
run_test_git( 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) { fn write_test_ticket_config(root: &Path, content: &str) {
let config_dir = root.join(".yoi"); let config_dir = root.join(".yoi");
std::fs::create_dir_all(&config_dir).unwrap(); 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) { fn init_test_repo(root: &Path) {
@ -337,8 +337,8 @@ fn ticket_workspace(
) )
.unwrap(); .unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); 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] #[tokio::test]
async fn ticket_action_rejects_stale_absent_config_without_mutation() { async fn ticket_action_rejects_stale_absent_config_without_mutation() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); 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 = let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) 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(); let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));

View File

@ -6,8 +6,8 @@ use std::time::Instant;
use protocol::WorkerStatus; use protocol::WorkerStatus;
use ticket::config::{ use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TicketOrchestrationConfig,
TicketOrchestrationConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, 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 { pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
match config_path.symlink_metadata() { 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!( Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
"{} exists but is not a regular file", "{} exists but is not a regular file",
TICKET_CONFIG_RELATIVE_PATH TICKET_CONFIG_RELATIVE_PATH
@ -608,12 +641,13 @@ fn load_orchestration_ticket_overlay(
} }
match validate_orchestration_overlay_source(workspace_root, &layout) { match validate_orchestration_overlay_source(workspace_root, &layout) {
Ok(()) => { Ok(()) => {
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language()) load_orchestration_ticket_overlay_states(&layout.path).unwrap_or_else(|message| {
.unwrap_or_else(|message| OrchestrationTicketOverlay { OrchestrationTicketOverlay {
states: BTreeMap::new(), states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!( diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration Ticket overlay unavailable: {message}" "Orchestration Ticket overlay unavailable: {message}"
))], ))],
}
}) })
} }
Err(message) => OrchestrationTicketOverlay { Err(message) => OrchestrationTicketOverlay {
@ -639,19 +673,25 @@ fn orchestration_worktree_layout(
fn load_orchestration_ticket_overlay_states( fn load_orchestration_ticket_overlay_states(
worktree_root: &Path, worktree_root: &Path,
record_language: Option<&str>,
) -> Result<OrchestrationTicketOverlay, String> { ) -> 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() { if !ticket_root.is_dir() {
return Ok(OrchestrationTicketOverlay { return Ok(OrchestrationTicketOverlay {
states: BTreeMap::new(), states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!( diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration worktree has no {} directory", "Orchestration worktree configured Ticket backend root {} is not a directory",
DEFAULT_TICKET_BACKEND_RELATIVE_PATH 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 let partial = backend
.list_partial(TicketFilter::all()) .list_partial(TicketFilter::all())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -1763,11 +1803,17 @@ mod tests {
} }
fn write_ticket_config(workspace_root: &Path) { 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"); let config_dir = workspace_root.join(".yoi");
fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&config_dir).unwrap();
fs::write( fs::write(
config_dir.join("ticket.config.toml"), config_dir.join("workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", format!(
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"{root}\"\n"
),
) )
.unwrap(); .unwrap();
} }
@ -1815,8 +1861,12 @@ mod tests {
} }
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) { 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 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.parent().unwrap()).unwrap();
fs::create_dir_all(&overlay_ticket_dir).unwrap(); fs::create_dir_all(&overlay_ticket_dir).unwrap();
fs::copy( fs::copy(
@ -1987,6 +2037,40 @@ mod tests {
assert!(model.rows.iter().all(|row| row.title != "Overlay Only")); 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] #[test]
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket() 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"); let config_dir = temp.path().join(".yoi");
fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&config_dir).unwrap();
fs::write( fs::write(
config_dir.join("ticket.config.toml"), config_dir.join("workspace.toml"),
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
@ -2678,11 +2762,11 @@ mod tests {
} }
#[test] #[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 temp = TempDir::new().unwrap();
let config_parent = temp.path().join(".yoi"); let config_parent = temp.path().join(".yoi");
fs::create_dir_all(&config_parent).unwrap(); 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!( assert!(matches!(
ticket_config_availability(temp.path()), ticket_config_availability(temp.path()),

View File

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

View File

@ -61,6 +61,9 @@ pub enum TicketFeatureBackend {
Local { Local {
root: PathBuf, root: PathBuf,
}, },
LocalWorkspace {
workspace_root: PathBuf,
},
WorkspaceHttp { WorkspaceHttp {
workspace_id: String, workspace_id: String,
base_url: String, base_url: String,
@ -125,6 +128,13 @@ impl TicketFeature {
access: Option<TicketFeatureAccess>, access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool, include_orchestration_tools: bool,
) -> Self { ) -> Self {
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
return Self::for_workspace_with_options(
workspace_root,
access,
include_orchestration_tools,
);
}
Self { Self {
backend, backend,
record_language: None, record_language: None,
@ -180,6 +190,7 @@ impl TicketFeature {
pub fn backend_root(&self) -> Option<&Path> { pub fn backend_root(&self) -> Option<&Path> {
match &self.backend { match &self.backend {
TicketFeatureBackend::Local { root } => Some(root), TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root),
TicketFeatureBackend::WorkspaceHttp { .. } => None, TicketFeatureBackend::WorkspaceHttp { .. } => None,
} }
} }
@ -219,7 +230,8 @@ impl TicketFeature {
} }
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> { fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend { match &self.backend {
TicketFeatureBackend::Local { root: _ } => { TicketFeatureBackend::Local { root: _ }
| TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => {
let usable_root = match self.usable_backend_root() { let usable_root = match self.usable_backend_root() {
Ok(root) => root, Ok(root) => root,
Err(reason) => { Err(reason) => {
@ -606,7 +618,7 @@ mod tests {
fn write_ticket_config(workspace: &Path, content: &str) { fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi"); let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap(); 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( fn pending_tool_description(
@ -853,11 +865,11 @@ language = "Japanese"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[backend] [ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = "tickets" root = "tickets"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
"#, "#,
); );
@ -886,7 +898,7 @@ profile = "project:coder"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[roles.operator] [ticket.roles.operator]
profile = "inherit" profile = "inherit"
"#, "#,
); );
@ -911,7 +923,7 @@ profile = "inherit"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[backend] [ticket.backend]
provider = "github" 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. /// Stable local Workspace identity persisted as a tracked, safe project record.
/// ///
/// The v0 TOML schema intentionally contains identity metadata only: /// The v0 TOML schema contains identity metadata plus optional tracked project
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected /// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
/// instead of preserved because this loader cannot safely round-trip future local /// here because this loader cannot safely round-trip future local runtime settings
/// runtime settings without risking accidental path or secret persistence. /// without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity { pub struct WorkspaceIdentity {
pub workspace_id: String, pub workspace_id: String,
@ -29,6 +29,8 @@ struct WorkspaceIdentityFile {
workspace_id: String, workspace_id: String,
created_at: String, created_at: String,
display_name: String, display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ticket: Option<toml::Value>,
} }
impl WorkspaceIdentity { impl WorkspaceIdentity {
@ -112,6 +114,7 @@ impl WorkspaceIdentity {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(), created_at: self.created_at.clone(),
display_name: self.display_name.clone(), display_name: self.display_name.clone(),
ticket: None,
}) })
.map_err(|error| { .map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {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 project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
use crate::{Error, Result}; use crate::{Error, Result};
@ -17,13 +18,16 @@ pub struct LocalProjectRecordReader {
} }
impl 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 workspace_root = workspace_root.into();
let ticket_root = workspace_root.join(".yoi/tickets"); let ticket_config = TicketConfig::load_workspace(&workspace_root)
Self { .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, workspace_root,
ticket_backend: LocalTicketBackend::new(ticket_root), ticket_backend,
} })
} }
pub fn workspace_root(&self) -> &Path { pub fn workspace_root(&self) -> &Path {
@ -297,7 +301,7 @@ mod tests {
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_objective(dir.path(), "00000000001J3", "Control plane", "active"); 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(); let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records"); assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].id, "00000000001J2");
@ -313,9 +317,41 @@ mod tests {
let objective = reader.objective("00000000001J3").unwrap(); let objective = reader.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body")); 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) { 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::create_dir_all(&ticket_dir).unwrap();
fs::write( fs::write(
ticket_dir.join("item.md"), ticket_dir.join("item.md"),

View File

@ -272,7 +272,7 @@ impl WorkspaceApi {
let companion = Arc::new(CompanionConsole::disabled()); let companion = Arc::new(CompanionConsole::disabled());
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self { Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()), records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
config, config,
store, store,
runtime, runtime,
@ -1248,11 +1248,10 @@ async fn scoped_ticket_backend_operation(
Json(operation): Json<TicketBackendOperation>, Json(operation): Json<TicketBackendOperation>,
) -> ApiResult<Json<TicketBackendHttpResponse>> { ) -> ApiResult<Json<TicketBackendHttpResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let backend = LocalTicketBackend::new( let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
api.config .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
.workspace_root let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), .with_record_language(config.ticket_record_language());
);
let response = match execute_ticket_backend_operation(&backend, operation) { let response = match execute_ticket_backend_operation(&backend, operation) {
Ok(result) => TicketBackendHttpResponse::Ok { result }, Ok(result) => TicketBackendHttpResponse::Ok { result },
Err(error) => TicketBackendHttpResponse::Error { 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 { async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
WorkspaceApi::new_with_execution_backend( WorkspaceApi::new_with_execution_backend(

View File

@ -1,11 +1,13 @@
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use ticket::config::{ use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
ticket_config_scaffold, WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
@ -252,11 +254,12 @@ fn run_command(
} }
fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> { fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
if config_path.exists() { if legacy_config_path.exists() {
return Err(TicketCliError::new(format!( return Err(TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", "legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.",
config_path.display() 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::create_dir_all(&tickets_dir)?;
fs::write(tickets_dir.join(".gitkeep"), b"")?; 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() let mut file = fs::OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.open(&config_path) .open(&settings_path)
.map_err(|error| { .map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists { if error.kind() == std::io::ErrorKind::AlreadyExists {
TicketCliError::new(format!( TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", "workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.",
config_path.display() settings_path.display()
)) ))
} else { } else {
TicketCliError::from(error) 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!( Ok(success(format!(
"created\t{}\nensured\t{}\n", "{verb}\t{}\nensured\t{}\n",
TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH 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> { fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?; let config = TicketConfig::load_workspace(workspace)?;
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
@ -1077,7 +1148,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { 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)] #[cfg(test)]
@ -1106,33 +1177,31 @@ mod tests {
} }
#[test] #[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 temp = TempDir::new().unwrap();
let initialized = run(&temp, &["init"]); let initialized = run(&temp, &["init"]);
assert_eq!(initialized.status, TicketCliStatus::Success); assert_eq!(initialized.status, TicketCliStatus::Success);
assert!( assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
initialized
.stdout
.contains("created\t.yoi/ticket.config.toml")
);
assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
assert!(temp.path().join(".yoi/tickets").exists()); assert!(temp.path().join(".yoi/tickets").exists());
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists()); assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap(); let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
assert!(config.contains("[backend]\n")); 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("provider = \"builtin:yoi_local\""));
assert!(config.contains("root = \".yoi/tickets\"")); assert!(config.contains("root = \".yoi/tickets\""));
assert!(config.contains("# [ticket]\n# language = \"Japanese\"")); assert!(config.contains("# language = \"Japanese\""));
for role in TicketRole::ALL { for role in TicketRole::ALL {
assert!(config.contains(&format!( assert!(config.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(), role.default_profile(),
role.default_workflow() role.default_workflow()
))); )));
} }
assert!(!config.contains("[roles.investigator]")); assert!(!config.contains("[ticket.roles.investigator]"));
} }
#[test] #[test]
@ -1148,9 +1217,9 @@ mod tests {
let cli = parse_ticket_args(&args(&["init"])).unwrap(); let cli = parse_ticket_args(&args(&["init"])).unwrap();
let err = run_in_workspace(cli, temp.path()).unwrap_err(); let err = run_in_workspace(cli, temp.path()).unwrap_err();
assert!(err.to_string().contains("already exists")); assert!(err.to_string().contains("legacy ticket config exists"));
assert!(err.to_string().contains("refusing to overwrite")); assert!(err.to_string().contains("obsolete and read-only"));
assert!(err.to_string().contains("yoi ticket init")); assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH));
assert_eq!( assert_eq!(
fs::read_to_string(config_path).unwrap(), fs::read_to_string(config_path).unwrap(),
"[backend]\nprovider = \"builtin:yoi_local\"\n" "[backend]\nprovider = \"builtin:yoi_local\"\n"
@ -1414,8 +1483,8 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", "[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
) )
.unwrap(); .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. | | `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. | | `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. 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 | | 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. | | `[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. |
| `[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.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`. | | `[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`. |
| `[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.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. | | 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. | | 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** 3. **Dogfood workflow retention**
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics. - 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** 4. **Vocabulary migration**
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language. - 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 ## 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: MVP shape:
```toml ```toml
[backend] [ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = ".yoi/tickets" root = ".yoi/tickets"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch" launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch" launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
launch_prompt = "$workspace/ticket/coder/launch" launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
@ -145,10 +148,10 @@ Fixed roles are:
- `reviewer` - `reviewer`
This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration. 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. 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. `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`. `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 provider: `builtin:yoi_local`
- backend root: `<workspace>/.yoi/tickets` - backend root: `<workspace>/.yoi/tickets`
@ -168,7 +171,7 @@ If `.yoi/ticket.config.toml` is missing, defaults are:
- coder: `multi-agent-workflow` - coder: `multi-agent-workflow`
- reviewer: `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 ## 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. `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. - intake launches the intake role without an existing Ticket and requires freeform context.
- route launches the orchestrator role for an existing Ticket. - route launches the orchestrator role for an existing Ticket.
@ -288,7 +291,7 @@ The role-launch path is:
```text ```text
User triggers a Ticket action in yoi panel User triggers a Ticket action in yoi panel
-> Dashboard builds a TicketRoleLaunchContext -> 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 selects the role Profile and workflow
-> launcher spawns the role Worker -> launcher spawns the role Worker
-> launcher sends Method::Run with WorkflowInvoke + Text segments -> 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 first run input contains:
- the selected fixed role; - 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; - Ticket id when the action targets an existing Ticket;
- freeform user instruction/context from the action; - freeform user instruction/context from the action;
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution. - 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 ### Dashboard setup
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions: Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
```toml ```toml
# .yoi/ticket.config.toml # .yoi/workspace.toml
[backend] [ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = ".yoi/tickets" root = ".yoi/tickets"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
``` ```
@ -340,8 +343,8 @@ If a role still uses `profile = "inherit"`, the Dashboard fails closed with a di
### Dashboard troubleshooting ### Dashboard troubleshooting
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`. - `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`.
- malformed `.yoi/ticket.config.toml`: fix the config and retry. - 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. - 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. - launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.