From 10d7844fc84683429e24671bf01dd57885a40084 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 8 Jun 2026 08:13:19 +0900 Subject: [PATCH 1/2] ticket: parse item frontmatter as YAML --- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../20260527-000009-pod-session-fork/item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- .../item.md | 2 +- Cargo.lock | 1 + crates/ticket/Cargo.toml | 1 + crates/ticket/src/lib.rs | 461 ++++++++++++++---- crates/tui/src/workspace_panel.rs | 40 ++ package.nix | 2 +- 53 files changed, 450 insertions(+), 151 deletions(-) diff --git a/.yoi/tickets/closed/20260527-000004-manual-turn-rollback/item.md b/.yoi/tickets/closed/20260527-000004-manual-turn-rollback/item.md index b738deaa..d0ba13c2 100644 --- a/.yoi/tickets/closed/20260527-000004-manual-turn-rollback/item.md +++ b/.yoi/tickets/closed/20260527-000004-manual-turn-rollback/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000004-manual-turn-rollback slug: manual-turn-rollback -title: Pod/TUI: 手動 rewind 導線 +title: 'Pod/TUI: 手動 rewind 導線' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000005-memory-tool-guidance-prompt/item.md b/.yoi/tickets/closed/20260527-000005-memory-tool-guidance-prompt/item.md index 94f7d817..1ad3dd5f 100644 --- a/.yoi/tickets/closed/20260527-000005-memory-tool-guidance-prompt/item.md +++ b/.yoi/tickets/closed/20260527-000005-memory-tool-guidance-prompt/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000005-memory-tool-guidance-prompt slug: memory-tool-guidance-prompt -title: プロンプト: memory / knowledge tool 利用タイミングのガイダンス +title: 'プロンプト: memory / knowledge tool 利用タイミングのガイダンス' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000008-pod-scope-persistence-authority/item.md b/.yoi/tickets/closed/20260527-000008-pod-scope-persistence-authority/item.md index c3c7b67a..93ca66ce 100644 --- a/.yoi/tickets/closed/20260527-000008-pod-scope-persistence-authority/item.md +++ b/.yoi/tickets/closed/20260527-000008-pod-scope-persistence-authority/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000008-pod-scope-persistence-authority slug: pod-scope-persistence-authority -title: Pod: scope 永続化 authority の整理 +title: 'Pod: scope 永続化 authority の整理' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000012-spawnpod-initial-run-confirmation/item.md b/.yoi/tickets/closed/20260527-000012-spawnpod-initial-run-confirmation/item.md index 93e63b85..555867f7 100644 --- a/.yoi/tickets/closed/20260527-000012-spawnpod-initial-run-confirmation/item.md +++ b/.yoi/tickets/closed/20260527-000012-spawnpod-initial-run-confirmation/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000012-spawnpod-initial-run-confirmation slug: spawnpod-initial-run-confirmation -title: SpawnPod: initial Run delivery confirmation +title: 'SpawnPod: initial Run delivery confirmation' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000013-tickets-sh-workitem-thread-mvp/item.md b/.yoi/tickets/closed/20260527-000013-tickets-sh-workitem-thread-mvp/item.md index a66113e1..33ab473f 100644 --- a/.yoi/tickets/closed/20260527-000013-tickets-sh-workitem-thread-mvp/item.md +++ b/.yoi/tickets/closed/20260527-000013-tickets-sh-workitem-thread-mvp/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000013-tickets-sh-workitem-thread-mvp slug: tickets-sh-workitem-thread-mvp -title: Ticket 管理: tickets.sh による WorkItem / Thread MVP +title: 'Ticket 管理: tickets.sh による WorkItem / Thread MVP' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000014-tui-actionbar-transient-notice-api/item.md b/.yoi/tickets/closed/20260527-000014-tui-actionbar-transient-notice-api/item.md index 220fd213..f5ce6325 100644 --- a/.yoi/tickets/closed/20260527-000014-tui-actionbar-transient-notice-api/item.md +++ b/.yoi/tickets/closed/20260527-000014-tui-actionbar-transient-notice-api/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000014-tui-actionbar-transient-notice-api slug: tui-actionbar-transient-notice-api -title: TUI: actionbar transient notice API +title: 'TUI: actionbar transient notice API' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000016-tui-picker-live-pending-pods/item.md b/.yoi/tickets/closed/20260527-000016-tui-picker-live-pending-pods/item.md index acd09a25..d5b6a057 100644 --- a/.yoi/tickets/closed/20260527-000016-tui-picker-live-pending-pods/item.md +++ b/.yoi/tickets/closed/20260527-000016-tui-picker-live-pending-pods/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000016-tui-picker-live-pending-pods slug: tui-picker-live-pending-pods -title: TUI picker: live pending Pod の表示優先と状態補完 +title: 'TUI picker: live pending Pod の表示優先と状態補完' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260527-000017-tui-spawned-pod-panel/item.md b/.yoi/tickets/closed/20260527-000017-tui-spawned-pod-panel/item.md index 8e26a873..e3c4b7c6 100644 --- a/.yoi/tickets/closed/20260527-000017-tui-spawned-pod-panel/item.md +++ b/.yoi/tickets/closed/20260527-000017-tui-spawned-pod-panel/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000017-tui-spawned-pod-panel slug: tui-spawned-pod-panel -title: TUI: spawned child Pod の一覧と一時 attach +title: 'TUI: spawned child Pod の一覧と一時 attach' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260528-001748-compact-session-log-exploration/item.md b/.yoi/tickets/closed/20260528-001748-compact-session-log-exploration/item.md index fcc44118..acdebea4 100644 --- a/.yoi/tickets/closed/20260528-001748-compact-session-log-exploration/item.md +++ b/.yoi/tickets/closed/20260528-001748-compact-session-log-exploration/item.md @@ -1,7 +1,7 @@ --- id: 20260528-001748-compact-session-log-exploration slug: compact-session-log-exploration -title: Compact: session log 探索型の要約入力に変更する +title: 'Compact: session log 探索型の要約入力に変更する' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260529-145355-manifest-profile-encrypted-secrets/item.md b/.yoi/tickets/closed/20260529-145355-manifest-profile-encrypted-secrets/item.md index 59099fb4..e1405fee 100644 --- a/.yoi/tickets/closed/20260529-145355-manifest-profile-encrypted-secrets/item.md +++ b/.yoi/tickets/closed/20260529-145355-manifest-profile-encrypted-secrets/item.md @@ -1,7 +1,7 @@ --- id: 20260529-145355-manifest-profile-encrypted-secrets slug: manifest-profile-encrypted-secrets -title: Manifest/Profile: local key-value secret store +title: 'Manifest/Profile: local key-value secret store' status: closed kind: feature priority: P2 diff --git a/.yoi/tickets/closed/20260530-062852-refresh-stale-docs/item.md b/.yoi/tickets/closed/20260530-062852-refresh-stale-docs/item.md index 0fe08564..5d804f00 100644 --- a/.yoi/tickets/closed/20260530-062852-refresh-stale-docs/item.md +++ b/.yoi/tickets/closed/20260530-062852-refresh-stale-docs/item.md @@ -1,7 +1,7 @@ --- id: 20260530-062852-refresh-stale-docs slug: refresh-stale-docs -title: Docs: refresh stale architecture and operation docs +title: 'Docs: refresh stale architecture and operation docs' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260530-204045-webfetch-readable-extraction/item.md b/.yoi/tickets/closed/20260530-204045-webfetch-readable-extraction/item.md index 5bc3d91d..e35de6ae 100644 --- a/.yoi/tickets/closed/20260530-204045-webfetch-readable-extraction/item.md +++ b/.yoi/tickets/closed/20260530-204045-webfetch-readable-extraction/item.md @@ -1,7 +1,7 @@ --- id: 20260530-204045-webfetch-readable-extraction slug: webfetch-readable-extraction -title: WebFetch: extract main HTML content with lightweight readability +title: 'WebFetch: extract main HTML content with lightweight readability' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260530-215928-webfetch-local-reader-markdown/item.md b/.yoi/tickets/closed/20260530-215928-webfetch-local-reader-markdown/item.md index 73d29efc..4060ff4d 100644 --- a/.yoi/tickets/closed/20260530-215928-webfetch-local-reader-markdown/item.md +++ b/.yoi/tickets/closed/20260530-215928-webfetch-local-reader-markdown/item.md @@ -1,7 +1,7 @@ --- id: 20260530-215928-webfetch-local-reader-markdown slug: webfetch-local-reader-markdown -title: WebFetch: replace readability dependency with Markdown-preserving local reader +title: 'WebFetch: replace readability dependency with Markdown-preserving local reader' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-003743-codex-gpt55-effective-context-window/item.md b/.yoi/tickets/closed/20260531-003743-codex-gpt55-effective-context-window/item.md index 0c3f340e..726f6156 100644 --- a/.yoi/tickets/closed/20260531-003743-codex-gpt55-effective-context-window/item.md +++ b/.yoi/tickets/closed/20260531-003743-codex-gpt55-effective-context-window/item.md @@ -1,7 +1,7 @@ --- id: 20260531-003743-codex-gpt55-effective-context-window slug: codex-gpt55-effective-context-window -title: Provider: make codex gpt-5.5 context window effective +title: 'Provider: make codex gpt-5.5 context window effective' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-005557-single-binary-insomnia-cli/item.md b/.yoi/tickets/closed/20260531-005557-single-binary-insomnia-cli/item.md index d4b40451..e8c88183 100644 --- a/.yoi/tickets/closed/20260531-005557-single-binary-insomnia-cli/item.md +++ b/.yoi/tickets/closed/20260531-005557-single-binary-insomnia-cli/item.md @@ -1,7 +1,7 @@ --- id: 20260531-005557-single-binary-insomnia-cli slug: single-binary-insomnia-cli -title: CLI: migrate toward a single insomnia binary +title: 'CLI: migrate toward a single insomnia binary' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-022821-pod-tool-surface-restore-list/item.md b/.yoi/tickets/closed/20260531-022821-pod-tool-surface-restore-list/item.md index 65467b8b..57f04b8d 100644 --- a/.yoi/tickets/closed/20260531-022821-pod-tool-surface-restore-list/item.md +++ b/.yoi/tickets/closed/20260531-022821-pod-tool-surface-restore-list/item.md @@ -1,7 +1,7 @@ --- id: 20260531-022821-pod-tool-surface-restore-list slug: pod-tool-surface-restore-list -title: Pod tools: unify pod listing and rename restore operation +title: 'Pod tools: unify pod listing and rename restore operation' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-043239-insomnia-pod-subcommand-runtime/item.md b/.yoi/tickets/closed/20260531-043239-insomnia-pod-subcommand-runtime/item.md index 712e7626..1d42ace9 100644 --- a/.yoi/tickets/closed/20260531-043239-insomnia-pod-subcommand-runtime/item.md +++ b/.yoi/tickets/closed/20260531-043239-insomnia-pod-subcommand-runtime/item.md @@ -1,7 +1,7 @@ --- id: 20260531-043239-insomnia-pod-subcommand-runtime slug: insomnia-pod-subcommand-runtime -title: CLI: add insomnia pod runtime entrypoint +title: 'CLI: add insomnia pod runtime entrypoint' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-045034-spawn-through-insomnia-pod-subcommand/item.md b/.yoi/tickets/closed/20260531-045034-spawn-through-insomnia-pod-subcommand/item.md index 2b47e41a..3ddcd3f3 100644 --- a/.yoi/tickets/closed/20260531-045034-spawn-through-insomnia-pod-subcommand/item.md +++ b/.yoi/tickets/closed/20260531-045034-spawn-through-insomnia-pod-subcommand/item.md @@ -1,7 +1,7 @@ --- id: 20260531-045034-spawn-through-insomnia-pod-subcommand slug: spawn-through-insomnia-pod-subcommand -title: CLI: spawn Pods through insomnia pod runtime +title: 'CLI: spawn Pods through insomnia pod runtime' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-054728-remove-insomnia-pod-binary/item.md b/.yoi/tickets/closed/20260531-054728-remove-insomnia-pod-binary/item.md index 4920499e..30f4d52e 100644 --- a/.yoi/tickets/closed/20260531-054728-remove-insomnia-pod-binary/item.md +++ b/.yoi/tickets/closed/20260531-054728-remove-insomnia-pod-binary/item.md @@ -1,7 +1,7 @@ --- id: 20260531-054728-remove-insomnia-pod-binary slug: remove-insomnia-pod-binary -title: CLI: remove insomnia-pod installed/runtime alias +title: 'CLI: remove insomnia-pod installed/runtime alias' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-064550-rename-pod-command-crate-to-insomnia/item.md b/.yoi/tickets/closed/20260531-064550-rename-pod-command-crate-to-insomnia/item.md index 77ad4032..40e830ff 100644 --- a/.yoi/tickets/closed/20260531-064550-rename-pod-command-crate-to-insomnia/item.md +++ b/.yoi/tickets/closed/20260531-064550-rename-pod-command-crate-to-insomnia/item.md @@ -1,7 +1,7 @@ --- id: 20260531-064550-rename-pod-command-crate-to-insomnia slug: rename-pod-command-crate-to-insomnia -title: CLI: rename pod-command crate to insomnia +title: 'CLI: rename pod-command crate to insomnia' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-074258-tui-extract-cli-parsing/item.md b/.yoi/tickets/closed/20260531-074258-tui-extract-cli-parsing/item.md index 0713be7a..b74593fb 100644 --- a/.yoi/tickets/closed/20260531-074258-tui-extract-cli-parsing/item.md +++ b/.yoi/tickets/closed/20260531-074258-tui-extract-cli-parsing/item.md @@ -1,7 +1,7 @@ --- id: 20260531-074258-tui-extract-cli-parsing slug: tui-extract-cli-parsing -title: TUI: extract CLI parsing from main.rs +title: 'TUI: extract CLI parsing from main.rs' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-074258-tui-extract-single-pod-runtime/item.md b/.yoi/tickets/closed/20260531-074258-tui-extract-single-pod-runtime/item.md index eae4685c..f19ec0dc 100644 --- a/.yoi/tickets/closed/20260531-074258-tui-extract-single-pod-runtime/item.md +++ b/.yoi/tickets/closed/20260531-074258-tui-extract-single-pod-runtime/item.md @@ -1,7 +1,7 @@ --- id: 20260531-074258-tui-extract-single-pod-runtime slug: tui-extract-single-pod-runtime -title: TUI: extract single-Pod runtime loop from main.rs +title: 'TUI: extract single-Pod runtime loop from main.rs' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-074258-tui-move-view-mode-state/item.md b/.yoi/tickets/closed/20260531-074258-tui-move-view-mode-state/item.md index 611c571e..4cc8dce5 100644 --- a/.yoi/tickets/closed/20260531-074258-tui-move-view-mode-state/item.md +++ b/.yoi/tickets/closed/20260531-074258-tui-move-view-mode-state/item.md @@ -1,7 +1,7 @@ --- id: 20260531-074258-tui-move-view-mode-state slug: tui-move-view-mode-state -title: TUI: move view mode state out of ui module +title: 'TUI: move view mode state out of ui module' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-082646-document-env-var-policy/item.md b/.yoi/tickets/closed/20260531-082646-document-env-var-policy/item.md index f44cf650..d051f6cd 100644 --- a/.yoi/tickets/closed/20260531-082646-document-env-var-policy/item.md +++ b/.yoi/tickets/closed/20260531-082646-document-env-var-policy/item.md @@ -1,7 +1,7 @@ --- id: 20260531-082646-document-env-var-policy slug: document-env-var-policy -title: Docs: document environment variable policy +title: 'Docs: document environment variable policy' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-085959-eliminate-test-only-env-vars/item.md b/.yoi/tickets/closed/20260531-085959-eliminate-test-only-env-vars/item.md index a3668d06..427911f8 100644 --- a/.yoi/tickets/closed/20260531-085959-eliminate-test-only-env-vars/item.md +++ b/.yoi/tickets/closed/20260531-085959-eliminate-test-only-env-vars/item.md @@ -1,7 +1,7 @@ --- id: 20260531-085959-eliminate-test-only-env-vars slug: eliminate-test-only-env-vars -title: Tests: eliminate test-only environment variables +title: 'Tests: eliminate test-only environment variables' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-085959-remove-insomnia-pod-command-env/item.md b/.yoi/tickets/closed/20260531-085959-remove-insomnia-pod-command-env/item.md index 5f680054..0118e80e 100644 --- a/.yoi/tickets/closed/20260531-085959-remove-insomnia-pod-command-env/item.md +++ b/.yoi/tickets/closed/20260531-085959-remove-insomnia-pod-command-env/item.md @@ -1,7 +1,7 @@ --- id: 20260531-085959-remove-insomnia-pod-command-env slug: remove-insomnia-pod-command-env -title: CLI: remove INSOMNIA_POD_COMMAND override +title: 'CLI: remove INSOMNIA_POD_COMMAND override' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-104614-pure-path-fallback-tests/item.md b/.yoi/tickets/closed/20260531-104614-pure-path-fallback-tests/item.md index df162000..9e9277f1 100644 --- a/.yoi/tickets/closed/20260531-104614-pure-path-fallback-tests/item.md +++ b/.yoi/tickets/closed/20260531-104614-pure-path-fallback-tests/item.md @@ -1,7 +1,7 @@ --- id: 20260531-104614-pure-path-fallback-tests slug: pure-path-fallback-tests -title: Tests: make path fallback tests independent from process env +title: 'Tests: make path fallback tests independent from process env' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-110818-remove-resource-dir/item.md b/.yoi/tickets/closed/20260531-110818-remove-resource-dir/item.md index 9b2d73ca..28e6d801 100644 --- a/.yoi/tickets/closed/20260531-110818-remove-resource-dir/item.md +++ b/.yoi/tickets/closed/20260531-110818-remove-resource-dir/item.md @@ -1,7 +1,7 @@ --- id: 20260531-110818-remove-resource-dir slug: remove-resource-dir -title: Manifest: remove filesystem resource_dir dependency +title: 'Manifest: remove filesystem resource_dir dependency' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-111956-insomnia-crate-cli-owner/item.md b/.yoi/tickets/closed/20260531-111956-insomnia-crate-cli-owner/item.md index 8250c33d..97f7364f 100644 --- a/.yoi/tickets/closed/20260531-111956-insomnia-crate-cli-owner/item.md +++ b/.yoi/tickets/closed/20260531-111956-insomnia-crate-cli-owner/item.md @@ -1,7 +1,7 @@ --- id: 20260531-111956-insomnia-crate-cli-owner slug: insomnia-crate-cli-owner -title: CLI: make insomnia crate own binary entrypoint and CLI dispatch +title: 'CLI: make insomnia crate own binary entrypoint and CLI dispatch' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-124040-dev-pod-runtime-command-env/item.md b/.yoi/tickets/closed/20260531-124040-dev-pod-runtime-command-env/item.md index 64d79466..67afe506 100644 --- a/.yoi/tickets/closed/20260531-124040-dev-pod-runtime-command-env/item.md +++ b/.yoi/tickets/closed/20260531-124040-dev-pod-runtime-command-env/item.md @@ -1,7 +1,7 @@ --- id: 20260531-124040-dev-pod-runtime-command-env slug: dev-pod-runtime-command-env -title: Dev: add Pod runtime executable override env +title: 'Dev: add Pod runtime executable override env' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260531-223506-memory-prompt-conditional-lookup/item.md b/.yoi/tickets/closed/20260531-223506-memory-prompt-conditional-lookup/item.md index 6e6459cb..60b24b95 100644 --- a/.yoi/tickets/closed/20260531-223506-memory-prompt-conditional-lookup/item.md +++ b/.yoi/tickets/closed/20260531-223506-memory-prompt-conditional-lookup/item.md @@ -1,7 +1,7 @@ --- id: 20260531-223506-memory-prompt-conditional-lookup slug: memory-prompt-conditional-lookup -title: Memory prompt: conditional guidance and proactive lookup +title: 'Memory prompt: conditional guidance and proactive lookup' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260601-013132-tui-new-session-first-message-missing/item.md b/.yoi/tickets/closed/20260601-013132-tui-new-session-first-message-missing/item.md index ca836b75..81251f33 100644 --- a/.yoi/tickets/closed/20260601-013132-tui-new-session-first-message-missing/item.md +++ b/.yoi/tickets/closed/20260601-013132-tui-new-session-first-message-missing/item.md @@ -1,7 +1,7 @@ --- id: 20260601-013132-tui-new-session-first-message-missing slug: tui-new-session-first-message-missing -title: TUI: first message missing when starting a new session +title: 'TUI: first message missing when starting a new session' status: closed kind: bug priority: P1 diff --git a/.yoi/tickets/closed/20260601-020202-tui-keys-inline-viewport-ui/item.md b/.yoi/tickets/closed/20260601-020202-tui-keys-inline-viewport-ui/item.md index cbba571e..5f353b42 100644 --- a/.yoi/tickets/closed/20260601-020202-tui-keys-inline-viewport-ui/item.md +++ b/.yoi/tickets/closed/20260601-020202-tui-keys-inline-viewport-ui/item.md @@ -1,7 +1,7 @@ --- id: 20260601-020202-tui-keys-inline-viewport-ui slug: tui-keys-inline-viewport-ui -title: TUI: align insomnia keys UI with inline viewport style +title: 'TUI: align insomnia keys UI with inline viewport style' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260601-132955-tui-peer-pod-handshake-command/item.md b/.yoi/tickets/closed/20260601-132955-tui-peer-pod-handshake-command/item.md index 14a0fb30..3c201dff 100644 --- a/.yoi/tickets/closed/20260601-132955-tui-peer-pod-handshake-command/item.md +++ b/.yoi/tickets/closed/20260601-132955-tui-peer-pod-handshake-command/item.md @@ -1,7 +1,7 @@ --- id: 20260601-132955-tui-peer-pod-handshake-command slug: tui-peer-pod-handshake-command -title: TUI: add peer Pod handshake and messaging command +title: 'TUI: add peer Pod handshake and messaging command' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/closed/20260603-122317-hook-public-surface-hardening/item.md b/.yoi/tickets/closed/20260603-122317-hook-public-surface-hardening/item.md index de954073..eb398c93 100644 --- a/.yoi/tickets/closed/20260603-122317-hook-public-surface-hardening/item.md +++ b/.yoi/tickets/closed/20260603-122317-hook-public-surface-hardening/item.md @@ -1,7 +1,7 @@ --- id: 20260603-122317-hook-public-surface-hardening slug: hook-public-surface-hardening -title: Hook: harden public hook surface before plugin exposure +title: 'Hook: harden public hook surface before plugin exposure' status: closed kind: task priority: P1 diff --git a/.yoi/tickets/closed/20260603-122317-plugin-feature-contribution-registry/item.md b/.yoi/tickets/closed/20260603-122317-plugin-feature-contribution-registry/item.md index 86e87d28..6d80763c 100644 --- a/.yoi/tickets/closed/20260603-122317-plugin-feature-contribution-registry/item.md +++ b/.yoi/tickets/closed/20260603-122317-plugin-feature-contribution-registry/item.md @@ -1,7 +1,7 @@ --- id: 20260603-122317-plugin-feature-contribution-registry slug: plugin-feature-contribution-registry -title: Plugin: feature contribution registry for built-in and external capabilities +title: 'Plugin: feature contribution registry for built-in and external capabilities' status: closed kind: feature priority: P1 diff --git a/.yoi/tickets/closed/20260604-223500-task-tools-builtin-plugin/item.md b/.yoi/tickets/closed/20260604-223500-task-tools-builtin-plugin/item.md index a1d63b31..d6ff7ee9 100644 --- a/.yoi/tickets/closed/20260604-223500-task-tools-builtin-plugin/item.md +++ b/.yoi/tickets/closed/20260604-223500-task-tools-builtin-plugin/item.md @@ -1,7 +1,7 @@ --- id: 20260604-223500-task-tools-builtin-plugin slug: task-tools-builtin-plugin -title: Feature: extract Task tools as builtin module +title: 'Feature: extract Task tools as builtin module' status: closed kind: task priority: P1 diff --git a/.yoi/tickets/closed/20260604-234844-feature-api-authority-separation/item.md b/.yoi/tickets/closed/20260604-234844-feature-api-authority-separation/item.md index 9e787f91..b8d8f145 100644 --- a/.yoi/tickets/closed/20260604-234844-feature-api-authority-separation/item.md +++ b/.yoi/tickets/closed/20260604-234844-feature-api-authority-separation/item.md @@ -1,7 +1,7 @@ --- id: 20260604-234844-feature-api-authority-separation slug: feature-api-authority-separation -title: Feature API: separate internal modules from external-plugin authority model +title: 'Feature API: separate internal modules from external-plugin authority model' status: closed kind: task priority: P1 diff --git a/.yoi/tickets/closed/20260605-004807-hook-context-system-item-sink/item.md b/.yoi/tickets/closed/20260605-004807-hook-context-system-item-sink/item.md index 1f500415..0df514b7 100644 --- a/.yoi/tickets/closed/20260605-004807-hook-context-system-item-sink/item.md +++ b/.yoi/tickets/closed/20260605-004807-hook-context-system-item-sink/item.md @@ -1,7 +1,7 @@ --- id: 20260605-004807-hook-context-system-item-sink slug: hook-context-system-item-sink -title: Hook: add context handles for host-mediated SystemItem append +title: 'Hook: add context handles for host-mediated SystemItem append' status: closed kind: feature priority: P1 diff --git a/.yoi/tickets/closed/20260605-004807-task-feature-own-store-reminder-hooks/item.md b/.yoi/tickets/closed/20260605-004807-task-feature-own-store-reminder-hooks/item.md index 8948c300..5d041094 100644 --- a/.yoi/tickets/closed/20260605-004807-task-feature-own-store-reminder-hooks/item.md +++ b/.yoi/tickets/closed/20260605-004807-task-feature-own-store-reminder-hooks/item.md @@ -1,7 +1,7 @@ --- id: 20260605-004807-task-feature-own-store-reminder-hooks slug: task-feature-own-store-reminder-hooks -title: Task: move TaskStore and reminders into Task feature +title: 'Task: move TaskStore and reminders into Task feature' status: closed kind: task priority: P1 diff --git a/.yoi/tickets/closed/20260605-025100-task-domain-in-pod-feature/item.md b/.yoi/tickets/closed/20260605-025100-task-domain-in-pod-feature/item.md index b8d23993..ca6c1013 100644 --- a/.yoi/tickets/closed/20260605-025100-task-domain-in-pod-feature/item.md +++ b/.yoi/tickets/closed/20260605-025100-task-domain-in-pod-feature/item.md @@ -1,7 +1,7 @@ --- id: 20260605-025100-task-domain-in-pod-feature slug: task-domain-in-pod-feature -title: Task: move Task domain out of tools into pod built-in feature +title: 'Task: move Task domain out of tools into pod built-in feature' status: closed kind: task priority: P1 diff --git a/.yoi/tickets/closed/20260606-210832-remove-tui-ticket-commands/item.md b/.yoi/tickets/closed/20260606-210832-remove-tui-ticket-commands/item.md index 821756dc..c9180f0a 100644 --- a/.yoi/tickets/closed/20260606-210832-remove-tui-ticket-commands/item.md +++ b/.yoi/tickets/closed/20260606-210832-remove-tui-ticket-commands/item.md @@ -1,7 +1,7 @@ --- id: 20260606-210832-remove-tui-ticket-commands slug: remove-tui-ticket-commands -title: Remove obsolete TUI :ticket commands +title: 'Remove obsolete TUI :ticket commands' status: closed kind: task priority: P2 diff --git a/.yoi/tickets/open/20260527-000006-permission-default-policy/item.md b/.yoi/tickets/open/20260527-000006-permission-default-policy/item.md index e80f386f..7001dbad 100644 --- a/.yoi/tickets/open/20260527-000006-permission-default-policy/item.md +++ b/.yoi/tickets/open/20260527-000006-permission-default-policy/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000006-permission-default-policy slug: permission-default-policy -title: Permission: allow-all 既定 policy への整理 +title: 'Permission: allow-all 既定 policy への整理' status: open kind: task priority: P2 diff --git a/.yoi/tickets/open/20260527-000009-pod-session-fork/item.md b/.yoi/tickets/open/20260527-000009-pod-session-fork/item.md index 1086b8cd..f005e05e 100644 --- a/.yoi/tickets/open/20260527-000009-pod-session-fork/item.md +++ b/.yoi/tickets/open/20260527-000009-pod-session-fork/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000009-pod-session-fork slug: pod-session-fork -title: Pod: 任意ターンからの Fork(複数ターン巻き戻し) +title: 'Pod: 任意ターンからの Fork(複数ターン巻き戻し)' status: open kind: task priority: P2 diff --git a/.yoi/tickets/open/20260527-000015-tui-navigation-mode-design/item.md b/.yoi/tickets/open/20260527-000015-tui-navigation-mode-design/item.md index c0a6999b..1403b603 100644 --- a/.yoi/tickets/open/20260527-000015-tui-navigation-mode-design/item.md +++ b/.yoi/tickets/open/20260527-000015-tui-navigation-mode-design/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000015-tui-navigation-mode-design slug: tui-navigation-mode-design -title: TUI: navigation mode / block focus の設計 +title: 'TUI: navigation mode / block focus の設計' status: open kind: task priority: P2 diff --git a/.yoi/tickets/open/20260527-000018-tui-user-model-setup/item.md b/.yoi/tickets/open/20260527-000018-tui-user-model-setup/item.md index 391085b7..55b5cffb 100644 --- a/.yoi/tickets/open/20260527-000018-tui-user-model-setup/item.md +++ b/.yoi/tickets/open/20260527-000018-tui-user-model-setup/item.md @@ -1,7 +1,7 @@ --- id: 20260527-000018-tui-user-model-setup slug: tui-user-model-setup -title: TUI: ユーザーマニフェストのモデル設定 wizard +title: 'TUI: ユーザーマニフェストのモデル設定 wizard' status: open kind: task priority: P2 diff --git a/.yoi/tickets/open/20260531-010005-plugin-extension-surface/item.md b/.yoi/tickets/open/20260531-010005-plugin-extension-surface/item.md index 33b1402b..3ea45088 100644 --- a/.yoi/tickets/open/20260531-010005-plugin-extension-surface/item.md +++ b/.yoi/tickets/open/20260531-010005-plugin-extension-surface/item.md @@ -1,7 +1,7 @@ --- id: 20260531-010005-plugin-extension-surface slug: plugin-extension-surface -title: Plugin: define extension surface for hooks and tools +title: 'Plugin: define extension surface for hooks and tools' status: open kind: feature priority: P2 diff --git a/.yoi/tickets/open/20260601-021104-tui-composer-history-persistence/item.md b/.yoi/tickets/open/20260601-021104-tui-composer-history-persistence/item.md index 20c36d3a..18c7cac6 100644 --- a/.yoi/tickets/open/20260601-021104-tui-composer-history-persistence/item.md +++ b/.yoi/tickets/open/20260601-021104-tui-composer-history-persistence/item.md @@ -1,7 +1,7 @@ --- id: 20260601-021104-tui-composer-history-persistence slug: tui-composer-history-persistence -title: TUI: persist composer input recall history per workspace +title: 'TUI: persist composer input recall history per workspace' status: open kind: task priority: P2 diff --git a/Cargo.lock b/Cargo.lock index 6bc64b65..555ee93d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3632,6 +3632,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_yaml", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/crates/ticket/Cargo.toml b/crates/ticket/Cargo.toml index 73809b26..04a7181c 100644 --- a/crates/ticket/Cargo.toml +++ b/crates/ticket/Cargo.toml @@ -12,6 +12,7 @@ llm-worker = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_yaml = "0.9.34" thiserror.workspace = true toml = { workspace = true } diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 9a7771a5..f3b18f96 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf}; use chrono::Utc; use fs4::fs_std::FileExt; +use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use thiserror::Error; pub mod config; @@ -789,7 +790,7 @@ impl LocalTicketBackend { let meta = ticket_meta(parsed.frontmatter.clone()); let document = TicketDocument { body: MarkdownText::new(parsed.body), - raw_frontmatter: parsed.frontmatter, + raw_frontmatter: parsed.frontmatter.raw, }; let thread_path = dir.join("thread.md"); let events = if thread_path.exists() { @@ -1021,33 +1022,52 @@ impl TicketBackend for LocalTicketBackend { fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?; atomic_write(&dir.join("artifacts/.gitkeep"), b"")?; let mut fields = Vec::new(); - fields.push(("id".to_string(), id.clone())); - fields.push(("slug".to_string(), slug.clone())); - fields.push(("title".to_string(), input.title)); - fields.push(("status".to_string(), "open".to_string())); - fields.push(("kind".to_string(), input.kind)); - fields.push(("priority".to_string(), input.priority)); + fields.push(("id".to_string(), format_yaml_string_scalar(&id))); + fields.push(("slug".to_string(), format_yaml_string_scalar(&slug))); + fields.push(( + "title".to_string(), + format_yaml_string_scalar(input.title.as_str()), + )); + fields.push(("status".to_string(), format_yaml_string_scalar("open"))); + fields.push(( + "kind".to_string(), + format_yaml_string_scalar(input.kind.as_str()), + )); + fields.push(( + "priority".to_string(), + format_yaml_string_scalar(input.priority.as_str()), + )); fields.push(("labels".to_string(), labels_yaml(&input.labels))); fields.push(( "workflow_state".to_string(), - input - .workflow_state - .unwrap_or(TicketWorkflowState::Intake) - .as_str() - .to_string(), + format_yaml_string_scalar( + input + .workflow_state + .unwrap_or(TicketWorkflowState::Intake) + .as_str(), + ), + )); + fields.push(( + "created_at".to_string(), + format_yaml_string_scalar(&created), + )); + fields.push(( + "updated_at".to_string(), + format_yaml_string_scalar(&created), )); - fields.push(("created_at".to_string(), created.clone())); - fields.push(("updated_at".to_string(), created.clone())); fields.push(( "assignee".to_string(), - input.assignee.unwrap_or_else(|| "null".to_string()), + yaml_string_or_null(input.assignee.as_deref()), )); fields.push(( "legacy_ticket".to_string(), - input.legacy_ticket.unwrap_or_else(|| "null".to_string()), + yaml_string_or_null(input.legacy_ticket.as_deref()), )); if let Some(readiness) = input.readiness { - fields.push(("readiness".to_string(), readiness)); + fields.push(( + "readiness".to_string(), + format_yaml_string_scalar(readiness.as_str()), + )); } if let Some(needs_preflight) = input.needs_preflight { fields.push(("needs_preflight".to_string(), needs_preflight.to_string())); @@ -1056,16 +1076,28 @@ impl TicketBackend for LocalTicketBackend { fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags))); } if let Some(action_required) = input.action_required { - fields.push(("action_required".to_string(), action_required)); + fields.push(( + "action_required".to_string(), + format_yaml_string_scalar(action_required.as_str()), + )); } if let Some(attention_required) = input.attention_required { - fields.push(("attention_required".to_string(), attention_required)); + fields.push(( + "attention_required".to_string(), + format_yaml_string_scalar(attention_required.as_str()), + )); } if let Some(queued_by) = input.queued_by { - fields.push(("queued_by".to_string(), queued_by)); + fields.push(( + "queued_by".to_string(), + format_yaml_string_scalar(queued_by.as_str()), + )); } if let Some(queued_at) = input.queued_at { - fields.push(("queued_at".to_string(), queued_at)); + fields.push(( + "queued_at".to_string(), + format_yaml_string_scalar(queued_at.as_str()), + )); } let item = serialize_item(&fields, input.body.as_str()); atomic_write(&dir.join("item.md"), item.as_bytes())?; @@ -1520,10 +1552,41 @@ impl Drop for BackendLock { #[derive(Debug, Clone)] struct ParsedItem { - frontmatter: BTreeMap, + frontmatter: TicketItemFrontmatter, body: String, } +#[derive(Debug, Clone, Default)] +struct TicketItemFrontmatter { + id: Option, + slug: Option, + title: Option, + status: Option, + kind: Option, + priority: Option, + labels: Vec, + created_at: Option, + updated_at: Option, + assignee: Option, + legacy_ticket: Option, + readiness: Option, + needs_preflight: Option, + risk_flags: Vec, + action_required: Option, + workflow_state: Option, + workflow_state_explicit: bool, + attention_required: Option, + queued_by: Option, + queued_at: Option, + raw: BTreeMap, +} + +impl TicketItemFrontmatter { + fn get(&self, key: &str) -> Option<&String> { + self.raw.get(key) + } +} + fn read_item_file(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; parse_item(&content).map_err(|message| TicketError::Parse { @@ -1540,17 +1603,15 @@ fn parse_item(content: &str) -> std::result::Result { if first != "---" { return Err("item.md missing frontmatter opener".to_string()); } - let mut frontmatter = BTreeMap::new(); let mut found_close = false; + let mut frontmatter_lines = Vec::new(); let mut body = String::new(); for line in &mut lines { if line == "---" { found_close = true; break; } - if let Some((key, value)) = line.split_once(':') { - frontmatter.insert(key.trim().to_string(), value.trim_start().to_string()); - } + frontmatter_lines.push(line); } if !found_close { return Err("item.md missing frontmatter closer".to_string()); @@ -1562,89 +1623,225 @@ fn parse_item(content: &str) -> std::result::Result { body.push('\n'); } } + let frontmatter = parse_ticket_frontmatter(&frontmatter_lines.join("\n"))?; Ok(ParsedItem { frontmatter, body }) } -fn ticket_meta(frontmatter: BTreeMap) -> TicketMeta { - let id = frontmatter.get("id").cloned().unwrap_or_default(); - let slug = frontmatter.get("slug").cloned().unwrap_or_default(); - let title = frontmatter.get("title").cloned().unwrap_or_default(); - let status = frontmatter - .get("status") - .map(|value| ExtensibleTicketStatus::from(value.as_str())) - .unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new())); - let kind = frontmatter.get("kind").cloned().unwrap_or_default(); - let priority = frontmatter.get("priority").cloned().unwrap_or_default(); - let labels = frontmatter - .get("labels") - .map(|value| parse_yaml_list(value)) - .unwrap_or_default(); - let risk_flags = frontmatter - .get("risk_flags") - .or_else(|| frontmatter.get("risks")) - .map(|value| parse_yaml_list(value)) - .unwrap_or_default(); - let workflow_state_explicit = frontmatter.contains_key("workflow_state"); - let workflow_state = frontmatter - .get("workflow_state") - .and_then(|value| TicketWorkflowState::parse(value)) - .unwrap_or_else(|| TicketWorkflowState::default_for_status(&status)); - TicketMeta { - id, - slug, - title, - status, - kind, - priority, - labels, - created_at: frontmatter.get("created_at").cloned(), - updated_at: frontmatter.get("updated_at").cloned(), - assignee: frontmatter.get("assignee").cloned().filter(|v| v != "null"), - legacy_ticket: frontmatter - .get("legacy_ticket") - .cloned() - .filter(|v| v != "null"), - readiness: frontmatter.get("readiness").cloned(), - needs_preflight: frontmatter - .get("needs_preflight") - .or_else(|| frontmatter.get("needs-preflight")) - .and_then(|value| parse_bool(value)), - risk_flags, - action_required: frontmatter.get("action_required").cloned(), +fn parse_ticket_frontmatter(content: &str) -> std::result::Result { + let value: YamlValue = + serde_yaml::from_str(content).map_err(|err| format!("invalid YAML frontmatter: {err}"))?; + let mapping = match value { + YamlValue::Mapping(mapping) => mapping, + YamlValue::Null => YamlMapping::new(), + other => { + return Err(format!( + "frontmatter must be a YAML mapping, found {}", + yaml_kind(&other) + )); + } + }; + + let mut raw = BTreeMap::new(); + for (key, value) in &mapping { + let YamlValue::String(key) = key else { + return Err("frontmatter keys must be strings".to_string()); + }; + raw.insert(key.clone(), raw_frontmatter_value(value)?); + } + + let workflow_state_explicit = mapping.contains_key(YamlValue::String("workflow_state".into())); + let workflow_state_value = yaml_string(&mapping, "workflow_state")?; + let workflow_state = match workflow_state_value.as_deref() { + Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| { + format!("invalid workflow_state '{value}': expected intake, ready, queued, inprogress, or done") + })?), + None => None, + }; + + Ok(TicketItemFrontmatter { + id: yaml_string(&mapping, "id")?, + slug: yaml_string(&mapping, "slug")?, + title: yaml_string(&mapping, "title")?, + status: yaml_string(&mapping, "status")?, + kind: yaml_string(&mapping, "kind")?, + priority: yaml_string(&mapping, "priority")?, + labels: yaml_string_list(&mapping, "labels")?, + created_at: yaml_string(&mapping, "created_at")?, + updated_at: yaml_string(&mapping, "updated_at")?, + assignee: yaml_string(&mapping, "assignee")?, + legacy_ticket: yaml_string(&mapping, "legacy_ticket")?, + readiness: yaml_string(&mapping, "readiness")?, + needs_preflight: yaml_bool(&mapping, "needs_preflight")?, + risk_flags: yaml_string_list(&mapping, "risk_flags")?, + action_required: yaml_string(&mapping, "action_required")?, workflow_state, workflow_state_explicit, - attention_required: frontmatter.get("attention_required").cloned(), - queued_by: frontmatter.get("queued_by").cloned(), - queued_at: frontmatter.get("queued_at").cloned(), - raw: frontmatter, + attention_required: yaml_string(&mapping, "attention_required")?, + queued_by: yaml_string(&mapping, "queued_by")?, + queued_at: yaml_string(&mapping, "queued_at")?, + raw, + }) +} + +fn yaml_key(key: &str) -> YamlValue { + YamlValue::String(key.to_string()) +} + +fn yaml_get<'a>(mapping: &'a YamlMapping, key: &str) -> Option<&'a YamlValue> { + mapping.get(yaml_key(key)) +} + +fn yaml_string(mapping: &YamlMapping, key: &str) -> std::result::Result, String> { + match yaml_get(mapping, key) { + Some(YamlValue::Null) | None => Ok(None), + Some(YamlValue::String(value)) => Ok(Some(value.clone())), + Some(value) => Err(format!( + "frontmatter field `{key}` must be a YAML string or null, found {}", + yaml_kind(value) + )), } } -fn parse_bool(value: &str) -> Option { - match value.trim() { - "true" | "yes" | "1" => Some(true), - "false" | "no" | "0" => Some(false), - _ => None, +fn yaml_bool(mapping: &YamlMapping, key: &str) -> std::result::Result, String> { + match yaml_get(mapping, key) { + Some(YamlValue::Null) | None => Ok(None), + Some(YamlValue::Bool(value)) => Ok(Some(*value)), + Some(value) => Err(format!( + "frontmatter field `{key}` must be a YAML boolean or null, found {}", + yaml_kind(value) + )), } } -fn parse_yaml_list(value: &str) -> Vec { - let trimmed = value.trim(); - if let Some(inner) = trimmed.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { - return inner - .split(',') - .map(|part| part.trim().trim_matches('"').trim_matches('\'')) - .filter(|part| !part.is_empty()) - .map(ToOwned::to_owned) - .collect(); +fn yaml_string_list(mapping: &YamlMapping, key: &str) -> std::result::Result, String> { + match yaml_get(mapping, key) { + Some(YamlValue::Null) | None => Ok(Vec::new()), + Some(YamlValue::Sequence(values)) => values + .iter() + .enumerate() + .map(|(idx, value)| match value { + YamlValue::String(value) => Ok(value.clone()), + other => Err(format!( + "frontmatter field `{key}` item {idx} must be a YAML string, found {}", + yaml_kind(other) + )), + }) + .collect(), + Some(value) => Err(format!( + "frontmatter field `{key}` must be a YAML sequence or null, found {}", + yaml_kind(value) + )), } - if trimmed.is_empty() || trimmed == "null" { - Vec::new() - } else { - vec![trimmed.to_string()] +} + +fn raw_frontmatter_value(value: &YamlValue) -> std::result::Result { + match value { + YamlValue::Null => Ok("null".to_string()), + YamlValue::Bool(value) => Ok(value.to_string()), + YamlValue::Number(value) => Ok(value.to_string()), + YamlValue::String(value) => Ok(value.clone()), + YamlValue::Sequence(values) => values + .iter() + .map(|value| match value { + YamlValue::String(value) => Ok(format_yaml_string_scalar(value)), + other => Err(format!( + "frontmatter sequence values must be strings, found {}", + yaml_kind(other) + )), + }) + .collect::, _>>() + .map(|values| format!("[{}]", values.join(", "))), + YamlValue::Mapping(_) => Err("frontmatter nested mappings are not supported".to_string()), + YamlValue::Tagged(tagged) => raw_frontmatter_value(&tagged.value), } } +fn yaml_kind(value: &YamlValue) -> &'static str { + match value { + YamlValue::Null => "null", + YamlValue::Bool(_) => "boolean", + YamlValue::Number(_) => "number", + YamlValue::String(_) => "string", + YamlValue::Sequence(_) => "sequence", + YamlValue::Mapping(_) => "mapping", + YamlValue::Tagged(_) => "tagged value", + } +} + +fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta { + let status = frontmatter + .status + .as_deref() + .map(ExtensibleTicketStatus::from) + .unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new())); + let workflow_state = frontmatter + .workflow_state + .unwrap_or_else(|| TicketWorkflowState::default_for_status(&status)); + TicketMeta { + id: frontmatter.id.unwrap_or_default(), + slug: frontmatter.slug.unwrap_or_default(), + title: frontmatter.title.unwrap_or_default(), + status, + kind: frontmatter.kind.unwrap_or_default(), + priority: frontmatter.priority.unwrap_or_default(), + labels: frontmatter.labels, + created_at: frontmatter.created_at, + updated_at: frontmatter.updated_at, + assignee: frontmatter.assignee, + legacy_ticket: frontmatter.legacy_ticket, + readiness: frontmatter.readiness, + needs_preflight: frontmatter.needs_preflight, + risk_flags: frontmatter.risk_flags, + action_required: frontmatter.action_required, + workflow_state, + workflow_state_explicit: frontmatter.workflow_state_explicit, + attention_required: frontmatter.attention_required, + queued_by: frontmatter.queued_by, + queued_at: frontmatter.queued_at, + raw: frontmatter.raw, + } +} + +fn format_yaml_string_scalar(value: &str) -> String { + let reserved = matches!( + value, + "" | "null" + | "Null" + | "NULL" + | "~" + | "true" + | "True" + | "TRUE" + | "false" + | "False" + | "FALSE" + ); + let plain_safe = !reserved + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.')); + if plain_safe { + return value.to_string(); + } + + let mut out = String::from("'"); + for ch in value.chars() { + if ch == '\'' { + out.push_str("''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +fn yaml_string_or_null(value: Option<&str>) -> String { + value + .map(format_yaml_string_scalar) + .unwrap_or_else(|| "null".to_string()) +} + fn labels_yaml(labels: &[String]) -> String { if labels.is_empty() { return "[]".to_string(); @@ -1655,6 +1852,7 @@ fn labels_yaml(labels: &[String]) -> String { .iter() .map(|label| label.trim()) .filter(|label| !label.is_empty()) + .map(format_yaml_string_scalar) .collect::>() .join(", ") ) @@ -1697,7 +1895,7 @@ fn replace_frontmatter_fields( if let Some((key, _)) = line.split_once(':') { let key = key.trim().to_string(); if let Some((_, value)) = updates.iter().find(|(update_key, _)| *update_key == key) { - *line = format!("{key}: {value}"); + *line = format!("{key}: {}", format_yaml_string_scalar(value)); seen.insert(key); } } @@ -1705,7 +1903,10 @@ fn replace_frontmatter_fields( let mut insert_at = end; for (key, value) in updates { if !seen.contains(*key) { - lines.insert(insert_at, format!("{key}: {value}")); + lines.insert( + insert_at, + format!("{key}: {}", format_yaml_string_scalar(value)), + ); insert_at += 1; } } @@ -2237,6 +2438,63 @@ queued_at: 2026-06-05T00:01:00Z assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z")); } + #[test] + fn yaml_frontmatter_preserves_typed_nulls_lists_bools_and_quoted_strings() { + let frontmatter = parse_ticket_frontmatter( + r#"labels: + - ticket + - backend +risk_flags: [low, local] +assignee: ~ +legacy_ticket: +attention_required: null +action_required: "null" +readiness: "~" +needs_preflight: false +workflow_state: intake +"#, + ) + .unwrap(); + let meta = ticket_meta(frontmatter); + assert_eq!(meta.labels, vec!["ticket", "backend"]); + assert_eq!(meta.risk_flags, vec!["low", "local"]); + assert_eq!(meta.assignee, None); + assert_eq!(meta.legacy_ticket, None); + assert_eq!(meta.attention_required, None); + assert_eq!(meta.action_required.as_deref(), Some("null")); + assert_eq!(meta.readiness.as_deref(), Some("~")); + assert_eq!(meta.needs_preflight, Some(false)); + assert_eq!(meta.workflow_state, TicketWorkflowState::Intake); + assert!(meta.workflow_state_explicit); + } + + #[test] + fn yaml_frontmatter_rejects_legacy_raw_string_fallbacks() { + let labels_error = parse_ticket_frontmatter("labels: ticket").unwrap_err(); + assert!( + labels_error.contains("must be a YAML sequence"), + "{labels_error}" + ); + + let bool_error = parse_ticket_frontmatter("needs_preflight: 1").unwrap_err(); + assert!( + bool_error.contains("must be a YAML boolean"), + "{bool_error}" + ); + + let workflow_error = parse_ticket_frontmatter("workflow_state: almost").unwrap_err(); + assert!( + workflow_error.contains("invalid workflow_state"), + "{workflow_error}" + ); + } + + #[test] + fn yaml_frontmatter_rejects_invalid_yaml() { + let err = parse_ticket_frontmatter("labels: [ticket").unwrap_err(); + assert!(err.contains("invalid YAML frontmatter"), "{err}"); + } + #[test] fn create_writes_local_ticket_layout() { let tmp = TempDir::new().unwrap(); @@ -2468,15 +2726,14 @@ queued_at: 2026-06-05T00:01:00Z fn workflow_state_defaults_and_queue_transition_round_trip() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); - let mut missing_frontmatter = BTreeMap::new(); - missing_frontmatter.insert("status".to_string(), "open".to_string()); - let missing_meta = ticket_meta(missing_frontmatter); + let missing_meta = ticket_meta( + parse_ticket_frontmatter("status: open").expect("missing workflow state parses"), + ); assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake); assert!(!missing_meta.workflow_state_explicit); - let mut closed_frontmatter = BTreeMap::new(); - closed_frontmatter.insert("status".to_string(), "closed".to_string()); - let closed_meta = ticket_meta(closed_frontmatter); + let closed_meta = + ticket_meta(parse_ticket_frontmatter("status: closed").expect("closed default parses")); assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done); assert!(!closed_meta.workflow_state_explicit); diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 0d8e8a55..a807e8ec 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1139,6 +1139,46 @@ mod tests { assert_eq!(queued.next_action, Some(NextUserAction::Wait)); } + #[test] + fn workspace_panel_treats_yaml_null_attention_required_as_unblocked_intake() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let ticket_ref = backend + .create({ + let mut input = NewTicket::new("Null Attention Intake"); + input.slug = Some("null-attention-intake".to_string()); + input.workflow_state = Some(TicketWorkflowState::Intake); + input + }) + .unwrap(); + let item_path = temp + .path() + .join(".yoi/tickets/open") + .join(&ticket_ref.id) + .join("item.md"); + let item = fs::read_to_string(&item_path).unwrap(); + fs::write( + &item_path, + item.replace( + "workflow_state: intake\ncreated_at:", + "workflow_state: intake\nattention_required: null\ncreated_at:", + ), + ) + .unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let row = model + .rows + .iter() + .find(|row| row.title == "Null Attention Intake") + .unwrap(); + + assert_eq!(row.status, "intake"); + assert_eq!(row.next_action, Some(NextUserAction::Clarify)); + assert_eq!(row.priority, ActionPriority::Background); + } + #[test] fn workspace_panel_defaults_missing_open_state_to_intake_and_displays_done_state() { let temp = TempDir::new().unwrap(); diff --git a/package.nix b/package.nix index e274ac2e..7d732e0e 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-aG07L64sHxGKYou7dzuNuYt6xoHjIgGhlsnI5kxGmUg="; + cargoHash = "sha256-uxmc3RsNb+ivbe9wnJcqLRWWRjU2uloF2HMvgZ6L0dI="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From dbdccc50c7d5ccbc48580d2938771cfcc2366bd9 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 8 Jun 2026 08:22:01 +0900 Subject: [PATCH 2/2] ticket: quote frontmatter strings conservatively --- crates/ticket/src/lib.rs | 60 ++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index f3b18f96..918f0e18 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -1803,27 +1803,6 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta { } fn format_yaml_string_scalar(value: &str) -> String { - let reserved = matches!( - value, - "" | "null" - | "Null" - | "NULL" - | "~" - | "true" - | "True" - | "TRUE" - | "false" - | "False" - | "FALSE" - ); - let plain_safe = !reserved - && value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.')); - if plain_safe { - return value.to_string(); - } - let mut out = String::from("'"); for ch in value.chars() { if ch == '\'' { @@ -2514,6 +2493,45 @@ workflow_state: intake assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn create_round_trips_numeric_looking_string_frontmatter_values() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut input = NewTicket::new("123"); + input.slug = Some("numeric-looking-strings".to_string()); + input.labels = vec!["123".into(), "01".into()]; + input.risk_flags = vec!["1".into(), "42".into()]; + input.assignee = Some("42".into()); + input.attention_required = Some("0".into()); + input.action_required = Some("true".into()); + let ticket = backend.create(input).unwrap(); + + let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); + assert_eq!(record.meta.title, "123"); + assert_eq!(record.meta.labels, vec!["123", "01"]); + assert_eq!(record.meta.risk_flags, vec!["1", "42"]); + assert_eq!(record.meta.assignee.as_deref(), Some("42")); + assert_eq!(record.meta.attention_required.as_deref(), Some("0")); + assert_eq!(record.meta.action_required.as_deref(), Some("true")); + + let item = fs::read_to_string( + tmp.path() + .join("tickets/open") + .join(&ticket.id) + .join("item.md"), + ) + .unwrap(); + assert!(item.contains("title: '123'"), "{item}"); + assert!(item.contains("labels: ['123', '01']"), "{item}"); + assert!(item.contains("risk_flags: ['1', '42']"), "{item}"); + assert!(item.contains("assignee: '42'"), "{item}"); + assert!(item.contains("attention_required: '0'"), "{item}"); + assert!(item.contains("action_required: 'true'"), "{item}"); + + let report = backend.doctor().unwrap(); + assert!(report.is_ok(), "{:?}", report.diagnostics); + } + #[test] fn add_event_review_status_and_close_preserve_local_layout() { let tmp = TempDir::new().unwrap();