merge: parse ticket frontmatter as yaml

This commit is contained in:
Keisuke Hirata 2026-06-08 08:35:31 +09:00
commit 10dc6da903
No known key found for this signature in database
53 changed files with 468 additions and 151 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

1
Cargo.lock generated
View File

@ -3632,6 +3632,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tokio",

View File

@ -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 }

View File

@ -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<String, String>,
frontmatter: TicketItemFrontmatter,
body: String,
}
#[derive(Debug, Clone, Default)]
struct TicketItemFrontmatter {
id: Option<String>,
slug: Option<String>,
title: Option<String>,
status: Option<String>,
kind: Option<String>,
priority: Option<String>,
labels: Vec<String>,
created_at: Option<String>,
updated_at: Option<String>,
assignee: Option<String>,
legacy_ticket: Option<String>,
readiness: Option<String>,
needs_preflight: Option<bool>,
risk_flags: Vec<String>,
action_required: Option<String>,
workflow_state: Option<TicketWorkflowState>,
workflow_state_explicit: bool,
attention_required: Option<String>,
queued_by: Option<String>,
queued_at: Option<String>,
raw: BTreeMap<String, String>,
}
impl TicketItemFrontmatter {
fn get(&self, key: &str) -> Option<&String> {
self.raw.get(key)
}
}
fn read_item_file(path: &Path) -> Result<ParsedItem> {
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<ParsedItem, String> {
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,204 @@ fn parse_item(content: &str) -> std::result::Result<ParsedItem, String> {
body.push('\n');
}
}
let frontmatter = parse_ticket_frontmatter(&frontmatter_lines.join("\n"))?;
Ok(ParsedItem { frontmatter, body })
}
fn ticket_meta(frontmatter: BTreeMap<String, String>) -> 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<TicketItemFrontmatter, String> {
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<Option<String>, 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<bool> {
match value.trim() {
"true" | "yes" | "1" => Some(true),
"false" | "no" | "0" => Some(false),
_ => None,
fn yaml_bool(mapping: &YamlMapping, key: &str) -> std::result::Result<Option<bool>, 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<String> {
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<Vec<String>, 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<String, String> {
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::<std::result::Result<Vec<_>, _>>()
.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 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 +1831,7 @@ fn labels_yaml(labels: &[String]) -> String {
.iter()
.map(|label| label.trim())
.filter(|label| !label.is_empty())
.map(format_yaml_string_scalar)
.collect::<Vec<_>>()
.join(", ")
)
@ -1697,7 +1874,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 +1882,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 +2417,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();
@ -2256,6 +2493,45 @@ queued_at: 2026-06-05T00:01:00Z
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();
@ -2468,15 +2744,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);

View File

@ -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();

View File

@ -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,