diff --git a/docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md b/docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md new file mode 100644 index 0000000..9edcec8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md @@ -0,0 +1,1789 @@ +# 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" +``` +