huskies: merge 946

This commit is contained in:
dave
2026-05-13 07:54:50 +00:00
parent 4a0fbcaa95
commit a7840ea4b0
49 changed files with 378 additions and 314 deletions
@@ -453,7 +453,7 @@ mod tests {
"5_story_test",
"1_backlog",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Test"),
);
let ctx = test_ctx(root);
@@ -485,7 +485,7 @@ mod tests {
"6_story_back",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Back"),
);
let ctx = test_ctx(root);
@@ -517,7 +517,7 @@ mod tests {
"9907_story_idem",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Idem"),
);
let ctx = test_ctx(root);
+5 -13
View File
@@ -177,14 +177,12 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
// --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
let mut front_matter = serde_json::Map::new();
if let Some(view) = crate::crdt_state::read_item(story_id) {
if let Some(name) = view.name() {
front_matter.insert("name".to_string(), json!(name));
}
front_matter.insert("name".to_string(), json!(view.name()));
if let Some(agent) = view.agent() {
front_matter.insert("agent".to_string(), json!(agent));
}
if let Some(qa) = view.qa_mode() {
front_matter.insert("qa".to_string(), json!(qa));
front_matter.insert("qa".to_string(), json!(qa.as_str()));
}
let rc = view.retry_count();
if rc > 0 {
@@ -194,15 +192,9 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
if !deps.is_empty() {
front_matter.insert("depends_on".to_string(), json!(deps));
}
if let Some(cb) = view.claimed_by()
&& !cb.is_empty()
{
front_matter.insert("claimed_by".to_string(), json!(cb));
}
if let Some(ca) = view.claimed_at()
&& ca > 0.0
{
front_matter.insert("claimed_at".to_string(), json!(ca));
if let Some(claim) = view.claim() {
front_matter.insert("claimed_by".to_string(), json!(claim.node));
front_matter.insert("claimed_at".to_string(), json!(claim.at));
}
}
+1 -1
View File
@@ -464,7 +464,7 @@ mod tests {
"9901_bug_crash",
"1_backlog",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Crash"),
);
// Stage the file so it's tracked
std::process::Command::new("git")
+1 -2
View File
@@ -37,8 +37,7 @@ pub(crate) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<Str
let contents = crate::http::workflow::read_story_content(&root, story_id)
.map_err(|_| format!("Story file not found: {story_id}.md"))?;
let story_name =
crate::crdt_state::read_item(story_id).and_then(|v| v.name().map(str::to_string));
let story_name = crate::crdt_state::read_item(story_id).map(|v| v.name().to_string());
let todos = parse_unchecked_todos(&contents);
serde_json::to_string_pretty(&json!({
+19 -10
View File
@@ -61,7 +61,8 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
continue;
};
if view.item_type() == Some("epic") {
use crate::io::story_metadata::ItemType;
if view.item_type() == Some(ItemType::Epic) {
epics.push((sid.clone(), item.name.clone()));
}
@@ -110,13 +111,17 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
let epic_view = crate::crdt_state::read_item(epic_id)
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
if epic_view.item_type() != Some("epic") {
use crate::io::story_metadata::ItemType;
if epic_view.item_type() != Some(ItemType::Epic) {
return Err(format!(
"'{epic_id}' is not an epic (item_type: {:?})",
epic_view.item_type()
));
}
// Parse the epic_id argument to a numeric EpicId for comparison.
let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id);
// Find member items.
let all_items = crate::pipeline_state::read_all_typed();
let mut member_items: Vec<Value> = Vec::new();
@@ -126,7 +131,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
let Some(member_view) = crate::crdt_state::read_item(sid) else {
continue;
};
if member_view.epic() == Some(epic_id) {
if member_view.epic() == epic_numeric {
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
// Stage variants — no more orthogonal boolean flags.
let stage_name = match &item.stage {
@@ -238,14 +243,18 @@ mod tests {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
use crate::crdt_state::EpicId;
use crate::io::story_metadata::ItemType;
// Write a fake epic with the typed CRDT registers (story 933).
// Epics use numeric-only story_ids (see create_epic_file).
crate::db::write_item_with_content(
"9990_epic_rollup",
"9990",
"1_backlog",
"# Rollup Epic\n\n## Goal\n\nTest\n",
crate::db::ItemMeta::named("Rollup Epic"),
);
crate::crdt_state::set_item_type("9990_epic_rollup", Some("epic"));
crate::crdt_state::set_item_type("9990", Some(ItemType::Epic));
// Write two member items: one done, one current.
crate::db::write_item_with_content(
@@ -254,8 +263,8 @@ mod tests {
"# Done Member\n",
crate::db::ItemMeta::named("Done Member"),
);
crate::crdt_state::set_item_type("9991_story_member_done", Some("story"));
crate::crdt_state::set_epic("9991_story_member_done", Some("9990_epic_rollup"));
crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story));
crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990)));
crate::db::write_item_with_content(
"9992_story_member_current",
@@ -263,8 +272,8 @@ mod tests {
"# Current Member\n",
crate::db::ItemMeta::named("Current Member"),
);
crate::crdt_state::set_item_type("9992_story_member_current", Some("story"));
crate::crdt_state::set_epic("9992_story_member_current", Some("9990_epic_rollup"));
crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story));
crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990)));
let tmp = tempfile::tempdir().unwrap();
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
@@ -272,7 +281,7 @@ mod tests {
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let epic = parsed
.iter()
.find(|e| e["epic_id"] == "9990_epic_rollup")
.find(|e| e["epic_id"] == "9990")
.expect("expected rollup epic in list");
assert_eq!(epic["members_total"], 2, "two members expected");
assert_eq!(epic["members_done"], 1, "one done member expected");
@@ -230,7 +230,7 @@ mod tests {
"51_story_no_branch",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("No Branch"),
);
let ctx = test_ctx(tmp.path());
@@ -246,6 +246,9 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
// Story 946 AC 5: nameless items are invisible at the CRDT layer.
// `extract_item_view` returns `None` for items with no name register set,
// so they are filtered from all read paths including `validate_story_dirs`.
crate::db::write_item_with_content(
"9908_test",
"2_current",
@@ -256,10 +259,9 @@ mod tests {
let ctx = test_ctx(tmp.path());
let result = tool_validate_stories(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let item = parsed
.iter()
.find(|v| v["story_id"] == "9908_test")
.expect("expected 9908_test in validation results");
assert_eq!(item["valid"], false);
assert!(
parsed.iter().all(|v| v["story_id"] != "9908_test"),
"nameless items must be invisible to tool_validate_stories"
);
}
}
@@ -24,7 +24,7 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
crate::crdt_state::set_agent(story_id, Some(agent));
}
if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) {
crate::crdt_state::set_epic(story_id, Some(epic).filter(|s| !s.is_empty()));
crate::crdt_state::set_epic(story_id, crate::crdt_state::EpicId::from_crdt_str(epic));
}
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
@@ -52,12 +52,16 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
crate::crdt_state::set_qa_mode(story_id, mode);
}
"epic" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_epic(story_id, s);
let parsed = value
.as_str()
.and_then(crate::crdt_state::EpicId::from_crdt_str);
crate::crdt_state::set_epic(story_id, parsed);
}
"type" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_item_type(story_id, s);
let parsed = value
.as_str()
.and_then(crate::io::story_metadata::ItemType::from_str);
crate::crdt_state::set_item_type(story_id, parsed);
}
"depends_on" => {
if let Some(arr) = value.as_array() {