merge 0141853c-faac-4353-b2ea-3b037354b4da

bd8cbb3gl0w

all commits

Message

merge 0141853c-faac-4353-b2ea-3b037354b4da

Diff

.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.md+12 −0
diff --git a/.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.md b/.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.mdnew file mode 100644index 0000000..9460f93--- /dev/null+++ b/.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.md@@ -0,0 +1,12 @@1+---2+id: 125bcf04-f0c9-4a69-a373-d12fa0ff62803+title: unknown-field warnings dropped in issue/label/milestone/task4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+Parsed.warnings never printed; only schema_note via warn_schema. label/milestone discard parse_list warnings as _w. Contract: unknown field = warning. Fix: emit warnings to stderr in these four cmds.
.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.md+6 −0
diff --git a/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.md b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.mdnew file mode 100644index 0000000..ce3e7bc--- /dev/null+++ b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.md@@ -0,0 +1,6 @@1+---2+id: bdcc8721-0ab8-4478-b66c-31a19e58a6c73+author: dog-issue4+created: 2026-09-08T04:03:03Z5+---6+fixed on dog-issue; quoting guard added to label/milestone/task esc()
.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.md+14 −0
diff --git a/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.md b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.mdnew file mode 100644index 0000000..48167f8--- /dev/null+++ b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.md@@ -0,0 +1,14 @@1+---2+id: 238a6e74-bed3-4d39-a6b5-a1b6c7345ab83+title: label/milestone/task render writes bare scalars that re-parse as int/float/bool4+state: open5+labels:6+- dogfood7+- pri8+assignees:9+- dog-issue10+milestone: null11+created: 2026-09-08T03:57:13Z12+updated: 2026-09-08T04:03:03Z13+---14+label create --name 123 --color true writes unquoted; reload fails: invalid type integer/float. Same esc()/plain() in label.rs milestone.rs task.rs. Live .forge/labels.md has color 5319E7 bare -> label list broken. Fix: quote YAML-risky scalars on write.
.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.md+12 −0
diff --git a/.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.md b/.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.mdnew file mode 100644index 0000000..c7faf2c--- /dev/null+++ b/.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.md@@ -0,0 +1,12 @@1+---2+id: 4918ac8b-3700-4f8d-8b62-620b98891c5c3+title: default issue list shows archived; spec says skip archived by default4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+list with no --state shows state archived rows. Fix: default filter excludes archived; explicit --state archived still shows them.
.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.md+12 −0
diff --git a/.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.md b/.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.mdnew file mode 100644index 0000000..613a775--- /dev/null+++ b/.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.md@@ -0,0 +1,12 @@1+---2+id: 53365978-18fe-4149-85f1-6520573477413+title: empty --label/--assignee stored as empty string4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+issue create --label '' stores labels ['','bug']; empty strings should be dropped (create/assign/label paths). Same for assignee.
.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.md+12 −0
diff --git a/.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.md b/.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.mdnew file mode 100644index 0000000..73329bd--- /dev/null+++ b/.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.md@@ -0,0 +1,12 @@1+---2+id: 7e4d18f4-b7d2-4644-9152-9458af2a654f3+title: issue list --state usage text says open|closed but accepts four states4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+Code accepts open/closed/in-progress/archived; usage string says open|closed. Fix text.
.forge/labels.md+8 −2
diff --git a/.forge/labels.md b/.forge/labels.mdindex e489e97..78e72d7 100644--- a/.forge/labels.md+++ b/.forge/labels.md@@ -1,3 +1,9 @@11 - name: dogfood2-  color: 5319E73-  description: "dogfood sweep"2+  color: "5319E7"3+  description: "dogfood sweep (quoted color)"4+- name: bug5+  color: FF00006+  description: defects7+- name: pri8+  color: FFCC009+  description: "priority work"
.forge/milestones.md+5 −0
diff --git a/.forge/milestones.md b/.forge/milestones.mdindex 9bca1b3..a2ef065 100644--- a/.forge/milestones.md+++ b/.forge/milestones.md@@ -1,2 +1,7 @@11 - name: dogfood-m122   state: open3+  description: "dogfood sprint M1"4+- name: sprint15+  state: open6+  due: 2026-10-017+  description: "dog-issue domain sprint"
.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.md+8 −0
diff --git a/.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.md b/.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.mdnew file mode 100644index 0000000..f01f82f--- /dev/null+++ b/.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.md@@ -0,0 +1,8 @@1+---2+id: 0763f7d0-ec4f-4f9b-8a5e-efdde775d3273+title: "dog-issue: bug-hunt edge cases"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.md+8 −0
diff --git a/.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.md b/.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.mdnew file mode 100644index 0000000..2d56b91--- /dev/null+++ b/.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.md@@ -0,0 +1,8 @@1+---2+id: 4d3bcb17-c471-42d4-8b02-17b3f89e08d93+title: "scale: 10k list + 500 wiki budget probe"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:03:22Z7+closed: 2026-09-08T04:03:22Z8+---
.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.md+8 −0
diff --git a/.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.md b/.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.mdnew file mode 100644index 0000000..eb03626--- /dev/null+++ b/.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.md@@ -0,0 +1,8 @@1+---2+id: 583763a8-fdb3-4215-92dc-a3d319a5c91c3+title: "clinic: verify issue cmds against stale data"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:03:22Z7+closed: 2026-09-08T04:03:22Z8+---
.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.md+8 −0
diff --git a/.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.md b/.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.mdnew file mode 100644index 0000000..0307eb1--- /dev/null+++ b/.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.md@@ -0,0 +1,8 @@1+---2+id: 838373ef-6553-4e21-9df7-b6ee97d35ff43+title: "dog-issue: sweep issue/label/milestone/task cmds"4+status: done5+created: 2026-09-08T03:54:07Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.md+7 −0
diff --git a/.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.md b/.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.mdnew file mode 100644index 0000000..6b244d5--- /dev/null+++ b/.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.md@@ -0,0 +1,7 @@1+---2+id: aa476ab6-3caa-43c1-b487-932d9594948a3+title: "dog-issue: fix + tests + PR"4+status: doing5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T03:58:55Z7+---
.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.md+8 −0
diff --git a/.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.md b/.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.mdnew file mode 100644index 0000000..f200e16--- /dev/null+++ b/.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.md@@ -0,0 +1,8 @@1+---2+id: af34c7c6-2738-40e7-85b1-c19a3e3d17a93+title: "dog-issue: labels + milestone shape verify"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
crates/cli/src/cmd/issue.rs+41 −8
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex 460c41a..13d8975 100644--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -91,6 +91,14 @@ fn resolve_id(fg: &Forge, raw: &str) -> Result<String, CliError> {9191     Err(usage("f0rge issue <verb> <id>"))9292 }9393 94+/// Unknown-field warnings + schema note, one line each on stderr.95+fn warn(file: &str, w: &[String], note: &Option<String>) {96+    for x in w {97+        eprintln!("warning: {file}: {x}");98+    }99+    output::warn_schema(note);100+}101+94102 /// List-hot-path load: core fast extract with strict fallback (same95103 /// values/errors as `load_full`; views and mutations stay strict).96104 fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {@@ -104,17 +112,18 @@ fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {104112         CliError::from(ForgeError::Parse { file: label.clone(), msg: "not utf-8".to_string() })105113     })?;106114     let p = f0rge_core::parse_issue_card(&text, &label).map_err(CliError::from)?;107-    output::warn_schema(&p.schema_note);115+    warn(&label, &p.warnings, &p.schema_note);108116     Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })109117 }110118 111119 fn load_full(fg: &Forge, uuid: &str) -> Result<Full, CliError> {112120     let rel = fg.issue_file(uuid);121+    let label = f0rge_core::base_name(&rel);113122     let p: f0rge_core::Parsed<Issue> = fg.load_entity(&rel).map_err(|e| match e {114123         ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),115124         _ => CliError::from(e),116125     })?;117-    output::warn_schema(&p.schema_note);126+    warn(&label, &p.warnings, &p.schema_note);118127     Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })119128 }120129 @@ -202,7 +211,7 @@ fn comment_list(fg: &Forge, uuid: &str) -> Result<Vec<(IssueComment, String)>, C202211         }203212         let rel = Path::new("issues").join(uuid).join("comments").join(&name);204213         let p: f0rge_core::Parsed<IssueComment> = fg.load_entity(&rel)?;205-        output::warn_schema(&p.schema_note);214+        warn(&name, &p.warnings, &p.schema_note);206215         out.push((p.value, p.body));207216     }208217     out.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));@@ -255,8 +264,20 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {255264         return Err(usage("f0rge issue create --title T"));256265     }257266     let body = norm_body(fs.get("body").unwrap_or(""));258-    let labels = dedup(fs.get_all("label").into_iter().map(|s| s.to_string()).collect());259-    let assignees = dedup(fs.get_all("assignee").into_iter().map(|s| s.to_string()).collect());267+    let labels = dedup(268+        fs.get_all("label")269+            .into_iter()270+            .filter(|s| !clean(s).is_empty())271+            .map(|s| s.to_string())272+            .collect(),273+    );274+    let assignees = dedup(275+        fs.get_all("assignee")276+            .into_iter()277+            .filter(|s| !clean(s).is_empty())278+            .map(|s| s.to_string())279+            .collect(),280+    );260281     let milestone = fs.get("milestone").map(|s| s.to_string());261282     let id = uuid::Uuid::new_v4().to_string();262283     let issue = Issue {@@ -281,7 +302,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {281302     }282303     if let Some(s) = fs.get("state") {283304         if !["open", "closed", "in-progress", "archived"].contains(&s) {284-            return Err(usage("f0rge issue list --state open|closed"));305+            return Err(usage("f0rge issue list --state open|closed|in-progress|archived"));285306         }286307     }287308     let all = load_all(fg)?;@@ -305,6 +326,8 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {305326             if f.issue.state != s {306327                 continue;307328             }329+        } else if f.issue.state == "archived" {330+            continue; // indexes skip archived by default308331         }309332         match (ms, &f.issue.milestone) {310333             (Some(m), Some(x)) if x == m => {}@@ -418,7 +441,12 @@ fn set_state(fs: &args::FlagSet, fg: &Forge, verb: &str, state: &str) -> Result<418441 419442 fn assign(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {420443     let uuid = resolve_id(fg, &one_id(fs, "assign")?)?;421-    let given: Vec<String> = fs.get_all("assignee").into_iter().map(|s| s.to_string()).collect();444+    let given: Vec<String> = fs445+        .get_all("assignee")446+        .into_iter()447+        .filter(|s| !clean(s).is_empty())448+        .map(|s| s.to_string())449+        .collect();422450     if given.is_empty() {423451         return Err(usage("f0rge issue assign <id> --assignee A"));424452     }@@ -441,7 +469,12 @@ fn assign(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {441469 442470 fn label(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {443471     let uuid = resolve_id(fg, &one_id(fs, "label")?)?;444-    let given: Vec<String> = fs.get_all("label").into_iter().map(|s| s.to_string()).collect();472+    let given: Vec<String> = fs473+        .get_all("label")474+        .into_iter()475+        .filter(|s| !clean(s).is_empty())476+        .map(|s| s.to_string())477+        .collect();445478     if given.is_empty() {446479         return Err(usage("f0rge issue label <id> --label L"));447480     }
crates/cli/src/cmd/label.rs+39 −2
diff --git a/crates/cli/src/cmd/label.rs b/crates/cli/src/cmd/label.rsindex 2fddbb0..0059489 100644--- a/crates/cli/src/cmd/label.rs+++ b/crates/cli/src/cmd/label.rs@@ -39,13 +39,50 @@ fn load(fg: &Forge) -> Result<Vec<Label>, CliError> {3939     if text.trim().is_empty() || text.trim() == "[]" {4040         return Ok(Vec::new());4141     }42-    let (items, _w) =42+    let (items, w) =4343         f0rge_core::parse_list::<Label>(&text, "labels.md").map_err(CliError::from)?;44+    for x in &w {45+        eprintln!("warning: labels.md: {x}");46+    }4447     Ok(items)4548 }4649 50+/// True when a plain scalar would re-parse as bool/null/number instead51+/// of a string (e.g. `123`, `5319E7`, `true`). Quoting is always safe.52+fn risky(s: &str) -> bool {53+    match s.to_ascii_lowercase().as_str() {54+        "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"55+        | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,56+        _ => {}57+    }58+    if !s.bytes().any(|b| b.is_ascii_digit()) {59+        return false;60+    }61+    if s.parse::<f64>().is_ok() {62+        return true;63+    }64+    let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);65+    if t.starts_with("0x")66+        || t.starts_with("0X")67+        || t.starts_with("0o")68+        || t.starts_with("0O")69+        || t.starts_with("0b")70+        || t.starts_with("0B")71+    {72+        return true;73+    }74+    let u: String = t.chars().filter(|c| *c != '_').collect();75+    if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {76+        return true;77+    }78+    let f: String = s.chars().filter(|c| *c != '_').collect();79+    f.parse::<f64>().is_ok()80+}81+4782 fn plain(s: &str) -> bool {48-    !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')83+    !s.is_empty()84+        && !risky(s)85+        && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')4986 }5087 5188 fn esc(s: &str) -> String {
crates/cli/src/cmd/milestone.rs+39 −2
diff --git a/crates/cli/src/cmd/milestone.rs b/crates/cli/src/cmd/milestone.rsindex 353aca5..d6a0b58 100644--- a/crates/cli/src/cmd/milestone.rs+++ b/crates/cli/src/cmd/milestone.rs@@ -39,13 +39,50 @@ fn load(fg: &Forge) -> Result<Vec<Milestone>, CliError> {3939     if text.trim().is_empty() || text.trim() == "[]" {4040         return Ok(Vec::new());4141     }42-    let (items, _w) =42+    let (items, w) =4343         f0rge_core::parse_list::<Milestone>(&text, "milestones.md").map_err(CliError::from)?;44+    for x in &w {45+        eprintln!("warning: milestones.md: {x}");46+    }4447     Ok(items)4548 }4649 50+/// True when a plain scalar would re-parse as bool/null/number instead51+/// of a string (e.g. `123`, `true`). Quoting is always safe.52+fn risky(s: &str) -> bool {53+    match s.to_ascii_lowercase().as_str() {54+        "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"55+        | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,56+        _ => {}57+    }58+    if !s.bytes().any(|b| b.is_ascii_digit()) {59+        return false;60+    }61+    if s.parse::<f64>().is_ok() {62+        return true;63+    }64+    let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);65+    if t.starts_with("0x")66+        || t.starts_with("0X")67+        || t.starts_with("0o")68+        || t.starts_with("0O")69+        || t.starts_with("0b")70+        || t.starts_with("0B")71+    {72+        return true;73+    }74+    let u: String = t.chars().filter(|c| *c != '_').collect();75+    if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {76+        return true;77+    }78+    let f: String = s.chars().filter(|c| *c != '_').collect();79+    f.parse::<f64>().is_ok()80+}81+4782 fn plain(s: &str) -> bool {48-    !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')83+    !s.is_empty()84+        && !risky(s)85+        && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')4986 }5087 5188 fn esc(s: &str) -> String {
crates/cli/src/cmd/task.rs+36 −0
diff --git a/crates/cli/src/cmd/task.rs b/crates/cli/src/cmd/task.rsindex 942431a..f94f516 100644--- a/crates/cli/src/cmd/task.rs+++ b/crates/cli/src/cmd/task.rs@@ -23,8 +23,41 @@ fn clean(s: &str) -> String {2323     s.chars().filter(|c| *c != '\n' && *c != '\r').take(80).collect::<String>().trim().to_string()2424 }2525 26+/// True when a plain scalar would re-parse as bool/null/number instead27+/// of a string (e.g. a task titled `123`). Quoting is always safe.28+fn risky(s: &str) -> bool {29+    match s.to_ascii_lowercase().as_str() {30+        "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"31+        | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,32+        _ => {}33+    }34+    if !s.bytes().any(|b| b.is_ascii_digit()) {35+        return false;36+    }37+    if s.parse::<f64>().is_ok() {38+        return true;39+    }40+    let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);41+    if t.starts_with("0x")42+        || t.starts_with("0X")43+        || t.starts_with("0o")44+        || t.starts_with("0O")45+        || t.starts_with("0b")46+        || t.starts_with("0B")47+    {48+        return true;49+    }50+    let u: String = t.chars().filter(|c| *c != '_').collect();51+    if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {52+        return true;53+    }54+    let f: String = s.chars().filter(|c| *c != '_').collect();55+    f.parse::<f64>().is_ok()56+}57+2658 fn plain(s: &str) -> bool {2759     !s.is_empty()60+        && !risky(s)2861         && s.bytes().all(|b| {2962             b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'3063         })@@ -127,6 +160,9 @@ fn load(fg: &Forge, uuid: &str) -> Result<(Task, Option<String>, String), CliErr127160     let label = format!("{uuid}.md");128161     let p: f0rge_core::Parsed<Task> =129162         f0rge_core::parse_entity(&text, &label).map_err(CliError::from)?;163+    for w in &p.warnings {164+        eprintln!("warning: {label}: {w}");165+    }130166     output::warn_schema(&p.schema_note);131167     if p.value.id != uuid {132168         return Err(CliError::Runtime(format!("task {uuid}: id mismatch")));
crates/cli/tests/dog_issue.rs+149 −0
diff --git a/crates/cli/tests/dog_issue.rs b/crates/cli/tests/dog_issue.rsnew file mode 100644index 0000000..2702b56--- /dev/null+++ b/crates/cli/tests/dog_issue.rs@@ -0,0 +1,149 @@1+//! dog-issue acceptance: quoting round-trip, empty-label drop,2+//! unknown-field warnings, archived default-hide on temp repos.3+4+use std::path::{Path, PathBuf};5+use std::process::Command;6+7+fn bin() -> Command {8+    Command::new(env!("CARGO_BIN_EXE_f0rge"))9+}10+11+fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {12+    let out = bin().current_dir(dir).args(args).output().expect("run f0rge");13+    (14+        String::from_utf8_lossy(&out.stdout).into_owned(),15+        String::from_utf8_lossy(&out.stderr).into_owned(),16+        out.status.code().unwrap_or(-1),17+    )18+}19+20+fn tmp(tag: &str) -> PathBuf {21+    let d = std::env::temp_dir()22+        .join(format!("f0rge-dogissue-{tag}-{}-{}", std::process::id(), line!()));23+    let _ = std::fs::remove_dir_all(&d);24+    std::fs::create_dir_all(&d).unwrap();25+    d26+}27+28+fn setup(tag: &str) -> PathBuf {29+    let dir = tmp(tag);30+    let (_, _, code) = run_in(&dir, &["init"]);31+    assert_eq!(code, 0);32+    dir33+}34+35+fn jv(s: &str) -> serde_json::Value {36+    serde_json::from_str(s.trim()).expect("json line")37+}38+39+#[test]40+fn label_quote_roundtrip() {41+    let dir = setup("quote");42+    let (_, err, code) = run_in(43+        &dir,44+        &["label", "create", "--name", "123", "--color", "5319E7", "--description", "true"],45+    );46+    assert_eq!(code, 0, "{err}");47+    let raw = std::fs::read_to_string(dir.join(".forge/labels.md")).unwrap();48+    assert!(raw.contains("\"123\""), "name quoted:\n{raw}");49+    assert!(raw.contains("\"5319E7\""), "color quoted:\n{raw}");50+    assert!(raw.contains("\"true\""), "desc quoted:\n{raw}");51+    let (o, err, code) = run_in(&dir, &["label", "list", "--json"]);52+    assert_eq!(code, 0, "{err}");53+    let v = jv(&o);54+    assert_eq!(v["name"], "123");55+    assert_eq!(v["color"], "5319E7");56+    assert_eq!(v["description"], "true");57+    // plain names stay bare for humans58+    let (_, _, code) = run_in(&dir, &["label", "create", "--name", "bug"]);59+    assert_eq!(code, 0);60+    let raw = std::fs::read_to_string(dir.join(".forge/labels.md")).unwrap();61+    assert!(raw.contains("- name: bug\n"), "plain stays bare:\n{raw}");62+    let _ = std::fs::remove_dir_all(&dir);63+}64+65+#[test]66+fn milestone_task_quote_roundtrip() {67+    let dir = setup("mtquote");68+    let (_, err, code) = run_in(&dir, &["milestone", "create", "--name", "007", "--due", "2026-10-01"]);69+    assert_eq!(code, 0, "{err}");70+    let (o, err, code) = run_in(&dir, &["milestone", "list", "--json"]);71+    assert_eq!(code, 0, "{err}");72+    assert_eq!(jv(&o)["name"], "007");73+    let (_, err, code) = run_in(&dir, &["task", "create", "--title", "123", "--json"]);74+    assert_eq!(code, 0, "{err}");75+    let (o, _, code) = run_in(&dir, &["task", "list", "--json"]);76+    assert_eq!(code, 0);77+    assert_eq!(jv(&o)["title"], "123");78+    let _ = std::fs::remove_dir_all(&dir);79+}80+81+#[test]82+fn issue_empty_labels_dropped() {83+    let dir = setup("empty");84+    let (o, _, code) = run_in(85+        &dir,86+        &["issue", "create", "--title", "T", "--label", "", "--label", "bug", "--assignee", ""],87+    );88+    assert_eq!(code, 0);89+    let v = jv(&o);90+    assert_eq!(v["labels"], serde_json::json!(["bug"]));91+    assert_eq!(v["assignees"], serde_json::json!([]));92+    // empty-only verbs are usage errors, one line93+    let n = v["number"].to_string();94+    let (_, err, code) = run_in(&dir, &["issue", "label", &n, "--label", ""]);95+    assert_eq!(code, 2);96+    assert_eq!(err.lines().count(), 1);97+    let (_, err, code) = run_in(&dir, &["issue", "assign", &n, "--assignee", ""]);98+    assert_eq!(code, 2);99+    assert_eq!(err.lines().count(), 1);100+    let _ = std::fs::remove_dir_all(&dir);101+}102+103+#[test]104+fn unknown_field_warns() {105+    let dir = setup("warn");106+    let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);107+    assert_eq!(code, 0);108+    let uuid = {109+        let issues = dir.join(".forge/issues");110+        let e = std::fs::read_dir(&issues).unwrap().flatten().next().unwrap();111+        e.file_name().to_string_lossy().into_owned()112+    };113+    let _ = o;114+    let f = dir.join(".forge/issues").join(&uuid).join("issue.md");115+    let t = std::fs::read_to_string(&f).unwrap().replace("state: open", "state: open\nxtra: 1");116+    std::fs::write(&f, t).unwrap();117+    let (_, err, code) = run_in(&dir, &["issue", "view", &uuid]);118+    assert_eq!(code, 0);119+    assert!(err.contains("unknown field"), "stderr:\n{err}");120+    std::fs::write(dir.join(".forge/labels.md"), "- name: x\n  bogus: 1\n").unwrap();121+    let (_, err, code) = run_in(&dir, &["label", "list"]);122+    assert_eq!(code, 0);123+    assert!(err.contains("unknown field"), "stderr:\n{err}");124+    let _ = std::fs::remove_dir_all(&dir);125+}126+127+#[test]128+fn archived_hidden_by_default() {129+    let dir = setup("arch");130+    let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "A", "--json"]);131+    assert_eq!(code, 0);132+    let n = jv(&o)["number"].to_string();133+    let (_, _, code) = run_in(&dir, &["issue", "close", &n]);134+    assert_eq!(code, 0);135+    // flip to archived on disk (no archive verb in scope)136+    let issues = dir.join(".forge/issues");137+    for e in std::fs::read_dir(&issues).unwrap().flatten() {138+        let f = issues.join(e.file_name()).join("issue.md");139+        let t = std::fs::read_to_string(&f).unwrap().replace("state: closed", "state: archived");140+        std::fs::write(&f, t).unwrap();141+    }142+    let (o, _, code) = run_in(&dir, &["issue", "list", "--json"]);143+    assert_eq!(code, 0);144+    assert_eq!(o, "[]\n", "default hides archived, got:\n{o}");145+    let (o, _, code) = run_in(&dir, &["issue", "list", "--json", "--state", "archived"]);146+    assert_eq!(code, 0);147+    assert_eq!(o.lines().count(), 1);148+    let _ = std::fs::remove_dir_all(&dir);149+}