feat: #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 #142.
This commit is contained in:
YeonGyu-Kim
2026-04-21 17:42:00 +09:00
parent 7763ca3260
commit 611eed1537
2 changed files with 120 additions and 6 deletions

View File

@@ -2948,11 +2948,15 @@ fn run_resume_command(
json: Some(render_memory_json()?),
}),
SlashCommand::Init => {
let message = init_claude_md()?;
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
let cwd = env::current_dir()?;
let report = crate::init::initialize_repo(&cwd)?;
let message = report.render();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&message)),
json: Some(init_json_value(&report, &message)),
})
}
SlashCommand::Diff => {
@@ -5666,20 +5670,31 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
}
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let message = init_claude_md()?;
let cwd = env::current_dir()?;
let report = initialize_repo(&cwd)?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&init_json_value(&message))?
serde_json::to_string_pretty(&init_json_value(&report, &message))?
),
}
Ok(())
}
fn init_json_value(message: &str) -> serde_json::Value {
/// #142: emit first-class structured fields alongside the legacy `message`
/// string so claws can detect per-artifact state without substring matching.
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
use crate::init::InitStatus;
json!({
"kind": "init",
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"artifacts": report.artifact_json_entries(),
"next_step": crate::init::InitReport::NEXT_STEP,
"message": message,
})
}