Make a conversation a registry, and add the second one
Some checks failed
CI / check (push) Failing after 4m57s

`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.

So the control flow is now `conversationFlow.js` and each kind of record is a
REGISTRY. Which conversation a skill runs is the skill's own `flow:` line,
resolved through a map: `routing.js` used to say `if (skill.id ===
'create-position')`, which made a second conversational skill a change to the
router rather than a file on disk — the `if agent_key == ...` shape the
platform rules out one level up. The panel's write callback is likewise a map
keyed by flow id instead of an `onCreatePosition` prop, and the outcome wording
comes off the registry, so nothing in the panel names a kind of record any
more.

`create-employee-role` is the second registry. The worker is asked for and
never assumed: a conversation that names nobody re-asks rather than falling
back to the session, because an operator records this on somebody's behalf. Its
`extract` is deliberately narrower than the posting's — "bartender, weekends,
$30/hr" settles three fields and leaves the subject alone, since guessing WHO a
record is about from a fragment is how a role gets filed against the wrong
person.

THE CONFIRMATION STEP ACCEPTED "create position" AND SILENTLY REJECTED "create
positions" — the plural the Positions page itself uses. An anchored regex missed
it, and the reader got the summary back with no indication of what was wrong
with what they said, which is indistinguishable from the screen not having
updated. Matching is now exact membership against a normalized reply, so a
vocabulary is a list of phrases somebody can read rather than an expression
somebody has to parse.

Two bugs in `extractRole`, both of which fabricated a value nobody typed on the
one field a position cannot be created without:

  - The phrase pattern marks "new" as the role by the same grammar that marks
    "sous chef", so "create new position" opened the conversation titled "New".
    The scaffolding is a PHRASE at least as often as a single word, so a
    per-word test still produced "Brand New" and "One More". Scaffolding words
    are now stripped to DECIDE whether the phrase named anything, and the
    ORIGINAL phrase is returned when it did — strip to test, never to rewrite,
    or "second chef" becomes "Chef" and the cure is worse than the bug.

  - `(?:a|an)?\s*` has no word boundary, so it matched the leading "a" of
    "another" and the capture began mid-word. That mangled scaffolding into
    "Nother New" and, worse, corrupted every role introduced with "an":
    "create an open kitchen lead position" titled the position "N Open Kitchen
    Lead". A real role, typed correctly, silently wrong. Found by mutation
    testing the first fix.

`@companies` and `@workers` resolve from data the panel already holds — postings
and profiles the API has already scoped to the caller — so neither widens
anybody's view and neither costs a request. The company list is deliberately
unsorted: `useJobPostings` asks for `-created_date`, so the clients staffed for
most recently come first, and the panel does no ranking of its own. That last
part is a rule the suite enforces structurally, and it is the right rule — a
second opinion formed in the panel outranking the server's is exactly the kind
of thing that decays quietly.

`npm test` now refuses a conversation step whose field its registry does not
define. `stepsOf` drops unknown fields, so a typo means the flow asks fewer
questions than the file lists — and a skill whose steps are ALL unknown asks
none, jumps to the summary, and offers to write an empty record. Nothing errors
and the Markdown still reads correctly. 970 checks, up from 924; the new ones
walk both conversations end to end, because a wrong answer at the confirmation
step re-renders the same summary a right answer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-09-02 15:29:48 +05:30
parent 1a0dc7e5f1
commit e02a0c23d4
20 changed files with 1708 additions and 533 deletions

View File

@@ -9,7 +9,8 @@ import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
useGenerateJobDescription,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
usePreferences, useRoleCategories,
@@ -441,6 +442,61 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* Every conversation's write, keyed by the flow's id.
*
* `useAssistant` looks the writer up by the flow the answering skill declares,
* so adding a conversation is adding an entry here rather than another prop
* threaded through the panel.
*/
const createRole = useCreateEmployeeRole();
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_employee_role', { draft, skill, status });
if (result?.type !== 'create_employee_role') return null;
return createRole.mutateAsync(result.data);
}, [createRole]);
const flowWriters = React.useMemo(() => ({
position: createPosition,
'employee-role': createEmployeeRole,
}), [createPosition, createEmployeeRole]);
/**
* The clients this organization already staffs for.
*
* Distinct company names off the postings the panel has already loaded for
* this caller — org-scoped by the API, and nothing here widens that. They are
* offered as chips on the conversation's company question so an existing
* client is a tap, while typing a name that is not on the list is still how a
* new one is named. There is no company record to create: see the `@companies`
* note in `lib/skills/flows/position.js`.
*
* Deliberately NOT sorted here. The order is the one the postings arrived in
* — the API's `-created_date` — so the clients staffed for most recently are
* the ones offered first, and the panel does no ranking of its own. That last
* part is a rule `npm test` enforces structurally, and it is the right rule:
* a second opinion formed in the panel outranking the server's is exactly the
* failure that decays quietly.
*/
/**
* The workers a role can be recorded against.
*
* The profiles the panel already holds for this caller — org-scoped by the
* API. The conversation offers the names as chips and resolves a pick back to
* the profile id and email, so the row names a real person rather than
* whatever was typed. The operator is never the subject: the question is
* required and there is no fallback to the session.
*/
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
id: w.id,
name: w.full_name || w.name || '',
email: w.email || '',
})).filter((w) => w.email), [facts.profiles]);
const companies = React.useMemo(() => [...new Set(
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
)], [facts.postings]);
/**
* What to ask next, from the server, after something has been written.
*
@@ -562,7 +618,9 @@ export default function KrowAssistant({
pageLabel: context.page,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
flowWriters,
companies,
workers,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
onGenerateDescription,

View File

@@ -23,7 +23,8 @@ import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
} from '@/lib/skills/actions';
import { beginPositionFlow } from '@/lib/skills/positionFlow';
import { beginFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
/**
* Intent routing — deciding whether a question belongs to the page you are on.
@@ -633,7 +634,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
*/
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext = null,
companies = [], skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
@@ -728,15 +729,21 @@ function resolveSkill({
}
/**
* Create Position is collected in the conversation, not in a form.
* A conversational skill is collected in the chat, not in a form.
*
* Nothing opens and nothing is navigated to: the skill's questions come back
* as a reply and its answers as chips, and the position is written at the end
* as a reply and its answers as chips, and the record is written at the end
* from what the conversation gathered. `flow` is the state that turn carries
* forward — the panel keeps it and feeds the next answer back in.
*
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
* second conversational skill a change to the router rather than a file on
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
*/
if (skill.id === 'create-position') {
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
const registry = flowFor(skill);
if (registry) {
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
}
/**
@@ -855,6 +862,10 @@ function resolveDraftAction(question, workforce, positionId = null) {
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
company question. Read off the postings the caller can already see, so it
expands nobody's view — see the `@companies` note in flows/position.js. */
companies = [],
/**
* The active agent, and where the reader is.
*
@@ -902,7 +913,7 @@ export function resolveIntent({
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */

View File

@@ -6,9 +6,8 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import { advanceFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
import {
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
publishedReply, weightsSetReply, weightsUnchangedReply,
@@ -237,7 +236,16 @@ function withoutAuthoringActions(messages = []) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
/**
* How each conversation's record gets written, keyed by the flow's id.
*
* A single `onCreatePosition` prop was the last place the panel named one
* kind of record. A second conversation needed a second prop, a second branch
* at the write, and a second set of outcome renderers — three edits to answer
* "and now employee roles too". This is one entry in a map.
*/
flowWriters = {},
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
@@ -245,6 +253,12 @@ export function useConversation({
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
company question. Derived from postings the caller can already read. */
companies = [],
/* The worker profiles a declared role can be recorded against, as
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
workers = [],
/**
* The active agent and where the reader is.
*
@@ -475,7 +489,10 @@ export function useConversation({
intent = {
kind: 'flow',
skill,
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers },
}),
};
}
}
@@ -490,7 +507,7 @@ export function useConversation({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
customSkills, roles, skillCategories, companies,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
@@ -509,13 +526,19 @@ export function useConversation({
* be true when it is said.
*/
if (intent.kind === 'flow' && intent.create) {
/* The skill says which conversation this is, so the write and the wording
of its outcome both come from that registry rather than from a name
hardcoded here. */
const registry = flowFor(intent.skill);
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
created = await flowWriters[registry.id]?.(
intent.create.draft, intent.skill, intent.create.status
);
} catch (error) {
created = null;
failure = error;
@@ -551,19 +574,16 @@ export function useConversation({
intent = {
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [...createdFollowUp(created), ...refreshed],
doc: registry.outcome.created(created),
followUp: [...registry.outcome.followUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(failure?.message),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
doc: registry.outcome.failed(failure?.message),
followUp: registry.outcome.retryChips,
};
}
}
@@ -785,12 +805,12 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext,
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
agent, agentCoversPage, agentSuggestion, owliverContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);