diff --git a/README.md b/README.md index 12634af2..2a8b09f1 100644 --- a/README.md +++ b/README.md @@ -205,17 +205,18 @@ Codex automation reviews source evidence: are not part of the installable Decodex plugin distribution. - `decodex radar bundle build` builds normalized GitHub bundles under `artifacts/github/bundles/` when a queued subject needs full source context. -- `scripts/github/backfill_release_range.py` fills release-window gaps before a - release or prerelease summary, but daily Radar still starts from the commit stream. +- `decodex radar backfill-release-range` fills release-window gaps before a release + or prerelease summary, but daily Radar still starts from the commit stream. - `docs/spec/upstream-review.md` records the queue and AI review boundary. - `docs/spec/upstream-impact.md` records how upstream Codex changes are classified for public signals and Control Plane follow-up work. -- `scripts/github/render_signal_entry.py` renders reviewed analysis drafts into site - content. +- `decodex radar render-signal` renders reviewed analysis drafts into site content. - `scripts/github/validate_signal_entry.py` validates the published signal collection. -- `decodex radar bundle validate`, `decodex radar ledger ...`, and - `decodex radar validate` provide the Rust-owned command surface for bundle - validation, local ledger maintenance, and checked Radar artifact validation. +- `decodex radar bundle validate`, `decodex radar ledger ...`, `decodex radar + render-signal`, `decodex radar backfill-release-range`, and `decodex radar + validate` provide the Rust-owned command surface for bundle validation, local ledger + maintenance, signal rendering, release-window backfill, and checked Radar artifact + validation. - `docs/spec/social-publishing.md` and `docs/runbook/social-publishing-workflow.md` govern automated low-frequency X publication for `@decodexspace`. diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 922c4386..9eda2563 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -24,9 +24,10 @@ use crate::{ }, prelude::{Result, eyre}, radar::{ - self, RadarBundleBuildRequest, RadarBundleValidateRequest, RadarLedgerArtifactLinkRequest, - RadarLedgerBootstrapRequest, RadarLedgerIngestExistingRequest, RadarLedgerIngestRequest, - RadarLedgerSummaryRequest, RadarValidateRequest, + self, RadarBackfillReleaseRangeRequest, RadarBundleBuildRequest, + RadarBundleValidateRequest, RadarLedgerArtifactLinkRequest, RadarLedgerBootstrapRequest, + RadarLedgerIngestExistingRequest, RadarLedgerIngestRequest, RadarLedgerSummaryRequest, + RadarRenderSignalRequest, RadarValidateRequest, }, recovery::{self, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest}, runtime, @@ -601,6 +602,8 @@ impl RadarCommand { RadarSubcommand::Bundle(args) => args.run(), RadarSubcommand::Ledger(args) => args.run(), RadarSubcommand::Validate(args) => args.run(), + RadarSubcommand::RenderSignal(args) => args.run(), + RadarSubcommand::BackfillReleaseRange(args) => args.run(), } } } @@ -817,6 +820,118 @@ impl RadarValidateCommand { } } +#[derive(Debug, Args)] +struct RadarRenderSignalCommand { + /// Path to a github_change_bundle/v1 JSON artifact. + #[arg(long)] + bundle: PathBuf, + /// Path to a Codex-owned analysis_draft JSON artifact. + #[arg(long)] + analysis: PathBuf, + /// Path to write the rendered signal_entry/v1 artifact. + #[arg(long)] + out: PathBuf, + /// Override the rendered publication timestamp. + #[arg(long)] + published_at: Option, +} +impl RadarRenderSignalCommand { + fn run(&self) -> Result<()> { + let report = radar::render_signal(&RadarRenderSignalRequest { + bundle: self.bundle.clone(), + analysis: self.analysis.clone(), + out: self.out.clone(), + published_at: self.published_at.clone(), + })?; + + println!("{}", report.out.display()); + + Ok(()) + } +} + +#[derive(Debug, Args)] +struct RadarBackfillReleaseRangeCommand { + /// GitHub repository in owner/name format. + #[arg(long, default_value = "openai/codex")] + repo: String, + /// Release-delta artifact to read or refresh. + #[arg(long, default_value = "site/src/content/release-deltas/openai-codex-latest.json")] + release_delta: PathBuf, + /// Stable tag name to backfill from. Defaults to the top-level stable release. + #[arg(long)] + stable_tag: Option, + /// Preview tag name to backfill to. Defaults to the top-level prerelease. + #[arg(long)] + preview_tag: Option, + /// Directory containing published signal_entry/v1 artifacts. + #[arg(long, default_value = "site/src/content/signals")] + signals_dir: PathBuf, + /// Directory for generated GitHub bundles. + #[arg(long, default_value = "artifacts/github/bundles")] + bundles_dir: PathBuf, + /// Directory for Codex-owned analysis drafts. + #[arg(long, default_value = "artifacts/github/analysis")] + analysis_dir: PathBuf, + /// Environment variable containing a GitHub token. + #[arg(long)] + token_env: Option, + /// Codex executable to invoke at the AI analysis boundary. + #[arg(long, default_value = "codex")] + codex_bin: String, + /// Optional Codex model override. + #[arg(long)] + model: Option, + /// Optional PR cap for debugging or partial runs. + #[arg(long)] + max_prs: Option, + /// Print selected PRs without generating new content. + #[arg(long)] + dry_run: bool, + /// Refresh release_delta/v1 into a temporary file before selecting the prerelease range. + #[arg(long)] + refresh_release_delta_first: bool, + /// Stable release limit passed through only by --refresh-release-delta-first. + #[arg(long)] + refresh_stable_limit: Option, + /// Prerelease limit passed through only by --refresh-release-delta-first. + #[arg(long)] + refresh_preview_limit: Option, + /// Compare pair limit passed through only by --refresh-release-delta-first. + #[arg(long)] + refresh_pair_limit: Option, + /// Python executable for non-ported helper boundaries. + #[arg(long, default_value = "python3")] + python_bin: String, +} +impl RadarBackfillReleaseRangeCommand { + fn run(&self) -> Result<()> { + let report = radar::backfill_release_range(&RadarBackfillReleaseRangeRequest { + repo: self.repo.clone(), + release_delta: self.release_delta.clone(), + stable_tag: self.stable_tag.clone(), + preview_tag: self.preview_tag.clone(), + signals_dir: self.signals_dir.clone(), + bundles_dir: self.bundles_dir.clone(), + analysis_dir: self.analysis_dir.clone(), + token_env: self.token_env.clone(), + codex_bin: self.codex_bin.clone(), + model: self.model.clone(), + max_prs: self.max_prs, + dry_run: self.dry_run, + refresh_release_delta_first: self.refresh_release_delta_first, + refresh_stable_limit: self.refresh_stable_limit, + refresh_preview_limit: self.refresh_preview_limit, + refresh_pair_limit: self.refresh_pair_limit, + python_bin: self.python_bin.clone(), + })?; + + println!("{}", serde_json::to_string_pretty(&report)?); + + Ok(()) + } +} + #[derive(Debug, Args)] struct MaintenancePruneCommand { /// Report candidates without applying retention changes. This is the default mode. @@ -930,6 +1045,7 @@ impl From for AttemptDispatchMode { } } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Subcommand)] enum Command { /// Create a signed local commit with a `decodex/commit/1` message. @@ -1017,6 +1133,7 @@ enum MaintenanceSubcommand { Prune(MaintenancePruneCommand), } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Subcommand)] enum RadarSubcommand { /// Build and validate deterministic GitHub change bundles. @@ -1025,6 +1142,10 @@ enum RadarSubcommand { Ledger(RadarLedgerCommand), /// Validate checked-in Radar artifact JSON contracts. Validate(RadarValidateCommand), + /// Render a signal_entry/v1 artifact from a bundle plus Codex analysis draft. + RenderSignal(RadarRenderSignalCommand), + /// Select and optionally execute release-window signal backfills. + BackfillReleaseRange(RadarBackfillReleaseRangeCommand), } #[derive(Debug, Subcommand)] @@ -1083,12 +1204,14 @@ mod tests { use crate::cli::{ AccountCommand, AccountSubcommand, AccountUseCommand, AttemptCommand, Cli, Command, CommitCommand, DiagnoseCommand, EvidenceCommand, LandCommand, ProbeCommand, ProjectCommand, - ProjectConfigArgs, ProjectSubcommand, RadarBundleBuildCommand, RadarBundleCommand, - RadarBundleSubcommand, RadarBundleValidateCommand, RadarCommand, RadarLedgerCommand, + ProjectConfigArgs, ProjectSubcommand, RadarBackfillReleaseRangeCommand, + RadarBundleBuildCommand, RadarBundleCommand, RadarBundleSubcommand, + RadarBundleValidateCommand, RadarCommand, RadarLedgerCommand, RadarLedgerIngestExistingCommand, RadarLedgerSubcommand, RadarLedgerSummaryCommand, - RadarSubcommand, RadarValidateCommand, RecoverCommand, RecoverSubcommand, - ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, - ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, StatusCommand, + RadarRenderSignalCommand, RadarSubcommand, RadarValidateCommand, RecoverCommand, + RecoverSubcommand, ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, + ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, + StatusCommand, }; #[test] @@ -1266,6 +1389,72 @@ mod tests { )); } + #[test] + fn parses_radar_render_signal_paths() { + let cli = Cli::parse_from([ + "decodex", + "radar", + "render-signal", + "--bundle", + "artifacts/github/bundles/openai-codex-pr-1.json", + "--analysis", + "artifacts/github/analysis/openai-codex-pr-1.analysis.json", + "--out", + "site/src/content/signals/openai-codex-pr-1.json", + "--published-at", + "2026-06-01T00:00:00Z", + ]); + + assert!(matches!( + cli.command, + Command::Radar(RadarCommand { + command: RadarSubcommand::RenderSignal(RadarRenderSignalCommand { + bundle, + analysis, + out, + published_at: Some(published_at), + }), + }) if bundle == Path::new("artifacts/github/bundles/openai-codex-pr-1.json") + && analysis == Path::new("artifacts/github/analysis/openai-codex-pr-1.analysis.json") + && out == Path::new("site/src/content/signals/openai-codex-pr-1.json") + && published_at == "2026-06-01T00:00:00Z" + )); + } + + #[test] + fn parses_radar_backfill_release_range() { + let cli = Cli::parse_from([ + "decodex", + "radar", + "backfill-release-range", + "--repo", + "openai/codex", + "--stable-tag", + "rust-v0.130.0", + "--preview-tag", + "rust-v0.131.0-alpha.9", + "--max-prs", + "2", + "--dry-run", + ]); + + assert!(matches!( + cli.command, + Command::Radar(RadarCommand { + command: RadarSubcommand::BackfillReleaseRange(RadarBackfillReleaseRangeCommand { + repo, + stable_tag: Some(stable_tag), + preview_tag: Some(preview_tag), + max_prs: Some(2), + dry_run: true, + .. + }), + }) if repo == "openai/codex" + && stable_tag == "rust-v0.130.0" + && preview_tag == "rust-v0.131.0-alpha.9" + )); + } + #[test] fn parses_radar_ledger_ingest_existing_defaults() { let cli = Cli::parse_from(["decodex", "radar", "ledger", "ingest-existing"]); diff --git a/apps/decodex/src/radar.rs b/apps/decodex/src/radar.rs index 114ca4fd..c72aa80e 100644 --- a/apps/decodex/src/radar.rs +++ b/apps/decodex/src/radar.rs @@ -2,9 +2,11 @@ use std::{ collections::{BTreeMap, BTreeSet}, - env, fs, + env, + fmt::{self, Display, Formatter}, + fs, path::{Path, PathBuf}, - process::Command, + process::{self, Command}, sync::OnceLock, thread, time::Duration, @@ -17,6 +19,7 @@ use reqwest::{ header::{ACCEPT, HeaderMap, LINK, USER_AGENT}, }; use rusqlite::{self, Connection, OptionalExtension as _}; +use serde::Serialize; use serde_json::{self, Map, Value}; use sha2::{Digest as _, Sha256}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -67,6 +70,11 @@ const UPSTREAM_REVIEW_NEXT_STEPS: &[&str] = &["ai_review_required"]; const UPSTREAM_REVIEW_PRIORITIES: &[&str] = &["critical", "high", "low", "normal"]; const UPSTREAM_SOURCE_STATES: &[&str] = &["closed", "commit_only", "merged", "open"]; const UPSTREAM_SUBJECT_KINDS: &[&str] = &["commit", "pr"]; +const GENERIC_COMMIT_TITLES: &[&str] = + &["update", "fix", "fix.", "fix tests", "fix tests.", "merge fixes", "flaky syntax"]; +const CONFIG_FEATURE_CATALOG_PATH: &str = "site/src/generated/codex-config-features.json"; +const BUILD_RELEASE_DELTA_SCRIPT: &str = "scripts/github/build_release_delta.py"; +const RUN_CODEX_ANALYSIS_SCRIPT: &str = "scripts/github/run_codex_analysis.py"; const REVIEW_STATUSES: &[&str] = &["archived", "control_plane", "deprecated", "seen", "signal", "skipped", "social", "watch"]; const ARTIFACT_KINDS: &[&str] = &[ @@ -180,6 +188,85 @@ pub(crate) struct RadarBundleValidateRequest { pub(crate) paths: Vec, } +/// Request to render one `signal_entry/v1` artifact from a bundle and analysis draft. +#[derive(Debug)] +pub(crate) struct RadarRenderSignalRequest { + /// Path to a `github_change_bundle/v1` JSON artifact. + pub(crate) bundle: PathBuf, + /// Path to a Codex-owned `analysis_draft` JSON artifact. + pub(crate) analysis: PathBuf, + /// Path to write the rendered `signal_entry/v1` artifact. + pub(crate) out: PathBuf, + /// Optional publication timestamp override. + pub(crate) published_at: Option, +} + +/// Summary of a rendered signal artifact. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct RadarRenderSignalReport { + /// Path that received the rendered signal artifact. + pub(crate) out: PathBuf, +} + +/// Request to backfill unpublished signals from a release-delta comparison window. +#[derive(Debug)] +pub(crate) struct RadarBackfillReleaseRangeRequest { + /// GitHub repository in `owner/name` format. + pub(crate) repo: String, + /// Release-delta artifact to read or refresh. + pub(crate) release_delta: PathBuf, + /// Stable tag to use as the comparison start. + pub(crate) stable_tag: Option, + /// Preview tag to use as the comparison end. + pub(crate) preview_tag: Option, + /// Directory containing published signal entries. + pub(crate) signals_dir: PathBuf, + /// Directory for generated GitHub bundles. + pub(crate) bundles_dir: PathBuf, + /// Directory for Codex-owned analysis drafts. + pub(crate) analysis_dir: PathBuf, + /// Optional GitHub token environment variable name passed through to helper scripts. + pub(crate) token_env: Option, + /// Codex executable to pass to the AI analysis boundary. + pub(crate) codex_bin: String, + /// Optional Codex model override for the AI analysis boundary. + pub(crate) model: Option, + /// Optional PR count cap for partial runs. + pub(crate) max_prs: Option, + /// Print selected targets without writing generated content. + pub(crate) dry_run: bool, + /// Refresh the release-delta artifact into a temporary file before selecting targets. + pub(crate) refresh_release_delta_first: bool, + /// Stable release limit passed through only when refreshing first. + pub(crate) refresh_stable_limit: Option, + /// Preview release limit passed through only when refreshing first. + pub(crate) refresh_preview_limit: Option, + /// Compare-pair limit passed through only when refreshing first. + pub(crate) refresh_pair_limit: Option, + /// Python executable used for non-ported helper boundaries and the AI boundary. + pub(crate) python_bin: String, +} + +/// Summary of a release-window backfill selection or run. +#[derive(Debug, Eq, PartialEq, Serialize)] +pub(crate) struct RadarBackfillReleaseRangeReport { + /// Stable tag selected for the comparison. + pub(crate) stable_tag: String, + /// Preview tag selected for the comparison. + pub(crate) preview_tag: String, + /// PR numbers selected for backfill. + pub(crate) target_prs: Vec, + /// Number of signal entries created by this run. + pub(crate) created: usize, + /// Whether the command only previewed targets. + pub(crate) dry_run: bool, +} +impl Display for RadarBackfillReleaseRangeReport { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", serde_json::to_string_pretty(self).map_err(|_| fmt::Error)?) + } +} + /// Summary of a Radar validation pass. #[derive(Debug, Eq, PartialEq)] pub(crate) struct RadarValidationReport { @@ -209,6 +296,33 @@ struct ReleaseOptionTags { preview: BTreeSet, } +#[derive(Debug)] +struct PreparedReleaseDelta { + path: PathBuf, + cleanup_dir: Option, +} +impl Drop for PreparedReleaseDelta { + fn drop(&mut self) { + if let Some(path) = &self.cleanup_dir { + let _ = fs::remove_dir_all(path); + } + } +} + +#[derive(Debug, Eq, PartialEq)] +struct ReleaseSelection { + stable_tag: String, + preview_tag: String, + pr_numbers: Vec, +} + +#[derive(Debug)] +struct BackfillPaths { + bundle: PathBuf, + analysis: PathBuf, + signal: PathBuf, +} + struct CommitInput<'a> { repo: &'a str, sha: &'a str, @@ -565,6 +679,768 @@ pub(crate) fn validate_bundles( } } +/// Render one `signal_entry/v1` artifact from a validated bundle and analysis draft. +pub(crate) fn render_signal( + request: &RadarRenderSignalRequest, +) -> crate::prelude::Result { + let bundle = load_json(&request.bundle)?; + let analysis = load_json(&request.analysis)?; + + validate_expected_schema(&bundle, BUNDLE_SCHEMA, "Bundle")?; + validate_analysis_draft(&analysis)?; + + let root = repo_root()?; + let known_features = load_known_feature_names(&root)?; + let config_flags = rendered_config_flags(&bundle, &analysis, &known_features); + let signal = + rendered_signal(&bundle, &analysis, request.published_at.as_deref(), config_flags)?; + + validate_expected_schema(&signal, SIGNAL_SCHEMA, "Signal")?; + write_json(&request.out, &signal)?; + + Ok(RadarRenderSignalReport { out: request.out.clone() }) +} + +/// Select and optionally execute release-window signal backfills. +pub(crate) fn backfill_release_range( + request: &RadarBackfillReleaseRangeRequest, +) -> crate::prelude::Result { + let root = repo_root()?; + let prepared_release_delta = prepare_release_delta_path(request, &root)?; + let release_delta = load_json(&prepared_release_delta.path)?; + let selection = selected_release_comparison( + &release_delta, + request.stable_tag.as_deref(), + request.preview_tag.as_deref(), + )?; + let signals_dir = resolve_against(&root, &request.signals_dir); + let published = published_pr_numbers(&signals_dir)?; + let mut target_prs = selection + .pr_numbers + .into_iter() + .filter(|number| !published.contains(number)) + .collect::>(); + + if let Some(limit) = request.max_prs { + target_prs.truncate(limit); + } + + let mut report = RadarBackfillReleaseRangeReport { + stable_tag: selection.stable_tag, + preview_tag: selection.preview_tag, + target_prs, + created: 0, + dry_run: request.dry_run, + }; + + if request.dry_run { + return Ok(report); + } + + for pr_number in &report.target_prs { + let paths = signal_backfill_paths(&request.repo, *pr_number, request); + let note = format!( + "Backfilled from release compare range {}...{}", + report.stable_tag, report.preview_tag + ); + let bundle_path = resolve_against(&root, &paths.bundle); + let analysis_path = resolve_against(&root, &paths.analysis); + let signal_path = resolve_against(&root, &paths.signal); + + run_build_bundle(request, *pr_number, &bundle_path, ¬e)?; + run_codex_analysis(&root, request, &bundle_path, &analysis_path)?; + render_signal(&RadarRenderSignalRequest { + bundle: bundle_path, + analysis: analysis_path, + out: signal_path, + published_at: None, + })?; + + report.created += 1; + } + + validate(&RadarValidateRequest { paths: vec![resolve_against(&root, &request.signals_dir)] })?; + run_build_release_delta(&root, request, &request.release_delta, false)?; + + Ok(report) +} + +fn validate_expected_schema( + value: &Value, + schema: &str, + label: &str, +) -> crate::prelude::Result<()> { + let validation = validate_artifact(value); + + if validation.schema.as_deref() != Some(schema) { + return Err(eyre::eyre!("{label} schema must be {schema}")); + } + if !validation.errors.is_empty() { + return Err(eyre::eyre!( + "{label} validation failed:\n- {}", + validation.errors.join("\n- ") + )); + } + + Ok(()) +} + +fn validate_analysis_draft(value: &Value) -> crate::prelude::Result<()> { + let Some(draft) = value.as_object() else { + return Err(eyre::eyre!("Analysis draft must be an object")); + }; + let mut errors = Vec::new(); + + for field in ["kind", "title", "summary", "why_it_matters", "confidence", "impact"] { + if !is_non_empty_string(draft.get(field)) { + errors.push(format!("{field} is required in analysis draft")); + } + } + + if !matches_one_of(draft.get("kind"), SIGNAL_KINDS) { + errors.push(format!("kind must be one of {}", choices(SIGNAL_KINDS))); + } + if !matches_one_of(draft.get("confidence"), SIGNAL_CONFIDENCE) { + errors.push(format!("confidence must be one of {}", choices(SIGNAL_CONFIDENCE))); + } + if !matches_one_of(draft.get("impact"), SIGNAL_IMPACT) { + errors.push(format!("impact must be one of {}", choices(SIGNAL_IMPACT))); + } + if non_empty_array(draft.get("proof_points")).is_none() { + errors.push("proof_points must be a non-empty list".into()); + } + if string_field(draft, "kind") == Some("try_now") + && !is_truthy_json_value(draft.get("how_to_try")) + { + errors.push("how_to_try is required when kind is try_now".into()); + } + if is_truthy_json_value(draft.get("how_to_try")) + && !is_truthy_json_value(draft.get("expected_effect")) + { + errors.push("expected_effect is required when how_to_try is present".into()); + } + if errors.is_empty() { + Ok(()) + } else { + Err(eyre::eyre!("Analysis draft validation failed:\n- {}", errors.join("\n- "))) + } +} + +fn rendered_signal( + bundle: &Value, + analysis: &Value, + published_at_override: Option<&str>, + config_flags: Vec, +) -> crate::prelude::Result { + let bundle = object_value(bundle, "Bundle")?; + let analysis = object_value(analysis, "Analysis draft")?; + let title = required_string(analysis, "title", "analysis draft")?; + let slug = string_field(analysis, "slug") + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| slugify(title)); + let mut signal = Map::new(); + + signal.insert("schema".into(), serde_json::json!(SIGNAL_SCHEMA)); + signal.insert("slug".into(), serde_json::json!(slug)); + signal.insert("lane".into(), serde_json::json!("github")); + + for field in ["kind", "title", "summary", "why_it_matters", "confidence", "impact"] { + signal.insert( + field.into(), + serde_json::json!(required_string(analysis, field, "analysis draft")?), + ); + } + + signal.insert( + "published_at".into(), + serde_json::json!(pick_published_at(bundle, analysis, published_at_override)?), + ); + signal.insert("config_flags".into(), serde_json::json!(config_flags)); + signal.insert( + "proof_points".into(), + analysis + .get("proof_points") + .cloned() + .ok_or_else(|| eyre::eyre!("analysis draft proof_points is required"))?, + ); + signal.insert("source_refs".into(), rendered_source_refs(bundle)?); + + for field in ["how_to_try", "expected_effect", "caveats", "watch_state"] { + if is_truthy_json_value(analysis.get(field)) { + signal.insert( + field.into(), + analysis + .get(field) + .cloned() + .ok_or_else(|| eyre::eyre!("analysis draft {field} is required"))?, + ); + } + } + + Ok(Value::Object(signal)) +} + +fn rendered_config_flags( + bundle: &Value, + analysis: &Value, + known_features: &BTreeSet, +) -> Vec { + let raw_flags = analysis + .get("config_flags") + .filter(|value| !value.is_null()) + .or_else(|| bundle.get("extracted_flags")); + + normalized_config_flags(raw_flags, known_features) +} + +fn normalized_config_flags( + raw_flags: Option<&Value>, + known_features: &BTreeSet, +) -> Vec { + let Some(raw_flags) = raw_flags.and_then(Value::as_array) else { + return Vec::new(); + }; + let mut normalized = Vec::new(); + let mut seen = BTreeSet::new(); + + for flag in raw_flags { + let Some(raw_value) = flag.as_str() else { + continue; + }; + let mut value = raw_value.trim().to_owned(); + + if value.is_empty() || seen.contains(&value) { + continue; + } + + if let Some(feature_name) = normalize_feature_flag(&value, known_features) { + value = format!("features.{feature_name} = true"); + } + + if !(value.starts_with("--") + || value.contains('=') + || value.ends_with(".json") + || value.ends_with(".toml")) + { + continue; + } + + seen.insert(value.clone()); + normalized.push(value); + } + + normalized +} + +fn load_known_feature_names(root: &Path) -> crate::prelude::Result> { + let path = root.join(CONFIG_FEATURE_CATALOG_PATH); + + if !path.exists() { + return Ok(BTreeSet::new()); + } + + let payload = load_json(&path)?; + let names = payload + .get("features") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("name").and_then(Value::as_str)) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .collect(); + + Ok(names) +} + +fn normalize_feature_flag(value: &str, known_features: &BTreeSet) -> Option { + let lower = value.to_ascii_lowercase(); + + if let Some(candidate) = lower.strip_prefix("--enable ") { + let candidate = candidate.trim(); + + return known_feature_name(candidate, known_features); + } + + let candidate = lower.strip_prefix("features.").unwrap_or(&lower); + let (name, enabled) = + candidate.split_once('=').map_or((candidate.trim(), true), |(name, enabled)| { + (name.trim(), enabled.trim() == "true") + }); + + enabled.then(|| known_feature_name(name, known_features)).flatten() +} + +fn known_feature_name(value: &str, known_features: &BTreeSet) -> Option { + let valid = !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }); + + if valid && known_features.contains(value) { Some(value.to_owned()) } else { None } +} + +fn rendered_source_refs(bundle: &Map) -> crate::prelude::Result { + let commits = bundle_commits(bundle)?; + let commit_urls = commits + .iter() + .filter_map(|commit| commit.get("url").and_then(Value::as_str)) + .collect::>(); + let mut refs = Map::new(); + + refs.insert("repo".into(), serde_json::json!(required_string(bundle, "repo", "bundle")?)); + refs.insert("commit_urls".into(), serde_json::json!(commit_urls)); + refs.insert("items".into(), serde_json::json!(rendered_source_items(bundle)?)); + + if let Some(pr_url) = bundle + .get("primary_pr") + .and_then(Value::as_object) + .and_then(|primary_pr| string_field(primary_pr, "url")) + { + refs.insert("pr_url".into(), serde_json::json!(pr_url)); + } + + Ok(Value::Object(refs)) +} + +fn rendered_source_items( + bundle: &Map, +) -> crate::prelude::Result>> { + let mut items = Vec::new(); + + if let Some(primary_pr) = bundle.get("primary_pr").and_then(Value::as_object) + && let (Some(url), Some(title)) = + (string_field(primary_pr, "url"), string_field(primary_pr, "title")) + { + let mut item = Map::new(); + + item.insert("kind".into(), serde_json::json!("pull_request")); + item.insert("title".into(), serde_json::json!(first_line(title))); + item.insert("url".into(), serde_json::json!(url)); + + if let Some(number) = primary_pr.get("number").and_then(Value::as_i64) { + item.insert("meta".into(), serde_json::json!(format!("#{number}"))); + } + + items.push(item); + } + + items.extend(rendered_commit_items(bundle)?); + + Ok(items) +} + +fn rendered_commit_items( + bundle: &Map, +) -> crate::prelude::Result>> { + let mut fallback_items = Vec::new(); + let mut picked_items = Vec::new(); + let mut seen_titles = BTreeSet::new(); + + for commit in bundle_commits(bundle)? { + let title = first_line(commit.get("message").and_then(Value::as_str).unwrap_or_default()); + + if title.is_empty() + || !seen_titles.insert(title.clone()) + || title.starts_with("Merge branch ") + { + continue; + } + + let entry = rendered_commit_item(commit, &title)?; + + fallback_items.push(entry.clone()); + + if !GENERIC_COMMIT_TITLES.contains(&title.to_ascii_lowercase().as_str()) { + picked_items.push(entry); + } + } + + Ok(if picked_items.is_empty() { fallback_items } else { picked_items }) +} + +fn rendered_commit_item( + commit: &Map, + title: &str, +) -> crate::prelude::Result> { + let sha = required_string(commit, "sha", "bundle commit")?; + let mut entry = Map::new(); + + entry.insert("kind".into(), serde_json::json!("commit")); + entry.insert("title".into(), serde_json::json!(title)); + entry.insert("url".into(), serde_json::json!(required_string(commit, "url", "bundle commit")?)); + entry.insert("meta".into(), serde_json::json!(short_sha(sha))); + + Ok(entry) +} + +fn bundle_commits(bundle: &Map) -> crate::prelude::Result>> { + bundle + .get("commits") + .and_then(Value::as_array) + .ok_or_else(|| eyre::eyre!("Bundle commits must be a list"))? + .iter() + .map(|commit| { + commit.as_object().ok_or_else(|| eyre::eyre!("Bundle commit must be an object")) + }) + .collect() +} + +fn pick_published_at( + bundle: &Map, + analysis: &Map, + override_value: Option<&str>, +) -> crate::prelude::Result { + if let Some(value) = override_value.filter(|value| !value.is_empty()) { + return Ok(value.to_owned()); + } + if let Some(value) = string_field(analysis, "published_at").filter(|value| !value.is_empty()) { + return Ok(value.to_owned()); + } + if let Some(value) = bundle + .get("primary_pr") + .and_then(Value::as_object) + .and_then(|primary_pr| string_field(primary_pr, "merged_at")) + .filter(|value| !value.is_empty()) + { + return Ok(value.to_owned()); + } + + let first_commit = bundle_commits(bundle)? + .into_iter() + .next() + .ok_or_else(|| eyre::eyre!("Bundle commits must be a non-empty list"))?; + + if let Some(value) = + string_field(first_commit, "committed_at").filter(|value| !value.is_empty()) + { + Ok(value.to_owned()) + } else { + utc_now_iso() + } +} + +fn short_sha(value: &str) -> String { + value.chars().take(7).collect() +} + +fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut previous_was_separator = false; + + for character in value.chars().flat_map(char::to_lowercase) { + if character.is_ascii_lowercase() || character.is_ascii_digit() { + slug.push(character); + + previous_was_separator = false; + } else if !previous_was_separator && !slug.is_empty() { + slug.push('-'); + + previous_was_separator = true; + } + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { "signal".into() } else { slug } +} + +fn repo_root() -> crate::prelude::Result { + let mut candidate = env::current_dir()?; + + loop { + if candidate.join("scripts/github/README.md").is_file() + && candidate.join("apps/decodex/src/radar.rs").is_file() + { + return Ok(candidate); + } + if !candidate.pop() { + return Err(eyre::eyre!( + "Unable to find Decodex repository root from current directory" + )); + } + } +} + +fn selected_release_comparison( + payload: &Value, + stable_tag: Option<&str>, + preview_tag: Option<&str>, +) -> crate::prelude::Result { + validate_expected_schema(payload, RELEASE_DELTA_SCHEMA, "Release-delta")?; + + let entry = + payload.as_object().ok_or_else(|| eyre::eyre!("Release-delta must be an object"))?; + let target_stable = stable_tag + .map(str::to_owned) + .or_else(|| release_tag(entry.get("stable_release"))) + .ok_or_else(|| eyre::eyre!("stable release tag could not be selected"))?; + let target_preview = preview_tag + .map(str::to_owned) + .or_else(|| release_tag(entry.get("prerelease"))) + .ok_or_else(|| eyre::eyre!("preview release tag could not be selected"))?; + let comparisons = entry + .get("comparisons") + .and_then(Value::as_array) + .ok_or_else(|| eyre::eyre!("Release-delta comparisons must be a list"))?; + + for comparison in comparisons { + let Some(comparison) = comparison.as_object() else { + continue; + }; + + if string_field(comparison, "stable_tag_name") == Some(target_stable.as_str()) + && string_field(comparison, "prerelease_tag_name") == Some(target_preview.as_str()) + { + return Ok(ReleaseSelection { + stable_tag: target_stable, + preview_tag: target_preview, + pr_numbers: comparison_pr_numbers(comparison), + }); + } + } + + Err(eyre::eyre!("No comparison found for {target_stable} -> {target_preview}")) +} + +fn release_tag(value: Option<&Value>) -> Option { + value + .and_then(Value::as_object) + .and_then(|release| string_field(release, "tag_name")) + .filter(|tag| !tag.is_empty()) + .map(str::to_owned) +} + +fn comparison_pr_numbers(comparison: &Map) -> Vec { + comparison + .get("compare") + .and_then(Value::as_object) + .and_then(|compare| compare.get("pr_numbers")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .collect() +} + +fn published_pr_numbers(signals_dir: &Path) -> crate::prelude::Result> { + let mut published = BTreeSet::new(); + let mut files = Vec::new(); + + for entry in fs::read_dir(signals_dir)? { + let path = entry?.path(); + + if path.extension().is_some_and(|extension| extension == "json") { + files.push(path); + } + } + + files.sort(); + + for path in files { + let payload = load_json(&path)?; + + validate_expected_schema(&payload, SIGNAL_SCHEMA, "Signal")?; + + if let Some(pr_number) = payload + .get("source_refs") + .and_then(Value::as_object) + .and_then(|refs| string_field(refs, "pr_url")) + .and_then(pr_number_from_url) + { + published.insert(pr_number); + } + } + + Ok(published) +} + +fn pr_number_from_url(value: &str) -> Option { + let marker = "/pull/"; + let index = value.rfind(marker)?; + let number = &value[index + marker.len()..]; + + (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit())) + .then(|| number.parse().ok()) + .flatten() +} + +fn prepare_release_delta_path( + request: &RadarBackfillReleaseRangeRequest, + root: &Path, +) -> crate::prelude::Result { + if !request.refresh_release_delta_first { + return Ok(PreparedReleaseDelta { + path: resolve_against(root, &request.release_delta), + cleanup_dir: None, + }); + } + + let temp_root = env::temp_dir().join(format!( + "decodex-prerelease-delta-{}-{}", + process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + + fs::create_dir_all(&temp_root)?; + + let release_delta = temp_root.join("release-delta.json"); + + run_build_release_delta(root, request, &release_delta, true)?; + + Ok(PreparedReleaseDelta { path: release_delta, cleanup_dir: Some(temp_root) }) +} + +fn run_build_bundle( + request: &RadarBackfillReleaseRangeRequest, + pr_number: u64, + out: &Path, + note: &str, +) -> crate::prelude::Result<()> { + build_bundle(&RadarBundleBuildRequest { + repo: request.repo.clone(), + pr: Some(pr_number), + commit: None, + force_commit_only: false, + token_env: request.token_env.clone(), + out: out.to_path_buf(), + notes: vec![note.to_owned()], + })?; + + Ok(()) +} + +fn run_codex_analysis( + root: &Path, + request: &RadarBackfillReleaseRangeRequest, + bundle: &Path, + out: &Path, +) -> crate::prelude::Result<()> { + let mut command = helper_command(root, request, RUN_CODEX_ANALYSIS_SCRIPT); + + command.args([ + "--bundle", + &path_arg(root, bundle), + "--out", + &path_arg(root, out), + "--repo-root", + &root.display().to_string(), + "--codex-bin", + request.codex_bin.as_str(), + ]); + + if let Some(model) = &request.model { + command.args(["--model", model]); + } + + run_helper(command, RUN_CODEX_ANALYSIS_SCRIPT) +} + +fn run_build_release_delta( + root: &Path, + request: &RadarBackfillReleaseRangeRequest, + out: &Path, + include_refresh_limits: bool, +) -> crate::prelude::Result<()> { + let mut command = helper_command(root, request, BUILD_RELEASE_DELTA_SCRIPT); + + command.args([ + "--repo", + request.repo.as_str(), + "--signals-dir", + &path_arg(root, &request.signals_dir), + "--out", + &path_arg(root, out), + ]); + + if let Some(token_env) = &request.token_env { + command.args(["--token-env", token_env]); + } + + if include_refresh_limits { + push_optional_limit(&mut command, "--stable-limit", request.refresh_stable_limit); + push_optional_limit(&mut command, "--preview-limit", request.refresh_preview_limit); + push_optional_limit(&mut command, "--pair-limit", request.refresh_pair_limit); + } + + run_helper(command, BUILD_RELEASE_DELTA_SCRIPT) +} + +fn push_optional_limit(command: &mut Command, flag: &str, value: Option) { + if let Some(value) = value { + command.args([flag, &value.to_string()]); + } +} + +fn helper_command( + root: &Path, + request: &RadarBackfillReleaseRangeRequest, + script: &str, +) -> Command { + let mut command = Command::new(&request.python_bin); + + command.current_dir(root).arg(root.join(script)); + + command +} + +fn run_helper(mut command: Command, script: &str) -> crate::prelude::Result<()> { + let output = command.output()?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let details = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "unknown error".into() + }; + + Err(eyre::eyre!("{script} failed: {details}")) +} + +fn signal_backfill_paths( + repo: &str, + pr_number: u64, + request: &RadarBackfillReleaseRangeRequest, +) -> BackfillPaths { + let stem = format!("{}-pr-{pr_number}", repo_path_stem(repo)); + + BackfillPaths { + bundle: request.bundles_dir.join(format!("{stem}.json")), + analysis: request.analysis_dir.join(format!("{stem}.analysis.json")), + signal: request.signals_dir.join(format!("{stem}.json")), + } +} + +fn repo_path_stem(repo: &str) -> String { + repo.chars() + .map( + |character| { + if character.is_ascii_alphanumeric() { character.to_ascii_lowercase() } else { '-' } + }, + ) + .collect::() + .trim_matches('-') + .to_owned() +} + +fn resolve_against(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { path.to_path_buf() } else { root.join(path) } +} + +fn path_arg(root: &Path, path: &Path) -> String { + path.strip_prefix(root).unwrap_or(path).display().to_string() +} + fn validation_paths(paths: &[PathBuf]) -> Vec { if paths.is_empty() { DEFAULT_VALIDATION_PATHS.iter().map(PathBuf::from).collect() @@ -2843,8 +3719,9 @@ mod tests { use serde_json::{self, Value}; use crate::radar::{ - self, RadarBundleValidateRequest, RadarLedgerArtifactLinkRequest, - RadarLedgerBootstrapRequest, RadarLedgerIngestExistingRequest, RadarValidateRequest, + self, RadarBackfillReleaseRangeRequest, RadarBundleValidateRequest, + RadarLedgerArtifactLinkRequest, RadarLedgerBootstrapRequest, + RadarLedgerIngestExistingRequest, RadarRenderSignalRequest, RadarValidateRequest, }; #[test] @@ -2967,6 +3844,97 @@ mod tests { assert_eq!(report.checked_files, 1); } + #[test] + fn renders_signal_from_bundle_and_analysis_fixture() { + let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); + let bundle_path = temp_dir.path().join("bundle.json"); + let analysis_path = temp_dir.path().join("analysis.json"); + let signal_path = temp_dir.path().join("signal.json"); + let analysis = serde_json::json!({ + "kind": "capability", + "title": "Unix sockets for remote Codex", + "summary": "Remote Codex can use Unix socket endpoints.", + "why_it_matters": "Operators can use local socket transports.", + "confidence": "confirmed", + "impact": "medium", + "proof_points": ["PR #22414 adds endpoint handling."], + "slug": null, + "config_flags": [], + "how_to_try": null, + "expected_effect": null, + "caveats": null, + "watch_state": null + }); + + fs::write(&bundle_path, valid_bundle().to_string()).expect("bundle should be written"); + fs::write(&analysis_path, analysis.to_string()).expect("analysis should be written"); + + let report = radar::render_signal(&RadarRenderSignalRequest { + bundle: bundle_path, + analysis: analysis_path, + out: signal_path.clone(), + published_at: None, + }) + .expect("rendered signal should pass validation"); + let rendered: Value = serde_json::from_str( + &fs::read_to_string(&signal_path).expect("rendered signal should be readable"), + ) + .expect("rendered signal should parse"); + + assert_eq!(report.out, signal_path); + assert_eq!(rendered["schema"], "signal_entry/v1"); + assert_eq!(rendered["slug"], "unix-sockets-for-remote-codex"); + assert_eq!(rendered["published_at"], "2026-06-01T00:00:00Z"); + assert_eq!(rendered["source_refs"]["items"][0]["meta"], serde_json::json!("#22414")); + assert_eq!(rendered["source_refs"]["items"][1]["meta"], "abc123"); + assert!(rendered.get("how_to_try").is_none()); + } + + #[test] + fn dry_run_backfill_selects_unpublished_release_window_prs() { + let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); + let release_delta_path = temp_dir.path().join("release-delta.json"); + let signals_dir = temp_dir.path().join("signals"); + let mut release_delta = valid_release_delta(); + + release_delta["compare"]["pr_numbers"] = serde_json::json!([22_414, 22_415, 22_416]); + release_delta["comparisons"][0]["compare"]["pr_numbers"] = + serde_json::json!([22_414, 22_415, 22_416]); + + fs::create_dir_all(&signals_dir).expect("signals directory should be created"); + fs::write(release_delta_path.as_path(), release_delta.to_string()) + .expect("release delta should be written"); + fs::write(signals_dir.join("published.json"), valid_signal().to_string()) + .expect("signal should be written"); + + let report = radar::backfill_release_range(&RadarBackfillReleaseRangeRequest { + repo: "openai/codex".into(), + release_delta: release_delta_path, + stable_tag: None, + preview_tag: None, + signals_dir, + bundles_dir: temp_dir.path().join("bundles"), + analysis_dir: temp_dir.path().join("analysis"), + token_env: None, + codex_bin: "codex".into(), + model: None, + max_prs: Some(1), + dry_run: true, + refresh_release_delta_first: false, + refresh_stable_limit: None, + refresh_preview_limit: None, + refresh_pair_limit: None, + python_bin: "python3".into(), + }) + .expect("dry-run backfill should select unpublished PRs"); + + assert_eq!(report.stable_tag, "rust-v0.1.0"); + assert_eq!(report.preview_tag, "rust-v0.2.0-alpha.1"); + assert_eq!(report.target_prs, vec![22_415]); + assert_eq!(report.created, 0); + assert!(report.dry_run); + } + #[test] fn ledger_bootstrap_migrates_social_draft_artifact_kind() { let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); diff --git a/dev/skills/README.md b/dev/skills/README.md index c6a53769..bc9e605d 100644 --- a/dev/skills/README.md +++ b/dev/skills/README.md @@ -18,7 +18,7 @@ follow-up work: 3. `codex-release-analysis`: evaluate release or changelog material against commits, PRs, release-delta artifacts, and already-published Decodex signals. 4. `github-signal`: turn the reviewed GitHub bundle and analysis result into the - `analysis_draft` JSON consumed by `scripts/github/render_signal_entry.py`. + `analysis_draft` JSON consumed by `decodex radar render-signal`. 5. `x-post-publisher`: turn evidence-backed Radar output into a low-frequency `social_post/v1` publication, block, skip, or failure record for `@decodexspace`. 6. `rate-limit-reset-watch`: review today's `@thsottiaux` X posts with AI semantic diff --git a/dev/skills/codex-release-analysis/SKILL.md b/dev/skills/codex-release-analysis/SKILL.md index 4a35da47..65713362 100644 --- a/dev/skills/codex-release-analysis/SKILL.md +++ b/dev/skills/codex-release-analysis/SKILL.md @@ -55,8 +55,8 @@ When the target is an OpenAI Codex release or prerelease: asks for a specific tag pair. 3. Start from existing `signal_entry/v1`, `upstream_impact/v1`, and recent commit/PR analyses that match the compare range. -4. Use `compare.pr_numbers` and `compare.commit_shas` to find gaps that still need - code analysis. +4. Use `decodex radar backfill-release-range --dry-run` to find `compare.pr_numbers` + gaps that still need code analysis. 5. Group findings by reader value: useful now, important for Decodex Control Plane, deprecated/removed behavior, and watch-only changes. 6. Publish release or prerelease X reporting only after the summary is grounded in @@ -101,5 +101,5 @@ Return: `operator_impact`, `release_rollup`, or `watch_note` Promote durable conclusions into existing artifacts only: `upstream_impact/v1`, -`analysis_draft` plus rendered `signal_entry/v1`, refreshed `release_delta/v1`, or -`social_post/v1`. +Codex-owned `analysis_draft` plus `decodex radar render-signal` output, refreshed +`release_delta/v1`, or `social_post/v1`. diff --git a/dev/skills/github-signal/SKILL.md b/dev/skills/github-signal/SKILL.md index 34065a33..4d1a18fa 100644 --- a/dev/skills/github-signal/SKILL.md +++ b/dev/skills/github-signal/SKILL.md @@ -1,6 +1,6 @@ --- name: github-signal -description: Use when turning a reviewed GitHub bundle and code-analysis result into a Decodex signal draft, especially for writing or updating the local editorial analysis JSON that feeds `scripts/github/render_signal_entry.py`. +description: Use when turning a reviewed GitHub bundle and code-analysis result into a Decodex signal draft, especially for writing or updating the local editorial analysis JSON that feeds `decodex radar render-signal`. --- # Decodex GitHub Signal @@ -10,10 +10,10 @@ This is a Decodex repository-development instruction surface, not a complete user-facing plugin skill, and it must not be packaged with the installable Decodex plugin. -This skill does not replace the deterministic scripts. It tells Codex how to read a +This skill does not replace the deterministic Radar CLI. It tells Codex how to read a reviewed bundle and in-session code-analysis result, decide whether the change deserves -publication, and draft the analysis JSON that the repo already renders into a final -`signal_entry/v1`. +publication, and draft the analysis JSON that `decodex radar render-signal` renders +into a final `signal_entry/v1`. ## Read before drafting @@ -147,7 +147,7 @@ Write a JSON analysis draft with these fields: 4. Draft the `analysis_draft` JSON under `artifacts/github/analysis/`. 5. Draft or update an `upstream_impact/v1` artifact when the change affects Control Plane or Publisher follow-up. -6. Render the final signal entry with the repo script. +6. Render the final signal entry with `decodex radar render-signal`. 7. Validate the published signal collection and site build. ## Commands @@ -155,13 +155,13 @@ Write a JSON analysis draft with these fields: Validate a bundle: ```bash -python3 scripts/github/validate_change_bundle.py artifacts/github/bundles/.json +decodex radar bundle validate artifacts/github/bundles/.json ``` Render the final signal entry after drafting: ```bash -python3 scripts/github/render_signal_entry.py \ +decodex radar render-signal \ --bundle artifacts/github/bundles/.json \ --analysis artifacts/github/analysis/.analysis.json \ --out site/src/content/signals/.json diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md index dd86703d..e3c47227 100644 --- a/docs/reference/workspace-layout.md +++ b/docs/reference/workspace-layout.md @@ -80,10 +80,12 @@ Those runtime and operator surfaces stay in `apps/decodex/` and `docs/spec/`. continuous Radar entrypoint: it scans recent upstream commits, resolves them back to PRs when possible, records local ledger state, and writes an `upstream_review_queue/v1` artifact for Codex automation. It does not run Codex or -render public signals. `backfill_release_range.py` fills gaps for release-window -summaries when an operator or automation chooses to generate signal content. Generated -GitHub bundles and analysis drafts live under `artifacts/github/` and must stay -explicit and checked into the repository when promoted into Publisher content. +render public signals. `decodex radar render-signal` renders published signals from +Codex-owned analysis drafts, and `decodex radar backfill-release-range` fills gaps for +release-window summaries when an operator or automation chooses to generate signal +content. Generated GitHub bundles and analysis drafts live under `artifacts/github/` +and must stay explicit and checked into the repository when promoted into Publisher +content. Raw bundles and analysis drafts are hot artifacts with a 21-day Git retention window. Older raw batches move to dedicated GitHub Release assets, with recovery manifests kept diff --git a/docs/runbook/local-github-signal-workflow.md b/docs/runbook/local-github-signal-workflow.md index fc44468c..dec80e14 100644 --- a/docs/runbook/local-github-signal-workflow.md +++ b/docs/runbook/local-github-signal-workflow.md @@ -45,7 +45,8 @@ Outputs: app update, or changelog entry. 6. Run final signal drafting with `dev/skills/github-signal/` and save the `analysis_draft` JSON under `artifacts/github/analysis/`. -7. Render the resulting signal entry into `site/src/content/signals/`. +7. Render the resulting signal entry into `site/src/content/signals/` with + `decodex radar render-signal`. 8. Validate the signal entry shape and collection consistency. 9. Classify upstream impact when the change may affect Control Plane or Publisher. 10. Regenerate the release-delta artifact so the homepage compares release windows @@ -64,25 +65,25 @@ Build a PR-first bundle: ```bash decodex radar bundle build \ --repo openai/codex \ - --pr 15222 \ - --out artifacts/github/bundles/openai-codex-pr-15222.json + --pr 22414 \ + --out artifacts/github/bundles/openai-codex-pr-22414.json ``` Validate the bundle: ```bash decodex radar bundle validate \ - artifacts/github/bundles/openai-codex-pr-15222.json + artifacts/github/bundles/openai-codex-pr-22414.json ``` -Render a final signal entry from the reviewed bundle plus the local editorial -draft: +Render a final signal entry from the reviewed bundle plus the Codex-owned +`analysis_draft`: ```bash -python3 scripts/github/render_signal_entry.py \ - --bundle artifacts/github/bundles/openai-codex-pr-15222.json \ - --analysis artifacts/github/analysis/openai-codex-pr-15222.analysis.json \ - --out site/src/content/signals/openai-codex-pr-15222.json +decodex radar render-signal \ + --bundle artifacts/github/bundles/openai-codex-pr-22414.json \ + --analysis artifacts/github/analysis/openai-codex-pr-22414.analysis.json \ + --out site/src/content/signals/openai-codex-pr-22414.json ``` Validate the published signal entries and the site collection: @@ -107,7 +108,7 @@ Preview unpublished PRs from a selected release compare range without generating content: ```bash -python3 scripts/github/backfill_release_range.py \ +decodex radar backfill-release-range \ --repo openai/codex \ --stable-tag rust-v0.130.0 \ --preview-tag rust-v0.131.0-alpha.9 \ @@ -116,13 +117,16 @@ python3 scripts/github/backfill_release_range.py \ Use release-range backfill to fill gaps in the accumulated commit/PR analysis before a release or prerelease summary. It should supplement continuous commit tracking, not -replace it. +replace it. Execute mode is still a Codex automation or local operator path: Rust +selects the release-window gaps and sequences deterministic helper boundaries, while +`scripts/github/run_codex_analysis.py` remains the read-only Codex AI helper that +creates validated `analysis_draft` artifacts. The repository already includes a real sample for this flow: -- bundle: `artifacts/github/bundles/openai-codex-pr-15222.json` -- editorial draft: `artifacts/github/analysis/openai-codex-pr-15222.analysis.json` -- rendered signal: `site/src/content/signals/openai-codex-pr-15222.json` +- bundle: `artifacts/github/bundles/openai-codex-pr-22414.json` +- editorial draft: `artifacts/github/analysis/openai-codex-pr-22414.analysis.json` +- rendered signal: `site/src/content/signals/openai-codex-pr-22414.json` Repo-local editorial instruction entrypoint: @@ -184,10 +188,13 @@ The current Decodex boundary is: - GitHub Actions: deterministic upstream commit discovery, PR mapping, review-queue refresh, release-delta refresh, validation, and commit/push of changed metadata. + Actions must not run Codex AI analysis, create `analysis_draft`, or execute release + backfills that cross that AI boundary. - Codex automation: AI source review, compatibility judgment, Publisher judgment, - social publication, and any promotion into signal or follow-up artifacts. + social publication, `analysis_draft` creation, `decodex radar render-signal`, and + any promotion into signal or follow-up artifacts. - local operator sessions: manual editorial review, batch backfills, prompt iteration, - and public-content audit. + `decodex radar backfill-release-range`, and public-content audit. The GitHub Actions paths assume: diff --git a/scripts/github/README.md b/scripts/github/README.md index 4ad786dd..f6cac658 100644 --- a/scripts/github/README.md +++ b/scripts/github/README.md @@ -26,9 +26,14 @@ Rust CLI foundation: bundle validation. - `decodex radar ledger ...` replaces `radar_ledger.py` bootstrap, ingest, ingest-existing, artifact-link, and summary operations. +- `decodex radar render-signal` renders `signal_entry/v1` from a validated + `github_change_bundle/v1` plus Codex-owned `analysis_draft`. +- `decodex radar backfill-release-range` selects release-window signal gaps and can + sequence the remaining helper boundaries for local or Codex automation backfills. -The Python scripts remain checked shared contracts during migration. Do not delete them -until the final cleanup issue. +The Python scripts remain compatibility and non-ported helper boundaries until cleanup +issues remove them. In particular, `run_codex_analysis.py` is the explicit Codex AI +boundary for creating `analysis_draft`; it is not a GitHub Actions entrypoint. Current checked contracts: @@ -52,16 +57,16 @@ Example flow: ```bash decodex radar bundle build \ --repo openai/codex \ - --pr 15222 \ - --out artifacts/github/bundles/openai-codex-pr-15222.json + --pr 22414 \ + --out artifacts/github/bundles/openai-codex-pr-22414.json -python3 scripts/github/render_signal_entry.py \ - --bundle artifacts/github/bundles/openai-codex-pr-15222.json \ - --analysis artifacts/github/analysis/openai-codex-pr-15222.analysis.json \ - --out site/src/content/signals/openai-codex-pr-15222.json +decodex radar render-signal \ + --bundle artifacts/github/bundles/openai-codex-pr-22414.json \ + --analysis artifacts/github/analysis/openai-codex-pr-22414.analysis.json \ + --out site/src/content/signals/openai-codex-pr-22414.json python3 scripts/github/validate_signal_entry.py \ - site/src/content/signals/openai-codex-pr-15222.json + site/src/content/signals/openai-codex-pr-22414.json ``` Continuous upstream Radar sync: @@ -88,7 +93,7 @@ decodex radar ledger ingest-existing Release-window gap fill: ```bash -python3 scripts/github/backfill_release_range.py \ +decodex radar backfill-release-range \ --repo openai/codex \ --stable-tag rust-v0.130.0 \ --preview-tag rust-v0.131.0-alpha.9 \ @@ -98,7 +103,7 @@ python3 scripts/github/backfill_release_range.py \ These scripts stay deterministic on purpose. GitHub Actions may refresh upstream queues, release deltas, and validation. Codex automation owns AI review of queued subjects and may then promote source-backed conclusions into `upstream_impact/v1`, -`analysis_draft`, rendered `signal_entry/v1`, or `social_post/v1`. +`analysis_draft`, `decodex radar render-signal` output, or `social_post/v1`. Repo-local skills under `dev/skills/` are reasoning instructions for the Codex analysis step and for manual Radar/Publisher work. They do not introduce extra