Skip to content

Commit 63671e3

Browse files
fix: writers emit tags as JSON array frontmatter (skill/command/subagent) (#202)
Mirrors the round-trip bug #200 caught for rule_writer: the DB reader for skills, commands, and sub-agents deserialises the `tags` column via `serde_json::from_str` (see `db/schema.rs:1727`, `:1860`, `:2009`), but the three corresponding writers silently dropped the `tags` field from frontmatter entirely. No live bug today — no scanner re-ingests these primitives — but once scanners do (and given the asymmetry #200 closed, that direction is likely), disk round-trip would either lose tags or, if someone shipped a comma-joined stopgap, silently corrupt them. Fixes: - skill_writer::generate_skill_markdown emits `tags: <json>` when non-empty - command_writer::generate_command_markdown emits `tags: <json>` when non-empty - subagent_writer::generate_subagent_markdown emits `tags: <json>` when non-empty Each writer gains two tests: one pinning the JSON-array shape, one pinning the empty-vec skip path. Full `cargo test --lib` passes. Not touched: `paths`, `allowed_tools`, `tools`, `skills`, `disallowed_tools` in the same writers still use comma-joined form. They have the same latent shape but are out of scope for this PR (would expand the diff; can land separately once their scanners exist and decide on a canonical form). Co-authored-by: Scot Campbell <prefrontalsys@users.noreply.github.com>
1 parent 4b2a0fb commit 63671e3

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

src-tauri/src/services/command_writer.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ pub(crate) fn generate_command_markdown(command: &Command) -> String {
4141
}
4242
}
4343

44+
if let Some(ref tags) = command.tags {
45+
if !tags.is_empty() {
46+
frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap()));
47+
}
48+
}
49+
4450
frontmatter.push_str("---\n\n");
4551
format!("{}{}", frontmatter, command.content)
4652
}
@@ -267,6 +273,31 @@ mod tests {
267273
assert!(md.contains("Minimal content."));
268274
}
269275

276+
#[test]
277+
fn test_generate_command_markdown_emits_tags_as_json_array() {
278+
// Mirrors the rule_writer fix: `tags` is read from the DB via
279+
// `serde_json::from_str`, so if a scanner ever ingests a command
280+
// frontmatter the value must be valid JSON, not comma-joined.
281+
// Also pins against silent-drop: previously `command.tags` was not
282+
// written to frontmatter at all.
283+
let mut command = sample_minimal_command();
284+
command.tags = Some(vec!["triage".to_string(), "prs".to_string()]);
285+
286+
let md = generate_command_markdown(&command);
287+
288+
assert!(md.contains("tags: [\"triage\",\"prs\"]\n"));
289+
}
290+
291+
#[test]
292+
fn test_generate_command_markdown_omits_empty_tags() {
293+
let mut command = sample_minimal_command();
294+
command.tags = Some(vec![]);
295+
296+
let md = generate_command_markdown(&command);
297+
298+
assert!(!md.contains("tags:"));
299+
}
300+
270301
// =========================================================================
271302
// write_command_file tests
272303
// =========================================================================

src-tauri/src/services/skill_writer.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ pub(crate) fn generate_skill_markdown(skill: &Skill) -> String {
6868
}
6969
}
7070

71+
if let Some(ref tags) = skill.tags {
72+
if !tags.is_empty() {
73+
frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap()));
74+
}
75+
}
76+
7177
frontmatter.push_str("---\n\n");
7278
format!("{}{}", frontmatter, skill.content)
7379
}
@@ -271,6 +277,31 @@ mod tests {
271277
assert!(md.contains("name: minimal\n"));
272278
}
273279

280+
#[test]
281+
fn test_generate_skill_markdown_emits_tags_as_json_array() {
282+
// Mirrors the rule_writer fix: `tags` is read from the DB via
283+
// `serde_json::from_str`, so if a scanner ever ingests a skill
284+
// frontmatter the value must be valid JSON, not comma-joined.
285+
// Also pins against silent-drop: previously `skill.tags` was not
286+
// written to frontmatter at all.
287+
let mut skill = sample_minimal_skill();
288+
skill.tags = Some(vec!["refactor".to_string(), "typescript".to_string()]);
289+
290+
let md = generate_skill_markdown(&skill);
291+
292+
assert!(md.contains("tags: [\"refactor\",\"typescript\"]\n"));
293+
}
294+
295+
#[test]
296+
fn test_generate_skill_markdown_omits_empty_tags() {
297+
let mut skill = sample_minimal_skill();
298+
skill.tags = Some(vec![]);
299+
300+
let md = generate_skill_markdown(&skill);
301+
302+
assert!(!md.contains("tags:"));
303+
}
304+
274305
// =========================================================================
275306
// write_skill_file tests (file system)
276307
// =========================================================================

src-tauri/src/services/subagent_writer.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ pub(crate) fn generate_subagent_markdown(subagent: &SubAgent) -> String {
7878
}
7979
}
8080

81+
if let Some(ref tags) = subagent.tags {
82+
if !tags.is_empty() {
83+
frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap()));
84+
}
85+
}
86+
8187
frontmatter.push_str("---\n\n");
8288
format!("{}{}", frontmatter, subagent.content)
8389
}
@@ -320,6 +326,31 @@ mod tests {
320326
assert!(md.contains("---\n\nYou are a helpful assistant."));
321327
}
322328

329+
#[test]
330+
fn test_generate_subagent_markdown_emits_tags_as_json_array() {
331+
// Mirrors the rule_writer fix: `tags` is read from the DB via
332+
// `serde_json::from_str`, so if a scanner ever ingests a subagent
333+
// frontmatter the value must be valid JSON, not comma-joined.
334+
// Also pins against silent-drop: previously `subagent.tags` was not
335+
// written to frontmatter at all.
336+
let mut subagent = sample_minimal_subagent();
337+
subagent.tags = Some(vec!["review".to_string(), "quality".to_string()]);
338+
339+
let md = generate_subagent_markdown(&subagent);
340+
341+
assert!(md.contains("tags: [\"review\",\"quality\"]\n"));
342+
}
343+
344+
#[test]
345+
fn test_generate_subagent_markdown_omits_empty_tags() {
346+
let mut subagent = sample_minimal_subagent();
347+
subagent.tags = Some(vec![]);
348+
349+
let md = generate_subagent_markdown(&subagent);
350+
351+
assert!(!md.contains("tags:"));
352+
}
353+
323354
#[test]
324355
fn test_generate_subagent_markdown_empty_tools_skipped() {
325356
let mut subagent = sample_full_subagent();

0 commit comments

Comments
 (0)