# Skill Normalized Result And Dashboard Local Reader Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a stable normalized-result pipeline in `claw-new`, a local HTTP reader in `digital-employee`, and a dashboard data path that consumes normalized skill results instead of raw `run-record.json`. **Architecture:** `claw-new` adds a result normalizer that reads scheduled skill `run-record.json` files and writes stable normalized snapshots plus `index.json`. `digital-employee` adds an embedded localhost-only Node reader that serves those normalized files over HTTP, while the Vue dashboard replaces static mock imports with a polling data layer that consumes the local API and supports `count_snapshot` and `detail_snapshot`. **Tech Stack:** Rust (`serde`, `serde_json`, existing `sg_claw` CLI), Node/Express in `digital-employee`, Vue 2 + Vuex + Vue CLI dev proxy. --- ## File Structure ### `claw-new` files **Create:** - `src/result_normalization/mod.rs` - `src/result_normalization/contract.rs` - `src/result_normalization/registry.rs` - `src/result_normalization/writer.rs` - `src/result_normalization/index_builder.rs` - `src/result_normalization/extractors/mod.rs` - `src/result_normalization/extractors/available_balance.rs` - `src/result_normalization/extractors/archive_workorder.rs` - `src/result_normalization/extractors/command_center_fee_control.rs` - `src/result_normalization/extractors/sgcc_todo_crawler.rs` - `tests/result_normalization_contract_test.rs` - `tests/result_normalization_writer_test.rs` - `tests/result_normalization_extractors_test.rs` - `tests/result_normalization_cli_test.rs` - `tests/fixtures/result_normalization/available-balance-below-zero-monitor.run-record.json` - `tests/fixtures/result_normalization/archive-workorder-grid-push-monitor.run-record.json` - `tests/fixtures/result_normalization/command-center-fee-control-monitor.run-record.json` - `tests/fixtures/result_normalization/sgcc-todo-crawler.run-record.json` **Modify:** - `src/lib.rs` - `src/bin/sg_claw.rs` - `Cargo.toml` ### `digital-employee` files **Create:** - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/index.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/config.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/routes/results.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/fileRepository.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/resultStore.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/validators.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/api/results.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/modules/results.js` **Modify:** - `D:/data/ideaSpace/rust/sgClaw/digital-employee/package.json` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/vue.config.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/index.js` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/views/Dashboard.vue` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/components/WorkReport.vue` - `D:/data/ideaSpace/rust/sgClaw/digital-employee/README.md` **Test / validate:** - `D:/data/ideaSpace/rust/sgClaw/digital-employee` dev server + local API manual smoke test --- ### Task 1: Add Public Normalized Result Contract In `claw-new` **Files:** - Create: `src/result_normalization/contract.rs` - Create: `src/result_normalization/mod.rs` - Modify: `src/lib.rs` - Test: `tests/result_normalization_contract_test.rs` - [ ] **Step 1: Write the failing contract test** Create `tests/result_normalization_contract_test.rs` with tests that force the public schema shape to exist before implementation: ```rust use sgclaw::result_normalization::contract::{ NormalizedResult, NormalizedResultType, ResultFreshness, ResultMetric, ResultSource, ResultStatus, }; #[test] fn normalized_result_contract_serializes_required_envelope_fields() { let result = NormalizedResult { schema_version: "1.0".to_string(), skill_id: "available-balance-below-zero-monitor".to_string(), skill_name: "可用电费小于零监测".to_string(), category: "monitor".to_string(), result_type: NormalizedResultType::CountSnapshot, observed_at: "2026-04-25T19:46:24+08:00".to_string(), generated_at: "2026-04-25T19:46:26+08:00".to_string(), status: ResultStatus::Ok, freshness: ResultFreshness { stale_after_seconds: 900, is_stale: false, }, summary: "2026-04-25 19:46:24--可用电费小于零监测检测到【数量】:4265".to_string(), metric: ResultMetric { label: "数量".to_string(), value: 4265, unit: "items".to_string(), }, payload: None, diagnostics: serde_json::json!({}), source: ResultSource { kind: "run_record".to_string(), run_record_path: r"D:\desk\sgclaw\sgclaw\results\available-balance-below-zero-monitor.run-record.json".to_string(), run_record_mtime: "2026-04-25T19:46:26+08:00".to_string(), extractor_version: "1.0".to_string(), }, }; let value = serde_json::to_value(&result).unwrap(); assert_eq!(value["schemaVersion"], "1.0"); assert_eq!(value["skillId"], "available-balance-below-zero-monitor"); assert_eq!(value["resultType"], "count_snapshot"); assert_eq!(value["status"], "ok"); assert_eq!(value["metric"]["value"], 4265); } #[test] fn normalized_result_type_serializes_public_names() { assert_eq!( serde_json::to_string(&NormalizedResultType::CountSnapshot).unwrap(), "\"count_snapshot\"" ); assert_eq!( serde_json::to_string(&NormalizedResultType::DetailSnapshot).unwrap(), "\"detail_snapshot\"" ); } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_contract_test ``` Expected: ```text FAIL unresolved import `sgclaw::result_normalization` ``` - [ ] **Step 3: Write the minimal public contract** Create `src/result_normalization/mod.rs`: ```rust pub mod contract; pub mod extractors; pub mod index_builder; pub mod registry; pub mod writer; ``` Create `src/result_normalization/contract.rs`: ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NormalizedResultType { CountSnapshot, DetailSnapshot, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResultStatus { Ok, Empty, SoftError, Error, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResultFreshness { pub stale_after_seconds: u64, pub is_stale: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResultMetric { pub label: String, pub value: i64, pub unit: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResultSource { pub kind: String, pub run_record_path: String, pub run_record_mtime: String, pub extractor_version: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NormalizedResult { pub schema_version: String, pub skill_id: String, pub skill_name: String, pub category: String, pub result_type: NormalizedResultType, pub observed_at: String, pub generated_at: String, pub status: ResultStatus, pub freshness: ResultFreshness, pub summary: String, pub metric: ResultMetric, #[serde(skip_serializing_if = "Option::is_none")] pub payload: Option, pub diagnostics: serde_json::Value, pub source: ResultSource, } ``` Modify `src/lib.rs`: ```rust pub mod result_normalization; ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_contract_test ``` Expected: ```text PASS 2 passed ``` - [ ] **Step 5: Commit** ```bash git add src/lib.rs src/result_normalization/mod.rs src/result_normalization/contract.rs tests/result_normalization_contract_test.rs git commit -m "feat: add normalized result contract" ``` ### Task 2: Add Registry And `index.json` Models **Files:** - Create: `src/result_normalization/registry.rs` - Create: `src/result_normalization/index_builder.rs` - Test: `tests/result_normalization_contract_test.rs` - [ ] **Step 1: Extend tests to lock registry and index schema** Append to `tests/result_normalization_contract_test.rs`: ```rust use sgclaw::result_normalization::contract::{NormalizedResultsIndex, NormalizedResultsIndexEntry}; use sgclaw::result_normalization::registry::skill_registry; #[test] fn registry_contains_four_initial_skills() { let ids: Vec<_> = skill_registry().iter().map(|item| item.skill_id.as_str()).collect(); assert_eq!( ids, vec![ "archive-workorder-grid-push-monitor", "available-balance-below-zero-monitor", "command-center-fee-control-monitor", "sgcc-todo-crawler" ] ); } #[test] fn index_contract_serializes_skill_entries() { let index = NormalizedResultsIndex { schema_version: "1.0".to_string(), generated_at: "2026-04-25T19:48:00+08:00".to_string(), revision: "2026-04-25T19:48:00+08:00".to_string(), skills: vec![NormalizedResultsIndexEntry { skill_id: "available-balance-below-zero-monitor".to_string(), skill_name: "可用电费小于零监测".to_string(), category: "monitor".to_string(), result_type: NormalizedResultType::CountSnapshot, status: ResultStatus::Ok, observed_at: "2026-04-25T19:46:24+08:00".to_string(), metric: ResultMetric { label: "数量".to_string(), value: 4265, unit: "items".to_string(), }, summary: "2026-04-25 19:46:24--可用电费小于零监测检测到【数量】:4265".to_string(), current_file: "latest/available-balance-below-zero-monitor.json".to_string(), last_good_file: "last-good/available-balance-below-zero-monitor.json".to_string(), }], }; let value = serde_json::to_value(index).unwrap(); assert_eq!(value["skills"][0]["currentFile"], "latest/available-balance-below-zero-monitor.json"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_contract_test ``` Expected: ```text FAIL cannot find type `NormalizedResultsIndex` cannot find function `skill_registry` ``` - [ ] **Step 3: Implement registry and index models** Extend `src/result_normalization/contract.rs` with: ```rust #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NormalizedResultsIndexEntry { pub skill_id: String, pub skill_name: String, pub category: String, pub result_type: NormalizedResultType, pub status: ResultStatus, pub observed_at: String, pub metric: ResultMetric, pub summary: String, pub current_file: String, pub last_good_file: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NormalizedResultsIndex { pub schema_version: String, pub generated_at: String, pub revision: String, pub skills: Vec, } ``` Create `src/result_normalization/registry.rs`: ```rust use crate::result_normalization::contract::NormalizedResultType; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkillRegistryItem { pub skill_id: &'static str, pub skill_name: &'static str, pub category: &'static str, pub result_type: NormalizedResultType, pub stale_after_seconds: u64, } pub fn skill_registry() -> Vec { vec![ SkillRegistryItem { skill_id: "archive-workorder-grid-push-monitor", skill_name: "归档工单配网推送监测", category: "monitor", result_type: NormalizedResultType::CountSnapshot, stale_after_seconds: 900, }, SkillRegistryItem { skill_id: "available-balance-below-zero-monitor", skill_name: "可用电费小于零监测", category: "monitor", result_type: NormalizedResultType::CountSnapshot, stale_after_seconds: 900, }, SkillRegistryItem { skill_id: "command-center-fee-control-monitor", skill_name: "指挥中心费控异常监测", category: "monitor", result_type: NormalizedResultType::CountSnapshot, stale_after_seconds: 900, }, SkillRegistryItem { skill_id: "sgcc-todo-crawler", skill_name: "国网待办抓取", category: "crawler", result_type: NormalizedResultType::DetailSnapshot, stale_after_seconds: 900, }, ] } ``` Create `src/result_normalization/index_builder.rs`: ```rust use crate::result_normalization::contract::{NormalizedResult, NormalizedResultsIndex, NormalizedResultsIndexEntry}; pub fn build_index(generated_at: String, revision: String, items: &[NormalizedResult]) -> NormalizedResultsIndex { let skills = items .iter() .map(|item| NormalizedResultsIndexEntry { skill_id: item.skill_id.clone(), skill_name: item.skill_name.clone(), category: item.category.clone(), result_type: item.result_type.clone(), status: item.status.clone(), observed_at: item.observed_at.clone(), metric: item.metric.clone(), summary: item.summary.clone(), current_file: format!("latest/{}.json", item.skill_id), last_good_file: format!("last-good/{}.json", item.skill_id), }) .collect(); NormalizedResultsIndex { schema_version: "1.0".to_string(), generated_at, revision, skills, } } ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_contract_test ``` Expected: ```text PASS 4 passed ``` - [ ] **Step 5: Commit** ```bash git add src/result_normalization/contract.rs src/result_normalization/registry.rs src/result_normalization/index_builder.rs tests/result_normalization_contract_test.rs git commit -m "feat: add normalized result registry and index models" ``` ### Task 3: Implement Raw Fixture Extractors For Four Initial Skills **Files:** - Create: `src/result_normalization/extractors/mod.rs` - Create: `src/result_normalization/extractors/available_balance.rs` - Create: `src/result_normalization/extractors/archive_workorder.rs` - Create: `src/result_normalization/extractors/command_center_fee_control.rs` - Create: `src/result_normalization/extractors/sgcc_todo_crawler.rs` - Create: `tests/result_normalization_extractors_test.rs` - Create: `tests/fixtures/result_normalization/*.run-record.json` - [ ] **Step 1: Add failing extractor tests with fixture expectations** Create `tests/result_normalization_extractors_test.rs`: ```rust use std::fs; use std::path::Path; use sgclaw::result_normalization::extractors::normalize_skill_run_record; fn fixture(name: &str) -> String { let path = Path::new("tests/fixtures/result_normalization").join(name); fs::read_to_string(path).unwrap() } #[test] fn available_balance_extractor_reads_raw_merged_count() { let raw = fixture("available-balance-below-zero-monitor.run-record.json"); let result = normalize_skill_run_record( "available-balance-below-zero-monitor", r"D:\desk\sgclaw\sgclaw\results\available-balance-below-zero-monitor.run-record.json", &raw, "2026-04-25T19:46:26+08:00", "2026-04-25T19:46:26+08:00", ) .unwrap(); assert_eq!(result.metric.value, 4265); assert_eq!(result.status, sgclaw::result_normalization::contract::ResultStatus::Ok); } #[test] fn archive_workorder_extractor_marks_soft_error_when_query_soft_error_exists() { let raw = fixture("archive-workorder-grid-push-monitor.run-record.json"); let result = normalize_skill_run_record( "archive-workorder-grid-push-monitor", r"D:\desk\sgclaw\sgclaw\results\archive-workorder-grid-push-monitor.run-record.json", &raw, "2026-04-25T19:46:06+08:00", "2026-04-25T19:46:06+08:00", ) .unwrap(); assert_eq!(result.metric.value, 0); assert_eq!(result.status, sgclaw::result_normalization::contract::ResultStatus::SoftError); } #[test] fn command_center_extractor_uses_query_abnor_list_count() { let raw = fixture("command-center-fee-control-monitor.run-record.json"); let result = normalize_skill_run_record( "command-center-fee-control-monitor", r"D:\desk\sgclaw\sgclaw\results\command-center-fee-control-monitor.run-record.json", &raw, "2026-04-25T19:47:56+08:00", "2026-04-25T19:47:56+08:00", ) .unwrap(); assert_eq!(result.metric.value, 1); } #[test] fn sgcc_todo_crawler_extractor_projects_items_and_raw_items() { let raw = fixture("sgcc-todo-crawler.run-record.json"); let result = normalize_skill_run_record( "sgcc-todo-crawler", r"D:\desk\sgclaw\sgclaw\results\sgcc-todo-crawler.run-record.json", &raw, "2026-04-25T19:47:36+08:00", "2026-04-25T19:47:36+08:00", ) .unwrap(); assert!(result.payload.is_some()); let payload = result.payload.unwrap(); assert_eq!(payload["aggregates"]["total"], payload["items"].as_array().unwrap().len()); assert!(payload["rawItems"].as_array().unwrap().len() >= payload["items"].as_array().unwrap().len()); } ``` Copy local fixtures into: ```text tests/fixtures/result_normalization/ available-balance-below-zero-monitor.run-record.json archive-workorder-grid-push-monitor.run-record.json command-center-fee-control-monitor.run-record.json sgcc-todo-crawler.run-record.json ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_extractors_test ``` Expected: ```text FAIL cannot find function `normalize_skill_run_record` ``` - [ ] **Step 3: Implement extractor dispatch and four extractors** Create `src/result_normalization/extractors/mod.rs`: ```rust pub mod archive_workorder; pub mod available_balance; pub mod command_center_fee_control; pub mod sgcc_todo_crawler; use anyhow::{anyhow, Result}; use crate::result_normalization::contract::NormalizedResult; pub fn normalize_skill_run_record( skill_id: &str, run_record_path: &str, raw_json: &str, observed_at: &str, generated_at: &str, ) -> Result { match skill_id { "available-balance-below-zero-monitor" => available_balance::normalize(run_record_path, raw_json, observed_at, generated_at), "archive-workorder-grid-push-monitor" => archive_workorder::normalize(run_record_path, raw_json, observed_at, generated_at), "command-center-fee-control-monitor" => command_center_fee_control::normalize(run_record_path, raw_json, observed_at, generated_at), "sgcc-todo-crawler" => sgcc_todo_crawler::normalize(run_record_path, raw_json, observed_at, generated_at), other => Err(anyhow!("unsupported normalized skill id: {other}")), } } ``` Implement each extractor with these minimal rules: - `available_balance.rs` - read `auditPreview.detectReadDiagnostics.rawMergedCount` - build `diagnostics` with slice counts and traces - mark `SoftError` if `sliceErrors` non-empty or trace status contains non-`ok` - `archive_workorder.rs` - read `newItemCount`, fallback `filteredCount`, fallback `pending_count` - mark `SoftError` when `queryStatus != "ok"` or any trace has `soft_error` - `command_center_fee_control.rs` - read `queryAbnorListCount` - preserve supporting diagnostics like `getOrgTreeStatus` - mark `SoftError` when primary read absent but fallback still available - `sgcc_todo_crawler.rs` - read `decisionPreview.pendingList` - build projected `items`, original `rawItems`, and aggregates `{ total, unread, read }` Use shared helper patterns in each extractor: ```rust let root: serde_json::Value = serde_json::from_str(raw_json)?; let diagnostics = root .pointer("/auditPreview/detectReadDiagnostics") .cloned() .unwrap_or_else(|| serde_json::json!({})); ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_extractors_test ``` Expected: ```text PASS 4 passed ``` - [ ] **Step 5: Commit** ```bash git add src/result_normalization/extractors tests/result_normalization_extractors_test.rs tests/fixtures/result_normalization git commit -m "feat: add scheduled skill result extractors" ``` ### Task 4: Implement Writer And `last-good` Policy **Files:** - Create: `src/result_normalization/writer.rs` - Create: `tests/result_normalization_writer_test.rs` - [ ] **Step 1: Add failing writer tests** Create `tests/result_normalization_writer_test.rs`: ```rust use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; use sgclaw::result_normalization::contract::{ NormalizedResult, NormalizedResultType, ResultFreshness, ResultMetric, ResultSource, ResultStatus, }; use sgclaw::result_normalization::writer::write_normalized_result; fn temp_dir() -> std::path::PathBuf { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("normalized-writer-{nanos}")); fs::create_dir_all(&dir).unwrap(); dir } fn sample(status: ResultStatus) -> NormalizedResult { NormalizedResult { schema_version: "1.0".to_string(), skill_id: "available-balance-below-zero-monitor".to_string(), skill_name: "可用电费小于零监测".to_string(), category: "monitor".to_string(), result_type: NormalizedResultType::CountSnapshot, observed_at: "2026-04-25T19:46:24+08:00".to_string(), generated_at: "2026-04-25T19:46:26+08:00".to_string(), status, freshness: ResultFreshness { stale_after_seconds: 900, is_stale: false }, summary: "summary".to_string(), metric: ResultMetric { label: "数量".to_string(), value: 4265, unit: "items".to_string() }, payload: None, diagnostics: serde_json::json!({}), source: ResultSource { kind: "run_record".to_string(), run_record_path: "x".to_string(), run_record_mtime: "2026-04-25T19:46:26+08:00".to_string(), extractor_version: "1.0".to_string(), }, } } #[test] fn writer_creates_latest_and_history() { let dir = temp_dir(); write_normalized_result(&dir, &sample(ResultStatus::Ok), true).unwrap(); assert!(dir.join("latest/available-balance-below-zero-monitor.json").exists()); assert!(dir.join("last-good/available-balance-below-zero-monitor.json").exists()); } #[test] fn writer_does_not_replace_last_good_when_status_is_error() { let dir = temp_dir(); write_normalized_result(&dir, &sample(ResultStatus::Ok), true).unwrap(); let original = fs::read_to_string(dir.join("last-good/available-balance-below-zero-monitor.json")).unwrap(); write_normalized_result(&dir, &sample(ResultStatus::Error), false).unwrap(); let after = fs::read_to_string(dir.join("last-good/available-balance-below-zero-monitor.json")).unwrap(); assert_eq!(original, after); } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_writer_test ``` Expected: ```text FAIL cannot find function `write_normalized_result` ``` - [ ] **Step 3: Implement atomic writer** Create `src/result_normalization/writer.rs`: ```rust use std::fs; use std::path::{Path, PathBuf}; use anyhow::Result; use crate::result_normalization::contract::{NormalizedResult, ResultStatus}; fn write_atomic(path: &Path, body: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let tmp = path.with_extension("json.tmp"); fs::write(&tmp, body)?; fs::rename(tmp, path)?; Ok(()) } pub fn should_update_last_good(status: &ResultStatus, allow_soft_error_last_good: bool) -> bool { match status { ResultStatus::Ok | ResultStatus::Empty => true, ResultStatus::SoftError => allow_soft_error_last_good, ResultStatus::Error => false, } } pub fn write_normalized_result( normalized_root: &Path, result: &NormalizedResult, allow_soft_error_last_good: bool, ) -> Result { let latest_path = normalized_root.join("latest").join(format!("{}.json", result.skill_id)); let last_good_path = normalized_root.join("last-good").join(format!("{}.json", result.skill_id)); let history_path = normalized_root .join("history") .join(&result.skill_id) .join(result.observed_at[0..4].to_string()) .join(result.observed_at[5..7].to_string()) .join(format!("{}.json", result.generated_at.replace(':', "-"))); let body = serde_json::to_string_pretty(result)?; write_atomic(&latest_path, &body)?; if should_update_last_good(&result.status, allow_soft_error_last_good) { write_atomic(&last_good_path, &body)?; } write_atomic(&history_path, &body)?; Ok(latest_path) } ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_writer_test ``` Expected: ```text PASS 2 passed ``` - [ ] **Step 5: Commit** ```bash git add src/result_normalization/writer.rs tests/result_normalization_writer_test.rs git commit -m "feat: add normalized result writer" ``` ### Task 5: Wire A `normalize-results` CLI Into `sg_claw` **Files:** - Modify: `src/bin/sg_claw.rs` - Create: `tests/result_normalization_cli_test.rs` - [ ] **Step 1: Add failing CLI parser tests** Create `tests/result_normalization_cli_test.rs`: ```rust use std::process::Command; #[test] fn sg_claw_reports_missing_results_dir_for_normalize_results() { let output = Command::new(env!("CARGO_BIN_EXE_sg_claw")) .arg("--normalize-results") .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("missing required --results-dir for --normalize-results")); } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_cli_test ``` Expected: ```text FAIL stderr does not contain "missing required --results-dir for --normalize-results" ``` - [ ] **Step 3: Implement CLI parsing and execution hook** Modify `src/bin/sg_claw.rs`: 1. Add a new `NormalizeResultsCliConfig`: ```rust #[derive(Debug, Clone, PartialEq, Eq)] struct NormalizeResultsCliConfig { results_dir: PathBuf, skills: Option>, } ``` 2. Add parser: ```rust fn parse_normalize_results_cli(args: &[String]) -> Result, String> { let mut enabled = false; let mut results_dir = None; let mut skills = None; let mut iter = args.iter().cloned(); while let Some(arg) = iter.next() { match arg.as_str() { "--normalize-results" => enabled = true, "--results-dir" => results_dir = Some(PathBuf::from(next_arg(&mut iter, "--results-dir")?)), "--skills" => { let raw = next_arg(&mut iter, "--skills")?; skills = Some(raw.split(',').map(|item| item.trim().to_string()).filter(|item| !item.is_empty()).collect()); } _ => { if let Some(value) = arg.strip_prefix("--results-dir=") { results_dir = Some(PathBuf::from(value)); } else if let Some(value) = arg.strip_prefix("--skills=") { skills = Some(value.split(',').map(|item| item.trim().to_string()).filter(|item| !item.is_empty()).collect()); } } } } if !enabled { return Ok(None); } Ok(Some(NormalizeResultsCliConfig { results_dir: results_dir.ok_or_else(|| "missing required --results-dir for --normalize-results".to_string())?, skills, })) } ``` 3. Before scheduled-monitoring parsing in `main()`, add: ```rust let raw_args: Vec = std::env::args().skip(1).collect(); match parse_normalize_results_cli(&raw_args) { Ok(Some(config)) => { match sgclaw::result_normalization::run_normalize_results(&config.results_dir, config.skills.as_deref()) { Ok(_) => return ExitCode::SUCCESS, Err(err) => { eprintln!("normalize results failed: {err}"); return ExitCode::FAILURE; } } } Ok(None) => {} Err(err) => { eprintln!("sg_claw argument error: {err}"); return ExitCode::FAILURE; } } ``` 4. In `src/result_normalization/mod.rs`, add a temporary orchestration stub: ```rust use std::path::Path; pub fn run_normalize_results(_results_dir: &Path, _skills: Option<&[String]>) -> anyhow::Result<()> { Ok(()) } ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_cli_test ``` Expected: ```text PASS 1 passed ``` - [ ] **Step 5: Commit** ```bash git add src/bin/sg_claw.rs src/result_normalization/mod.rs tests/result_normalization_cli_test.rs git commit -m "feat: add normalize-results cli entry" ``` ### Task 6: Implement Full Normalization Orchestration And `index.json` Rebuild **Files:** - Modify: `src/result_normalization/mod.rs` - Modify: `src/result_normalization/index_builder.rs` - Test: `tests/result_normalization_cli_test.rs` - [ ] **Step 1: Extend CLI tests to require normalized outputs** Append to `tests/result_normalization_cli_test.rs`: ```rust use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_results_dir() -> std::path::PathBuf { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("normalize-results-cli-{nanos}")); fs::create_dir_all(&dir).unwrap(); dir } #[test] fn normalize_results_cli_generates_latest_and_index() { let dir = temp_results_dir(); fs::copy( "tests/fixtures/result_normalization/available-balance-below-zero-monitor.run-record.json", dir.join("available-balance-below-zero-monitor.run-record.json"), ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_sg_claw")) .arg("--normalize-results") .arg("--results-dir") .arg(&dir) .arg("--skills") .arg("available-balance-below-zero-monitor") .output() .unwrap(); assert!(output.status.success(), "stderr={}", String::from_utf8_lossy(&output.stderr)); assert!(dir.join("normalized/latest/available-balance-below-zero-monitor.json").exists()); assert!(dir.join("normalized/index.json").exists()); } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell cargo test --test result_normalization_cli_test ``` Expected: ```text FAIL normalized/latest/available-balance-below-zero-monitor.json does not exist ``` - [ ] **Step 3: Implement orchestration** Replace `src/result_normalization/mod.rs` with: ```rust pub mod contract; pub mod extractors; pub mod index_builder; pub mod registry; pub mod writer; use std::fs; use std::path::Path; use anyhow::{Context, Result}; use chrono::{DateTime, Local}; use crate::result_normalization::extractors::normalize_skill_run_record; use crate::result_normalization::index_builder::build_index; use crate::result_normalization::registry::skill_registry; use crate::result_normalization::writer::write_normalized_result; fn observed_time_from_metadata(metadata: &std::fs::Metadata) -> String { let dt: DateTime = DateTime::from(metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now())); dt.to_rfc3339() } pub fn run_normalize_results(results_dir: &Path, skills: Option<&[String]>) -> Result<()> { let normalized_root = results_dir.join("normalized"); let registry = skill_registry(); let selected: Vec<_> = registry .into_iter() .filter(|item| skills.map(|list| list.iter().any(|skill| skill == item.skill_id)).unwrap_or(true)) .collect(); let mut generated = Vec::new(); for item in selected { let run_record_path = results_dir.join(format!("{}.run-record.json", item.skill_id)); let raw = fs::read_to_string(&run_record_path) .with_context(|| format!("read run record failed: {}", run_record_path.display()))?; let metadata = fs::metadata(&run_record_path)?; let observed_at = observed_time_from_metadata(&metadata); let generated_at = chrono::Local::now().to_rfc3339(); let result = normalize_skill_run_record( item.skill_id, &run_record_path.display().to_string(), &raw, &observed_at, &generated_at, )?; write_normalized_result(&normalized_root, &result, false)?; generated.push(result); } let revision = chrono::Local::now().to_rfc3339(); let index = build_index(revision.clone(), revision, &generated); let index_body = serde_json::to_string_pretty(&index)?; crate::result_normalization::writer::write_index(&normalized_root, &index_body)?; Ok(()) } ``` Extend `src/result_normalization/writer.rs` with: ```rust pub fn write_index(normalized_root: &Path, body: &str) -> Result<()> { let index_path = normalized_root.join("index.json"); write_atomic(&index_path, body) } ``` - [ ] **Step 4: Run test to verify it passes** Run: ```powershell cargo test --test result_normalization_cli_test ``` Expected: ```text PASS 2 passed ``` - [ ] **Step 5: Commit** ```bash git add src/result_normalization/mod.rs src/result_normalization/writer.rs src/result_normalization/index_builder.rs tests/result_normalization_cli_test.rs git commit -m "feat: orchestrate normalized result generation" ``` ### Task 7: Add `digital-employee` Local Reader Service **Files:** - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/index.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/config.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/routes/results.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/fileRepository.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/resultStore.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/validators.js` - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/package.json` - [ ] **Step 1: Add server dependencies and scripts** Modify `D:/data/ideaSpace/rust/sgClaw/digital-employee/package.json`: ```json { "scripts": { "serve": "vue-cli-service serve", "serve:web": "vue-cli-service serve", "serve:api": "node server/index.js", "dev": "concurrently \"npm run serve:api\" \"npm run serve:web\"", "build": "vue-cli-service build", "build:web": "vue-cli-service build", "lint": "vue-cli-service lint" }, "dependencies": { "concurrently": "^9.0.1", "core-js": "^3.8.3", "echarts": "^6.0.0", "element-ui": "^2.15.14", "express": "^4.21.2", "vue": "^2.6.14", "vue-router": "^3.5.1", "vuex": "^3.6.2" } } ``` - [ ] **Step 2: Create local reader configuration** Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/config.js`: ```javascript const path = require('path') module.exports = { host: process.env.LOCAL_READER_HOST || '127.0.0.1', port: Number(process.env.LOCAL_READER_PORT || 31337), resultsDir: process.env.SGCLAW_RESULTS_DIR || 'D:\\desk\\sgclaw\\sgclaw\\results', normalizedDir: process.env.SGCLAW_NORMALIZED_DIR || path.join(process.env.SGCLAW_RESULTS_DIR || 'D:\\desk\\sgclaw\\sgclaw\\results', 'normalized'), staleAfterSeconds: Number(process.env.RESULT_STALE_AFTER_SECONDS || 900) } ``` - [ ] **Step 3: Create validation and repository helpers** Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/validators.js`: ```javascript function assertSkillId(skillId) { if (!/^[a-z0-9-]+$/.test(skillId)) { const error = new Error(`invalid skill id: ${skillId}`) error.statusCode = 400 throw error } } module.exports = { assertSkillId } ``` Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/fileRepository.js`: ```javascript const fs = require('fs') const path = require('path') function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } function resolveNormalizedPath(normalizedDir, ...parts) { return path.join(normalizedDir, ...parts) } module.exports = { readJson, resolveNormalizedPath } ``` - [ ] **Step 4: Create result store and routes** Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/services/resultStore.js`: ```javascript const fs = require('fs') const path = require('path') const { readJson, resolveNormalizedPath } = require('./fileRepository') const { assertSkillId } = require('./validators') function safeRead(filePath) { return fs.existsSync(filePath) ? readJson(filePath) : null } function createResultStore(config) { function readIndex() { return readJson(resolveNormalizedPath(config.normalizedDir, 'index.json')) } function readSkill(skillId) { assertSkillId(skillId) return { current: safeRead(resolveNormalizedPath(config.normalizedDir, 'latest', `${skillId}.json`)), lastGood: safeRead(resolveNormalizedPath(config.normalizedDir, 'last-good', `${skillId}.json`)) } } function readSkillItems(skillId) { const { current } = readSkill(skillId) return current?.payload?.items || [] } function readSkillRawItems(skillId) { const { current } = readSkill(skillId) return current?.payload?.rawItems || [] } function readHistory(skillId, limit = 30) { assertSkillId(skillId) const dir = resolveNormalizedPath(config.normalizedDir, 'history', skillId) if (!fs.existsSync(dir)) return [] const paths = [] for (const year of fs.readdirSync(dir)) { const yearDir = path.join(dir, year) for (const month of fs.readdirSync(yearDir)) { const monthDir = path.join(yearDir, month) for (const file of fs.readdirSync(monthDir)) { paths.push(path.join(monthDir, file)) } } } return paths .sort() .reverse() .slice(0, limit) .map(readJson) } return { readIndex, readSkill, readSkillItems, readSkillRawItems, readHistory } } module.exports = { createResultStore } ``` Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/routes/results.js`: ```javascript const express = require('express') function createResultsRouter(store, config) { const router = express.Router() router.get('/health', (req, res) => { let revision = null try { revision = store.readIndex().revision } catch (_) {} res.json({ ok: true, host: config.host, port: config.port, resultsDir: config.resultsDir, normalizedDir: config.normalizedDir, revision }) }) router.get('/results', (req, res, next) => { try { res.json(store.readIndex()) } catch (error) { next(error) } }) router.get('/results/:skillId', (req, res, next) => { try { res.json(store.readSkill(req.params.skillId)) } catch (error) { next(error) } }) router.get('/results/:skillId/items', (req, res, next) => { try { res.json({ items: store.readSkillItems(req.params.skillId) }) } catch (error) { next(error) } }) router.get('/results/:skillId/raw-items', (req, res, next) => { try { res.json({ items: store.readSkillRawItems(req.params.skillId) }) } catch (error) { next(error) } }) router.get('/results/:skillId/history', (req, res, next) => { try { const limit = Number(req.query.limit || 30) res.json({ items: store.readHistory(req.params.skillId, limit) }) } catch (error) { next(error) } }) return router } module.exports = { createResultsRouter } ``` Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/server/index.js`: ```javascript const express = require('express') const config = require('./config') const { createResultStore } = require('./services/resultStore') const { createResultsRouter } = require('./routes/results') const app = express() const store = createResultStore(config) app.use('/api', createResultsRouter(store, config)) app.use((error, req, res, next) => { const status = error.statusCode || 500 res.status(status).json({ ok: false, error: error.message || 'local reader error' }) }) app.listen(config.port, config.host, () => { console.log(`local reader listening on http://${config.host}:${config.port}`) }) ``` - [ ] **Step 5: Install dependencies and smoke run the API** Run: ```powershell npm install npm run serve:api ``` Expected: ```text local reader listening on http://127.0.0.1:31337 ``` - [ ] **Step 6: Commit** ```bash git -C D:/data/ideaSpace/rust/sgClaw/digital-employee add package.json package-lock.json server git -C D:/data/ideaSpace/rust/sgClaw/digital-employee commit -m "feat: add local reader api" ``` ### Task 8: Add Dev Proxy And Frontend API Wrapper **Files:** - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/vue.config.js` - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/api/results.js` - [ ] **Step 1: Add API proxy** Modify `D:/data/ideaSpace/rust/sgClaw/digital-employee/vue.config.js`: ```javascript const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ transpileDependencies: true, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:31337', changeOrigin: false } } } }) ``` - [ ] **Step 2: Add browser API wrapper** Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/api/results.js`: ```javascript async function request(pathname) { const response = await fetch(pathname) if (!response.ok) { const body = await response.json().catch(() => ({})) throw new Error(body.error || `request failed: ${pathname}`) } return response.json() } export function fetchResultsIndex() { return request('/api/results') } export function fetchSkillResult(skillId) { return request(`/api/results/${skillId}`) } export function fetchSkillItems(skillId) { return request(`/api/results/${skillId}/items`) } ``` - [ ] **Step 3: Smoke the proxy path** Run: ```powershell npm run serve:api npm run serve:web ``` Expected: ```text browser requests to /api/results are proxied to http://127.0.0.1:31337/api/results ``` - [ ] **Step 4: Commit** ```bash git -C D:/data/ideaSpace/rust/sgClaw/digital-employee add vue.config.js src/api/results.js git -C D:/data/ideaSpace/rust/sgClaw/digital-employee commit -m "feat: add dashboard result api client" ``` ### Task 9: Add Vuex Results Module With Polling **Files:** - Create: `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/modules/results.js` - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/index.js` - [ ] **Step 1: Create results module** Create `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/modules/results.js`: ```javascript import { fetchResultsIndex, fetchSkillResult } from '@/api/results' const state = () => ({ index: null, skillDetails: {}, loading: false, error: null, pollTimer: null }) const mutations = { SET_LOADING(state, value) { state.loading = value }, SET_ERROR(state, value) { state.error = value }, SET_INDEX(state, value) { state.index = value }, SET_SKILL_DETAIL(state, { skillId, detail }) { state.skillDetails = { ...state.skillDetails, [skillId]: detail } }, SET_POLL_TIMER(state, timer) { state.pollTimer = timer }, CLEAR_POLL_TIMER(state) { if (state.pollTimer) clearInterval(state.pollTimer) state.pollTimer = null } } const actions = { async refreshIndex({ commit }) { commit('SET_LOADING', true) commit('SET_ERROR', null) try { const index = await fetchResultsIndex() commit('SET_INDEX', index) } catch (error) { commit('SET_ERROR', error.message) } finally { commit('SET_LOADING', false) } }, async loadSkillDetail({ commit }, skillId) { const detail = await fetchSkillResult(skillId) commit('SET_SKILL_DETAIL', { skillId, detail }) }, startPolling({ dispatch, commit, state }) { if (state.pollTimer) return dispatch('refreshIndex') const timer = setInterval(() => dispatch('refreshIndex'), 30000) commit('SET_POLL_TIMER', timer) }, stopPolling({ commit }) { commit('CLEAR_POLL_TIMER') } } const getters = { skills(state) { return state.index?.skills || [] } } export default { namespaced: true, state, mutations, actions, getters } ``` - [ ] **Step 2: Register the module** Modify `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/store/index.js`: ```javascript import results from './modules/results' export default new Vuex.Store({ modules: { results }, state: { ``` - [ ] **Step 3: Smoke the module** Run: ```powershell npm run dev ``` Expected: ```text dashboard boot does not throw "unknown namespace results" ``` - [ ] **Step 4: Commit** ```bash git -C D:/data/ideaSpace/rust/sgClaw/digital-employee add src/store/index.js src/store/modules/results.js git -C D:/data/ideaSpace/rust/sgClaw/digital-employee commit -m "feat: add dashboard results store" ``` ### Task 10: Replace Static Report Data With Live Results In Dashboard **Files:** - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/views/Dashboard.vue` - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/components/WorkReport.vue` - [ ] **Step 1: Start and stop polling with dashboard lifecycle** Modify `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/views/Dashboard.vue`: ```javascript export default { name: 'Dashboard', components: { Header, UserInfo, SkillManager, Avatar3D, WorkReport, SkinSwitch }, mounted() { this.$store.dispatch('results/startPolling') }, beforeDestroy() { this.$store.dispatch('results/stopPolling') } } ``` - [ ] **Step 2: Replace static imports in `WorkReport.vue` with store-driven data** Modify `D:/data/ideaSpace/rust/sgClaw/digital-employee/src/components/WorkReport.vue`: 1. Remove: ```javascript import reportData from '@/data/work-reports.json' import anomalyData from '@/data/anomaly-logs.json' ``` 2. Replace component data/computed with: ```javascript export default { name: 'WorkReport', data() { return { chart: null } }, computed: { skills() { return this.$store.getters['results/skills'] }, report() { const countSkills = this.skills.filter(item => item.resultType === 'count_snapshot') return { totalProcessed: countSkills.reduce((sum, item) => sum + Number(item.metric?.value || 0), 0), successCount: this.skills.filter(item => item.status === 'ok' || item.status === 'empty').length, failCount: this.skills.filter(item => item.status === 'error' || item.status === 'soft_error').length, trend: countSkills.map(item => ({ month: item.skillName, count: Number(item.metric?.value || 0) })) } }, anomalyLogs() { return this.skills .filter(item => item.status === 'error' || item.status === 'soft_error') .map((item, index) => ({ id: index + 1, message: `${item.skillName}:${item.summary}` })) } }, ``` 3. Add chart refresh watcher: ```javascript watch: { report: { deep: true, handler() { if (this.chart) this.initChart() } } }, ``` 4. Guard empty chart data: ```javascript data: this.report.trend.length ? this.report.trend.map(t => t.count) : [0] ``` - [ ] **Step 3: Smoke the live dashboard path** Run: ```powershell npm run dev ``` Expected: ```text dashboard loads WorkReport renders values from /api/results no static import dependency remains for live result cards ``` - [ ] **Step 4: Commit** ```bash git -C D:/data/ideaSpace/rust/sgClaw/digital-employee add src/views/Dashboard.vue src/components/WorkReport.vue git -C D:/data/ideaSpace/rust/sgClaw/digital-employee commit -m "feat: wire dashboard to normalized results" ``` ### Task 11: Document Operator Workflow And Validate End-To-End **Files:** - Modify: `D:/data/ideaSpace/rust/sgClaw/digital-employee/README.md` - Modify: `docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md` - [ ] **Step 1: Update `digital-employee/README.md` for local-reader workflow** Append this section: ```markdown ## Local skill result dashboard workflow 1. Copy raw `*.run-record.json` files into `D:\desk\sgclaw\sgclaw\results` 2. Run: ```powershell sg_claw.exe --normalize-results --results-dir D:\desk\sgclaw\sgclaw\results ``` 3. Start dashboard + local API: ```powershell npm install npm run dev ``` 4. Open the Vue dashboard and verify it loads `/api/results` ``` - [ ] **Step 2: Run end-to-end verification** Run: ```powershell cargo test --test result_normalization_contract_test cargo test --test result_normalization_extractors_test cargo test --test result_normalization_writer_test cargo test --test result_normalization_cli_test cargo build --bin sg_claw ``` Then in `D:/data/ideaSpace/rust/sgClaw/digital-employee` run: ```powershell npm install npm run build ``` Manual smoke: 1. copy at least `available-balance-below-zero-monitor.run-record.json` into `D:\desk\sgclaw\sgclaw\results` 2. run: ```powershell target\debug\sg_claw.exe --normalize-results --results-dir D:\desk\sgclaw\sgclaw\results --skills available-balance-below-zero-monitor ``` 3. start: ```powershell npm run serve:api npm run serve:web ``` 4. visit dashboard and verify: - `/api/health` returns `ok: true` - `/api/results` includes `available-balance-below-zero-monitor` - `WorkReport` shows non-static live values - [ ] **Step 3: Mark the plan complete and commit docs if needed** ```bash git add docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md D:/data/ideaSpace/rust/sgClaw/digital-employee/README.md git commit -m "docs: document normalized result implementation workflow" ```