Skip to content

Commit 611eed1

Browse files
committed
feat: ultraworkers#142 structured fields in claw init --output-format json
Previously `claw init --output-format json` emitted a valid JSON envelope but packed the entire human-formatted output into a single `message` string. Claw scripts had to substring-match human language to tell `created` from `skipped`. Changes: - Add InitStatus::json_tag() returning machine-stable "created"|"updated"|"skipped" (unlike label() which includes the human " (already exists)" suffix). - Add InitReport::NEXT_STEP constant so claws can read the next-step hint without grepping the message string. - Add InitReport::artifacts_with_status() to partition artifacts by state. - Add InitReport::artifact_json_entries() for the structured artifacts[] array. - Rewrite run_init + init_json_value to emit first-class fields alongside the legacy message string (kept for text consumers): project_path, created[], updated[], skipped[], artifacts[], next_step, message. - Update the slash-command Init dispatch to use the same structured JSON. - Add regression test artifacts_with_status_partitions_fresh_and_idempotent_runs asserting both fresh + idempotent runs produce the right partitioning and that the machine-stable tag is bare 'skipped' not label()'s phrasing. Verified output: - Fresh dir: created[] has 4 entries, skipped[] empty - Idempotent call: created[] empty, skipped[] has 4 entries - project_path, next_step as first-class keys - message preserved verbatim for backward compat Full workspace test green except pre-existing resume_latest flake (unrelated). Closes ROADMAP ultraworkers#142.
1 parent 7763ca3 commit 611eed1

2 files changed

Lines changed: 120 additions & 6 deletions

File tree

rust/crates/rusty-claude-cli/src/init.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ impl InitStatus {
2727
Self::Skipped => "skipped (already exists)",
2828
}
2929
}
30+
31+
/// Machine-stable identifier for structured output (#142).
32+
/// Unlike `label()`, this never changes wording: claws can switch on
33+
/// these values without brittle substring matching.
34+
#[must_use]
35+
pub(crate) fn json_tag(self) -> &'static str {
36+
match self {
37+
Self::Created => "created",
38+
Self::Updated => "updated",
39+
Self::Skipped => "skipped",
40+
}
41+
}
3042
}
3143

3244
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -58,6 +70,36 @@ impl InitReport {
5870
lines.push(" Next step Review and tailor the generated guidance".to_string());
5971
lines.join("\n")
6072
}
73+
74+
/// Summary constant that claws can embed in JSON output without having
75+
/// to read it out of the human-formatted `message` string (#142).
76+
pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance";
77+
78+
/// Artifact names that ended in the given status. Used to build the
79+
/// structured `created[]`/`updated[]`/`skipped[]` arrays for #142.
80+
#[must_use]
81+
pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec<String> {
82+
self.artifacts
83+
.iter()
84+
.filter(|artifact| artifact.status == status)
85+
.map(|artifact| artifact.name.to_string())
86+
.collect()
87+
}
88+
89+
/// Structured artifact list for JSON output (#142). Each entry carries
90+
/// `name` and machine-stable `status` tag.
91+
#[must_use]
92+
pub(crate) fn artifact_json_entries(&self) -> Vec<serde_json::Value> {
93+
self.artifacts
94+
.iter()
95+
.map(|artifact| {
96+
serde_json::json!({
97+
"name": artifact.name,
98+
"status": artifact.status.json_tag(),
99+
})
100+
})
101+
.collect()
102+
}
61103
}
62104

63105
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -333,7 +375,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
333375

334376
#[cfg(test)]
335377
mod tests {
336-
use super::{initialize_repo, render_init_claude_md};
378+
use super::{initialize_repo, render_init_claude_md, InitStatus};
337379
use std::fs;
338380
use std::path::Path;
339381
use std::time::{SystemTime, UNIX_EPOCH};
@@ -413,6 +455,63 @@ mod tests {
413455
fs::remove_dir_all(root).expect("cleanup temp dir");
414456
}
415457

458+
#[test]
459+
fn artifacts_with_status_partitions_fresh_and_idempotent_runs() {
460+
// #142: the structured JSON output needs to be able to partition
461+
// artifacts into created/updated/skipped without substring matching
462+
// the human-formatted `message` string.
463+
let root = temp_dir();
464+
fs::create_dir_all(&root).expect("create root");
465+
466+
let fresh = initialize_repo(&root).expect("fresh init should succeed");
467+
let created_names = fresh.artifacts_with_status(InitStatus::Created);
468+
assert_eq!(
469+
created_names,
470+
vec![
471+
".claw/".to_string(),
472+
".claw.json".to_string(),
473+
".gitignore".to_string(),
474+
"CLAUDE.md".to_string(),
475+
],
476+
"fresh init should place all four artifacts in created[]"
477+
);
478+
assert!(
479+
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
480+
"fresh init should have no skipped artifacts"
481+
);
482+
483+
let second = initialize_repo(&root).expect("second init should succeed");
484+
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
485+
assert_eq!(
486+
skipped_names,
487+
vec![
488+
".claw/".to_string(),
489+
".claw.json".to_string(),
490+
".gitignore".to_string(),
491+
"CLAUDE.md".to_string(),
492+
],
493+
"idempotent init should place all four artifacts in skipped[]"
494+
);
495+
assert!(
496+
second.artifacts_with_status(InitStatus::Created).is_empty(),
497+
"idempotent init should have no created artifacts"
498+
);
499+
500+
// artifact_json_entries() uses the machine-stable `json_tag()` which
501+
// never changes wording (unlike `label()` which says "skipped (already exists)").
502+
let entries = second.artifact_json_entries();
503+
assert_eq!(entries.len(), 4);
504+
for entry in &entries {
505+
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
506+
assert_eq!(
507+
status, "skipped",
508+
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
509+
);
510+
}
511+
512+
fs::remove_dir_all(root).expect("cleanup temp dir");
513+
}
514+
416515
#[test]
417516
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
418517
let root = temp_dir();

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2948,11 +2948,15 @@ fn run_resume_command(
29482948
json: Some(render_memory_json()?),
29492949
}),
29502950
SlashCommand::Init => {
2951-
let message = init_claude_md()?;
2951+
// #142: run the init once, then render both text + structured JSON
2952+
// from the same InitReport so both surfaces stay in sync.
2953+
let cwd = env::current_dir()?;
2954+
let report = crate::init::initialize_repo(&cwd)?;
2955+
let message = report.render();
29522956
Ok(ResumeCommandOutcome {
29532957
session: session.clone(),
29542958
message: Some(message.clone()),
2955-
json: Some(init_json_value(&message)),
2959+
json: Some(init_json_value(&report, &message)),
29562960
})
29572961
}
29582962
SlashCommand::Diff => {
@@ -5666,20 +5670,31 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
56665670
}
56675671

56685672
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
5669-
let message = init_claude_md()?;
5673+
let cwd = env::current_dir()?;
5674+
let report = initialize_repo(&cwd)?;
5675+
let message = report.render();
56705676
match output_format {
56715677
CliOutputFormat::Text => println!("{message}"),
56725678
CliOutputFormat::Json => println!(
56735679
"{}",
5674-
serde_json::to_string_pretty(&init_json_value(&message))?
5680+
serde_json::to_string_pretty(&init_json_value(&report, &message))?
56755681
),
56765682
}
56775683
Ok(())
56785684
}
56795685

5680-
fn init_json_value(message: &str) -> serde_json::Value {
5686+
/// #142: emit first-class structured fields alongside the legacy `message`
5687+
/// string so claws can detect per-artifact state without substring matching.
5688+
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
5689+
use crate::init::InitStatus;
56815690
json!({
56825691
"kind": "init",
5692+
"project_path": report.project_root.display().to_string(),
5693+
"created": report.artifacts_with_status(InitStatus::Created),
5694+
"updated": report.artifacts_with_status(InitStatus::Updated),
5695+
"skipped": report.artifacts_with_status(InitStatus::Skipped),
5696+
"artifacts": report.artifact_json_entries(),
5697+
"next_step": crate::init::InitReport::NEXT_STEP,
56835698
"message": message,
56845699
})
56855700
}

0 commit comments

Comments
 (0)