From b2e6868824f8362c91863493c577165deb6d66a3 Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 20 Aug 2026 18:18:10 +0530 Subject: [PATCH] update agents skill design --- scripts/__baseline__/owliver-baseline.json | 1189 +++++++ scripts/owliver-baseline.mjs | 53 + scripts/owliver-capture.mjs | 158 + scripts/skill-check.mjs | 2973 +++++++++++++++++ src/App.jsx | 7 + src/agents/activity-agent.md | 42 + src/agents/analytics-agent.md | 42 + src/agents/candidates-agent.md | 41 + src/agents/control-center-agent.md | 47 + src/agents/hired-history-agent.md | 41 + src/agents/krow-forge-agent.md | 41 + src/agents/krow-workforce-agent.md | 100 + src/agents/positions-agent.md | 42 + src/agents/talent-pool-agent.md | 40 + src/api/attendanceSeed.js | 233 ++ src/api/base44Client.js | 3 + src/api/seed.js | 6 + src/components/agents/AddSkillsModal.jsx | 180 + src/components/agents/AgentCanvas.jsx | 516 +++ src/components/agents/AgentConfigure.jsx | 681 ++++ src/components/agents/AgentInsightsPanel.jsx | 238 ++ src/components/agents/AgentTestPanel.jsx | 347 ++ src/components/agents/icons.js | 30 + src/components/ai-assistant/AgentContext.jsx | 196 ++ src/components/ai-assistant/AgentSwitcher.jsx | 234 ++ .../ai-assistant/AssistantMessage.jsx | 60 +- .../ai-assistant/AssistantPanelContext.jsx | 10 +- src/components/ai-assistant/KrowAssistant.jsx | 93 +- .../ai-assistant/capabilities/workspace.js | 552 +++ src/components/ai-assistant/contexts.js | 92 + src/components/ai-assistant/dynamic.js | 120 + src/components/ai-assistant/history.js | 100 +- src/components/ai-assistant/insights.js | 74 +- src/components/ai-assistant/placement.js | 103 +- src/components/ai-assistant/provider.js | 72 +- src/components/ai-assistant/routing.js | 55 +- src/components/ai-assistant/useAssistant.js | 91 +- src/components/ds/index.js | 8 + src/components/skills/SkillSurface.jsx | 8 +- src/lib/activitySignals.js | 103 + src/lib/agents/agentConfig.js | 313 ++ src/lib/agents/agentFields.js | 209 ++ src/lib/agents/agentLifecycle.js | 83 + src/lib/agents/context.js | 114 + src/lib/agents/conversationInsights.js | 153 + src/lib/agents/customAgents.js | 114 + src/lib/agents/knowledge.js | 157 + src/lib/agents/registry.js | 276 ++ src/lib/agents/runtime.js | 388 +++ src/lib/agents/useAgents.js | 137 + src/lib/agents/vocabulary.js | 117 + src/lib/attendance.js | 389 +++ src/lib/krowHooks.js | 15 + src/lib/skills/actions.js | 13 + src/lib/skills/dataResolver.js | 698 ++++ src/lib/skills/registry.js | 16 +- src/lib/skills/surfaces.js | 216 ++ src/lib/skills/tools.js | 177 + src/pages/admin/AgentDetail.jsx | 267 ++ src/pages/admin/Positions.jsx | 4 +- src/pages/admin/Workspace.jsx | 36 + src/pages/admin/WorkspaceAgents.jsx | 564 ++++ src/skills/owliver/activity-analysis.md | 99 + src/skills/owliver/anomaly-detection.md | 96 + src/skills/owliver/attendance-analysis.md | 105 + src/skills/owliver/candidate-analysis.md | 98 + src/skills/owliver/executive-summary.md | 81 + src/skills/owliver/hiring-history-analysis.md | 88 + src/skills/owliver/hiring-pulse-analysis.md | 88 + src/skills/owliver/learning-analysis.md | 80 + src/skills/owliver/operational-risk.md | 94 + src/skills/owliver/overtime-analysis.md | 98 + src/skills/owliver/staffing-risk.md | 99 + src/skills/owliver/talent-pool-analysis.md | 93 + src/skills/owliver/workforce-analytics.md | 88 + 75 files changed, 14586 insertions(+), 98 deletions(-) create mode 100644 scripts/__baseline__/owliver-baseline.json create mode 100644 scripts/owliver-baseline.mjs create mode 100644 scripts/owliver-capture.mjs create mode 100644 src/agents/activity-agent.md create mode 100644 src/agents/analytics-agent.md create mode 100644 src/agents/candidates-agent.md create mode 100644 src/agents/control-center-agent.md create mode 100644 src/agents/hired-history-agent.md create mode 100644 src/agents/krow-forge-agent.md create mode 100644 src/agents/krow-workforce-agent.md create mode 100644 src/agents/positions-agent.md create mode 100644 src/agents/talent-pool-agent.md create mode 100644 src/api/attendanceSeed.js create mode 100644 src/components/agents/AddSkillsModal.jsx create mode 100644 src/components/agents/AgentCanvas.jsx create mode 100644 src/components/agents/AgentConfigure.jsx create mode 100644 src/components/agents/AgentInsightsPanel.jsx create mode 100644 src/components/agents/AgentTestPanel.jsx create mode 100644 src/components/agents/icons.js create mode 100644 src/components/ai-assistant/AgentContext.jsx create mode 100644 src/components/ai-assistant/AgentSwitcher.jsx create mode 100644 src/components/ai-assistant/capabilities/workspace.js create mode 100644 src/lib/activitySignals.js create mode 100644 src/lib/agents/agentConfig.js create mode 100644 src/lib/agents/agentFields.js create mode 100644 src/lib/agents/agentLifecycle.js create mode 100644 src/lib/agents/context.js create mode 100644 src/lib/agents/conversationInsights.js create mode 100644 src/lib/agents/customAgents.js create mode 100644 src/lib/agents/knowledge.js create mode 100644 src/lib/agents/registry.js create mode 100644 src/lib/agents/runtime.js create mode 100644 src/lib/agents/useAgents.js create mode 100644 src/lib/agents/vocabulary.js create mode 100644 src/lib/attendance.js create mode 100644 src/lib/skills/tools.js create mode 100644 src/pages/admin/AgentDetail.jsx create mode 100644 src/pages/admin/WorkspaceAgents.jsx create mode 100644 src/skills/owliver/activity-analysis.md create mode 100644 src/skills/owliver/anomaly-detection.md create mode 100644 src/skills/owliver/attendance-analysis.md create mode 100644 src/skills/owliver/candidate-analysis.md create mode 100644 src/skills/owliver/executive-summary.md create mode 100644 src/skills/owliver/hiring-history-analysis.md create mode 100644 src/skills/owliver/hiring-pulse-analysis.md create mode 100644 src/skills/owliver/learning-analysis.md create mode 100644 src/skills/owliver/operational-risk.md create mode 100644 src/skills/owliver/overtime-analysis.md create mode 100644 src/skills/owliver/staffing-risk.md create mode 100644 src/skills/owliver/talent-pool-analysis.md create mode 100644 src/skills/owliver/workforce-analytics.md diff --git a/scripts/__baseline__/owliver-baseline.json b/scripts/__baseline__/owliver-baseline.json new file mode 100644 index 0000000..add7667 --- /dev/null +++ b/scripts/__baseline__/owliver-baseline.json @@ -0,0 +1,1189 @@ +{ + "schema": 1, + "today": "2026-08-20T09:00:00.000Z", + "skillIds": [ + "analytics-insights", + "bartending-training", + "candidate-search", + "create-position", + "customer-service-training", + "food-safety-training", + "forge-skill-management", + "hiring-activity-assistant", + "leadership-training", + "server-training" + ], + "diagnostics": [], + "routes": { + "/admin": "admin.controlCenter", + "/admin/activity": "admin.activity", + "/admin/analytics": "admin.analytics", + "/admin/candidates": "admin.candidatesList", + "/admin/candidates-analysis": "admin.candidates", + "/admin/hired": "admin.hiredHistory", + "/admin/positions": "admin.positions", + "/admin/positions/new": "admin.createPosition", + "/admin/profile": "admin.profile", + "/admin/talent-pool": "admin.talentPool", + "/admin/university": "admin.forge" + }, + "contexts": { + "admin.controlCenter": { + "pageKey": "control-center", + "route": "/admin", + "skills": [], + "suggestions": [], + "prompts": [ + "What needs attention?", + "Platform health", + "Bottleneck at Hired", + "Summarize platform usage", + "Hiring operations", + "Identify operational risks" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.positions": { + "pageKey": "positions", + "route": "/admin/positions", + "skills": [ + "create-position", + "hiring-activity-assistant" + ], + "suggestions": [ + "Show hiring activity as a flow", + "Summarize hiring activity for this position" + ], + "prompts": [ + "What needs attention?", + "Which position has the strongest pipeline?", + "Show the 10 applications waiting for review", + "Summarize hiring activity across all positions" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": "hiring-activity-assistant", + "kind": "skill", + "skill": "hiring-activity-assistant", + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.createPosition": { + "pageKey": "create-position", + "route": "/admin/positions/new", + "skills": [], + "suggestions": [], + "prompts": [ + "Explain the vetting weights", + "Compare with similar roles", + "Credentials in use", + "How this form works" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.candidatesList": { + "pageKey": "candidates", + "route": "/admin/candidates", + "skills": [ + "candidate-search" + ], + "suggestions": [], + "prompts": [ + "1 waiting on a decision", + "Who are the strongest candidates?", + "Which candidates are ready for interview?", + "10 have no score", + "Summarize the candidate pipeline", + "Identify candidate risks" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.candidates": { + "pageKey": "candidates-analysis", + "route": "/admin/candidates-analysis", + "skills": [ + "candidate-search" + ], + "suggestions": [], + "prompts": [ + "Compare Chef & Marcus", + "1 missing credentials", + "10 unscored applicants", + "Recommend next actions", + "Recruitment insights" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.hiredHistory": { + "pageKey": "hired-history", + "route": "/admin/hired", + "skills": [], + "suggestions": [], + "prompts": [ + "3 hires unrated", + "Bartender leads on hires", + "Which positions hired the strongest talent?", + "Summarize recent hires", + "What hiring outcomes should I review?" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.talentPool": { + "pageKey": "talent-pool", + "route": "/admin/talent-pool", + "skills": [], + "suggestions": [], + "prompts": [ + "Why does Maria rank first?", + "4 profiles unscored", + "7 need verification", + "Who is currently available?", + "Who has the strongest score?" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.forge": { + "pageKey": "krow-forge", + "route": "/admin/university", + "skills": [ + "forge-skill-management" + ], + "suggestions": [], + "prompts": [ + "Create a skill training", + "40 published", + "What does Owliver evaluate?", + "What skills do we currently have?", + "4 skills verified" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.analytics": { + "pageKey": "analytics", + "route": "/admin/analytics", + "skills": [ + "analytics-insights" + ], + "suggestions": [], + "prompts": [ + "What is the hiring trend?", + "Which department is performing best?", + "Biggest drop at Hired", + "Which positions are converting best?", + "Summarize hiring performance", + "10 unscored applications" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.activity": { + "pageKey": "activity", + "route": "/admin/activity", + "skills": [], + "suggestions": [], + "prompts": [ + "3 unusual patterns", + "Summarize today's activity", + "What changed recently?", + "Identify security concerns", + "Who is most active?" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.profile", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + }, + "admin.profile": { + "pageKey": "profile", + "route": "/admin/profile", + "skills": [], + "suggestions": [], + "prompts": [ + "What are my permissions?", + "3 scopes are restricted", + "How is my account secured?", + "What are my preferences set to?", + "My last 8 actions" + ], + "intents": [ + { + "question": "What needs my attention?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Summarize this page", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + }, + { + "question": "Show hiring activity", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "Which positions need attention?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.positions", + "doc": [ + "text", + "note" + ] + }, + { + "question": "Where are candidates dropping off?", + "matchedSkill": null, + "kind": "navigate", + "skill": null, + "capability": null, + "action": null, + "destination": "admin.candidatesList", + "doc": [ + "text", + "note" + ] + }, + { + "question": "What are my permissions?", + "matchedSkill": null, + "kind": "answer", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": null + }, + { + "question": "What is the weather today?", + "matchedSkill": null, + "kind": "outOfScope", + "skill": null, + "capability": null, + "action": null, + "destination": null, + "doc": [ + "text", + "list", + "note" + ] + } + ] + } + } +} diff --git a/scripts/owliver-baseline.mjs b/scripts/owliver-baseline.mjs new file mode 100644 index 0000000..f3ccc22 --- /dev/null +++ b/scripts/owliver-baseline.mjs @@ -0,0 +1,53 @@ +/** + * Writes the Owliver behaviour baseline. + * + * node scripts/owliver-baseline.mjs # report drift, write nothing + * node scripts/owliver-baseline.mjs --write # (re)generate the snapshot + * + * The snapshot is the existing product's behaviour, recorded before the Agent + * layer was built and asserted by `skill-check.mjs` on every run afterwards. + * + * Regenerating is a deliberate act with a reason attached. A baseline rewritten + * to make a red check go green records the regression instead of catching it, + * which is worse than having no baseline at all — it converts a caught bug into + * a documented one. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { createServer } from 'vite'; +import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; + +const ROOT = process.cwd(); + +const server = await createServer({ + root: ROOT, + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'error', +}); + +const captured = await captureBaseline(server); +await server.close(); + +const serialized = `${JSON.stringify(captured, null, 2)}\n`; + +if (process.argv.includes('--write')) { + mkdirSync(dirname(BASELINE_PATH), { recursive: true }); + writeFileSync(BASELINE_PATH, serialized); + const pages = Object.keys(captured.contexts).length; + console.log(`Baseline written — ${pages} contexts, ${captured.skillIds.length} skills, ${Object.keys(captured.routes).length} routes.`); + process.exit(0); +} + +if (!existsSync(BASELINE_PATH)) { + console.error('No baseline recorded. Run `node scripts/owliver-baseline.mjs --write`.'); + process.exit(1); +} + +if (readFileSync(BASELINE_PATH, 'utf8') === serialized) { + console.log('Owliver behaviour matches the baseline.'); + process.exit(0); +} + +console.error('Owliver behaviour has DRIFTED from the baseline. Run `npm test` for the per-page detail.'); +process.exit(1); diff --git a/scripts/owliver-capture.mjs b/scripts/owliver-capture.mjs new file mode 100644 index 0000000..ed49c6c --- /dev/null +++ b/scripts/owliver-capture.mjs @@ -0,0 +1,158 @@ +/** + * Today's Owliver behaviour, captured as data. + * + * The Agent layer is additive: opening a Krow page must produce exactly the + * Owliver it produces now. That is a claim about eleven page contexts, their + * skills, their suggestions, their prompts and their intent routing — far more + * than anyone can hold in their head while editing, and all of it silent when + * it breaks. So it is recorded here as a value, committed, and compared on + * every run. + * + * One module, two callers: `owliver-baseline.mjs` writes the snapshot and + * `skill-check.mjs` asserts against it. A second implementation of "what we + * measure" would be a second definition of the contract. + * + * Everything below is pinned. `TODAY` is fixed rather than `new Date()`, + * because `buildFacts` windows records by date and a baseline that drifts + * daily is not a baseline. Lists are sorted; nothing depends on registry + * ordering or on the clock. + */ + +import { join } from 'node:path'; + +/** Where the committed snapshot lives. Named here rather than in the CLI so a + * reader can import the path without also running a Vite server. */ +export const BASELINE_PATH = join(process.cwd(), 'scripts/__baseline__/owliver-baseline.json'); + +/** Fixed so the snapshot means the same thing tomorrow. */ +export const TODAY = new Date('2026-08-20T09:00:00.000Z'); + +/** + * The contexts under contract. + * + * The eight Krow pages the brief protects, plus the three further Admin + * contexts the placement table carries — Create Position, Candidates Analysis + * and Profile. They are page-scoped by exactly the same mechanism, so leaving + * them out would protect most of the boundary and quietly not the rest. + */ +export const CONTEXTS = [ + 'admin.controlCenter', + 'admin.positions', + 'admin.createPosition', + 'admin.candidatesList', + 'admin.candidates', + 'admin.hiredHistory', + 'admin.talentPool', + 'admin.forge', + 'admin.analytics', + 'admin.activity', + 'admin.profile', +]; + +/** + * What gets asked, per context. + * + * Three kinds on purpose, because they exercise three different branches of + * `resolveIntent` and a change to any one of them is a regression a reader + * would notice: a question the page owns, a question another page owns (which + * must route, not answer), and a question nothing owns (which must decline). + */ +export const QUESTIONS = [ + 'What needs my attention?', + 'Summarize this page', + 'Show hiring activity', + 'Which positions need attention?', + 'Where are candidates dropping off?', + 'What are my permissions?', + 'What is the weather today?', +]; + +const sorted = (list) => [...list].sort(); + +/** Block types only — structure is the contract, wording is not. */ +const docShape = (document) => + Array.isArray(document?.blocks) ? document.blocks.map((b) => b?.type ?? null) : null; + +/** + * Loads the real module graph and reads today's behaviour off it. + * + * `ssrLoadModule` rather than a mock, for the reason `skill-check.mjs` gives: + * the pipeline is what is under test, and a mock of it would reproduce none of + * the failures this exists to catch. + */ +export async function captureBaseline(server) { + const registry = await server.ssrLoadModule('/src/lib/skills/registry.js'); + const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js'); + const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js'); + const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js'); + const routing = await server.ssrLoadModule('/src/components/ai-assistant/routing.js'); + const insights = await server.ssrLoadModule('/src/components/ai-assistant/insights.js'); + const seed = await server.ssrLoadModule('/src/api/seed.js'); + + /* The fact sheet the panel builds, from the shipped seed at a fixed instant. */ + const facts = insights.buildFacts({ + applications: seed.seedData.JobApplication, + postings: seed.seedData.JobPosting, + interviews: seed.seedData.AIInterview, + staff: seed.seedData.Staff, + profiles: seed.seedData.WorkerProfile, + activity: seed.seedData.UserActivity, + courses: seed.seedData.Course, + profile: null, + user: seed.DEMO_USER, + today: TODAY, + }); + + const contexts = {}; + for (const contextId of CONTEXTS) { + const pageKey = registry.pageKeyForContext(contextId); + + contexts[contextId] = { + pageKey, + route: pageKey ? registry.routeForPageKey(pageKey) : null, + + /* The page boundary itself: which skills this page carries with no + agent, no account customization and nothing disabled. */ + skills: sorted(registry.skillsForContext(contextId, [], []).map((s) => s.id)), + + suggestions: sorted( + resolver.owliverSuggestions(contextId, [], [], {}).map((s) => s.label) + ), + + prompts: (dynamic.buildPrompts(contextId, facts, null) || []).map((p) => p.label), + + intents: QUESTIONS.map((question) => { + const matched = registry.matchSkill(question, contextId, [], []); + const intent = routing.resolveIntent({ question, contextId }); + return { + question, + matchedSkill: matched?.id ?? null, + kind: intent?.kind ?? null, + skill: intent?.skill?.id ?? null, + capability: intent?.capability ?? null, + action: intent?.action?.name ?? null, + destination: intent?.destination?.contextId ?? null, + doc: docShape(intent?.doc), + }; + }), + }; + } + + /* Route → context, for every placement the product declares. An agent must + never become an input to this. */ + const routes = {}; + for (const route of Object.keys(placement.PLACEMENT_ROUTES).sort()) { + routes[route] = placement.resolveAssistantContext('admin', route)?.id ?? null; + } + + return { + /* Bumped only when the shape of what we measure changes — never to make a + failing comparison pass. */ + schema: 1, + today: TODAY.toISOString(), + skillIds: sorted(registry.SKILLS.map((s) => s.id)), + diagnostics: registry.readSkillRegistry([]).diagnostics, + routes, + contexts, + }; +} diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index b2be613..e0d076a 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -15,6 +15,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createServer } from 'vite'; +import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; const ROOT = process.cwd(); const results = []; @@ -1383,6 +1384,2978 @@ record('a capability inheriting from `ui:` still hydrates with its source', fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity', fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost'); +/* ── 12. Attendance and overtime data ───────────────────────────────────── + * + * Attendance is the one collection whose dates are anchored to now rather than + * written as calendar dates, and the one whose foreign keys have to line up + * with a narrative written months earlier. Both are easy to get wrong in ways + * that look fine: a shift pointing at a staff member who was never hired reads + * as a working feature until someone asks whose shift it was. + * + * These checks are about the data being *true* — that it joins, that it is + * windowable by the existing period machinery, and that the analyses over it + * find what is there and stay quiet about what is not. + */ +console.log('\n── Attendance and overtime data ──'); + +const attendance = await server.ssrLoadModule('/src/lib/attendance.js'); +const shiftSeed = await server.ssrLoadModule('/src/api/attendanceSeed.js'); +const seedModule = await server.ssrLoadModule('/src/api/seed.js'); +const resolverModule = await server.ssrLoadModule('/src/lib/skills/dataResolver.js'); + +const SHIFTS = shiftSeed.SHIFT_RECORDS; +const SHIFT_STATUSES = ['present', 'late', 'absent', 'no_show', 'excused']; + +record('shift records are seeded', SHIFTS.length > 0, `${SHIFTS.length} shifts`); +record('the seed registers them as `ShiftRecord`', + Array.isArray(seedModule.seedData.ShiftRecord) && seedModule.seedData.ShiftRecord.length === SHIFTS.length, + `${seedModule.seedData.ShiftRecord?.length ?? 0} in seedData`); + +record('every shift has a unique id', + new Set(SHIFTS.map((s) => s.id)).size === SHIFTS.length); + +record('every shift status is one the product recognises', + SHIFTS.every((s) => SHIFT_STATUSES.includes(s.status)), + [...new Set(SHIFTS.map((s) => s.status))].join(', ')); + +record('every shift was scheduled for real hours', + SHIFTS.every((s) => s.scheduled_hours > 0)); + +record('overtime is never negative', + SHIFTS.every((s) => s.overtime_hours >= 0)); + +/* ── The joins ───────────────────────────────────────────────────────────── */ + +const staffIds = new Set(seedModule.seedData.Staff.map((s) => s.id)); +const postingsById = new Map(seedModule.seedData.JobPosting.map((p) => [p.id, p])); + +record('every shift belongs to someone who was actually hired', + SHIFTS.every((s) => staffIds.has(s.staff_id)), + [...new Set(SHIFTS.map((s) => s.staff_id).filter((id) => !staffIds.has(id)))].join(', ') || 'all resolve'); + +record('every shift belongs to a real position', + SHIFTS.every((s) => postingsById.has(s.job_posting_id)), + [...new Set(SHIFTS.map((s) => s.job_posting_id).filter((id) => !postingsById.has(id)))].join(', ') || 'all resolve'); + +/* "Department" has to mean the same thing here as it does on Hired History and + Analytics, both of which read it off the posting — see the join in + `lib/hiringRecords.js`. A shift carrying its own category is a denormalized + copy, and a copy that disagrees is worse than no copy. */ +record('a shift\'s department matches its position\'s', + SHIFTS.every((s) => postingsById.get(s.job_posting_id)?.role_category === s.role_category), + SHIFTS.filter((s) => postingsById.get(s.job_posting_id)?.role_category !== s.role_category) + .map((s) => s.id).join(', ') || 'all agree'); + +/** + * `created_date` is the instant the shift was worked. + * + * Load-bearing rather than incidental: `inPeriod` windows every collection on + * `created_date`, so if these drifted apart every period reading of attendance + * would come back empty and nothing would say why. + */ +record('a shift\'s created date is the instant it was worked', + SHIFTS.every((s) => s.created_date === s.scheduled_start)); + +/* ── Hours add up ────────────────────────────────────────────────────────── */ + +record('a missed shift is zero hours worked, not a short one', + SHIFTS.filter((s) => s.status === 'absent' || s.status === 'no_show') + .every((s) => s.actual_hours === 0 && s.overtime_hours === 0 && s.actual_start === null)); + +record('a worked shift\'s hours are its schedule, less lateness, plus overtime', + SHIFTS.filter((s) => s.status === 'present' || s.status === 'late').every((s) => { + const expected = s.scheduled_hours - s.minutes_late / 60 + s.overtime_hours; + return Math.abs(s.actual_hours - expected) < 0.02; + })); + +record('a late shift is one that was turned up for', + SHIFTS.filter((s) => s.status === 'late').every((s) => s.minutes_late > 0 && s.actual_start !== null)); + +/* ── Reachable the way a page reaches it ─────────────────────────────────── */ + +const client = await server.ssrLoadModule('/src/api/base44Client.js'); +const listed = await client.base44.entities.ShiftRecord.list('-created_date', 500); + +record('shifts are queryable through the entity API', + listed.length === SHIFTS.length, `${listed.length} returned`); + +record('...newest first, like every other collection', + listed.every((s, i) => i === 0 + || new Date(listed[i - 1].created_date).getTime() >= new Date(s.created_date).getTime())); + +const filtered = await client.base44.entities.ShiftRecord.filter({ status: 'absent' }); +record('...and filterable by field', + filtered.length > 0 && filtered.every((s) => s.status === 'absent'), + `${filtered.length} absences`); + +/* ── The sources future skills will declare ──────────────────────────────── */ + +for (const id of ['workforce.attendance', 'workforce.overtime']) { + const source = surfaces.dataSourceFor(id); + record(`\`${id}\` is a declarable source`, Boolean(source)); + record(`\`${id}\` needs no record in context`, source?.context === null); + record(`\`${id}\` supports every shape it declares`, + source.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)), + source.shapes.join(', ')); + record(`\`${id}\` accepts the options it declares`, + (source.options || []).every((option) => surfaces.sourceSupportsOption(id, option)), + (source.options || []).join(', ') || 'none'); + + /* Honest emptiness. A source with nothing to read must say so rather than + reporting zeros that look like findings. */ + const empty = resolverModule.resolveSkillData({ source: id }, { shifts: [] }); + record(`\`${id}\` reports honestly when there are no shifts`, + empty.empty === true && Boolean(empty.emptyNote), + empty.emptyNote || 'NO NOTE'); + + const full = resolverModule.resolveSkillData({ source: id }, { shifts: SHIFTS }); + record(`\`${id}\` reads the seeded shifts`, + full.empty === false && full.steps.length > 0 && full.items.length > 0, + `${full.steps.length} figures, ${full.items.length} rows`); + + const periodic = resolverModule.resolveSkillData( + { source: id, periods: ['last-7-days', 'previous-month'] }, { shifts: SHIFTS } + ); + record(`\`${id}\` windows by period`, + periodic.steps.length === 2 && periodic.steps.some((s) => s.records.length > 0), + periodic.steps.map((s) => `${s.label}=${s.records.length}`).join(', ')); +} + +/* ── The analyses ────────────────────────────────────────────────────────── */ + +const summary = attendance.attendanceSummary(SHIFTS); +record('attendance counts every scheduled shift exactly once', + summary.worked + summary.missed === summary.scheduled, + `${summary.worked} worked + ${summary.missed} missed = ${summary.scheduled}`); +record('attendance rate is a percentage', + summary.attendanceRate >= 0 && summary.attendanceRate <= 100, `${summary.attendanceRate}%`); +record('punctuality is never better than attendance', + summary.punctualityRate <= summary.attendanceRate, + `${summary.punctualityRate}% punctual / ${summary.attendanceRate}% present`); + +const overtime = attendance.overtimeSummary(SHIFTS); +record('overtime is the gap between hours worked and hours scheduled', + overtime.hours > 0 && overtime.hoursWorked > overtime.hoursScheduled - summary.minutesLate / 60, + `${overtime.hours}h overtime`); + +const workers = attendance.attendanceByWorker(SHIFTS); +record('every worker on the roster is compared', + workers.length === new Set(SHIFTS.map((s) => s.staff_id)).size, `${workers.length} workers`); +record('workers are ordered worst attendance first', + workers.every((w, i) => i === 0 || workers[i - 1].attendanceRate <= w.attendanceRate), + workers.map((w) => `${w.name.split(' ')[0]} ${w.attendanceRate}%`).join(', ')); + +const overtimeWorkers = attendance.overtimeByWorker(SHIFTS); +record('overtime comparison is ordered most hours first', + overtimeWorkers.every((w, i) => i === 0 || overtimeWorkers[i - 1].hours >= w.hours), + overtimeWorkers.map((w) => `${w.name.split(' ')[0]} ${w.hours}h`).join(', ')); + +const departments = attendance.attendanceByDepartment(SHIFTS); +record('departments are compared too', + departments.length > 1 && departments.every((d) => d.people > 0), + departments.map((d) => `${d.name} ${d.attendanceRate}%`).join(', ')); + +const trend = attendance.weeklyTrend(SHIFTS); +record('the weekly trend runs oldest to newest', + trend.length >= 4 && trend.every((w, i) => i === 0 + || new Date(trend[i - 1].weekStart).getTime() < new Date(w.weekStart).getTime()), + `${trend.length} weeks`); +record('a week nobody was rostered for is not reported as 0% attendance', + trend.every((w) => w.scheduled > 0)); + +/* ── Anomalies: what it finds, and what it stays quiet about ─────────────── */ + +const findings = attendance.attendanceAnomalies(SHIFTS); +record('the seeded attendance decline is found', + findings.some((f) => f.kind === 'attendance'), + findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND'); +record('the seeded overtime climb is found', + findings.some((f) => f.kind === 'overtime'), + findings.find((f) => f.kind === 'overtime')?.detail || 'NOT FOUND'); +record('every finding carries the figures behind it', + findings.every((f) => f.title && f.detail && f.severity && f.metric)); +record('findings are ordered most severe first', + findings.every((f, i) => i === 0 + || ({ high: 0, medium: 1, low: 2 })[findings[i - 1].severity] <= ({ high: 0, medium: 1, low: 2 })[f.severity]), + findings.map((f) => f.severity).join(' → ')); + +/** + * The check that matters most, and the one a detector like this usually fails. + * + * A rule that flags everything is worse than no rule: it trains its reader to + * skim, and the one finding that mattered goes past unread. So a workforce with + * nothing wrong must produce *nothing at all* — not a low-severity note, not a + * "no issues" finding. + */ +const flawless = SHIFTS.map((s) => ({ + ...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours, +})); +record('a workforce with nothing wrong produces no findings', + attendance.attendanceAnomalies(flawless).length === 0, + `${attendance.attendanceAnomalies(flawless).length} findings`); + +record('no shift records produces no findings either', + attendance.attendanceAnomalies([]).length === 0); + +/* One bad week is a bad week; it takes a second to be a direction. */ +const oneOffWeek = SHIFTS.map((s) => { + const daysBack = Math.round((Date.now() - new Date(s.created_date).getTime()) / 86400000); + return daysBack > 21 && daysBack <= 28 ? { ...s, status: 'absent', actual_hours: 0, overtime_hours: 0 } : { ...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours }; +}); +record('a single bad week months ago is not reported as a current problem', + !attendance.attendanceAnomalies(oneOffWeek).some((f) => f.id === 'missed-shifts-rising'), + attendance.attendanceAnomalies(oneOffWeek).map((f) => f.id).join(', ') || 'none'); + +/* ── 13. Cross-domain data sources ──────────────────────────────────────── + * + * Eight readings over records the workspace already holds. The failure these + * guard against is a source that *looks* like it works: one that returns + * plausible zeros when it has nothing to read, or that quietly assumes a field + * the data does not carry. Either produces a skill that appears to answer and + * is answering about nothing. + * + * So every source is checked twice — against the seed, and against an empty + * workspace — and the empty case has to say so rather than report zeros. + */ +console.log('\n── Cross-domain data sources ──'); + +const signalsModule = await server.ssrLoadModule('/src/lib/activitySignals.js'); +const insightsModule = await server.ssrLoadModule('/src/components/ai-assistant/insights.js'); + +const expectedBaseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +const SEED = seedModule.seedData; + +/* The workspace a declared reading is checked against — the same collections the + panel hands a resolver at runtime. Declared once here because both the + analysis-skill and agent sections read against it. */ +const AGENT_SKILL_CONTEXT = { + positions: SEED.JobPosting, + applications: SEED.JobApplication, + interviews: SEED.AIInterview, + staff: SEED.Staff, + profiles: SEED.WorkerProfile, + workerProfiles: SEED.WorkerProfile, + activity: SEED.UserActivity, + assignments: SEED.Assignment, + courses: SEED.Course, + shifts: SHIFTS, + trainingPaths: [], +}; +const FULL_CONTEXT = { + positions: SEED.JobPosting, + applications: SEED.JobApplication, + interviews: SEED.AIInterview, + staff: SEED.Staff, + profiles: SEED.WorkerProfile, + workerProfiles: SEED.WorkerProfile, + activity: SEED.UserActivity, + assignments: SEED.Assignment, + shifts: SHIFTS, +}; + +const NEW_SOURCES = [ + 'positions.risk', + 'candidates.quality', + 'talent.pool', + 'workforce.coverage', + 'activity.signals', + 'activity.breakdown', + 'operations.risk', + 'workspace.summary', +]; + +for (const id of NEW_SOURCES) { + const decl = surfaces.dataSourceFor(id); + + record(`\`${id}\` is a declarable source`, Boolean(decl)); + if (!decl) continue; + + /* Every one of these is a workspace-level reading. A source that needed a + record in context could not be answered from a page that has none, and + would validate then fail at read time. */ + record(`\`${id}\` needs no record in context`, decl.context === null); + + record(`\`${id}\` supports every shape it declares`, + decl.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)), + decl.shapes.join(', ')); + + record(`\`${id}\` accepts the options it declares`, + (decl.options || []).every((option) => surfaces.sourceSupportsOption(id, option)), + (decl.options || []).join(', ') || 'none'); + + /* Reads the workspace as it actually is. */ + const readFull = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT); + record(`\`${id}\` reads the seeded workspace`, + readFull.empty === false && (readFull.steps?.length > 0), + `${readFull.steps?.length ?? 0} figures, ${readFull.items?.length ?? 0} rows`); + + record(`\`${id}\` returns figures, never prose`, + (readFull.steps || []).every((step) => typeof step.value === 'number'), + (readFull.steps || []).map((s) => `${s.title}=${s.value}`).join(', ')); + + /** + * The check that separates a working source from one that only looks like it. + * + * An empty workspace must produce `empty: true` and a note saying why — not + * a row of zeros, which reads as a measured result rather than an absent one. + */ + const readEmpty = resolverModule.resolveSkillData({ source: id }, {}); + record(`\`${id}\` reports honestly when there is nothing to read`, + readEmpty.empty === true && Boolean(readEmpty.emptyNote), + readEmpty.emptyNote || 'NO NOTE'); + + record(`\`${id}\` never throws on a half-empty workspace`, + (() => { + try { + const partialRead = resolverModule.resolveSkillData({ source: id }, { positions: SEED.JobPosting }); + return partialRead && typeof partialRead.empty === 'boolean'; + } catch { + return false; + } + })()); +} + +/* ── Period filtering, where it applies ──────────────────────────────────── */ + +for (const id of ['candidates.quality', 'activity.breakdown']) { + const readPeriodic = resolverModule.resolveSkillData( + { source: id, periods: ['previous-month'] }, FULL_CONTEXT + ); + const all = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT); + record(`\`${id}\` windows by period`, + readPeriodic.total <= all.total, + `${readPeriodic.total} in the previous month / ${all.total} in total`); + + /* Overlapping windows must not count the same record twice — `today` sits + inside `last-7-days`, and a naive concatenation would double it. */ + const overlapping = resolverModule.resolveSkillData( + { source: id, periods: ['today', 'last-7-days'] }, FULL_CONTEXT + ); + const sevenOnly = resolverModule.resolveSkillData( + { source: id, periods: ['last-7-days'] }, FULL_CONTEXT + ); + record(`\`${id}\` does not double-count overlapping periods`, + overlapping.total === sevenOnly.total, + `${overlapping.total} vs ${sevenOnly.total}`); +} + +/* ── `activity.signals` reuses the assistant's own detection ─────────────── */ + +/** + * The reuse assertion. + * + * `activitySignals` moved out of `buildFacts` so a data source could read it + * without a library importing from the component tree. The whole value of that + * move is that there is still exactly one implementation — "two unusual + * patterns" has to mean the same two in the greeting and on the card. + */ +const factSheet = insightsModule.buildFacts({ + applications: SEED.JobApplication, + postings: SEED.JobPosting, + interviews: SEED.AIInterview, + staff: SEED.Staff, + profiles: SEED.WorkerProfile, + activity: SEED.UserActivity, + courses: SEED.Course, + profile: null, + user: seedModule.DEMO_USER, + today: new Date('2026-08-20T09:00:00.000Z'), +}); +const direct = signalsModule.activitySignals(SEED.UserActivity, new Date('2026-08-20T09:00:00.000Z')); + +record('the fact sheet and the extracted detection agree exactly', + JSON.stringify(factSheet.activitySignals) === JSON.stringify(direct), + JSON.stringify(direct.flags)); + +record('`PRIVILEGED_EVENTS` is still importable from insights.js', + JSON.stringify(insightsModule.PRIVILEGED_EVENTS) === JSON.stringify(signalsModule.PRIVILEGED_EVENTS), + JSON.stringify(insightsModule.PRIVILEGED_EVENTS)); + +const signalsRead = resolverModule.resolveSkillData({ source: 'activity.signals' }, FULL_CONTEXT); +record('the activity.signals source reports the same flags the greeting counts', + signalsRead.items.length === factSheet.activitySignals.flags.length, + `${signalsRead.items.length} signals`); + +record('every signal is explained rather than named', + signalsRead.items.every((i) => i.title && i.detail && i.title !== i.id)); + +/* A workspace with nothing out of pattern is a real answer, and a different + one from having no log at all. */ +const quiet = resolverModule.resolveSkillData({ source: 'activity.signals' }, { activity: [] }); +record('an empty log and a quiet log are different answers', + quiet.emptyNote !== signalsRead.emptyNote); + +/* ── `workforce.coverage` refuses to invent a headcount ──────────────────── */ + +/** + * No seeded position declares a headcount, and `demandFor` reports that rather + * than defaulting to one person per role. This check exists because the obvious + * shortcut — assume 1, report a fill rate — produces a confident percentage + * that means nothing, and nothing on screen would say so. + */ +const coverage = resolverModule.resolveSkillData({ source: 'workforce.coverage' }, FULL_CONTEXT); +const declaredStep = coverage.steps.find((s) => s.id === 'declared'); +record('coverage says out loud when no role states a headcount', + declaredStep.value === 0 && /does not|not state|No open role/i.test(declaredStep.detail), + declaredStep.detail); +record('coverage still reports who has actually been hired', + coverage.steps.find((s) => s.id === 'covered').value > 0, + `${coverage.steps.find((s) => s.id === 'covered').value} roles have someone hired`); +record('a role with no headcount reports hires, not a fill percentage', + coverage.items.every((i) => i.declared || /headcount not stated/.test(i.detail))); + +/* ── `positions.risk` distinguishes "nothing wrong" from "nothing posted" ── */ + +const noRoles = resolverModule.resolveSkillData({ source: 'positions.risk' }, { positions: [], applications: [] }); +const healthy = resolverModule.resolveSkillData({ source: 'positions.risk' }, { + positions: SEED.JobPosting.filter((p) => p.status === 'active').slice(0, 1), + applications: SEED.JobApplication.map((a) => ({ + ...a, job_posting_id: SEED.JobPosting.find((p) => p.status === 'active').id, ai_score: 88, status: 'hired', + })), +}); +record('positions.risk separates "no roles open" from "no roles at risk"', + noRoles.emptyNote !== healthy.emptyNote, + `${noRoles.emptyNote} / ${healthy.emptyNote}`); + +/* ── `operations.risk` stays quiet when the operation is running ─────────── */ + +record('operations.risk finds the real backlog', + resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT).findings.length > 0, + resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT) + .findings.map((f) => f.id).join(', ')); + +const readClean = resolverModule.resolveSkillData({ source: 'operations.risk' }, { + /* Everything screened and decided, every role has applicants, every shift worked. */ + applications: SEED.JobApplication.map((a) => ({ ...a, status: 'hired', ai_score: 90 })), + positions: SEED.JobPosting.filter((p) => p.status === 'active').map((p) => ({ ...p })), + shifts: SHIFTS.map((s) => ({ ...s, status: 'present' })), +}); +record('operations.risk reports nothing when nothing is wrong', + readClean.findings.length === 0 && readClean.empty === true, + readClean.findings.map((f) => f.id).join(', ') || 'no findings'); + +/* ── `talent.pool` does not average away the unscored ───────────────────── */ + +/** + * Four of the nine seeded profiles have no score. Averaging them in as zero + * would report a healthy pool as poor, in exact proportion to how much of it + * nobody has assessed yet — a figure that gets worse as the pool grows. + */ +const pool = resolverModule.resolveSkillData({ source: 'talent.pool' }, FULL_CONTEXT); +const scoredProfiles = SEED.WorkerProfile.filter((p) => (p.krow_score || 0) > 0); +const expectedAvg = Math.round( + scoredProfiles.reduce((sum, p) => sum + p.krow_score, 0) / scoredProfiles.length +); +record('talent.pool averages only the profiles that have been scored', + pool.steps.find((s) => s.id === 'quality').value === expectedAvg, + `${pool.steps.find((s) => s.id === 'quality').value} vs ${expectedAvg} expected`); +record('...and says how many are not yet assessed', + /not yet assessed/.test(pool.steps.find((s) => s.id === 'scored').detail), + pool.steps.find((s) => s.id === 'scored').detail); +record('an unscored person reads as unscored, not as a zero score', + pool.items.filter((i) => i.value === 0).every((i) => /Not yet scored/.test(i.detail))); + +/* ── `candidates.quality` reports coverage beside quality ────────────────── */ + +const quality = resolverModule.resolveSkillData({ source: 'candidates.quality' }, FULL_CONTEXT); +record('candidate quality reports how much of the pool was actually scored', + quality.steps.find((s) => s.id === 'coverage').value < 100, + quality.steps.find((s) => s.id === 'coverage').detail); +record('score bands add up to the number scored', + quality.bands.reduce((n, b) => n + b.value, 0) + === SEED.JobApplication.filter((a) => a.ai_score > 0).length, + quality.bands.map((b) => `${b.label}=${b.value}`).join(', ')); + +/* ── Nothing here reaches for a record it was not given ─────────────────── */ + +/** + * A resolver that throws takes the page down; one that invents a default is + * worse, because it reports a figure nobody can trace. Every source is called + * with each collection missing in turn. + */ +const COLLECTIONS = ['positions', 'applications', 'interviews', 'staff', 'profiles', 'activity', 'shifts', 'assignments']; +let survived = true; +let culprit = ''; +for (const id of NEW_SOURCES) { + for (const drop of COLLECTIONS) { + const withoutOne = { ...FULL_CONTEXT }; + delete withoutOne[drop]; + try { + const out = resolverModule.resolveSkillData({ source: id }, withoutOne); + if (!out || typeof out.empty !== 'boolean') { survived = false; culprit = `${id} without ${drop}`; } + } catch (error) { + survived = false; + culprit = `${id} without ${drop}: ${error.message}`; + } + } +} +record('every source survives any single collection being absent', survived, culprit || `${NEW_SOURCES.length} sources × ${COLLECTIONS.length} collections`); + +/* ── 14. Analysis skills ────────────────────────────────────────────────── + * + * Thirteen definitions written against the sources built in the two previous + * phases. The failure they guard against is a definition that registers, looks + * complete, and answers nothing — a capability bound to a source that cannot be + * read, or a trigger that quietly takes a question another definition was + * written to answer. + * + * Every one is therefore executed, not merely parsed: each declared capability + * is resolved against the seeded workspace and has to come back with figures. + */ +console.log('\n── Analysis skills ──'); + +const ANALYSIS_SKILLS = [ + 'staffing-risk', 'attendance-analysis', 'overtime-analysis', 'candidate-analysis', + 'talent-pool-analysis', 'workforce-analytics', 'anomaly-detection', 'activity-analysis', + 'operational-risk', 'executive-summary', 'hiring-history-analysis', 'learning-analysis', + 'hiring-pulse-analysis', +]; + +record('every analysis skill registers', + ANALYSIS_SKILLS.every((id) => reg.SKILLS.some((s) => s.id === id)), + ANALYSIS_SKILLS.filter((id) => !reg.SKILLS.some((s) => s.id === id)).join(', ') || `${ANALYSIS_SKILLS.length} skills`); + +for (const id of ANALYSIS_SKILLS) { + const skill = reg.SKILLS.find((s) => s.id === id); + if (!skill) continue; + + /* Written in the one format, read by the one parser. */ + record(`\`${id}\` is a valid definition`, + reg.validateSkillSource(skill.markdown) === null, + reg.validateSkillSource(skill.markdown) || 'ok'); + + record(`\`${id}\` is an Owliver skill on real pages`, + skill.facets.includes('owliver') + && skill.pages.length > 0 + && skill.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(surfaces.canonicalPage(p) || p)), + JSON.stringify(skill.pages)); + + /* The sections the brief asks every definition to carry. `Purpose` and + `Capabilities` are parsed into fields; the rest are prose the authoring + flow will later read back, so they are checked for presence rather than + for shape. */ + record(`\`${id}\` states its purpose and capabilities`, + skill.purpose.length > 0 && skill.capabilities.length > 0, + `${skill.purpose.length} purpose, ${skill.capabilities.length} capabilities`); + + record(`\`${id}\` documents its data, analysis, output and limitations`, + ['## Data', '## Analysis', '## Output', '## Limitations'].every((h) => skill.body.includes(h)), + ['Data', 'Analysis', 'Output', 'Limitations'].filter((h) => !skill.body.includes(`## ${h}`)).join(', ') || 'all four'); + + /* Every capability resolves, and comes back with figures rather than an + apology. This is what separates a definition that works from one that + merely parses. */ + const failures = []; + for (const capability of skill.owliver.capabilities) { + const response = skill.owliver.responses[capability]; + if (!response) { failures.push(`${capability}: no response`); continue; } + + const declared = surfaces.dataSourceFor(response.source); + if (!declared) { failures.push(`${capability}: unknown source ${response.source}`); continue; } + + /* Shape has to be one the source actually supports, or the definition + promises a rendering the data cannot produce. */ + const shape = surfaces.shapeForCapability(capability); + if (shape && !surfaces.sourceSupportsShape(response.source, shape)) { + failures.push(`${capability}: ${response.source} cannot draw ${shape}`); + continue; + } + + if (declared.context) continue; /* needs a record; asked for at runtime */ + + const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT); + if (read?.unavailable) failures.push(`${capability}: unreadable`); + else if (read?.empty && !read.emptyNote) failures.push(`${capability}: empty with no explanation`); + } + record(`\`${id}\` answers every capability it declares`, + failures.length === 0, + failures.join(' | ') || `${skill.owliver.capabilities.length} capabilities`); + + /* Every suggestion names the capability it asks for. Without this a chip + falls through to the first declared capability, and two chips silently + become one answer — the lesson recorded in hiring-activity-assistant.md. */ + record(`\`${id}\` names a capability on every suggestion`, + skill.owliver.suggestions.every((s) => s.capability + && skill.owliver.capabilities.includes(s.capability)), + skill.owliver.suggestions.map((s) => `${s.label} → ${s.capability}`).join(' | ')); + + /* Its own triggers, claimed rather than inherited from its name. */ + record(`\`${id}\` claims its own triggers`, + skill.declaredTriggers && skill.triggers.length > 0, + JSON.stringify(skill.triggers)); +} + +/* ── Reachable from the pages they name ──────────────────────────────────── */ + +const PAGE_EXPECTATIONS = { + 'admin.controlCenter': ['executive-summary', 'staffing-risk', 'operational-risk', 'anomaly-detection', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'], + 'admin.positions': ['staffing-risk'], + 'admin.candidatesList': ['candidate-analysis'], + 'admin.hiredHistory': ['hiring-history-analysis'], + 'admin.talentPool': ['talent-pool-analysis'], + 'admin.forge': ['learning-analysis'], + 'admin.analytics': ['workforce-analytics', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'], + 'admin.activity': ['activity-analysis', 'anomaly-detection', 'operational-risk'], +}; + +for (const [contextId, expected] of Object.entries(PAGE_EXPECTATIONS)) { + const available = reg.skillsForContext(contextId, [], []).map((s) => s.id); + record(`${contextId.replace('admin.', '')} offers its analysis skills`, + expected.every((id) => available.includes(id)), + expected.filter((id) => !available.includes(id)).join(', ') || `${expected.length} available`); +} + +/** + * Profile keeps none, and that is correct. + * + * There is no data source about an account, so a skill there would be a + * placeholder. The emptiness is pinned so a later phase cannot quietly fill it + * to make a list look complete. + */ +record('the Profile page still carries no analysis skill', + reg.skillsForContext('admin.profile', [], []).length === 0, + JSON.stringify(reg.skillsForContext('admin.profile', [], []).map((s) => s.id))); + +/* ── Triggers reach their skill, and take nothing that was not theirs ────── */ + +const TRIGGER_CASES = [ + ['admin.positions', 'Which roles are at risk?', 'staffing-risk'], + ['admin.analytics', 'How is attendance this month?', 'attendance-analysis'], + ['admin.analytics', 'How much overtime are we running?', 'overtime-analysis'], + ['admin.candidatesList', 'What is the candidate quality like?', 'candidate-analysis'], + ['admin.talentPool', 'How healthy is the talent pool?', 'talent-pool-analysis'], + ['admin.analytics', 'Show me workforce coverage', 'workforce-analytics'], + ['admin.activity', 'Is there anything unusual?', 'anomaly-detection'], + ['admin.activity', 'Give me an event breakdown', 'activity-analysis'], + ['admin.controlCenter', 'What is the operational risk?', 'operational-risk'], + ['admin.controlCenter', 'Give me an executive summary', 'executive-summary'], + ['admin.hiredHistory', 'What is our time to hire?', 'hiring-history-analysis'], + ['admin.forge', 'How is training progress?', 'learning-analysis'], + ['admin.controlCenter', 'What is the hiring pulse?', 'hiring-pulse-analysis'], +]; + +for (const [contextId, question, expected] of TRIGGER_CASES) { + const matched = reg.matchSkill(question, contextId, [], []); + record(`"${question}" reaches \`${expected}\``, + matched?.id === expected, matched?.id || 'no match'); +} + +/** + * The check that protects everything already shipped. + * + * Thirteen new definitions across shared pages is the likeliest way to take a + * question that an existing definition — or a page's own reader — was answering. + * Every baseline question is replayed on every page: whatever answered it before + * must still answer it. + */ +const stolen = []; +for (const contextId of Object.keys(expectedBaseline.contexts)) { + const before = expectedBaseline.contexts[contextId]; + for (const intent of before.intents) { + const now = reg.matchSkill(intent.question, contextId, [], [])?.id ?? null; + if (now !== intent.matchedSkill) { + stolen.push(`${contextId} "${intent.question}": ${intent.matchedSkill ?? 'page reader'} → ${now}`); + } + } +} +record('no new skill takes a question something else was answering', + stolen.length === 0, stolen.join(' | ') || `${Object.keys(expectedBaseline.contexts).length} contexts replayed`); + +/* ── The format stays suitable for non-technical authoring ──────────────── */ + +/** + * Every analysis definition round-trips through the field writer. + * + * The Markdown is the canonical representation, and a future authoring flow + * will compose it rather than replace it. That only holds if a definition can + * be read into fields and written back without losing what it said — so the + * property is asserted now, while there are thirteen definitions to test it + * against, rather than discovered later. + */ +const lossy = []; +for (const id of ANALYSIS_SKILLS) { + const skill = reg.SKILLS.find((s) => s.id === id); + if (!skill) continue; + const rewritten = fields.patchFrontmatter(skill.markdown, { description: skill.description }); + const reparsedSkill = reg.parseSkill(rewritten, { custom: true }); + if (reparsedSkill.body !== skill.body) lossy.push(`${id}: body`); + if (JSON.stringify(reparsedSkill.owliver) !== JSON.stringify(skill.owliver)) lossy.push(`${id}: owliver`); + if (JSON.stringify(reparsedSkill.pages) !== JSON.stringify(skill.pages)) lossy.push(`${id}: pages`); +} +record('an analysis definition survives being written back through the field writer', + lossy.length === 0, lossy.join(', ') || `${ANALYSIS_SKILLS.length} definitions`); + +record('every analysis skill declares a category for grouping', + ANALYSIS_SKILLS.every((id) => reg.SKILLS.find((s) => s.id === id)?.category), + [...new Set(ANALYSIS_SKILLS.map((id) => reg.SKILLS.find((s) => s.id === id)?.category))].join(', ')); + +/* ── 15. Agent registry ─────────────────────────────────────────────────── + * + * Agents are the layer above skills, and they are read by the *same* parser — + * `parseAgent` imports `parseFrontmatter` and the section readers from the + * skill registry rather than reimplementing them. These checks exist to keep + * that true, and to keep an agent honest about what it carries: an agent that + * names a skill nobody provides is a capability promised and not delivered, + * and it fails silently. + */ +console.log('\n── Agent registry ──'); + +const agentReg = await server.ssrLoadModule('/src/lib/agents/registry.js'); +const agentFields = await server.ssrLoadModule('/src/lib/agents/agentFields.js'); +const customAgents = await server.ssrLoadModule('/src/lib/agents/customAgents.js'); +const vocab = await server.ssrLoadModule('/src/lib/agents/vocabulary.js'); + +const agentFiles = readdirSync(join(ROOT, 'src/agents')).filter((f) => f.endsWith('.md')); + + +record('every .md under src/agents registers', + agentReg.AGENTS.length === agentFiles.length, + `${agentReg.AGENTS.length} registered / ${agentFiles.length} files`); + +record('no two agents share an id', + new Set(agentReg.AGENTS.map((a) => a.id)).size === agentReg.AGENTS.length); + +const shippedAgents = agentReg.readAgentRegistry([]); + +record('shipped agent registry reports no diagnostics', + shippedAgents.diagnostics.length === 0, + shippedAgents.diagnostics.map((d) => d.message).join(' | ') || 'none'); + +record('every shipped agent registered every field it declared', + agentReg.AGENTS.every((a) => !a.errors?.length), + agentReg.AGENTS.flatMap((a) => a.errors || []).join(' | ') || 'none'); + +/* The nine the product ships: one per Krow page, plus the root. */ +const EXPECTED_AGENTS = { + 'krow-workforce-agent': null, // covers every page + 'control-center-agent': 'control-center', + 'positions-agent': 'positions', + 'candidates-agent': 'candidates', + 'hired-history-agent': 'hired-history', + 'talent-pool-agent': 'talent-pool', + 'krow-forge-agent': 'krow-forge', + 'analytics-agent': 'analytics', + 'activity-agent': 'activity', +}; + +for (const [id, page] of Object.entries(EXPECTED_AGENTS)) { + const agent = agentReg.getAgent(agentReg.AGENTS, id); + record(`\`${id}\` is registered and published`, + Boolean(agent) && agent.status === 'published', + agent ? agent.status : 'MISSING'); + if (agent && page) { + record(`\`${id}\` covers \`${page}\``, agent.pages.includes(page), + JSON.stringify(agent.pages)); + } +} + +/* The root agent reaches every surface a skill may name, so it can stand in on + a page whose own agent carries nothing. */ +const root = agentReg.getAgent(agentReg.AGENTS, 'krow-workforce-agent'); +record('the root agent covers every supported page', + surfaces.SUPPORTED_SKILL_PAGES.every((p) => root.pages.includes(p)), + `${root.pages.length}/${surfaces.SUPPORTED_SKILL_PAGES.length}`); +record('the root agent carries the other eight as subagents', + root.subagents.length === 8 + && root.subagents.every((s) => s !== root.id && EXPECTED_AGENTS[s] !== undefined), + JSON.stringify(root.subagents)); + +/* Every address an agent states must resolve. */ +const registeredSkillIds = new Set(reg.SKILLS.map((s) => s.id)); +const registeredAgentIds = new Set(agentReg.AGENTS.map((a) => a.id)); + +record('every skill an agent names exists in the skill registry', + agentReg.AGENTS.every((a) => a.skills.every((s) => registeredSkillIds.has(s))), + agentReg.AGENTS.flatMap((a) => a.skills.filter((s) => !registeredSkillIds.has(s))).join(', ') || 'all resolve'); + +record('every subagent an agent names exists', + agentReg.AGENTS.every((a) => a.subagents.every((s) => registeredAgentIds.has(s)))); + +record('every page an agent names is a real surface', + agentReg.AGENTS.every((a) => a.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(p))), + agentReg.AGENTS.flatMap((a) => a.pages.filter((p) => !surfaces.SUPPORTED_SKILL_PAGES.includes(p))).join(', ') || 'all resolve'); + +/** + * No agent carries a placeholder skill. + * + * This replaces an earlier check that pinned four agents at zero skills, which + * was the honest assertion while no skill existed for their pages: the + * temptation when building an agent UI is to invent one so the list looks + * populated. Those skills now exist and are real, so pinning zero would be + * pinning the wrong thing — but the property worth protecting is unchanged, and + * this states it directly instead of by proxy. + * + * A skill is real when it declares a capability whose response binds to a + * registered data source, and that source resolves against the seeded workspace + * without reporting itself unavailable. A definition that names a source the + * product does not have, or one that cannot be read, is a promise the agent + * cannot keep. + */ +const skillById = new Map(reg.SKILLS.map((s) => [s.id, s])); +const placeholders = []; +const unreadable = []; + +for (const agent of agentReg.AGENTS) { + for (const skillId of agent.skills) { + const skill = skillById.get(skillId); + if (!skill) continue; /* already reported as `unattached` above */ + + const responses = Object.values(skill.owliver?.responses || {}); + const sources = responses.map((r) => r.source).filter(Boolean); + + /** + * Two kinds of real skill, and only one of them reads data. + * + * An *analysis* skill binds capabilities to data sources. An *action* skill + * — `create-position`, `forge-skill-management` — declares actions or a + * guided conversation instead, and carries no responses at all. Both are + * real; a definition that does neither is the placeholder this looks for. + */ + const doesSomething = sources.length > 0 + || (skill.actions || []).length > 0 + || (skill.conversation || []).length > 0; + + if (!doesSomething || !sources.every((src) => surfaces.dataSourceFor(src))) { + placeholders.push(`${agent.id}/${skillId}`); + continue; + } + + for (const response of responses) { + const declared = surfaces.dataSourceFor(response.source); + /* A source that needs a position or a candidate in context is *correctly* + unavailable when it is handed neither — `resolveEntity` asks the reader + which one at runtime. Only workspace-level sources can be read cold, + so only those are asserted here. */ + if (declared?.context) continue; + + const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT); + /* `unavailable` means the resolver has no reading for that source at all. + `empty` is fine and honest — the source read successfully and found + nothing. */ + if (read?.unavailable) unreadable.push(`${agent.id}/${skillId}:${response.source}`); + } + } +} + +record('no agent carries a skill without a real data source', + placeholders.length === 0, placeholders.join(', ') || 'every attached skill declares one'); + +record('every skill an agent carries reads a source the product can resolve', + unreadable.length === 0, unreadable.join(', ') || 'all resolve'); + +/* Every page agent now carries at least one skill of its own, except where the + product genuinely has none to give it. Stated as data rather than as a + number, so a future page with no source is visible rather than assumed. */ +const withoutSkills = agentReg.AGENTS.filter((a) => a.skills.length === 0).map((a) => a.id); +record('every agent that has a skill available to it carries one', + withoutSkills.length === 0, withoutSkills.join(', ') || 'all nine agents carry skills'); + +/* ── Diagnostics: nothing half-loads in silence ──────────────────────────── */ + +const UNATTACHED = `---\nid: ghost-agent\nname: Ghost Agent\npages:\n - positions\nskills:\n - no-such-skill\n---\n\n# Ghost\n`; +const withUnattached = agentReg.readAgentRegistry([{ path: 'custom/ghost.md', raw: UNATTACHED }]); +record('an agent naming a missing skill is reported', + withUnattached.diagnostics.some((d) => d.kind === 'unattached' && d.agentId === 'ghost-agent'), + withUnattached.diagnostics.find((d) => d.kind === 'unattached')?.message ?? 'NO DIAGNOSTIC'); + +const BROKEN_AGENT = `---\nid: broken\n name: bad indent\n---\n# Broken\n`; +const withBrokenAgent = agentReg.readAgentRegistry([{ path: 'custom/broken.md', raw: BROKEN_AGENT }]); +record('an unreadable stored agent is reported, not silently dropped', + withBrokenAgent.diagnostics.some((d) => d.kind === 'unreadable'), + withBrokenAgent.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC'); + +const SHADOW_AGENT = `---\nid: positions-agent\nname: My Positions Agent\npages:\n - positions\n---\n\n# Shadow\n`; +const withShadowAgent = agentReg.readAgentRegistry([{ path: 'custom/shadow.md', raw: SHADOW_AGENT }]); +record('a stored agent overriding a built-in is reported', + withShadowAgent.diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent'), + withShadowAgent.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC'); + +/* A definition with one bad field keeps the rest AND says so. */ +const PARTIAL_AGENT = `---\nid: partial-agent\nname: Partial Agent\npages:\n - positions\nreasoning: telepathy\n---\n\n# Partial\n`; +const withPartialAgent = agentReg.readAgentRegistry([{ path: 'custom/partial.md', raw: PARTIAL_AGENT }]); +const partialAgent = withPartialAgent.agents.find((a) => a.id === 'partial-agent'); +record('an agent with one bad field still registers', + Boolean(partialAgent) && partialAgent.pages.includes('positions')); +record('...falls back to the documented default', + partialAgent?.reasoning === 'balanced', partialAgent?.reasoning); +record('...and the field it lost is reported', + withPartialAgent.diagnostics.some((d) => d.kind === 'incomplete' && d.agentId === 'partial-agent'), + withPartialAgent.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC'); + +/* A cycle would make subagent resolution non-terminating. */ +const SELF_SUB = `---\nid: loop-agent\nname: Loop Agent\npages:\n - positions\nsubagents:\n - loop-agent\n---\n\n# Loop\n`; +const looped = agentReg.parseAgent(SELF_SUB, { custom: true }); +record('an agent cannot be its own subagent', + looped.subagents.length === 0 && looped.errors.length > 0, + looped.errors[0] || 'NOT REPORTED'); + +/* ── Validation: what the editor refuses ─────────────────────────────────── */ + +const REFUSALS = [ + ['an empty definition', ''], + ['a malformed id', `---\nid: Not An Id\nname: Bad\npages:\n - positions\n---\n# x\n`], + ['no name', `---\nid: no-name\npages:\n - positions\n---\n# x\n`], + ['no pages', `---\nid: no-pages\nname: No Pages\n---\n# x\n`], + ['an unsupported page', `---\nid: bad-page\nname: Bad Page\npages:\n - the-moon\n---\n# x\n`], + ['an unsupported reasoning mode', `---\nid: bad-reason\nname: Bad Reason\npages:\n - positions\nreasoning: vibes\n---\n# x\n`], + ['an unsupported permission role', `---\nid: bad-role\nname: Bad Role\npages:\n - positions\npermissions:\n people:\n - user: a@b.com\n role: emperor\n---\n# x\n`], +]; + +for (const [label, source] of REFUSALS) { + record(`validateAgentSource refuses ${label}`, + Boolean(agentReg.validateAgentSource(source)), + agentReg.validateAgentSource(source) || 'ACCEPTED'); +} + +/** + * An agent that omits `id:` derives one from its name. + * + * The same fallback `parseSkill` applies, and deliberately not a refusal: an + * explicit id is an address other definitions refer to, so writing one is + * encouraged, but leaving it out means "call it after its name" rather than + * "this file is broken". + */ +const DERIVED_ID = `---\nname: Derived Id Agent\npages:\n - positions\n---\n\n# D\n`; +record('an agent with no `id:` derives one from its name', + agentReg.parseAgent(DERIVED_ID, { custom: true }).id === 'derived-id-agent' + && agentReg.validateAgentSource(DERIVED_ID) === null, + agentReg.parseAgent(DERIVED_ID, { custom: true }).id); + +record('an agent with neither id nor name is refused', + Boolean(agentReg.validateAgentSource(`---\npages:\n - positions\n---\n\n# x\n`))); + +record('validateAgentSource accepts a minimal agent', + agentReg.validateAgentSource(`---\nid: minimal\nname: Minimal\npages:\n - positions\n---\n\n# Minimal\n`) === null); + +/** + * An agent carrying no skills is accepted. + * + * Deliberately not a refusal, unlike a skill that declares no capabilities. + * A skill with nothing to say cannot answer; an agent with no skills still has + * its page's own reader — which is exactly how Control Center, Hired History, + * Talent Pool and Activity answer today. + */ +record('validateAgentSource accepts an agent with no skills', + agentReg.validateAgentSource(`---\nid: skill-less\nname: Skill-less\npages:\n - control-center\n---\n\n# S\n`) === null); + +/* ── Fields ⇄ definition, through the existing writer ────────────────────── */ + +const rootSource = root.markdown; +const patched = agentFields.applyAgentFields(rootSource, { + name: 'Renamed Agent', + permissions: { + owner: 'owner@krow.app', + access: 'specific', + people: [{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }], + }, + knowledge: [{ id: 'policy', label: 'Overtime policy', kind: 'note', body: 'Beyond 20h needs sign-off.' }], +}); +const reparsed = agentReg.parseAgent(patched, { custom: true }); + +/* The claim the whole editor rests on: `patchFrontmatter` already writes + nested block maps and `- key: value` sequences, so agents needed no second + writer. Asserted rather than assumed. */ +record('a nested `permissions.people` list round-trips through the existing writer', + JSON.stringify(reparsed.permissions.people) + === JSON.stringify([{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }]), + JSON.stringify(reparsed.permissions.people)); + +record('a nested `knowledge` entry round-trips too', + reparsed.knowledge.length === 1 && reparsed.knowledge[0].body === 'Beyond 20h needs sign-off.', + JSON.stringify(reparsed.knowledge)); + +record('patching one field leaves the body untouched', + reparsed.instructions === root.instructions); + +record('patching one field leaves the others untouched', + JSON.stringify(reparsed.skills) === JSON.stringify(root.skills) + && reparsed.subagents.length === root.subagents.length, + `${reparsed.skills.length} skills, ${reparsed.subagents.length} subagents`); + +record('the patched definition still validates', + agentReg.validateAgentSource(patched) === null, + agentReg.validateAgentSource(patched) || 'ok'); + +record('fields read back out match what was written in', + agentFields.agentFieldsFromSource(patched).permissions.access === 'specific'); + +/* ── Storage round trip ──────────────────────────────────────────────────── */ + +const template = customAgents.agentTemplate({ id: 'my-agent', name: 'My Agent', pages: ['positions'] }); +record('a fresh agent template validates', + agentReg.validateAgentSource(template) === null, + agentReg.validateAgentSource(template) || 'ok'); +record('a fresh agent is a draft, never published', + agentReg.parseAgent(template, { custom: true }).status === 'draft', + agentReg.parseAgent(template, { custom: true }).status); + +const storedAgent = customAgents.upsertCustomAgent([], template); +record('an authored agent stores as its own Markdown', + storedAgent.next.length === 1 && storedAgent.next[0].raw === template); +record('...and reads back with its id intact', + agentReg.parseAgent(storedAgent.next[0].raw, { custom: true }).id === 'my-agent'); + +const restored = customAgents.upsertCustomAgent(storedAgent.next, template); +record('re-saving an agent replaces its entry rather than duplicating it', + restored.next.length === 1, `${restored.next.length} stored`); + +record('customAgentSource finds a stored definition', + customAgents.customAgentSource(storedAgent.next, 'my-agent') === template); + +record('removeCustomAgent drops exactly one entry', + customAgents.removeCustomAgent(storedAgent.next, 'my-agent').length === 0); + +/* An unparseable storedAgent entry must not take its neighbours with it. */ +const mixed = [{ path: 'custom/bad.md', raw: BROKEN_AGENT }, ...storedAgent.next]; +record('a broken stored agent does not remove the good ones', + customAgents.removeCustomAgent(mixed, 'nobody').length === 2); + +/* ── Search ─────────────────────────────────────────────────────────────── */ + +record('an empty search returns every agent', + agentReg.searchAgents(agentReg.AGENTS, '').length === agentReg.AGENTS.length); +record('search matches on name', + agentReg.searchAgents(agentReg.AGENTS, 'analytics').some((a) => a.id === 'analytics-agent')); +record('search matches on what an agent is for', + agentReg.searchAgents(agentReg.AGENTS, 'audit trail').some((a) => a.id === 'activity-agent')); +record('search that matches nothing returns nothing', + agentReg.searchAgents(agentReg.AGENTS, 'zzzznope').length === 0); + +/* ── Vocabulary is closed ───────────────────────────────────────────────── */ + +record('every agent names an icon the product has', + agentReg.AGENTS.every((a) => vocab.AGENT_ICONS.includes(a.icon)), + agentReg.AGENTS.map((a) => a.icon).join(', ')); +record('every agent names a supported reasoning mode', + agentReg.AGENTS.every((a) => vocab.SUPPORTED_REASONING.includes(a.reasoning))); +record('every agent has a version of at least 1', + agentReg.AGENTS.every((a) => Number.isInteger(a.version) && a.version >= 1)); +record('every agent states when to use it', + agentReg.AGENTS.every((a) => a.trigger.length > 0)); +record('every agent states its instructions', + agentReg.AGENTS.every((a) => a.instructions.length > 0)); + +/* ── The parser is the skill parser ─────────────────────────────────────── */ + +/* A BOM, CRLF line endings, a blank line above the fence and trailing spaces + after it — the four things that used to take a skill definition down. An + agent gets the same tolerance for free, because it is the same function. */ +const HOSTILE = `\r\n\r\n--- \r\nid: hostile-agent\r\nname: Hostile Agent\r\npages:\r\n - positions\r\n--- \r\n\r\n# Hostile\r\n\r\n## Instructions\r\n\r\nStill readable.\r\n`; +const hostile = agentReg.parseAgent(HOSTILE, { custom: true }); +record('an agent survives a BOM, CRLF, a leading blank line and trailing spaces', + hostile.id === 'hostile-agent' && hostile.pages.includes('positions'), + `${hostile.id} / ${JSON.stringify(hostile.pages)}`); +record('...and its body still reads', + hostile.instructions.includes('Still readable.'), + hostile.instructions || 'LOST'); + +/* ── 16. Page boundary: runtime, knowledge and tools ────────────────────── + * + * The one property the whole design rests on: **an agent narrows a page and can + * never widen it.** Every layer added on top — data, knowledge, tools — has to + * inherit that, or selecting an agent becomes a way around the page boundary. + * + * It is asserted exhaustively rather than by example. Every agent is tried on + * every page, and the scoped result must be a subset of what the page offers + * with no agent at all. A subset cannot contain something the page did not have, + * so this is a proof rather than a spot-check. + */ +console.log('\n── Page boundary: runtime, knowledge and tools ──'); + +const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js'); +const contexts = await server.ssrLoadModule('/src/components/ai-assistant/contexts.js'); +const knowledge = await server.ssrLoadModule('/src/lib/agents/knowledge.js'); +const tools = await server.ssrLoadModule('/src/lib/skills/tools.js'); +const agentContext = await server.ssrLoadModule('/src/lib/agents/context.js'); +const routingModule = await server.ssrLoadModule('/src/components/ai-assistant/routing.js'); +const actions = await server.ssrLoadModule('/src/lib/skills/actions.js'); + +const ALL_AGENTS = agentReg.AGENTS; +const ALL_SKILLS = reg.SKILLS; +const PAGE_CONTEXTS = Object.keys(expectedBaseline.contexts); + +/* ── The invariant, over every page × agent pair ─────────────────────────── */ + +const widened = []; +const scopedCounts = []; + +for (const contextId of PAGE_CONTEXTS) { + const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id); + + for (const agent of ALL_AGENTS) { + const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []); + const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id); + + const extra = scoped.filter((id) => !unscoped.includes(id)); + if (extra.length) widened.push(`${contextId} + ${agent.id}: ${JSON.stringify(extra)}`); + scopedCounts.push(scoped.length); + } +} + +record(`no agent widens any page (${PAGE_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents)`, + widened.length === 0, + widened.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} combinations, every result a subset`); + +/* With no agent at all, nothing is withheld — the pre-agent behaviour. */ +record('no agent selected withholds nothing', + PAGE_CONTEXTS.every((contextId) => + JSON.stringify(reg.skillsForContext(contextId, runtime.agentScopedDisabled(null, ALL_SKILLS, []), []).map((s) => s.id)) + === JSON.stringify(reg.skillsForContext(contextId, [], []).map((s) => s.id)))); + +record('`agentScopedDisabled(null, …)` returns its input untouched', + JSON.stringify(runtime.agentScopedDisabled(null, ALL_SKILLS, ['a', 'b'])) === JSON.stringify(['a', 'b'])); + +/* ── The three named cross-boundary cases ────────────────────────────────── */ + +const CROSS_CASES = [ + ['Positions page + Analytics Agent', 'admin.positions', 'analytics-agent'], + ['Candidates page + Krow Workforce Agent', 'admin.candidatesList', 'krow-workforce-agent'], + ['Analytics page + Positions Agent', 'admin.analytics', 'positions-agent'], +]; + +for (const [label, contextId, agentId] of CROSS_CASES) { + const agent = agentReg.getAgent(ALL_AGENTS, agentId); + const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id); + const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []); + const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id); + + /* Data. */ + record(`${label}: reads nothing the page does not already offer`, + scoped.every((id) => pageOnly.includes(id)), + `page offers ${JSON.stringify(pageOnly)}, agent sees ${JSON.stringify(scoped)}`); + + /* Tools. */ + const pageTools = tools.toolsForContext(contextId, [], []).map((t) => t.name); + const agentTools = tools.toolsForContext(contextId, disabled, []).map((t) => t.name); + record(`${label}: reaches no tool the page does not already offer`, + agentTools.every((name) => pageTools.includes(name)), + `page offers ${JSON.stringify(pageTools)}, agent reaches ${JSON.stringify(agentTools)}`); + + /* Knowledge. */ + const covers = runtime.agentCovers(agent, contextId); + const read = knowledge.retrieveKnowledge({ + agent, contextId, question: 'What does the scope note say about pages?', + }); + if (!covers) { + record(`${label}: retrieves no knowledge, because the agent is constrained here`, + read.available === false && read.passages.length === 0, + read.note || 'NO NOTE'); + } else { + record(`${label}: knowledge stays inside the agent's own entries`, + read.passages.every((p) => (agent.knowledge || []).some((k) => k.id === p.documentId)), + `${read.passages.length} passage(s)`); + } + + /* Routing. */ + const intent = routingModule.resolveIntent({ + question: 'What needs my attention?', + contextId, + disabledSkills: disabled, + agent, + agentCoversPage: covers, + agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId), + }); + record(`${label}: ${covers ? 'answers from this page' : 'declines honestly instead of reaching'}`, + covers ? intent.kind !== 'constrained' : intent.kind === 'constrained', + `kind=${intent.kind}`); +} + +/** + * A constrained agent must not answer *from the pages it does cover*. + * + * The subtlest way to break the boundary: Analytics Agent on Positions could + * plausibly answer an analytics question "because that is what it is for". It + * must not — the page is the boundary, and an agent is a lens on the page in + * front of the reader, never a route to a different one. + */ +const analyticsAgent = agentReg.getAgent(ALL_AGENTS, 'analytics-agent'); +const leaked = routingModule.resolveIntent({ + question: 'How much overtime are we running?', + contextId: 'admin.positions', + disabledSkills: runtime.agentScopedDisabledWith(analyticsAgent, ALL_AGENTS, ALL_SKILLS, []), + agent: analyticsAgent, + agentCoversPage: false, + agentSuggestion: agentReg.getAgent(ALL_AGENTS, 'positions-agent'), +}); +record('a constrained agent does not answer from the pages it covers elsewhere', + leaked.kind === 'constrained' && !leaked.skill, + `kind=${leaked.kind}, skill=${leaked.skill?.id ?? 'none'}`); + +record('...and says which agent belongs here instead', + JSON.stringify(leaked.doc).includes('Positions Agent')); + +/* ── Tools ──────────────────────────────────────────────────────────────── */ + +record('every action a handler exists for is described', + tools.undescribedActions().length === 0, + tools.undescribedActions().join(', ') || `${tools.TOOL_NAMES.length} tools described`); + +record('a tool that writes a record requires approval', + tools.toolRequiresApproval('create_position') === true); + +record('a tool that only navigates does not', + ['navigate_to_positions', 'navigate_to_analytics', 'open_related_page'] + .every((name) => tools.toolRequiresApproval(name) === false)); + +record('every mutating tool requires approval', + tools.TOOLS.filter((t) => t.mutates).every((t) => t.requiresApproval), + tools.TOOLS.filter((t) => t.mutates).map((t) => t.name).join(', ') || 'none mutate'); + +record('every read-only tool is honest about mutating nothing', + tools.TOOLS.filter((t) => t.readOnly).every((t) => t.mutates === null)); + +/* A tool is reachable only through a skill on this page that declares it. */ +const undeclaredTools = []; +for (const contextId of PAGE_CONTEXTS) { + const declared = new Set( + reg.skillsForContext(contextId, [], []).flatMap((s) => s.actions || []) + ); + for (const tool of tools.toolsForContext(contextId, [], [])) { + if (!declared.has(tool.name)) undeclaredTools.push(`${contextId}: ${tool.name}`); + } +} +record('a tool is reachable only where a skill on that page declares it', + undeclaredTools.length === 0, undeclaredTools.join(', ') || 'every tool traced to a declaring skill'); + +/** + * The real action still refuses what a skill did not declare. + * + * `runAction` gated on `skill.actions` before any of this existed, and the tool + * layer describes actions rather than performing them — so the gate must be + * exactly where it was. + */ +const positionsSkill = reg.SKILLS.find((s) => s.id === 'staffing-risk'); +record('runAction still refuses an action the skill did not declare', + actions.runAction('create_position', { skill: positionsSkill, draft: {}, status: 'draft' }) === null); + +const creator = reg.SKILLS.find((s) => s.id === 'create-position'); +record('...and still performs one it did', + actions.runAction('create_position', { skill: creator, draft: { title: 'X' }, status: 'draft' })?.type + === 'create_position'); + +record('open_related_page resolves only addresses the product has', + actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'positions' })?.route + === '/admin/positions' + && actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'the-moon' }) === null); + +/* ── Knowledge ──────────────────────────────────────────────────────────── */ + +record('an agent with no knowledge says so rather than returning nothing', + (() => { + const bare = agentReg.getAgent(ALL_AGENTS, 'activity-agent'); + const read = knowledge.retrieveKnowledge({ agent: bare, contextId: 'admin.activity', question: 'policy' }); + return read.available === false && /no knowledge attached/i.test(read.note || ''); + })()); + +record('no agent is active means no knowledge, stated honestly', + (() => { + const read = knowledge.retrieveKnowledge({ agent: null, question: 'anything' }); + return read.available === false && read.passages.length === 0 && Boolean(read.note); + })()); + +const rootAgent = agentReg.getAgent(ALL_AGENTS, 'krow-workforce-agent'); +const found = knowledge.retrieveKnowledge({ + agent: rootAgent, contextId: 'admin.positions', question: 'What can this agent see across pages?', +}); +record('an agent with knowledge retrieves from its own entries', + found.available === true && found.passages.length > 0, + `${found.passages.length} passage(s)`); + +record('every passage names the document it came from', + found.passages.every((p) => p.documentId && p.chunkId && p.text)); + +record('a question the knowledge does not cover returns nothing, and says so', + (() => { + const miss = knowledge.retrieveKnowledge({ + agent: rootAgent, contextId: 'admin.positions', question: 'zzzz quantum bicycles', + }); + return miss.available === true && miss.passages.length === 0 && Boolean(miss.note); + })()); + +record('no knowledge document is invented', + ALL_AGENTS.every((a) => knowledge.knowledgeDocuments(a) + .every((d) => (a.knowledge || []).some((k) => k.id === d.id))), + `${ALL_AGENTS.reduce((n, a) => n + knowledge.knowledgeDocuments(a).length, 0)} declared documents in total`); + +/* ── Question classification ────────────────────────────────────────────── */ + +record('a question about records is structured, never retrieval', + runtime.classifyQuestion({ question: 'Which employees worked more than 20 overtime hours?' }) === 'structured'); + +record('a question about a document is knowledge', + runtime.classifyQuestion({ question: 'What does our overtime policy say?' }) === 'knowledge'); + +record('a question needing both is combined', + runtime.classifyQuestion({ question: 'Which employees exceeded the overtime policy this month?' }) === 'combined'); + +record('an ambiguous question stays structured rather than guessing at retrieval', + runtime.classifyQuestion({ question: 'How is attendance?' }) === 'structured'); + +/* ── Coverage and defaults ──────────────────────────────────────────────── */ + +const NATIVE = { + 'admin.controlCenter': 'control-center-agent', + 'admin.positions': 'positions-agent', + 'admin.candidatesList': 'candidates-agent', + 'admin.hiredHistory': 'hired-history-agent', + 'admin.talentPool': 'talent-pool-agent', + 'admin.forge': 'krow-forge-agent', + 'admin.analytics': 'analytics-agent', + 'admin.activity': 'activity-agent', +}; +for (const [contextId, expected] of Object.entries(NATIVE)) { + record(`${contextId.replace('admin.', '')} opens on its own agent`, + runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id === expected, + runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id || 'none'); +} + +record('every page has an agent to open with', + PAGE_CONTEXTS.every((contextId) => runtime.defaultAgentForContext(ALL_AGENTS, contextId)), + PAGE_CONTEXTS.filter((c) => !runtime.defaultAgentForContext(ALL_AGENTS, c)).join(', ') || 'all covered'); + +record('a requested agent is never silently swapped for another', + (() => { + const turn = runtime.resolveAgentForTurn(ALL_AGENTS, 'analytics-agent', 'admin.positions'); + return turn.agent.id === 'analytics-agent' && turn.covers === false && turn.suggestion?.id === 'positions-agent'; + })()); + +/* ── Subagents ──────────────────────────────────────────────────────────── */ + +record('subagent skills are inherited one level deep', + runtime.agentSkillIds(rootAgent, ALL_AGENTS).length >= rootAgent.skills.length, + `${rootAgent.skills.length} own → ${runtime.agentSkillIds(rootAgent, ALL_AGENTS).length} with subagents`); + +record('an unpublished subagent contributes nothing', + (() => { + const draftSub = { ...agentReg.getAgent(ALL_AGENTS, 'analytics-agent'), status: 'draft' }; + const others = ALL_AGENTS.map((a) => (a.id === 'analytics-agent' ? draftSub : a)); + const withDraft = runtime.agentSkillIds(rootAgent, others); + const withPublished = runtime.agentSkillIds(rootAgent, ALL_AGENTS); + return withDraft.length <= withPublished.length; + })()); + +record('a subagent cycle terminates', + (() => { + const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' }; + const b = { id: 'b', skills: ['s2'], subagents: ['a'], status: 'published' }; + return runtime.agentSkillIds(a, [a, b]).length === 2; + })()); + +/* ── Starters ───────────────────────────────────────────────────────────── */ + +record('an agent offers no starters on a page it does not cover', + runtime.agentStarters(analyticsAgent, 'admin.positions').length === 0); + +record('an agent offers its starters on a page it does cover', + runtime.agentStarters(analyticsAgent, 'admin.analytics').length > 0, + runtime.agentStarters(analyticsAgent, 'admin.analytics').map((s) => s.label).join(' | ')); + +record('a starter names no capability, so it cannot address an unoffered skill', + ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null))); + +/* ── Context envelope ───────────────────────────────────────────────────── */ + +const envelope = agentContext.buildOwliverContext({ + context: { id: 'admin.positions', page: 'Positions' }, + pathname: '/admin/positions', + pageContext: { position: { id: 'job_chef' } }, +}); +record('the envelope resolves its address from the placement table', + envelope.route === '/admin/positions' && envelope.pageKey === 'positions', + `${envelope.pageKey} @ ${envelope.route}`); + +record('a page that publishes only a record still yields a valid envelope', + envelope.position?.id === 'job_chef' + && Array.isArray(envelope.selectedItems) && envelope.selectedItems.length === 0 + && envelope.period === null, + JSON.stringify({ selected: envelope.selectedItems.length, period: envelope.period })); + +record('a page that publishes nothing at all still yields a valid envelope', + (() => { + const bare = agentContext.buildOwliverContext({ context: { id: 'admin.activity', page: 'Activity' } }); + return bare.pageKey === 'activity' && bare.position === null && Object.keys(bare.metrics).length === 0; + })()); + +record('the stored form of the envelope carries no records', + (() => { + const stored = agentContext.storableContext(envelope); + return !('selectedItems' in stored) && !('metrics' in stored) && !('position' in stored) + && stored.pageKey === 'positions'; + })()); + +/* ── Nothing above changed the unscoped product ─────────────────────────── */ + +/** + * The regression assertion for the whole phase. + * + * `resolveIntent` gained four parameters. With none of them supplied it must + * behave exactly as it did before — same branch, same skill, same document + * shape — or every existing caller has quietly changed. + */ +const UNSCOPED_CASES = [ + ['admin.positions', 'Which positions need attention?'], + ['admin.candidatesList', 'Who is waiting on a decision?'], + ['admin.analytics', 'What is the hiring trend?'], + ['admin.activity', 'What happened recently?'], + ['admin.profile', 'What are my permissions?'], +]; +const drifted = []; +for (const [contextId, question] of UNSCOPED_CASES) { + const out = routingModule.resolveIntent({ question, contextId }); + const before = expectedBaseline.contexts[contextId]?.intents.find((i) => i.question === question); + if (out.kind === 'constrained') drifted.push(`${contextId}: became constrained without an agent`); + if (before && out.kind !== before.kind) drifted.push(`${contextId} "${question}": ${before.kind} → ${out.kind}`); +} +record('resolveIntent with no agent behaves exactly as before', + drifted.length === 0, drifted.join(' | ') || `${UNSCOPED_CASES.length} cases unchanged`); + +/* ── 17. Agent switcher wiring ──────────────────────────────────────────── + * + * The switcher is a React component and there is no component harness here, so + * what is checked is the logic behind it: which agent a page opens on, what the + * list offers, and — the part that matters — that none of it can move the page. + * + * The rendering itself is verified by driving the real application; see the + * Phase 6 report. + */ +console.log('\n── Agent switcher wiring ──'); + +const icons = await server.ssrLoadModule('/src/components/agents/icons.js'); + +/* Every icon an agent names resolves, or falls back to the avatar on purpose. */ +record('every agent icon resolves to a component or to the avatar', + ALL_AGENTS.every((a) => a.icon === 'owliver' || icons.agentIconFor(a.icon)), + ALL_AGENTS.map((a) => `${a.icon}${icons.agentIconFor(a.icon) ? '' : '(avatar)'}`).join(', ')); + +record('the primary agent draws the Owliver avatar rather than a glyph', + icons.agentIconFor('owliver') === null); + +record('an unknown icon falls back to the avatar rather than crashing', + icons.agentIconFor('not-an-icon') === null); + +/* ── The list the switcher renders ──────────────────────────────────────── */ + +/** + * Agents that work here sort above those that do not. + * + * On a page where two of nine apply, the seven that cannot must not sit above + * them. Order is otherwise the registry's own, so the list does not reshuffle + * as the reader types. + */ +for (const contextId of ['admin.positions', 'admin.analytics', 'admin.activity']) { + const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')] + .sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId))); + const firstConstrained = ordered.findIndex((a) => !runtime.agentCovers(a, contextId)); + const lastCovering = ordered.map((a) => runtime.agentCovers(a, contextId)).lastIndexOf(true); + record(`${contextId.replace('admin.', '')}: agents that work here are listed first`, + firstConstrained === -1 || lastCovering < firstConstrained, + ordered.slice(0, 3).map((a) => `${a.name}${runtime.agentCovers(a, contextId) ? '' : ' (constrained)'}`).join(', ')); +} + +record('search reaches every agent by name', + ALL_AGENTS.every((a) => agentReg.searchAgents(ALL_AGENTS, a.name).some((m) => m.id === a.id))); + +record('a constrained agent is still listed rather than hidden', + agentReg.searchAgents(ALL_AGENTS, 'Analytics').some((a) => a.id === 'analytics-agent'), + 'listed on every page, marked where it does not apply'); + +/* ── Native agent per page ──────────────────────────────────────────────── */ + +/** + * A page opens on the agent written for it, and that state is behaviourally + * identical to no agent at all. + * + * The second half is what makes automatic selection safe: a page agent carries + * every skill its own page offers, so selecting it withholds nothing. If that + * ever stopped being true, opening a page would silently lose a capability. + */ +for (const [contextId, expected] of Object.entries(NATIVE)) { + const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId); + const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id); + const withNative = reg.skillsForContext( + contextId, runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []), [] + ).map((s) => s.id); + + record(`${contextId.replace('admin.', '')}: opening on its native agent withholds nothing`, + JSON.stringify(unscoped) === JSON.stringify(withNative), + `${expected}: ${withNative.length}/${unscoped.length} skills`); +} + +/* ── Switching agent cannot move the page ───────────────────────────────── */ + +/** + * The protected contract, at the UI seam. + * + * `AgentProvider` is handed the context id and only reads it. There is no + * setter, no navigation and no write-back, so selecting an agent cannot change + * which page the reader is on. Asserted structurally: the page's context id and + * the skills it offers are identical whichever agent is active. + */ +const movedPage = []; +for (const contextId of PAGE_CONTEXTS) { + const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id); + for (const candidate of ALL_AGENTS) { + const turn = runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId); + /* The context the panel resolves is unchanged by the choice. */ + if (turn.agent?.id !== candidate.id) movedPage.push(`${contextId}: ${candidate.id} was swapped`); + /* And the page still offers exactly what it offered. */ + const stillOffers = reg.skillsForContext(contextId, [], []).map((s) => s.id); + if (JSON.stringify(stillOffers) !== JSON.stringify(pageOnly)) { + movedPage.push(`${contextId}: page changed under ${candidate.id}`); + } + } +} +record('choosing an agent never changes the page or what it offers', + movedPage.length === 0, + movedPage.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} selections, page unchanged in every one`); + +/* ── The constrained state ──────────────────────────────────────────────── */ + +const constrainedPairs = []; +for (const contextId of PAGE_CONTEXTS) { + for (const candidate of ALL_AGENTS) { + if (runtime.agentCovers(candidate, contextId)) continue; + constrainedPairs.push([contextId, candidate]); + } +} + +record('a constrained agent offers no starters', + constrainedPairs.every(([contextId, candidate]) => runtime.agentStarters(candidate, contextId).length === 0), + `${constrainedPairs.length} constrained pairs`); + +/** + * A constrained agent does not answer — which is not the same as carrying no + * skills here. + * + * An earlier version of this check asserted the empty list and was wrong. + * Control Center Agent is constrained on Positions, yet it carries + * `staffing-risk`, and `staffing-risk` genuinely *is* a Positions skill. The + * list is non-empty and entirely within the page's boundary — the subset proof + * above already covers that. + * + * What actually protects the reader is that the constrained branch fires before + * any skill is consulted, so the agent declines rather than answering under a + * name that does not belong to this page. That is what is asserted here, over + * every constrained pair rather than a sample. + */ +const answeredWhileConstrained = []; +for (const [contextId, candidate] of constrainedPairs) { + const intent = routingModule.resolveIntent({ + question: 'What needs my attention?', + contextId, + disabledSkills: runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []), + agent: candidate, + agentCoversPage: false, + agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId), + }); + if (intent.kind !== 'constrained') { + answeredWhileConstrained.push(`${contextId} + ${candidate.id}: ${intent.kind}`); + } +} +record('a constrained agent declines rather than answering, on every page', + answeredWhileConstrained.length === 0, + answeredWhileConstrained.slice(0, 3).join(' | ') || `${constrainedPairs.length} constrained pairs all decline`); + +/* And whatever it does carry is still inside the page's own boundary. */ +record('a constrained agent still cannot exceed the page it is constrained on', + constrainedPairs.every(([contextId, candidate]) => { + const pageOnly = reg.skillsForContext(contextId, [], []).map((x) => x.id); + return reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []), []) + .every((x) => pageOnly.includes(x.id)); + })); + +record('every constrained pair has a native agent to point at instead', + constrainedPairs.every(([contextId]) => runtime.defaultAgentForContext(ALL_AGENTS, contextId))); + +/* ── Starters merge without duplicating a suggestion ────────────────────── */ + +/** + * A starter worded like a skill's suggestion must yield one chip, not two. + * + * The panel de-duplicates on what a chip *resolves to* as well as its label, and + * an agent starter deliberately carries no capability — so it can never address + * a skill the page has not offered, and a duplicate wording drops out. + */ +const dupes = []; +for (const [contextId] of Object.entries(NATIVE)) { + const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId); + const disabled = runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []); + const starters = runtime.agentStarters(native, contextId); + const suggestions = resolver.owliverSuggestions(contextId, disabled, [], {}); + + const labels = [...starters, ...suggestions].map((c) => String(c.label).trim().toLowerCase()); + const unique = new Set(labels); + if (labels.length !== unique.size) { + /* Not a failure in itself — the panel drops the repeat — but it is worth + knowing which wordings collide. */ + dupes.push(`${contextId}: ${labels.length - unique.size}`); + } +} +record('agent starters carry no capability, so they cannot address an unoffered skill', + ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null)), + dupes.length ? `overlapping wordings de-duplicated on: ${dupes.join(', ')}` : 'no overlapping wordings'); + +/** + * Every destination the switcher offers must exist. + * + * This check exists because it did not, and a visual review found two footer + * actions navigating to routes the router had no entry for. Tests covered what + * the switcher *computed* and nothing about where it *sent* the reader, which + * is exactly the gap a 404 lives in. + * + * Read out of `App.jsx` rather than asserted against a written list, so a route + * that is renamed or removed fails here rather than in someone's browser. + */ +const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); +const switcherSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'), 'utf8'); + +/* Only live navigations count: a disabled control goes nowhere by design. */ +const navigated = [...switcherSource.matchAll(/navigate\('([^']+)'\)/g)].map((m) => m[1]); + +const routed = new Set( + [...appSource.matchAll(/ m[1]) + .filter((path) => path !== '*') + .map((path) => (path.startsWith('/') ? path : `/admin/${path}`)) +); + +const dead = navigated.filter((to) => !routed.has(to)); +record('every route the agent switcher navigates to exists', + dead.length === 0, + dead.length ? `DEAD: ${dead.join(', ')}` : `${navigated.length} destination(s): ${navigated.join(', ')}`); + +/** + * Authoring is reachable from the switcher, and its screen exists. + * + * An earlier version of this asserted the opposite — that the control was + * disabled — which was correct while the management screens did not exist and + * a visual review had found it navigating to a 404. Now that it is built, the + * durable property is the one above: whatever the switcher offers must resolve. + * This states the specific case so the pair cannot silently invert again. + */ +record('creating an agent is reachable from the switcher', + /navigate\('\/admin\/workspace\/agents\/new'\)/.test(switcherSource) + && routed.has('/admin/workspace/agents/new'), + 'offered, and the screen exists'); + +record('every published agent offers at least one starter where it applies', + ALL_AGENTS.filter((a) => a.status === 'published') + .every((a) => a.pages.length === 0 || runtime.agentStarters(a, null).length > 0), + ALL_AGENTS.map((a) => `${a.id}:${a.starters.length}`).join(' ')); + +/* ── 18. Conversation records and insights ─────────────────────────────── + * + * Conversations gained fields: which agent answered, where, what it used, and + * how it was rated. The risk in changing a stored shape is not that the new + * records are wrong — it is that the old ones quietly stop reading, and a + * reader's history disappears without anything saying so. + * + * So the migration is asserted first, and the figures built on top are asserted + * to describe only records that exist. + */ +console.log('\n── Conversation records and insights ──'); + +const insightsSelectors = await server.ssrLoadModule('/src/lib/agents/conversationInsights.js'); +const historyModule = await server.ssrLoadModule('/src/components/ai-assistant/history.js'); + +/* `history.js` writes to localStorage, which does not exist under SSR. A + minimal in-memory stand-in lets the real module be exercised rather than a + reimplementation of it — the migration is the thing under test, and a mock of + it would reproduce none of the failures this section exists for. */ +const historyStore = new Map(); +globalThis.localStorage = { + getItem: (k) => (historyStore.has(k) ? historyStore.get(k) : null), + setItem: (k, v) => historyStore.set(k, String(v)), + removeItem: (k) => historyStore.delete(k), + clear: () => historyStore.clear(), +}; + +const HISTORY_KEY = 'krow_assistant:history'; +const seedHistory = (records) => historyStore.set(HISTORY_KEY, JSON.stringify(records)); + +/* ── A conversation held before any of this existed ──────────────────────── */ + +const V1_RECORD = { + id: 'c_old', + contextId: 'admin.positions', + page: 'Positions', + title: 'Which positions need attention?', + turns: 2, + updatedAt: new Date().toISOString(), + messages: [ + { role: 'user', text: 'Which positions need attention?' }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Three roles need attention.' }] }, + ], +}; + +seedHistory([V1_RECORD]); +const migrated = historyModule.readHistory(); + +record('a conversation stored before agents existed still reads', + migrated.length === 1 && migrated[0].id === 'c_old', + `${migrated.length} record(s)`); + +record('...and its messages are returned byte-identical', + JSON.stringify(migrated[0].messages) === JSON.stringify(V1_RECORD.messages)); + +record('...with the new fields present and honestly empty', + migrated[0].schema === 2 + && migrated[0].agentId === null + && migrated[0].feedback === null + && Array.isArray(migrated[0].skillsUsed) && migrated[0].skillsUsed.length === 0, + JSON.stringify({ + schema: migrated[0].schema, agentId: migrated[0].agentId, skills: migrated[0].skillsUsed, + })); + +record('migration does not rewrite the stored copy', + JSON.parse(historyStore.get(HISTORY_KEY))[0].schema === undefined, + 'read-time migration, so nothing can fail half-written'); + +/* ── A conversation held now ─────────────────────────────────────────────── */ + +historyStore.clear(); +historyModule.saveConversation({ + id: 'c_new', + contextId: 'admin.positions', + page: 'Positions', + agentId: 'positions-agent', + pageContext: { page: 'Positions', pageKey: 'positions', route: '/admin/positions', period: null }, + skillsUsed: ['staffing-risk', 'staffing-risk'], + toolsUsed: ['create_position'], + knowledgeUsed: [], + messages: [ + { role: 'user', text: 'Which roles are at risk?' }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] }, + ], +}); + +const [savedConversation] = historyModule.readHistory(); + +record('a conversation records which agent answered', + savedConversation.agentId === 'positions-agent', savedConversation.agentId); + +record('...what it used, deduplicated', + JSON.stringify(savedConversation.skillsUsed) === JSON.stringify(['staffing-risk']) + && JSON.stringify(savedConversation.toolsUsed) === JSON.stringify(['create_position']), + `${JSON.stringify(savedConversation.skillsUsed)} / ${JSON.stringify(savedConversation.toolsUsed)}`); + +/** + * The stored context is the *reduced* envelope. + * + * Selections and computed figures are records; writing them per turn would put + * the dataset into localStorage a message at a time. What a reviewer needs + * later is where the question was asked, not a copy of what was on screen. + */ +record('the stored page context carries no records', + !('selectedItems' in savedConversation.pageContext) && !('metrics' in savedConversation.pageContext) + && !('position' in savedConversation.pageContext) && savedConversation.pageContext.pageKey === 'positions', + Object.keys(savedConversation.pageContext).join(', ')); + +/* ── Feedback ───────────────────────────────────────────────────────────── */ + +historyModule.recordFeedback('c_new', { rating: 'up' }); +record('a conversation can be rated', historyModule.readHistory()[0].feedback?.rating === 'up'); + +historyModule.recordFeedback('c_new', { rating: 'down', note: 'Missed the chef role' }); +const rerated = historyModule.readHistory(); +record('re-rating corrects rather than appends', + rerated.length === 1 && rerated[0].feedback.rating === 'down' && rerated[0].feedback.note === 'Missed the chef role', + `${rerated.length} record(s), rating=${rerated[0].feedback.rating}`); + +historyModule.recordFeedback('c_new', null); +record('clearing a rating leaves none behind, not a neutral one', + historyModule.readHistory()[0].feedback === null); + +record('rating a conversation that does not exist changes nothing', + historyModule.recordFeedback('c_nope', { rating: 'up' }).length === 1); + +/* A rating survives the thread growing. */ +historyModule.recordFeedback('c_new', { rating: 'up' }); +historyModule.saveConversation({ + id: 'c_new', + contextId: 'admin.positions', + page: 'Positions', + agentId: 'positions-agent', + messages: [ + { role: 'user', text: 'Which roles are at risk?' }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] }, + { role: 'user', text: 'And the chef role?' }, + ], +}); +record('a rating survives the conversation continuing', + historyModule.readHistory()[0].feedback?.rating === 'up' + && historyModule.readHistory()[0].turns === 2, + `rating kept across ${historyModule.readHistory()[0].turns} turns`); + +/* ── Insight selectors ──────────────────────────────────────────────────── */ + +const nowMs = Date.now(); +const day = (n) => new Date(nowMs - n * 86400000).toISOString(); + +const FIXTURE = [ + { id: 'a1', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 3, skillsUsed: ['staffing-risk'], toolsUsed: [], feedback: { rating: 'up' }, updatedAt: day(0), messages: [{ role: 'user', text: 'x' }] }, + { id: 'a2', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 1, skillsUsed: ['staffing-risk', 'create-position'], toolsUsed: ['create_position'], feedback: { rating: 'down' }, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] }, + { id: 'a3', agentId: 'analytics-agent', contextId: 'admin.analytics', page: 'Analytics', turns: 2, skillsUsed: ['overtime-analysis'], toolsUsed: [], feedback: null, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] }, +]; + +/** + * Nothing recorded is a different answer from nothing happening. + * + * A row of zeros reads as "the agent was asked and did nothing". The empty flag + * is what lets a view say "not asked yet" instead — which is the whole reason + * Insights can be honest before any conversation exists. + */ +const none = insightsSelectors.conversationStats([]); +record('no conversations reports empty rather than zeros', + none.empty === true && none.total === 0 && none.feedback.score === null, + `score=${none.feedback.score} (null, not 0)`); + +const stats = insightsSelectors.conversationStats(FIXTURE); +record('conversation counts match the records', stats.total === 3 && stats.turns === 6, + `${stats.total} conversations, ${stats.turns} turns`); + +record('pages counted are the distinct contexts', stats.pages === 2, `${stats.pages} pages`); + +record('feedback counts every rating and every absence', + stats.feedback.up === 1 && stats.feedback.down === 1 && stats.feedback.unrated === 1 + && stats.feedback.score === 50, + JSON.stringify(stats.feedback)); + +record('skills are tallied across conversations, most used first', + JSON.stringify(stats.bySkill) === JSON.stringify([ + { id: 'staffing-risk', count: 2 }, { id: 'create-position', count: 1 }, { id: 'overtime-analysis', count: 1 }, + ]), + JSON.stringify(stats.bySkill)); + +record('tools are tallied too', + JSON.stringify(stats.byTool) === JSON.stringify([{ id: 'create_position', count: 1 }])); + +record('conversations are grouped per agent', + JSON.stringify(stats.byAgent) === JSON.stringify([ + { id: 'positions-agent', count: 2 }, { id: 'analytics-agent', count: 1 }, + ])); + +record('a day nothing was asked is not charted as a zero', + stats.byDay.length === stats.activeDays && stats.byDay.every((d) => d.count > 0), + `${stats.byDay.length} active day(s)`); + +record('stats can be scoped to one agent', + insightsSelectors.conversationStats(FIXTURE, { agentId: 'analytics-agent' }).total === 1); + +record('an agent with no conversations reports empty, not zero', + insightsSelectors.conversationStats(FIXTURE, { agentId: 'activity-agent' }).empty === true); + +record('stats can be windowed by date', + insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total === 1, + `${insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total} in the last 12 hours`); + +record('conversationsForAgent narrows without mutating the input', + insightsSelectors.conversationsForAgent(FIXTURE, 'positions-agent').length === 2 + && FIXTURE.length === 3); + +record('unrated conversations are the review queue', + insightsSelectors.unratedConversations(FIXTURE).map((r) => r.id).join(',') === 'a3'); + +/** + * A review row carries no thread. + * + * A list renders forty of these and one is ever opened; including the messages + * would load every conversation to draw a table. + */ +const row = insightsSelectors.reviewRow(FIXTURE[0]); +record('a review row omits the thread itself', + !('messages' in row) && row.id === 'a1' && row.page === 'Positions', + Object.keys(row).join(', ')); + +/* ── Nothing here invents a figure ──────────────────────────────────────── */ + +const invented = []; +for (const key of ['total', 'turns', 'pages']) { + if (insightsSelectors.conversationStats([])[key] !== 0) invented.push(key); +} +for (const entry of [...stats.bySkill, ...stats.byTool, ...stats.byAgent]) { + const real = FIXTURE.some((r) => [...r.skillsUsed, ...r.toolsUsed, r.agentId].includes(entry.id)); + if (!real) invented.push(entry.id); +} +record('every figure traces to a record that exists', + invented.length === 0, invented.join(', ') || 'nothing invented'); + +delete globalThis.localStorage; + +/* ── 19. Agent management ───────────────────────────────────────────────── + * + * The management screens let someone who has never seen a Markdown file + * create, configure and publish an agent. What makes that safe is that they are + * not a second agent system: every screen writes fields, `agentPatch` turns + * those into frontmatter, and the existing parser reads them back. + * + * So what is checked here is the round trip — a form edit must survive being + * written and re-read — and the lifecycle rules that protect a published agent. + */ +console.log('\n── Agent management ──'); + +const lifecycle = await server.ssrLoadModule('/src/lib/agents/agentLifecycle.js'); + +const shippedSource = agentReg.getAgent(ALL_AGENTS, 'positions-agent').markdown; + +/* ── Fields survive the round trip ──────────────────────────────────────── */ + +/** + * The property the whole management UI rests on. + * + * A form holds fields; storage holds Markdown. If a field could not survive + * being written and read back, configuring an agent would silently lose part of + * it — and the loss would only show up later, in an answer that did not happen. + */ +const EDITS = { + name: 'Renamed Positions Agent', + description: 'A different description.', + trigger: 'Use when roles are not filling.', + instructions: 'Answer about open roles only.\n\nAsk which role when none is open.', + icon: 'briefcase', + reasoning: 'deep', + webSearch: true, + pages: ['positions', 'control-center'], + skills: ['staffing-risk', 'create-position'], + subagents: ['analytics-agent'], + starters: [{ label: 'Which roles are at risk?', prompt: 'Which roles are at risk?' }], + knowledge: [{ id: 'policy', label: 'Fill policy', kind: 'note', body: 'A role open 30 days is escalated.', url: '' }], + permissions: { owner: 'demo@krow.app', access: 'specific', people: [{ user: 'a@krow.app', role: 'editor' }] }, +}; + +const composed = agentFields.applyAgentFields(shippedSource, EDITS); +const readBack = agentFields.agentFieldsFromSource(composed); + +const lost = []; +for (const [key, value] of Object.entries(EDITS)) { + if (JSON.stringify(readBack[key]) !== JSON.stringify(value)) { + lost.push(`${key}: wrote ${JSON.stringify(value)}, read ${JSON.stringify(readBack[key])}`); + } +} +record('every configurable field survives being written and read back', + lost.length === 0, lost.slice(0, 2).join(' | ') || `${Object.keys(EDITS).length} fields`); + +record('the composed definition is valid', + agentReg.validateAgentSource(composed) === null, + agentReg.validateAgentSource(composed) || 'ok'); + +record('configuring an agent never has to touch Markdown', + /^---/.test(composed) && agentReg.parseAgent(composed, { custom: true }).name === EDITS.name, + 'fields in, frontmatter out, parsed by the one parser'); + +/* Editing one field leaves the rest of the file alone, including its prose. */ +const oneField = agentFields.applyAgentFields(shippedSource, { description: 'Just this.' }); +const before8 = agentReg.parseAgent(shippedSource, { custom: true }); +const after8 = agentReg.parseAgent(oneField, { custom: true }); +record('editing one field leaves the others untouched', + after8.description === 'Just this.' + && JSON.stringify(after8.skills) === JSON.stringify(before8.skills) + && after8.instructions === before8.instructions, + `${after8.skills.length} skills and the instructions kept`); + +/* ── Lifecycle ──────────────────────────────────────────────────────────── */ + +record('a duplicate is always a draft at v1', + (() => { + const copy = agentReg.parseAgent( + lifecycle.duplicateAgent(shippedSource, { existingIds: ALL_AGENTS.map((a) => a.id) }), + { custom: true } + ); + return copy.status === 'draft' && copy.version === 1 && copy.id !== 'positions-agent'; + })(), + 'a copy of a published agent must not enter the switcher unreviewed'); + +record('a duplicate keeps what the original carried', + (() => { + const copy = agentReg.parseAgent( + lifecycle.duplicateAgent(shippedSource, { existingIds: [] }), { custom: true } + ); + return JSON.stringify(copy.skills) === JSON.stringify(before8.skills) + && copy.instructions === before8.instructions; + })()); + +record('a duplicate never collides with an existing id', + (() => { + const taken = ALL_AGENTS.map((a) => a.id); + const first = agentReg.parseAgent(lifecycle.duplicateAgent(shippedSource, { existingIds: taken }), { custom: true }); + const second = agentReg.parseAgent( + lifecycle.duplicateAgent(shippedSource, { existingIds: [...taken, first.id] }), { custom: true } + ); + return first.id !== second.id; + })()); + +record('archiving takes an agent out of service without altering it', + (() => { + const archived = agentReg.parseAgent(lifecycle.archiveAgent(shippedSource), { custom: true }); + return archived.status === 'archived' + && JSON.stringify(archived.skills) === JSON.stringify(before8.skills); + })()); + +record('restoring brings it back as a draft, not straight back into service', + agentReg.parseAgent(lifecycle.restoreAgent(lifecycle.archiveAgent(shippedSource)), { custom: true }) + .status === 'draft'); + +/** + * Publishing must never discard a version somebody else published. + * + * The failure it prevents is silent: a draft taken from v1 published over a v2 + * looks exactly like the v2 change never having been made. + */ +record('publishing a draft moves it into service', + (() => { + const draft = lifecycle.restoreAgent(shippedSource); + const out = lifecycle.publishAgent(draft); + return !out.conflict && agentReg.parseAgent(out.source, { custom: true }).status === 'published'; + })()); + +record('republishing a published agent moves its version on', + (() => { + const out = lifecycle.publishAgent(shippedSource); + return agentReg.parseAgent(out.source, { custom: true }).version === before8.version + 1; + })(), + 'so "what is live" is always a specific version'); + +record('publishing over a newer version is refused, not silently applied', + (() => { + const out = lifecycle.publishAgent(lifecycle.restoreAgent(shippedSource), { publishedVersion: 5 }); + return Boolean(out.conflict) && !out.source; + })(), + 'a conflict the screen can explain, rather than a lost change'); + +/* ── An agent created from nothing ──────────────────────────────────────── */ + +/** + * The path a non-technical author actually takes: a blank template, filled in + * through the form, saved. It has to produce a definition the runtime accepts. + */ +const fresh = agentFields.applyAgentFields(customAgents.agentTemplate(), { + id: 'hr-helper', + name: 'HR Helper', + description: 'Answers hiring questions for the HR team.', + trigger: 'Use on Candidates for pipeline questions.', + instructions: 'Answer from candidate records on this page.', + icon: 'users', + reasoning: 'balanced', + pages: ['candidates'], + skills: ['candidate-analysis'], + starters: [{ label: 'How strong is the pool?', prompt: 'How strong is the pool?' }], +}); + +record('an agent created entirely through the form is valid', + agentReg.validateAgentSource(fresh) === null, + agentReg.validateAgentSource(fresh) || 'ok'); + +const freshAgent = agentReg.parseAgent(fresh, { custom: true }); +record('...and starts as a draft rather than live', + freshAgent.status === 'draft', freshAgent.status); + +record('...and registers alongside the shipped ones', + (() => { + const { agents, diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/hr-helper.md', raw: fresh }]); + return agents.some((a) => a.id === 'hr-helper') && diagnostics.length === 0; + })(), + 'no diagnostics, so nothing it declared was dropped'); + +record('...and is bounded by the page exactly like a shipped agent', + (() => { + const disabled = runtime.agentScopedDisabledWith(freshAgent, ALL_AGENTS, ALL_SKILLS, []); + const pageOnly = reg.skillsForContext('admin.candidatesList', [], []).map((s) => s.id); + const scoped = reg.skillsForContext('admin.candidatesList', disabled, []).map((s) => s.id); + /* And it reaches nothing at all on a page it does not cover. */ + const elsewhere = reg.skillsForContext('admin.analytics', disabled, []).map((s) => s.id); + return scoped.every((x) => pageOnly.includes(x)) && elsewhere.length === 0; + })(), + 'a user-created agent gets the same boundary, not a weaker one'); + +/* ── Editing a shipped agent overrides rather than mutates ───────────────── */ + +record('editing a shipped agent is reported as an override', + (() => { + const { diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/positions-agent.md', raw: composed }]); + return diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent'); + })(), + 'the shipped definition is never altered on disk'); + +record('the shipped definition is still intact after an override', + agentReg.AGENTS.find((a) => a.id === 'positions-agent').name === 'Positions Agent'); + +/* ── The Add Skills modal reads the one registry ────────────────────────── */ + +/** + * Asserted against the registry rather than the component, because the failure + * worth preventing is architectural: a separate list for agents would drift + * from the one Owliver runs, and an agent would offer a skill the runtime does + * not have. + */ +const attachable = reg.skillsWithFacet(reg.allSkills([]), 'owliver').filter((s) => s.status === 'active'); +record('every attachable skill comes from the shared registry', + attachable.every((s) => reg.SKILLS.some((r) => r.id === s.id)), + `${attachable.length} attachable`); + +record('workforce training paths are not offered as agent skills', + attachable.every((s) => s.kind !== 'workforce'), + 'a training path is something a person learns, not something an agent does'); + +record('every category offered by the picker matches at least one skill', + (() => { + const categories = [...new Set(attachable.map((s) => s.category).filter(Boolean))]; + return categories.every((c) => attachable.some((s) => s.category === c)); + })(), + [...new Set(attachable.map((s) => s.category).filter(Boolean))].join(', ')); + +/* ── Every management destination exists ────────────────────────────────── */ + +const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new']; +const appRoutes = new Set( + [...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/ m[1]) + .map((path) => (path.startsWith('/') ? path : `/admin/${path}`)) +); +record('every agent management route is registered', + managementRoutes.every((r) => appRoutes.has(r)), + managementRoutes.filter((r) => !appRoutes.has(r)).join(', ') || managementRoutes.join(', ')); + +record('the dynamic agent route is registered after the static one', + (() => { + const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); + return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id'); + })(), + 'so `agents/new` cannot be read as an agent whose id is "new"'); + +/* ── 20. Owliver on the agent configuration screen ──────────────────────── + * + * Configure is a workspace page, not an operational one, and it is now a host + * for the *existing* Owliver rather than a second chat. Two things have to hold + * and they pull in opposite directions: + * + * - Owliver must actually mount there, at both addresses. + * - Standing there must not become standing on an operational page — most + * sharply when the agent being *edited* is an operational agent. + * + * The second is the one worth testing hardest: configuring the Analytics Agent + * must not put a reader on Analytics. + */ +console.log('\n── Owliver on Agent Configure ──'); + +const CONFIGURE_CONTEXT = 'admin.agentConfigure'; + +/* ── It mounts, at both addresses ────────────────────────────────────────── */ + +for (const route of ['/admin/workspace/agents/new', '/admin/workspace/agents/analytics-agent']) { + const resolved = placement.resolveAssistantContext('admin', route); + record(`Owliver mounts on \`${route}\``, + resolved?.id === CONFIGURE_CONTEXT, resolved?.id || 'no panel'); +} + +record('the configure context is the one Owliver already uses, not a new panel', + Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.respond) + && Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.capabilities?.length), + 'a context in the existing table, resolved by the existing placement'); + +/** + * The rest of the workspace hosts Owliver too — under its *own* context. + * + * These two checks used to assert the opposite: that the agents list and the + * other workspace routes carried no panel at all. That was right while Agent + * Configure was the only workspace host, and wrong as a general rule — it left + * Owliver dead on every configuration surface in the product, on the reasoning + * that a page with no specialist agent is a page with no assistant. It is not. + * + * What still matters, and is what these now assert, is that each one resolves + * to *its own* context rather than borrowing Agent Configure's: the page a + * reader is standing on is never something another page's context describes. + */ +const WORKSPACE_HOSTS = { + '/admin/settings': 'admin.settings', + '/admin/workspace': 'admin.workspace', + '/admin/workspace/agents': 'admin.workspaceAgents', + '/admin/workspace/skills': 'admin.workspaceSkills', + '/admin/workspace/skill-development': 'admin.skillDevelopment', + '/admin/workspace/skills/new': 'admin.skillConfigure', + '/admin/workspace/skills/owliver/new': 'admin.skillConfigure', + '/admin/workspace/skills/my-skill': 'admin.skillConfigure', + '/admin/workspace/skills/owliver/my-skill': 'admin.skillConfigure', +}; +const misplaced = Object.entries(WORKSPACE_HOSTS) + .filter(([route, id]) => placement.resolveAssistantContext('admin', route)?.id !== id) + .map(([route, id]) => `${route}: expected ${id}, got ${placement.resolveAssistantContext('admin', route)?.id ?? 'no panel'}`); +record('every workspace surface hosts Owliver under its own context', + misplaced.length === 0, + misplaced.join(' | ') || `${Object.keys(WORKSPACE_HOSTS).length} routes`); + +record('the agents list does not borrow the configure context', + placement.resolveAssistantContext('admin', '/admin/workspace/agents')?.id === 'admin.workspaceAgents'); + +/** + * The pattern must not swallow addresses beneath it. + * + * One segment after `agents/`, and no deeper. A nested route added later would + * otherwise silently inherit this panel. + */ +record('the dynamic pattern matches one segment only', + placement.resolveAssistantContext('admin', '/admin/workspace/agents/x/y') === null + && placement.resolveAssistantContext('admin', '/admin/workspace/agents/x') !== null); + +/* ── The eight operational pages are untouched ───────────────────────────── */ + +/** + * Exact matching still happens first, so none of the eight ever reaches the + * pattern table. Asserted rather than assumed, because "I added a fallback" is + * exactly the change that quietly re-routes something. + */ +const OPERATIONAL = { + '/admin': 'admin.controlCenter', + '/admin/positions': 'admin.positions', + '/admin/candidates': 'admin.candidatesList', + '/admin/hired': 'admin.hiredHistory', + '/admin/talent-pool': 'admin.talentPool', + '/admin/university': 'admin.forge', + '/admin/analytics': 'admin.analytics', + '/admin/activity': 'admin.activity', +}; +const rerouted = Object.entries(OPERATIONAL) + .filter(([route, expectedId]) => placement.resolveAssistantContext('admin', route)?.id !== expectedId); +record('all eight operational pages resolve exactly as before', + rerouted.length === 0, rerouted.map(([r]) => r).join(', ') || '8 pages unchanged'); + +/* ── The critical boundary ──────────────────────────────────────────────── */ + +/** + * Editing the Analytics Agent does not put the reader on Analytics. + * + * The edited agent is a record being changed, not the page anyone is standing + * on. Two independent reasons this holds, both asserted: no skill declares the + * configure page, and the Analytics Agent does not cover it. + */ +record('no skill is available on the configure page at all', + reg.skillsForContext(CONFIGURE_CONTEXT, [], []).length === 0, + JSON.stringify(reg.skillsForContext(CONFIGURE_CONTEXT, [], []).map((s) => s.id))); + +const analyticsAgentCfg = agentReg.getAgent(ALL_AGENTS, 'analytics-agent'); +record('the Analytics Agent does not cover the configure page', + runtime.agentCovers(analyticsAgentCfg, CONFIGURE_CONTEXT) === false); + +record('editing the Analytics Agent exposes no analytics skill', + reg.skillsForContext( + CONFIGURE_CONTEXT, + runtime.agentScopedDisabledWith(analyticsAgentCfg, ALL_AGENTS, ALL_SKILLS, []), + [] + ).length === 0, + 'the edited agent is metadata, not the current page'); + +/* The Analytics page keeps everything it had. A subset check, not equality: + the snapshot predates the analysis skills added since, and the baseline + section above already governs additions. What matters here is that mounting + Owliver on a workspace page took nothing away from an operational one. */ +const analyticsNow = reg.skillsForContext('admin.analytics', [], []).map((s) => s.id); +const analyticsLost = expectedBaseline.contexts['admin.analytics'].skills + .filter((id) => !analyticsNow.includes(id)); +record('...and the Analytics page kept every skill it had', + analyticsLost.length === 0, + analyticsLost.length ? `LOST ${JSON.stringify(analyticsLost)}` : `${analyticsNow.length} skills, none lost`); + +/* Every agent, on the configure page: none reaches an operational skill. */ +const leakedOnConfigure = ALL_AGENTS.filter((a) => + reg.skillsForContext( + CONFIGURE_CONTEXT, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), [] + ).length > 0); +record('no agent reaches an operational skill from the configure page', + leakedOnConfigure.length === 0, + leakedOnConfigure.map((a) => a.id).join(', ') || `${ALL_AGENTS.length} agents, none`); + +/* ── The page answers honestly ──────────────────────────────────────────── */ + +const configureContext = contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]; + +record('the configure page has a native agent to answer with', + runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.id === 'krow-workforce-agent', + runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.name || 'none'); + +record('it answers questions about agents and skills', + ['What agents can I configure?', 'Where do skills come from?', 'What does reasoning do?'] + .every((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT }).kind === 'answer'), + 'agent-management questions are in scope'); + +/** + * A workforce question asked here is declined, not answered. + * + * This is the honest half of the boundary: the page has no operational records, + * so it must say so and point at the page that does — rather than answering + * from whatever the configuration screen happens to know. + */ +const workforceHere = ['How many candidates applied this week?', 'What is our attendance rate?'] + .map((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT })); +record('a workforce question on the configure page is declined or routed away', + workforceHere.every((i) => i.kind === 'outOfScope' || i.kind === 'navigate'), + workforceHere.map((i) => i.kind).join(', ')); + +record('...and never answered from the configure page itself', + workforceHere.every((i) => i.kind !== 'answer' && !i.skill)); + +/* ── One Owliver, not two ───────────────────────────────────────────────── */ + +/** + * Asserted structurally: the configure screen must not import or define a chat. + * The panel it gets is the one the Admin shell already mounts. + */ +const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8'); +const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8'); + +record('the configure screen defines no chat of its own', + !/KrowAssistant|AssistantPanel|useConversation|createAssistantProvider/.test(detailSource + configureSource), + 'no second panel, provider or conversation'); + +record('the panel is mounted once, by the shell', + /* ` !s.pages.includes('workspace-agent-configure')), + 'so there is nothing operational to reach here'); + +/* ── The configure workspace ───────────────────────────────────────────── + * + * The screen is an authoring surface and the only one in the console that is, + * so it carries a visual treatment the eight operational pages do not. What + * follows guards the two things that treatment must not cost. + * + * **Every control still exists.** A redesign that quietly drops a field looks + * exactly like a redesign that kept it — until someone cannot set an icon. So + * each closed vocabulary is asserted to be *rendered from its own table*, not + * merely present as a word: the icon picker over `AGENT_ICONS`, the page chips + * over `SUPPORTED_SKILL_PAGES`, reasoning over `REASONING_MODES`, and the + * knowledge kinds over `KNOWLEDGE_KINDS`. A hand-written subset would pass a + * grep and still be wrong the day a value is added. + * + * **It stays Krow.** The palette is the tokens, and nothing else. + */ + +const canvasSource = readFileSync(join(ROOT, 'src/components/agents/AgentCanvas.jsx'), 'utf8'); +const agentUi = detailSource + configureSource + canvasSource; + +/* One surface with sections inside it, not four cards side by side. */ +record('the configure screen composes one workspace surface', + / m[1]); +const railLeaves = [...configureSource.matchAll(/\{\s*id:\s*'([a-z-]+)',\s*label:/g)].map((m) => m[1]); +const railIds = [...railSections, ...railLeaves]; +const missingTargets = railIds.filter((id) => !new RegExp(`id="${id}"`).test(configureSource)); +record('every rail entry addresses something the document renders', + railSections.length === 4 && railLeaves.length === 12 && missingTargets.length === 0, + missingTargets.length + ? `no target for ${missingTargets.join(', ')}` + : `${railSections.length} sections, ${railLeaves.length} leaves, all addressable`); + +/* Each closed vocabulary is rendered from its own table. */ +record('the icon picker offers every icon an agent may name', + /icons=\{AGENT_ICONS\}/.test(configureSource) && vocab.AGENT_ICONS.length === 10, + `${vocab.AGENT_ICONS.length} icons, from AGENT_ICONS`); + +record('the page chips offer every supported page', + /SUPPORTED_SKILL_PAGES\.map/.test(configureSource), + `${surfaces.SUPPORTED_SKILL_PAGES.length} pages, from SUPPORTED_SKILL_PAGES`); + +record('reasoning offers every mode', + /REASONING_MODES\.map/.test(configureSource), + vocab.REASONING_MODES.map((m) => m.label).join(' / ')); + +record('knowledge offers every kind', + /KNOWLEDGE_KINDS\.map/.test(configureSource), + vocab.KNOWLEDGE_KINDS.join(', ')); + +/* The controls that are not list-driven, one by one. */ +const CONTROLS = { + name: /value=\{fields\.name\}/, + description: /value=\{fields\.description\}/, + 'when to use': /value=\{fields\.trigger\}/, + instructions: /value=\{fields\.instructions\}/, + 'add skills': /setAddingSkills\(true\)/, + 'remove skill': /skills: fields\.skills\.filter/, + 'add knowledge': /knowledge: \[\s*\n?\s*\.\.\.fields\.knowledge/, + 'remove knowledge': /knowledge: fields\.knowledge\.filter/, + 'add starter': /starters: \[\.\.\.fields\.starters/, + 'remove starter': /starters: fields\.starters\.filter/, + 'web search': /onCheckedChange=\{\(webSearch\) => set\(\{ webSearch \}\)\}/, + 'add subagent': /subagents: \[\.\.\.fields\.subagents/, + 'remove subagent': /subagents: fields\.subagents\.filter/, +}; +const missingControls = Object.entries(CONTROLS) + .filter(([, re]) => !re.test(configureSource)).map(([k]) => k); +record('every field the editor had is still editable', + missingControls.length === 0, + missingControls.length ? `MISSING ${missingControls.join(', ')}` : `${Object.keys(CONTROLS).length} controls`); + +/* Lifecycle state stays legible: version, status, and the customized marker. */ +record('the header still states version, status and customization', + /v\{agent\.version\}/.test(detailSource) + && /\{agent\.status\}/.test(detailSource) + && /isOverridden\(id\) && Customized/.test(detailSource) + && /Publish update/.test(detailSource), + 'v · status · Customized · Publish update'); + +record('save state is shown and Save is offered only when there is something to save', + /dirty \? 'Unsaved changes' : 'Saved'/.test(detailSource) + && /onClick=\{persist\} disabled=\{!dirty\}/.test(detailSource)); + +/** + * Closed sections stay reachable by the rail, which means they stay mounted — + * and a mounted control nobody can see must not be in the tab order. + */ +record('collapsed content is inert rather than merely hidden', + /inert=\{open \? undefined : true\}/.test(canvasSource) + && !/aria-hidden=\{open/.test(canvasSource), + 'not tabbable while closed'); + +/** + * Motion is CSS transitions only, so the global `prefers-reduced-motion` rule + * in index.css governs it. A JavaScript animation would need its own guard and + * would eventually be written without one. + */ +record('the workspace animates in CSS, so reduced motion is inherited', + !/framer-motion/.test(configureSource + canvasSource) + && /transition-\[grid-template-rows\]/.test(canvasSource), + 'no JS animation on this screen'); + +/** + * The palette is the design system's. Not "no hex anywhere" — the check is + * that this screen introduced none of its own. + */ +const strayColour = [...agentUi.matchAll(/(?:bg|text|border|from|via|to|ring)-(?:\[#[0-9a-fA-F]{3,8}\]|purple|violet|fuchsia|pink|indigo|cyan|teal|lime|orange)-?\d*/g)] + .map((m) => m[0]); +record('the configure screen introduces no colour outside the Krow palette', + strayColour.length === 0, + strayColour.length ? `STRAY ${[...new Set(strayColour)].join(', ')}` : 'tokens only'); + +/* The redesign is scoped to this screen. Nothing else may import its parts. */ +const canvasImporters = [ + ...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]), + ...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]), +] + .filter(([, f]) => /\.jsx?$/.test(f)) + .filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f)) + .filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/ + .test(readFileSync(join(ROOT, dir, f), 'utf8'))) + .map(([dir, f]) => `${dir}/${f}`); +record('the workspace treatment is used by the configure screen alone', + canvasImporters.length === 0, + canvasImporters.join(', ') || 'no other page imports it'); + +/* ── 20b. Native agent pages vs agent-less pages ────────────────────────── + * + * The correction this section pins down, in one sentence: **a page with no + * agent of its own is not a page without Owliver.** + * + * Two runtime modes, and only the first existed as a deliberate design: + * + * 1. **Native.** The eight operational pages open on the agent written for + * them, with that page's skills and that page's records. Unchanged, and + * most of what follows exists to prove it stayed unchanged. + * 2. **Fallback.** Settings, the workspace surfaces, Agent Configure and + * Profile have no specialist and need none. They open on the general Krow + * Workforce Agent, and Owliver works there — chat, chips, history, + * everything — while still declining any question about records the page + * does not hold. + * + * The failure this replaces was reading "no native agent" as "constrained", and + * a constrained panel is a dead one: no starters, no answers, a decline to every + * question. That is the right behaviour for an agent the reader *chose* which + * does not cover the page, and the wrong behaviour for a page nobody wrote an + * agent for. Both are asserted below, because the whole correction is the + * distinction between them. + */ +console.log('\n── Native agent pages vs agent-less pages ──'); + +/** Pages with an agent of their own. `NATIVE` above is the same eight. */ +const NATIVE_CONTEXTS = Object.keys(NATIVE); + +/** + * Pages with none. + * + * Profile is on this list and always was — it had no specialist before any of + * this and resolved to the general agent, which is exactly the behaviour the + * six new surfaces now share. Listing it here is what proves the fallback is + * one rule rather than a special case for the pages added last. + */ +const AGENTLESS_CONTEXTS = [ + 'admin.settings', + 'admin.workspace', + 'admin.workspaceAgents', + 'admin.workspaceSkills', + 'admin.skillConfigure', + 'admin.skillDevelopment', + 'admin.agentConfigure', + 'admin.profile', +]; + +const GENERAL = 'krow-workforce-agent'; + +/* ── 1–2. Resolution, both modes ────────────────────────────────────────── */ + +for (const contextId of NATIVE_CONTEXTS) { + const native = runtime.nativeAgentForContext(ALL_AGENTS, contextId); + const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId); + record(`${contextId.replace('admin.', '')}: resolves its own agent`, + native?.id === NATIVE[contextId] && resolved?.id === NATIVE[contextId], + resolved?.id || 'none'); +} + +for (const contextId of AGENTLESS_CONTEXTS) { + const page = contextId.replace('admin.', ''); + record(`${page}: has no agent of its own, and resolves the general one`, + runtime.nativeAgentForContext(ALL_AGENTS, contextId) === null + && runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id === GENERAL, + runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id || 'NONE'); +} + +record('the general agent covers every agent-less page', + AGENTLESS_CONTEXTS.every((c) => runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, GENERAL), c)), + `${AGENTLESS_CONTEXTS.length} pages`); + +/* Nothing above changed which agent the eight operational pages open on. */ +record('the eight native pages still open on exactly the agents they did', + Object.entries(NATIVE).every(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id === id), + Object.entries(NATIVE).filter(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id !== id) + .map(([c]) => c).join(', ') || '8 pages unchanged'); + +/* ── 3–5. An agent-less page does not open constrained ──────────────────── */ + +/** + * The heart of it. + * + * With nothing chosen, the panel resolves an agent that covers the page — so + * `covers` is true, no constrained document is produced, and the reader gets an + * assistant rather than an apology. + */ +const openedConstrained = []; +for (const contextId of AGENTLESS_CONTEXTS) { + const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId); + const turn = runtime.resolveAgentForTurn(ALL_AGENTS, null, contextId); + const intent = routingModule.resolveIntent({ + question: 'What can I do here?', + contextId, + agent: resolved, + agentCoversPage: runtime.agentCovers(resolved, contextId), + agentSuggestion: resolved, + }); + if (!turn.covers || intent.kind === 'constrained') openedConstrained.push(contextId); +} +record('no agent-less page opens in the constrained state', + openedConstrained.length === 0, + openedConstrained.join(', ') || `${AGENTLESS_CONTEXTS.length} pages open with a working agent`); + +record('Settings opens on the general agent, not constrained', + runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')?.id === GENERAL + && runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.settings').covers === true); + +record('Agent Configure opens on the general agent, not constrained', + runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL + && runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.agentConfigure').covers === true); + +/* ── 6–7. The edited agent is metadata, never the runtime ───────────────── */ + +/** + * Configuring an agent does not become standing on the page it covers. + * + * Section 20 proves the *data* half — no skill is reachable there. This is the + * *identity* half: whichever agent is being edited, the agent answering on the + * configure page is the general one, because nothing about the record on the + * form is an input to agent resolution. + */ +for (const edited of ['analytics-agent', 'positions-agent', 'activity-agent']) { + record(`editing \`${edited}\` does not make it the runtime agent`, + runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL + && runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, edited), 'admin.agentConfigure') === false, + 'the edited agent is configuration, not the active runtime identity'); +} + +/* ── 8–13. Stale selection ──────────────────────────────────────────────── */ + +/** + * A selection made on one page must not decide another. + * + * `resolveSelection` is a pure function of (agents, selection, context), so the + * whole rule is provable here rather than through a React tree. Three outcomes, + * and every case below is one of them: applied because it covers, applied + * because this is where it was chosen, or retired. + */ +const LEAKS = [ + ['positions-agent', 'admin.positions', 'admin.settings'], + ['analytics-agent', 'admin.analytics', 'admin.settings'], + ['activity-agent', 'admin.activity', 'admin.settings'], + ['positions-agent', 'admin.positions', 'admin.agentConfigure'], + ['control-center-agent', 'admin.controlCenter', 'admin.workspaceSkills'], + ['talent-pool-agent', 'admin.talentPool', 'admin.skillDevelopment'], +]; +const leakedSelections = []; +for (const [id, chosenOn, arrivingAt] of LEAKS) { + const applied = runtime.resolveSelection(ALL_AGENTS, { id, contextId: chosenOn }, arrivingAt); + const answering = runtime.resolveAgentForTurn(ALL_AGENTS, applied.id, arrivingAt); + if (applied.id !== null || !applied.retire || answering.agent?.id !== GENERAL || !answering.covers) { + leakedSelections.push(`${id} (${chosenOn}) → ${arrivingAt}: ${answering.agent?.id}`); + } +} +record('a selection made on another page never follows onto an agent-less page', + leakedSelections.length === 0, + leakedSelections.join(' | ') || `${LEAKS.length} navigations, every one resolved to the general agent`); + +record('a retired selection is retired, not merely ignored', + runtime.resolveSelection(ALL_AGENTS, { id: 'positions-agent', contextId: 'admin.positions' }, 'admin.settings').retire === true, + 'so returning to that page later does not resurrect it'); + +/* Arriving on a page the selection *does* cover keeps it — the choice is only + dropped where it could not answer. */ +record('a selection that covers the page it arrives on is kept', + (() => { + const applied = runtime.resolveSelection( + ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.analytics' + ); + return applied.id === 'analytics-agent' && applied.covers === true && applied.retire === false; + })(), + 'Settings → Analytics keeps a deliberate choice'); + +record('Agent Configure → Analytics resolves Analytics normally', + (() => { + const carried = runtime.resolveSelection(ALL_AGENTS, { id: null, contextId: 'admin.agentConfigure' }, 'admin.analytics'); + return carried.id === null + && runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent'; + })()); + +record('Settings → Analytics resolves Analytics normally', + runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent'); + +/* An account-level default is a choice made nowhere, and is read by the same + rule: it applies where it covers, and never constrains a page it does not. */ +record('an account default that does not cover a page does not constrain it', + runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.settings').id === null + && runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.analytics').id === 'analytics-agent'); + +/* A stored id for an agent that no longer exists resolves to nothing rather + than leaving the panel pointed at a ghost. */ +record('a selection naming an agent that no longer exists is retired', + runtime.resolveSelection(ALL_AGENTS, { id: 'deleted-agent', contextId: 'admin.settings' }, 'admin.settings').retire === true); + +/* ── 18. An explicit incompatible choice still constrains ───────────────── */ + +/** + * The other half, and the reason "stale" had to be defined rather than just + * cleared: choosing a specialist *here*, on a page it does not cover, is a + * deliberate act. It is honoured, shown as constrained, and it declines — the + * honest answer, and the same one every constrained pair gives above. + */ +const explicit = runtime.resolveSelection( + ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.settings' +); +const explicitTurn = runtime.resolveAgentForTurn(ALL_AGENTS, explicit.id, 'admin.settings'); +record('choosing an incompatible agent on this page is honoured, and constrained', + explicit.id === 'analytics-agent' && explicit.retire === false + && explicitTurn.agent?.id === 'analytics-agent' && explicitTurn.covers === false, + 'the reader chose it here, so it is not swapped out from under them'); + +record('...and it declines rather than answering', + routingModule.resolveIntent({ + question: 'What can I configure here?', + contextId: 'admin.settings', + agent: explicitTurn.agent, + agentCoversPage: false, + agentSuggestion: runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings'), + }).kind === 'constrained'); + +record('...and the way out of it is named on a page with no specialist', + Boolean(runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')), + 'the general agent is what a constrained answer points at'); + +/* ── 14–17. Owliver actually works there ────────────────────────────────── */ + +/* Every one of these pages mounts the panel. A resolution rule is worth nothing + if there is no panel to resolve for. */ +record('every agent-less context is a real placement with a panel', + AGENTLESS_CONTEXTS.every((id) => Object.values(placement.PLACEMENT_ROUTES).includes(id) + || Object.values(placement.PLACEMENT_PATTERN_ROUTES).includes(id)), + AGENTLESS_CONTEXTS.filter((id) => !Object.values(placement.PLACEMENT_ROUTES).includes(id)).join(', ') || 'all mounted'); + +/* Each one names itself, so the header reads the page the reader is on. */ +record('every agent-less page states its own name, key and route', + AGENTLESS_CONTEXTS.every((id) => { + const context = contexts.ASSISTANT_CONTEXTS[id]; + const pageKey = reg.pageKeyForContext(id); + return Boolean(context?.page) && Boolean(pageKey) && Boolean(reg.routeForPageKey(pageKey)); + }), + AGENTLESS_CONTEXTS.map((id) => `${contexts.ASSISTANT_CONTEXTS[id].page}/${reg.pageKeyForContext(id)}`).join(', ')); + +/** + * The chips are real. + * + * Every suggestion offered on these pages is answered by the page it is offered + * on — a chip is a promise, and one that declines is worse than no chip. The + * count is asserted too: suggestions disappearing on an agent-less page was one + * of the reported symptoms. + */ +const emptyChips = []; +const brokenChips = []; +for (const contextId of AGENTLESS_CONTEXTS) { + const prompts = dynamic.buildPrompts(contextId, factSheet, null) || []; + if (!prompts.length) emptyChips.push(contextId); + for (const prompt of prompts) { + const intent = routingModule.resolveIntent({ question: prompt.prompt, contextId }); + if (intent.kind !== 'answer') brokenChips.push(`${contextId}: "${prompt.label}" → ${intent.kind}`); + } +} +record('every agent-less page offers suggestions', + emptyChips.length === 0, + emptyChips.join(', ') || AGENTLESS_CONTEXTS + .map((c) => `${c.replace('admin.', '')}:${(dynamic.buildPrompts(c, factSheet, null) || []).length}`).join(' ')); + +record('every suggestion an agent-less page offers is one it can answer', + brokenChips.length === 0, + brokenChips.slice(0, 3).join(' | ') || 'every chip resolves to an answer'); + +/* And the landing screen is furnished — a title, something to type into. */ +record('every agent-less page has a landing screen rather than an empty panel', + AGENTLESS_CONTEXTS.every((contextId) => { + const intro = dynamic.buildIntro(contextId, factSheet, 'Test'); + return Boolean(intro.title) && Boolean(intro.description) && intro.placeholders.length > 0; + })); + +/** + * No fake skills. + * + * The lazy way to make a configuration page answer is to invent a skill for it. + * That would show up in exactly two places, and both are checked: the page would + * carry skills, and its chips would resolve to one. Neither is true — the + * answers come from the page responder, which is the mechanism every context in + * the table already used. + */ +record('no agent-less page carries any skill', + AGENTLESS_CONTEXTS.every((c) => reg.skillsForContext(c, [], []).length === 0), + AGENTLESS_CONTEXTS.filter((c) => reg.skillsForContext(c, [], []).length).join(', ') || 'no skills, on any of them'); + +/* A chip may name one of the page's own capabilities — Profile's always have — + and that is a context responder, not a skill. What must not exist is a skill + behind any of them. */ +record('no chip on an agent-less page is backed by a skill', + AGENTLESS_CONTEXTS.every((contextId) => + (dynamic.buildPrompts(contextId, factSheet, null) || []).every((p) => + !reg.matchSkill(p.prompt, contextId, [], []))), + 'the fallback is architectural, not a skill layer'); + +record('no agent reaches a skill from an agent-less page', + AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((a) => + reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), []).length === 0)), + `${AGENTLESS_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents`); + +/** + * Honest about what it cannot answer. + * + * An operational question asked on a configuration screen must never be + * answered from that screen. It may route to the page that holds the records — + * which is the useful answer — or decline. What it may not do is produce a + * figure. + */ +const OPERATIONAL_QUESTIONS = [ + 'What are the open positions today?', + 'What is our attendance rate?', + 'Which candidates need review?', + 'How many people did we hire last month?', +]; +const fabricated = []; +for (const contextId of AGENTLESS_CONTEXTS) { + for (const question of OPERATIONAL_QUESTIONS) { + const intent = routingModule.resolveIntent({ question, contextId }); + if (intent.kind === 'answer' || intent.skill) fabricated.push(`${contextId}: "${question}" → ${intent.kind}`); + } +} +record('an operational question on an agent-less page is declined or routed, never answered', + fabricated.length === 0, + fabricated.slice(0, 3).join(' | ') + || `${AGENTLESS_CONTEXTS.length} pages × ${OPERATIONAL_QUESTIONS.length} questions`); + +/* The decline names what the page *can* do, so it is an offer rather than a + dead end. */ +record('the decline offers what the page can answer instead', + AGENTLESS_CONTEXTS.every((contextId) => { + const intent = routingModule.resolveIntent({ question: 'What is our attendance rate?', contextId }); + if (intent.kind !== 'outOfScope') return true; + return (intent.doc?.blocks || []).some((b) => b.type === 'list' && (b.items || []).length > 0); + })); + +/* ── The switcher still works there ─────────────────────────────────────── */ + +/** + * An agent-less page is not a page with no switcher. + * + * The list is the whole registry, ordered so the agents that work here come + * first — which on these pages is the general agent, because it is the one that + * covers them. + */ +record('the switcher lists every agent on an agent-less page', + AGENTLESS_CONTEXTS.every((contextId) => + agentReg.searchAgents(ALL_AGENTS, '').length === ALL_AGENTS.length), + `${ALL_AGENTS.length} agents, on every page`); + +record('the general agent sorts to the top of the list on an agent-less page', + AGENTLESS_CONTEXTS.every((contextId) => { + const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')] + .sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId))); + return ordered[0]?.id === GENERAL; + }), + 'the one that can answer here is offered first'); + +record('choosing an agent on an agent-less page still cannot move the page', + AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((candidate) => + runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId).agent?.id === candidate.id))); + +/* ── 21. Owliver behaviour baseline ─────────────────────────────────────── + * + * The Agent layer is additive, which is a claim rather than a guarantee: every + * page's skills, suggestions, prompts and intent routing must survive it + * unchanged. That is far more than fits in a reviewer's head, and all of it + * fails silently — a page quietly answering with one skill fewer looks exactly + * like a page that never had it. + * + * So today's behaviour was recorded before any of it was built + * (`scripts/__baseline__/owliver-baseline.json`) and is compared here, per + * context and per question. `captureBaseline` is the same function that wrote + * the file, so there is one definition of what is measured. + * + * A failure here is a regression in the existing product, not a stale + * expectation. The file is regenerated deliberately, never to turn a check + * green. + */ +console.log('\n── Owliver behaviour baseline ──'); + +if (!existsSync(BASELINE_PATH)) { + record('baseline snapshot exists', false, 'run `node scripts/owliver-baseline.mjs --write`'); +} else { + const expected = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); + const actual = await captureBaseline(server); + + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + + record('baseline schema matches', expected.schema === actual.schema, + `expected ${expected.schema}, got ${actual.schema}`); + + /** + * Additive is allowed; losing something is not. + * + * The snapshot records the product as it was before the agent layer existed, + * and later phases legitimately add skills — that is what they are for. So a + * strict equality here would fail on every intended change and force the + * snapshot to be regenerated, which is exactly how a baseline stops catching + * anything. + * + * The contract is therefore a *subset*: every skill that existed then must + * still exist now. A skill disappearing is a regression and still fails. + * What must not move at all — routes, page keys, suggested prompts and intent + * routing — is asserted strictly below, and those are the checks that catch a + * new definition quietly taking over an existing question. + */ + const lostSkills = expected.skillIds.filter((id) => !actual.skillIds.includes(id)); + const gainedSkills = actual.skillIds.filter((id) => !expected.skillIds.includes(id)); + record('every skill that existed before the agent layer still registers', + lostSkills.length === 0, + lostSkills.length + ? `LOST ${JSON.stringify(lostSkills)}` + : `${expected.skillIds.length} kept, ${gainedSkills.length} added since`); + + record('registry still reports no diagnostics', actual.diagnostics.length === 0, + actual.diagnostics.map((d) => d.message).join(' | ') || 'none'); + + /** + * Route → context: every address that resolved before must resolve the same + * way now. + * + * Additive, for the same reason page skills are. A later phase can mount + * Owliver somewhere new — the agent configuration screen is the first — and a + * strict equality would fail on that intended addition and force the snapshot + * to be regenerated, which is how a baseline stops catching anything. + * + * What must never happen is an *existing* route resolving somewhere else, or + * ceasing to resolve at all: that is one of the eight operational pages + * quietly changing which assistant it carries. Both are failures here. + */ + const movedRoutes = Object.entries(expected.routes) + .filter(([route, contextId]) => actual.routes[route] !== contextId) + .map(([route, contextId]) => `${route}: ${contextId} → ${actual.routes[route] ?? 'nothing'}`); + const addedRoutes = Object.keys(actual.routes).filter((route) => !(route in expected.routes)); + + record('every route that resolved before resolves the same way', + movedRoutes.length === 0, + movedRoutes.join(' | ') + || `${Object.keys(expected.routes).length} unchanged${addedRoutes.length ? `, added ${JSON.stringify(addedRoutes)}` : ''}`); + + /* Per context, so a failure names the page it broke rather than reporting + that "something" moved. */ + for (const contextId of Object.keys(expected.contexts)) { + const want = expected.contexts[contextId]; + const got = actual.contexts[contextId]; + const page = contextId.replace(/^admin\./, ''); + + if (!got) { + record(`${page}: context still exists`, false, 'context missing from the registry'); + continue; + } + + record(`${page}: page key and route unchanged`, + want.pageKey === got.pageKey && want.route === got.route, + `${got.pageKey} @ ${got.route}`); + + /* The page boundary itself. A page may gain skills as later phases add + them; it may never lose one, because that is a capability the page had + and silently stopped offering. */ + const lost = want.skills.filter((id) => !got.skills.includes(id)); + const gained = got.skills.filter((id) => !want.skills.includes(id)); + record(`${page}: keeps every skill it had`, lost.length === 0, + lost.length ? `LOST ${JSON.stringify(lost)}` + : gained.length ? `${want.skills.length} kept, gained ${JSON.stringify(gained)}` + : `${got.skills.length} skill(s), unchanged`); + + const lostSuggestions = want.suggestions.filter((label) => !got.suggestions.includes(label)); + record(`${page}: keeps every suggestion it offered`, lostSuggestions.length === 0, + lostSuggestions.length ? `LOST ${JSON.stringify(lostSuggestions)}` + : `${want.suggestions.length} kept, ${got.suggestions.length - want.suggestions.length} added`); + + record(`${page}: suggested prompts unchanged`, same(want.prompts, got.prompts), + same(want.prompts, got.prompts) + ? `${got.prompts.length} prompt(s)` + : `expected ${JSON.stringify(want.prompts)}, got ${JSON.stringify(got.prompts)}`); + + /* Intent routing, question by question — skill matching, the branch taken, + and the shape of the answer. */ + const drifted = want.intents.filter((w, i) => !same(w, got.intents[i])); + record(`${page}: intent routing unchanged`, drifted.length === 0, + drifted.length === 0 + ? `${want.intents.length} question(s)` + : drifted.map((d) => `"${d.question}"`).join(', ')); + } +} + await server.close(); /* ── 8. Production bundle ─────────────────────────────────────────────────── */ diff --git a/src/App.jsx b/src/App.jsx index f47818f..6378df3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,6 +43,8 @@ import AdminProfile from '@/pages/admin/Profile'; import AdminSettings from '@/pages/admin/Settings'; import AdminWorkspace from '@/pages/admin/Workspace'; import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills'; +import AdminWorkspaceAgents from '@/pages/admin/WorkspaceAgents'; +import AdminAgentDetail from '@/pages/admin/AgentDetail'; import AdminSkillEditor from '@/pages/admin/SkillEditor'; import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor'; import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment'; @@ -110,6 +112,11 @@ const AuthenticatedApp = () => { both subjects. */} } /> } /> + } /> + {/* Static before dynamic, so `agents/new` cannot be read as an + agent whose id is "new". */} + } /> + } /> } /> {/* Two editors, because a UI skill and an Owliver skill configure different things. Static segments rank above the dynamic `:id`, diff --git a/src/agents/activity-agent.md b/src/agents/activity-agent.md new file mode 100644 index 0000000..a8e3dad --- /dev/null +++ b/src/agents/activity-agent.md @@ -0,0 +1,42 @@ +--- +id: activity-agent +name: Activity Agent +description: The audit trail — what happened in this workspace, who did it, and what looks unusual. +icon: activity +status: published +version: 1 +reasoning: balanced +trigger: Use on Activity, for the event log, who did what, and anything that looks out of pattern. +pages: + - activity +skills: + - activity-analysis + - anomaly-detection + - operational-risk +starters: + - label: What happened recently? + prompt: What has happened in the workspace recently? + - label: Anything unusual? + prompt: Is there any unusual activity? +permissions: + owner: demo@krow.app + access: all +--- + +# Activity Agent + +## Instructions + +Answer about what has happened in this workspace: which events, by which +account, and when. + +Report something as unusual only when it genuinely departs from the pattern in +the log. Flagging ordinary activity trains the reader to ignore the flag. + +This agent carries no skills of its own; Activity answers from its own page +reader. + +## Purpose + +- Report recent workspace events and who performed them. +- Surface activity that departs from the usual pattern. diff --git a/src/agents/analytics-agent.md b/src/agents/analytics-agent.md new file mode 100644 index 0000000..8797eea --- /dev/null +++ b/src/agents/analytics-agent.md @@ -0,0 +1,42 @@ +--- +id: analytics-agent +name: Analytics Agent +description: Hiring performance over time — trends, conversion, and how departments compare. +icon: bar-chart +status: published +version: 1 +reasoning: balanced +trigger: Use on Analytics, for trends over time, conversion rates and department comparisons. +pages: + - analytics +skills: + - analytics-insights + - workforce-analytics + - attendance-analysis + - overtime-analysis + - hiring-pulse-analysis +starters: + - label: What is the hiring trend? + prompt: What is the hiring trend? + - label: Where does the funnel lose people? + prompt: Where does the funnel lose candidates? +permissions: + owner: demo@krow.app + access: all +--- + +# Analytics Agent + +## Instructions + +Answer about performance over time: how hiring is trending, where the funnel +converts and where it leaks, and how departments compare. + +Explain the figures the Analytics page is already showing rather than producing +different ones. When a movement is small enough to be noise, say so rather than +narrating it as a trend. + +## Purpose + +- Explain hiring trend and conversion. +- Compare department performance, and identify where the funnel loses people. diff --git a/src/agents/candidates-agent.md b/src/agents/candidates-agent.md new file mode 100644 index 0000000..51f0b60 --- /dev/null +++ b/src/agents/candidates-agent.md @@ -0,0 +1,41 @@ +--- +id: candidates-agent +name: Candidates Agent +description: The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off. +icon: users +status: published +version: 1 +reasoning: balanced +trigger: Use on Candidates, for screening, shortlisting and pipeline questions about applicants. +pages: + - candidates + - candidates-analysis +skills: + - candidate-search + - candidate-analysis +starters: + - label: Who needs a decision? + prompt: Which candidates are waiting on a decision? + - label: Who is strongest? + prompt: Who are the strongest candidates right now? +permissions: + owner: demo@krow.app + access: all +--- + +# Candidates Agent + +## Instructions + +Answer about the people who have applied: who is waiting, who scores well, who +has not been screened, and where the pipeline is losing candidates. + +Quote a score only where one has been computed. An unscored candidate is +unscored — say so rather than implying a low score. + +Never advance, decline or hire a candidate without being asked to. + +## Purpose + +- Report who is waiting on a decision, and who is strongest. +- Find candidates matching what a role asks for. diff --git a/src/agents/control-center-agent.md b/src/agents/control-center-agent.md new file mode 100644 index 0000000..c67ab19 --- /dev/null +++ b/src/agents/control-center-agent.md @@ -0,0 +1,47 @@ +--- +id: control-center-agent +name: Control Center Agent +description: The operational picture — what needs attention across the workspace today. +icon: layers +status: published +version: 1 +reasoning: balanced +trigger: Use on the Control Center, for workspace health, urgency and what to do next. +pages: + - control-center +skills: + - executive-summary + - staffing-risk + - operational-risk + - anomaly-detection + - attendance-analysis + - overtime-analysis + - hiring-pulse-analysis +starters: + - label: What needs my attention? + prompt: What needs my attention right now? + - label: How is the pipeline? + prompt: How healthy is my hiring pipeline? +permissions: + owner: demo@krow.app + access: all +--- + +# Control Center Agent + +## Instructions + +Answer about the state of the workspace as a whole: what is urgent, where the +funnel is losing people, and what the reader should do next. + +Read the figures the Control Center already shows rather than recomputing them, +so the answer and the dashboard beside it can never disagree. + +This agent carries no skills of its own. That is deliberate — the Control +Center answers from its own page reader, and inventing skills to fill the list +would promise capabilities that do not exist. + +## Purpose + +- Say what needs attention across the workspace. +- Explain where the hiring funnel is losing candidates. diff --git a/src/agents/hired-history-agent.md b/src/agents/hired-history-agent.md new file mode 100644 index 0000000..d43f204 --- /dev/null +++ b/src/agents/hired-history-agent.md @@ -0,0 +1,41 @@ +--- +id: hired-history-agent +name: Hired History Agent +description: Completed hires — who was hired, for which role, how quickly, and how well. +icon: user-check +status: published +version: 1 +reasoning: balanced +trigger: Use on Hired History, for hiring outcomes, time-to-hire and quality by department. +pages: + - hired-history +skills: + - hiring-history-analysis +starters: + - label: Who did we hire recently? + prompt: Who did we hire recently? + - label: How is hire quality? + prompt: How is hire quality by department? +permissions: + owner: demo@krow.app + access: all +--- + +# Hired History Agent + +## Instructions + +Answer about hires that have already happened: who, for which role, how long it +took and how they scored. + +This is the record after the decision, not the pipeline before it. A question +about people still being considered belongs to Candidates. + +This agent carries no skills of its own. Hired History answers from its own +page reader, and a placeholder skill would promise a capability that does not +exist. + +## Purpose + +- Report recent hires, and how quickly they were made. +- Compare hiring outcomes across departments. diff --git a/src/agents/krow-forge-agent.md b/src/agents/krow-forge-agent.md new file mode 100644 index 0000000..f3dcfa7 --- /dev/null +++ b/src/agents/krow-forge-agent.md @@ -0,0 +1,41 @@ +--- +id: krow-forge-agent +name: KROW Forge Agent +description: The training library — what exists, what is published, and how the workforce is progressing. +icon: graduation-cap +status: published +version: 1 +reasoning: balanced +trigger: Use on KROW Forge, for training paths, challenges, verification and skill progression. +pages: + - krow-forge +skills: + - forge-skill-management + - learning-analysis +starters: + - label: What is in the library? + prompt: What training does the library hold? + - label: Where are the gaps? + prompt: Where are the gaps in workforce training? +permissions: + owner: demo@krow.app + access: all +--- + +# KROW Forge Agent + +## Instructions + +Answer about the training library and what the workforce has proved: which +paths exist, which are published, what a challenge checks, and where coverage +is thin. + +A skill in Forge is something a person learns and is verified in. It is not an +Owliver capability — never describe the two as the same thing. + +Never publish or archive training without being asked to. + +## Purpose + +- Report what the training library holds and what is live. +- Identify gaps between what roles need and what is taught. diff --git a/src/agents/krow-workforce-agent.md b/src/agents/krow-workforce-agent.md new file mode 100644 index 0000000..13e909e --- /dev/null +++ b/src/agents/krow-workforce-agent.md @@ -0,0 +1,100 @@ +--- +id: krow-workforce-agent +name: Krow Workforce Agent +description: The general workforce agent. Reasons across every Krow domain, within whatever page you are on. +icon: owliver +status: published +version: 1 +reasoning: balanced +trigger: Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help. +pages: + - control-center + - positions + - create-position + - candidates + - candidates-analysis + - hired-history + - talent-pool + - krow-forge + - analytics + - activity + - profile + # The agent workspace. Carries no operational skill, so standing here the + # root agent answers about agents and skills and nothing else — which is the + # point: configuring the Analytics Agent must not put the reader on Analytics. + - workspace-agent-configure + # Settings and the rest of the workspace. Nobody wrote a specialist for a + # configuration screen and nobody should: these pages hold no workforce + # records, so what they need is a general agent, not a Settings Agent with + # invented skills. Listing them here is the whole of the fallback — a page + # named by this agent has an agent, and Owliver is alive on it. + - settings + - workspace + - workspace-agents + - workspace-skills + - workspace-skill-configure + - skill-development +skills: + - create-position + - hiring-activity-assistant + - candidate-search + - analytics-insights + - forge-skill-management + - staffing-risk + - attendance-analysis + - overtime-analysis + - candidate-analysis + - talent-pool-analysis + - workforce-analytics + - anomaly-detection + - activity-analysis + - operational-risk + - executive-summary + - hiring-history-analysis + - learning-analysis + - hiring-pulse-analysis +subagents: + - control-center-agent + - positions-agent + - candidates-agent + - hired-history-agent + - talent-pool-agent + - krow-forge-agent + - analytics-agent + - activity-agent +knowledge: + - id: page-boundary + label: What this agent can see + kind: note + body: Owliver answers from the page you are on. Covering every page does not mean reading every page at once — the page you are standing on decides which records are in reach. +starters: + - label: What needs my attention? + prompt: What needs my attention right now? + - label: Summarize this page + prompt: Summarize what this page is showing +permissions: + owner: demo@krow.app + access: all + people: + - user: demo@krow.app + role: manager +--- + +# Krow Workforce Agent + +## Instructions + +Answer from the records this workspace holds, for the page the reader is on. + +State a figure only where a skill has read it. When a reading needs a position +or a candidate and none is open, ask which one rather than choosing one. + +Covering every page is not permission to read every page at once. The page in +front of the reader decides what is in reach; a question that belongs somewhere +else should be answered by naming where it belongs, not by reaching for it. + +## Purpose + +- Answer questions that span more than one Krow domain. +- Stand in on pages whose own agent carries no skills. +- Hand a question that clearly belongs to another page back to that page. diff --git a/src/agents/positions-agent.md b/src/agents/positions-agent.md new file mode 100644 index 0000000..9fbc695 --- /dev/null +++ b/src/agents/positions-agent.md @@ -0,0 +1,42 @@ +--- +id: positions-agent +name: Positions Agent +description: Open roles — what they need, who has applied, and which are at risk of going unfilled. +icon: briefcase +status: published +version: 1 +reasoning: balanced +trigger: Use on Positions, for open roles, applicant flow, and specifying a new role. +pages: + - positions + - create-position +skills: + - create-position + - hiring-activity-assistant + - staffing-risk +starters: + - label: Which positions need attention? + prompt: Which positions need attention? + - label: Show hiring activity + prompt: Show hiring activity as a flow +permissions: + owner: demo@krow.app + access: all +--- + +# Positions Agent + +## Instructions + +Answer about the roles this workspace has open: how they are filling, which are +starved of applicants, and what a role still needs before it can be published. + +When a question names a role, answer about that role. When it does not and one +is open on the page, answer about that one. When neither is true, ask which. + +Never create or publish a position without being asked to. + +## Purpose + +- Report how open roles are filling, and which are at risk. +- Help specify a new role and its screening weights. diff --git a/src/agents/talent-pool-agent.md b/src/agents/talent-pool-agent.md new file mode 100644 index 0000000..16ecd86 --- /dev/null +++ b/src/agents/talent-pool-agent.md @@ -0,0 +1,40 @@ +--- +id: talent-pool-agent +name: Talent Pool Agent +description: Available talent — who is in the pool, who is verified, and who is ready to place. +icon: layers +status: published +version: 1 +reasoning: balanced +trigger: Use on Talent Pool, for supply, availability and readiness of known workers. +pages: + - talent-pool +skills: + - talent-pool-analysis +starters: + - label: Who is available? + prompt: Who is available in the talent pool? + - label: How verified is the pool? + prompt: How much of the talent pool is verified? +permissions: + owner: demo@krow.app + access: all +--- + +# Talent Pool Agent + +## Instructions + +Answer about the people this workspace already knows: who is in the pool, what +they are verified in, and who could be placed now. + +This is supply, not applicants. Someone in the pool has not applied to anything +by being here — do not describe them as a candidate for a role. + +This agent carries no skills of its own; Talent Pool answers from its own page +reader. + +## Purpose + +- Report who is available, and how ready they are. +- Describe the pool's segments and verification coverage. diff --git a/src/api/attendanceSeed.js b/src/api/attendanceSeed.js new file mode 100644 index 0000000..9b9abda --- /dev/null +++ b/src/api/attendanceSeed.js @@ -0,0 +1,233 @@ +/** + * Shift records — the workforce actually turning up, or not. + * + * This is the one collection in the demo whose dates are **anchored to now** + * rather than written as calendar dates. Everything else in `seed.js` is a + * fixed narrative: 22 people applied on particular days and three were hired, + * and those dates are the story. Attendance is not a story, it is a rolling + * operational record — "how was attendance last week" has to mean *last week*, + * every week, or the feature reads as permanently empty and looks broken. + * + * The snapshot persists on first load (see `store.js`), so the figures are + * stable for a browser once written; only a fresh workspace re-anchors them. + * Every foreign key points at a fixed seeded record, so correlation with + * positions and hires stays exact however the dates land. + * + * Nothing here is random. The distribution is written out below and generated + * deterministically, so the same workspace always produces the same figures and + * a test can assert against them. What it is *shaped* to contain: + * + * - **Marco** — the control. Reliable, occasional event-night overtime. + * - **Marcus** — attendance degrading in the last fortnight: two absences, a + * no-show and repeated lateness, against a clean record before that. This + * is the attendance anomaly, and it is recent enough to be actionable. + * - **Antoine** — present throughout, but overtime climbing steadily week on + * week. This is the overtime anomaly, and it is a trend rather than a + * spike, which is the kind a person reading a table would miss. + * + * The roster is three people because three people have been hired — `Staff` is + * the workforce, and inventing a fourth to make the charts look busier would be + * inventing an employee. + */ + +/** Eight weeks: long enough for a week-on-week trend and a month comparison. */ +const WINDOW_DAYS = 56; + +/** + * A local instant `n` days back, at a given hour. + * + * Local rather than UTC because a shift belongs to the day it was worked in the + * place it was worked, and `periodRange` windows on local day boundaries too. + */ +function daysAgo(n, hour = 9, minute = 0) { + const d = new Date(); + d.setDate(d.getDate() - n); + d.setHours(hour, minute, 0, 0); + return d; +} + +const round1 = (n) => Math.round(n * 10) / 10; +const round2 = (n) => Math.round(n * 100) / 100; +const HOUR = 60 * 60 * 1000; + +/** + * The roster, joined to the records they were hired against. + * + * `job_posting_id` and `role_category` are the posting's own, so "department" + * means here exactly what it means on Hired History and Analytics — see the + * join in `lib/hiringRecords.js`. + */ +const ROSTER = [ + { + staff_id: 'staff_marco', + worker_name: 'Marco Rivera', + worker_email: 'marco.rivera@email.com', + job_posting_id: 'job_bartender_corp', + role: 'Experienced Bartender – Corporate Events', + role_category: 'Bartender', + /* Wed–Sat: corporate events run late in the week. */ + weekdays: [3, 4, 5, 6], + startHour: 16, + scheduledHours: 8, + }, + { + staff_id: 'staff_marcus', + worker_name: 'Marcus Williams', + worker_email: 'marcus.w@email.com', + job_posting_id: 'job_security', + role: 'Event Security Officer', + role_category: 'Security', + /* Mon–Fri: a fixed security rota, which is what makes the recent + absences stand out rather than read as an irregular schedule. */ + weekdays: [1, 2, 3, 4, 5], + startHour: 14, + scheduledHours: 8, + }, + { + staff_id: 'staff_antoine', + worker_name: 'Chef Antoine Dubois', + worker_email: 'antoine.dubois@email.com', + job_posting_id: 'job_chef', + role: 'Executive Chef – Catering', + role_category: 'Chef', + /* Tue–Sat: kitchen service. */ + weekdays: [2, 3, 4, 5, 6], + startHour: 12, + scheduledHours: 9, + }, +]; + +/** + * How each worker's series behaves, by position in it. + * + * `i` counts back from the most recent shift, so "the last fortnight" is a + * range of small indices and stays that way as the window rolls forward. + * Returning a plain record keeps every rule visible in one place instead of + * spread across the generator. + */ +const BEHAVIOUR = { + /* Reliable. One late arrival every couple of months, and overtime only on + the nights events actually overrun. */ + staff_marco: (i, weekday) => ({ + status: i === 14 ? 'late' : 'present', + minutesLate: i === 14 ? 9 : 0, + /* Friday and Saturday events overrun; midweek ones do not. */ + overtime: weekday === 5 || weekday === 6 ? 1 : 0, + notes: '', + }), + + /** + * Clean for six weeks, then coming apart. + * + * Indices 0–9 are roughly the last fortnight. Two absences, one no-show and + * three late arrivals inside that window, against a single late arrival in + * the six weeks before it — a change big enough to be worth surfacing and + * specific enough to act on. + */ + staff_marcus: (i) => { + if (i === 2 || i === 7) { + return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' }; + } + if (i === 4) { + return { status: 'no_show', minutesLate: 0, overtime: 0, notes: 'No contact' }; + } + if (i === 1) return { status: 'late', minutesLate: 24, overtime: 0, notes: '' }; + if (i === 5) return { status: 'late', minutesLate: 16, overtime: 0, notes: '' }; + if (i === 9) return { status: 'late', minutesLate: 12, overtime: 0, notes: '' }; + if (i === 26) return { status: 'late', minutesLate: 7, overtime: 0, notes: '' }; + return { status: 'present', minutesLate: 0, overtime: 0, notes: '' }; + }, + + /** + * Always there, increasingly late leaving. + * + * Overtime rises about half an hour a week as the kitchen carries more + * covers, on the three busiest shifts of each week. A steady climb rather + * than a spike, which is exactly the shape that hides in a table of totals. + */ + staff_antoine: (i, weekday) => { + const weekIndex = Math.floor(i / 5); + const busy = weekday === 4 || weekday === 5 || weekday === 6; + const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0; + return { status: 'present', minutesLate: 0, overtime, notes: '' }; + }, +}; + +/** Every shift date for one worker, most recent first. */ +function shiftOffsets(weekdays) { + const offsets = []; + for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) { + const day = daysAgo(offset).getDay(); + if (weekdays.includes(day)) offsets.push(offset); + } + return offsets; +} + +const pad = (n) => String(n).padStart(2, '0'); +const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + +function buildShifts() { + const records = []; + + for (const worker of ROSTER) { + const offsets = shiftOffsets(worker.weekdays); + + offsets.forEach((offset, i) => { + const scheduledStart = daysAgo(offset, worker.startHour); + const weekday = scheduledStart.getDay(); + const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR); + + const { status, minutesLate, overtime, notes } = BEHAVIOUR[worker.staff_id](i, weekday); + const worked = status !== 'absent' && status !== 'no_show'; + + const actualStart = worked + ? new Date(scheduledStart.getTime() + minutesLate * 60 * 1000) + : null; + const actualEnd = worked + ? new Date(scheduledEnd.getTime() + overtime * HOUR) + : null; + + records.push({ + id: `shift_${worker.staff_id.replace('staff_', '')}_${pad(offsets.length - i)}`, + staff_id: worker.staff_id, + worker_name: worker.worker_name, + worker_email: worker.worker_email, + job_posting_id: worker.job_posting_id, + role: worker.role, + role_category: worker.role_category, + + shift_date: localDate(scheduledStart), + scheduled_start: scheduledStart.toISOString(), + scheduled_end: scheduledEnd.toISOString(), + scheduled_hours: worker.scheduledHours, + + actual_start: actualStart ? actualStart.toISOString() : null, + actual_end: actualEnd ? actualEnd.toISOString() : null, + /* Arriving late shortens the shift; staying on lengthens it. A missed + shift is zero hours worked, not a short one. */ + actual_hours: worked + ? round2(worker.scheduledHours - minutesLate / 60 + overtime) + : 0, + overtime_hours: worked ? round1(overtime) : 0, + minutes_late: worked ? minutesLate : 0, + status, + notes, + + /* Load-bearing: `inPeriod` in `lib/skills/dataResolver.js` windows every + collection on `created_date`, so a shift's created date *is* the + instant it was worked. Without that, every period reading of this + collection would be empty and nothing would say why. */ + created_date: scheduledStart.toISOString(), + updated_date: (actualEnd || scheduledEnd).toISOString(), + }); + }); + } + + /* Most recent first, matching the `-created_date` order every other + collection is listed in. */ + return records.sort( + (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() + ); +} + +export const SHIFT_RECORDS = buildShifts(); diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 6d38611..89b3e05 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -22,6 +22,9 @@ const ENTITY_NAMES = [ into workforce allocation: without it a position knows its demand and its applicants but not who is actually covering it. */ 'Assignment', + /* Shifts worked, missed and overrun. The operational record behind + attendance and overtime analysis — see `api/attendanceSeed.js`. */ + 'ShiftRecord', ]; const entities = Object.fromEntries( diff --git a/src/api/seed.js b/src/api/seed.js index d682ca5..bb155ce 100644 --- a/src/api/seed.js +++ b/src/api/seed.js @@ -8,6 +8,8 @@ * decision rate. */ +import { SHIFT_RECORDS } from './attendanceSeed'; + const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString(); /* ── Reference data ────────────────────────────────────────────────────── */ @@ -1904,6 +1906,9 @@ export const DEMO_USER = { not exist. */ const ASSIGNMENTS = []; +/* Attendance lives in its own module: it is generated rather than written out, + and its dates are anchored to now rather than to this file's fixed calendar. + See the note at the top of `attendanceSeed.js`. */ export const seedData = { JobPosting: JOB_POSTINGS, JobApplication: JOB_APPLICATIONS, @@ -1917,6 +1922,7 @@ export const seedData = { RoleCategory: ROLE_CATEGORIES, UserActivity: USER_ACTIVITY, Assignment: ASSIGNMENTS, + ShiftRecord: SHIFT_RECORDS, Evidence: EVIDENCE, User: [DEMO_USER], }; diff --git a/src/components/agents/AddSkillsModal.jsx b/src/components/agents/AddSkillsModal.jsx new file mode 100644 index 0000000..6bf4890 --- /dev/null +++ b/src/components/agents/AddSkillsModal.jsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button, Modal, SearchInput } from '@/components/ds'; +import { allSkills, skillsWithFacet } from '@/lib/skills/registry'; +import { surfaceFor } from '@/lib/skills/surfaces'; + +/** + * Attaching skills to an agent. + * + * Reads **the one skill registry** — the same `allSkills` the Skills page and + * Owliver itself read. There is deliberately no separate list for agents: a + * second one would drift, and an agent would end up offering a skill the + * runtime does not have. + * + * Categories are derived rather than written down. A skill's own `category` + * field when it declares one, and the pages it attaches to otherwise, so a + * filter can never offer a grouping that matches nothing — and a skill added + * tomorrow appears under its own category without this file being edited. + */ + +const ALL = 'all'; + +/** The groupings the registry actually contains, in a stable order. */ +function categoriesFor(skills) { + const named = new Set(); + for (const skill of skills) { + if (skill.category) named.add(skill.category); + } + return [ALL, ...[...named].sort()]; +} + +/** What this skill contributes, in the terms the reader is choosing between. */ +function skillSummary(skill) { + const pages = skill.pages.map((p) => surfaceFor(p)?.label || p); + const capabilities = skill.owliver?.capabilities || []; + return [ + pages.length ? pages.join(', ') : 'No pages', + capabilities.length + ? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}` + : (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null, + ].filter(Boolean).join(' · '); +} + +/** @param {any} props */ +export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) { + const [query, setQuery] = React.useState(''); + const [category, setCategory] = React.useState(ALL); + const [picked, setPicked] = React.useState([]); + + /* A fresh sheet each time, so a previous selection is not still ticked. */ + React.useEffect(() => { + if (open) { setQuery(''); setCategory(ALL); setPicked([]); } + }, [open]); + + /* Owliver skills only. A workforce training path is something a person + learns, not something an agent can be asked to do, and offering one here + would promise behaviour that does not exist. */ + const available = React.useMemo( + () => skillsWithFacet(allSkills(customSkills), 'owliver') + .filter((s) => s.status === 'active' && !attached.includes(s.id)), + [customSkills, attached] + ); + + const categories = React.useMemo(() => categoriesFor(available), [available]); + + const visible = React.useMemo(() => { + const q = query.trim().toLowerCase(); + return available + .filter((s) => category === ALL || s.category === category) + .filter((s) => !q + || s.name.toLowerCase().includes(q) + || s.description.toLowerCase().includes(q) + || s.id.includes(q)); + }, [available, category, query]); + + const toggle = (id) => + setPicked((current) => (current.includes(id) + ? current.filter((x) => x !== id) + : [...current, id])); + + return ( + +

+ {picked.length + ? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected` + : 'Nothing selected yet'} +

+ + + + } + > +
+ + + {categories.length > 1 && ( +
+ {categories.map((id) => ( + + ))} +
+ )} + +
+ {visible.map((skill) => { + const chosen = picked.includes(skill.id); + return ( + + ); + })} + + {!visible.length && ( +

+ {available.length + ? 'No skills match that.' + : 'Every available skill is already attached to this agent.'} +

+ )} +
+
+
+ ); +} + +export default AddSkillsModal; diff --git a/src/components/agents/AgentCanvas.jsx b/src/components/agents/AgentCanvas.jsx new file mode 100644 index 0000000..f9f3686 --- /dev/null +++ b/src/components/agents/AgentCanvas.jsx @@ -0,0 +1,516 @@ +import * as React from 'react'; +import { ChevronRight, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { agentIconFor } from './icons'; +import OwliverAvatar from '@/components/krow/OwliverAvatar'; + +/** + * The Agent Configure workspace, as parts. + * + * The screen this builds is an *authoring* surface, and it is the only one in + * the Admin console that is. The eight operational pages report; this one is + * written in. That is the whole reason it is allowed to look different from + * them, and the difference is composition rather than colour: every token here + * — `border-border`, `surface-subtle`, `krow-blue-tint`, the ink scale, the + * duration and easing variables — is the same Control Tower vocabulary the rest + * of the console uses. + * + * Three ideas carry the layout: + * + * 1. **One document, not a stack of cards.** Four equal cards make four + * unrelated settings groups. One surface divided by hairlines makes a thing + * being edited, which is what an agent definition is. + * + * 2. **A spine on the left.** The rail lists what an agent is made of — + * Identity, Instructions, Capabilities, Behavior, and what is inside each — + * with its current value beside it. It is the tree reading of the + * configuration, and it is what lets someone understand the page before + * reading a single field. It is navigation, never a second copy of state. + * + * 3. **Values on the surface.** A row states what it currently holds while + * closed. Nothing has to be opened to be read. + * + * Motion is CSS only. `prefers-reduced-motion` is handled once, globally, in + * `index.css`, so a component that animates with transitions inherits that and + * a component that animates in JavaScript would not. + */ + +/* ── The surface ─────────────────────────────────────────────────────────── */ + +/** + * The workspace: rail beside document, both inside one border. + * + * The rail only becomes a column when there is genuinely room for one. Owliver + * holds a ~380px track on the right of every Admin page, so below `xl` the + * remaining width belongs to the editor and the rail becomes a strip above it. + */ +export function Workspace({ rail, children, className }) { + return ( +
+
+ {rail} +
+
{children}
+
+ ); +} + +/* ── Motion ──────────────────────────────────────────────────────────────── */ + +/** + * Open and close, in height. + * + * `grid-template-rows: 0fr → 1fr` is the one way to transition to an unknown + * height without measuring it in JavaScript. Content stays mounted, which is + * what keeps a section addressable by the rail while it is closed. + */ +/** @param {any} props */ +export function Collapse({ open, children, className }) { + return ( +
+ {/* `inert` rather than `aria-hidden`: the content stays mounted so a + closed section is still addressable, and a mounted control that cannot + be seen must not be reachable by Tab either. `aria-hidden` alone would + hide it from a screen reader while leaving it in the tab order, which + is the worse of both. */} +
+ {children} +
+
+ ); +} + +/* ── The rail ────────────────────────────────────────────────────────────── */ + +/** + * The spine. + * + * Provides clear section navigation and leaf anchor shortcuts across the + * configuration workspace. + */ +/** @param {any} props */ +export function Rail({ sections, activeId, onSelect }) { + const activeSection = sections.find((s) => s.id === activeId); + + return ( + + ); +} + +/* ── The document ────────────────────────────────────────────────────────── */ + +/** + * One section of the document. + */ +/** @param {any} props */ +export function DocSection({ + id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false, + children, +}) { + return ( +
+ + + +
+ {children} +
+
+
+ ); +} + +/** A labelled field in the document body. */ +/** @param {any} props */ +export function DocField({ id, label, hint, children, className }) { + const reactId = React.useId(); + const controlId = id ? `${id}-control` : reactId; + + return ( +
+ + {React.isValidElement(children) + ? React.cloneElement(children, { id: children.props.id || controlId }) + : children} + {hint &&

{hint}

} +
+ ); +} + +/** + * The head of a group inside a section — Pages, Skills, Knowledge. + * + * One notch quieter than a section title and one louder than a row, which is + * exactly the level of the thing it names. + */ +/** @param {any} props */ +export function GroupHead({ id, icon: Icon, title, count, action, className }) { + return ( +
+ {Icon &&
+ ); +} + +/** + * A thing attached to the agent — a skill, a subagent — with a way to detach it. + * + * A row, not a card. Five attached skills as five cards is five borders + * competing with the section that holds them; as rows they read as a list, + * which is what they are. + */ +/** @param {any} props */ +export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) { + return ( +
  • + {Icon && ( +
  • + ); +} + +/** + * A behaviour setting: what it is, what it is set to, and the way to change it. + * + * Two shapes, one row. A setting whose control fits on the row carries it + * there — a switch, a segmented choice — and is not a button, because wrapping + * a switch in a button makes one press mean two things. A setting that needs + * space gets a chevron and opens underneath. + */ +/** @param {any} props */ +export function SettingRow({ + id, icon: Icon, title, value, description, control, children, open, onToggle, last = false, +}) { + const expandable = Boolean(children); + + const body = ( + <> + {Icon &&