diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index eece992..3fd86d3 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -6547,3 +6547,52 @@ artifacts: created-by: ai-assisted model: claude-opus-4-8 timestamp: 2026-06-06T10:30:00Z + + - id: REQ-211 + type: requirement + title: "`rivet list --format json --full` emits description/tags/fields in bulk so machine-readable queries don't need N× `get` or a raw YAML parse" + status: implemented + description: | + Finding (#506, dogfooding). AGENTS.md/CLAUDE.md direct agents to + `rivet list --format json` for "machine-readable artifact queries", but + that output is summary-only — `id`, `type`, `title`, `status`, `links`. + It omits `description`, `tags`, and `fields`, so the values agents most + often need (e.g. a `priority` field, the acceptance text) are not + reachable in bulk. The full per-artifact view exists via + `rivet get --format json`, but recovering fields for the whole + store means one subprocess per artifact (917 in this repo) or parsing + the raw YAML — which is exactly the fallback the next-id memory had to + use. `--variant` emits a merged `fields:` view but requires a variant + config and applies overlays, so it is not a clean "all fields" export. + + Fix: a `--full` flag on `rivet list`. When set with `--format json`, + each artifact entry also carries `description`, `tags`, and `fields`, + mirroring `get`'s JSON shape. Additive and opt-in: default output is + unchanged, so existing consumers are unaffected + (`list-output.schema.json` is `additionalProperties: true`). The same + change corrects long-standing drift in that schema: `links` was still + typed as an integer count, but the actual output has been an array of + `{type,target}` objects since #358 — the schema now matches. + + Acceptance: + - `rivet list --format json --full` emits, for each artifact, + `description` (string), `tags` (array), and `fields` (object) in + addition to the summary keys; plain `rivet list --format json` + still emits only the summary keys (no `description`/`tags`/`fields`). + - The `--full` `fields` object for a given id equals the `fields` + object from `rivet get --format json`. + - `cargo test -p rivet-cli --test cli_commands` passes (full suite), + including a new test asserting the presence/absence of the rich keys. + - `cargo fmt --check` + `cargo clippy --all-targets -- -D warnings` clean. + - `rivet validate` on the rivet repo still PASS. + tags: [list, json, query, machine-readable, dogfooding, dx] + fields: + priority: should + category: functional + links: + - type: traces-to + target: REQ-007 + provenance: + created-by: ai-assisted + model: claude-opus-4-8 + timestamp: 2026-06-06T11:00:00Z diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 00ea247..49d5e33 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -505,6 +505,15 @@ enum Command { /// by id. #[arg(long)] rank_by_backlinks: bool, + + /// Include full artifact fields in JSON output — `description`, + /// `tags`, and `fields` — matching `rivet get --format json`, + /// but in bulk. Without it `--format json` emits only the summary + /// (id/type/title/status/links), so a machine-readable query for a + /// field value (e.g. `priority`) otherwise needs one `get` call per + /// artifact or a raw YAML parse (#506). No effect on text output. + #[arg(long)] + full: bool, }, /// Show artifact summary statistics @@ -2109,6 +2118,7 @@ fn run(cli: Cli) -> Result { variant, orphans, rank_by_backlinks, + full, } => cmd_list( &cli, r#type.as_deref(), @@ -2119,6 +2129,7 @@ fn run(cli: Cli) -> Result { variant.as_deref(), *orphans, *rank_by_backlinks, + *full, ), Command::Get { id, format } => cmd_get(&cli, id, format), // REQ-167 / #426: `trace` is the discoverable namesake verb for the @@ -6457,6 +6468,7 @@ fn cmd_list( variant_arg: Option<&str>, orphans_only: bool, rank_by_backlinks: bool, + full: bool, ) -> Result { validate_format(format, &["text", "json"])?; let ctx = ProjectContext::load(cli)?; @@ -6543,6 +6555,32 @@ fn cmd_list( map.insert("inbound_links".into(), serde_json::json!(n)); } } + // `--full` (#506): include the same rich fields as + // `get --format json` — description/tags/fields — so a + // bulk machine-readable query can read a field value without + // N× `get` or a raw YAML parse. Inserted BEFORE the variant + // block so a `--variant` merged `fields:` view still wins. + if full { + if let serde_json::Value::Object(ref mut map) = entry { + let fields_json: serde_json::Value = a + .fields + .iter() + .map(|(k, v)| { + ( + k.clone(), + serde_json::to_value(v).unwrap_or(serde_json::Value::Null), + ) + }) + .collect::>() + .into(); + map.insert( + "description".into(), + serde_json::json!(a.description.as_deref().unwrap_or("")), + ); + map.insert("tags".into(), serde_json::json!(a.tags)); + map.insert("fields".into(), fields_json); + } + } if let Some(ref name) = variant_name { // Emit the merged fields view so downstream tooling // can see the variant-resolved values without a diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 2383efe..d160a5e 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -1243,6 +1243,74 @@ fn list_json_artifact_fields() { } } +/// REQ-211 / #506: `rivet list --format json` is summary-only (no +/// description/tags/fields); `--full` adds them in bulk, matching the +/// `get --format json` shape. Verify the presence/absence split and +/// that `--full`'s `fields` equals `get`'s `fields` for the same id. +#[test] +fn list_json_full_includes_rich_fields() { + let root = project_root(); + let root_str = root.to_str().unwrap(); + let run = |args: &[&str]| { + let mut full_args = vec!["--project", root_str]; + full_args.extend_from_slice(args); + let out = Command::new(rivet_bin()) + .args(&full_args) + .output() + .expect("execute rivet"); + assert!( + out.status.success(), + "rivet {:?} must exit 0. stderr: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice::(&out.stdout).expect("valid JSON") + }; + + // Plain list: summary-only — no rich keys. + let plain = run(&["list", "--format", "json"]); + let plain_first = plain["artifacts"] + .as_array() + .and_then(|a| a.first()) + .expect("at least one artifact"); + assert!( + plain_first.get("description").is_none() + && plain_first.get("tags").is_none() + && plain_first.get("fields").is_none(), + "plain `list --format json` must NOT carry description/tags/fields" + ); + + // --full: every artifact carries description (string), tags (array), + // fields (object). + let full = run(&["list", "--format", "json", "--full"]); + let full_arts = full["artifacts"].as_array().expect("artifacts array"); + assert!(!full_arts.is_empty()); + for a in full_arts { + assert!( + a.get("description").map(|v| v.is_string()).unwrap_or(false), + "--full artifact {} must have a string 'description'", + a.get("id").and_then(|v| v.as_str()).unwrap_or("?") + ); + assert!( + a.get("tags").map(|v| v.is_array()).unwrap_or(false), + "--full artifact must have an array 'tags'" + ); + assert!( + a.get("fields").map(|v| v.is_object()).unwrap_or(false), + "--full artifact must have an object 'fields'" + ); + } + + // --full's `fields` for a known id must equal `get`'s `fields`. + let id = full_arts[0]["id"].as_str().expect("id"); + let got = run(&["get", id, "--format", "json"]); + assert_eq!( + full_arts[0].get("fields"), + got.get("fields"), + "list --full fields must equal get fields for {id}" + ); +} + // ── rivet init then validate roundtrip ────────────────────────────────── /// Initialize a project, then validate it — the sample artifacts should pass. diff --git a/schemas/json/list-output.schema.json b/schemas/json/list-output.schema.json index bedb70e..fee740e 100644 --- a/schemas/json/list-output.schema.json +++ b/schemas/json/list-output.schema.json @@ -30,9 +30,34 @@ "description": "Status string, or '-' if unset." }, "links": { + "type": "array", + "description": "Outgoing links (issue #358): each is an object with the link `type` and `target`. Count is recoverable as `.links | length`.", + "items": { + "type": "object", + "required": ["type", "target"], + "properties": { + "type": { "type": "string" }, + "target": { "type": "string" } + } + } + }, + "inbound_links": { "type": "integer", "minimum": 0, - "description": "Number of outgoing links from this artifact." + "description": "Inbound-link count; present only with --rank-by-backlinks." + }, + "description": { + "type": "string", + "description": "Full description; present only with --full (#506)." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags; present only with --full (#506)." + }, + "fields": { + "type": "object", + "description": "Custom field map; present with --full (#506) or --variant (merged view)." } } }