diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs
index 3cf20c2..b2be613 100644
--- a/scripts/skill-check.mjs
+++ b/scripts/skill-check.mjs
@@ -798,6 +798,591 @@ for (const skill of reg.SKILLS) {
`${rehydrated.id} / ${rehydrated.name}`);
}
+/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
+ The two authoring paths, asserted to produce one artefact.
+
+ A form that cannot express what the format can is not a shortcut to it: the
+ manual Owliver editor carried a single `source` shared by every selected
+ capability, so a capability chosen before a source was picked composed no
+ `responses:` block at all — and a definition whose capabilities all drop
+ registers, shows as active on its page, and contributes no chip. These check
+ that both doors compose the same definition, and that the one that cannot be
+ configured is refused rather than saved empty. */
+console.log('\n── Markdown ↔ manual equivalence ──');
+
+const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
+const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
+ .find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
+
+/* The canonical model is what `parseSkill` makes of a definition. Comparing the
+ whole record would compare its Markdown too, which is the one thing the two
+ paths are allowed to differ on — so the runtime configuration is compared,
+ which is exactly what every consumer reads. */
+const canonical = (skill) => JSON.parse(JSON.stringify({
+ id: skill.id,
+ name: skill.name,
+ description: skill.description,
+ status: skill.status,
+ pages: skill.pages,
+ kind: skill.kind,
+ triggers: skill.triggers,
+ ui: skill.ui,
+ owliver: skill.owliver,
+}));
+
+/* TEST A / B — the Board card, written both ways. */
+const BOARD_MD = `---
+id: board
+name: Board
+description: Helps Owliver understand, analyze, and act on the current task board.
+pages:
+ - positions
+status: active
+ui:
+ type: card
+ placement: after-position-list-summary
+ title: Board
+ source: candidates.activity
+ periods:
+ - today
+ - last-7-days
+ - previous-month
+---
+
+# Board
+`;
+
+const boardManual = templates.uiSkillTemplate({
+ id: 'board',
+ name: 'Board',
+ description: 'Helps Owliver understand, analyze, and act on the current task board.',
+ pages: ['positions'],
+ type: 'card',
+ placement: 'after-position-list-summary',
+ title: 'Board',
+ source: 'candidates.activity',
+ periods: ['today', 'last-7-days', 'previous-month'],
+});
+
+record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
+ reg.validateSkillSource(BOARD_MD) || 'accepted');
+record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
+ reg.validateSkillSource(boardManual) || 'accepted');
+
+const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
+const boardFromForm = reg.parseSkill(boardManual, { custom: true });
+const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
+
+record('TEST A: one section, above the position list',
+ Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
+ && boardSection(boardFromMd).placement === 'after-position-list-summary',
+ `${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
+record('TEST A: the Board card contributes no Owliver capability',
+ boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
+record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
+ reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
+record('TEST B: manual Board renders the identical section',
+ JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
+ JSON.stringify(boardSection(boardFromForm)));
+record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
+ String(boardSection(boardFromForm).title));
+
+/* TEST C / D — the conversational skill, written both ways. */
+const OWLIVER_MD = `---
+id: owliver-conversation-test
+name: Hiring Activity Test
+description: Shows hiring activity from the Positions page.
+pages:
+ - positions
+status: active
+triggers:
+ - hiring activity test
+owliver:
+ enabled: true
+ suggestions:
+ - Show hiring activity
+ - Summarize hiring activity
+ capabilities:
+ - summary
+ responses:
+ summary:
+ source: candidates.activity
+ periods:
+ - today
+ - last-7-days
+ - previous-month
+---
+
+# Hiring Activity Test
+`;
+
+const owliverManual = templates.owliverSkillTemplate({
+ id: 'owliver-conversation-test',
+ name: 'Hiring Activity Test',
+ description: 'Shows hiring activity from the Positions page.',
+ pages: ['positions'],
+ suggestions: ['Show hiring activity', 'Summarize hiring activity'],
+ capabilities: ['summary'],
+ responses: {
+ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
+ },
+});
+
+record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
+ reg.validateSkillSource(OWLIVER_MD) || 'accepted');
+record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
+ reg.validateSkillSource(owliverManual) || 'accepted');
+
+for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
+ const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
+ const skill = reg.parseSkill(source, { custom: true });
+
+ record(`${label}: the capability keeps its source`,
+ skill.owliver.responses.summary?.source === 'candidates.activity',
+ skill.owliver.responses.summary?.source || 'dropped');
+ record(`${label}: the capability keeps its periods`,
+ JSON.stringify(skill.owliver.responses.summary?.periods)
+ === JSON.stringify(['today', 'last-7-days', 'previous-month']),
+ JSON.stringify(skill.owliver.responses.summary?.periods));
+ record(`${label}: it draws no page card`,
+ Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
+
+ const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
+ const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
+ record(`${label}: its suggestion reaches the panel`,
+ mine.some((c) => c.label === 'Show hiring activity'),
+ JSON.stringify(chips.map((c) => c.label)));
+ record(`${label}: the suggestion answers without asking for a record`,
+ mine.every((c) => !c.deferred));
+
+ const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
+ const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
+ record(`${label}: clicking it routes to this skill's summary`,
+ matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
+ `${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
+
+ const answered = resolver.resolveOwliverResponse({
+ skill: matched.skill,
+ capability: matched.capability,
+ question: 'Show hiring activity',
+ context: {
+ positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
+ applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
+ },
+ now: new Date(),
+ });
+ record(`${label}: it answers with the configured reading`,
+ answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
+ JSON.stringify(resolver.summaryLines(answered?.data || {})));
+}
+
+/* TEST H — the same skill through both doors, normalized, compared. */
+record('TEST H: Markdown and manual normalize identically (Board)',
+ JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
+ JSON.stringify(canonical(boardFromForm)).slice(0, 120));
+record('TEST H: Markdown and manual normalize identically (Owliver)',
+ JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
+ === JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
+ JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
+
+/* TEST E — a capability with no source is refused, never saved empty. */
+const noSourceFields = {
+ id: 'no-source-test',
+ name: 'No Source Test',
+ description: 'A capability with nothing to read.',
+ pages: ['positions'],
+ suggestions: ['Show hiring activity'],
+ capabilities: ['summary'],
+ responses: {},
+};
+const noSource = templates.owliverSkillTemplate(noSourceFields);
+record('TEST E: an unconfigured capability is named by the form',
+ JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
+ JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
+record('TEST E: and the save is refused',
+ /owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
+ reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
+record('TEST E: the refused definition would have had zero capabilities',
+ reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
+ 'which is why it is refused rather than stored');
+
+/* TEST F — two capabilities, two different sources, neither inheriting. */
+const twoSources = templates.owliverSkillTemplate({
+ id: 'two-source-test',
+ name: 'Two Source Test',
+ description: 'Two capabilities reading two sources.',
+ pages: ['positions'],
+ suggestions: ['Summarize workspace applications', 'List the open roles'],
+ capabilities: ['summary', 'list'],
+ responses: {
+ summary: { source: 'candidates.activity', periods: ['today'] },
+ list: { source: 'positions.demand', limit: 5 },
+ },
+});
+const twoSkill = reg.parseSkill(twoSources, { custom: true });
+record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
+ reg.validateSkillSource(twoSources) || 'accepted');
+record('TEST F: each capability keeps its own source',
+ twoSkill.owliver.responses.summary?.source === 'candidates.activity'
+ && twoSkill.owliver.responses.list?.source === 'positions.demand',
+ `summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
+record('TEST F: neither inherits the other\'s options',
+ JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
+ && twoSkill.owliver.responses.list.limit === 5
+ && twoSkill.owliver.responses.list.periods.length === 0,
+ `summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
+record('TEST F: both are offered as suggestions',
+ resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
+ .filter((c) => c.skillId === 'two-source-test').length === 2);
+
+/* TEST G — editing one field leaves every other configuration alone. */
+const edited = fields.patchFrontmatter(
+ twoSources,
+ fields.owliverPatch(
+ { ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
+ { existing: twoSources }
+ )
+);
+const editedSkill = reg.parseSkill(edited, { custom: true });
+record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
+record('TEST G: every capability source survives the edit',
+ JSON.stringify(canonical(editedSkill).owliver.responses)
+ === JSON.stringify(canonical(twoSkill).owliver.responses),
+ `summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
+record('TEST G: suggestions survive the edit',
+ JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
+
+const boardEdited = fields.patchFrontmatter(
+ boardManual,
+ fields.boardPatch(
+ { ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
+ { existing: boardManual }
+ )
+);
+record('TEST G: Board UI configuration survives the edit',
+ JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
+ === JSON.stringify(boardSection(boardFromForm)),
+ JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
+
+/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
+record('shape compatibility: the resolver refuses what the normalizer refuses',
+ surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
+ && !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
+ `list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
+record('shape compatibility: a list against candidates.activity is refused on save',
+ /cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
+ id: 'bad-shape-test',
+ name: 'Bad Shape Test',
+ description: 'A list of something with no list in it.',
+ pages: ['positions'],
+ capabilities: ['list'],
+ responses: { list: { source: 'candidates.activity' } },
+ })) || ''),
+ 'the picker cannot offer it, and the save refuses it');
+record('summary is prose, so every source suits it',
+ surfaces.shapeForCapability('summary') === null
+ && surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
+
+/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
+console.log('\n── Regression: manual Owliver skill on Positions ──');
+
+/* Exactly the configuration from the report: Pages = Positions, one suggestion,
+ Summary, `candidates.activity`, three periods — composed the way the form
+ composes it, then read the way the panel reads it. */
+const REGRESSION = templates.owliverSkillTemplate({
+ id: 'owliver-conversation-test',
+ name: 'Hiring Activity Test',
+ description: 'Shows hiring activity from the Positions page.',
+ pages: ['positions'],
+ suggestions: ['Show hiring activity'],
+ capabilities: ['summary'],
+ responses: {
+ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
+ },
+});
+const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
+
+record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
+ reg.validateSkillSource(REGRESSION) || 'accepted');
+record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
+record('it is attached to Positions',
+ reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
+record('it keeps a capability — the step that used to empty',
+ reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
+ JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
+record('it reaches owliverSkillsForContext',
+ resolver.owliverSkillsForContext(positionsContext, [], stored)
+ .some((s) => s.id === 'owliver-conversation-test'));
+
+const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
+record('"Show hiring activity" is offered as a chip',
+ regressionChips.some((c) => c.label === 'Show hiring activity'),
+ JSON.stringify(regressionChips.map((c) => c.label)));
+
+const clicked = resolver.matchOwliverSkill(
+ 'Show hiring activity',
+ resolver.owliverSkillsForContext(positionsContext, [], stored)
+);
+record('clicking it invokes this skill\'s summary',
+ clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
+ `${clicked?.skill.id}:${clicked?.capability}`);
+
+const now = new Date();
+const answer = resolver.resolveOwliverResponse({
+ skill: clicked.skill,
+ capability: clicked.capability,
+ question: 'Show hiring activity',
+ context: {
+ positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
+ applications: [
+ { id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
+ { id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
+ ],
+ },
+ now,
+});
+record('it answers with the configured source and periods',
+ answer?.missing === null && answer.section.source === 'candidates.activity'
+ && answer.section.periods.length === 3,
+ `${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
+record('the answer carries real figures',
+ resolver.summaryLines(answer.data).length === 3,
+ JSON.stringify(resolver.summaryLines(answer.data)));
+record('and no `position.activity needs a position` is possible',
+ dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
+ 'the source declares no context requirement');
+
+/* Reload: what is stored is the Markdown, so the round trip through storage is
+ the round trip through the parser. */
+const reloaded = reg.parseSkill(
+ fields.patchFrontmatter(REGRESSION, {}),
+ { custom: true }
+);
+record('the source survives a reload',
+ reloaded.owliver.responses.summary?.source === 'candidates.activity',
+ reloaded.owliver.responses.summary?.source || 'lost');
+record('the form reads it back unchanged',
+ JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
+ === JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
+ JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
+
+/* ── 10. Storage round trip ───────────────────────────────────────────────
+ The editors do not hand the registry a record; they write Markdown into
+ `preferences.customSkills`, which `base44Client` persists to localStorage and
+ reads back on the next load. "The source disappeared after reload" is a claim
+ about *that* path, so it is exercised here rather than assumed: the same
+ module the app runs, against a localStorage the same shape the browser's is. */
+console.log('\n── Storage round trip ──');
+
+const store = new Map();
+globalThis.localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+};
+
+const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
+const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
+
+/* Saving is what the editor's Save button does: compose, validate, upsert,
+ write the preference. */
+const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
+const written = await base44.auth.updatePreferences({ customSkills: saved.next });
+record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
+
+/* Reload: a fresh read of the same key, exactly as a page load does. */
+const rehydratedSkills = base44.auth.preferences().customSkills || [];
+record('it survives the reload', rehydratedSkills.length === 1
+ && String(rehydratedSkills[0].raw).includes('candidates.activity'),
+ `${rehydratedSkills.length} stored`);
+
+const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
+record('its capability survives the reload',
+ afterReload.owliver.responses.summary?.source === 'candidates.activity',
+ afterReload.owliver.responses.summary?.source || 'lost');
+record('and it still offers its chip after the reload',
+ resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
+ .some((c) => c.label === 'Show hiring activity'),
+ JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
+record('the editor reopens it with its source intact',
+ fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
+ JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
+
+/* The Board skill through the same path, stored beside it — the two subsystems
+ are independent, so both must survive one write. */
+const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
+await base44.auth.updatePreferences({ customSkills: bothStored });
+const finalStored = base44.auth.preferences().customSkills || [];
+const finalRegistry = reg.readSkillRegistry(finalStored);
+record('Board and Owliver skills coexist in storage', finalStored.length === 2,
+ `${finalStored.length} stored`);
+record('neither produces a registry diagnostic',
+ !finalRegistry.diagnostics.some((d) => d.level === 'error'),
+ finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
+record('the Board card draws once on Positions, and offers no chip',
+ reg.getSkillsForPage('positions', { customSources: finalStored })
+ .filter((sk) => sk.id === 'board')
+ .every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
+ && sk.owliver.capabilities.length === 0));
+record('the Owliver skill offers its chip, and draws no card',
+ resolver.owliverSuggestions(positionsContext, [], finalStored, {})
+ .some((c) => c.skillId === 'owliver-conversation-test')
+ && Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
+
+/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
+ What the form starts as, asserted at the state that drives the checkbox
+ rather than at the checkbox. `checked` is
+ `draft.capabilities.includes(capability.id)` and the per-capability
+ configuration is rendered under `{checked && …}`, so the array below is the
+ whole of "what is ticked" and "what is shown". */
+console.log('\n── Fresh Add Owliver Skill ──');
+
+const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
+
+/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
+ editor initializes to when there is no `:id` and nothing handed over. */
+record('new skill: initial capabilities are []',
+ Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
+ && fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
+ JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
+record('new skill: no response is pre-configured either',
+ Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
+ JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
+record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
+ CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
+ CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
+
+/* The template the fresh screen composes must not declare capabilities either:
+ an author who types a name before ticking anything patches *that* file, and a
+ `capabilities:` key in it would tick boxes nobody chose. */
+const freshTemplate = templates.owliverSkillTemplate({});
+record('new skill: the composed template declares no capabilities',
+ !/^\s*capabilities:/m.test(freshTemplate),
+ freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
+record('new skill: reading that template back still yields []',
+ fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
+ JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
+
+/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
+ state merge, so the assertion is on what that array then means. */
+const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
+record('selecting Summary ticks exactly Summary',
+ JSON.stringify(pickedSummary.capabilities) === '["summary"]');
+record('and only Summary renders a configuration block',
+ CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
+ CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
+record('an unsourced Summary is invalid, and only Summary is reported',
+ JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
+ JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
+
+/* 3. Choosing its source clears the error, and the definition saves. */
+const sourcedSummary = {
+ ...pickedSummary,
+ id: 'fresh-summary-test',
+ name: 'Fresh Summary Test',
+ description: 'One capability, configured.',
+ pages: ['positions'],
+ suggestions: ['Show hiring activity'],
+ responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
+};
+record('choosing a source clears the validation error',
+ fields.unconfiguredCapabilities(sourcedSummary).length === 0,
+ JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
+const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
+record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
+ reg.validateSkillSource(freshSaved) || 'accepted');
+record('the saved definition declares only the chosen capability',
+ JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
+ JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
+record('and its suggestion reaches the Positions panel',
+ resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
+ .some((c) => c.skillId === 'fresh-summary-test'));
+
+/* 4. Several, chosen by hand — only those, each validated on its own. */
+const pickedTwo = {
+ ...fields.EMPTY_OWLIVER_FIELDS,
+ capabilities: ['summary', 'list'],
+ responses: { summary: { source: 'candidates.activity' } },
+};
+record('selecting two capabilities renders exactly those two',
+ CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
+record('and only the unconfigured one is reported',
+ JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
+ JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
+
+/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
+ Including the capability that did *not* resolve: it is the one the author
+ opened the editor to fix, and dropping it from the form would delete it
+ from the file on the next save. */
+const DECLARED_ONE = `---
+id: declared-one
+name: Declared One
+description: Declares a single capability.
+pages:
+ - positions
+status: active
+owliver:
+ enabled: true
+ suggestions:
+ - Show hiring activity
+ capabilities:
+ - summary
+ responses:
+ summary:
+ source: candidates.activity
+---
+
+# Declared One
+`;
+record('existing skill: hydrates exactly its declared capability',
+ JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
+ JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
+record('existing skill: no new-skill default is applied on top',
+ CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
+
+const HALF_BROKEN = DECLARED_ONE
+ .replace(' - summary\n', ' - summary\n - list\n')
+ .replace('id: declared-one', 'id: half-broken');
+const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
+record('existing skill: a declared capability that lost its source still shows',
+ JSON.stringify(halfFields.capabilities) === '["summary","list"]',
+ JSON.stringify(halfFields.capabilities));
+record('...unsourced, so it stays invalid rather than silently dropped',
+ JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
+ JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
+record('...and the configured one keeps its source through the round trip',
+ halfFields.responses.summary.source === 'candidates.activity',
+ halfFields.responses.summary.source);
+record('...while the runtime still registers only what can answer',
+ JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
+ JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
+
+/* A definition inheriting its reading from a `ui:` section still hydrates the
+ capability it actually offers — the union is declaration-first, not
+ declaration-only. */
+const INHERITED = `---
+id: inherited-cap
+name: Inherited Cap
+description: A capability reading the page section.
+pages:
+ - positions
+status: active
+ui:
+ type: card
+ placement: after-position-list-summary
+ source: candidates.activity
+owliver:
+ enabled: true
+ capabilities:
+ - card
+---
+
+# Inherited Cap
+`;
+record('a capability inheriting from `ui:` still hydrates with its source',
+ fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
+ fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
+
await server.close();
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx
index a3119a3..1c9bc70 100644
--- a/src/components/ai-assistant/AssistantMessage.jsx
+++ b/src/components/ai-assistant/AssistantMessage.jsx
@@ -1,5 +1,4 @@
import * as React from 'react';
-import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
diff --git a/src/lib/skills/customSkills.js b/src/lib/skills/customSkills.js
index 222c614..1cdf542 100644
--- a/src/lib/skills/customSkills.js
+++ b/src/lib/skills/customSkills.js
@@ -1,4 +1,5 @@
import { parseSkill } from './registry';
+import { boardPatch, owliverPatch, patchFrontmatter } from './skillFields';
/**
* Account-authored skills, as stored.
@@ -28,6 +29,22 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
'status: active',
].join('\n');
+/**
+ * The identity block every definition opens with, and nothing else.
+ *
+ * The configuration that follows it — the `ui:` section, the `owliver:` block —
+ * is written by `boardPatch` and `owliverPatch`, which is the same writer the
+ * editors use on an existing file. A template that composed its own YAML was a
+ * second writer with a second field set, and the fields one knew about were not
+ * the fields the other did.
+ */
+const skeleton = (fields, fallback, body) => `---
+${frontMatter({ ...fields, fallback })}
+---
+
+${body}
+`;
+
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
@@ -40,26 +57,18 @@ export const uiSkillTemplate = ({
* record the page never had. A template must produce something that works
* before it is edited, so the default reads a source that needs nothing.
*/
- type = 'flow', placement = '', source = 'candidates.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` : ''}---
+ type = 'flow', placement = '', title = '', source = 'candidates.activity', periods = [],
+} = {}) => {
+ const identity = { id, name, description, pages };
+ const fallback = {
+ id: 'my-ui-skill',
+ name: 'My UI Skill',
+ description: 'What this skill adds to the page.',
+ };
+ const heading = name || fallback.name;
-# ${name || 'My UI Skill'}
+ return patchFrontmatter(
+ skeleton(identity, fallback, `# ${heading}
## Purpose
@@ -68,59 +77,36 @@ Describe what this section shows, and why it belongs on these pages.
## Capabilities
- Describe one thing the section reports.
-- Add more as needed.
-`;
+- Add more as needed.`),
+ boardPatch({
+ ...identity,
+ type,
+ placement,
+ /* A card with no title of its own is headed by the skill's name, which is
+ what the previous template wrote out. Kept, so a fresh draft reads the
+ same as it always did. */
+ title: title || heading,
+ source,
+ periods,
+ })
+ );
+};
/** A definition that teaches Owliver what it can be asked for. */
export const owliverSkillTemplate = ({
id = '', name = '', description = '', pages = [],
- triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
+ triggers = [], suggestions = [], capabilities = [], responses = {},
} = {}) => {
- 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.',
- },
- })}`];
+ const identity = { id, name, description, pages };
+ const fallback = {
+ id: 'my-owliver-skill',
+ name: 'My Owliver Skill',
+ description: 'What this skill helps Owliver answer.',
+ };
+ const label = name || fallback.name;
- 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}
+ return patchFrontmatter(
+ skeleton(identity, fallback, `# ${label}
## Purpose
@@ -129,10 +115,17 @@ 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');
+- Add more as needed.`),
+ owliverPatch({
+ ...identity,
+ /* A definition that claims no phrase of its own still answers to its
+ name — written out so the author can see what it will match on. */
+ triggers: triggers.length ? triggers : [label.toLowerCase()],
+ suggestions,
+ capabilities,
+ responses,
+ })
+ );
};
/**
diff --git a/src/lib/skills/skillFields.js b/src/lib/skills/skillFields.js
index 79c8351..d23c8da 100644
--- a/src/lib/skills/skillFields.js
+++ b/src/lib/skills/skillFields.js
@@ -1,4 +1,5 @@
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
+import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
/**
* A definition, read into editor fields — and edited fields, written back.
@@ -62,13 +63,33 @@ export const isReadableDefinition = (source) => hasFrontmatter(source);
/** The Board (UI) editor's fields, before a definition is loaded into them. */
export const EMPTY_BOARD_FIELDS = {
id: '', name: '', description: '', pages: [],
- type: 'flow', placement: '', source: 'candidates.activity', periods: [],
+ type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
};
-/** The Owliver editor's fields, before a definition is loaded into them. */
+/**
+ * The Owliver editor's fields, before a definition is loaded into them.
+ *
+ * `responses` is keyed by capability, because that is how the format is keyed
+ * and how the runtime reads it. It used to be one `source` and one `periods`
+ * shared by every selected capability, and that single field is the whole of
+ * the bug this shape exists to remove:
+ *
+ * - A capability selected before a source was picked composed **no**
+ * `responses:` block at all. `normalizeSkillOwliver` then dropped every
+ * capability for want of a source, `owliver.capabilities` normalized to
+ * `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
+ * definition registered, showed as active on its page, and contributed no
+ * suggestion chip.
+ * - Two capabilities could not read different sources, and one global source
+ * that suited the first often could not be drawn as the second: `list`
+ * against `candidates.activity` is refused by `normalizeSection`, so
+ * picking it silently cost the author that capability.
+ *
+ * A form that cannot express what the format can is not a shortcut to it.
+ */
export const EMPTY_OWLIVER_FIELDS = {
id: '', name: '', description: '', pages: [],
- triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
+ triggers: [], suggestions: [], capabilities: [], responses: {},
};
/**
@@ -94,6 +115,10 @@ export function boardFieldsFromSource(source) {
pages: skill.pages,
type: section?.type || EMPTY_BOARD_FIELDS.type,
placement: section?.placement || '',
+ /* The heading the card draws. Composed from the skill's name when it is
+ left out, but it is a field of its own in the format and a definition
+ that set it must survive a round trip through the form. */
+ title: section?.title || '',
source: section?.source || EMPTY_BOARD_FIELDS.source,
periods: section?.periods || [],
};
@@ -113,8 +138,61 @@ export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
- const first = skill.owliver.capabilities[0];
- const response = first ? skill.owliver.responses[first] : null;
+
+ /**
+ * Every capability the definition *declares*, not only the ones that
+ * resolved.
+ *
+ * `skill.owliver.capabilities` is filtered to those that found a reading,
+ * which is right for the runtime and wrong for a form: a stored definition
+ * declaring `summary` and `list` where `list` lost its source would open
+ * with `list` simply absent, and the next save would delete a capability
+ * its author never removed. The one that cannot answer is exactly the one
+ * they opened the editor to fix, so it is shown — checked, with no source,
+ * and refused by the same validation as before.
+ *
+ * Read from the frontmatter the definition actually carries, and narrowed
+ * to the closed vocabulary, so a typo is still not a capability.
+ */
+ const declared = (() => {
+ let data;
+ try {
+ ({ data } = parseFrontmatter(String(source)));
+ } catch {
+ return [];
+ }
+ const block = data?.owliver;
+ if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
+ const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
+ const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
+ ? Object.keys(block.responses)
+ : [];
+ return [...listed, ...keyed]
+ .map((capability) => String(capability).trim())
+ .filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
+ })();
+
+ /* Declaration order first, then anything that resolved without being
+ listed — a definition whose capability came from its `ui:` section. */
+ const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
+
+ /**
+ * Every capability's own reading, not the first one's.
+ *
+ * Reading one and showing it against all of them is how a two-capability
+ * definition lost the second's source the first time any field was edited:
+ * the form wrote back what it had read, and it had read half the block.
+ */
+ const responses = Object.fromEntries(
+ capabilities.map((capability) => {
+ const response = skill.owliver.responses[capability];
+ return [capability, {
+ source: response?.source || '',
+ periods: response?.periods || [],
+ limit: response?.limit ?? null,
+ }];
+ })
+ );
return {
id: skill.id,
@@ -133,9 +211,8 @@ export function owliverFieldsFromSource(source) {
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
- capabilities: skill.owliver.capabilities,
- source: response?.source || '',
- periods: response?.periods || [],
+ capabilities,
+ responses,
};
} catch {
return EMPTY_OWLIVER_FIELDS;
@@ -404,3 +481,115 @@ export function patchFrontmatter(source, patch = {}) {
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
}
+
+/* ── Writing fields back as one canonical patch ─────────────────────────── */
+
+/**
+ * The frontmatter a set of Board fields means.
+ *
+ * Both composing paths go through this: a fresh draft applies it to an empty
+ * definition, an existing one applies it to the file already open. That is what
+ * makes "typed into the form" and "pasted as Markdown" the same artefact — the
+ * fields have one writer, so there is no second field set that only one path
+ * knows how to express.
+ *
+ * `existing` is the definition being edited, and only two questions are asked of
+ * it: whether its `pages:` are derived from its `ui:` entries, and whether its
+ * `ui:` block is one this form can represent at all. Both are refusals to
+ * overwrite what the fields cannot hold, never a second shape.
+ */
+export function boardPatch(fields, { existing = '' } = {}) {
+ const derived = existing ? pagesAreDerived(existing) : false;
+ const editable = existing ? uiIsEditableFromFields(existing) : true;
+
+ const section = editable ? {
+ 'ui.type': fields.type || undefined,
+ 'ui.placement': fields.placement || undefined,
+ 'ui.title': fields.title || undefined,
+ 'ui.source': fields.source || undefined,
+ /* Periods only where the reading has any. A source that counts nothing over
+ time keeps no period list, so switching to one cannot leave the previous
+ source's windows behind as a block that validates and does nothing. */
+ 'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
+ ? fields.periods
+ : REMOVE,
+ } : {};
+
+ return {
+ id: fields.id || undefined,
+ name: fields.name || undefined,
+ description: fields.description || undefined,
+ pages: fields.pages?.length && !derived ? fields.pages : undefined,
+ ...section,
+ };
+}
+
+/**
+ * One capability's response, as the format writes it.
+ *
+ * Returns null when the capability has no source. That is deliberate and is the
+ * point of the whole change: an unconfigured capability is left out of
+ * `responses:` so `normalizeSkillOwliver` reports it by name and
+ * `validateSkillSource` refuses the save. The alternative — inventing a source
+ * to make the block well-formed — is how a definition gets saved reading data
+ * its author never chose.
+ */
+function responseFor(fields, capability) {
+ const response = fields.responses?.[capability];
+ const source = response?.source || '';
+ if (!source) return null;
+
+ const entry = { source };
+ if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
+ entry.periods = response.periods;
+ }
+ if (response.limit && sourceSupportsOption(source, 'limit')) {
+ entry.limit = Number(response.limit);
+ }
+ return entry;
+}
+
+/**
+ * The frontmatter a set of Owliver fields means.
+ *
+ * Every selected capability that has a source is written with its *own*
+ * `source:`, and its own periods or limit where the reading takes them. Nothing
+ * is inherited from a `ui:` block here: this editor owns definitions that have
+ * no `ui:` block at all, and a response that relies on inheriting one silently
+ * loses its reading the day the section is edited.
+ */
+export function owliverPatch(fields, { existing = '' } = {}) {
+ const derived = existing ? pagesAreDerived(existing) : false;
+
+ const responses = {};
+ for (const capability of fields.capabilities || []) {
+ const response = responseFor(fields, capability);
+ if (response) responses[capability] = response;
+ }
+
+ return {
+ id: fields.id || undefined,
+ name: fields.name || undefined,
+ description: fields.description || undefined,
+ pages: fields.pages?.length && !derived ? fields.pages : undefined,
+ triggers: fields.triggers?.length ? fields.triggers : REMOVE,
+ 'owliver.enabled': true,
+ 'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
+ 'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
+ /* An empty mapping is not a mapping the parser will take — `owliver.responses`
+ must be a mapping of capability names — so nothing configured removes the
+ key rather than writing a header with nothing under it. */
+ 'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
+ };
+}
+
+/**
+ * Which selected capabilities are not configured, by name.
+ *
+ * The editor shows these against the capability rather than only as a refusal
+ * on save, so "Summary needs a source" is read where the source is chosen. The
+ * *refusal* is still `validateSkillSource`'s, on the same definition the
+ * registry reads — this only says the same thing earlier.
+ */
+export const unconfiguredCapabilities = (fields) =>
+ (fields.capabilities || []).filter((capability) => !responseFor(fields, capability));
diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js
index 1ba8b8e..01eac60 100644
--- a/src/lib/skills/surfaces.js
+++ b/src/lib/skills/surfaces.js
@@ -438,6 +438,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
+ options: ['periods'],
},
{
id: 'position.pipeline',
@@ -452,6 +453,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
+ options: ['limit'],
},
{
/**
@@ -469,6 +471,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
+ options: ['limit'],
},
{
id: 'position.requirements',
@@ -504,6 +507,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
+ options: ['periods'],
},
{
id: 'positions.demand',
@@ -511,6 +515,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
+ options: ['limit'],
},
{
id: 'workforce.training',
@@ -518,6 +523,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
+ options: ['limit'],
},
{
/* The vetting weights a position is being specified with. `positionId`
@@ -548,6 +554,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
+ options: ['limit'],
},
{
id: 'hires.performance',
@@ -562,6 +569,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
+ options: ['limit'],
},
];
@@ -569,6 +577,60 @@ export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
+/* ── Source / shape compatibility ───────────────────────────────────────────
+ One resolver, three consumers: `normalizeSection` refuses on it, the Board
+ editor's source picker filters on it, and the Owliver editor's per-capability
+ picker filters on it. They used to be three readings of `source.shapes` —
+ inline in the normalizer, absent from one editor and approximated in the
+ other — which is how a form could compose `list` against a source that has no
+ list in it and only find out at save. */
+
+/**
+ * The section type a capability is drawn with, or null when it is prose.
+ *
+ * `summary` is the one capability with no component, so it is compatible with
+ * every source: the figures are read back as sentences rather than drawn.
+ */
+export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
+
+/** Can this source be drawn as this shape? A null shape is prose, and always can. */
+export function sourceSupportsShape(sourceId, shape) {
+ const source = dataSourceFor(sourceId);
+ if (!source) return false;
+ if (!shape) return true;
+ return Boolean(source.shapes?.includes(shape));
+}
+
+/**
+ * The sources that can fill this shape, optionally narrowed to what a placement
+ * can supply context for.
+ *
+ * `context` is the list a placement provides — `contextSuppliedBy(pages,
+ * placement)`. Passing it is how the Board editor offers only sources that can
+ * actually resolve where the section is mounted; the Owliver editor passes
+ * nothing, because a response with an unmet need asks which record is meant
+ * rather than rendering dead.
+ */
+export function sourcesForShape(shape, { context = null } = {}) {
+ return DATA_SOURCES.filter((source) => {
+ if (!sourceSupportsShape(source.id, shape)) return false;
+ if (!context) return true;
+ return !source.context || context.includes(source.context);
+ });
+}
+
+/**
+ * Whether a source reads an option a form can offer — `periods` or `limit`.
+ *
+ * Declared per source rather than inferred from its shapes, because the two do
+ * not line up: `positions.demand` is a list that honours `limit` and has no
+ * periods at all, while `candidates.activity` is the reverse. The editors used
+ * to guess from the shape list and offered period checkboxes that changed
+ * nothing.
+ */
+export const sourceSupportsOption = (sourceId, option) =>
+ Boolean(dataSourceFor(sourceId)?.options?.includes(option));
+
/**
* Can this reading be written back?
*
diff --git a/src/lib/skills/uiConfig.js b/src/lib/skills/uiConfig.js
index ce9c115..330d7a3 100644
--- a/src/lib/skills/uiConfig.js
+++ b/src/lib/skills/uiConfig.js
@@ -1,7 +1,7 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
- surfaceFor,
+ sourceSupportsShape, surfaceFor,
} from './surfaces';
/**
@@ -151,7 +151,10 @@ export function normalizeSection(raw, {
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)) {
+ /* `sourceSupportsShape` rather than a reading of `definition.shapes` here:
+ the editors' source pickers ask the same question, and the answer has to
+ come from one place or a form can compose what the normalizer refuses. */
+ if (!sourceSupportsShape(source, shape)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
);
diff --git a/src/pages/admin/OwliverSkillEditor.jsx b/src/pages/admin/OwliverSkillEditor.jsx
index 5a82819..f882268 100644
--- a/src/pages/admin/OwliverSkillEditor.jsx
+++ b/src/pages/admin/OwliverSkillEditor.jsx
@@ -12,11 +12,12 @@ import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/li
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
- owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
+ owliverFieldsFromSource, owliverPatch, patchFrontmatter, unconfiguredCapabilities,
} from '@/lib/skills/skillFields';
import {
- DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
- owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
+ OWLIVER_CAPABILITIES, PERIODS, contextLabel, dataSourceLabel, owliverCapabilityLabel,
+ periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
+ surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -186,32 +187,12 @@ export default function OwliverSkillEditor() {
return;
}
- /* Responses are only rewritten when this form has enough to say what they
- are. A definition whose capabilities read from an inherited `ui:` section
- declares no `responses:` block, and inventing one here would pin a
- reading the author deliberately left to be inherited. */
- const responses = next.capabilities.length && next.source
- ? Object.fromEntries(next.capabilities.map((capability) => [
- capability,
- next.periods.length
- ? { source: next.source, periods: next.periods }
- : { source: next.source },
- ]))
- : undefined;
-
- setSource(patchFrontmatter(source, {
- id: next.id || undefined,
- name: next.name || undefined,
- description: next.description || undefined,
- /* A definition whose reach comes from its `ui:` entries shows those pages
- as a read-out; writing them back would pin a list that then governs the
- block. Same rule as the Board editor, for the same reason. */
- pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
- triggers: next.triggers?.length ? next.triggers : undefined,
- 'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
- 'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
- 'owliver.responses': responses,
- }));
+ /* `owliverPatch` is the same writer the template composes a fresh draft
+ with, so a definition typed into this form and one pasted as Markdown are
+ the same artefact rather than two dialects of it. Each selected capability
+ carries its own source, periods and limit — nothing is inherited from a
+ `ui:` block, which this editor's definitions frequently do not have. */
+ setSource(patchFrontmatter(source, owliverPatch(next, { existing: source })));
};
const setStatus = (nextActive) => {
@@ -288,11 +269,23 @@ export default function OwliverSkillEditor() {
navigate('/admin/workspace/skills?tab=owliver');
};
- /* Only the sources the declared pages can actually supply context for are
- worth offering — a source needing a candidate is not answerable from the
- Analytics panel, and offering it would validate and then never resolve. */
- const source_ = draft.source ? dataSourceFor(draft.source) : null;
- const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
+ /**
+ * One capability's response, edited.
+ *
+ * Keyed by capability because the format is: `owliver.responses.summary` and
+ * `owliver.responses.list` are two readings, and a form with one source field
+ * could only ever write one of them.
+ */
+ const updateResponse = (capability, patch) => update({
+ responses: {
+ ...draft.responses,
+ [capability]: { ...(draft.responses[capability] || {}), ...patch },
+ },
+ });
+
+ /* Capabilities selected but not yet configured. Shown against the capability
+ itself; `validateSkillSource` is still what refuses the save. */
+ const unconfigured = unconfiguredCapabilities(draft);
/* The other half of the same definition, when it has one. Read-only: this
editor does not own it, but hiding it would misrepresent the skill. */
@@ -312,7 +305,16 @@ export default function OwliverSkillEditor() {
>
Skills
-