-
- {state.name}
- {/* The training path behind this level, named from the Forge
- definition attached to this page. The pathway wording is
- the level being worked towards, so a reader can see both
- where the person is and what they are on. */}
- {byId.get(state.skillId) && state.nextLevel && (
-
- {' · '}{byId.get(state.skillId).name}
- {' · '}{levelLabel(state.nextLevel)} pathway
-
+
+
+
+
+ {state.name}
+ {/* The training path behind this level, named from the Forge
+ definition attached to this page. The pathway wording is
+ the level being worked towards, so a reader can see both
+ where the person is and what they are on. */}
+ {byId.get(state.skillId) && state.nextLevel && (
+
+ {' · '}{byId.get(state.skillId).name}
+
+ )}
+
+
+
-
- {state.verifiedLabel}
- {state.nextLevel && state.requiredForNext > 0 && (
-
- {' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
- {' · '}{state.progressToNext}%
-
- )}
-
+ >
+ {state.verifiedLevel && }
+ {STATUS_LABEL[state.status]}
+
-
- {state.verifiedLevel && }
- {STATUS_LABEL[state.status]}
-
+
+
))}
diff --git a/src/components/skills/SkillSections.jsx b/src/components/skills/SkillSections.jsx
new file mode 100644
index 0000000..838b0bc
--- /dev/null
+++ b/src/components/skills/SkillSections.jsx
@@ -0,0 +1,401 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { ArrowRight, ChevronRight, Sparkles } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ProgressBar } from '@/components/ds';
+
+/**
+ * The components a skill definition may ask for, and nothing else.
+ *
+ * `type: flow` in a Markdown file resolves to `FlowSection` through the table
+ * at the bottom of this file. That table is the only bridge between a
+ * definition and a React component: a definition names a key, the renderer
+ * looks it up, and a key that is not here was already refused by validation.
+ * There is no dynamic import, no component name evaluated from a string, and
+ * no markup carried from the file.
+ *
+ * Every section is built from the existing KROW vocabulary — the same borders,
+ * blue, pills and type as the rest of Admin — so an extension looks like part
+ * of the product rather than like an embedded widget.
+ */
+
+/** What a section shows when its data has nothing in it. Never a fake figure. */
+function EmptyNote({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * A sequence — periods, or the stages of a funnel.
+ *
+ * Built from flex and borders rather than a graph library: the shape is a row
+ * of steps with connectors, and a dependency for that would be heavier than the
+ * feature. Wraps to a column on narrow screens, where a horizontal flow would
+ * either overflow or become unreadable.
+ */
+function FlowSection({ data }) {
+ if (data.empty || !data.steps?.length) return
{data.emptyNote} ;
+
+ const peak = Math.max(...data.steps.map((s) => s.value || 0), 1);
+
+ return (
+
+ {data.steps.map((step, i) => (
+
+ {i > 0 && (
+
+ {/* The connector turns with the layout: down when stacked. */}
+
+ ↓
+
+ )}
+
+ {step.label}
+ {step.value}
+ {step.detail && {step.detail}
}
+
+
+
+ ))}
+
+ );
+}
+
+/** Counted figures, side by side. */
+function StatsSection({ data }) {
+ const items = data.steps || data.items || [];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.label || item.title}
+
+
+ {item.value ?? '—'}
+
+
+ ))}
+
+ );
+}
+
+/** Records as rows, each optionally linking to the record it names. */
+function ListSection({ data }) {
+ const items = data.items || [];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ return (
+
+ {items.map((item) => {
+ const body = (
+ <>
+
+ {item.title}
+ {item.detail && {item.detail} }
+
+ {item.value != null && (
+
+ {item.value}
+
+ )}
+ {item.to && }
+ >
+ );
+
+ return (
+
+ {item.to ? (
+
+ {body}
+
+ ) : (
+ {body}
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/** Dated events, most recent first. */
+function TimelineSection({ data }) {
+ const items = [...(data.items || [])]
+ .filter((item) => item.at)
+ .sort((a, b) => new Date(b.at) - new Date(a.at));
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.title}
+ {item.detail && — {item.detail} }
+
+
+ {new Date(item.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
+
+
+ ))}
+
+ );
+}
+
+/** Rows and columns, from whatever the source declared. */
+function TableSection({ data }) {
+ const items = data.items || data.steps || [];
+ const columns = data.columns || [
+ { key: 'label', label: 'Item' },
+ { key: 'value', label: 'Value', align: 'right' },
+ ];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ return (
+
+
+
+
+ {columns.map((c) => (
+
+ {c.label}
+
+ ))}
+
+
+
+ {items.map((item) => (
+
+ {columns.map((c) => (
+
+ {item[c.key] ?? '—'}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+/** Bars against a total. */
+function ProgressSection({ data }) {
+ const items = data.items || data.steps || [];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ const peak = Math.max(...items.map((i) => i.max || i.value || 0), 1);
+
+ return (
+
+ );
+}
+
+/** One finding, stated plainly. */
+function InsightSection({ data }) {
+ const items = data.items || data.steps || [];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ const lead = items.reduce((best, item) => ((item.value || 0) > (best?.value || 0) ? item : best), items[0]);
+
+ return (
+
+
+
+ {lead.label || lead.title}
+ {lead.value != null && <> — {lead.value} >}
+ {lead.detail && · {lead.detail} }
+
+
+ );
+}
+
+/** A plain panel: whatever figures the source gives, as a sentence and a row. */
+function CardSection({ data }) {
+ const items = data.steps || data.items || [];
+ if (data.empty || !items.length) return
{data.emptyNote} ;
+
+ return (
+
+
+ {items.slice(0, 6).map((item) => (
+
+
+ {item.label || item.title}
+
+ {item.value ?? item.detail ?? '—'}
+
+ ))}
+
+
+ );
+}
+
+
+/**
+ * Weighted criteria that share one budget.
+ *
+ * Distinct from `ProgressSection`, which draws bars against independent
+ * maximums. Here the rows are shares of a single total, so the total is stated
+ * once for the set and flagged when it does not come to 100 — which is the only
+ * thing that makes a set of weights wrong rather than merely different.
+ *
+ * The same component draws the page card and Owliver's compact answer; `compact`
+ * only tightens spacing. Two renderings of one declaration, not two components.
+ *
+ * **Editing.** Controls appear only when the definition asked for them *and* the
+ * surface passed an `onApply` — which a page only supplies for a source it owns.
+ * Adjustments are held locally until Apply, so a half-dragged slider never
+ * writes a total of 87% into the form; Apply hands the whole set over at once
+ * and the page's own state is the only place it lands.
+ */
+function WeightsSection({ data, section, onApply, compact = false }) {
+ const rows = React.useMemo(() => data?.steps || data?.items || [], [data]);
+ const editable = Boolean(section?.editable && onApply);
+
+ /* Local only while editing, and re-seeded whenever the page's own values
+ change — so a change made on the form shows up here even mid-edit, and
+ there is never a second copy of the weights outliving the interaction. */
+ const [draft, setDraft] = React.useState(null);
+ const signature = React.useMemo(
+ () => rows.map((r) => `${r.id}:${r.value}`).join('|'),
+ [rows]
+ );
+ React.useEffect(() => { setDraft(null); }, [signature]);
+
+ if (data?.empty || !rows.length) return
{data?.emptyNote} ;
+
+ const current = draft || Object.fromEntries(rows.map((r) => [r.id, Number(r.value) || 0]));
+ const total = Object.values(current).reduce((a, b) => a + b, 0);
+ const balanced = total === 100;
+ const dirty = draft !== null && rows.some((r) => (Number(r.value) || 0) !== current[r.id]);
+
+ return (
+
+
+
+
+
+ Total{' '}
+
+ {total}%
+
+ {!balanced && — must total 100% }
+
+
+ {editable && (
+ { onApply(current); setDraft(null); }}
+ className="inline-flex items-center rounded-lg bg-krow-blue px-2.5 py-1 text-caption font-semibold text-white
+ transition-colors hover:bg-krow-blue-strong disabled:cursor-not-allowed disabled:opacity-40
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
+ >
+ Apply to position
+
+ )}
+
+
+ );
+}
+
+/**
+ * The registry: declared type → component.
+ *
+ * The only mapping from a definition to rendering. Adding a type means adding a
+ * component here and a name in `surfaces.js` — never a branch on a skill id.
+ */
+export const SECTION_COMPONENTS = {
+ card: CardSection,
+ stats: StatsSection,
+ list: ListSection,
+ timeline: TimelineSection,
+ flow: FlowSection,
+ table: TableSection,
+ progress: ProgressSection,
+ insight: InsightSection,
+ weights: WeightsSection,
+};
diff --git a/src/components/skills/SkillSurface.jsx b/src/components/skills/SkillSurface.jsx
new file mode 100644
index 0000000..c3290c6
--- /dev/null
+++ b/src/components/skills/SkillSurface.jsx
@@ -0,0 +1,181 @@
+import React, { useMemo } from 'react';
+import { Sparkles } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
+ usePreferences, useStaff, useUserActivity, useWorkerProfiles,
+} from '@/lib/krowHooks';
+import { allSkills } from '@/lib/skills/registry';
+import { sectionsForPage } from '@/lib/skills/uiConfig';
+import { resolveSkillData } from '@/lib/skills/dataResolver';
+import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
+import { profileForEmail } from '@/lib/skillGraph';
+import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
+/* The page's current selection, from the one channel that carries it. Imported
+ from the module rather than the package index so a page section never pulls
+ the assistant panel in behind it. */
+import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
+
+/**
+ * The extension point: one controlled slot a page offers to skills.
+ *
+ * A page says where a skill *may* appear; a skill definition says where it
+ * *does*. Neither knows the other exists — the page names a surface and a
+ * placement, the definition names the same two things, and this component is
+ * what joins them. That is why a new skill needs no change to any page, and why
+ * a page can be redesigned without touching a skill.
+ *
+ *
+ *
+ * What it will not do is as important as what it does. It renders only sections
+ * that survived validation, only from skills that are active, only through the
+ * component table, and only with data the resolver produced from real records.
+ * Nothing from the Markdown reaches the DOM as markup.
+ */
+
+/** The skills contributing sections to this page right now. */
+function useSkillSections(page, placement) {
+ const preferences = usePreferences();
+ const customKey = JSON.stringify(preferences.customSkills || []);
+ const disabledKey = JSON.stringify(preferences.disabledSkills || []);
+
+ return useMemo(() => {
+ const custom = JSON.parse(customKey);
+ const disabled = JSON.parse(disabledKey);
+
+ return allSkills(custom)
+ /* Inactive means registered but not offered — the same rule the assistant
+ follows, so switching a skill off removes its UI too. */
+ .filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
+ .flatMap((skill) => sectionsForPage(skill, page)
+ .filter((section) => !placement || section.placement === placement)
+ .map((section) => ({ skill, section })));
+ }, [page, placement, customKey, disabledKey]);
+}
+
+/**
+ * The records a section may be read from.
+ *
+ * Loaded once per surface rather than per section, and passed to the resolver —
+ * which is the only thing that touches them. A section never queries anything.
+ *
+ * Exported because the panel needs the identical reading: an answer drawn in the
+ * chat and a card drawn on the page must resolve from one set of collections, or
+ * the two surfaces of the same definition can disagree. Every hook inside is a
+ * cache read the app has already paid for.
+ */
+export function useSkillDataContext(context) {
+ /* What the page published about itself, for a surface that was not handed a
+ record directly. An explicit `context` prop still wins — a card knows which
+ position it is, and that is more specific than what the page says. */
+ const published = usePageContext();
+ const { data: applications = [] } = useApplications();
+ const { data: positions = [] } = useJobPostings();
+ const { data: interviews = [] } = useInterviews();
+ const { data: courses = [] } = useCourses();
+ const { data: workerProfiles = [] } = useWorkerProfiles();
+ /* The hires and the audit trail, for the sources that read outcomes and
+ events. Same caches the pages themselves render from, so a section and the
+ panel beside it can never disagree. */
+ const { data: staff = [] } = useStaff();
+ const { data: activity = [] } = useUserActivity();
+ /* Who is already committed, for the sources that score people against a role:
+ the matching engine reads availability from assignments, and without them
+ everybody would look free. */
+ const { data: assignments = [] } = useAssignments();
+ const { data: user } = useCurrentUser();
+ const { statesFor } = useWorkforcePaths();
+
+ const trainingPaths = useMemo(
+ () => statesFor(courses, profileForEmail(workerProfiles, user?.email)),
+ [statesFor, courses, workerProfiles, user?.email]
+ );
+
+ return useMemo(() => ({
+ ...published,
+ ...context,
+ applications,
+ positions,
+ interviews,
+ courses,
+ workerProfiles,
+ /* The workforce engine's own name for the same records. Both keys are
+ published so a resolver can be written against either without a rename
+ rippling through every existing source. */
+ profiles: workerProfiles,
+ assignments,
+ staff,
+ activity,
+ trainingPaths,
+ /* `published` belongs here: it is what changes when the reader edits the
+ form this panel is sitting beside, and leaving it out froze every
+ section on the first value the page ever published. */
+ }), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
+ staff, activity, trainingPaths]);
+}
+
+/** One declared section, resolved and drawn. */
+function SkillSection({ skill, section, context }) {
+ const Component = SECTION_COMPONENTS[section.type];
+ const data = useMemo(
+ () => resolveSkillData(section, context),
+ [section, context]
+ );
+
+ /* How this section writes back, if the page is offering that write at all.
+ Null on every page that is not, which is what keeps a section declared
+ `editable` honest on a surface that only reads. */
+ const apply = usePageAction(section.editable ? section.source : null);
+
+ /* Validation refuses an unknown type long before this, so a missing component
+ means the registry and the vocabulary have drifted apart. Render nothing
+ rather than a broken panel. */
+ if (!Component) return null;
+
+ return (
+
+
+
+
+ {section.title || skill.name}
+
+ {section.description && (
+
{section.description}
+ )}
+
+ {/* Attribution, so an admin can tell an extension from a built-in panel
+ and knows which skill to switch off. */}
+
+
+ {skill.name}
+
+
+
+
+
+ );
+}
+
+export function SkillSurface({ page, placement, context = null, className }) {
+ const sections = useSkillSections(page, placement);
+ const resolved = useSkillDataContext(context);
+
+ if (!sections.length) return null;
+
+ return (
+
+ {sections.map(({ skill, section }) => (
+
+ ))}
+
+ );
+}
diff --git a/src/lib/hiringRecords.js b/src/lib/hiringRecords.js
new file mode 100644
index 0000000..c3b26c6
--- /dev/null
+++ b/src/lib/hiringRecords.js
@@ -0,0 +1,315 @@
+/**
+ * The hiring record, derived once.
+ *
+ * Analytics and Hired History ask different questions of the same facts — "how
+ * is our hiring performing" and "who did we hire, and what happened" — and they
+ * answer them with different pages. What they must never do is *count*
+ * differently: a total on one page and the same total on the other have to be
+ * the same number, or the two pages stop being two views and become two claims.
+ *
+ * So the counting lives here, in plain functions over the collections the app
+ * already holds, and each page renders what it needs from the result. Nothing in
+ * this module knows what either page looks like.
+ */
+
+/** The mean of a numeric list, rounded. Zero for an empty list. */
+export const avg = (xs) => {
+ const values = xs.filter((n) => Number.isFinite(n));
+ return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
+};
+
+/**
+ * Everyone hired, as one list.
+ *
+ * A Staff record is the hire; the application it came from carries how long it
+ * took and what it scored, and the posting carries the department. Joined here
+ * so neither page repeats the join — and so "department" means the same thing
+ * on both.
+ */
+export function buildHires({ staff = [], applications = [], postings = [] }) {
+ return staff.map((s) => {
+ const app = applications.find((a) => a.id === s.application_id);
+ const posting = postings.find((p) => p.id === s.job_posting_id);
+ const days = app
+ ? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
+ : null;
+
+ return {
+ ...s,
+ company: s.company || posting?.company || '—',
+ department: posting?.role_category || s.department || '—',
+ role: s.role || posting?.title || '—',
+ timeToHire: days || s.timeToHire || null,
+ score: s.ai_score || app?.ai_score || s.score || null,
+ profile_tier: s.profile_tier || 'skilled',
+ hire_date: s.hire_date || s.created_date || null,
+ applicationId: app?.id || s.application_id || null,
+ };
+ });
+}
+
+/**
+ * Demo fill, carried over from the page this module was extracted from.
+ *
+ * The store seeds three Staff records; Hired History has always padded that to
+ * eight so the page reads as a hiring history rather than as three rows. That
+ * padding is pre-existing product behaviour, not something derived — it is kept
+ * here, named for what it is, so both pages show what the page has always shown
+ * and there is one list to delete when the deployment has real volume.
+ *
+ * It only ever *adds* people the store does not already have, matched on email,
+ * so a real hire is never shadowed by a demo one.
+ */
+const DEMO_FILL = [
+ { id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
+ { id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
+ { id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
+ { id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
+ { id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
+];
+
+/** The joined hires, with the demo fill applied for anyone not already on file. */
+export function hiresWithFill(sources) {
+ const live = buildHires(sources);
+ const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
+
+ const filled = [...live];
+ for (const person of DEMO_FILL) {
+ const key = String(person.email || person.name).toLowerCase();
+ if (!seen.has(key)) {
+ filled.push({ ...person, company: person.company || '—' });
+ seen.add(key);
+ }
+ }
+ return filled;
+}
+
+/** Headline figures: volume, speed, quality, and how many are still on. */
+export function summarise(hires) {
+ return {
+ total: hires.length,
+ speed: avg(hires.map((h) => h.timeToHire)),
+ quality: avg(hires.map((h) => h.score)),
+ active: hires.filter((h) => h.status !== 'inactive').length,
+ onboarding: hires.filter((h) => h.status === 'onboarding').length,
+ };
+}
+
+/** The application stages this product counts, in the order they happen. */
+export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
+
+/**
+ * Applied → screened → shortlisted → interview → hired, with the pass-through
+ * rate and the loss at each step.
+ *
+ * Counted at-or-beyond, so a candidate who reached interview is counted as
+ * having been screened — a funnel that counts only the current status shows
+ * later stages as larger than earlier ones, which is not a funnel.
+ */
+export function buildFunnel(applications) {
+ const atOrBeyond = (stage) => {
+ const from = STAGE_ORDER.indexOf(stage);
+ return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
+ };
+
+ const stages = [
+ { key: 'applied', label: 'Applied', count: applications.length },
+ { key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
+ { key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
+ { key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
+ { key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
+ ];
+
+ const transitions = stages.slice(1).map((stage, i) => {
+ const previous = stages[i];
+ return {
+ from: previous.key,
+ to: stage.key,
+ rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0,
+ lost: Math.max(0, previous.count - stage.count),
+ };
+ });
+
+ /* The step losing the most people — the one worth acting on. */
+ const weakest = transitions.reduce(
+ (worst, t) => (!worst || t.rate < worst.rate ? t : worst),
+ null
+ );
+
+ return {
+ stages,
+ transitions,
+ weakestKey: weakest?.to || null,
+ conversion: applications.length
+ ? Math.round((stages[4].count / applications.length) * 100)
+ : 0,
+ };
+}
+
+/** Cumulative hires by month — a trend needs a baseline, not a single bar. */
+export function buildTrend(hires) {
+ const byMonth = new Map();
+ hires.filter((h) => h.hire_date).forEach((h) => {
+ const d = new Date(h.hire_date);
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+ byMonth.set(key, (byMonth.get(key) || 0) + 1);
+ });
+
+ let running = 0;
+ return [...byMonth.entries()].sort().map(([key, count]) => {
+ running += count;
+ const [y, m] = key.split('-');
+ return {
+ label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
+ hires: count,
+ cumulative: running,
+ };
+ });
+}
+
+/** Hires grouped by department, best-performing first. */
+export function byDepartment(hires) {
+ const map = new Map();
+
+ hires.forEach((h) => {
+ const name = h.department && h.department !== '—' ? h.department : 'General';
+ const entry = map.get(name) || {
+ name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [],
+ };
+ entry.count += 1;
+ if (h.score) entry.scores.push(h.score);
+ if (h.timeToHire) entry.times.push(h.timeToHire);
+ if (h.role && h.role !== '—') entry.rolesSet.add(h.role);
+ entry.hiresList.push(h);
+ map.set(name, entry);
+ });
+
+ return [...map.values()]
+ .map((e) => ({
+ name: e.name,
+ department: e.department,
+ count: e.count,
+ scores: e.scores,
+ avgScore: avg(e.scores),
+ avgTimeToHire: avg(e.times),
+ roles: [...e.rolesSet],
+ hiresList: e.hiresList,
+ }))
+ .sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
+}
+
+/** Hires grouped by the role they were hired into, most-filled first. */
+export function byPosition(hires) {
+ const map = new Map();
+
+ hires.forEach((h) => {
+ const key = h.role && h.role !== '—' ? h.role : 'Unspecified';
+ const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
+ entry.count += 1;
+ if (h.score) entry.scores.push(h.score);
+ if (h.timeToHire) entry.days.push(h.timeToHire);
+ if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); }
+ map.set(key, entry);
+ });
+
+ return [...map.values()]
+ .map((e) => ({
+ role: e.role,
+ count: e.count,
+ avgScore: avg(e.scores),
+ avgDays: avg(e.days),
+ rated: e.rated,
+ avgRating: e.ratings.length
+ ? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
+ : null,
+ }))
+ .sort((a, b) => b.count - a.count);
+}
+
+/**
+ * Where hiring is slow, and where it is fast.
+ *
+ * Velocity is only meaningful against something, so each role is measured
+ * against the workspace's own median rather than an industry figure nobody
+ * here can check.
+ */
+export function buildEfficiency(hires) {
+ const timed = hires.filter((h) => h.timeToHire);
+ if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 };
+
+ const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire);
+ const median = sorted[Math.floor(sorted.length / 2)].timeToHire;
+
+ const roles = byPosition(timed).filter((r) => r.avgDays);
+
+ return {
+ median,
+ fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4),
+ slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4),
+ within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100),
+ };
+}
+
+/**
+ * What the numbers say, as findings rather than figures.
+ *
+ * Every one is conditional on the data supporting it: a claim about the fastest
+ * department is only made when there is more than one department to compare, and
+ * a risk is only raised when something is actually at risk. A page that always
+ * shows three insights is showing decoration.
+ */
+export function buildInsights({ hires, departments, positions, funnel, efficiency }) {
+ const out = [];
+ const summary = summarise(hires);
+
+ if (departments.length > 1) {
+ const best = departments[0];
+ out.push({
+ tone: 'success',
+ title: `${best.department} is hiring the strongest candidates`,
+ body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`,
+ });
+ }
+
+ if (funnel.weakestKey) {
+ const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey);
+ const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label;
+ if (weak && weak.lost > 0) {
+ out.push({
+ tone: 'warning',
+ title: `The largest drop-off is into ${label}`,
+ body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`,
+ });
+ }
+ }
+
+ if (efficiency.slowest.length && efficiency.median) {
+ const slow = efficiency.slowest[0];
+ if (slow.avgDays > efficiency.median) {
+ out.push({
+ tone: 'risk',
+ title: `${slow.role} takes longest to fill`,
+ body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`,
+ });
+ }
+ }
+
+ const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0);
+ if (unrated > 0) {
+ out.push({
+ tone: 'info',
+ title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`,
+ body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.',
+ });
+ }
+
+ if (funnel.conversion) {
+ out.push({
+ tone: 'info',
+ title: `${funnel.conversion}% of applicants are hired`,
+ body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`,
+ });
+ }
+
+ return out;
+}
diff --git a/src/lib/skills/actions.js b/src/lib/skills/actions.js
index ecce915..a3e8bb5 100644
--- a/src/lib/skills/actions.js
+++ b/src/lib/skills/actions.js
@@ -432,9 +432,12 @@ const HANDLERS = {
* Reached only from the confirmation step, after the position has been read
* back and the user has chosen to create it.
*/
- create_position: ({ draft }) => ({
+ /* `status` is the one the conversation's confirmation step chose — draft or
+ active — and is passed to the payload builder the form already uses, so
+ both routes write the same record with the same defaults. */
+ create_position: ({ draft, status }) => ({
type: 'create_position',
- data: toPositionPayload(draft || {}),
+ data: toPositionPayload(draft || {}, status ? { status } : undefined),
}),
/**
diff --git a/src/lib/skills/customSkills.js b/src/lib/skills/customSkills.js
index 5718be3..825fc81 100644
--- a/src/lib/skills/customSkills.js
+++ b/src/lib/skills/customSkills.js
@@ -9,30 +9,132 @@ import { parseSkill } from './registry';
* writers with two shapes would be a second skill system by accident.
*/
-/** The starting definition offered to an author, in the existing format. */
-export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
-id: ${id || 'my-skill'}
-name: ${name || 'My Skill'}
-description: ${description || 'What this skill helps Owliver do.'}
-pages:
-${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
-status: active
-triggers:
- - ${(name || 'my skill').toLowerCase()}
----
+/**
+ * The starting definitions offered to an author.
+ *
+ * Two templates, because there are two jobs and one of them was being learned
+ * from the other's example. A UI skill's first draft declares a section; an
+ * Owliver skill's declares triggers and the shapes of an answer. Both are the
+ * same format, read by the same parser — what differs is which half of it the
+ * author is being handed.
+ */
-# ${name || 'My Skill'}
+const frontMatter = ({ id, name, description, pages, fallback }) => [
+ `id: ${id || fallback.id}`,
+ `name: ${name || fallback.name}`,
+ `description: ${description || fallback.description}`,
+ 'pages:',
+ (pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'),
+ 'status: active',
+].join('\n');
+
+/** A definition that draws a section on the pages it names. */
+export const uiSkillTemplate = ({
+ id = '', name = '', description = '', pages = [],
+ type = 'flow', placement = '', source = 'position.activity', periods = [],
+} = {}) => `---
+${frontMatter({
+ id,
+ name,
+ description,
+ pages,
+ fallback: {
+ id: 'my-ui-skill',
+ name: 'My UI Skill',
+ description: 'What this skill adds to the page.',
+ },
+ })}
+ui:
+ type: ${type}
+${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
+ source: ${source}
+${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
+
+# ${name || 'My UI Skill'}
## Purpose
-Describe what Owliver should help with on these pages.
+Describe what this section shows, and why it belongs on these pages.
## Capabilities
-- Describe one thing the skill can do.
+- Describe one thing the section reports.
- Add more as needed.
`;
+/** A definition that teaches Owliver what it can be asked for. */
+export const owliverSkillTemplate = ({
+ id = '', name = '', description = '', pages = [],
+ triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
+} = {}) => {
+ const label = name || 'My Owliver Skill';
+ const lines = [`---
+${frontMatter({
+ id,
+ name,
+ description,
+ pages,
+ fallback: {
+ id: 'my-owliver-skill',
+ name: label,
+ description: 'What this skill helps Owliver answer.',
+ },
+ })}`];
+
+ lines.push('triggers:');
+ lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
+
+ lines.push('owliver:');
+ lines.push(' enabled: true');
+
+ if (suggestions.length) {
+ lines.push(' suggestions:');
+ lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
+ }
+
+ if (capabilities.length) {
+ lines.push(' capabilities:');
+ lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
+
+ if (source) {
+ lines.push(' responses:');
+ for (const capability of capabilities) {
+ lines.push(` ${capability}:`);
+ lines.push(` source: ${source}`);
+ if (periods.length) {
+ lines.push(' periods:');
+ lines.push(periods.map((p) => ` - ${p}`).join('\n'));
+ }
+ }
+ }
+ }
+
+ lines.push(`---
+
+# ${label}
+
+## Purpose
+
+Describe what Owliver should be able to answer on these pages.
+
+## Capabilities
+
+- Describe one thing Owliver can be asked for.
+- Add more as needed.
+`);
+
+ return lines.join('\n');
+};
+
+/**
+ * The template the Add Skill dialog offers.
+ *
+ * That dialog opens from Owliver's own header, mid-conversation, so what it
+ * hands the author is an Owliver skill. Kept under its original name because
+ * it is what the dialog already imports.
+ */
+export const skillTemplate = owliverSkillTemplate;
+
/** Parses a stored entry, tolerating the bare-string form. */
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
diff --git a/src/lib/skills/dataResolver.js b/src/lib/skills/dataResolver.js
new file mode 100644
index 0000000..84bfc73
--- /dev/null
+++ b/src/lib/skills/dataResolver.js
@@ -0,0 +1,516 @@
+import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
+import { CRITERIA_LABELS } from '@/lib/positionModel';
+import { poolFor } from '@/lib/workforce';
+import { candidateRoute } from './workforceFlow';
+
+/**
+ * The one place a skill's declared data source becomes real data.
+ *
+ * A section says `data.source: position.activity`. It does not say where that
+ * comes from, cannot reach a store, and cannot name a field. This module owns
+ * the mapping — source id in, normalized reading out — which is what keeps a
+ * declarative file from turning into a query language.
+ *
+ * Two rules hold throughout:
+ *
+ * - **Real records only.** Every figure is counted from the collections the
+ * application already holds. Nothing is generated to make a section look
+ * populated; a source with nothing to report returns `empty: true` and the
+ * renderer says so.
+ * - **Time is computed, never stored.** "Today" is a window over the record
+ * timestamps, evaluated against the current date at read time. No date is
+ * written into a definition and none is hard-coded here.
+ */
+
+const DAY = 24 * 60 * 60 * 1000;
+
+/** Midnight at the start of the given day, in local time. */
+const startOfDay = (date) => {
+ const d = new Date(date);
+ d.setHours(0, 0, 0, 0);
+ return d;
+};
+
+/**
+ * The window a period covers, as `[from, to)`.
+ *
+ * Weeks run Monday to Monday and months from the 1st, which is how the rest of
+ * the product reports them.
+ */
+export function periodRange(period, now = new Date()) {
+ const today = startOfDay(now);
+
+ switch (period) {
+ case 'today':
+ return { from: today, to: new Date(today.getTime() + DAY) };
+ case 'yesterday':
+ return { from: new Date(today.getTime() - DAY), to: today };
+ case 'last-7-days':
+ return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) };
+ case 'last-week': {
+ /* The calendar week before the one we are in. */
+ const weekday = (today.getDay() + 6) % 7;
+ const thisMonday = new Date(today.getTime() - weekday * DAY);
+ return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday };
+ }
+ case 'this-month': {
+ const from = new Date(today.getFullYear(), today.getMonth(), 1);
+ return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) };
+ }
+ case 'previous-month': {
+ const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) };
+ }
+ default:
+ return null;
+ }
+}
+
+/** Records whose `created_date` falls inside the window. */
+const inPeriod = (records, period, now) => {
+ const range = periodRange(period, now);
+ if (!range) return [];
+ return records.filter((record) => {
+ const at = new Date(record.created_date || record.updated_date || 0).getTime();
+ return at >= range.from.getTime() && at < range.to.getTime();
+ });
+};
+
+/**
+ * Why a matched candidate fits, in one line.
+ *
+ * Met requirements first, then the gaps, then what is known about availability —
+ * the same order and the same words the panel's match cards use, because they
+ * are read from the same row. A gap is stated as a gap: a line that only listed
+ * strengths would make every candidate look like a strong match.
+ */
+function matchDetail(row) {
+ if (!row.match) return 'Nothing on this position to score this candidate against';
+
+ const parts = [
+ ...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`),
+ ...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`),
+ ];
+
+ if (row.availability?.known === false) parts.push('Availability not on file');
+ else if (row.availability) {
+ parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
+ }
+
+ return parts.join(' · ');
+}
+
+/** The application stages this product counts, in the order they happen. */
+const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
+
+const atOrBeyond = (applications, stage) => {
+ const from = STAGE_ORDER.indexOf(stage);
+ return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
+};
+
+/** Applications counted by period — the reading behind an activity section. */
+function activityOverTime(applications, periods, now) {
+ const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week'];
+
+ const steps = wanted
+ .filter((period) => SUPPORTED_PERIODS.includes(period))
+ .map((period) => {
+ const records = inPeriod(applications, period, now);
+ return {
+ id: period,
+ label: periodLabel(period),
+ value: records.length,
+ detail: records.length
+ ? `${records.length} application${records.length === 1 ? '' : 's'}`
+ : 'No applications',
+ records,
+ };
+ });
+
+ return {
+ steps,
+ total: applications.length,
+ empty: steps.every((s) => s.value === 0),
+ emptyNote: applications.length
+ ? 'No applications in these periods.'
+ : 'No applications on this position yet.',
+ };
+}
+
+/** The hiring funnel for a set of applications. */
+function pipelineOf(applications) {
+ const steps = [
+ { id: 'applied', label: 'Applied', value: applications.length },
+ { id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
+ { id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
+ { id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
+ { id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
+ ];
+
+ return {
+ steps,
+ total: applications.length,
+ empty: applications.length === 0,
+ emptyNote: 'No applications on this position yet.',
+ };
+}
+
+/**
+ * Every source the vocabulary offers, and how each is read.
+ *
+ * Keyed by the same ids `surfaces.js` publishes, so the list an author can
+ * choose from and the list that can actually be resolved are the same list.
+ */
+const RESOLVERS = {
+ 'position.activity': ({ position, applications }, section, now) => {
+ const mine = applications.filter((a) => a.job_posting_id === position?.id);
+ return activityOverTime(mine, section.periods, now);
+ },
+
+ 'position.pipeline': ({ position, applications }) =>
+ pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)),
+
+ 'position.candidates': ({ position, applications }, section) => {
+ const mine = applications
+ .filter((a) => a.job_posting_id === position?.id)
+ .sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))
+ .slice(0, section.limit || 5);
+
+ return {
+ items: mine.map((a) => ({
+ id: a.id,
+ title: a.applicant_name,
+ detail: [
+ a.years_experience != null ? `${a.years_experience} yrs experience` : null,
+ String(a.status || '').replace(/_/g, ' '),
+ ].filter(Boolean).join(' · '),
+ value: a.ai_score > 0 ? a.ai_score : null,
+ to: `/admin/candidates/${a.id}`,
+ })),
+ columns: [
+ { key: 'title', label: 'Candidate' },
+ { key: 'detail', label: 'Status' },
+ { key: 'value', label: 'Score', align: 'right' },
+ ],
+ empty: mine.length === 0,
+ emptyNote: 'No candidates have applied to this position yet.',
+ };
+ },
+
+ /**
+ * The candidate pool, scored against this position.
+ *
+ * Every figure here comes from `poolFor` — the same engine behind the
+ * workforce conversation and the position page's own recommendations. This
+ * resolver ranks nothing and scores nothing; it reads the rows the engine
+ * returned and states them, including *why* each one fits, in the engine's own
+ * terms. A candidate the position gives nothing to measure against is reported
+ * as unscored rather than given a number.
+ *
+ * `to` is the candidate's real record, carrying the position they were being
+ * considered for — the same route the panel's match cards open.
+ */
+ 'position.matches': ({ position, ...context }, section) => {
+ if (!position) {
+ return { items: [], empty: true, emptyNote: 'No position to match candidates against.' };
+ }
+
+ const rows = poolFor(position, {
+ profiles: context.profiles || context.workerProfiles || [],
+ applications: context.applications || [],
+ assignments: context.assignments || [],
+ courses: context.courses || [],
+ staff: context.staff || [],
+ }).slice(0, section.limit || 5);
+
+ return {
+ items: rows.map((row) => ({
+ id: row.candidateId,
+ title: row.name,
+ /* The met requirements and the gaps, as the engine stated them. */
+ detail: matchDetail(row),
+ value: row.scored ? row.score : null,
+ to: candidateRoute(row, context.applications || [], position),
+ })),
+ columns: [
+ { key: 'title', label: 'Candidate' },
+ { key: 'detail', label: 'Why' },
+ { key: 'value', label: 'Match', align: 'right' },
+ ],
+ empty: rows.length === 0,
+ emptyNote: 'No candidates on file to score against this position yet.',
+ };
+ },
+
+ 'position.requirements': ({ position }) => {
+ const items = [
+ position?.min_experience_years
+ ? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` }
+ : null,
+ position?.english_required
+ ? { id: 'english', title: 'English level', detail: String(position.english_required) }
+ : null,
+ ...(position?.certifications_required || []).map((c) => ({
+ id: `cert-${c}`, title: 'Certification', detail: c,
+ })),
+ ...(position?.skill_requirements || []).map((r) => ({
+ id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`,
+ })),
+ ].filter(Boolean);
+
+ return {
+ items,
+ columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }],
+ empty: items.length === 0,
+ emptyNote: 'This position states no requirements.',
+ };
+ },
+
+ 'candidate.readiness': ({ candidate }) => {
+ const breakdown = candidate?.score_breakdown || {};
+ const items = Object.entries(breakdown)
+ .filter(([, value]) => Number(value) > 0)
+ .map(([key, value]) => ({
+ id: key,
+ title: key.replace(/_/g, ' '),
+ value: Number(value),
+ max: 100,
+ }));
+
+ return {
+ items,
+ columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }],
+ empty: items.length === 0,
+ emptyNote: 'This candidate has not been screened, so there are no dimensions to show.',
+ };
+ },
+
+ 'candidate.activity': ({ candidate, interviews = [] }) => {
+ const events = [
+ candidate?.created_date && {
+ id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date,
+ },
+ candidate?.ai_score > 0 && {
+ id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date,
+ },
+ ...interviews
+ .filter((i) => i.application_id === candidate?.id)
+ .map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
+ candidate?.status === 'hired' && {
+ id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
+ },
+ ].filter(Boolean);
+
+ return {
+ items: events,
+ empty: events.length === 0,
+ emptyNote: 'Nothing has happened on this record yet.',
+ };
+ },
+
+ 'candidates.pipeline': ({ applications }) => pipelineOf(applications),
+
+ 'candidates.activity': ({ applications }, section, now) =>
+ activityOverTime(applications, section.periods, now),
+
+ 'positions.demand': ({ positions = [], applications }, section) => {
+ const items = positions
+ .filter((p) => p.status === 'active')
+ .slice(0, section.limit || 5)
+ .map((p) => {
+ const mine = applications.filter((a) => a.job_posting_id === p.id);
+ return {
+ id: p.id,
+ title: p.title,
+ detail: [p.company, p.location].filter(Boolean).join(' · '),
+ value: mine.length,
+ to: `/admin/positions/${p.id}`,
+ };
+ });
+
+ return {
+ items,
+ columns: [
+ { key: 'title', label: 'Position' },
+ { key: 'detail', label: 'Client' },
+ { key: 'value', label: 'Applicants', align: 'right' },
+ ],
+ empty: items.length === 0,
+ emptyNote: 'No open positions.',
+ };
+ },
+
+ 'workforce.training': ({ trainingPaths = [] }, section) => {
+ const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({
+ id: definition.id,
+ title: state.name,
+ detail: state.verifiedLabel,
+ value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0,
+ max: 100,
+ }));
+
+ return {
+ items,
+ columns: [
+ { key: 'title', label: 'Path' },
+ { key: 'detail', label: 'Level' },
+ { key: 'value', label: 'Complete', align: 'right' },
+ ],
+ empty: items.length === 0,
+ emptyNote: 'No training paths are registered.',
+ };
+ },
+
+ /**
+ * The vetting weights on the position in context.
+ *
+ * The same reading whether that position is a saved record or the draft being
+ * typed into Create Position — both carry `vetting_criteria`, so a section
+ * declared once reports the specification as it stands on either surface.
+ */
+ 'position.vetting': ({ position }) => {
+ const criteria = position?.vetting_criteria || {};
+ const steps = Object.entries(criteria).map(([key, value]) => ({
+ id: key,
+ label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
+ title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
+ value: Number(value) || 0,
+ max: 100,
+ detail: `${Number(value) || 0}% of the screening score`,
+ }));
+
+ const total = steps.reduce((sum, s) => sum + s.value, 0);
+
+ return {
+ steps,
+ items: steps,
+ total,
+ columns: [
+ { key: 'title', label: 'Criterion' },
+ { key: 'value', label: 'Weight', align: 'right' },
+ ],
+ empty: steps.length === 0,
+ emptyNote: 'This position states no vetting weights.',
+ };
+ },
+
+ /** Everyone hired, most recent first — the record rather than the analysis. */
+ 'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
+ const items = [...staff]
+ .sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
+ .slice(0, section.limit || 10)
+ .map((s) => {
+ const app = applications.find((a) => a.id === s.application_id);
+ const posting = positions.find((p) => p.id === s.job_posting_id);
+ return {
+ id: s.id,
+ title: s.name,
+ detail: [s.role || posting?.title, posting?.role_category || s.department]
+ .filter(Boolean).join(' · '),
+ value: s.ai_score || app?.ai_score || s.score || null,
+ at: s.hire_date || s.created_date || null,
+ };
+ });
+
+ return {
+ items,
+ columns: [
+ { key: 'title', label: 'Hire' },
+ { key: 'detail', label: 'Role' },
+ { key: 'value', label: 'Score', align: 'right' },
+ ],
+ empty: items.length === 0,
+ emptyNote: 'Nobody has been hired yet.',
+ };
+ },
+
+ /**
+ * The four figures that answer "how is our hiring performing" — counted from
+ * applications and the staff records they became, never stored.
+ */
+ 'hires.performance': ({ applications = [], staff = [] }) => {
+ const hired = applications.filter((a) => a.status === 'hired');
+ const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
+ const days = hired
+ .map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
+ .filter((n) => Number.isFinite(n) && n >= 0);
+
+ const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
+ const total = staff.length || hired.length;
+
+ const steps = [
+ { id: 'hires', label: 'Total hires', title: 'Total hires', value: total },
+ { id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) },
+ { id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) },
+ {
+ id: 'conversion',
+ label: 'Conversion rate',
+ title: 'Conversion rate',
+ value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0,
+ },
+ ];
+
+ return {
+ steps,
+ items: steps,
+ total,
+ columns: [
+ { key: 'title', label: 'Measure' },
+ { key: 'value', label: 'Value', align: 'right' },
+ ],
+ empty: applications.length === 0 && total === 0,
+ emptyNote: 'No hiring activity has been recorded yet.',
+ };
+ },
+
+ /** The workspace audit trail, most recent first. */
+ 'activity.events': ({ activity = [] }, section) => {
+ const items = [...activity]
+ .sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
+ .slice(0, section.limit || 10)
+ .map((event) => ({
+ id: event.id,
+ title: String(event.event_type || 'event').replace(/_/g, ' '),
+ detail: [event.user_name, event.details].filter(Boolean).join(' — '),
+ at: event.created_date,
+ }));
+
+ return {
+ items,
+ columns: [
+ { key: 'title', label: 'Event' },
+ { key: 'detail', label: 'Who' },
+ ],
+ empty: items.length === 0,
+ emptyNote: 'No activity has been recorded yet.',
+ };
+ },
+};
+
+/**
+ * One section's data, read from the application's own records.
+ *
+ * `context` is what the page supplies — the position or candidate being looked
+ * at, plus the collections it already loaded. A source whose required context
+ * is missing returns `unavailable`, which the renderer states rather than
+ * filling in.
+ */
+export function resolveSkillData(section, context = {}, now = new Date()) {
+ const resolve = RESOLVERS[section?.source];
+ if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` };
+
+ if (section.context === 'positionId' && !context.position) {
+ return { unavailable: true, emptyNote: 'This section needs a position to read.' };
+ }
+ if (section.context === 'candidateId' && !context.candidate) {
+ return { unavailable: true, emptyNote: 'This section needs a candidate to read.' };
+ }
+
+ try {
+ return resolve(context, section, now);
+ } catch {
+ /* A resolver that throws is a bug in this file, not in the definition —
+ the section reports it has nothing rather than taking the page down. */
+ return { unavailable: true, emptyNote: 'This section could not be read.' };
+ }
+}
diff --git a/src/lib/skills/owliverConfig.js b/src/lib/skills/owliverConfig.js
new file mode 100644
index 0000000..81dc9e7
--- /dev/null
+++ b/src/lib/skills/owliverConfig.js
@@ -0,0 +1,247 @@
+import { normalizeSection } from './uiConfig';
+import {
+ SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor,
+} from './surfaces';
+
+/**
+ * The `owliver:` block of a skill definition, checked and normalized.
+ *
+ * The same file that extends a page can extend the panel beside it. `ui:` says
+ * what the page renders; `owliver:` says what can be *asked for*, and both name
+ * the same data source — so the card and the answer are two readings of one
+ * declaration rather than two definitions that have to be kept in step.
+ *
+ * owliver:
+ * enabled: true
+ * suggestions:
+ * - Show hiring activity
+ * - Summarize hiring activity
+ * capabilities:
+ * - summary
+ * - flow
+ * responses:
+ * flow:
+ * title: Hiring Activity Flow
+ * source: position.activity
+ * steps: [today, yesterday, last-week]
+ *
+ * Three properties hold, and each one is a rule the rest of the system relies
+ * on:
+ *
+ * - **A response is a section.** It is normalized by the same function the
+ * page's `ui:` sections go through, so a capability resolves to the same
+ * record, the same data source and the same renderer. There is no second
+ * shape for "the chat version".
+ * - **The block is optional.** A definition with no `owliver:` normalizes to
+ * a disabled record and behaves exactly as it did before this existed.
+ * - **Nothing unknown survives.** Capabilities, sources, periods and shapes
+ * are checked against the closed vocabulary in `surfaces.js`; an
+ * unrecognised value is a named error rather than a dropped key.
+ */
+
+/** What a definition with no `owliver:` block gets. */
+export const NO_OWLIVER = Object.freeze({
+ enabled: false,
+ suggestions: [],
+ capabilities: [],
+ responses: {},
+});
+
+/** The section type a capability is drawn with — `summary` draws nothing. */
+const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null;
+
+/**
+ * The reading a response inherits when it does not name one.
+ *
+ * A skill that already declares a `ui:` section has stated its source once;
+ * making it state it again for the panel would be the format asking the author
+ * to repeat themselves, and would let the two drift apart. The first declared
+ * section wins, in declaration order.
+ */
+function inheritedSection(ui) {
+ for (const page of Object.values(ui || {})) {
+ const section = (page.sections || [])[0];
+ if (section) return section;
+ }
+ return null;
+}
+
+/** One suggestion, in either the plain-string or the mapping form. */
+function normalizeSuggestion(raw, { errors, capabilities, index }) {
+ const where = `owliver.suggestions[${index}]`;
+
+ if (typeof raw === 'string' || typeof raw === 'number') {
+ const label = String(raw).trim();
+ if (!label) {
+ errors.push(`${where}: a suggestion needs text.`);
+ return null;
+ }
+ return { label, prompt: label, capability: null };
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`);
+ return null;
+ }
+
+ const label = String(raw.label ?? raw.prompt ?? '').trim();
+ if (!label) {
+ errors.push(`${where}: a suggestion needs a \`label\`.`);
+ return null;
+ }
+
+ /* A suggestion may say which capability it asks for. That is what makes a
+ chip exact — the words are the author's, and the answer is not left to be
+ re-derived from them. */
+ const capability = raw.capability == null ? null : String(raw.capability).trim();
+ if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
+ errors.push(
+ `Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
+ );
+ return null;
+ }
+ if (capability && capabilities.length && !capabilities.includes(capability)) {
+ errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`);
+ return null;
+ }
+
+ return {
+ label,
+ prompt: String(raw.prompt ?? raw.label).trim(),
+ capability: capability || null,
+ };
+}
+
+/**
+ * The whole `owliver:` block, normalized.
+ *
+ * Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what
+ * does not is reported: a definition with one bad response still registers its
+ * good ones, and the author is told why the other was refused.
+ */
+export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) {
+ const errors = [];
+
+ if (raw == null) return { owliver: NO_OWLIVER, errors };
+
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
+ return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] };
+ }
+
+ /* Declaring the block is the opt-in; `enabled: false` is how it is switched
+ off without deleting what was written. */
+ const enabled = raw.enabled !== false;
+
+ /* Capabilities may be listed, or left to be read off the responses — which is
+ what a definition that writes one response and nothing else means. */
+ const declared = Array.isArray(raw.capabilities)
+ ? raw.capabilities.map((c) => String(c).trim()).filter(Boolean)
+ : [];
+ const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses)
+ ? raw.responses
+ : {};
+ if (raw.responses != null && !Object.keys(responsesRaw).length) {
+ errors.push('`owliver.responses` must be a mapping of capability names to responses.');
+ }
+
+ const capabilities = [];
+ for (const capability of [...declared, ...Object.keys(responsesRaw)]) {
+ if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
+ errors.push(
+ `Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
+ );
+ continue;
+ }
+ if (!capabilities.includes(capability)) capabilities.push(capability);
+ }
+
+ /* Suggestions are read after capabilities, so one naming a capability can be
+ checked against what the skill actually offers. */
+ const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions;
+ let suggestions = [];
+ if (!Array.isArray(suggestionsRaw)) {
+ errors.push('`owliver.suggestions` must be a list.');
+ } else {
+ suggestions = suggestionsRaw
+ .map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index }))
+ .filter(Boolean);
+ }
+
+ /* Every capability resolves to a section — declared, or inherited from the
+ page UI this skill already configures. A capability that can name no
+ reading is refused: it would otherwise register as something Owliver
+ offers and then have nothing to answer with. */
+ const inherited = inheritedSection(ui);
+ const responses = {};
+ const seen = new Set();
+
+ for (const capability of capabilities) {
+ const declaredResponse = responsesRaw[capability];
+ if (declaredResponse != null
+ && (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) {
+ errors.push(`owliver.responses.${capability}: expected a mapping of options.`);
+ continue;
+ }
+
+ const response = declaredResponse || {};
+ /* `steps:` is what a flow reads like in a definition; `periods:` is what
+ the rest of the format calls the same list. */
+ const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods);
+ const source = response.data?.source ?? response.source ?? inherited?.source;
+
+ if (!source) {
+ errors.push(
+ `owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.`
+ );
+ continue;
+ }
+
+ const section = normalizeSection(
+ {
+ id: `${skillId || 'skill'}-${capability}`,
+ type: capability,
+ title: response.title ?? null,
+ description: response.description ?? null,
+ source,
+ periods: periods ?? [],
+ limit: response.limit ?? inherited?.limit ?? null,
+ /* Editing is a property of the capability, and is inherited from the
+ page section the same way the source is: a definition that made its
+ card adjustable meant the answer to be adjustable too, unless it
+ says otherwise. */
+ editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false,
+ },
+ {
+ errors,
+ seen,
+ where: `owliver.responses.${capability}`,
+ fallbackId: skillId,
+ placement: false,
+ types: [...SUPPORTED_SECTION_TYPES, 'summary'],
+ shapeFor: shapeOf,
+ }
+ );
+
+ if (!section) continue;
+
+ responses[capability] = {
+ ...section,
+ capability,
+ /* The shape the answer is drawn with, resolved once here so no consumer
+ has to know that `summary` is the one capability with no component. */
+ shape: shapeOf(capability),
+ title: section.title || skillName || null,
+ };
+ }
+
+ return {
+ owliver: {
+ enabled,
+ suggestions,
+ /* Only capabilities that resolved to a reading are offered. */
+ capabilities: capabilities.filter((c) => responses[c]),
+ responses,
+ },
+ errors,
+ };
+}
diff --git a/src/lib/skills/owliverResolver.js b/src/lib/skills/owliverResolver.js
new file mode 100644
index 0000000..9728e38
--- /dev/null
+++ b/src/lib/skills/owliverResolver.js
@@ -0,0 +1,416 @@
+import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
+import { skillsForContext } from './registry';
+import { resolveSkillData } from './dataResolver';
+import { resolvePosition } from './workforceFlow';
+
+/**
+ * The Owliver half of a skill definition, resolved.
+ *
+ * The page reads a definition through `SkillSurface`; this is the other reader.
+ * Given the page you are on and what you asked, it answers three questions and
+ * nothing else:
+ *
+ * 1. which registered skills apply here,
+ * 2. which of them you are asking for, and which capability,
+ * 3. what the answer is, read from the source the definition names.
+ *
+ * The rule that makes this an architecture rather than a lookup table: **no
+ * skill is named here.** There is no `if (skill.id === …)`, no prompt string
+ * matched against a constant, and no component per skill. A definition is
+ * matched by what it declares — its triggers, its suggestions, its name, its
+ * description — and answered by the shape it declares. A skill written after
+ * this file was last edited resolves exactly as well as one written before it.
+ */
+
+/* ── Which skills apply ─────────────────────────────────────────────────── */
+
+/**
+ * The Owliver-enabled skills registered for a page context.
+ *
+ * `skillsForContext` is the single answer to "what is attached here", already
+ * honouring `pages:`, `status:` and the account's switched-off list — so the
+ * page's sections and the panel's answers are filtered by one rule, and
+ * switching a skill off in Settings removes both at once.
+ */
+export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
+ return skillsForContext(contextId, disabled, customSources)
+ .filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
+}
+
+/* ── Suggestions ────────────────────────────────────────────────────────── */
+
+/** How many chips one skill may contribute, and how many all of them may. */
+const PER_SKILL = 3;
+const TOTAL = 4;
+
+/**
+ * The chips a page's skills offer.
+ *
+ * Capped deliberately. A workspace with six skills attached would otherwise
+ * bury the page's own suggestions under twenty of them, and a suggestion nobody
+ * can find is not a suggestion. A skill contributes its first few, and the set
+ * as a whole stays within what the composer can show without becoming a menu.
+ *
+ * A suggestion naming a capability the skill does not offer is dropped rather
+ * than shown and then refused.
+ */
+export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
+ const chips = [];
+
+ for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
+ /**
+ * Can this skill answer without asking a question back?
+ *
+ * A capability reading one position cannot say anything until it knows
+ * which — so on a page with nothing selected, clicking it produces a
+ * question rather than an answer. Those suggestions are still offered, but
+ * they are marked so the panel can rank them behind the ones that will
+ * actually answer. That is a property of the declared source, not of any
+ * particular skill: a definition added tomorrow reading one record is
+ * ranked the same way.
+ */
+ const needs = (capability) => skill.owliver.responses[capability]?.context || null;
+ const met = (need) => !need
+ || (need === 'positionId' && Boolean(context.position))
+ || (need === 'candidateId' && Boolean(context.candidate));
+
+ const offered = skill.owliver.suggestions
+ .filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
+ .slice(0, PER_SKILL)
+ /* Deliberately not carried as `capability`: a chip with that field is one
+ of the *page's* own answers and bypasses routing entirely. A skill's
+ chip is an ordinary question, and is resolved the same way the same
+ words typed by hand would be — one path, so a chip can never answer
+ something the typed form would not. */
+ .map((s) => {
+ const need = needs(s.capability || skill.owliver.capabilities[0]);
+ return {
+ label: s.label,
+ prompt: s.prompt,
+ skillId: skill.id,
+ skillCapability: s.capability || null,
+ /* True when clicking this would have to ask which record first. */
+ deferred: !met(need),
+ };
+ });
+ chips.push(...offered);
+ }
+
+ /* Answerable suggestions first, then the ones that would ask a question
+ back — stable within each group, so a definition's own order is kept. */
+ const ready = chips.filter((c) => !c.deferred);
+ const asking = chips.filter((c) => c.deferred);
+ return [...ready, ...asking].slice(0, TOTAL);
+}
+
+/* ── Matching ───────────────────────────────────────────────────────────── */
+
+const lower = (value) => String(value ?? '').toLowerCase();
+
+/** Words worth matching on — the ones that carry the subject of a question. */
+const STOP_WORDS = new Set([
+ 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
+ 'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
+ 'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
+]);
+
+const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
+
+/**
+ * Does a declared trigger match? `*` stands for anything in between, the same
+ * way the registry's own trigger matching reads it.
+ */
+function triggerMatches(trigger, question) {
+ if (!trigger.includes('*')) return question.includes(trigger);
+ const pattern = trigger
+ .split('*')
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('[\\s\\S]{0,40}?');
+ return new RegExp(pattern).test(question);
+}
+
+/**
+ * How strongly a question asks for this skill.
+ *
+ * Evidence is weighted by how deliberate it is. A suggestion the author wrote
+ * and the reader clicked is the strongest signal there is; a declared trigger
+ * is next; the skill's own name is next; and shared words with its description
+ * are the weakest — enough to break a tie, never enough to win on their own.
+ */
+function scoreSkill(skill, question) {
+ const q = lower(question);
+
+ const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
+ if (suggestion) return { score: 100, suggestion };
+
+ /**
+ * Deliberate evidence: the author said this skill answers this.
+ *
+ * A suggestion the reader is echoing, a declared trigger, or the skill's own
+ * name. One of these must hold before a definition may claim a question at
+ * all — see the floor below.
+ */
+ let deliberate = 0;
+ if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
+ if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
+ if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
+
+ /**
+ * The floor. Sharing a word with a description is not a claim.
+ *
+ * This is the bug that let "create a position" be answered by a candidate
+ * matching skill: its description happened to contain the word "position",
+ * which scored three points, and three points beat nothing. Corroboration
+ * was being treated as evidence.
+ *
+ * So description overlap can now only *break a tie* between definitions that
+ * already named the subject, and can never qualify one on its own. A skill
+ * about candidates cannot claim a question about creating a role however its
+ * description happens to be worded — which is the general property, not a
+ * fix aimed at these two definitions.
+ */
+ if (!deliberate) return { score: 0, suggestion: null };
+
+ const overlap = words(skill.description).filter((w) => q.includes(w)).length;
+ return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
+}
+
+/** The capability a question asks for, from the shape words it uses. */
+function scoreCapability(capability, question) {
+ const q = lower(question);
+ const definition = owliverCapabilityFor(capability);
+ if (!definition) return 0;
+
+ /* Longest matching term wins, so "as a flow" beats "flow" and a question
+ naming two shapes resolves to the more explicit one. */
+ return definition.terms.reduce(
+ (best, term) => (q.includes(term) && term.length > best ? term.length : best),
+ 0
+ );
+}
+
+/**
+ * The skill and capability a question resolves to, or null.
+ *
+ * Both halves have to hold: a question that names no registered skill is not
+ * this system's to answer, and a skill matched with no capability falls back to
+ * the first one its definition declares — which is what makes "Show hiring
+ * activity" work without the author writing a trigger per shape.
+ */
+export function matchOwliverSkill(question, skills = []) {
+ let best = null;
+
+ for (const skill of skills) {
+ const { score, suggestion } = scoreSkill(skill, question);
+ if (score <= 0) continue;
+ if (!best || score > best.score) best = { skill, score, suggestion };
+ }
+ if (!best) return null;
+
+ const { skill, suggestion, score } = best;
+ const available = skill.owliver.capabilities;
+
+ /**
+ * `exact` means the question *is* a suggestion this definition published —
+ * the reader clicked a chip, or typed its words. It is the strongest claim
+ * anything can have on a question, and callers weighing this match against
+ * another matcher need to be able to see that rather than infer it from a
+ * number.
+ */
+ const exact = Boolean(suggestion);
+
+ /* A chip that declared its capability has already answered this. */
+ if (suggestion?.capability && available.includes(suggestion.capability)) {
+ return { skill, capability: suggestion.capability, score, exact };
+ }
+
+ const asked = available
+ .map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
+ .filter((c) => c.weight > 0)
+ .sort((a, b) => b.weight - a.weight)[0];
+
+ return { skill, capability: asked?.capability || available[0], score, exact };
+}
+
+/* ── The record a response is about ─────────────────────────────────────── */
+
+/**
+ * The entity a source needs, resolved from the question and the page.
+ *
+ * Sources declare what they need — a position, a candidate, a draft, or
+ * nothing — and this is the one place that need is met. Three orders of
+ * evidence, most specific first:
+ *
+ * 1. the question named a record ("summarize hiring activity for Line Cook"),
+ * 2. the page has one open (the drawer, the form being filled in),
+ * 3. neither, and the answer has to ask.
+ *
+ * A source needing nothing resolves against the workspace and is always met.
+ *
+ * Naming the record wins over the page's selection deliberately: an admin who
+ * says which role they mean has said so, and answering about a different one
+ * because a drawer happened to be open would be worse than asking.
+ */
+export function resolveEntity(section, question, context = {}) {
+ const need = section.context;
+ if (!need) return { context, ok: true };
+
+ if (need === 'positionId') {
+ const named = resolvePosition(question, context.positions || [], null);
+ const position = named || context.position || null;
+ return position
+ ? { context: { ...context, position }, ok: true }
+ : { ok: false, need: 'position' };
+ }
+
+ if (need === 'candidateId') {
+ const q = lower(question);
+ const named = (context.applications || []).find(
+ (a) => a.applicant_name && q.includes(lower(a.applicant_name))
+ );
+ const candidate = named || context.candidate || null;
+ return candidate
+ ? { context: { ...context, candidate }, ok: true }
+ : { ok: false, need: 'candidate' };
+ }
+
+ return { context, ok: true };
+}
+
+/* ── The answer ─────────────────────────────────────────────────────────── */
+
+/** The rows a reading offers, whatever shape its source returns them in. */
+const rowsOf = (data) => data?.steps || data?.items || [];
+
+/**
+ * One row, read back as a line.
+ *
+ * A reading carries a figure, a description of it, or both, depending on the
+ * source — so the line is assembled from what is there rather than from a fixed
+ * template, and a row with a description that already states its figure
+ * ("2 applications") does not repeat it.
+ */
+function rowLine(row) {
+ const label = String(row.label || row.title || row.id || '').trim();
+ if (!label) return null;
+
+ const value = row.value == null || row.value === '' ? null : String(row.value);
+ const detail = row.detail ? String(row.detail) : null;
+
+ const tail = detail
+ ? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
+ : value;
+
+ return tail ? `${label} — ${tail}` : label;
+}
+
+/**
+ * A summary, built from whatever the source returned.
+ *
+ * Generic on purpose: it reads rows and states them. Nothing here knows what a
+ * period is, what a stage is, or which skill asked — which is precisely why a
+ * definition written tomorrow gets a summary without this function changing.
+ */
+export function summaryLines(data) {
+ return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
+}
+
+/** The label a reading is introduced by — the definition's words, then the source's. */
+export const responseTitle = (skill, section) =>
+ section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
+
+/**
+ * Everything an answer needs, resolved: the section, the data, and whether the
+ * page could supply the record the source required.
+ *
+ * Returns `{ skill, capability, section, data, missing }`. `missing` names what
+ * the caller must ask for; when it is null the reading is real and complete.
+ */
+export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
+ const section = skill.owliver.responses[capability];
+ if (!section) return null;
+
+ const entity = resolveEntity(section, question, context);
+ if (!entity.ok) {
+ return { skill, capability, section, data: null, missing: entity.need };
+ }
+
+ /* The same resolver the page's own sections go through, on the same
+ collections — so the panel and the card beside it cannot report different
+ figures for the same position. */
+ const data = resolveSkillData(section, entity.context, now);
+ return { skill, capability, section, data, missing: null, context: entity.context };
+}
+
+/**
+ * A reading, reduced to what a drawn section reads.
+ *
+ * A reply is kept in the thread, so it is stored: the records a source counted
+ * are the evidence behind a figure, not part of the answer, and writing every
+ * application into session storage to draw one bar would be paying for the
+ * whole dataset per turn. Nothing the renderers use is dropped.
+ */
+export function presentable(data) {
+ if (!data) return data;
+ const strip = ({ records, ...row }) => row;
+
+ return {
+ ...data,
+ ...(data.steps ? { steps: data.steps.map(strip) } : null),
+ ...(data.items ? { items: data.items.map(strip) } : null),
+ };
+}
+
+/** Every capability the product understands, for the editor and the previews. */
+export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
+ ({ id, label, summary }) => ({ id, label, summary })
+);
+
+/* ── Suggestions for a record that has just appeared ────────────────────── */
+
+/**
+ * What can now be asked about a record the conversation just produced.
+ *
+ * Creating a position is the moment "who could do this?" becomes worth asking,
+ * and the panel is the only thing that knows a position now exists. Rather than
+ * naming a skill to offer — which would put a candidate-matching feature inside
+ * the position-creation flow — this asks the registry the general question: of
+ * the skills attached to this page, which declare a capability whose reading is
+ * *about one position*? Those are exactly the ones that can say something about
+ * the record just made.
+ *
+ * The record's own title is appended to each prompt, so the answer resolves
+ * against it directly and the reader is never asked to pick from a list that
+ * includes the position they are looking at. Nothing is named here: a skill
+ * added tomorrow that reads a position is offered on the same terms.
+ */
+export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
+ if (!position?.title) return [];
+
+ const chips = [];
+
+ for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
+ /* Only capabilities that read one position — a workspace-wide reading has
+ nothing to do with the record that was just created. */
+ const scoped = skill.owliver.capabilities.filter(
+ (capability) => skill.owliver.responses[capability]?.context === 'positionId'
+ );
+ if (!scoped.length) continue;
+
+ const offered = skill.owliver.suggestions
+ .filter((s) => !s.capability || scoped.includes(s.capability))
+ .slice(0, 1)
+ .map((s) => ({
+ label: s.label,
+ /* Named, so the reading resolves against this position rather than
+ asking which one. */
+ prompt: `${s.prompt} for ${position.title}`,
+ skillId: skill.id,
+ skillCapability: s.capability || null,
+ }));
+
+ chips.push(...offered);
+ }
+
+ return chips.slice(0, 2);
+}
diff --git a/src/lib/skills/positionFlow.js b/src/lib/skills/positionFlow.js
index 31e04aa..05237f4 100644
--- a/src/lib/skills/positionFlow.js
+++ b/src/lib/skills/positionFlow.js
@@ -56,6 +56,26 @@ const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere els
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
+ /**
+ * The client this role is being staffed for.
+ *
+ * A "client" is not a record of its own in this product — it is the `company`
+ * on the position, which is the field the Create Position form writes, the
+ * Positions card leads with, and Hired History reports against. So the
+ * conversation collects it into the same field rather than into a store of
+ * its own, and asking Owliver to create a client starts here.
+ */
+ company: {
+ label: 'Company',
+ settled: (draft) => Boolean(String(draft.company || '').trim()),
+ retry: 'Type the client or company name — "Fairmont San Jose".',
+ parse: (answer) => {
+ const value = cleanPhrase(answer, 60);
+ return value && value.length > 1 ? { company: titleCase(value) } : null;
+ },
+ summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
+ },
+
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
@@ -233,10 +253,13 @@ function review(flow, steps) {
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
- note('Nothing is saved until you choose Create position.')
+ note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
),
+ /* The two the form offers, in the same words and the same order, so the
+ conversation and the page commit a position the same two ways. */
followUp: [
- { label: 'Create position', prompt: 'Create position' },
+ { label: 'Save as Draft', prompt: 'Save as draft' },
+ { label: 'Publish Job Posting', prompt: 'Publish job posting' },
{ label: 'Change details', prompt: 'Change details' },
],
};
@@ -305,9 +328,15 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
- if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
+ /* Saving unpublished. The same write, with the status the form's own
+ "Save as Draft" button sets — one create path, two statuses. */
+ if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
- return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
+ return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
+ }
+ if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
+ if (missingRequired(flow, steps).length) return review(flow, steps);
+ return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
@@ -322,7 +351,7 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
- note('Choose Create position, or tell me what to change.')
+ note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
),
};
}
@@ -430,14 +459,19 @@ function applyStatement(flow, steps, said, roles) {
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
+ const isDraft = position.status === 'draft';
+
return doc(
- text('Position created successfully.'),
+ text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'),
list([
+ position.company,
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
- note('It is on the Positions list now — applications will start appearing against it.')
+ note(isDraft
+ ? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
+ : 'It is on the Positions list now — applications will start appearing against it.')
);
}
@@ -449,7 +483,20 @@ export function positionFailedReply() {
);
}
-/** What the panel offers after a position is created. */
-export const createdFollowUp = (position) => [
- { label: 'View position', route: `/admin/positions/${position.id}` },
-];
+/**
+ * What the panel offers after a position is created.
+ *
+ * A published role has an obvious next question — who can fill it — so it is
+ * offered here rather than left to be typed. The chip carries the position's
+ * own title and the wording the workforce engine already answers, so it is an
+ * ordinary question resolved by the path that was already there: no handler,
+ * no navigation, and the same answer as asking it by hand.
+ */
+export const createdFollowUp = (position) => (position.status === 'draft'
+ /* A draft is unfinished, so the way on is the form that finishes it — the
+ same route the Positions card's Continue uses. */
+ ? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }]
+ : [
+ { label: 'View position', route: `/admin/positions/${position.id}` },
+ { label: 'Match candidates', prompt: `Who matches ${position.title}?` },
+ ]);
diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js
index 1efbe71..790c12d 100644
--- a/src/lib/skills/registry.js
+++ b/src/lib/skills/registry.js
@@ -1,4 +1,8 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
+import { parseYaml } from './yaml';
+import { normalizeSkillUi } from './uiConfig';
+import { normalizeSkillOwliver } from './owliverConfig';
+import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
/**
* Owliver skill registry.
@@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
- * Frontmatter, parsed to the subset the format actually uses: `key: value` and
- * `key:` followed by an indented `- item` list.
+ * Frontmatter, as data.
*
- * Deliberately not a YAML library — the app has none, this needs no dependency,
- * and a skill file that reaches for anchors or nested maps has outgrown being a
- * declaration anyway.
+ * Skills grew declarative UI configuration, which is nested, so this reads the
+ * YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
+ * The old shapes are a strict subset of the new one — a definition written for
+ * the previous parser parses identically here.
+ *
+ * A file whose frontmatter cannot be read raises rather than registering a
+ * half-understood definition; `parseSkill` decides what to do with that.
*/
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
if (!match) return { data: {}, body: raw };
- const data = {};
- let listKey = null;
-
- for (const line of match[1].split(/\r?\n/)) {
- if (!line.trim()) continue;
-
- const item = /^\s*-\s+(.*)$/.exec(line);
- if (item && listKey) {
- data[listKey].push(item[1].trim());
- continue;
- }
-
- const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
- if (!pair) continue;
-
- const [, key, value] = pair;
- if (value === '') {
- listKey = key;
- data[key] = [];
- } else {
- listKey = null;
- data[key] = value.trim();
- }
- }
-
- return { data, body: raw.slice(match[0].length).trim() };
+ const data = parseYaml(match[1]);
+ return {
+ data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
+ body: raw.slice(match[0].length).trim(),
+ };
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
@@ -131,10 +117,14 @@ function sectionLevels(body) {
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
- * Derived from the placement table rather than written down again, so a route
- * added there is addressable by a skill without touching this file.
+ * The surface table answers first, because a surface already states its own
+ * route and its key is not always the path tail: `/admin/positions/new` is
+ * `create-position`, not `positions/new`. Falling back to the tail keeps every
+ * route that has no declared surface behaving exactly as it did.
*/
export function pageKeyForRoute(route) {
+ const surface = surfaceForRoute(route);
+ if (surface) return surface.id;
const tail = route.replace(/^\/admin\/?/, '');
return tail === '' ? 'control-center' : tail;
}
@@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route,
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
+/**
+ * The two management surfaces a definition can belong to.
+ *
+ * `ui` extends a KROW page; `owliver` extends the assistant. They share the
+ * parser, the registry, the validator, the persistence and the data resolver —
+ * only the authoring and management experience is separate, which is what this
+ * classification serves.
+ */
+export const SKILL_FACETS = ['ui', 'owliver'];
+
+/**
+ * Which of them a definition belongs to.
+ *
+ * Declared, never configured: a `ui:` block is a page extension, and an
+ * `owliver:` block, triggers, actions, a prompt or a conversation is an
+ * assistant extension. A definition that declares neither is an assistant
+ * skill — that is what every definition written before the split was, and
+ * reading it any other way would drop it out of both lists.
+ *
+ * Workforce paths are neither. They define a capability the workforce holds
+ * and are managed in Skill Development, so they carry no facet and appear on
+ * neither list.
+ */
+export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
+ if (kind === 'workforce') return [];
+
+ const extendsPage = Object.keys(ui).length > 0;
+
+ /**
+ * Behaviour Owliver actually gains: something to answer with, something to
+ * open, or questions to ask.
+ *
+ * Triggers alone are deliberately not on this list. A trigger is a way of
+ * being *named*, and a page-drawing definition that names itself is still a
+ * page-drawing definition — listing it as an Owliver skill would offer an
+ * author a capability list it never declared. A definition with no `ui:` is
+ * the other way round: triggers are all it has, and they are what it does.
+ */
+ const teachesOwliver = Boolean(
+ data.owliver
+ || (Array.isArray(data.actions) && data.actions.length)
+ || data.prompt
+ || conversation.length
+ || owliver?.capabilities?.length
+ || owliver?.suggestions?.length
+ );
+
+ return [
+ extendsPage ? 'ui' : null,
+ teachesOwliver || !extendsPage ? 'owliver' : null,
+ ].filter(Boolean);
+}
+
/**
* One Markdown definition → one skill.
*
@@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
*/
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
+ /* The declarative UI this definition contributes, checked against the
+ closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
+ exactly what it was before this existed. */
+ const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
+ declaredPages: pages,
+ skillId: id,
+ });
+
+ /* The same definition's second consumer. `owliver:` declares what can be
+ asked for in the panel, reading the source the page section already
+ names — so one file answers "what does this page show" and "what can
+ Owliver be asked here" without either being written twice. A definition
+ with no `owliver:` block is exactly what it was before this existed. */
+ const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
+ ui,
+ skillId: id,
+ skillName: data.name || '',
+ });
+
+ /* The questions this skill asks, when it collects its input in the chat
+ rather than by opening something. */
+ const conversation = sectionSteps(body, 'Conversation');
+
return {
id,
kind,
+ ui,
+ uiErrors,
+ owliver,
+ owliverErrors,
+ /**
+ * What this definition extends, derived from what it declares.
+ *
+ * Two things wear the same format and are managed as different lists: a
+ * definition with a `ui:` block extends a *page*, and one that teaches
+ * Owliver — an `owliver:` block, triggers, actions, a conversation —
+ * extends the *assistant*. Reading that off the declaration rather than
+ * off a `type:` field is what makes the split free: every definition
+ * already written classifies itself, nothing stored has to be migrated,
+ * and a definition doing both is listed in both places rather than
+ * losing half of itself to a category.
+ */
+ facets: skillFacets({ data, kind, ui, owliver, conversation }),
name: data.name || 'Untitled skill',
description: data.description || '',
status: data.status === 'inactive' ? 'inactive' : 'active',
@@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
? data.triggers
: [data.name].filter(Boolean)
).map((t) => String(t).toLowerCase()),
+ /**
+ * Whether those triggers were *claimed* or merely inherited.
+ *
+ * The fallback above is convenient and, until this field existed,
+ * indistinguishable from the real thing — so a definition that only draws
+ * a card was silently claiming its own name as a phrase Owliver answers
+ * to, and could take a question from a definition written to answer it.
+ * Keeping the distinction lets the matcher weigh a claim differently from
+ * a default without changing what `triggers` contains.
+ */
+ declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
prompt: data.prompt || null,
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
- /* The questions this skill asks, when it collects its input in the chat
- rather than by opening something. */
- conversation: sectionSteps(body, 'Conversation'),
+ conversation,
path,
body,
custom,
@@ -255,23 +347,41 @@ export function allSkills(customSources = []) {
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}
-/** Validates a definition before it is stored. Returns an error string or null. */
+/**
+ * Validates a definition before it is stored. Returns an error string or null.
+ *
+ * Frontmatter first, then the declarative UI — an author is told the first
+ * thing that is wrong, in the order they would fix it.
+ */
export function validateSkillSource(raw) {
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
let skill;
try {
skill = parseSkill(raw, { custom: true });
- } catch {
- return 'That definition could not be parsed.';
+ } catch (error) {
+ /* The YAML subset reports the line it failed on; that is far more useful
+ than "could not be parsed". */
+ return `That definition could not be parsed. ${error.message || ''}`.trim();
}
if (!skill.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
if (!skill.name) return 'The frontmatter needs a `name`.';
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
- const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
+
+ const unknown = skill.pages.filter((p) => !surfaceFor(p));
if (unknown.length) {
- return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
+ return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
}
+
+ /* A UI block that names something the product does not offer is refused
+ outright rather than registered with the offending section dropped. */
+ if (skill.uiErrors?.length) return skill.uiErrors[0];
+
+ /* Same rule for the panel half of the definition: a capability, source or
+ step the product cannot honour is refused now rather than registered as
+ something Owliver offers and then cannot answer. */
+ if (skill.owliverErrors?.length) return skill.owliverErrors[0];
+
return null;
}
@@ -280,7 +390,10 @@ export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
/** Context ids a skill applies to, resolved through the placement table. */
export function contextIdsForSkill(skill) {
- return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
+ return skill.pages
+ .map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
+ || ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
+ .filter(Boolean);
}
/**
@@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) {
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
if (!pageId) return [];
+ const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(
(s) => s.status === 'active'
&& !disabled.includes(s.id)
- && s.pages.includes(pageId)
+ && s.pages.some((p) => (canonicalPage(p) || p) === wanted)
&& (!kind || s.kind === kind)
);
}
@@ -338,10 +452,40 @@ function triggerMatches(trigger, question) {
return new RegExp(pattern).test(question);
}
-/** The first skill on this page whose triggers match the question. */
+/**
+ * The first skill on this page whose triggers match the question.
+ *
+ * A definition has to be *addressable by the assistant* before its triggers
+ * count, and there are two ways to be: teach Owliver something — an `owliver:`
+ * block, an action, a conversation — or explicitly claim a phrase with
+ * `triggers:`. A definition that does neither is a page extension that happens
+ * to have a name, and matching it here means answering a question with a
+ * restatement of a card's description.
+ *
+ * That was live: `hiring-activity` draws a flow on the Positions page, declares
+ * no triggers, and inherited "hiring activity" from its own name — enough to
+ * take "show hiring activity" from the definition written to answer it.
+ *
+ * Both conditions are read off what the definition declares, so a skill written
+ * tomorrow is admitted or excluded by the same rule, and nothing that claimed a
+ * phrase loses it.
+ */
+const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
+
export function matchSkill(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
- (s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
+ (s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
}
+
+/**
+ * The definitions belonging to one management surface.
+ *
+ * The single answer to "what belongs on the UI Skills list" and "what belongs
+ * on the Owliver Skills list". Both lists come from `allSkills` — one registry,
+ * two readings of it — so a definition cannot exist on one list and be unknown
+ * to the other system.
+ */
+export const skillsWithFacet = (skills = [], facet) =>
+ skills.filter((s) => s.facets?.includes(facet));
diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js
new file mode 100644
index 0000000..c77e98b
--- /dev/null
+++ b/src/lib/skills/surfaces.js
@@ -0,0 +1,478 @@
+/**
+ * What a skill definition is allowed to say about the product.
+ *
+ * A skill can extend a KROW page: name a surface, declare a section, and the
+ * page renders it. That only stays safe — and only stays a *product* rather
+ * than a scripting host — because the vocabulary is closed. Everything a
+ * definition may name is in this file: the surfaces, the placements on each
+ * surface, the component types, and the data sources.
+ *
+ * The rule that makes it safe: **nothing here is code, and nothing here is
+ * looked up dynamically from the file.** A definition names a key; this module
+ * says whether that key exists; the renderer maps it to a component the app
+ * already ships. A definition that names something absent is rejected with a
+ * message, never rendered as an unknown thing and never executed.
+ */
+
+/**
+ * The surfaces a skill can extend.
+ *
+ * `aliases` keep the page keys the existing skills already use — `hired`,
+ * `university` — working under the names the product now shows, so the eight
+ * definitions on disk did not have to be rewritten to gain this feature.
+ */
+export const SKILL_SURFACES = [
+ {
+ id: 'control-center',
+ label: 'Control Center',
+ route: '/admin',
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'positions',
+ label: 'Positions',
+ route: '/admin/positions',
+ /* Mounted in three places, because Positions is three experiences: the page
+ that lists the roles, the card for each one, and the drawer "View
+ position" opens.
+
+ `after-position-list-summary` and `after-position-list` are the *page*:
+ they render once, above and below the grid, with no position in context.
+ `after-position-card` renders inside each card. The rest render inside
+ the drawer and on the full position page, where one position is being
+ read. Every one of these is mounted — a placement the vocabulary offers
+ and no page provides is a definition that validates and then silently
+ does nothing. */
+ placements: [
+ 'after-position-list-summary',
+ 'after-position-list',
+ 'after-header',
+ 'after-position-card',
+ 'after-position-summary',
+ 'before-candidates',
+ 'after-candidates',
+ 'before-footer',
+ ],
+ },
+ {
+ /* The authoring form, which is a surface in its own right: what a skill has
+ to say there is about the position being specified, not about the ones
+ that already exist. Its placements follow the form's own three parts, so
+ a definition can sit beside the field group it is about. */
+ id: 'create-position',
+ label: 'Create Position',
+ route: '/admin/positions/new',
+ aliases: ['new-position'],
+ placements: [
+ 'after-header',
+ 'after-job-description',
+ 'after-vetting-weights',
+ 'before-footer',
+ ],
+ },
+ {
+ id: 'candidates',
+ label: 'Candidates',
+ route: '/admin/candidates',
+ placements: ['after-header', 'after-candidate-summary', 'before-footer'],
+ },
+ {
+ id: 'hired-history',
+ label: 'Hired History',
+ route: '/admin/hired',
+ aliases: ['hired'],
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'talent-pool',
+ label: 'Talent Pool',
+ route: '/admin/talent-pool',
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'krow-forge',
+ label: 'KROW Forge',
+ route: '/admin/university',
+ aliases: ['university', 'forge'],
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'analytics',
+ label: 'Analytics',
+ route: '/admin/analytics',
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'activity',
+ label: 'Activity',
+ route: '/admin/activity',
+ placements: ['after-header', 'before-footer'],
+ },
+ /* Not in the eight product surfaces, but skills already attach to it and the
+ account page reads them. Kept so nothing that works today stops working. */
+ {
+ id: 'profile',
+ label: 'Profile',
+ route: '/admin/profile',
+ placements: ['after-header', 'before-footer'],
+ },
+ {
+ id: 'candidates-analysis',
+ label: 'Candidate Analysis',
+ route: '/admin/candidates-analysis',
+ placements: ['after-header', 'before-footer'],
+ },
+];
+
+const BY_KEY = new Map();
+for (const surface of SKILL_SURFACES) {
+ BY_KEY.set(surface.id, surface);
+ for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
+}
+
+/** Every name a definition may use for a surface, for error messages. */
+export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
+
+/** The surface a declared page name refers to, or null. */
+export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
+
+/** The canonical id for a declared page name — `hired` → `hired-history`. */
+export const canonicalPage = (page) => surfaceFor(page)?.id || null;
+
+/**
+ * The surface an Admin route belongs to — `/admin/positions/new` →
+ * `create-position`.
+ *
+ * The surfaces already carry their routes, so this reads the answer off the
+ * table rather than deriving a page key from the path a second time. That
+ * matters for the surfaces whose key is not their path tail: without it,
+ * `/admin/positions/new` would key as `positions/new`, which is a page nothing
+ * declares and no definition could attach to.
+ */
+export const surfaceForRoute = (route) =>
+ SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
+
+/**
+ * The section types a definition may ask for.
+ *
+ * Each entry names a component the application already ships. A type is a key
+ * in this table and nothing else: there is no path from a definition to a
+ * component that is not listed here, which is what stops `type:` from being an
+ * import statement in disguise.
+ */
+export const SECTION_TYPES = [
+ { id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
+ { id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
+ { id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
+ { id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
+ { id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
+ { id: 'table', label: 'Table', summary: 'Rows and columns.' },
+ { id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
+ { id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
+ /* Weighted criteria that share a budget. Distinct from `progress`, which is
+ bars against an independent maximum: these bars are shares of one total,
+ and the total is a fact about the set rather than about any one row. */
+ { id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
+];
+
+export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
+
+/**
+ * What a definition may ask *Owliver* to do with the same reading.
+ *
+ * A skill declares `ui:` for the page and `owliver:` for the panel, and both
+ * name the same data source. A capability is the second half of that: the shape
+ * the answer takes when it is asked for in conversation rather than rendered on
+ * the page.
+ *
+ * `shape` is the section type the answer is drawn with, so `flow` in a chat
+ * reply is the *same* component the page renders — there is one flow renderer,
+ * not one per consumer. `summary` has no shape because a summary is prose: the
+ * figures are read back as sentences rather than drawn.
+ *
+ * `terms` are how a question is recognised as asking for this shape. They are
+ * deliberately about the *shape* and never about a subject: "as a flow" belongs
+ * here, "hiring activity" belongs in a definition's `triggers`. That split is
+ * what keeps this table closed while the skills stay open.
+ */
+export const OWLIVER_CAPABILITIES = [
+ {
+ id: 'summary',
+ label: 'Summary',
+ shape: null,
+ summary: 'Reads the figures back as sentences.',
+ terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
+ 'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
+ 'what is the', 'how is'],
+ },
+ {
+ id: 'flow',
+ label: 'Flow',
+ shape: 'flow',
+ summary: 'Draws the stages or periods as a sequence.',
+ terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
+ 'visualise', 'visualize', 'step by step'],
+ },
+ {
+ id: 'stats',
+ label: 'Stats',
+ shape: 'stats',
+ summary: 'A row of counted figures.',
+ terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
+ },
+ {
+ id: 'list',
+ label: 'List',
+ shape: 'list',
+ summary: 'A ranked or plain list of records.',
+ terms: ['list', 'who are', 'which ones', 'show me the records'],
+ },
+ {
+ id: 'table',
+ label: 'Table',
+ shape: 'table',
+ summary: 'Rows and columns.',
+ terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
+ },
+ {
+ id: 'timeline',
+ label: 'Timeline',
+ shape: 'timeline',
+ summary: 'Dated events, most recent first.',
+ terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
+ },
+ {
+ id: 'progress',
+ label: 'Progress',
+ shape: 'progress',
+ summary: 'Bars against a total.',
+ terms: ['progress', 'bars', 'completion', 'how far'],
+ },
+ {
+ id: 'weights',
+ label: 'Weights',
+ shape: 'weights',
+ summary: 'The weighted criteria, adjustable when the page accepts the write.',
+ terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
+ 'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
+ 'criteria'],
+ },
+ {
+ id: 'insight',
+ label: 'Insight',
+ shape: 'insight',
+ summary: 'One finding, stated plainly.',
+ terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
+ },
+ {
+ id: 'card',
+ label: 'Card',
+ shape: 'card',
+ summary: 'A titled panel of figures.',
+ terms: ['card', 'panel', 'at a glance'],
+ },
+];
+
+export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
+
+/** The capability a declared name refers to, or null. */
+export const owliverCapabilityFor = (id) =>
+ OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
+
+/** `flow` → `Flow`. */
+export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
+
+/**
+ * The data a section may ask for.
+ *
+ * Each source is a named reading of data the application already holds, and
+ * `context` says what a page must know for the reading to be possible — a
+ * position id, a candidate id, or nothing. A source is resolved by
+ * `dataResolver.js`; a definition cannot reach a store directly, cannot write,
+ * and cannot name a field that is not offered here.
+ */
+export const DATA_SOURCES = [
+ {
+ id: 'position.activity',
+ label: 'Position activity',
+ context: 'positionId',
+ summary: 'Applications to this position, counted over time.',
+ shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
+ },
+ {
+ id: 'position.pipeline',
+ label: 'Position pipeline',
+ context: 'positionId',
+ summary: 'Applied → screened → shortlisted → interviewed → hired.',
+ shapes: ['flow', 'stats', 'progress', 'table', 'card'],
+ },
+ {
+ id: 'position.candidates',
+ label: 'Position candidates',
+ context: 'positionId',
+ summary: 'Candidates matched to this position, best first.',
+ shapes: ['list', 'table', 'stats', 'card'],
+ },
+ {
+ /**
+ * Who could actually do this work, ranked.
+ *
+ * Distinct from `position.candidates`, which lists the people who *applied
+ * here*. This one reads the whole candidate pool against what the position
+ * states — so a role published a minute ago, with no applications at all,
+ * still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
+ * same engine the panel's workforce conversation and the position page use;
+ * nothing about matching is decided in the resolver.
+ */
+ id: 'position.matches',
+ label: 'Position candidate matches',
+ context: 'positionId',
+ summary: 'The candidate pool scored against this position, best first.',
+ shapes: ['list', 'table', 'stats', 'card', 'insight'],
+ },
+ {
+ id: 'position.requirements',
+ label: 'Position requirements',
+ context: 'positionId',
+ summary: 'What this position states it needs.',
+ shapes: ['list', 'table', 'card'],
+ },
+ {
+ id: 'candidate.readiness',
+ label: 'Candidate readiness',
+ context: 'candidateId',
+ summary: 'Screening dimensions for one candidate.',
+ shapes: ['progress', 'stats', 'list', 'table', 'card'],
+ },
+ {
+ id: 'candidate.activity',
+ label: 'Candidate activity',
+ context: 'candidateId',
+ summary: 'What has happened on this candidate’s record.',
+ shapes: ['timeline', 'list', 'table', 'card'],
+ },
+ {
+ id: 'candidates.pipeline',
+ label: 'Candidate pipeline',
+ context: null,
+ summary: 'Every candidate, counted by stage.',
+ shapes: ['flow', 'stats', 'progress', 'table', 'card'],
+ },
+ {
+ id: 'candidates.activity',
+ label: 'Candidate activity',
+ context: null,
+ summary: 'Applications across the workspace, counted over time.',
+ shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
+ },
+ {
+ id: 'positions.demand',
+ label: 'Position demand',
+ context: null,
+ summary: 'Open positions and what they still need.',
+ shapes: ['list', 'table', 'stats', 'card'],
+ },
+ {
+ id: 'workforce.training',
+ label: 'Workforce training',
+ context: null,
+ summary: 'Training paths and progress against them.',
+ shapes: ['progress', 'list', 'stats', 'table', 'card'],
+ },
+ {
+ /* The vetting weights a position is being specified with. `positionId`
+ context, but the "position" on Create Position is the draft in the form
+ rather than a saved record — which is the point: the resolver reads the
+ same field either way, so one source serves the form and the position it
+ becomes. */
+ id: 'position.vetting',
+ label: 'Position vetting weights',
+ context: 'positionId',
+ summary: 'How this position weights each screening criterion.',
+ shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
+ /**
+ * This reading can be written back.
+ *
+ * `writable` is what lets a definition declare `editable: true` and get
+ * controls instead of a read-out. It is a property of the *source*, not of
+ * the definition — a skill cannot make a reading writable by asking, and a
+ * source with no page willing to accept the write simply renders read-only.
+ * That keeps the closed vocabulary closed in both directions.
+ */
+ writable: true,
+ writeSummary: 'Sets the screening weights on the position being specified.',
+ },
+ {
+ id: 'hires.recent',
+ label: 'Recent hires',
+ context: null,
+ summary: 'Who was hired, for which role, and when.',
+ shapes: ['list', 'table', 'timeline', 'stats', 'card'],
+ },
+ {
+ id: 'hires.performance',
+ label: 'Hiring performance',
+ context: null,
+ summary: 'Hires, time-to-hire, quality and conversion, counted together.',
+ shapes: ['stats', 'card', 'table', 'flow', 'insight'],
+ },
+ {
+ id: 'activity.events',
+ label: 'Workspace activity',
+ context: null,
+ summary: 'What has happened across the workspace, most recent first.',
+ shapes: ['timeline', 'list', 'table', 'stats', 'card'],
+ },
+];
+
+export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
+
+export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
+
+/**
+ * Can this reading be written back?
+ *
+ * The one question `editable:` is checked against. A page opts in by publishing
+ * a handler for the source (see `usePublishPageActions`); a definition opts in
+ * by declaring `editable: true`. Both have to hold before a control is drawn,
+ * so neither the author nor the page can enable editing on its own.
+ */
+export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
+
+/**
+ * The periods a time-based section may ask for.
+ *
+ * Only the ones the data layer can actually compute from record timestamps.
+ * A period is a window over `created_date`, resolved at read time against the
+ * current date — never a stored figure and never a hard-coded date.
+ */
+export const PERIODS = [
+ { id: 'today', label: 'Today' },
+ { id: 'yesterday', label: 'Yesterday' },
+ { id: 'last-7-days', label: 'Last 7 days' },
+ { id: 'last-week', label: 'Last week' },
+ { id: 'this-month', label: 'This month' },
+ { id: 'previous-month', label: 'Previous month' },
+];
+
+export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
+
+export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
+
+/* ── Human labels ───────────────────────────────────────────────────────────
+ The vocabulary is written in kebab-case because it is configuration; it is
+ read by people, so every id has a label. One place, so the preview, the
+ rendered section and any future surface all say the same words. */
+
+/** `flow` → `Flow`. */
+export const sectionTypeLabel = (id) =>
+ SECTION_TYPES.find((t) => t.id === id)?.label || id;
+
+/** `position.activity` → `Position activity`. */
+export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
+
+/** `after-position-summary` → `After position summary`. */
+export const placementLabel = (id) => {
+ const words = String(id || '').replace(/-/g, ' ').trim();
+ return words ? words[0].toUpperCase() + words.slice(1) : id;
+};
diff --git a/src/lib/skills/uiConfig.js b/src/lib/skills/uiConfig.js
new file mode 100644
index 0000000..9098331
--- /dev/null
+++ b/src/lib/skills/uiConfig.js
@@ -0,0 +1,293 @@
+import {
+ SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
+ SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
+} from './surfaces';
+
+/**
+ * The `ui:` block of a skill definition, checked and normalized.
+ *
+ * A definition declares what it wants; this decides whether the product can
+ * honour it, and turns a loose YAML shape into one predictable record. Two
+ * things follow from doing it here rather than in the renderer:
+ *
+ * - **Nothing unknown reaches a component.** Every page, placement, type,
+ * data source and period is checked against the closed vocabulary in
+ * `surfaces.js`. An unrecognised value is an error with a message naming
+ * it, not a silently dropped key and not a rendered blank.
+ * - **Only listed keys survive.** The normalized section carries exactly the
+ * fields the renderers read. Anything else an author writes is ignored
+ * rather than passed through, so no property can arrive at React that this
+ * module did not put there.
+ */
+
+/**
+ * A section as the renderers receive it. Nothing else is carried.
+ *
+ * Used by both consumers of a definition. The page passes a `page`, so the
+ * section is checked against that surface's placements; Owliver passes
+ * `placement: false`, because a chat reply has no placement to sit at — the
+ * rest of the checks, and the record that comes out, are identical. That is
+ * deliberate: it is what makes a flow drawn in the panel the same section as
+ * the flow drawn on the page rather than a parallel shape that resembles it.
+ *
+ * `types` narrows what `type:` may be. The page offers the components it can
+ * mount; Owliver offers those plus the shapes that are only answers.
+ */
+export function normalizeSection(raw, {
+ page, errors, seen, fallbackId = '', where: label = null,
+ placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES,
+ shapeFor = (type) => type,
+}) {
+ const where = label || `ui.${page}`;
+ /* Where an error points. A page section is addressed by the id it was given;
+ a capability response is already addressed by the capability it answers, so
+ appending a generated section id there would name something the author
+ never wrote. */
+ const at = label ? where : null;
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ errors.push(`${where}: each section must be a mapping of options.`);
+ return null;
+ }
+
+ /* An id is how a section is keyed and de-duplicated, not something an author
+ should have to invent for a definition that declares exactly one. Falls
+ back to the title, then to the skill's own id. */
+ const slug = (value) => String(value || '')
+ .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
+ if (!id) {
+ errors.push(`${where}: a section needs an \`id\`.`);
+ return null;
+ }
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
+ errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`);
+ return null;
+ }
+ if (seen.has(id)) {
+ errors.push(`${where}: two sections share the id \`${id}\`.`);
+ return null;
+ }
+ seen.add(id);
+
+ const type = String(raw.type || '').trim();
+ if (!type) {
+ errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
+ return null;
+ }
+ if (!types.includes(type)) {
+ errors.push(
+ `Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.`
+ );
+ return null;
+ }
+
+ /* `position:` is what the format calls it; `placement:` is accepted because
+ it is the word the rest of the system uses. A section that is an answer
+ rather than a panel has nowhere to be placed, and says so with `null`. */
+ let placement = null;
+ if (wantPlacement) {
+ const surface = surfaceFor(page);
+ const declaredPlacement = String(raw.position || raw.placement || '').trim();
+ placement = declaredPlacement || surface.placements[0];
+ if (!surface.placements.includes(placement)) {
+ errors.push(
+ `Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
+ );
+ return null;
+ }
+ }
+
+ /* `data.source:` and a flat `source:` mean the same thing. The nested form
+ groups options when a section grows; the flat form is what a one-section
+ definition actually reads like, and refusing it would be the format being
+ precious about punctuation. */
+ const source = String(raw.data?.source ?? raw.source ?? '').trim();
+ if (!source) {
+ errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
+ return null;
+ }
+ if (!SUPPORTED_DATA_SOURCES.includes(source)) {
+ errors.push(
+ `Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
+ );
+ return null;
+ }
+
+ /* A source knows which shapes it can fill. Asking for a timeline of something
+ that has no dates is an authoring mistake worth naming now rather than
+ rendering as an empty panel later. */
+ const definition = dataSourceFor(source);
+ /* `shapeFor` is how a type that is not a drawn component is exempted: a
+ summary is prose, so there is no shape for a source to be incompatible
+ with. Every drawn type maps to itself. */
+ const shape = shapeFor(type);
+ if (shape && definition.shapes && !definition.shapes.includes(shape)) {
+ errors.push(
+ `${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
+ );
+ return null;
+ }
+
+ const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : [];
+ const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p));
+ if (unknownPeriod) {
+ errors.push(
+ `Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
+ );
+ return null;
+ }
+
+ const limit = Number(raw.limit);
+
+ /**
+ * Whether this section offers controls rather than a read-out.
+ *
+ * Two independent things must agree before anything is editable, and this is
+ * the first: the definition asking for it, and the source declaring that it
+ * can be written at all. The second is the page publishing a handler for that
+ * source at render time. A definition asking to edit a reading the product
+ * does not expose for writing is an authoring mistake worth naming here,
+ * rather than a control that silently does nothing.
+ */
+ const editable = raw.editable === true || raw.editable === 'true';
+ if (editable && !isSourceWritable(source)) {
+ errors.push(
+ `${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.`
+ );
+ return null;
+ }
+
+ return {
+ id,
+ title: String(raw.title || '').trim() || null,
+ description: String(raw.description || '').trim() || null,
+ type,
+ placement,
+ source,
+ /* Declared intent only. A section stays read-only wherever no page offers
+ to accept the write — see `SkillSurface` and `SkillSectionBlock`. */
+ editable,
+ /* Context the page must supply for this source to resolve. */
+ context: definition.context,
+ periods,
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null,
+ };
+}
+
+/**
+ * The keys that mean "this object *is* a section".
+ *
+ * How the two shapes are told apart. A definition may write its UI either way:
+ *
+ * ui: ui:
+ * type: flow positions:
+ * placement: … sections:
+ * source: … - type: flow
+ *
+ * The first is one section applying to every page the skill declares; the
+ * second addresses pages by name and can differ per page. Both are legitimate,
+ * and the difference is structural — an object carrying `type` or `source` is a
+ * section, an object whose keys are page names is a page map. Nothing is
+ * decided by a skill id, and neither shape is privileged.
+ */
+const SECTION_KEYS = new Set([
+ 'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
+ 'limit', 'editable',
+]);
+
+const looksLikeSection = (value) =>
+ Boolean(value)
+ && typeof value === 'object'
+ && !Array.isArray(value)
+ && Object.keys(value).some((key) => SECTION_KEYS.has(key));
+
+/** The sections a page entry declares, in either the list or single-section form. */
+function sectionsOf(config) {
+ if (Array.isArray(config?.sections)) return config.sections;
+ if (Array.isArray(config)) return config;
+ if (looksLikeSection(config)) return [config];
+ return null;
+}
+
+/**
+ * The whole `ui:` block, normalized per page.
+ *
+ * Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with
+ * one bad section still registers its good ones — and the author still sees why
+ * the other was refused.
+ */
+export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) {
+ const errors = [];
+ const ui = {};
+
+ if (rawUi == null) return { ui, errors };
+
+ if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
+ return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
+ }
+
+ const pages = declaredPages.map(canonicalPage).filter(Boolean);
+
+ /* Shorthand: one section, applied to every page the skill declares. The keys
+ inside it are section options and are never read as page names — which is
+ exactly what this branch exists to prevent. */
+ if (looksLikeSection(rawUi)) {
+ if (!pages.length) {
+ return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] };
+ }
+
+ for (const page of pages) {
+ const section = normalizeSection(rawUi, {
+ page, errors, seen: new Set(), fallbackId: skillId,
+ });
+ if (section) ui[page] = { sections: [section] };
+ }
+ return { ui, errors };
+ }
+
+ /* Otherwise every key is a page name. */
+ for (const [rawPage, config] of Object.entries(rawUi)) {
+ const page = canonicalPage(rawPage);
+ if (!page) {
+ errors.push(
+ `Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
+ );
+ continue;
+ }
+
+ /* A page cannot be extended unless the skill also declares it. Otherwise a
+ definition could render onto a surface it never said it applied to. */
+ if (pages.length && !pages.includes(page)) {
+ errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`);
+ continue;
+ }
+
+ const rawSections = sectionsOf(config);
+ if (!rawSections) {
+ errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`);
+ continue;
+ }
+
+ const seen = new Set();
+ const sections = rawSections
+ .map((section) => normalizeSection(section, {
+ page: rawPage, errors, seen, fallbackId: skillId,
+ }))
+ .filter(Boolean);
+
+ if (sections.length) ui[page] = { sections };
+ }
+
+ return { ui, errors };
+}
+
+/** Every section this skill contributes to a page, in declaration order. */
+export const sectionsForPage = (skill, page) => {
+ const key = canonicalPage(page);
+ return key ? skill?.ui?.[key]?.sections || [] : [];
+};
+
+/** How many UI sections a definition registers, across every page. */
+export const countSections = (skill) =>
+ Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);
diff --git a/src/lib/skills/yaml.js b/src/lib/skills/yaml.js
new file mode 100644
index 0000000..4a93fea
--- /dev/null
+++ b/src/lib/skills/yaml.js
@@ -0,0 +1,174 @@
+/**
+ * The YAML subset skill frontmatter is allowed to use.
+ *
+ * Skills gained declarative UI configuration, which is nested — `ui:` holds a
+ * page, which holds sections, which hold their own options. The old parser read
+ * flat `key: value` pairs and one level of `- item`, so nesting was impossible
+ * to express.
+ *
+ * This is deliberately a *subset*, not a YAML library:
+ *
+ * - block maps and block sequences, nested to any depth
+ * - scalars: strings, integers, floats, booleans, null
+ * - quoted strings, for values containing `:` or `#`
+ * - `- key: value` — a mapping that starts on the dash
+ * - `#` comments, and blank lines
+ *
+ * Everything else — anchors, aliases, merge keys, multi-document files, flow
+ * mappings, block scalars, tags — is not supported and is not silently
+ * half-read: an unparseable line raises, so a definition either means what it
+ * says or is rejected with a line number.
+ *
+ * It returns plain data and nothing else. There is no code path from this file
+ * to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no
+ * JSON with a reviver. A skill definition is configuration, and this is the
+ * boundary that keeps it configuration.
+ */
+
+/** `true` / `false` / `null` / numbers, or the string as written. */
+function toScalar(raw) {
+ const value = String(raw).trim();
+
+ if (value === '' || value === '~' || value === 'null') return null;
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+
+ /* Quoted: taken literally, which is how a value containing `:` or `#` is
+ written. No escape processing beyond the doubled quote. */
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
+ if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]);
+
+ if (/^-?\d+$/.test(value)) return Number(value);
+ if (/^-?\d*\.\d+$/.test(value)) return Number(value);
+
+ /* An unquoted trailing comment is a comment. `#` inside a word is not. */
+ return value.replace(/\s+#.*$/, '').trim();
+}
+
+/** One line, reduced to what the parser needs to decide. */
+function readLines(source) {
+ return String(source)
+ .split(/\r?\n/)
+ .map((text, i) => ({ text, line: i + 1 }))
+ .filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text))
+ .map(({ text, line }) => ({
+ line,
+ indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length,
+ content: text.trim(),
+ }));
+}
+
+/**
+ * Parses one block at `indent` or deeper, starting at `cursor.i`.
+ *
+ * Returns a map or an array depending on what the first line at this level is,
+ * which is how YAML itself decides. Recursion handles nesting; the cursor is
+ * shared so a child can consume the lines it owns.
+ */
+function parseBlock(lines, cursor, indent) {
+ const first = lines[cursor.i];
+ if (!first) return null;
+
+ return first.content.startsWith('- ')
+ || first.content === '-'
+ ? parseSequence(lines, cursor, indent)
+ : parseMapping(lines, cursor, indent);
+}
+
+function parseSequence(lines, cursor, indent) {
+ const out = [];
+
+ while (cursor.i < lines.length) {
+ const { content, indent: at, line } = lines[cursor.i];
+ if (at < indent) break;
+ if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
+ if (!content.startsWith('-')) break;
+
+ const rest = content.replace(/^-\s*/, '');
+ cursor.i += 1;
+
+ if (rest === '') {
+ /* `-` alone: the item is the indented block beneath it. */
+ out.push(cursor.i < lines.length && lines[cursor.i].indent > indent
+ ? parseBlock(lines, cursor, lines[cursor.i].indent)
+ : null);
+ continue;
+ }
+
+ /* `- key: value` opens a mapping whose first key sits on the dash. The
+ remaining keys are indented to where that key started. */
+ const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
+ if (pair) {
+ const keyIndent = indent + (content.length - rest.length);
+ const item = {};
+ const [, key, value] = pair;
+
+ item[key] = value === ''
+ && cursor.i < lines.length
+ && lines[cursor.i].indent > indent
+ ? parseBlock(lines, cursor, lines[cursor.i].indent)
+ : toScalar(value);
+
+ while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent
+ && !lines[cursor.i].content.startsWith('- ')) {
+ Object.assign(item, parseMapping(lines, cursor, keyIndent));
+ }
+ out.push(item);
+ continue;
+ }
+
+ out.push(toScalar(rest));
+ }
+
+ return out;
+}
+
+function parseMapping(lines, cursor, indent) {
+ const out = {};
+
+ while (cursor.i < lines.length) {
+ const { content, indent: at, line } = lines[cursor.i];
+ if (at < indent) break;
+ if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
+ if (content.startsWith('- ')) break;
+
+ const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content);
+ if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`);
+
+ const [, key, value] = pair;
+ cursor.i += 1;
+
+ if (value !== '') {
+ out[key] = toScalar(value);
+ continue;
+ }
+
+ /* An empty value means the value is the block below — or nothing. */
+ const next = lines[cursor.i];
+ out[key] = next && next.indent > indent
+ ? parseBlock(lines, cursor, next.indent)
+ : null;
+ }
+
+ return out;
+}
+
+/**
+ * A YAML document, as plain data.
+ *
+ * Throws on anything it cannot read rather than guessing, so a malformed
+ * definition is reported to its author instead of being registered in a shape
+ * nobody intended.
+ */
+export function parseYaml(source) {
+ const lines = readLines(source);
+ if (!lines.length) return {};
+
+ const cursor = { i: 0 };
+ const value = parseBlock(lines, cursor, lines[0].indent);
+
+ if (cursor.i < lines.length) {
+ throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`);
+ }
+ return value;
+}
diff --git a/src/pages/CreatePosition.jsx b/src/pages/CreatePosition.jsx
index 72ba23a..67a110c 100644
--- a/src/pages/CreatePosition.jsx
+++ b/src/pages/CreatePosition.jsx
@@ -1,11 +1,13 @@
import React, { useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
-import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
+import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
+import { SkillSurface } from '@/components/skills/SkillSurface';
+import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
/**
* Create a Position.
@@ -38,9 +40,32 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
const [showWeights, setShowWeights] = useState(false);
const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])];
- const [draftId, setDraftId] = useState(null);
+
+ /**
+ * Continuing a draft.
+ *
+ * `?draft=
` is how the Positions list hands an unfinished position back to
+ * the form that was writing it. It is the same route, the same component and
+ * the same state as writing a new one — the only difference is that the form
+ * starts from the saved record and saves back to it, so nothing the author
+ * already entered is asked for twice and no second editor exists.
+ */
+ const [searchParams] = useSearchParams();
+ const continuingId = searchParams.get('draft');
+ const { data: continuing } = useJobPosting(continuingId);
+
+ const [draftId, setDraftId] = useState(continuingId || null);
+ const [loadedId, setLoadedId] = useState(null);
const [aiResult, setAiResult] = useState(null);
+ /* Seeded once per record, not on every render of it: after this the form owns
+ the values, and re-seeding would discard whatever has been typed since. */
+ if (continuing && continuing.id !== loadedId) {
+ setLoadedId(continuing.id);
+ setDraftId(continuing.id);
+ setForm({ ...defaultPosition(), ...continuing });
+ }
+
const update = (field, value) => setForm({ ...form, [field]: value });
const toggleCert = (cert) => {
@@ -55,6 +80,46 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0);
+ /* What a skill on this surface reads: the position being specified, as it
+ stands right now. The draft carries the same fields a saved position does,
+ so one declared section reports the form and the record it becomes.
+ Memoized on the form so the sections re-resolve on a change rather than on
+ every render. */
+ const skillContext = React.useMemo(() => ({ position: form }), [form]);
+
+ /* The same draft, published to the panel beside the form. A skill declaring
+ `create-position` is available to both consumers, and both read this one
+ record — so "summarize the vetting weights" answers about the weights on
+ screen, updating as they are edited. */
+ usePublishPageContext(skillContext);
+
+ /**
+ * The write that goes with that read.
+ *
+ * Owliver can already see the weights on this form; this is what lets it set
+ * them. The handler is the form's own setter — there is no second copy of the
+ * weights anywhere, so a change made in the panel and a change made on the
+ * slider below are the same change, and the section further down this page
+ * re-renders from the same state either way.
+ *
+ * Keyed by the data source rather than by a skill or an invented action name:
+ * any definition reading `position.vetting` can offer to set it, and no
+ * definition can write anywhere it cannot read. A functional update keeps the
+ * handler stable, so the published map is memoized once rather than rebuilt as
+ * the form is typed into.
+ */
+ const applyVettingWeights = React.useCallback((next) => {
+ setForm((previous) => ({
+ ...previous,
+ vetting_criteria: { ...previous.vetting_criteria, ...next },
+ }));
+ toast.success('Vetting weights updated');
+ }, []);
+
+ usePublishPageActions(
+ React.useMemo(() => ({ 'position.vetting': applyVettingWeights }), [applyVettingWeights])
+ );
+
const handleGenerateDescription = async () => {
if (!form.title) {
toast.error('Please enter a job title first');
@@ -107,7 +172,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
if (onDone) {
onDone(createdId);
} else if (status === 'active' && createdId) {
- navigate(`/admin/positions/${createdId}`);
+ /* Back to the board, with the published role named in the URL. The list
+ is where the position now lives, and it is the surface Owliver sits
+ beside — so the role that was just published is the one the panel is
+ reasoning about, and offering candidates for, on arrival. */
+ navigate(`/admin/positions?published=${encodeURIComponent(createdId)}`);
} else {
navigate('/admin/positions');
}
@@ -141,6 +210,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
Create Job Posting
+ {/* Extension points. A definition naming `create-position` and one of
+ these placements renders here, against the draft in the form. The
+ form knows no skill; the definition names the slot. Nothing below is
+ moved or replaced — the sections sit between what is already here. */}
+