Implement S6 doctor and A1 daily-board with fixtures.

Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
This commit is contained in:
Ty
2026-07-27 12:41:40 -07:00
parent 0198ab6881
commit 15f6a6c713
24 changed files with 2011 additions and 40 deletions
+18 -5
View File
@@ -12,6 +12,8 @@ help:
@echo " make install - full install S0bS5" @echo " make install - full install S0bS5"
@echo " make install-s0-s2 - install stages S0b through S2" @echo " make install-s0-s2 - install stages S0b through S2"
@echo " make install-s3-s5 - install stages S3 through S5 (stack, sandbox, policy, skills)" @echo " make install-s3-s5 - install stages S3 through S5 (stack, sandbox, policy, skills)"
@echo " make doctor - health checks (S6)"
@echo " make verify - unit tests (fixtures, no model)"
@echo "" @echo ""
@echo "Staged install:" @echo "Staged install:"
@echo " make install-s0b - S0b only: Docker bootstrap" @echo " make install-s0b - S0b only: Docker bootstrap"
@@ -23,8 +25,6 @@ help:
@echo "" @echo ""
@echo "Not yet implemented (stubbed):" @echo "Not yet implemented (stubbed):"
@echo " make upgrade - product upgrade" @echo " make upgrade - product upgrade"
@echo " make doctor - health checks"
@echo " make verify - lint + tests + smoke (fixtures)"
@echo " make sync-design - list design pack paths" @echo " make sync-design - list design pack paths"
# ── Implemented targets ──────────────────────────────────────────────────── # ── Implemented targets ────────────────────────────────────────────────────
@@ -61,10 +61,23 @@ install-s4:
install-s5: install-s5:
@bash scripts/install.sh --stage s5 @bash scripts/install.sh --stage s5
# ── Stubbed targets (S6+ not yet implemented) ────────────────────────────── # ── S6: Doctor ─────────────────────────────────────────────────────────────
upgrade doctor verify: doctor:
@echo "not implemented — S6+ stages pending" >&2; exit 1 @bash scripts/doctor.sh
# ── Stubbed targets ────────────────────────────────────────────────────────
upgrade:
@echo "not implemented — upgrade pending" >&2; exit 1
# ── Verify ─────────────────────────────────────────────────────────────────
verify:
@echo "Running unit tests..."
@python3 -m pytest tests/unit/ -v
@echo ""
@echo "verify: OK"
sync-design: sync-design:
@echo "Design SSOT:" @echo "Design SSOT:"
+37 -4
View File
@@ -1,11 +1,44 @@
# Fixtures (scaffold) # Fixtures
Demo persona when implemented: **Claire Bennett**, **Lumina Hair Studio & Spa**. Demo persona: **Claire Bennett**, **Lumina Hair Studio & Spa**.
All fixture data is clearly labeled in skill output — never silent fake live data.
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `books/` | Sample QBO-shaped JSON | | `scheduling/` | Sample appointment data for daily-board |
| `books/` | Sample QBO-shaped JSON (future) |
| `media/` | Sample social media assets for vision tests | | `media/` | Sample social media assets for vision tests |
| `../recorded/` | Optional recorded responses (local only; do not commit secrets) | | `../recorded/` | Optional recorded responses (local only; do not commit secrets) |
No fixture JSON committed until **build** unless explicitly requested. ## Scheduling fixtures
| File | Description |
|------|-------------|
| `scheduling/claire_bennett_2026-07-28.json` | Sample salon day: 7 appointments (2 staff, 1 cancelled) |
### Fixture schema
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {"open": "09:00", "close": "18:00"},
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:30:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss"
}
]
}
```
**Status values:** `confirmed`, `pending`, `cancelled`, `completed`, `no_show`
@@ -0,0 +1,97 @@
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {
"open": "09:00",
"close": "18:00"
},
"staff": [
{
"name": "Claire Bennett",
"role": "owner-stylist"
},
{
"name": "Maya Torres",
"role": "colorist"
}
],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:30:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss. Allergic to ammonia."
},
{
"id": "APT-002",
"start": "2026-07-28T10:30:00",
"end": "2026-07-28T11:30:00",
"client_name": "Sarah Kim",
"service_name": "Blowout + Updo",
"staff_name": "Claire Bennett",
"status": "pending",
"needs_confirmation": true,
"notes": "Wedding guest — updo reference photo sent."
},
{
"id": "APT-003",
"start": "2026-07-28T11:00:00",
"end": "2026-07-28T12:30:00",
"client_name": "Jasmine Patel",
"service_name": "Root Touch-Up",
"staff_name": "Maya Torres",
"status": "confirmed",
"needs_confirmation": false,
"notes": "2B dark brown. Regular client."
},
{
"id": "APT-004",
"start": "2026-07-28T13:00:00",
"end": "2026-07-28T14:00:00",
"client_name": "Chris Nguyen",
"service_name": "Men's Cut",
"staff_name": "Claire Bennett",
"status": "pending",
"needs_confirmation": true,
"notes": "Running late from work — may be 15 min behind."
},
{
"id": "APT-005",
"start": "2026-07-28T14:00:00",
"end": "2026-07-28T15:30:00",
"client_name": "Priya Sharma",
"service_name": "Deep Conditioning Treatment",
"staff_name": "Maya Torres",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Post-color repair. Keratin-safe product only."
},
{
"id": "APT-006",
"start": "2026-07-28T15:30:00",
"end": "2026-07-28T17:00:00",
"client_name": "Aisha Williams",
"service_name": "Full Color + Style",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "First visit — consultation included."
},
{
"id": "APT-007",
"start": "2026-07-28T12:30:00",
"end": "2026-07-28T13:00:00",
"client_name": "Tom Bradley",
"service_name": "Quick Trim",
"staff_name": "Maya Torres",
"status": "cancelled",
"needs_confirmation": false,
"notes": "Client cancelled — rescheduled to next week."
}
]
}
+5 -4
View File
@@ -1,6 +1,6 @@
# Install # Install
**Status:** Stages S0S5 implemented. S6S7 pending. **Status:** Stages S0S6 implemented. S7 pending.
## Stages ## Stages
@@ -13,7 +13,7 @@
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented | | S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented | | S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented | | S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
| S6 | Host script | Doctor green | ⏳ Pending | | S6 | Host script | Doctor green | ✅ Implemented |
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending | | S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending |
## Platform commands (normative) ## Platform commands (normative)
@@ -156,13 +156,14 @@ make install
make install-s3-s5 make install-s3-s5
``` ```
## After install (S0S5) ## After install (S0S6)
- Verify `.env` values are correct for your environment. - Verify `.env` values are correct for your environment.
- Check policy: `nemohermes <name> policy-list` - Check policy: `nemohermes <name> policy-list`
- Continue with S6 (doctor) when implemented. - Run health checks: `make doctor`
- See [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install. - See [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install.
- See [design/scenarios.md](../design/scenarios.md) (S1S5) for operational scenarios. - See [design/scenarios.md](../design/scenarios.md) (S1S5) for operational scenarios.
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
## UAT host notes ## UAT host notes
+43 -3
View File
@@ -1,14 +1,54 @@
# Operations (day-2) # Operations (day-2)
**Status:** Outline. **Status:** Doctor (S6) implemented.
## Operator commands (when implemented) ## Health checks (doctor)
```bash ```bash
# Run all health checks (human-readable)
make doctor
# or
./scripts/doctor.sh ./scripts/doctor.sh
# Machine-readable JSON summary
./scripts/doctor.sh --json
```
**What doctor checks:**
| Check | What it validates | Severity |
|-------|-------------------|----------|
| Docker | Daemon running and accessible | Critical |
| nemohermes CLI | Installed and versioned | Critical |
| openshell CLI | Installed and versioned | Critical |
| Sandbox | `nemohermes <name> doctor` healthy | Critical |
| Policy | `lumina-inference` preset applied | Warning |
| Skills | Skill directories with SKILL.md present | Warning |
| Inference endpoint | `/v1/models` reachable from .env URL | Critical |
| Inference gateway | `openshell inference get` configured | Warning |
**Exit codes:** `0` = all critical checks passed; `1` = one or more critical failures.
## Platform diagnostics
```bash
# Sandbox status
nemohermes <sandbox-name> status nemohermes <sandbox-name> status
# Sandbox doctor (platform-level)
nemohermes <sandbox-name> doctor
# Sandbox logs
nemohermes <sandbox-name> logs --follow nemohermes <sandbox-name> logs --follow
docker compose -f deploy/compose/docker-compose.yml logs
# Gateway status
openshell status
# Inference config
openshell inference get
# Policy presets
nemohermes <sandbox-name> policy-list
``` ```
## Log levels ## Log levels
+7 -2
View File
@@ -1,6 +1,6 @@
# Host scripts # Host scripts
**Status:** S0bS5 implemented. S6S7 pending. **Status:** S0bS6 implemented. S7 pending.
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API. All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
@@ -14,8 +14,8 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
| `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 | | `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 |
| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 | | `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 |
| `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 | | `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 |
| `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 |
| `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending | | `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending |
| `doctor.sh` | Health checks | ⏳ Pending |
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending | | `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending |
## Shared library ## Shared library
@@ -42,6 +42,10 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
./scripts/install.sh --stage s5 # policy + skills ./scripts/install.sh --stage s5 # policy + skills
./scripts/install.sh --stage s3-s5 # S3 through S5 ./scripts/install.sh --stage s3-s5 # S3 through S5
# Health checks (S6)
./scripts/doctor.sh # human-readable
./scripts/doctor.sh --json # machine-readable
# Or via Make # Or via Make
make bootstrap make bootstrap
make install make install
@@ -49,6 +53,7 @@ make install-s1
make install-s2 make install-s2
make install-s3-s5 make install-s3-s5
make install-s5 make install-s5
make doctor
``` ```
## Design reference ## Design reference
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env bash
# scripts/doctor.sh — S6: Lumina product health checks
#
# Composes platform-layer health checks:
# Docker · nemohermes · openshell · sandbox · policy · skills · inference
#
# Platform-first: wraps nemohermes / openshell / Docker. No parallel control API.
#
# Usage:
# ./scripts/doctor.sh # full check
# ./scripts/doctor.sh --json # machine-readable summary
# ./scripts/doctor.sh --help
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source shared helpers
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
# shellcheck source=lib/env.sh
source "$SCRIPT_DIR/lib/env.sh"
# ── Defaults ───────────────────────────────────────────────────────────────
JSON_OUTPUT=0
# ── Parse args ─────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
S6: Lumina product health checks.
Checks:
Docker daemon, nemohermes CLI, openshell CLI, sandbox status,
policy overlays, skills directory, inference endpoint.
Options:
--json Machine-readable JSON summary
--help Show this help
Exit codes:
0 All critical checks passed (warnings are non-fatal)
1 One or more critical checks failed
EOF
exit 0
;;
--json)
JSON_OUTPUT=1
shift
;;
*)
log_error "Unknown argument: $1"
exit 1
;;
esac
done
# ── State tracking ─────────────────────────────────────────────────────────
CRITICAL_FAIL=0
WARN_COUNT=0
declare -a CHECK_RESULTS=()
# Strip ANSI escape codes from a string
strip_ansi() {
sed 's/\x1b\[[0-9;]*m//g' <<< "$1"
}
# Record a check result: "group|label|status|detail"
record_check() {
local group="$1" label="$2" status="$3" detail="$4"
# Strip any ANSI codes that may have leaked from CLI output
detail="$(strip_ansi "$detail")"
CHECK_RESULTS+=("${group}|${label}|${status}|${detail}")
if [[ "$status" == "FAIL" ]]; then
CRITICAL_FAIL=1
elif [[ "$status" == "WARN" ]]; then
WARN_COUNT=$((WARN_COUNT + 1))
fi
}
# ── Load .env (best-effort; warn if missing) ───────────────────────────────
if [[ $JSON_OUTPUT -eq 1 ]]; then
load_env >/dev/null 2>&1 || true
else
load_env 2>/dev/null || true
fi
SANDBOX_NAME="$(get_sandbox_name)"
SKILLS_DIR="$REPO_ROOT/skills"
POLICY_DIR="$REPO_ROOT/policy/openshell/overlays"
# ── Check: Docker ──────────────────────────────────────────────────────────
check_docker() {
if ! cmd_exists docker; then
record_check "Docker" "CLI" "FAIL" "docker command not found"
return
fi
if ! docker info &>/dev/null; then
record_check "Docker" "Daemon" "FAIL" "docker daemon not running or not accessible"
return
fi
local version
version="$(docker --version 2>/dev/null | sed 's/^Docker version //' | cut -d',' -f1 | tr -d ' ')"
record_check "Docker" "Daemon" "OK" "running ($version)"
}
# ── Check: nemohermes CLI ──────────────────────────────────────────────────
check_nemohermes() {
if ! cmd_exists nemohermes; then
record_check "CLI" "nemohermes" "FAIL" "nemohermes not found — install NemoClaw platform"
return
fi
local version
version="$(nemohermes --version 2>/dev/null | head -1 | grep -oP 'v[\d.]+' || echo 'unknown')"
record_check "CLI" "nemohermes" "OK" "$version"
}
# ── Check: openshell CLI ───────────────────────────────────────────────────
check_openshell() {
if ! cmd_exists openshell; then
record_check "CLI" "openshell" "FAIL" "openshell not found — install OpenShell"
return
fi
local version
version="$(openshell --version 2>/dev/null | head -1 | grep -oP '[\d.]+' || echo 'unknown')"
record_check "CLI" "openshell" "OK" "$version"
}
# ── Check: Sandbox status ──────────────────────────────────────────────────
check_sandbox() {
# Skip if nemohermes is missing (already flagged)
if ! cmd_exists nemohermes; then
record_check "Sandbox" "Status" "FAIL" "skipped — nemohermes not available"
return
fi
# Run nemohermes doctor for the sandbox — this is the authoritative platform check
local doctor_output
doctor_output="$(nemohermes "$SANDBOX_NAME" doctor 2>&1)" || true
# Check for summary line
if echo "$doctor_output" | grep -qi "Summary: healthy"; then
record_check "Sandbox" "Doctor" "OK" "$SANDBOX_NAME healthy"
elif echo "$doctor_output" | grep -qi "Summary:.*warning"; then
record_check "Sandbox" "Doctor" "WARN" "$SANDBOX_NAME has warnings"
elif echo "$doctor_output" | grep -qi "Summary:.*unhealthy\|Summary:.*critical"; then
record_check "Sandbox" "Doctor" "FAIL" "$SANDBOX_NAME unhealthy"
else
# Fallback: check status command
if nemohermes "$SANDBOX_NAME" status &>/dev/null; then
record_check "Sandbox" "Status" "OK" "$SANDBOX_NAME reachable"
else
record_check "Sandbox" "Status" "FAIL" "$SANDBOX_NAME not found or not reachable"
fi
fi
}
# ── Check: Policy overlays ─────────────────────────────────────────────────
check_policy() {
# Skip if nemohermes is missing
if ! cmd_exists nemohermes; then
record_check "Policy" "Overlays" "FAIL" "skipped — nemohermes not available"
return
fi
# Check that policy overlay files exist in the repo
local inference_policy="$POLICY_DIR/inference.yaml"
if [[ ! -f "$inference_policy" ]]; then
record_check "Policy" "Overlay files" "WARN" "inference.yaml not found in $POLICY_DIR"
fi
# Check that lumina-inference preset is applied in the sandbox
local policy_list
policy_list="$(nemohermes "$SANDBOX_NAME" policy-list 2>&1)" || {
record_check "Policy" "policy-list" "FAIL" "could not list policy presets"
return
}
if echo "$policy_list" | grep -q "lumina-inference"; then
record_check "Policy" "lumina-inference" "OK" "preset applied"
else
record_check "Policy" "lumina-inference" "WARN" "preset not found in sandbox — run S5 or: nemohermes $SANDBOX_NAME policy-add --from-file $inference_policy --yes"
fi
}
# ── Check: Skills ──────────────────────────────────────────────────────────
check_skills() {
if [[ ! -d "$SKILLS_DIR" ]]; then
record_check "Skills" "Directory" "FAIL" "skills directory not found at $SKILLS_DIR"
return
fi
# Count skill directories (exclude _lib and hidden)
local skill_count=0
local skill_with_md=0
for skill_dir in "$SKILLS_DIR"/*/; do
[[ -d "$skill_dir" ]] || continue
local name
name="$(basename "$skill_dir")"
[[ "$name" == "_lib" ]] && continue
[[ "$name" == "README.md" ]] && continue
skill_count=$((skill_count + 1))
if [[ -f "$skill_dir/SKILL.md" ]]; then
skill_with_md=$((skill_with_md + 1))
fi
done
if [[ $skill_count -eq 0 ]]; then
record_check "Skills" "Pack" "WARN" "no skill directories found in $SKILLS_DIR"
elif [[ $skill_with_md -lt $skill_count ]]; then
record_check "Skills" "Pack" "WARN" "$skill_with_md/$skill_count skills have SKILL.md"
else
record_check "Skills" "Pack" "OK" "$skill_count skills with SKILL.md"
fi
}
# ── Check: Inference endpoint ──────────────────────────────────────────────
check_inference() {
# Check .env has the required keys
local env_file="${REPO_ROOT}/.env"
if [[ ! -f "$env_file" ]]; then
record_check "Inference" "Config" "FAIL" ".env not found — run S1 first"
return
fi
# Source .env to get variables (already done by load_env, but verify)
local base_url="${LUMINA_INFERENCE_BASE_URL:-}"
local model="${LUMINA_INFERENCE_MODEL:-}"
if [[ -z "$base_url" ]]; then
record_check "Inference" "Endpoint URL" "FAIL" "LUMINA_INFERENCE_BASE_URL not set in .env"
return
fi
if [[ -z "$model" ]]; then
record_check "Inference" "Model" "FAIL" "LUMINA_INFERENCE_MODEL not set in .env"
return
fi
# Check endpoint reachability
local url="$base_url"
# Ensure URL ends with /v1 for the models endpoint
if [[ "$url" != */v1 && "$url" != */v1/* ]]; then
url="${url%/}/v1"
fi
if curl -sf --max-time 15 "${url}/models" &>/dev/null; then
record_check "Inference" "Endpoint" "OK" "reachable ($base_url)"
else
record_check "Inference" "Endpoint" "FAIL" "unreachable at $base_url"
fi
# Check openshell inference config (optional — gateway may not be connected)
if cmd_exists openshell; then
local inf_output
inf_output="$(openshell inference get 2>&1)" || true
if echo "$inf_output" | grep -q "Provider:"; then
local provider
# Strip ANSI escape codes and whitespace
provider="$(echo "$inf_output" | grep "Provider:" | head -1 | sed 's/.*Provider: *//' | sed 's/\x1b\[[0-9;]*m//g' | tr -d '[:space:]')"
record_check "Inference" "Gateway route" "OK" "configured ($provider)"
else
record_check "Inference" "Gateway route" "WARN" "not configured via openshell"
fi
fi
}
# ── Run all checks ─────────────────────────────────────────────────────────
if [[ $JSON_OUTPUT -eq 0 ]]; then
log_section "Lumina Doctor (S6)"
fi
check_docker
check_nemohermes
check_openshell
check_sandbox
check_policy
check_skills
check_inference
# ── Output results ─────────────────────────────────────────────────────────
if [[ $JSON_OUTPUT -eq 1 ]]; then
# Machine-readable JSON summary
checks_json="["
first=1
for entry in "${CHECK_RESULTS[@]}"; do
IFS='|' read -r group label status detail <<< "$entry"
if [[ $first -eq 1 ]]; then
first=0
else
checks_json+=","
fi
# Escape backslashes first, then double quotes (JSON-safe)
detail="${detail//\\/\\\\}"
detail="${detail//\"/\\\"}"
checks_json+="{\"group\":\"$group\",\"label\":\"$label\",\"status\":\"$status\",\"detail\":\"$detail\"}"
done
checks_json+="]"
overall="healthy"
[[ $CRITICAL_FAIL -eq 1 ]] && overall="unhealthy"
cat <<EOF
{
"product": "Lumina",
"stage": "S6",
"sandbox": "$SANDBOX_NAME",
"overall": "$overall",
"critical_failures": $CRITICAL_FAIL,
"warnings": $WARN_COUNT,
"checks": $checks_json
}
EOF
else
# Human-readable summary
log_section "Results"
ok_count=0
warn_count=0
fail_count=0
for entry in "${CHECK_RESULTS[@]}"; do
IFS='|' read -r group label status detail <<< "$entry"
case "$status" in
OK) printf " \033[0;32m[OK]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
WARN) printf " \033[1;33m[WARN]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
FAIL) printf " \033[0;31m[FAIL]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
esac
case "$status" in
OK) ok_count=$((ok_count + 1)) ;;
WARN) warn_count=$((warn_count + 1)) ;;
FAIL) fail_count=$((fail_count + 1)) ;;
esac
done
log_section "Summary"
printf " Checks: %d OK, %d WARN, %d FAIL\n" "$ok_count" "$warn_count" "$fail_count"
if [[ $CRITICAL_FAIL -eq 1 ]]; then
printf " Overall: \033[0;31mUNHEALTHY\033[0m\n"
elif [[ $WARN_COUNT -gt 0 ]]; then
printf " Overall: \033[1;33mHEALTHY (with warnings)\033[0m\n"
else
printf " Overall: \033[0;32mHEALTHY\033[0m\n"
fi
fi
# ── Exit code ──────────────────────────────────────────────────────────────
if [[ $CRITICAL_FAIL -eq 1 ]]; then
exit 1
fi
exit 0
+28 -6
View File
@@ -1,9 +1,31 @@
# Shared skill library (scaffold) # Shared skill library
**Status:** Empty until **build**. **Status:** Partially implemented.
Planned packages under `providers/`: Shared deterministic library used by Salon_Assistant skills. All code here is
**deterministic** — no model inference, no network calls. See
[design/det-vs-inf.md](../../../design/det-vs-inf.md).
- `scheduling/` — Vagaro / Square adapters ## Packages
- `books/` — QuickBooks Online adapters
- `mcp/` — MCP client helpers / allowlist metadata | Package | Status | Purpose |
|---------|--------|---------|
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
## Usage
```python
from lumina_skills.domain import Appointment, AppointmentStatus
from lumina_skills.board_builder import build_board, format_board_text
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
```
## Design references
- Deterministic boundary: [design/det-vs-inf.md](../../../design/det-vs-inf.md)
- Use cases: [design/use-cases.md](../../../design/use-cases.md)
+1
View File
@@ -0,0 +1 @@
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
+279
View File
@@ -0,0 +1,279 @@
"""Deterministic board builder.
Takes a list of Appointment objects and produces a DayBoard with:
- Sorted appointments
- Computed gaps between consecutive appointments per staff member
- Confirmation flags
- Offline/fixture labeling
All logic is deterministic no model inference.
See design/det-vs-inf.md.
"""
from __future__ import annotations
import re
import warnings
from datetime import datetime, time
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus, DayBoard, Gap
def build_board(
appointments: list[Appointment],
date: str,
salon_name: str,
source: str = "fixtures",
business_hours: dict[str, str] | None = None,
) -> DayBoard:
"""Build a complete DayBoard from a list of appointments.
Args:
appointments: Raw appointment list (from fixtures or live source).
date: The board date in YYYY-MM-DD format.
salon_name: Display name of the salon.
source: Data source label "fixtures", "offline", "vagaro", "square".
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"} to
compute gaps at day boundaries.
Returns:
A fully populated DayBoard.
"""
is_offline = source in ("fixtures", "offline")
# Filter out cancelled appointments for the board view.
active = [a for a in appointments if a.status != AppointmentStatus.CANCELLED]
# Sort by start time.
active.sort(key=lambda a: a.start_time)
# Compute gaps per staff member.
gaps = _compute_gaps(active, date, business_hours)
# Confirmation flags: pending appointments that need confirmation.
needs_confirmation = [a for a in active if a.needs_confirmation]
# Totals.
total_booked = sum(a.duration_minutes() for a in active)
total_gap = sum(g.duration_minutes for g in gaps)
return DayBoard(
date=date,
salon_name=salon_name,
source=source,
is_offline=is_offline,
appointments=active,
gaps=gaps,
needs_confirmation=needs_confirmation,
total_booked_minutes=total_booked,
total_gap_minutes=total_gap,
)
def _compute_gaps(
appointments: list[Appointment],
date: str,
business_hours: dict[str, str] | None = None,
) -> list[Gap]:
"""Compute unbooked gaps between consecutive appointments per staff.
Gaps are computed per staff member. If business_hours is provided,
gaps from openfirst appointment and last appointmentclose are
included (only if >= 30 minutes).
Args:
appointments: Sorted list of active appointments.
date: Board date string (YYYY-MM-DD).
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"}.
Returns:
List of Gap objects.
"""
gaps: list[Gap] = []
# Group appointments by staff.
staff_apts: dict[str, list[Appointment]] = {}
for apt in appointments:
staff_apts.setdefault(apt.staff_name, []).append(apt)
for staff_name, apts in staff_apts.items():
# apts is already sorted by start_time from the caller.
open_time = None
close_time = None
if business_hours:
open_str = business_hours.get("open", "")
close_str = business_hours.get("close", "")
if open_str:
open_time = _parse_time_str(open_str)
if open_time is None:
warnings.warn(
f"Invalid business_hours.open format: {open_str!r} "
f"(expected HH:MM). Skipping open boundary gap.",
UserWarning,
stacklevel=2,
)
if close_str:
close_time = _parse_time_str(close_str)
if close_time is None:
warnings.warn(
f"Invalid business_hours.close format: {close_str!r} "
f"(expected HH:MM). Skipping close boundary gap.",
UserWarning,
stacklevel=2,
)
# Gap from open to first appointment.
if open_time and apts:
first_start = apts[0].start_time_only()
gap_mins = _time_diff_minutes(open_time, first_start)
if gap_mins >= 30:
gaps.append(Gap(
start_time=open_time,
end_time=first_start,
duration_minutes=gap_mins,
staff_name=staff_name,
following_appointment_id=apts[0].appointment_id,
))
# Gaps between consecutive appointments.
for i in range(len(apts) - 1):
current_end = apts[i].end_time_only()
next_start = apts[i + 1].start_time_only()
gap_mins = _time_diff_minutes(current_end, next_start)
if gap_mins < 0:
warnings.warn(
f"Overlapping appointments for {staff_name}: "
f"{apts[i].appointment_id} ends at {current_end} but "
f"{apts[i + 1].appointment_id} starts at {next_start} "
f"({abs(gap_mins)} min overlap). Gap skipped.",
UserWarning,
stacklevel=2,
)
continue
if gap_mins >= 30:
gaps.append(Gap(
start_time=current_end,
end_time=next_start,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[i].appointment_id,
following_appointment_id=apts[i + 1].appointment_id,
))
# Gap from last appointment to close.
if close_time and apts:
last_end = apts[-1].end_time_only()
gap_mins = _time_diff_minutes(last_end, close_time)
if gap_mins >= 30:
gaps.append(Gap(
start_time=last_end,
end_time=close_time,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[-1].appointment_id,
))
return gaps
def _parse_time_str(raw: str) -> time | None:
"""Parse an HH:MM string into a time object.
Returns None if the format is invalid (not HH:MM with valid ranges).
"""
m = re.fullmatch(r"(\d{2}):(\d{2})", raw)
if m is None:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h > 23 or mi > 59:
return None
return time(h, mi)
def _time_diff_minutes(start: time, end: time) -> int:
"""Minutes between two time objects (same day assumed).
Returns a negative value when *end* is before *start* (overlap).
Callers should check for negative results and warn.
"""
diff = datetime.combine(datetime.today(), end) - datetime.combine(datetime.today(), start)
return int(diff.total_seconds() // 60)
def format_board_text(board: DayBoard) -> str:
"""Format a DayBoard as structured text for chat display.
This is deterministic formatting no model inference.
The model may rephrase when presenting to the owner, but the
facts come from this function.
"""
lines: list[str] = []
# Header with offline label.
source_label = "📋 FIXTURE DATA" if board.is_offline else "📅 LIVE DATA"
lines.append(f"═══ {board.salon_name}{board.date} ═══")
lines.append(f"[{source_label}]")
lines.append("")
# Appointments.
lines.append("── Appointments ──")
if not board.appointments:
lines.append(" No appointments.")
else:
for apt in board.appointments:
start_str = apt.start_time.strftime("%H:%M")
end_str = apt.end_time.strftime("%H:%M")
status_icon = _status_icon(apt.status)
confirm_flag = " ⚠️ CONFIRM" if apt.needs_confirmation else ""
lines.append(
f" {start_str}{end_str} {status_icon} {apt.client_name}"
f"{apt.service_name} ({apt.staff_name}){confirm_flag}"
)
if apt.notes:
lines.append(f" 📝 {apt.notes}")
lines.append("")
# Gaps.
lines.append("── Gaps (≥30 min) ──")
if not board.gaps:
lines.append(" No significant gaps.")
else:
for gap in board.gaps:
start_str = gap.start_time.strftime("%H:%M")
end_str = gap.end_time.strftime("%H:%M")
lines.append(
f" {start_str}{end_str} ({gap.duration_minutes} min) "
f"{gap.staff_name}"
)
lines.append("")
# Confirmation needed.
if board.needs_confirmation:
lines.append("── Needs Confirmation ──")
for apt in board.needs_confirmation:
start_str = apt.start_time.strftime("%H:%M")
lines.append(
f" ⚠️ {apt.client_name}{apt.service_name} at {start_str}"
)
lines.append("")
# Summary.
lines.append("── Summary ──")
lines.append(f" Booked: {board.total_booked_minutes} min | Gaps: {board.total_gap_minutes} min")
lines.append(f" Appointments: {len(board.appointments)} | "
f"Need confirmation: {len(board.needs_confirmation)}")
return "\n".join(lines)
def _status_icon(status: AppointmentStatus) -> str:
"""Emoji icon for appointment status."""
icons = {
AppointmentStatus.CONFIRMED: "",
AppointmentStatus.PENDING: "",
AppointmentStatus.COMPLETED: "✔️",
AppointmentStatus.NO_SHOW: "",
AppointmentStatus.CANCELLED: "🚫",
}
return icons.get(status, "")
+121
View File
@@ -0,0 +1,121 @@
"""Deterministic domain types for scheduling / board building.
These are pure data classes no model inference, no network calls.
See design/det-vs-inf.md for the deterministic boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from enum import Enum
from typing import Optional
class AppointmentStatus(str, Enum):
"""Standardized appointment status."""
CONFIRMED = "confirmed"
PENDING = "pending"
CANCELLED = "cancelled"
COMPLETED = "completed"
NO_SHOW = "no_show"
@dataclass(frozen=True)
class Appointment:
"""A single salon appointment — the core domain object.
Fields match what the daily-board (A1) needs to display:
time, client, service, staff, status, confirmation flag.
"""
appointment_id: str
start_time: datetime
end_time: datetime
client_name: str
service_name: str
staff_name: str
status: AppointmentStatus
notes: str = ""
# Whether the client still needs a confirmation call/message.
# Derived at build time from status + last_contact, but stored here
# for fixture convenience.
needs_confirmation: bool = False
def duration_minutes(self) -> int:
"""Appointment duration in whole minutes."""
delta = self.end_time - self.start_time
return int(delta.total_seconds() // 60)
def start_time_only(self) -> time:
return self.start_time.time()
def end_time_only(self) -> time:
return self.end_time.time()
@dataclass(frozen=True)
class Gap:
"""An unbooked time slot between two appointments (or day boundary)."""
start_time: time
end_time: time
duration_minutes: int
staff_name: str
# The appointment immediately before this gap (if any).
preceding_appointment_id: Optional[str] = None
# The appointment immediately after this gap (if any).
following_appointment_id: Optional[str] = None
@dataclass(frozen=True)
class DayBoard:
"""The complete daily board for one staff member or the whole salon.
This is the structured output that the daily-board skill presents.
All data is deterministic no model inference.
"""
date: str # YYYY-MM-DD
salon_name: str
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
is_offline: bool # True when source is fixtures or offline
appointments: list[Appointment] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
needs_confirmation: list[Appointment] = field(default_factory=list)
total_booked_minutes: int = 0
total_gap_minutes: int = 0
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON output."""
return {
"date": self.date,
"salon_name": self.salon_name,
"source": self.source,
"is_offline": self.is_offline,
"appointments": [
{
"id": a.appointment_id,
"start": a.start_time.isoformat(),
"end": a.end_time.isoformat(),
"client": a.client_name,
"service": a.service_name,
"staff": a.staff_name,
"status": a.status.value,
"needs_confirmation": a.needs_confirmation,
"notes": a.notes,
}
for a in self.appointments
],
"gaps": [
{
"start": g.start_time.isoformat(),
"end": g.end_time.isoformat(),
"duration_minutes": g.duration_minutes,
"staff": g.staff_name,
}
for g in self.gaps
],
"needs_confirmation": [
a.appointment_id for a in self.needs_confirmation
],
"total_booked_minutes": self.total_booked_minutes,
"total_gap_minutes": self.total_gap_minutes,
}
@@ -0,0 +1 @@
"""Provider adapters for external data sources."""
@@ -0,0 +1 @@
"""Books provider: QuickBooks Online (future)."""
@@ -0,0 +1 @@
"""MCP client helpers and allowlist metadata (future)."""
@@ -0,0 +1 @@
"""Scheduling provider: fixtures, Vagaro, Square (future)."""
@@ -0,0 +1,125 @@
"""Fixture provider for scheduling data.
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
This is the *only* data source for the daily-board until live SaaS adapters
(Vagaro, Square) are implemented.
All output is labeled `source: fixtures` / `is_offline: True` so the owner
never sees silent fake live data.
"""
from __future__ import annotations
import json
import pathlib
from datetime import datetime
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus
# Mapping from fixture status strings to domain enum.
_STATUS_MAP: dict[str, AppointmentStatus] = {
"confirmed": AppointmentStatus.CONFIRMED,
"pending": AppointmentStatus.PENDING,
"cancelled": AppointmentStatus.CANCELLED,
"completed": AppointmentStatus.COMPLETED,
"no_show": AppointmentStatus.NO_SHOW,
}
def _parse_status(raw: str) -> AppointmentStatus:
"""Convert a fixture status string to AppointmentStatus.
Raises:
ValueError: If the status string is not recognized.
"""
key = raw.lower()
if key not in _STATUS_MAP:
raise ValueError(
f"Unknown appointment status {raw!r}. "
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
)
return _STATUS_MAP[key]
def _parse_datetime(raw: str) -> datetime:
"""Parse ISO-format datetime strings from fixtures."""
return datetime.fromisoformat(raw)
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
"""Load appointments from a fixture JSON file.
Expected top-level shape:
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {"open": "09:00", "close": "18:00"},
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:00:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss"
}
]
}
```
Args:
fixture_path: Path to a JSON fixture file.
Returns:
List of Appointment domain objects.
Raises:
FileNotFoundError: If the fixture file does not exist.
ValueError: If the fixture JSON is malformed.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
appointments: list[Appointment] = []
for apt_raw in raw.get("appointments", []):
appointments.append(Appointment(
appointment_id=apt_raw["id"],
start_time=_parse_datetime(apt_raw["start"]),
end_time=_parse_datetime(apt_raw["end"]),
client_name=apt_raw["client_name"],
service_name=apt_raw["service_name"],
staff_name=apt_raw["staff_name"],
status=_parse_status(apt_raw.get("status", "pending")),
notes=apt_raw.get("notes", ""),
needs_confirmation=apt_raw.get("needs_confirmation", False),
))
return appointments
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
"""Load non-appointment metadata from a fixture file.
Returns salon_name, date, business_hours, staff list, etc.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
return {
"salon_name": raw.get("salon_name", "Unknown Salon"),
"date": raw.get("date", ""),
"business_hours": raw.get("business_hours", {}),
"staff": raw.get("staff", []),
}
+45 -3
View File
@@ -1,5 +1,47 @@
# `daily-board` (scaffold) # `daily-board`
**Status:** Not implemented. Implementation requires explicit **build**. **Status:** Implemented (fixtures only).
Intent: see [design/use-cases.md](../../design/use-cases.md) and [design/scenarios.md](../../design/scenarios.md). Builds a structured daily board from scheduling data: appointments, gaps, confirmation flags, and summary.
## What it does
- Loads appointment data from fixture JSON files
- Computes gaps between appointments (≥30 min)
- Flags appointments needing client confirmation
- Labels all output as `📋 FIXTURE DATA` — never silent fake live data
- Outputs structured text or JSON
## Quick start
```bash
# Text output (default)
python skills/daily-board/scripts/build_board.py
# JSON output
python skills/daily-board/scripts/build_board.py --format json
```
## Files
| Path | Purpose |
|------|---------|
| `SKILL.md` | Skill spec and usage |
| `scripts/build_board.py` | CLI entrypoint |
| `../../skills/_lib/lumina_skills/domain.py` | Domain types |
| `../../skills/_lib/lumina_skills/board_builder.py` | Board builder logic |
| `../../skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py` | Fixture loader |
| `../../data/fixtures/scheduling/` | Fixture JSON files |
## Design references
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
## Future
- Live Vagaro adapter (S5)
- Live Square adapter (S3)
- Staff filtering
- Multi-day boards
+81 -4
View File
@@ -1,18 +1,95 @@
--- ---
name: daily-board name: daily-board
description: "Today's salon board: appointments, tasks, and priorities" description: "Today's salon board: appointments, gaps, and confirmation flags"
domain: operations domain: operations
--- ---
# daily-board # daily-board
Today's salon board: appointments, tasks, and priorities. Today's salon board: appointments, gaps, and confirmation flags.
## Description ## Description
Pulls together the day's schedule, pending tasks, and key metrics into a single board view for the salon owner. Builds a structured daily board from scheduling data showing:
- **Appointments** — time, client, service, staff, status
- **Gaps** — unbooked slots ≥30 minutes between appointments
- **Confirmation flags** — appointments that still need client confirmation
- **Summary** — total booked time, gap time, appointment count
All data is labeled with its source. When using fixtures, output is clearly
marked `📋 FIXTURE DATA` so the owner never sees silent fake live data.
## Data sources
| Source | Status | Label in output |
|--------|--------|-----------------|
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
| Vagaro | Not yet | `📅 LIVE DATA` (future) |
| Square | Not yet | `📅 LIVE DATA` (future) |
## Constraints ## Constraints
- Deterministic facts from tools; inference for ranking and wording only. - Deterministic facts from fixtures; no model inference for board data.
- No silent send or publish. - No silent send or publish.
- Cancelled appointments are excluded from the board view.
- Gaps under 30 minutes are not shown (too short for a meaningful slot).
- Output always labels fixture/offline — never silent fake live data.
## Usage
### CLI
```bash
# Build board from fixtures (default demo data)
python skills/daily-board/scripts/build_board.py
# Build board for a specific fixture file
python skills/daily-board/scripts/build_board.py \
--fixtures data/fixtures/scheduling/claire_bennett_2026-07-28.json
# Output as JSON
python skills/daily-board/scripts/build_board.py --format json
# Specify date and salon name explicitly
python skills/daily-board/scripts/build_board.py \
--date 2026-07-28 --salon "Lumina Hair Studio & Spa"
```
### Programmatic
```python
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
from lumina_skills.board_builder import build_board, format_board_text
appointments = load_fixtures("data/fixtures/scheduling/claire_bennett_2026-07-28.json")
board = build_board(appointments, date="2026-07-28", salon_name="Lumina Hair Studio & Spa")
print(format_board_text(board))
```
## Output format
### Text (default)
Structured text with sections for appointments, gaps, confirmation flags, and summary.
### JSON
```json
{
"date": "2026-07-28",
"salon_name": "Lumina Hair Studio & Spa",
"source": "fixtures",
"is_offline": true,
"appointments": [...],
"gaps": [...],
"needs_confirmation": [...],
"total_booked_minutes": 420,
"total_gap_minutes": 120
}
```
## Design references
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Build a daily board from scheduling fixtures.
Usage:
python build_board.py [--fixtures PATH] [--date YYYY-MM-DD] [--salon NAME] [--format text|json]
All data is labeled as fixture/offline never silent fake live data.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
# Ensure the _lib package is importable regardless of cwd.
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
from lumina_skills.providers.scheduling.fixture_provider import (
load_fixtures,
load_fixture_metadata,
)
from lumina_skills.board_builder import build_board, format_board_text
# Default fixture: Claire Bennett sample day.
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
def main() -> int:
parser = argparse.ArgumentParser(
description="Build a daily salon board from fixture data.",
)
parser.add_argument(
"--fixtures",
type=pathlib.Path,
default=_DEFAULT_FIXTURE,
help="Path to fixture JSON file (default: Claire Bennett sample day).",
)
parser.add_argument(
"--date",
type=str,
default=None,
help="Override board date (YYYY-MM-DD). Defaults to fixture date.",
)
parser.add_argument(
"--salon",
type=str,
default=None,
help="Override salon name. Defaults to fixture salon_name.",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text).",
)
args = parser.parse_args()
# Load fixture data.
try:
appointments = load_fixtures(args.fixtures)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
except (json.JSONDecodeError, KeyError, ValueError) as exc:
print(f"Error parsing fixture: {exc}", file=sys.stderr)
return 1
# Load metadata for defaults.
try:
meta = load_fixture_metadata(args.fixtures)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
print(f"Warning: could not parse fixture metadata: {exc}", file=sys.stderr)
meta = {}
date = args.date or meta.get("date", "unknown")
salon_name = args.salon or meta.get("salon_name", "Unknown Salon")
business_hours = meta.get("business_hours")
# Build the board.
board = build_board(
appointments=appointments,
date=date,
salon_name=salon_name,
source="fixtures",
business_hours=business_hours,
)
# Output.
if args.format == "json":
print(json.dumps(board.to_dict(), indent=2))
else:
print(format_board_text(board))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -9
View File
@@ -1,12 +1,22 @@
# Tests (scaffold) # Tests
**Status:** Structure only — no product tests until **build**. | Path | Purpose | Status |
|------|---------|--------|
| `unit/` | Deterministic domain/skill logic (no live model required) | ✅ Partial |
| `contract/` | Provider/MCP allow-deny contracts | ⏳ |
| `integration/` | Optional cheap-model dialogue paths | ⏳ |
| `fixtures/` | Test-only fixtures | ⏳ |
| Path | Purpose | ## Running tests
|------|---------|
| `unit/` | Deterministic domain/skill logic (no live model required) |
| `contract/` | Provider/MCP allow-deny contracts |
| `integration/` | Optional cheap-model dialogue paths |
| `fixtures/` | Test-only fixtures |
Design boundary: [design/det-vs-inf.md](../design/det-vs-inf.md). ```bash
# All unit tests
python -m pytest tests/unit/ -v
# Specific test file
python -m pytest tests/unit/test_board_builder.py -v
```
## Design boundary
[design/det-vs-inf.md](../design/det-vs-inf.md) — unit tests cover the deterministic column without a live model.
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Salon_Assistant deterministic logic."""
+306
View File
@@ -0,0 +1,306 @@
"""Tests for the deterministic board builder."""
from __future__ import annotations
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
from lumina_skills.board_builder import (
build_board,
format_board_text,
)
def _apt(
apt_id: str,
start_h: int,
start_m: int,
end_h: int,
end_m: int,
client: str = "Client",
service: str = "Service",
staff: str = "Staff",
status: AppointmentStatus = AppointmentStatus.CONFIRMED,
needs_confirmation: bool = False,
notes: str = "",
) -> Appointment:
"""Helper to create an Appointment quickly."""
return Appointment(
appointment_id=apt_id,
start_time=datetime(2026, 7, 28, start_h, start_m),
end_time=datetime(2026, 7, 28, end_h, end_m),
client_name=client,
service_name=service,
staff_name=staff,
status=status,
notes=notes,
needs_confirmation=needs_confirmation,
)
# ── build_board ────────────────────────────────────────────────────────────
def test_build_board_basic():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon", source="fixtures")
assert len(board.appointments) == 2
assert board.total_booked_minutes == 120
assert board.is_offline is True
def test_build_board_excludes_cancelled():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", status=AppointmentStatus.CANCELLED),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.appointments) == 2 # Cancelled excluded
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A3"
def test_build_board_sorts_by_time():
apts = [
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A2"
def test_build_board_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=False),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", needs_confirmation=True),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.needs_confirmation) == 2
assert board.needs_confirmation[0].appointment_id == "A2"
assert board.needs_confirmation[1].appointment_id == "A3"
def test_build_board_gaps_between_apts():
"""Gap between two appointments on the same staff."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.gaps) == 1
gap = board.gaps[0]
assert gap.start_time == time(10, 0)
assert gap.end_time == time(11, 0)
assert gap.duration_minutes == 60
assert gap.staff_name == "Claire"
def test_build_board_no_small_gaps():
"""Gaps under 30 minutes are not included."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 15, 11, 15, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# 15-minute gap should be excluded.
assert len(board.gaps) == 0
def test_build_board_overlapping_appointments_warns():
"""Overlapping appointments emit a warning and skip the negative gap."""
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), # starts 30 min before A1 ends
]
with pytest.warns(UserWarning, match="Overlapping appointments"):
board = build_board(apts, "2026-07-28", "Test Salon")
# The negative gap should not appear in the board.
assert all(g.duration_minutes >= 0 for g in board.gaps)
# Both appointments still appear (overlap is a data issue, not a filter).
assert len(board.appointments) == 2
def test_build_board_boundary_gaps():
"""Gaps from open→first and last→close when business_hours provided."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
_apt("A2", 15, 0, 16, 0, "Bob", "Color", "Claire"),
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
# Should have: 09:00-10:00 (60 min), 11:00-15:00 (240 min), 16:00-18:00 (120 min)
assert len(board.gaps) == 3
assert board.gaps[0].start_time == time(9, 0)
assert board.gaps[0].end_time == time(10, 0)
assert board.gaps[2].start_time == time(16, 0)
assert board.gaps[2].end_time == time(18, 0)
def test_build_board_multi_staff_gaps():
"""Gaps are computed per staff member."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 9, 0, 10, 0, "Bob", "Color", "Maya"),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
_apt("A4", 11, 0, 12, 0, "Dave", "Trim", "Maya"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# Each staff has a 60-min gap.
assert len(board.gaps) == 2
staff_gaps = {g.staff_name: g.duration_minutes for g in board.gaps}
assert staff_gaps["Claire"] == 60
assert staff_gaps["Maya"] == 60
def test_build_board_invalid_business_hours_warns():
"""Malformed business_hours values warn and skip boundary gaps."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
]
with pytest.warns(UserWarning, match="Invalid business_hours"):
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "nine", "close": "18:00"},
)
# Only the close boundary gap should appear (open was invalid).
assert len(board.gaps) == 1
assert board.gaps[0].start_time == time(11, 0)
assert board.gaps[0].end_time == time(18, 0)
def test_build_board_empty():
board = build_board([], "2026-07-28", "Empty Salon")
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
assert board.total_gap_minutes == 0
def test_build_board_source_labeling():
"""Source is correctly set and is_offline derived."""
board = build_board([], "2026-07-28", "Test", source="fixtures")
assert board.source == "fixtures"
assert board.is_offline is True
board2 = build_board([], "2026-07-28", "Test", source="offline")
assert board2.is_offline is True
board3 = build_board([], "2026-07-28", "Test", source="vagaro")
assert board3.is_offline is False
def test_build_board_totals():
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"), # 90 min
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), # 60 min
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
assert board.total_booked_minutes == 150
# Gaps: 10:30-11:00 (30 min), 12:00-18:00 (360 min)
assert board.total_gap_minutes == 390
# ── format_board_text ──────────────────────────────────────────────────────
def test_format_text_includes_offline_label():
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "FIXTURE DATA" in text
def test_format_text_includes_appointments():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Alice" in text
assert "Cut" in text
assert "Claire" in text
assert "09:00" in text
def test_format_text_includes_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Gaps" in text
assert "60 min" in text
def test_format_text_includes_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "CONFIRM" in text
assert "Needs Confirmation" in text
def test_format_text_includes_summary():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Summary" in text
assert "60 min" in text
def test_format_text_no_appointments():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "No appointments" in text
def test_format_text_no_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "No significant gaps" in text
def test_format_text_notes():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", notes="Allergic to ammonia"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Allergic to ammonia" in text
+182
View File
@@ -0,0 +1,182 @@
"""Tests for lumina_skills.domain types."""
from __future__ import annotations
import json
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
# ── Appointment ────────────────────────────────────────────────────────────
def test_appointment_duration():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 30),
client_name="Elena Rossi",
service_name="Balayage + Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
)
assert apt.duration_minutes() == 90
def test_appointment_duration_one_hour():
apt = Appointment(
appointment_id="APT-002",
start_time=datetime(2026, 7, 28, 13, 0),
end_time=datetime(2026, 7, 28, 14, 0),
client_name="Chris Nguyen",
service_name="Men's Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.PENDING,
)
assert apt.duration_minutes() == 60
def test_appointment_time_only():
apt = Appointment(
appointment_id="APT-003",
start_time=datetime(2026, 7, 28, 11, 15),
end_time=datetime(2026, 7, 28, 12, 45),
client_name="Jasmine Patel",
service_name="Root Touch-Up",
staff_name="Maya Torres",
status=AppointmentStatus.CONFIRMED,
)
assert apt.start_time_only() == time(11, 15)
assert apt.end_time_only() == time(12, 45)
def test_appointment_frozen():
"""Appointment is immutable."""
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.CONFIRMED,
)
with pytest.raises(Exception): # FrozenInstanceError
apt.client_name = "Hacker"
def test_appointment_needs_confirmation_default():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.PENDING,
)
assert apt.needs_confirmation is False
def test_appointment_status_enum():
assert AppointmentStatus.CONFIRMED.value == "confirmed"
assert AppointmentStatus.PENDING.value == "pending"
assert AppointmentStatus.CANCELLED.value == "cancelled"
assert AppointmentStatus.COMPLETED.value == "completed"
assert AppointmentStatus.NO_SHOW.value == "no_show"
# ── Gap ────────────────────────────────────────────────────────────────────
def test_gap_creation():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 30),
duration_minutes=90,
staff_name="Claire Bennett",
)
assert gap.duration_minutes == 90
assert gap.staff_name == "Claire Bennett"
def test_gap_with_references():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 0),
duration_minutes=60,
staff_name="Claire Bennett",
preceding_appointment_id="APT-001",
following_appointment_id="APT-002",
)
assert gap.preceding_appointment_id == "APT-001"
assert gap.following_appointment_id == "APT-002"
# ── DayBoard ───────────────────────────────────────────────────────────────
def test_dayboard_to_dict():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Elena Rossi",
service_name="Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
needs_confirmation=False,
)
board = DayBoard(
date="2026-07-28",
salon_name="Lumina Hair Studio & Spa",
source="fixtures",
is_offline=True,
appointments=[apt],
gaps=[],
needs_confirmation=[],
total_booked_minutes=60,
total_gap_minutes=0,
)
d = board.to_dict()
assert d["date"] == "2026-07-28"
assert d["salon_name"] == "Lumina Hair Studio & Spa"
assert d["source"] == "fixtures"
assert d["is_offline"] is True
assert len(d["appointments"]) == 1
assert d["appointments"][0]["client"] == "Elena Rossi"
assert d["total_booked_minutes"] == 60
def test_dayboard_to_dict_serializable():
"""to_dict output must be JSON-serializable."""
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
d = board.to_dict()
# Should not raise.
json.dumps(d)
def test_dayboard_empty():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="offline",
is_offline=True,
)
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the scheduling fixture provider."""
from __future__ import annotations
import json
import pathlib
import tempfile
import pytest
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import Appointment, AppointmentStatus
from lumina_skills.providers.scheduling.fixture_provider import (
load_fixtures,
load_fixture_metadata,
)
# Path to the real fixture file.
_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
def _make_fixture_file(tmp_path: pathlib.Path, data: dict) -> pathlib.Path:
"""Write a fixture dict to a temp JSON file."""
p = tmp_path / "test_fixture.json"
p.write_text(json.dumps(data), encoding="utf-8")
return p
# ── load_fixtures ──────────────────────────────────────────────────────────
def test_load_real_fixture():
"""Load the Claire Bennett fixture file."""
apts = load_fixtures(_FIXTURE_PATH)
assert len(apts) == 7 # 7 appointments in the fixture
assert all(isinstance(a, Appointment) for a in apts)
def test_load_fixture_statuses():
"""Fixture statuses are correctly parsed."""
apts = load_fixtures(_FIXTURE_PATH)
statuses = {a.appointment_id: a.status for a in apts}
assert statuses["APT-001"] == AppointmentStatus.CONFIRMED
assert statuses["APT-002"] == AppointmentStatus.PENDING
assert statuses["APT-007"] == AppointmentStatus.CANCELLED
def test_load_fixture_needs_confirmation():
"""needs_confirmation flag is loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
flags = {a.appointment_id: a.needs_confirmation for a in apts}
assert flags["APT-001"] is False
assert flags["APT-002"] is True
assert flags["APT-004"] is True
def test_load_fixture_notes():
"""Notes are loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
notes = {a.appointment_id: a.notes for a in apts}
assert "ammonia" in notes["APT-001"].lower()
assert "Wedding" in notes["APT-002"]
def test_load_fixture_missing_file(tmp_path: pathlib.Path):
"""FileNotFoundError for missing fixture."""
with pytest.raises(FileNotFoundError):
load_fixtures(tmp_path / "nonexistent.json")
def test_load_fixture_empty_appointments(tmp_path: pathlib.Path):
"""Empty appointments list returns empty list."""
data = {"salon_name": "Test", "date": "2026-01-01", "appointments": []}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts == []
def test_load_fixture_default_status(tmp_path: pathlib.Path):
"""Missing status field defaults to PENDING (via .get default)."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
# No status field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].status == AppointmentStatus.PENDING
def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path):
"""Unknown status string raises ValueError."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "typo_status",
}]
}
path = _make_fixture_file(tmp_path, data)
with pytest.raises(ValueError, match="Unknown appointment status"):
load_fixtures(path)
def test_load_fixture_default_needs_confirmation(tmp_path: pathlib.Path):
"""Missing needs_confirmation defaults to False."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "confirmed",
# No needs_confirmation field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].needs_confirmation is False
def test_load_fixture_malformed_json(tmp_path: pathlib.Path):
"""ValueError for invalid JSON."""
p = tmp_path / "bad.json"
p.write_text("{not valid json}", encoding="utf-8")
with pytest.raises(ValueError):
load_fixtures(p)
# ── load_fixture_metadata ─────────────────────────────────────────────────
def test_load_metadata():
meta = load_fixture_metadata(_FIXTURE_PATH)
assert meta["salon_name"] == "Lumina Hair Studio & Spa"
assert meta["date"] == "2026-07-28"
assert meta["business_hours"]["open"] == "09:00"
assert meta["business_hours"]["close"] == "18:00"
assert len(meta["staff"]) == 2
def test_load_metadata_missing_file(tmp_path: pathlib.Path):
with pytest.raises(FileNotFoundError):
load_fixture_metadata(tmp_path / "nonexistent.json")