fix error line
This commit is contained in:
@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
|
||||
* consumer stay unchanged.
|
||||
*/
|
||||
|
||||
const AuthContext = createContext();
|
||||
const AuthContext = createContext(/** @type {any} */ (null));
|
||||
|
||||
/** @param {any} props */
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
|
||||
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))
|
||||
? Math.max(1, Math.round((new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / 86400000))
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useCurrentUser() {
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updateMe(patch),
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
@@ -46,7 +46,7 @@ export function usePreferences() {
|
||||
export function useUpdatePreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updatePreferences(patch),
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
@@ -96,7 +96,7 @@ export function useStaff() {
|
||||
export function useUpdateStaff() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
},
|
||||
@@ -120,7 +120,7 @@ export function useCertifications() {
|
||||
export function useCreateCertification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.Certification.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||
},
|
||||
@@ -130,7 +130,7 @@ export function useCreateCertification() {
|
||||
export function useDeleteCertification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id) => base44.entities.Certification.delete(id),
|
||||
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||
},
|
||||
@@ -140,7 +140,7 @@ export function useDeleteCertification() {
|
||||
export function useCreateRoleCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.RoleCategory.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export function useUserActivity() {
|
||||
export function useCreateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.JobPosting.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||
logActivity('create_position');
|
||||
@@ -168,7 +168,7 @@ export function useCreateJobPosting() {
|
||||
export function useUpdateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.JobPosting.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
||||
@@ -179,7 +179,7 @@ export function useUpdateJobPosting() {
|
||||
export function useCreateApplication() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.JobApplication.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
logActivity('apply_job');
|
||||
@@ -190,7 +190,7 @@ export function useCreateApplication() {
|
||||
export function useUpdateApplication() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export function useUpdateApplication() {
|
||||
export function useScreenCandidate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||
const result = await screenCandidate(application, job);
|
||||
return base44.entities.JobApplication.update(application.id, {
|
||||
status: 'ai_screened',
|
||||
@@ -223,7 +223,7 @@ export function useScreenCandidate() {
|
||||
export function useScreenAllCandidates() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ applications, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
|
||||
const results = [];
|
||||
for (const app of applications) {
|
||||
if (app.status === 'applied' || app.status === 'ai_screened') {
|
||||
@@ -253,7 +253,7 @@ export function useScreenAllCandidates() {
|
||||
export function useGenerateJobDescription() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ data, draftId }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
|
||||
const result = await generateJobDescription(data);
|
||||
if (draftId) {
|
||||
await base44.entities.JobPosting.update(draftId, {
|
||||
@@ -274,7 +274,7 @@ export function useGenerateJobDescription() {
|
||||
export function useHireCandidate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
|
||||
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
|
||||
await base44.entities.Staff.create({
|
||||
@@ -301,14 +301,14 @@ export function useHireCandidate() {
|
||||
|
||||
export function useMatchTalentForJob() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
||||
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInterview() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.AIInterview.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
@@ -339,7 +339,7 @@ export function useCourse(id) {
|
||||
export function useCreateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.Course.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
logActivity('create_skill_training');
|
||||
@@ -351,7 +351,7 @@ export function useCreateCourse() {
|
||||
export function useUpdateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
|
||||
@@ -385,7 +385,7 @@ export function useAssignments() {
|
||||
export function useAssignWorkers() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ position, workers = [], applications = [] }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
|
||||
const startsAt = position.start_date
|
||||
? new Date(position.start_date).toISOString()
|
||||
: new Date().toISOString();
|
||||
@@ -484,7 +484,7 @@ export function useAssignWorkers() {
|
||||
export function useMarkInterviewReady() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, position }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
|
||||
if (!application?.id) throw new Error('No application to schedule against');
|
||||
|
||||
const updated = await base44.entities.JobApplication.update(application.id, {
|
||||
@@ -564,7 +564,7 @@ export function useWorkerProfiles() {
|
||||
export function useUpdateWorkerProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
@@ -575,7 +575,7 @@ export function useUpdateWorkerProfile() {
|
||||
export function useCompleteCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ profile, course, quizScore }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
|
||||
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
|
||||
const completed_courses = already ? profile.completed_courses : [
|
||||
...profile.completed_courses,
|
||||
@@ -616,7 +616,7 @@ export function useEvidenceList(workerEmail) {
|
||||
export function useSubmitChallenge() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ profile, course, type, mediaUrl, transcript, identified }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
|
||||
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
|
||||
const verdict = result.verdict || 'needs_work';
|
||||
const score = Math.round(Number(result.score) || 0);
|
||||
@@ -674,7 +674,7 @@ export function useSubmitChallenge() {
|
||||
export function useVerifyEvidence() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ evidenceId }) =>
|
||||
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
|
||||
base44.entities.Evidence.update(evidenceId, {
|
||||
supervisor_verified: true,
|
||||
supervisor_name: 'Supervisor',
|
||||
|
||||
@@ -397,7 +397,7 @@ const RESOLVERS = {
|
||||
/** 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))
|
||||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0).getTime() - new Date(a.hire_date || a.created_date || 0).getTime())
|
||||
.slice(0, section.limit || 10)
|
||||
.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
@@ -432,7 +432,7 @@ const RESOLVERS = {
|
||||
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))
|
||||
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 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);
|
||||
@@ -466,7 +466,7 @@ const RESOLVERS = {
|
||||
/** 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))
|
||||
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
|
||||
.slice(0, section.limit || 10)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
|
||||
@@ -41,14 +41,68 @@ function parseFrontmatter(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
/**
|
||||
* The text under a `## Heading`, up to the next one.
|
||||
*
|
||||
* The heading is escaped before it becomes a pattern. It used to be
|
||||
* interpolated raw, so a heading containing regular-expression punctuation —
|
||||
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
|
||||
* not match the words it was built from, and the whole section silently read as
|
||||
* absent. A section that is present and unreadable is the failure this parser
|
||||
* must never have: it looks exactly like a section the author did not write.
|
||||
*/
|
||||
function sectionSource(body, heading) {
|
||||
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
return section ? section[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List items under a `## Heading`, for the capability list shown in Settings.
|
||||
*
|
||||
* Two kinds of content were being dropped without a word, and both are the same
|
||||
* mistake — treating "a line I do not recognise" as "a line that is not there":
|
||||
*
|
||||
* - **Wrapped items.** A bullet long enough to run onto a second line kept
|
||||
* only its first line. `create-position.md` documented reading a request
|
||||
* "out of a single sentence" and the registry held the sentence without its
|
||||
* last three words. An item is now everything up to the next item or the
|
||||
* blank line that ends the list.
|
||||
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
|
||||
* write ordered instructions — parsed as an empty section. Both markers are
|
||||
* list items in Markdown and both are read as one here.
|
||||
*
|
||||
* Neither change alters any definition currently on disk: every one of them
|
||||
* uses single-line `-` bullets, so this widens what can be written without
|
||||
* moving what already was.
|
||||
*/
|
||||
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
|
||||
|
||||
function sectionBullets(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return [];
|
||||
return section[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
|
||||
.filter(Boolean);
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return [];
|
||||
|
||||
const items = [];
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
items.push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
/* A blank line closes the current item; anything else indented under one is
|
||||
its continuation and belongs to it. Prose before the first item — the
|
||||
explanatory paragraph `## Conversation` opens with — matches neither and
|
||||
is ignored, exactly as before. */
|
||||
if (!line.trim()) {
|
||||
if (items.length) items.push('');
|
||||
continue;
|
||||
}
|
||||
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
|
||||
items[items.length - 1] += ` ${line.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => i.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +137,11 @@ function sectionSteps(body, heading) {
|
||||
* rather than a list.
|
||||
*/
|
||||
function sectionText(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return '';
|
||||
return section[1]
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return '';
|
||||
return source
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
||||
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseSkill(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The registry as actually assembled, with everything that went wrong assembling
|
||||
* it.
|
||||
*
|
||||
* This function exists because the three ways a definition could fail to arrive
|
||||
* intact were all *silent*, and between them they are the whole of the
|
||||
* "sometimes a skill only half works" report:
|
||||
*
|
||||
* 1. **A stored definition that will not parse was dropped.** The `catch`
|
||||
* here returned `null` and the entry vanished. Nothing anywhere said a
|
||||
* skill had been discarded, so the skill simply stopped answering and the
|
||||
* registry looked healthy.
|
||||
* 2. **A definition that parses can still register incomplete.**
|
||||
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
|
||||
* the rest as errors — correct, and the reason a single bad response does
|
||||
* not cost an author their whole file. But only `validateSkillSource`
|
||||
* reads those errors, and that runs in the authoring dialog. A definition
|
||||
* arriving from storage was registered with its broken capability quietly
|
||||
* missing, which is exactly "some checks work and others do not".
|
||||
* 3. **A custom definition silently replaces a built-in of the same id.**
|
||||
* Intended — it is how a shipped skill is overridden — but indistinguishable
|
||||
* from the built-in having broken, because nothing reported the override.
|
||||
*
|
||||
* Why deployment exposed all three: custom definitions live in per-account
|
||||
* `preferences`, not in the repository. The bundled `.md` files are identical
|
||||
* everywhere — verified — so a definition that behaves differently in a
|
||||
* deployed workspace differs because of what that *account* has stored, and
|
||||
* because stored Markdown is validated when it is written and never again.
|
||||
* Ship a change to the supported vocabulary and yesterday's valid definition
|
||||
* silently loses a capability on next load.
|
||||
*
|
||||
* Nothing is dropped that used to register, and nothing new registers. The only
|
||||
* change is that the failures now have somewhere to be read from.
|
||||
*/
|
||||
export function readSkillRegistry(customSources = []) {
|
||||
const diagnostics = [];
|
||||
const custom = [];
|
||||
|
||||
customSources.forEach((entry, i) => {
|
||||
const path = entry?.path || `custom/${i}.md`;
|
||||
let skill = null;
|
||||
try {
|
||||
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!skill?.id) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: 'A stored skill has no `id` and is not registered.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
custom.push(skill);
|
||||
});
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
for (const skill of custom) {
|
||||
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'shadowed',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
|
||||
});
|
||||
}
|
||||
byId.set(skill.id, skill);
|
||||
}
|
||||
|
||||
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/* A registered definition that lost part of itself on the way in. Reported
|
||||
for every skill, not only custom ones, so a file on disk that stops
|
||||
resolving after a vocabulary change is just as visible. */
|
||||
for (const skill of skills) {
|
||||
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'incomplete',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two definitions claiming one phrase on one page.
|
||||
*
|
||||
* Only the first is ever consulted, and "first" means alphabetically by name —
|
||||
* so the losing definition answers nothing, with no way to tell that from it
|
||||
* being switched off. Reported per page, because a shared trigger on two
|
||||
* different pages is not a contest.
|
||||
*/
|
||||
const claims = new Map();
|
||||
for (const skill of skills) {
|
||||
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
|
||||
for (const page of skill.pages) {
|
||||
const key = canonicalPage(page) || page;
|
||||
for (const trigger of new Set(skill.triggers)) {
|
||||
const at = `${key}::${trigger}`;
|
||||
if (!claims.has(at)) claims.set(at, []);
|
||||
claims.get(at).push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [at, claimants] of claims) {
|
||||
if (claimants.length < 2) continue;
|
||||
const [page, trigger] = at.split('::');
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'trigger-collision',
|
||||
path: claimants[0].path,
|
||||
skillId: claimants[0].id,
|
||||
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
|
||||
+ `Only ${claimants[0].id} is consulted.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in skills plus any the account has added.
|
||||
*
|
||||
@@ -330,21 +517,17 @@ export const SKILLS = Object.entries(FILES)
|
||||
* is the same artefact a file on disk would be — nothing is half-parsed into a
|
||||
* bespoke record. A custom skill sharing an id with a built-in replaces it,
|
||||
* which is how one would be overridden without editing the repository.
|
||||
*
|
||||
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
|
||||
* done with its failures kept.
|
||||
*/
|
||||
export function allSkills(customSources = []) {
|
||||
const custom = customSources
|
||||
.map((entry, i) => {
|
||||
try {
|
||||
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((s) => s && s.id);
|
||||
return readSkillRegistry(customSources).skills;
|
||||
}
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
custom.forEach((s) => byId.set(s.id, s));
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
/** Everything that went wrong assembling the registry, for the Skills page. */
|
||||
export function skillDiagnostics(customSources = []) {
|
||||
return readSkillRegistry(customSources).diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
/**
|
||||
* Every skill on this page whose triggers match — in registry order.
|
||||
*
|
||||
* `matchSkill` answers with the first and is what routing uses, because one
|
||||
* question gets one answer. The full list is what makes the choice *auditable*:
|
||||
* when two definitions claim the same phrase, the loser was previously
|
||||
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
|
||||
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
|
||||
* without either file's triggers changing. Nothing here changes which skill
|
||||
* answers; it makes the fact that there was a contest something a caller can
|
||||
* see and report.
|
||||
*/
|
||||
export function matchSkills(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
return skillsForContext(contextId, disabled, customSources).filter(
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
);
|
||||
}
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,3 +688,28 @@ export function matchSkill(question, contextId, disabled = [], customSources = [
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
/**
|
||||
* AI agent skills — the ones Workspace & Skills governs.
|
||||
*
|
||||
* Two different things live in this registry, and they belong to two different
|
||||
* products:
|
||||
*
|
||||
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
|
||||
* An Owliver skill teaches it what it can be asked; a Board skill draws a
|
||||
* section on a page. Both are governed in Workspace & Skills.
|
||||
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
|
||||
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
|
||||
* Forge, and is read through `workforceSkillStates`.
|
||||
*
|
||||
* This exists so no surface has to reach for `allSkills()` and hope. A page that
|
||||
* calls `allSkills()` gets both kinds, which is how five training paths ended up
|
||||
* counted as Owliver capabilities on the Workspace landing page — the registry
|
||||
* was right and the reading was not. Asking for agent skills by name means a
|
||||
* training path added tomorrow cannot appear there by default.
|
||||
*/
|
||||
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
|
||||
|
||||
/** Workforce training definitions — KROW Forge's half of the same registry. */
|
||||
export const workforceTrainingSkills = (skills = []) =>
|
||||
skills.filter((s) => s.kind === 'workforce');
|
||||
|
||||
@@ -47,7 +47,7 @@ export function endsAt(position, today = new Date()) {
|
||||
|
||||
/** Days from now until the work starts. Negative or zero means it is live. */
|
||||
export const daysUntilStart = (position, today = new Date()) =>
|
||||
Math.round((startsAt(position, today) - today) / DAY_MS);
|
||||
Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
|
||||
|
||||
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
|
||||
export function startLabel(position, today = new Date()) {
|
||||
@@ -160,7 +160,7 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
partial fit for it, and anyone without is a full one. */
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free) return { covers: true, ratio: 1, note: null };
|
||||
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
|
||||
const months = Math.max(0, (free.getTime() - startsAt(position, today).getTime()) / MONTH_MS);
|
||||
return {
|
||||
covers: false,
|
||||
ratio: Math.min(1, months / 12),
|
||||
@@ -171,8 +171,8 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
|
||||
|
||||
const needed = end - startsAt(position, today);
|
||||
const have = Math.max(0, free - startsAt(position, today));
|
||||
const needed = end.getTime() - startsAt(position, today).getTime();
|
||||
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
|
||||
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
|
||||
|
||||
return {
|
||||
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
|
||||
* function existing separately from the mutation is what makes "show me first"
|
||||
* the only possible path rather than a convention someone has to remember.
|
||||
*/
|
||||
export function prepareAssignment(position, { count, ...context } = {}) {
|
||||
/** @param {any} position @param {any} [options] */
|
||||
export function prepareAssignment(position, options = {}) {
|
||||
const { count, ...context } = options || {};
|
||||
const status = workforceStatusFor(position, context);
|
||||
const take = Math.min(
|
||||
count ?? status.demand.remaining,
|
||||
|
||||
Reference in New Issue
Block a user