feat(933): add item_type + epic CRDT registers + migrate epic mechanism

Replaces the YAML-only `type: epic` / `epic: <id>` front-matter fields with
typed CRDT registers on PipelineItemCrdt. The epic-mechanism MCP tools
(`tool_list_epics`, `tool_show_epic`), the epic-context injection in agent
spawn, and the type-classifier helpers (`item_type_from_id`, `is_bug_item`,
`is_refactor_item`) now all read from the CRDT.

Schema:
- PipelineItemCrdt: `item_type: LwwRegisterCrdt<String>` and
  `epic: LwwRegisterCrdt<String>` registers.
- WorkItem: typed `item_type()` and `epic()` accessors returning `Option<&str>`.
- crdt_state::set_item_type(story_id, Option<&str>) and
  crdt_state::set_epic(story_id, Option<&str>) typed setters.

Write paths populate the new registers:
- create_story_file / create_bug_file / create_spike_file /
  create_refactor_file / create_epic_file — each calls set_item_type after
  write_story_content.
- tool_update_story intercepts `epic` and `type` fields and routes them to
  the typed setters (same pattern as qa / depends_on).

Read paths migrated off yaml_legacy:
- http/mcp/story_tools/epic.rs: tool_list_epics + tool_show_epic.
- agents/lifecycle.rs::item_type_from_id (numeric-only IDs).
- agents/pool/start/spawn.rs epic-context injection.
- http/workflow/bug_ops/bug.rs::is_bug_item, refactor.rs::is_refactor_item.
- http/workflow/pipeline.rs::load_pipeline_state — review_hold/qa/epic_id
  all come from the CRDT now; only merge_failure is still YAML (sweep in
  929 stage 10).

All `yaml_residue(...)` wraps for item_type / epic are removed; the
remaining residue marker doc no longer references 933.

cargo fmt --check, clippy --all-targets -- -D warnings, and the 2857-test
suite all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 19:58:43 +01:00
parent aadbb1b2af
commit 7d7ab85994
16 changed files with 200 additions and 109 deletions
+23 -27
View File
@@ -20,8 +20,8 @@ use crate::slog;
/// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID.
///
/// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID.
/// For numeric-only IDs (e.g. `"4"`), the type is read from the `type:` field in
/// the content-store front matter. Falls back to `"story"` if not found.
/// For numeric-only IDs (e.g. `"4"`), the type is read from the typed CRDT
/// `item_type` register (story 933). Falls back to `"story"` if not found.
pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
if after_num.starts_with("_bug_") {
@@ -31,15 +31,10 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
} else if after_num.starts_with("_refactor_") {
return "refactor";
}
// Numeric-only ID: check content store front matter for explicit type.
// `item_type` has no CRDT register yet (story 933 — epic mechanism).
// Marked with yaml_residue so the gap is grep-findable.
// Numeric-only ID: consult the typed CRDT register.
if after_num.is_empty()
&& let Some(content) = crate::db::read_content(item_id)
&& let Ok(meta) = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::parse_front_matter(&content),
)
&& let Some(t) = meta.item_type.as_deref()
&& let Some(view) = crate::crdt_state::read_item(item_id)
&& let Some(t) = view.item_type()
{
return match t {
"bug" => "bug",
@@ -519,30 +514,31 @@ mod tests {
}
#[test]
fn item_type_from_id_falls_back_to_content_store_for_numeric_ids() {
fn item_type_from_id_uses_crdt_register_for_numeric_ids() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
// Write a bug item with numeric-only ID into the content store.
let bug_content = "---\ntype: bug\nname: \"Test Bug\"\n---\n\n# Bug 9999: Test Bug\n";
crate::db::write_content("9999", bug_content);
let spike_content =
"---\ntype: spike\nname: \"Test Spike\"\n---\n\n# Spike 9998: Test Spike\n";
crate::db::write_content("9998", spike_content);
let refactor_content =
"---\ntype: refactor\nname: \"Test Refactor\"\n---\n\n# Refactor 9997: Test Refactor\n";
crate::db::write_content("9997", refactor_content);
let story_content =
"---\ntype: story\nname: \"Test Story\"\n---\n\n# Story 9996: Test Story\n";
crate::db::write_content("9996", story_content);
// Story 933: numeric-only IDs read item_type from the CRDT register.
for (id, t) in [
("9999", "bug"),
("9998", "spike"),
("9997", "refactor"),
("9996", "story"),
] {
crate::db::write_item_with_content(
id,
"1_backlog",
&format!("# Test {t}\n"),
crate::db::ItemMeta::named(format!("Test {t}")),
);
crate::crdt_state::set_item_type(id, Some(t));
}
assert_eq!(item_type_from_id("9999"), "bug");
assert_eq!(item_type_from_id("9998"), "spike");
assert_eq!(item_type_from_id("9997"), "refactor");
assert_eq!(item_type_from_id("9996"), "story");
// No content store entry → defaults to "story".
// No CRDT entry → defaults to "story".
assert_eq!(item_type_from_id("99999"), "story");
}