Compare commits
2 Commits
e5e179e541
...
15f6a6c713
| Author | SHA1 | Date | |
|---|---|---|---|
| 15f6a6c713 | |||
| 0198ab6881 |
@@ -1,24 +1,30 @@
|
|||||||
# Salon_Assistant / Lumina — operator entrypoints
|
# Salon_Assistant / Lumina — operator entrypoints
|
||||||
# Approved structure: document targets only; do not invoke unimplemented scripts.
|
# Approved structure: document targets only; do not invoke unimplemented scripts.
|
||||||
|
|
||||||
.PHONY: help bootstrap install install-s0-s2 upgrade doctor verify sync-design
|
.PHONY: help bootstrap install install-s0-s2 install-s3-s5 upgrade doctor verify sync-design \
|
||||||
|
install-s0b install-s1 install-s2 install-s3 install-s4 install-s5
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')"
|
@echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Implemented targets:"
|
@echo "Implemented targets:"
|
||||||
@echo " make bootstrap - host prereqs (Docker if missing) [S0b]"
|
@echo " make bootstrap - host prereqs (Docker if missing) [S0b]"
|
||||||
@echo " make install - full install S0b–S2"
|
@echo " make install - full install S0b–S5"
|
||||||
@echo " make install-s0-s2 - install stages S0b through S2 (same as install)"
|
@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 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-s1 - S1 only: repository environment (.env)"
|
@echo " make install-s1 - S1 only: repository environment (.env)"
|
||||||
@echo " make install-s2 - S2 only: model + vision config + smoke"
|
@echo " make install-s2 - S2 only: model + vision config + smoke"
|
||||||
|
@echo " make install-s3 - S3 only: stack alignment (documentation)"
|
||||||
|
@echo " make install-s4 - S4 only: sandbox verify/onboard"
|
||||||
|
@echo " make install-s5 - S5 only: policy overlays + skills sync"
|
||||||
@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 ────────────────────────────────────────────────────
|
||||||
@@ -26,19 +32,52 @@ help:
|
|||||||
bootstrap:
|
bootstrap:
|
||||||
@bash scripts/bootstrap.sh
|
@bash scripts/bootstrap.sh
|
||||||
|
|
||||||
install install-s0-s2:
|
install:
|
||||||
@bash scripts/install.sh
|
@bash scripts/install.sh
|
||||||
|
|
||||||
|
install-s0-s2:
|
||||||
|
@bash scripts/install.sh --stage s0b
|
||||||
|
@bash scripts/install.sh --stage s1
|
||||||
|
@bash scripts/install.sh --stage s2
|
||||||
|
|
||||||
|
install-s3-s5:
|
||||||
|
@bash scripts/install.sh --stage s3-s5
|
||||||
|
|
||||||
|
install-s0b:
|
||||||
|
@bash scripts/install.sh --stage s0b
|
||||||
|
|
||||||
install-s1:
|
install-s1:
|
||||||
@bash scripts/install.sh --stage s1
|
@bash scripts/install.sh --stage s1
|
||||||
|
|
||||||
install-s2:
|
install-s2:
|
||||||
@bash scripts/install.sh --stage s2
|
@bash scripts/install.sh --stage s2
|
||||||
|
|
||||||
# ── Stubbed targets (S3+ not yet implemented) ──────────────────────────────
|
install-s3:
|
||||||
|
@bash scripts/install.sh --stage s3
|
||||||
|
|
||||||
upgrade doctor verify:
|
install-s4:
|
||||||
@echo "not implemented — S3+ stages pending" >&2; exit 1
|
@bash scripts/install.sh --stage s4
|
||||||
|
|
||||||
|
install-s5:
|
||||||
|
@bash scripts/install.sh --stage s5
|
||||||
|
|
||||||
|
# ── S6: Doctor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
doctor:
|
||||||
|
@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:"
|
||||||
|
|||||||
+49
-7
@@ -1,13 +1,55 @@
|
|||||||
# Hermes agent package (scaffold)
|
# Hermes agent package
|
||||||
|
|
||||||
**Status:** Structure only until **build**.
|
**Status:** Config fragments and manifest ready for S4–S5.
|
||||||
|
|
||||||
## Intended contents
|
## Structure
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `config/` | Onboard fragments (model, MCP, channels placeholders) |
|
| `config/inference.yaml` | Inference config fragment (main + aux vision models) |
|
||||||
| `skills-manifest/` | Which Lumina skills ship with the profile |
|
| `config/mcp-servers.yaml` | MCP server config fragment (Square, QBO — enabled at S7) |
|
||||||
| Identity templates | SOUL / USER / assistant naming (Claire demo persona when implemented) |
|
| `config/channels.yaml` | Messaging channel config fragment (WhatsApp, Telegram, Email — enabled at S7) |
|
||||||
|
| `skills-manifest/manifest.yaml` | Skills manifest listing all Lumina skills for sync |
|
||||||
|
| `identity/assistant.yaml` | Identity template (name, role, capabilities, constraints) |
|
||||||
|
|
||||||
Onboard via `nemohermes onboard` using this package — never hand-edit in-sandbox config as SSOT.
|
## Onboard modes
|
||||||
|
|
||||||
|
### Attach (default for UAT)
|
||||||
|
|
||||||
|
When the sandbox already exists (e.g., `hermes` on this host), S4 skips
|
||||||
|
onboard and verifies the sandbox is healthy. Config fragments are used as
|
||||||
|
reference only — the live config is managed by `nemohermes` sealed commands.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Attach mode: verify sandbox exists and is healthy
|
||||||
|
nemohermes <name> status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Onboard (clean host)
|
||||||
|
|
||||||
|
On a fresh host, use `nemohermes onboard` with this agent package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Onboard with agent package (dry-run first)
|
||||||
|
nemohermes onboard --from-dir agents/hermes --dry-run
|
||||||
|
|
||||||
|
# Onboard for real
|
||||||
|
nemohermes onboard --from-dir agents/hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
The onboard process:
|
||||||
|
1. Creates the sandbox container
|
||||||
|
2. Applies inference config from `.env`
|
||||||
|
3. Registers the identity template
|
||||||
|
4. Skills are synced separately in S5
|
||||||
|
|
||||||
|
## Platform-first
|
||||||
|
|
||||||
|
All config mutations use `nemohermes` sealed commands. Never hand-edit
|
||||||
|
in-sandbox config as SSOT. The fragments in this directory are the
|
||||||
|
product's source of truth for what gets configured.
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
|
||||||
|
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||||
|
- [design/DESIGN_PLAN.md §2](../../design/DESIGN_PLAN.md) — Host automation (no custom control API)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# agents/hermes/config/channels.yaml
|
||||||
|
# Messaging channel config fragment for nemohermes onboard.
|
||||||
|
#
|
||||||
|
# Channels are registered via nemohermes <name> channels add, not by
|
||||||
|
# hand-editing in-sandbox config. This file documents the planned channels.
|
||||||
|
#
|
||||||
|
# Platform-first: channel mutations use nemohermes <name> channels add/stop/start.
|
||||||
|
|
||||||
|
# Planned channels (enabled at S7 when connected):
|
||||||
|
# - whatsapp
|
||||||
|
# - telegram
|
||||||
|
# - email
|
||||||
|
|
||||||
|
# Rules:
|
||||||
|
# - Owner ↔ agent: full messaging
|
||||||
|
# - Client outbound: draft-first only (no silent send/publish)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# agents/hermes/config/inference.yaml
|
||||||
|
# Inference config fragment for nemohermes onboard.
|
||||||
|
#
|
||||||
|
# This fragment is merged into the sandbox's managed config during onboard.
|
||||||
|
# Values are resolved from .env at install time.
|
||||||
|
#
|
||||||
|
# Platform-first: the actual config write is done by nemohermes inference set,
|
||||||
|
# not by hand-editing in-sandbox files.
|
||||||
|
|
||||||
|
# Main model endpoint (OpenAI-compatible)
|
||||||
|
# Resolved from LUMINA_INFERENCE_BASE_URL and LUMINA_INFERENCE_MODEL
|
||||||
|
inference:
|
||||||
|
provider: compatible-endpoint
|
||||||
|
# model: set by nemohermes inference set from .env
|
||||||
|
# base_url: set by nemohermes inference set from .env
|
||||||
|
|
||||||
|
# Auxiliary vision model
|
||||||
|
# Resolved from LUMINA_VISION_MODEL
|
||||||
|
auxiliary:
|
||||||
|
vision:
|
||||||
|
# model: set from LUMINA_VISION_MODEL (often same as main if multimodal)
|
||||||
|
pass
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# agents/hermes/config/mcp-servers.yaml
|
||||||
|
# MCP server config fragment for nemohermes onboard.
|
||||||
|
#
|
||||||
|
# Lists MCP servers that may be registered at S7 (SaaS connections).
|
||||||
|
# At S3–S5, this is a placeholder — no live MCP servers are configured.
|
||||||
|
#
|
||||||
|
# Platform-first: MCP registration is done via nemohermes <name> mcp add,
|
||||||
|
# not by hand-editing in-sandbox config.
|
||||||
|
|
||||||
|
mcp_servers:
|
||||||
|
# Square — remote MCP (enabled at S7 when connected)
|
||||||
|
# - name: square
|
||||||
|
# url: ${SQUARE_MCP_URL}
|
||||||
|
# tools:
|
||||||
|
# include:
|
||||||
|
# - bookings.*
|
||||||
|
# - customers.*
|
||||||
|
# - catalog.*
|
||||||
|
# - inventory.*
|
||||||
|
# exclude:
|
||||||
|
# - payments.*
|
||||||
|
# - refunds.*
|
||||||
|
# - cards.*
|
||||||
|
# - checkout.*
|
||||||
|
# - payouts.*
|
||||||
|
|
||||||
|
# QuickBooks Online — local MCP (enabled at S7 when connected)
|
||||||
|
# - name: qbo
|
||||||
|
# url: http://lumina-qbo-mcp:3000 # compose service
|
||||||
|
# tools:
|
||||||
|
# include:
|
||||||
|
# - reports.*
|
||||||
|
# - search.*
|
||||||
|
# - get.*
|
||||||
|
# exclude:
|
||||||
|
# - create_payment.*
|
||||||
|
# - bill_payment.*
|
||||||
|
# - write.*
|
||||||
|
# - update.*
|
||||||
|
# - delete.*
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# agents/hermes/identity/assistant.yaml
|
||||||
|
# Lumina identity template — minimal, product-safe.
|
||||||
|
#
|
||||||
|
# This template defines the assistant's identity for the Hermes sandbox.
|
||||||
|
# The owner's chosen name replaces the default at onboard time.
|
||||||
|
#
|
||||||
|
# Rules:
|
||||||
|
# - Default name: "Lumina" (generic; owner renames at setup)
|
||||||
|
# - No hardcoded persona details (demo "Claire" is a fixture, not identity)
|
||||||
|
# - Owner name/profile persists across upgrades (volume-backed)
|
||||||
|
|
||||||
|
identity:
|
||||||
|
# Default display name (overridden by owner at setup)
|
||||||
|
name: Lumina
|
||||||
|
# Role description
|
||||||
|
role: "Salon and spa owner assistant"
|
||||||
|
# Capabilities summary (used in intro messages)
|
||||||
|
capabilities:
|
||||||
|
- daily operations board
|
||||||
|
- appointment availability
|
||||||
|
- client management
|
||||||
|
- books and finance overview
|
||||||
|
- social media drafts
|
||||||
|
- vendor communications
|
||||||
|
# Safety constraints
|
||||||
|
constraints:
|
||||||
|
- draft-first outbound messaging
|
||||||
|
- no agent payments
|
||||||
|
- no silent send or publish
|
||||||
|
- deterministic facts from tools; inference for wording only
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# agents/hermes/skills-manifest/manifest.yaml
|
||||||
|
# Lumina skills manifest — which skills ship with the Hermes profile.
|
||||||
|
#
|
||||||
|
# This manifest is used by the install script (S5) to determine which
|
||||||
|
# skill directories to sync into the sandbox via nemohermes skill install.
|
||||||
|
#
|
||||||
|
# Skills are organized by domain. Each entry maps to a directory under
|
||||||
|
# skills/ in the repository root.
|
||||||
|
|
||||||
|
skills:
|
||||||
|
# ── Daily operations ──────────────────────────────────────────────────
|
||||||
|
- name: daily-board
|
||||||
|
domain: operations
|
||||||
|
description: "Today's salon board: appointments, tasks, priorities"
|
||||||
|
|
||||||
|
- name: availability
|
||||||
|
domain: operations
|
||||||
|
description: "Check and display appointment availability"
|
||||||
|
|
||||||
|
- name: service-menu
|
||||||
|
domain: operations
|
||||||
|
description: "Service catalog and pricing"
|
||||||
|
|
||||||
|
- name: retail-stock
|
||||||
|
domain: operations
|
||||||
|
description: "Retail product inventory levels"
|
||||||
|
|
||||||
|
# ── Client management ─────────────────────────────────────────────────
|
||||||
|
- name: client-card
|
||||||
|
domain: clients
|
||||||
|
description: "Client profile and history"
|
||||||
|
|
||||||
|
- name: draft-client-message
|
||||||
|
domain: clients
|
||||||
|
description: "Draft outbound messages to clients (draft-first)"
|
||||||
|
|
||||||
|
# ── Books / finance ───────────────────────────────────────────────────
|
||||||
|
- name: books-snapshot
|
||||||
|
domain: books
|
||||||
|
description: "QuickBooks snapshot: P&L, balance, cash"
|
||||||
|
|
||||||
|
- name: ar-open-invoices
|
||||||
|
domain: books
|
||||||
|
description: "Accounts receivable: open invoices"
|
||||||
|
|
||||||
|
- name: ap-bills-due
|
||||||
|
domain: books
|
||||||
|
description: "Accounts payable: bills due"
|
||||||
|
|
||||||
|
- name: vendor-spend
|
||||||
|
domain: books
|
||||||
|
description: "Vendor spending summary"
|
||||||
|
|
||||||
|
- name: vendor-inbox
|
||||||
|
domain: books
|
||||||
|
description: "Vendor communications and documents"
|
||||||
|
|
||||||
|
- name: draft-invoice
|
||||||
|
domain: books
|
||||||
|
description: "Draft invoices for clients (draft-first)"
|
||||||
|
|
||||||
|
# ── Social / marketing ────────────────────────────────────────────────
|
||||||
|
- name: social-draft
|
||||||
|
domain: social
|
||||||
|
description: "Draft social media posts (draft-first; vision aux)"
|
||||||
|
|
||||||
|
- name: weekly-digest
|
||||||
|
domain: social
|
||||||
|
description: "Weekly business digest for the owner"
|
||||||
|
|
||||||
|
# ── System ────────────────────────────────────────────────────────────
|
||||||
|
- name: remember-forget
|
||||||
|
domain: system
|
||||||
|
description: "Confirm-to-remember persistence; forget entries"
|
||||||
|
|
||||||
|
- name: setup-education
|
||||||
|
domain: system
|
||||||
|
description: "Owner-safe connect education and capability report"
|
||||||
|
|
||||||
|
- name: publish-boundary-test
|
||||||
|
domain: system
|
||||||
|
description: "Boundary test: verify publish/send denials"
|
||||||
+37
-4
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,55 @@
|
|||||||
# Compose (scaffold)
|
# Compose — product-managed services
|
||||||
|
|
||||||
**Status:** No `docker-compose.yml` until **build**.
|
**Status:** Optional stub. Not required for S3–S5 UAT attach path.
|
||||||
|
|
||||||
Will host gateway/sandbox alignment, local QBO MCP, webhooks, volumes — per [design/DESIGN_PLAN.md](../../design/DESIGN_PLAN.md).
|
## Host vs container boundaries
|
||||||
|
|
||||||
|
| Layer | Owner | Managed by |
|
||||||
|
|-------|-------|------------|
|
||||||
|
| **Host** | Operator | `nemohermes` CLI, `openshell` CLI, product scripts |
|
||||||
|
| **Gateway** | OpenShell | `nemohermes` (sealed transactions) |
|
||||||
|
| **Hermes sandbox** | OpenShell | `nemohermes <name> start/stop/rebuild/destroy` |
|
||||||
|
| **Local MCP / webhooks** | Product | `docker compose` (this file) — optional |
|
||||||
|
|
||||||
|
**Key principle:** OpenShell owns the Hermes sandbox container. The product does NOT define the sandbox in `docker-compose.yml`. The sandbox is created and managed exclusively through `nemohermes` commands.
|
||||||
|
|
||||||
|
## What this compose file provides
|
||||||
|
|
||||||
|
An optional Docker Compose file for **product-managed services** that run alongside the Hermes sandbox:
|
||||||
|
|
||||||
|
- **QBO MCP** — local MCP server for QuickBooks Online (stdio or network-attached)
|
||||||
|
- **Vagaro webhooks** — REST endpoint for Vagaro webhook ingestion
|
||||||
|
|
||||||
|
These services share the `lumina` Docker network. The Hermes sandbox container is NOT part of this compose file.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- **UAT attach (S3–S5):** Not needed. The sandbox runs via `nemohermes`; no local MCP or webhooks yet.
|
||||||
|
- **S7+ (SaaS connections):** Uncomment and configure the relevant service when connecting Square, QBO, or Vagaro.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# When services are uncommented and configured:
|
||||||
|
cd deploy/compose
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
| Volume | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `qbo-mcp-data` | QBO MCP state (tokens, cache) |
|
||||||
|
| `vagaro-webhooks-data` | Webhook processing state |
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
|
||||||
|
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||||
|
- [design/DESIGN_PLAN.md §6](../../design/DESIGN_PLAN.md) — MCP and SaaS integration
|
||||||
|
- [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — overall architecture
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Salon_Assistant / Lumina — optional product services
|
||||||
|
#
|
||||||
|
# This compose file is OPTIONAL. It is NOT required for the S3–S5 UAT attach path.
|
||||||
|
# OpenShell manages the Hermes sandbox container directly; nemohermes owns
|
||||||
|
# sandbox lifecycle (start/stop/rebuild/destroy). This compose file exists only
|
||||||
|
# for future local MCP processes and webhook services that run alongside the
|
||||||
|
# sandbox on the same Docker network.
|
||||||
|
#
|
||||||
|
# Usage (when needed):
|
||||||
|
# cd deploy/compose && docker compose up -d
|
||||||
|
#
|
||||||
|
# See deploy/compose/README.md for details.
|
||||||
|
|
||||||
|
# ── Network ────────────────────────────────────────────────────────────────
|
||||||
|
# All product-managed services share this network. The Hermes sandbox container
|
||||||
|
# is NOT defined here — it is managed by nemohermes/OpenShell.
|
||||||
|
networks:
|
||||||
|
lumina:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# ── Volumes ────────────────────────────────────────────────────────────────
|
||||||
|
volumes:
|
||||||
|
qbo-mcp-data:
|
||||||
|
vagaro-webhooks-data:
|
||||||
|
|
||||||
|
# ── Services (stubs — enabled when SaaS connections are configured) ────────
|
||||||
|
# Uncomment and configure when the corresponding SaaS integration is connected.
|
||||||
|
|
||||||
|
# qbo-mcp:
|
||||||
|
# image: ghcr.io/ty-tech/qbo-mcp:latest # placeholder — real image at S7
|
||||||
|
# container_name: lumina-qbo-mcp
|
||||||
|
# networks:
|
||||||
|
# - lumina
|
||||||
|
# volumes:
|
||||||
|
# - qbo-mcp-data:/data
|
||||||
|
# environment:
|
||||||
|
# - QBO_CLIENT_ID=${QBO_CLIENT_ID}
|
||||||
|
# - QBO_CLIENT_SECRET=${QBO_CLIENT_SECRET}
|
||||||
|
# - QBO_REFRESH_TOKEN=${QBO_REFRESH_TOKEN}
|
||||||
|
# - QBO_REALM_ID=${QBO_REALM_ID}
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
# vagaro-webhooks:
|
||||||
|
# image: ghcr.io/ty-tech/vagaro-webhooks:latest # placeholder — real image at S7
|
||||||
|
# container_name: lumina-vagaro-webhooks
|
||||||
|
# networks:
|
||||||
|
# - lumina
|
||||||
|
# volumes:
|
||||||
|
# - vagaro-webhooks-data:/data
|
||||||
|
# environment:
|
||||||
|
# - VAGARO_API_KEY=${VAGARO_API_KEY}
|
||||||
|
# - VAGARO_WEBHOOK_SECRET=${VAGARO_WEBHOOK_SECRET}
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:8090:8080"
|
||||||
|
# restart: unless-stopped
|
||||||
+71
-9
@@ -1,17 +1,19 @@
|
|||||||
# Install
|
# Install
|
||||||
|
|
||||||
**Status:** Stages S0–S2 implemented. S3–S7 pending.
|
**Status:** Stages S0–S6 implemented. S7 pending.
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
|
|
||||||
| Stage | Where | Outcome | Status |
|
| Stage | Where | Outcome | Status |
|
||||||
|-------|--------|---------|--------|
|
|-------|-------|---------|--------|
|
||||||
| S0 | Human | Host per [DEPLOYER_HOST.md](DEPLOYER_HOST.md) | ✅ Procedural |
|
| S0 | Human | Host per [DEPLOYER_HOST.md](DEPLOYER_HOST.md) | ✅ Procedural |
|
||||||
| S0b | Host script | Docker installed if missing | ✅ Implemented |
|
| S0b | Host script | Docker installed if missing | ✅ Implemented |
|
||||||
| S1 | Host script | Repo env, `.env` from `.env.example` | ✅ Implemented |
|
| S1 | Host script | Repo env, `.env` from `.env.example` | ✅ Implemented |
|
||||||
| S2 | Host script | Main + aux vision config; vision smoke | ✅ Implemented |
|
| S2 | Host script | Main + aux vision config; vision smoke | ✅ Implemented |
|
||||||
| S3–S5 | Host → Compose / `nemohermes` | Stack, sandbox, policy, skills | ⏳ Pending |
|
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
|
||||||
| S6 | Host script | Doctor green | ⏳ Pending |
|
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
||||||
|
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
|
||||||
|
| 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)
|
||||||
@@ -86,22 +88,82 @@ make install-s2
|
|||||||
|
|
||||||
**If the gateway is not yet connected:** the script validates the endpoint and skips the `openshell` write. S3+ will handle full gateway configuration.
|
**If the gateway is not yet connected:** the script validates the endpoint and skips the `openshell` write. S3+ will handle full gateway configuration.
|
||||||
|
|
||||||
## Run all stages (S0b–S2)
|
## S3: Stack alignment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh --stage s3
|
||||||
|
# or
|
||||||
|
make install-s3
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Documents that OpenShell manages the Hermes sandbox container.
|
||||||
|
- The product compose file (`deploy/compose/docker-compose.yml`) is optional — used only for local MCP and webhook services at S7+.
|
||||||
|
- No action needed for UAT attach path.
|
||||||
|
|
||||||
|
**Key principle:** OpenShell owns the sandbox. The product does NOT define the sandbox in `docker-compose.yml`.
|
||||||
|
|
||||||
|
## S4: Sandbox verification or onboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install/s4-sandbox.sh # attach mode (default)
|
||||||
|
./scripts/install/s4-sandbox.sh --mode attach
|
||||||
|
./scripts/install/s4-sandbox.sh --mode onboard
|
||||||
|
./scripts/install/s4-sandbox.sh --mode onboard --dry-run
|
||||||
|
# or
|
||||||
|
make install-s4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attach mode (default):**
|
||||||
|
- Verifies the sandbox exists and is healthy.
|
||||||
|
- Checks that the agent package (`agents/hermes/`) is present.
|
||||||
|
- No destructive operations.
|
||||||
|
|
||||||
|
**Onboard mode (clean host):**
|
||||||
|
- Creates a new sandbox from the agent package using `nemohermes onboard`.
|
||||||
|
- If a sandbox with the same name already exists, falls back to attach mode for safety.
|
||||||
|
- Use `--dry-run` to preview without executing.
|
||||||
|
|
||||||
|
## S5: Policy overlays + skills sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install/s5-policy-skills.sh # policy + skills
|
||||||
|
./scripts/install/s5-policy-skills.sh --policy-only
|
||||||
|
./scripts/install/s5-policy-skills.sh --skills-only
|
||||||
|
# or
|
||||||
|
make install-s5
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. **Policy:** Applies the inference policy overlay from `policy/openshell/overlays/inference.yaml` via `nemohermes policy-add`. Existing balanced-tier presets (npm, pypi, huggingface, brew) are preserved.
|
||||||
|
2. **Skills:** Iterates skill directories under `skills/` (skipping `_lib`) and installs each via `nemohermes skill install`. Skills without `SKILL.md` are skipped with a warning.
|
||||||
|
|
||||||
|
**Additive only:** policy-add never removes existing presets. Skills install is idempotent.
|
||||||
|
|
||||||
|
## Run all stages (S0b–S5)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/install.sh
|
./scripts/install.sh
|
||||||
# or
|
# or
|
||||||
make install
|
make install
|
||||||
# or
|
|
||||||
make install-s0-s2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## After install (S0–S2)
|
## Run S3–S5 only (attach path)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh --stage s3-s5
|
||||||
|
# or
|
||||||
|
make install-s3-s5
|
||||||
|
```
|
||||||
|
|
||||||
|
## After install (S0–S6)
|
||||||
|
|
||||||
- Verify `.env` values are correct for your environment.
|
- Verify `.env` values are correct for your environment.
|
||||||
- Continue with S3+ when implemented (compose stack, sandbox creation, policy, skills).
|
- Check policy: `nemohermes <name> policy-list`
|
||||||
|
- 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) (S1–S5) for operational scenarios.
|
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
||||||
|
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
|
||||||
|
|
||||||
## UAT host notes
|
## UAT host notes
|
||||||
|
|
||||||
|
|||||||
+43
-3
@@ -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
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
# OpenShell policy (scaffold)
|
# OpenShell policy
|
||||||
|
|
||||||
**Status:** Structure only — no live policy applied until **build**.
|
**Status:** Base policy and overlays ready for S5.
|
||||||
|
|
||||||
## Intended contents
|
## Structure
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `policy.yaml` (or equivalent) | Deny-by-default base for salon egress |
|
| `base.yaml` | Deny-by-default intent (reference; not applied directly) |
|
||||||
| `overlays/` | Per-integration allowlists (Square MCP host, QBO local, channels, etc.) |
|
| `overlays/inference.yaml` | Inference endpoint (applied at S5) |
|
||||||
|
| `overlays/square-mcp.yaml` | Square MCP (applied at S7 when connected) |
|
||||||
|
| `overlays/quickbooks.yaml` | QuickBooks Online (applied at S7 when connected) |
|
||||||
|
| `overlays/vagaro.yaml` | Vagaro REST API (applied at S7 when connected) |
|
||||||
|
| `overlays/channels.yaml` | Messaging channels (applied at S7 when connected) |
|
||||||
|
|
||||||
Apply via platform CLIs only: `openshell policy set`, `nemohermes <name> policy-add` / `policy-remove`.
|
## Policy lifecycle
|
||||||
|
|
||||||
See [design/mcp-integrations.md](../../design/mcp-integrations.md) and [docs/POLICY.md](../../docs/POLICY.md).
|
1. **Install (S5):** Apply inference overlay. Existing balanced-tier presets (npm, pypi, huggingface, brew) are preserved.
|
||||||
|
2. **Connect (S7):** Apply SaaS/channel overlays as integrations are enabled.
|
||||||
|
3. **Upgrade:** Re-apply all overlays (idempotent via `--yes`).
|
||||||
|
4. **Doctor (S6):** Verify active policy matches expected state.
|
||||||
|
|
||||||
|
## Apply policy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply inference overlay (S5)
|
||||||
|
nemohermes <name> policy-add --from-file policy/openshell/overlays/inference.yaml --yes
|
||||||
|
|
||||||
|
# Apply SaaS overlay (S7, when connected)
|
||||||
|
nemohermes <name> policy-add --from-file policy/openshell/overlays/square-mcp.yaml --yes
|
||||||
|
|
||||||
|
# Apply built-in channel preset (S7, when connected)
|
||||||
|
nemohermes <name> policy-add telegram --yes
|
||||||
|
|
||||||
|
# List active presets
|
||||||
|
nemohermes <name> policy-list
|
||||||
|
|
||||||
|
# Export current policy
|
||||||
|
nemohermes <name> policy-get
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key principles
|
||||||
|
|
||||||
|
- **Additive only:** policy-add never removes existing presets.
|
||||||
|
- **No wipe:** Never remove balanced-tier presets (npm, pypi, huggingface, brew).
|
||||||
|
- **Always deny:** Social publish, payment/refund/payout/bill-pay are never allowed.
|
||||||
|
- **Platform-first:** All policy mutations via `nemohermes` / `openshell` CLIs.
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
|
||||||
|
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||||
|
- [docs/POLICY.md](../../docs/POLICY.md) — Full policy documentation
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# policy/openshell/base.yaml
|
||||||
|
# Lumina base policy — reference document.
|
||||||
|
#
|
||||||
|
# This file documents the deny-by-default intent for the Lumina sandbox.
|
||||||
|
# It is NOT applied directly. The live policy is managed by nemohermes
|
||||||
|
# policy-add/policy-remove commands.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add --from-file <path>
|
||||||
|
#
|
||||||
|
# ── Deny-by-default intent ────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The sandbox starts with NO network egress except what is explicitly allowed
|
||||||
|
# by applied policy presets. The base tier (balanced) provides:
|
||||||
|
#
|
||||||
|
# npm, pypi, huggingface, brew — package management (always allowed)
|
||||||
|
#
|
||||||
|
# Lumina adds overlays for:
|
||||||
|
# - inference endpoints (main model + vision)
|
||||||
|
# - SaaS integrations (Square, QBO, Vagaro) — when connected
|
||||||
|
# - messaging channels (WhatsApp, Telegram, Email) — when connected
|
||||||
|
#
|
||||||
|
# ── Always deny ────────────────────────────────────────────────────────────
|
||||||
|
# - Social publish APIs (no silent publish)
|
||||||
|
# - Payment/refund/payout/bill-pay endpoints
|
||||||
|
# - Any host not explicitly listed in an overlay
|
||||||
|
#
|
||||||
|
# ── Policy lifecycle ───────────────────────────────────────────────────────
|
||||||
|
# 1. Install (S5): apply base overlays (inference)
|
||||||
|
# 2. Connect (S7): apply SaaS/channel overlays as integrations are enabled
|
||||||
|
# 3. Upgrade: re-apply all overlays (idempotent)
|
||||||
|
# 4. Doctor (S6): verify active policy matches expected state
|
||||||
|
#
|
||||||
|
# See docs/POLICY.md for full policy documentation.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# policy/openshell/overlays/channels.yaml
|
||||||
|
# Messaging channels policy overlay (applied at S7 when channels are connected).
|
||||||
|
#
|
||||||
|
# Allows the sandbox to reach messaging channel APIs. Each channel has
|
||||||
|
# its own built-in preset in nemohermes (telegram, whatsapp, slack, etc.).
|
||||||
|
# This overlay documents which channels Lumina uses.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add <preset-name> --yes
|
||||||
|
# (Built-in presets; no --from-file needed for standard channels)
|
||||||
|
#
|
||||||
|
# Planned channels:
|
||||||
|
# - whatsapp (nemohermes built-in preset)
|
||||||
|
# - telegram (nemohermes built-in preset)
|
||||||
|
# - email (handled by openshell provider store)
|
||||||
|
#
|
||||||
|
# Rules:
|
||||||
|
# - Owner ↔ agent: full messaging
|
||||||
|
# - Client outbound: draft-first only (no silent send/publish)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# policy/openshell/overlays/inference.yaml
|
||||||
|
# Inference endpoint policy overlay for Lumina.
|
||||||
|
#
|
||||||
|
# Allows the sandbox to reach the configured inference endpoint through
|
||||||
|
# the OpenShell gateway. The gateway resolves inference.local to the
|
||||||
|
# actual endpoint URL from .env.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||||
|
#
|
||||||
|
# This overlay is additive — it does not remove existing presets.
|
||||||
|
|
||||||
|
preset:
|
||||||
|
name: lumina-inference
|
||||||
|
network_policies:
|
||||||
|
lumina-inference:
|
||||||
|
name: lumina-inference
|
||||||
|
endpoints:
|
||||||
|
- host: inference.local
|
||||||
|
port: 443
|
||||||
|
protocol: rest
|
||||||
|
enforcement: enforce
|
||||||
|
rules:
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /v1/chat/completions
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /v1/messages
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /v1/responses
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /v1/completions
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /v1/embeddings
|
||||||
|
- allow:
|
||||||
|
method: GET
|
||||||
|
path: /v1/models
|
||||||
|
- allow:
|
||||||
|
method: GET
|
||||||
|
path: /v1/models/**
|
||||||
|
binaries:
|
||||||
|
- path: /usr/local/bin/hermes
|
||||||
|
# Glob supported by nemohermes policy engine (verified against built-in
|
||||||
|
# presets: huggingface, nous_research, npm_yarn, pypi all use python3*)
|
||||||
|
- path: /usr/bin/python3*
|
||||||
|
- path: /opt/hermes/.venv/bin/python
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# policy/openshell/overlays/quickbooks.yaml
|
||||||
|
# QuickBooks Online policy overlay (applied at S7 when QBO is connected).
|
||||||
|
#
|
||||||
|
# Allows the sandbox to reach QuickBooks Online APIs for read operations
|
||||||
|
# (reports, search, get). Write/update/delete operations are excluded
|
||||||
|
# at the MCP tool level.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||||
|
|
||||||
|
preset:
|
||||||
|
name: quickbooks-online
|
||||||
|
network_policies:
|
||||||
|
quickbooks-online:
|
||||||
|
name: quickbooks-online
|
||||||
|
endpoints:
|
||||||
|
- host: quickbooks.api.intuit.com
|
||||||
|
port: 443
|
||||||
|
protocol: rest
|
||||||
|
enforcement: enforce
|
||||||
|
rules:
|
||||||
|
- allow:
|
||||||
|
method: GET
|
||||||
|
path: /**
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /**
|
||||||
|
- host: oauth.platform.intuit.com
|
||||||
|
port: 443
|
||||||
|
protocol: rest
|
||||||
|
enforcement: enforce
|
||||||
|
rules:
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /**
|
||||||
|
binaries:
|
||||||
|
- path: /usr/local/bin/hermes
|
||||||
|
- path: /usr/bin/python3*
|
||||||
|
- path: /opt/hermes/.venv/bin/python
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# policy/openshell/overlays/square-mcp.yaml
|
||||||
|
# Square MCP policy overlay (applied at S7 when Square is connected).
|
||||||
|
#
|
||||||
|
# Allows the sandbox to reach Square's MCP server for bookings, customers,
|
||||||
|
# catalog, and inventory reads. Payment/refund/payout tools are excluded
|
||||||
|
# at the MCP tool level, not the network level.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||||
|
|
||||||
|
preset:
|
||||||
|
name: square-mcp
|
||||||
|
network_policies:
|
||||||
|
square-mcp:
|
||||||
|
name: square-mcp
|
||||||
|
endpoints:
|
||||||
|
- host: connect.squareup.com
|
||||||
|
port: 443
|
||||||
|
protocol: rest
|
||||||
|
enforcement: enforce
|
||||||
|
rules:
|
||||||
|
- allow:
|
||||||
|
method: GET
|
||||||
|
path: /**
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /**
|
||||||
|
binaries:
|
||||||
|
- path: /usr/local/bin/hermes
|
||||||
|
- path: /usr/bin/python3*
|
||||||
|
- path: /opt/hermes/.venv/bin/python
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# policy/openshell/overlays/vagaro.yaml
|
||||||
|
# Vagaro policy overlay (applied at S7 when Vagaro is connected).
|
||||||
|
#
|
||||||
|
# Allows the sandbox to reach Vagaro's REST API for appointments, clients,
|
||||||
|
# services, and staff data. Webhook ingestion is handled by the local
|
||||||
|
# vagaro-webhooks service (compose-managed), not direct sandbox egress.
|
||||||
|
#
|
||||||
|
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||||
|
|
||||||
|
preset:
|
||||||
|
name: vagaro
|
||||||
|
network_policies:
|
||||||
|
vagaro:
|
||||||
|
name: vagaro
|
||||||
|
endpoints:
|
||||||
|
- host: api.vagaro.com
|
||||||
|
port: 443
|
||||||
|
protocol: rest
|
||||||
|
enforcement: enforce
|
||||||
|
rules:
|
||||||
|
- allow:
|
||||||
|
method: GET
|
||||||
|
path: /**
|
||||||
|
- allow:
|
||||||
|
method: POST
|
||||||
|
path: /**
|
||||||
|
- allow:
|
||||||
|
method: PUT
|
||||||
|
path: /**
|
||||||
|
binaries:
|
||||||
|
- path: /usr/local/bin/hermes
|
||||||
|
- path: /usr/bin/python3*
|
||||||
|
- path: /opt/hermes/.venv/bin/python
|
||||||
+16
-4
@@ -1,6 +1,6 @@
|
|||||||
# Host scripts
|
# Host scripts
|
||||||
|
|
||||||
**Status:** S0b–S2 implemented. S3–S7 pending.
|
**Status:** S0b–S6 implemented. S7 pending.
|
||||||
|
|
||||||
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
||||||
|
|
||||||
@@ -9,11 +9,13 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
|||||||
| Script | Role | Status |
|
| Script | Role | Status |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| `bootstrap.sh` | Host prereqs; install Docker if missing | ✅ S0b |
|
| `bootstrap.sh` | Host prereqs; install Docker if missing | ✅ S0b |
|
||||||
| `install.sh` | Staged installer (S0b–S2) | ✅ S0b–S2 |
|
| `install.sh` | Staged installer (S0b–S5) | ✅ S0b–S5 |
|
||||||
| `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 |
|
| `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 |
|
||||||
| `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/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
|
||||||
@@ -30,18 +32,28 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
|||||||
# Bootstrap (Docker if missing)
|
# Bootstrap (Docker if missing)
|
||||||
./scripts/bootstrap.sh
|
./scripts/bootstrap.sh
|
||||||
|
|
||||||
# Full install (S0b–S2)
|
# Full install (S0b–S5)
|
||||||
./scripts/install.sh
|
./scripts/install.sh
|
||||||
|
|
||||||
# Individual stages
|
# Individual stages
|
||||||
./scripts/install.sh --stage s1 # env only
|
./scripts/install.sh --stage s1 # env only
|
||||||
./scripts/install.sh --stage s2 # models only
|
./scripts/install.sh --stage s2 # models only
|
||||||
|
./scripts/install.sh --stage s4 # sandbox verify/onboard
|
||||||
|
./scripts/install.sh --stage s5 # policy + skills
|
||||||
|
./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
|
||||||
make install-s1
|
make install-s1
|
||||||
make install-s2
|
make install-s2
|
||||||
|
make install-s3-s5
|
||||||
|
make install-s5
|
||||||
|
make doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design reference
|
## Design reference
|
||||||
|
|||||||
Executable
+355
@@ -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
|
||||||
+41
-13
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# scripts/install.sh — Lumina staged installer
|
# scripts/install.sh — Lumina staged installer
|
||||||
#
|
#
|
||||||
# Runs install stages S0b–S2 (S3+ not yet implemented).
|
# Runs install stages S0b–S5.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/install.sh # run all implemented stages (S0b–S2)
|
# ./scripts/install.sh # run all implemented stages (S0b–S5)
|
||||||
# ./scripts/install.sh --stage s1 # run only S1
|
# ./scripts/install.sh --stage s1 # run only S1
|
||||||
# ./scripts/install.sh --stage s2 # run only S2
|
# ./scripts/install.sh --stage s3-s5 # run S3 through S5
|
||||||
# ./scripts/install.sh --help
|
# ./scripts/install.sh --help
|
||||||
#
|
#
|
||||||
# All stages are idempotent. Re-running is safe.
|
# All stages are idempotent. Re-running is safe.
|
||||||
@@ -25,23 +25,27 @@ usage() {
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
Run Lumina install stages (S0b–S2 implemented).
|
Run Lumina install stages (S0b–S5 implemented).
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--stage <s1|s2> Run only the specified stage
|
--stage <s0b|s1|s2|s3|s4|s5|s3-s5> Run only the specified stage or range
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
Stages:
|
Stages:
|
||||||
S0b Docker install-if-missing (bootstrap)
|
S0b Docker install-if-missing (bootstrap)
|
||||||
S1 Repository environment (.env)
|
S1 Repository environment (.env)
|
||||||
S2 Model + vision configuration + smoke test
|
S2 Model + vision configuration + smoke test
|
||||||
|
S3 Stack alignment (compose documentation; no-op on attach)
|
||||||
|
S4 Sandbox verification or onboard (attach mode default)
|
||||||
|
S5 Policy overlays + skills sync
|
||||||
|
|
||||||
All stages are idempotent.
|
All stages are idempotent.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") # run S0b → S1 → S2
|
$(basename "$0") # run S0b → S1 → S2 → S3 → S4 → S5
|
||||||
$(basename "$0") --stage s1 # run only S1 (env)
|
$(basename "$0") --stage s1 # run only S1 (env)
|
||||||
$(basename "$0") --stage s2 # run only S2 (models)
|
$(basename "$0") --stage s5 # run only S5 (policy + skills)
|
||||||
|
$(basename "$0") --stage s3-s5 # run S3 → S4 → S5
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
SINGLE_STAGE="${1:-}"
|
SINGLE_STAGE="${1:-}"
|
||||||
if [[ -z "$SINGLE_STAGE" ]]; then
|
if [[ -z "$SINGLE_STAGE" ]]; then
|
||||||
log_error "--stage requires a value (s1 or s2)"
|
log_error "--stage requires a value"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
@@ -67,7 +71,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Run stages ─────────────────────────────────────────────────────────────
|
# ── Stage runners ──────────────────────────────────────────────────────────
|
||||||
run_s0b() {
|
run_s0b() {
|
||||||
log_section "S0b: Docker bootstrap"
|
log_section "S0b: Docker bootstrap"
|
||||||
bash "$SCRIPT_DIR/bootstrap.sh"
|
bash "$SCRIPT_DIR/bootstrap.sh"
|
||||||
@@ -81,7 +85,23 @@ run_s2() {
|
|||||||
bash "$SCRIPT_DIR/install/s2-models.sh"
|
bash "$SCRIPT_DIR/install/s2-models.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_section "Lumina installer (stages S0b–S2)"
|
run_s3() {
|
||||||
|
log_section "S3: Stack alignment"
|
||||||
|
log_info "OpenShell manages the Hermes sandbox container."
|
||||||
|
log_info "Product compose (deploy/compose/) is optional for local MCP/webhooks."
|
||||||
|
log_info "No action needed for UAT attach path."
|
||||||
|
}
|
||||||
|
|
||||||
|
run_s4() {
|
||||||
|
bash "$SCRIPT_DIR/install/s4-sandbox.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_s5() {
|
||||||
|
bash "$SCRIPT_DIR/install/s5-policy-skills.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
log_section "Lumina installer (stages S0b–S5)"
|
||||||
warn_if_root
|
warn_if_root
|
||||||
|
|
||||||
if [[ -n "$SINGLE_STAGE" ]]; then
|
if [[ -n "$SINGLE_STAGE" ]]; then
|
||||||
@@ -89,9 +109,13 @@ if [[ -n "$SINGLE_STAGE" ]]; then
|
|||||||
s0b) run_s0b ;;
|
s0b) run_s0b ;;
|
||||||
s1) run_s1 ;;
|
s1) run_s1 ;;
|
||||||
s2) run_s2 ;;
|
s2) run_s2 ;;
|
||||||
|
s3) run_s3 ;;
|
||||||
|
s4) run_s4 ;;
|
||||||
|
s5) run_s5 ;;
|
||||||
|
s3-s5) run_s3; run_s4; run_s5 ;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown stage: $SINGLE_STAGE"
|
log_error "Unknown stage: $SINGLE_STAGE"
|
||||||
log_error "Valid stages: s0b, s1, s2"
|
log_error "Valid stages: s0b, s1, s2, s3, s4, s5, s3-s5"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -100,10 +124,14 @@ else
|
|||||||
run_s0b
|
run_s0b
|
||||||
run_s1
|
run_s1
|
||||||
run_s2
|
run_s2
|
||||||
|
run_s3
|
||||||
|
run_s4
|
||||||
|
run_s5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_section "Install complete (S0b–S2)"
|
log_section "Install complete (S0b–S5)"
|
||||||
log_info "Next steps:"
|
log_info "Next steps:"
|
||||||
log_info " - Review .env for correctness"
|
log_info " - Review .env for correctness"
|
||||||
log_info " - Continue with S3+ when implemented (compose, sandbox, policy)"
|
log_info " - Verify policy: nemohermes $(get_sandbox_name) policy-list"
|
||||||
|
log_info " - Continue with S6 (doctor) when implemented"
|
||||||
log_info " - See docs/INSTALL.md for full procedure"
|
log_info " - See docs/INSTALL.md for full procedure"
|
||||||
|
|||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/install/s4-sandbox.sh — S4: sandbox verification / onboard
|
||||||
|
#
|
||||||
|
# Two modes:
|
||||||
|
# attach (default) — verify existing sandbox is healthy; no destructive ops
|
||||||
|
# onboard — create new sandbox from agent package (clean host only)
|
||||||
|
#
|
||||||
|
# Platform-first: all mutations via nemohermes. Never hand-edit in-sandbox config.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/install/s4-sandbox.sh # attach mode (default)
|
||||||
|
# ./scripts/install/s4-sandbox.sh --mode attach
|
||||||
|
# ./scripts/install/s4-sandbox.sh --mode onboard
|
||||||
|
# ./scripts/install/s4-sandbox.sh --mode onboard --dry-run
|
||||||
|
# ./scripts/install/s4-sandbox.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 ───────────────────────────────────────────────────────────────
|
||||||
|
MODE="${LUMINA_INSTALL_MODE:-attach}"
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h)
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
S4: Sandbox verification or onboard.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--mode <attach|onboard> Install mode (default: attach)
|
||||||
|
--dry-run Preview onboard without executing
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
attach Verify existing sandbox is healthy (UAT default)
|
||||||
|
onboard Create new sandbox from agent package (clean host)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") # attach mode
|
||||||
|
$(basename "$0") --mode onboard # onboard mode
|
||||||
|
$(basename "$0") --mode onboard --dry-run # onboard dry-run
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--mode)
|
||||||
|
shift
|
||||||
|
MODE="${1:-}"
|
||||||
|
if [[ -z "$MODE" ]]; then
|
||||||
|
log_error "--mode requires a value (attach or onboard)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Validate mode ──────────────────────────────────────────────────────────
|
||||||
|
if [[ "$MODE" != "attach" && "$MODE" != "onboard" ]]; then
|
||||||
|
log_error "Invalid mode: $MODE (must be 'attach' or 'onboard')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_section "S4: Sandbox ($MODE mode)"
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
# ── Validate required keys ─────────────────────────────────────────────────
|
||||||
|
validate_env || exit 1
|
||||||
|
|
||||||
|
# ── Check CLI prerequisites ────────────────────────────────────────────────
|
||||||
|
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
AGENT_PKG_DIR="$REPO_ROOT/agents/hermes"
|
||||||
|
|
||||||
|
# ── Attach mode ────────────────────────────────────────────────────────────
|
||||||
|
do_attach() {
|
||||||
|
log_info "Attach mode: verifying sandbox '$SANDBOX_NAME'"
|
||||||
|
|
||||||
|
# Check sandbox exists and report status
|
||||||
|
if ! nemohermes "$SANDBOX_NAME" status &>/dev/null; then
|
||||||
|
log_error "Sandbox '$SANDBOX_NAME' not found or not reachable."
|
||||||
|
log_error "If this is a clean host, re-run with --mode onboard"
|
||||||
|
log_error "Or create the sandbox manually: nemohermes onboard"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Sandbox '$SANDBOX_NAME' is healthy."
|
||||||
|
|
||||||
|
# Verify agent package exists (reference only in attach mode)
|
||||||
|
if [[ -d "$AGENT_PKG_DIR" ]]; then
|
||||||
|
log_info "Agent package found at $AGENT_PKG_DIR"
|
||||||
|
if [[ -f "$AGENT_PKG_DIR/skills-manifest/manifest.yaml" ]]; then
|
||||||
|
log_info "Skills manifest present — S5 will sync skills"
|
||||||
|
else
|
||||||
|
log_warn "Skills manifest not found — skills sync (S5) may be incomplete"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn "Agent package directory not found at $AGENT_PKG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "S4 attach complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Onboard mode ───────────────────────────────────────────────────────────
|
||||||
|
do_onboard() {
|
||||||
|
log_info "Onboard mode: preparing sandbox '$SANDBOX_NAME' from agent package"
|
||||||
|
|
||||||
|
# Verify agent package exists
|
||||||
|
if [[ ! -d "$AGENT_PKG_DIR" ]]; then
|
||||||
|
log_error "Agent package not found at $AGENT_PKG_DIR"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if sandbox already exists — do not destroy it
|
||||||
|
if nemohermes "$SANDBOX_NAME" status &>/dev/null; then
|
||||||
|
log_warn "Sandbox '$SANDBOX_NAME' already exists."
|
||||||
|
log_warn "Onboard mode does not destroy existing sandboxes."
|
||||||
|
log_warn "Switching to attach behavior for safety."
|
||||||
|
do_attach
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
log_info "DRY-RUN: Would execute:"
|
||||||
|
log_info " nemohermes onboard --from-dir $AGENT_PKG_DIR"
|
||||||
|
log_info " (with inference from .env: $LUMINA_INFERENCE_BASE_URL)"
|
||||||
|
log_info "S4 onboard dry-run complete."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Onboard with agent package
|
||||||
|
log_info "Running nemohermes onboard with agent package…"
|
||||||
|
if nemohermes onboard --from-dir "$AGENT_PKG_DIR"; then
|
||||||
|
log_info "Sandbox '$SANDBOX_NAME' onboarded successfully."
|
||||||
|
else
|
||||||
|
log_error "Onboard failed. Check nemohermes logs for details."
|
||||||
|
log_error "You may need to run nemohermes onboard manually first."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "S4 onboard complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Execute ────────────────────────────────────────────────────────────────
|
||||||
|
case "$MODE" in
|
||||||
|
attach) do_attach ;;
|
||||||
|
onboard) do_onboard ;;
|
||||||
|
esac
|
||||||
Executable
+197
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/install/s5-policy-skills.sh — S5: policy apply + skills sync
|
||||||
|
#
|
||||||
|
# Applies Lumina policy overlays and syncs skills into the sandbox.
|
||||||
|
# Additive only: never removes existing presets.
|
||||||
|
#
|
||||||
|
# Platform-first: all mutations via nemohermes.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/install/s5-policy-skills.sh
|
||||||
|
# ./scripts/install/s5-policy-skills.sh --policy-only
|
||||||
|
# ./scripts/install/s5-policy-skills.sh --skills-only
|
||||||
|
# ./scripts/install/s5-policy-skills.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 ───────────────────────────────────────────────────────────────
|
||||||
|
DO_POLICY=1
|
||||||
|
DO_SKILLS=1
|
||||||
|
POLICY_ONLY_SET=0
|
||||||
|
SKILLS_ONLY_SET=0
|
||||||
|
|
||||||
|
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h)
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
S5: Apply policy overlays and sync skills into the sandbox.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--policy-only Apply policy overlays only (skip skills sync)
|
||||||
|
--skills-only Sync skills only (skip policy apply)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") # policy + skills
|
||||||
|
$(basename "$0") --policy-only # policy only
|
||||||
|
$(basename "$0") --skills-only # skills only
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--policy-only)
|
||||||
|
DO_POLICY=1
|
||||||
|
DO_SKILLS=0
|
||||||
|
POLICY_ONLY_SET=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skills-only)
|
||||||
|
DO_POLICY=0
|
||||||
|
DO_SKILLS=1
|
||||||
|
SKILLS_ONLY_SET=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Validate mutually exclusive flags ──────────────────────────────────────
|
||||||
|
if [[ $POLICY_ONLY_SET -eq 1 && $SKILLS_ONLY_SET -eq 1 ]]; then
|
||||||
|
log_error "--policy-only and --skills-only are mutually exclusive"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ $DO_POLICY -eq 0 && $DO_SKILLS -eq 0 ]]; then
|
||||||
|
log_error "Internal error: both DO_POLICY and DO_SKILLS are disabled"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_section "S5: Policy + Skills sync"
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
# ── Validate required keys ─────────────────────────────────────────────────
|
||||||
|
validate_env || exit 1
|
||||||
|
|
||||||
|
# ── Check CLI prerequisites ────────────────────────────────────────────────
|
||||||
|
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
POLICY_DIR="$REPO_ROOT/policy/openshell/overlays"
|
||||||
|
SKILLS_DIR="$REPO_ROOT/skills"
|
||||||
|
|
||||||
|
# ── Verify sandbox exists ──────────────────────────────────────────────────
|
||||||
|
if ! nemohermes "$SANDBOX_NAME" status &>/dev/null 2>&1; then
|
||||||
|
log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Policy apply ───────────────────────────────────────────────────────────
|
||||||
|
apply_policy() {
|
||||||
|
log_section "S5a: Apply policy overlays"
|
||||||
|
|
||||||
|
if [[ ! -d "$POLICY_DIR" ]]; then
|
||||||
|
log_warn "Policy overlays directory not found: $POLICY_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply inference overlay (always apply at S5)
|
||||||
|
local inference_policy="$POLICY_DIR/inference.yaml"
|
||||||
|
if [[ -f "$inference_policy" ]]; then
|
||||||
|
log_info "Applying inference policy overlay…"
|
||||||
|
if nemohermes "$SANDBOX_NAME" policy-add --from-file "$inference_policy" --yes 2>&1; then
|
||||||
|
log_info "Inference policy overlay applied."
|
||||||
|
else
|
||||||
|
log_warn "Inference policy overlay may already be applied (idempotent)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn "Inference policy overlay not found: $inference_policy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List current policy for verification
|
||||||
|
log_info "Current policy presets:"
|
||||||
|
nemohermes "$SANDBOX_NAME" policy-list 2>&1 || log_warn "Could not list policy presets"
|
||||||
|
|
||||||
|
log_info "S5a policy apply complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Skills sync ────────────────────────────────────────────────────────────
|
||||||
|
sync_skills() {
|
||||||
|
log_section "S5b: Sync skills"
|
||||||
|
|
||||||
|
if [[ ! -d "$SKILLS_DIR" ]]; then
|
||||||
|
log_warn "Skills directory not found: $SKILLS_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local skill_count=0
|
||||||
|
local skill_ok=0
|
||||||
|
local skill_skip=0
|
||||||
|
local skill_fail=0
|
||||||
|
|
||||||
|
# Iterate skill directories (skip _lib and hidden dirs)
|
||||||
|
for skill_dir in "$SKILLS_DIR"/*/; do
|
||||||
|
# Skip if not a directory
|
||||||
|
[[ -d "$skill_dir" ]] || continue
|
||||||
|
|
||||||
|
local skill_name
|
||||||
|
skill_name="$(basename "$skill_dir")"
|
||||||
|
|
||||||
|
# Skip _lib (shared library, not a skill)
|
||||||
|
if [[ "$skill_name" == "_lib" ]]; then
|
||||||
|
log_info "Skipping _lib (shared library)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
skill_count=$((skill_count + 1))
|
||||||
|
|
||||||
|
# Check for SKILL.md (required by nemohermes skill install)
|
||||||
|
local skill_md="$skill_dir/SKILL.md"
|
||||||
|
if [[ ! -f "$skill_md" ]]; then
|
||||||
|
log_warn "Skipping '$skill_name': no SKILL.md found"
|
||||||
|
skill_skip=$((skill_skip + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installing skill: $skill_name"
|
||||||
|
if nemohermes "$SANDBOX_NAME" skill install "$skill_dir" 2>&1; then
|
||||||
|
log_info " ✓ $skill_name installed"
|
||||||
|
skill_ok=$((skill_ok + 1))
|
||||||
|
else
|
||||||
|
log_warn " ✗ $skill_name failed (may already be installed)"
|
||||||
|
skill_fail=$((skill_fail + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_info "Skills sync summary: $skill_count found, $skill_ok installed, $skill_skip skipped, $skill_fail failed"
|
||||||
|
|
||||||
|
if [[ $skill_count -eq 0 ]]; then
|
||||||
|
log_warn "No skill directories found in $SKILLS_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "S5b skills sync complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Execute ────────────────────────────────────────────────────────────────
|
||||||
|
if [[ $DO_POLICY -eq 1 ]]; then
|
||||||
|
apply_policy
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DO_SKILLS -eq 1 ]]; then
|
||||||
|
sync_skills
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "S5 complete."
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
|
||||||
@@ -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 open→first appointment and last appointment→close 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, "❓")
|
||||||
@@ -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", []),
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: ap-bills-due
|
||||||
|
description: "Accounts payable: bills due"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# ap-bills-due
|
||||||
|
|
||||||
|
Accounts payable: bills due.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Lists upcoming and overdue bills from QuickBooks Online.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only bill data.
|
||||||
|
- No bill payment execution.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: ar-open-invoices
|
||||||
|
description: "Accounts receivable: open invoices"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# ar-open-invoices
|
||||||
|
|
||||||
|
Accounts receivable: open invoices.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Lists outstanding invoices and aging summary from QuickBooks Online.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only invoice data.
|
||||||
|
- No payment processing.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: availability
|
||||||
|
description: "Check and display appointment availability"
|
||||||
|
domain: operations
|
||||||
|
---
|
||||||
|
|
||||||
|
# availability
|
||||||
|
|
||||||
|
Check and display appointment availability.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Shows available time slots for appointments based on the salon's schedule and service durations.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Deterministic gap math from tools.
|
||||||
|
- No booking without owner confirmation.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: books-snapshot
|
||||||
|
description: "QuickBooks snapshot: P&L, balance, cash"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# books-snapshot
|
||||||
|
|
||||||
|
QuickBooks snapshot: P&L, balance, cash.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Provides a high-level financial snapshot from QuickBooks Online: profit and loss, balance sheet summary, and cash position.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only QuickBooks data.
|
||||||
|
- No write/update/delete operations.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: client-card
|
||||||
|
description: "Client profile and history"
|
||||||
|
domain: clients
|
||||||
|
---
|
||||||
|
|
||||||
|
# client-card
|
||||||
|
|
||||||
|
Client profile and history.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Displays a client's profile, visit history, preferences, and notes.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only client data.
|
||||||
|
- No PII export without owner consent.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: daily-board
|
||||||
|
description: "Today's salon board: appointments, gaps, and confirmation flags"
|
||||||
|
domain: operations
|
||||||
|
---
|
||||||
|
|
||||||
|
# daily-board
|
||||||
|
|
||||||
|
Today's salon board: appointments, gaps, and confirmation flags.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- Deterministic facts from fixtures; no model inference for board data.
|
||||||
|
- 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)
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: draft-client-message
|
||||||
|
description: "Draft outbound messages to clients (draft-first)"
|
||||||
|
domain: clients
|
||||||
|
---
|
||||||
|
|
||||||
|
# draft-client-message
|
||||||
|
|
||||||
|
Draft outbound messages to clients (draft-first).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Composes messages to clients for appointments, reminders, or follow-ups. Owner reviews and sends.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Draft-first: owner must approve before sending.
|
||||||
|
- No silent send.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: draft-invoice
|
||||||
|
description: "Draft invoices for clients (draft-first)"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# draft-invoice
|
||||||
|
|
||||||
|
Draft invoices for clients (draft-first).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Creates draft invoices for client services. Owner reviews and sends.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Draft-first: owner must approve before sending.
|
||||||
|
- No automatic payment collection.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: publish-boundary-test
|
||||||
|
description: "Boundary test: verify publish/send denials"
|
||||||
|
domain: system
|
||||||
|
---
|
||||||
|
|
||||||
|
# publish-boundary-test
|
||||||
|
|
||||||
|
Boundary test: verify publish/send denials.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Test skill that verifies the sandbox correctly denies publish, send, and payment operations. Used for validation during install and upgrade.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- This skill should fail when attempting publish/send/pay.
|
||||||
|
- If it succeeds, the policy is misconfigured.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: remember-forget
|
||||||
|
description: "Confirm-to-remember persistence; forget entries"
|
||||||
|
domain: system
|
||||||
|
---
|
||||||
|
|
||||||
|
# remember-forget
|
||||||
|
|
||||||
|
Confirm-to-remember persistence; forget entries.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Manages persistent memory entries. Requires explicit owner confirmation before remembering. Supports forgetting previously stored entries.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Write only after structured confirmation.
|
||||||
|
- No silent persistence.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: retail-stock
|
||||||
|
description: "Retail product inventory levels"
|
||||||
|
domain: operations
|
||||||
|
---
|
||||||
|
|
||||||
|
# retail-stock
|
||||||
|
|
||||||
|
Retail product inventory levels.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Shows current stock levels for retail products, with low-stock alerts.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Deterministic thresholds from tools.
|
||||||
|
- No automatic reordering.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: service-menu
|
||||||
|
description: "Service catalog and pricing"
|
||||||
|
domain: operations
|
||||||
|
---
|
||||||
|
|
||||||
|
# service-menu
|
||||||
|
|
||||||
|
Service catalog and pricing.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Displays the salon's service menu with descriptions, durations, and prices.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only catalog data.
|
||||||
|
- No price changes without owner action.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: setup-education
|
||||||
|
description: "Owner-safe connect education and capability report"
|
||||||
|
domain: system
|
||||||
|
---
|
||||||
|
|
||||||
|
# setup-education
|
||||||
|
|
||||||
|
Owner-safe connect education and capability report.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) and messaging channels. Provides a capability report showing what is connected.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Owner-safe: no terminal instructions.
|
||||||
|
- Browser/vendor UI steps only.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: social-draft
|
||||||
|
description: "Draft social media posts (draft-first; vision aux)"
|
||||||
|
domain: social
|
||||||
|
---
|
||||||
|
|
||||||
|
# social-draft
|
||||||
|
|
||||||
|
Draft social media posts (draft-first; vision aux).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Creates draft social media posts using salon photos and content. Owner reviews and publishes.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Draft-first: owner must approve before publishing.
|
||||||
|
- No silent publish.
|
||||||
|
- Vision aux for photo understanding.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: vendor-inbox
|
||||||
|
description: "Vendor communications and documents"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# vendor-inbox
|
||||||
|
|
||||||
|
Vendor communications and documents.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Tracks vendor communications, invoices received, and related documents.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only vendor data.
|
||||||
|
- Draft-first for any vendor responses.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: vendor-spend
|
||||||
|
description: "Vendor spending summary"
|
||||||
|
domain: books
|
||||||
|
---
|
||||||
|
|
||||||
|
# vendor-spend
|
||||||
|
|
||||||
|
Vendor spending summary.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Summarizes spending by vendor from QuickBooks Online.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Read-only financial data.
|
||||||
|
- No money movement.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: weekly-digest
|
||||||
|
description: "Weekly business digest for the owner"
|
||||||
|
domain: social
|
||||||
|
---
|
||||||
|
|
||||||
|
# weekly-digest
|
||||||
|
|
||||||
|
Weekly business digest for the owner.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Compiles a weekly summary of appointments, revenue, client activity, and key metrics.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Deterministic facts from tools; inference for summary wording.
|
||||||
|
- Read-only data aggregation.
|
||||||
+19
-9
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for Salon_Assistant deterministic logic."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user