Files
claw/docs/superpowers/plans/2026-04-26-skill-normalized-result-and-dashboard-local-reader-implementation-plan.md

1795 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Skill Normalized Result And Dashboard Local Reader Implementation Plan
> Historical note (2026-05-06): this plan describes the pre-extraction in-repo `normalized_writer` implementation that once lived inside `claw-new`. The canonical implementation now lives in `D:\data\ideaSpace\rust\sgClaw\normalized_writer`.
>
> Historical archive scope: paths such as `src/result_normalization/*`, `src/bin/normalized_writer.rs`, `sgclaw::result_normalization`, and `CARGO_BIN_EXE_normalized_writer` are preserved below only as historical implementation context.
>
> Historical execution status: this file is retained as an archive of the original in-repo execution sequence. The task lists and command blocks below are no longer valid implementation instructions for `claw-new`.
> **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:** Historical 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:** Historical architecture: `claw-new` added a result normalizer that read scheduled skill `run-record.json` files and wrote stable normalized snapshots plus `index.json`. `digital-employee` added an embedded localhost-only Node reader that served those normalized files over HTTP, while the Vue dashboard replaced static mock imports with a polling data layer that consumed the local API and supported `count_snapshot` and `detail_snapshot`.
**Tech Stack:** Historical stack: Rust (`serde`, `serde_json`, existing `sg_claw` CLI), Node/Express in `digital-employee`, Vue 2 + Vuex + Vue CLI dev proxy.
---
## Historical 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: Historical Execution - 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<serde_json::Value>,
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<NormalizedResultsIndexEntry>,
}
```
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<SkillRegistryItem> {
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<NormalizedResult> {
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<PathBuf> {
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<Vec<String>>,
}
```
2. Add parser:
```rust
fn parse_normalize_results_cli(args: &[String]) -> Result<Option<NormalizeResultsCliConfig>, 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<String> = 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<Local> = 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: Historical Execution - 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: Historical Execution - 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"
```