36 Commits

Author SHA1 Message Date
e676d259b2 docs: finalize TypeScript migration documentation
Some checks failed
CI / check (push) Failing after 5m5s
The migration is done and the documentation had not caught up. Two files,
no source changes.

README.md. Twenty-seven references still named modules by their old
extension — `main.jsx`, `krowHooks.js`, `AuthContext.jsx` and the rest.
Every one was checked to resolve under its new extension before being
touched. `store.js` is deliberately NOT among them: that file does not
exist under any extension, having been deleted when the transport moved
to HTTP, so "correcting" it to `.ts` would have replaced a visibly stale
reference with a plausible-looking false one. It stays as it is, with the
rest of that architecture section, for a separate pass.

Also: the assertion count 1691 -> 1693, since recapturing the baselines
replaced one check with three; and the Owliver skill count 18 -> 19,
which had been wrong since `create-employee-role` was added.

The "Known-failing checks" section is now "Check status", and the
rewrite is the part worth reading. It claimed two failures that no longer
exist, and my first attempt at replacing it merged two unrelated
histories into one sentence. They are now separate, because they are:

  - The 834/835 suite failure was `the seeded overtime climb is found`.
    It asserted against the live calendar — the oldest week in the window
    thinned as the week wore on and inflated the baseline every later
    week was compared against — so the climb was reported Sunday through
    Thursday and vanished on Friday and Saturday. That is a product
    defect, not a flaky assertion, and it was fixed in
    `src/lib/attendance.ts` at `88c412f` by dropping a leading week
    rostered well below the usual, only from the front so that a genuine
    collapse in the middle is still a finding.
  - The 59 type errors were resolved by this migration.
  - `the backend fixture is in step with this seed` failed for its own
    reasons and is recorded because it is easy to confuse with the first.
    Fixed at `3ddacf2` by teaching the generator to emit the `users`
    array the Go seeder reads, rather than by overwriting the fixture.

All three pass. The heading is kept rather than deleted so the absence of
failures is stated rather than merely implied.

MIGRATION_BASELINE.md is appended to, never edited: 68 lines added, zero
removed, and its first 141 lines are byte-identical to the previous
commit. The 2026-09-11 measurements — 71 errors, 1641/1642, 302 files
linted — are the thing the migration was checked against, so bringing
them up to date would destroy the comparison rather than update it. The
new dated entry is the other end of it, and records how the three items
that document left open were each closed.

  typecheck   0 errors
  lint        exit 0, 0 errors, 289 warnings
  npm test    1693/1693
  build       exit 0, bundle 74d17e2d… unchanged
  seed:check  in step
  owliver     matches the baseline

No file under `src/` changed, which is why the bundle hash cannot move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 15:52:12 +05:30
34505d7cf6 chore: refresh owliver baseline
Commit B of the baseline remediation, and independent of Commit A.

The baseline recorded 23 skills; the runtime resolves 24. The one addition
is `create-employee-role`, which appears in the skill list and in the two
contexts that offer it, `admin.positions` and `admin.talentPool`. Three
lines, three insertions, no deletions.

The drift is older than either workstream and was never a symptom of them.
`src/skills/owliver/create-employee-role.md` exists on `main` and at
`dca1842`; it was introduced by `6249e00`/`e02a0c2`, while this baseline was
last written at `eaf08e0`/`b2e6868` — before the skill existed. It is absent
from `dca1842`'s file list and from every commit on this branch. The skill
files and the baseline are byte-identical between `main` and HEAD, so `main`
captures the same drift.

Refreshing it cannot hide a migration regression, which is the only reason
it is safe to write. `owliver-capture.mjs` loads seven modules — the skills
registry, `placement`, `owliverResolver`, `dynamic`, `routing`, `insights`
and `seed` — and none of them is among the files whose emitted JavaScript
differs from `main`. Identical inputs, identical capture. What a regression
would look like here is a removal or a moved route, and there is neither:
routes are byte-identical at 18, the eleven contexts are the same eleven
with the same fields, and no skill was lost.

Written with `node scripts/owliver-baseline.mjs --write`, the mechanism the
file's own header names. Verified before the write by capturing to a scratch
path and diffing, and after it by confirming the written file is byte-
identical to that independent capture.

  owliver-baseline.mjs   "Owliver behaviour matches the baseline." — exit 0
  typecheck              0 errors
  lint                   exit 0, 0 errors, 289 warnings
  npm test               1693/1693, exit 0
  build                  exit 0, bundle 74d17e2d… unchanged

Nothing under `src/` changed, no Owliver skill, agent, route or context was
edited, the capture scripts are untouched, and Commit A's five files are
byte-identical to `878a235`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 01:00:54 +05:30
878a23532a test(baseline): recapture the three HTML baselines that held invented people
Commit A of the baseline remediation. The Owliver baseline is deliberately
untouched and is Commit B.

Five of the six failing checks were the `DEMO_FILL` removal. `hiringRecords`
used to pad the hires list with five invented people so Hired History read
as a history rather than as three rows, and the padding reached Analytics
too, where "total hires" counted eight against a database holding three.
Because these baselines render with queries disabled, the padding was ALL
they contained. The sixth is Candidates becoming the final-selection queue,
which changed what its empty state says.

Verified before regenerating rather than after. Hired History's tag counts,
baseline -> now: `<td` 40->0, `<span` 51->13, `<div` 78->39, `<p` 26->7,
`<svg` 20->8. Analytics lost 269 class attributes and 238 words to the same
cause; Candidates gained exactly one class, the description paragraph under
"Nobody is awaiting a decision". Every generated file was checked for the
five invented names (none), for the intended new copy (present) and for its
node identities before it was put in place.

Renamed, because a file called `pre-migration` holding post-`dca1842`
markup is a lie in the filename. `activity-page`, `candidates-analysis`,
`control-center`, `positions` and `talent-pool` still hold genuine
pre-migration markup, still pass, and keep the name that says so — so the
`PAGES` table now carries each baseline's FILENAME rather than deriving one
suffix for all five.

The migration proof was replaced, not discarded. `Hired History added only
identity wrappers` compared tag tallies to assert that migrating the page
added exactly two `<div>`s and changed nothing else. That was true, and it
was checkable only while the DATA was frozen as well: the predicate
subtracts one render from another, so removing the invented hires moved
every count and the arithmetic stopped describing wrappers. Recapturing
would not have rescued it — with the baseline equal to the render the delta
is zero and a predicate demanding two can never hold — so left in place it
would have stayed red for a new reason. Three checks read the render
directly instead and need no frozen file:

    Hired History wraps exactly the node types registered to wrap
    Hired History identities are unique and name composed nodes
    Hired History identity wrappers carry no styling

They say what the tally said: `UiTreeRenderer` encloses a type registered
`wrap: true` in `<div {...attrs}>`, every other type takes the attributes on
its own root element, and a wrapper carries identity and no styling.

Writing them first, against the OLD baseline, is what caught my own error:
the second check began as "one identity per composed node" and failed at
4 of 5. Hired History composes five nodes and `hired-extensions-top` is an
extension slot that renders nothing while no skill is attached to it, so a
one-to-one rule would have asserted that every slot is always filled. It
asserts uniqueness and no strays instead — a node addressed twice, or an
identity naming nothing the page composed, would break the editor's and
Owliver's ability to name a node. Had the baselines been recaptured first,
that mistake would have been invisible.

`carries node identity in the DOM` was documented as needing pre-migration
markup. It does not — it reads only the live render — so it is unchanged and
still passes. The dead `tally` helper is removed with its last caller.

  typecheck   0 errors
  lint        exit 0, 0 errors, 289 warnings
  npm test    1693/1693, exit 0 — was 1685/1691
  build       exit 0, bundle 74d17e2d… unchanged
  owliver     still DRIFTED — that is Commit B, untouched here

No file under `src/` changed, which is why the bundle hash cannot move.
`owliver-baseline.json` is byte-identical, the backend is untouched, and CI
configuration is unchanged. The suite total rises 1691 -> 1693 because one
assertion became three; CI asserts `passed == total` and a floor of 900,
both satisfied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 00:52:53 +05:30
3ddacf269a fix(seed): emit the users array the backend seeder reads
The fixture generator was a version behind the seeder, and the gap was
the `users` key.

`internal/seeder` unmarshals `seed.json` into `{demoUser, users,
entities}` and writes every account in `users`, falling back to
`demoUser` alone when the key is absent. `fixtureFrom` never emitted it —
its comment still claimed the Go loader "ignores the rest", which stopped
being true when the seeder gained the field. So `npm run seed:check`
reported stale on every run, and that is the seventh suite failure, the
one that pre-dates the TypeScript migration.

What made this worth investigating rather than regenerating: the
committed fixture was NOT wrong. It carries two accounts — the demo
administrator and `employer@krow.app` — and running `npm run seed:fixture`
against the old generator would have written a fixture without them.
`krow-backend/docs/deploy-9d3192a.md` says so in as many words, and tells
anyone deploying not to run it.

The accounts are not deleted from a live database by that — the only
DELETE in the seeder is the shift-record prune, and `upsertUser`'s ON
CONFLICT deliberately leaves `password_hash` alone. The damage lands on
the next fresh environment, where `user_employer` would simply never be
created, leaving nobody to sign in as to reach the employer console —
which is the exact gap `seeder.go` records the account as having been
added to close.

The matching frontend half already existed, unmerged, on
`feat/krow-employee-pages` (7a4b61d). That branch is a 53-file console
restructure that renames the same files this migration renamed, so it is
not mergeable here. Only the three files that carry the contract are
taken: `EMPLOYER_USER`, `seedData.User` naming both accounts, and the
generator emitting `users`.

The proof is that nothing had to be written. With the generator corrected
its output is byte-identical to the fixture already committed in
krow-backend — 168,293 bytes, `cmp` clean — so `seed.json` was never
opened for writing, and its md5 and mtime are untouched.

  typecheck     0 errors
  seed:check    "seed.json is in step with src/api/seed.js", exit 0
  npm test      1685/1691, up from 1684 — the fixture check now reads
                "24 applications, byte-identical". The remaining 6 are
                exactly the documented dca1842 baseline failures.
  lint          exit 0, 0 errors, 289 warnings
  build         exit 0, bundle 74d17e2d… unchanged

The bundle being unchanged is itself a check: `demoUser.ts` IS in the
production bundle, and `EMPLOYER_USER` is a new export of it. Nothing in
the app imports it, so it is tree-shaken out and not one byte reaches the
shipped code.

No baseline artifact touched. krow-backend not modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-19 23:54:36 +05:30
2f9b3baa56 chore(ts-migration): remove the migration-only TypeScript configuration
`src` holds 321 TypeScript files and no JavaScript, so four settings that
existed only to let the two coexist have nothing left to act on. Each was
removed on its own and validated before the next.

  allowJs / checkJs   Removed together, because they are coupled: `checkJs`
                      without `allowJs` is TS5052, a configuration error
                      rather than a code one. Verified first that nothing
                      under `src` imports a `.js` module. The program still
                      resolves the same 321 files and still reports 0 errors.

                      This also closes a door. With `allowJs` on, a `.js`
                      file added under `src` would be compiled and bundled
                      silently; now it is a resolution failure, which is the
                      right outcome for a codebase that has finished
                      migrating.

  jsconfig.json       Deleted. It had been inert since `tsconfig.json`
                      appeared — TypeScript ignores a jsconfig wherever a
                      tsconfig sits beside it — and it still carried the old
                      `types: []` and the three stale excludes. A second,
                      unread copy of the options is an invitation to edit the
                      wrong file.

  components.json     `"tsx": false` -> `true`, so `npx shadcn add` emits
                      TSX. No runtime effect; it would have quietly
                      reintroduced `.jsx` into a repository that has none.

`README.md` documented `npm run typecheck` as `tsc -p ./jsconfig.json`
with `checkJs`, which named a file that no longer exists. Corrected, along
with a stale assertion count in the same table (835, against 1691 today).

No file under `src` changed, so there is nothing for a per-file emitted-JS
comparison to compare; the bundle hash is the check that matters and it is
unmoved.

  typecheck   0 errors, 321 files in the program
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  owliver     unchanged; baselines still pinned, not recaptured
  build       exit 0, bundle 74d17e2d… identical

Left in place deliberately, reported rather than removed:
`scripts/ssr-resolve.mjs`. Its own comment says it can go once `src` holds
no `.js`, but that is only half the condition — the 203 literal `.js`
paths inside `skill-check.mjs` would all have to be renamed first, and
that is a large diff against the file that guards this migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 18:57:37 +05:30
e8207038dd refactor(ts-migration): type src/api and the first of src/lib for noImplicitAny
Phase 12 step 1, in progress. The flag is not enabled yet — these are the
annotations it will require, landed first so the switch itself is a
one-line commit with a number that is already zero.

`noImplicitAny` projects 3496 errors across 242 files. This clears 179 of
them in 7 files: all of `src/api` (107 -> 0) and `workforce`,
`hiringRecords` in `src/lib`.

The leverage is real and worth recording, because it shapes the rest of
the step. Eight annotations on `aiEngine`'s prompt readers cleared 27
errors: where a value is `any`, every callback beneath it — `.map((c) =>
…)`, `.filter((r) => …)` — has no contextual type and errors on its own.
Typing the source fixes the callbacks for free, so this works bottom-up,
sources first.

Types are taken from what already exists wherever possible. The generated
entity types fit `workforce` and `hiringRecords` without a single
cascade: `JobPosting`, `JobApplication`, `Staff`, `AIInterview`,
`WorkerProfile`, `Assignment`, `Course`. `PreferencesUpdateResult` in
`src/types/user.ts` already described `updatePreferences`'s return.
`buildInsights` takes the other builders' outputs, so its parameters are
`ReturnType<typeof byDepartment>` and friends rather than a restatement
that could drift.

Two things are recorded rather than fixed:

`buildHires` probes four fields that are not `staff` columns —
`timeToHire`, `score`, `company` and `department` are absent from
`information_schema` and from the generated `Staff`. They are read as
fallbacks, so at run time they are always `undefined` and the other
branch always wins. `HireSourceRow` writes them down as optional so the
dead fallbacks are visible; removing the reads would be a behaviour
change. `Hire` likewise widens `profile_tier`, because the builder's
default `'skilled'` is lower-case where the column's check constraint
spells it `'Skilled'`.

`aiEngine`'s talent pool stays `any[]`. It is `JSON.parse` output from a
block embedded in a prompt, carrying computed fields like `match_score`
that no entity declares — typing it `WorkerProfile[]` would assert a
shape nothing validates.

One mistake worth keeping. I replaced an inline lookup with a hoisted
`const URGENCY = {…}`, and per-file esbuild said the output was
unchanged: with `--minify-syntax` it inlines a single-use const straight
back. The production bundle disagreed — `43e7f268` against `74d17e2d`.
Reverted to a type assertion, which erases. Hoisting reads as a tidy-up
and is a real change to the emitted code; the two checks disagreeing is
exactly why both are run.

  typecheck   0 under the committed config; 3317 under the probe, from 3496
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  build       exit 0, bundle back to 74d17e2d…
  emitted JS  7/7 identical

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 17:18:18 +05:30
21133a6064 chore(ts-migration): make the tsconfig root set authoritative
Phase 12 prerequisite. `exclude` still carried `src/components/ui`,
`src/api` and `src/lib` from `jsconfig.json`; each was meant to come off
as its tree was converted, in Phases 4, 5 and 6, and all three were
missed.

The miss was quiet by construction. Excluding a file only keeps it out of
the ROOT set — anything an included file imports is pulled in anyway — so
318 of 321 files were being checked regardless, and the omission cost
nothing visible. The three that were not checked are the ones nothing
under `src` imports: `api/seed.ts`, `api/attendanceSeed.ts` and
`lib/skills/positionFlow.ts`. They are not dead code. `scripts/` loads
them, `seed.ts` from six separate places, and they held four real errors
that no previous count in this migration has included.

All four were one cause. `MODULE_TABLE` is 36 rows of mixed literals, so
it infers as an array of the UNION of its column types and destructuring
a row gives every field `string | number | string[]` —
`SKILL_CATEGORY[skill_id]` then refuses a key that might be an array. It
is now written as the tuple it is. The migration plan predicted this
class of error for three other tables by name.

Doing this before the ratchet rather than after: a strictness flag
measured against an incomplete root set produces a number that grows
again later for reasons unrelated to the flag, and the whole method here
is that each step's error count means one thing.

  root set    321 files, was 318
  typecheck   0 errors, with every file in src now checked
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  build       exit 0, identical bundle hash 74d17e2d…
  seed.ts     emitted JS identical

`allowJs`, `checkJs` and `jsconfig.json` are deliberately left alone —
they come off at final cleanup, after strictness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 16:54:27 +05:30
78f1b44c2e chore(ts-migration): remove the now-redundant @ts-ignore in the agent registry
`import.meta.glob` needed a suppression while `tsc` modelled only the
standard `ImportMeta`. Phase 1 set `types: ["vite/client"]`, which
declares Vite's additions, and the directive has had nothing to suppress
since. Phase 1 scheduled its removal for Phase 11; this is that.

Removing it is the point rather than tidying. `@ts-ignore` suppresses
whatever the next line produces, so one that no longer applies is a
directive that would silently swallow a real error on that line later.
Verified redundant before removing: typecheck is still 0, and the emitted
JavaScript is identical.

`src/vite-env.d.ts` described this suppression as present, so its comment
is corrected too.

`src/` now contains no `@ts-ignore` or `@ts-expect-error` at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:54:55 +05:30
34acd63a80 fix(ts-migration): clear the last six pre-existing type errors
These six pre-date the migration. They were unfixable while the files
holding them were JavaScript with `checkJs`, because the fix in each case
is an annotation JSDoc cannot express. `src/` is TypeScript now, so:

  - `setWidthState.timer` in `AssistantPanelContext` is a debounce handle
    hung on the callback itself, so it survives re-renders without a ref.
    TypeScript has no way to describe an expando on a `useCallback`
    result, so the binding is `any`.
  - `runAction(name, { skill, ...payload } = {})` — the `= {}` default
    types the parameter `{}`, so reading `skill` off it looked wrong.
  - `Progression` in `Positions` is a local presentational component
    whose `className` is omitted at both call sites, like its siblings in
    the same file.
  - `normalizeSection`'s `page` reads as required because it has no
    default, but `owliverConfig` deliberately calls it without one: `page`
    only builds a fallback `where` label, and that caller passes `where`
    explicitly, so the branch that would read it never runs. Stating the
    parameter object makes `page` optional, which is what the function has
    always accepted.

`npm run typecheck` is now 0 errors, from 71 when this migration started
and 20 when Phase 11 began. Nothing was suppressed to reach it: there is
no `@ts-ignore` or `@ts-expect-error` added anywhere in this branch.

4/4 byte-identical, suite and bundle unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:44:33 +05:30
284a7e7671 refactor(ts-migration): Phase 11 batch 11 — the data pages
The last nine admin pages. `src/` now contains no `.js` or `.jsx` at all.

One error, and it was the same kind of thing the whole phase has been
finding: `getBandMembers(min, max)` in `TalentPool` is called with one
argument for the top band, and its own body reads
`max == null || s < max`. The parameter has always been optional
everywhere except the signature. The marker erases.

9/9 byte-identical, and the bundle still hashes to 74d17e2d…

Three of these nine — `Analytics`, `Candidates`, `HiredHistory` — are
the pages the Owliver baselines cover, and they carry six of the seven
pre-existing suite failures. Those failures are unchanged and still match
`ecf5e75` line for line, which is the point: if a rename had altered what
these pages render, it would have altered how they fail, and it did not.
The baselines are untouched, as they have been in every batch.

Phase 11 is 122/122 files and eleven batches. Across all of them:

  type erasure  122/122 byte-identical
  bundle        74d17e2d… unchanged from `ecf5e75` in every batch
  npm test      1684/1691 with the same 7 failures, in every batch
  typecheck     20 errors at the start, 6 now

The remaining 6 all pre-date the migration. They are no longer in
JavaScript, so they are now fixable; that is the next commit, not this
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:42:35 +05:30
a0f3f77900 refactor(ts-migration): Phase 11 batch 10 — the authoring and settings pages
Nine admin pages: `AgentDetail`, both skill editors, the three Workspace
pages, `SkillDevelopment`, `Settings` and `Profile`. 9/9 byte-identical,
bundle hash unchanged.

Six errors, five of them the same shape this phase has met repeatedly:
`Object.values(skill.ui || {}).flatMap((page) => page.sections)` yields
`unknown` because inference into the union parameter of `values` does not
distribute. Three copies of that line across the two editors and
`WorkspaceSkills`, plus one `Object.entries` and one `new Set` whose
element type reached a React `key`, where `unknown` is not allowed.

The sixth is worth its own line. `WorkspaceSkills.toggleSkill` builds a
preferences patch as `{ disabledSkills }` and then adds `customSkills` to
it conditionally, several lines later, when re-enabling a skill flips an
inactive definition back to active. The literal's inferred type does not
carry a key assigned after the fact, so the later write looked wrong.
The annotation names both keys and marks the conditional one optional,
which is what the function does.

Measured against `b99dc7c`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  113/113 byte-identical across Phase 11 so far

Nine files left in Phase 11, all data pages. No baseline artifact
touched — three of those nine are the pages the baselines cover, so the
recapture question arrives with the next batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:40:35 +05:30
b99dc7c576 refactor(ts-migration): Phase 11 batch 9 — the page composition tables
The eight `nodes` files under `src/pages/admin/*/`, which declare what
each admin page is made of. Renamed with no annotations needed at all:
zero new errors, 8/8 byte-identical, bundle hash unchanged.

They came through clean because everything they call was typed first —
`makeNode` and the registry in batch 1, the skill sections in batch 2.
A descriptor table is only as checkable as the constructor it feeds.

`positions/nodes.js` became `.tsx`, joining its seven `nodes.jsx`
siblings. That is the exact case `scripts/ssr-resolve.mjs` was written
for and says so in its comment: it is the only `.js` under `src/pages`,
`skill-check.mjs` loads it by literal path as `.js`, and the resolver
tries `.ts` and `.tsx` in turn. The suite loads it and still passes,
which is the first time that particular branch has been exercised.

Two of these files are also read as source TEXT rather than loaded —
`candidates/nodes.jsx` at `skill-check.mjs:6049` and
`talent-pool/nodes.jsx` at `:6298`, both through `resolveSourcePath`.
That is the second channel, the one that was missed in Phase 0 and found
by an ENOContent failure in Phase 4b-ii. Both resolve.

Measured against `7a95d95`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  104/104 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:38:29 +05:30
7a95d95151 refactor(ts-migration): Phase 11 batch 8 — the assistant panel components
The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.

39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.

The remaining six were already there before this batch and are
unchanged.

13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.

Measured against `3f835ee`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 24/24 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  96/96 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:37:09 +05:30
3f835eee93 refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules
The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.

79 errors, and two optional markers cleared 63 of them.

`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.

Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.

The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.

Measured against `dde4ba6`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  83/83 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:35:15 +05:30
dde4ba62c6 refactor(ts-migration): Phase 11 batch 6 — agent and skill components
The fifteen files under `src/components/agents/` (including `skills/`)
and `src/components/skills/`. 15/15 erase byte-identically; bundle hash
unchanged.

Renaming raised 43 errors and 29 of them came from one thing: these
files carry 24 `/** @param {any} props */` annotations, one per
component, and JSDoc stops applying at the extension boundary. Without
them TypeScript infers every destructured prop as required, so a call
site passing seven of nine props is an error — which is how a file that
declared its props `any` ended up with fourteen complaints about missing
`className`. Restoring the author's own declaration as `: any` is not
blanket typing; it is the annotation that was already there, in the only
form that still works.

`Workspace` in `AgentCanvas` was the one component in that file its
author left without the hatch. It now matches its siblings.

The rest were five separate things:

  - `React.isValidElement(children)` no longer narrows enough to read
    `children.props.id`: React 19 types `ReactElement`'s props as
    `unknown`. `isValidElement<any>` says what the `cloneElement` call
    beneath it has always assumed. The migration plan predicted this
    site by name.
  - `useSkillSections(page, placement)` is called with one argument by
    `UiEditingProvider`, which its doc comment explicitly permits —
    "called with no placement it returns every section on the page". The
    parameter simply lacked its optional marker. The marker erases, so
    the emitted signature is unchanged.
  - `new Date(b.at) - new Date(a.at)` is valueOf coercion, which
    JavaScript performs and TypeScript refuses to describe. Cast rather
    than rewritten to `.getTime()`: that would change the emitted code,
    and this comparison orders the list.
  - `Object.values<any>` on a tally, the same inference gap as earlier
    batches, which also fixed a `ReactNode` complaint downstream of it.

All fifteen are linted under their new extensions, checked by name.

Measured against `446df7b`:

  typecheck   6 errors, down from 9; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 15/15 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  72/72 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:32:14 +05:30
446df7b37b refactor(ts-migration): Phase 11 batch 5 — ui-tree and ui-editor
The eleven files that render and edit the node tree: six in
`src/components/ui-tree/`, five in `src/components/ui-editor/`. The
first `.jsx` -> `.tsx` of this phase.

Renaming raised six errors, all in one file, and one annotation cleared
all six. `UiNodeBoundary` is a class component declared
`extends React.Component` with no type arguments, so both its props and
its state are `{}` — which is why reading `this.props.node` and
`this.state.failed` looked wrong. `UiNodeBoundaryProps` and
`UiNodeBoundaryState` write down what the class already uses: `node` and
`children`, and `failed` plus `forNode`. `forNode` is what stops the
boundary staying latched after a broken node is hidden, so it is part of
the contract rather than an implementation detail.

Nothing else needed anything. That is the earlier batches paying off:
these files consume `lib/ui`, which was typed in batch 1, so the tree and
node values arriving here are already described.

The `.jsx` -> `.tsx` move is the one that can change emitted output —
esbuild's `tsx` loader elides unused imports where the `jsx` loader does
not, which cost a 942-byte bundle change in Phase 10. It did not happen
here: all eleven erase byte-identically, and the bundle hash is
unchanged.

ESLint now reports all eleven under their new extensions, checked by
name rather than by count. That is the failure Phase 0 existed to
prevent — `.tsx` outside the globs would have dropped them silently while
`eslint .` went on exiting 0.

Measured against `55ddaa1`:

  typecheck   9 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 11/11 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  57/57 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:28:01 +05:30
55ddaa134a refactor(ts-migration): Phase 11 batch 4 — the agent layer
All thirteen modules under `src/lib/agents/`. Renamed and annotated; no
logic touched. 13/13 erase to byte-identical JavaScript, and the bundle
still hashes to 74d17e2d…

Project typecheck errors are now 9, down from the 20 this phase started
from. Nothing was suppressed: batch 3's `Frontmatter` cleared 4 and this
batch's annotations cleared 7 more that had been sitting in `agentStore`,
`runtime` and `useAgents` since before the migration.

Two findings, both recorded rather than fixed:

`agentSkillIds(agent)` takes one parameter and is called with two, at
`runtime.ts:83` and `:95`. Not a bug — its own doc comment says so:
subagent skills were deliberately removed from it, because CLAUDE.md §3
makes `subagents` a delegation list rather than a skill list, and
"`agents` is still accepted so every call site keeps working; it is no
longer read." The contract is restored with an overload signature, which
emits no JavaScript — confirmed by reading the emitted output, where
`agentSkillIds` still takes exactly one parameter. Worth knowing that
`agentScopedDisabledWith` therefore computes what `agentScopedDisabled`
computes; that is intended, and `skill-check.mjs` asserts the behaviour
at eleven call sites.

`useAgents` returns five different shapes from eleven `return`
statements, which is the latent problem the migration plan predicted
here. `AgentActionResult` writes them down, but open: `ok` plus four
optional fields. Nothing stops a caller reading `.agent` off a failure
and getting `undefined` — `AgentDetail.jsx` reads `.conflict` and
`.error` off the same value. Closing it properly needs `as const` on
eleven literals so `ok` stops widening to `boolean` and starts
discriminating, which is an edit to these function bodies and not
something a rename may do. The type is the record of the decision, not
the decision.

Writing that interface also corrected my own count. I described four
shapes; the compiler rejected `duplicate`'s `{ ...result, id }` and made
it five.

Other annotations: React Query v5 infers `void` for an unconstrained
`mutationFn` parameter, so both mutations in `agentStore` had their
variables stated; `existingIds = []` in `agentLifecycle` infers
`undefined[]`, which rejects `.includes(id)`, so it is `string[]`; two
accumulators and two inline JSDoc hatches restated as annotations.

Measured against `fc8d7ee`:

  typecheck   9 errors, down from 16; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  type erasure  46/46 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:25:35 +05:30
fc8d7eec52 refactor(ts-migration): Phase 11 batch 3 — the rest of the skills layer
The remaining fifteen modules under `src/lib/skills/`, including the
three under `flows/`. `src/lib/skills` now holds no JavaScript.

Renaming them raised 134 errors, which came from nineteen values, not
134 places. Eleven were accumulators or parameters written `= {}`, whose
type is then `{}` — an object with no properties — so every later read of
a key looked like a mistake. Five were `Object.entries`/`values` on a
dynamic value, which yields `unknown` rather than `any` because
inference into their union parameter does not distribute. The rest were
`reduce` accumulators in the same position.

Annotating the nineteen sources cleared all 134. Where the keys were
knowable they are written down rather than waved away: both `prefill`
accumulators in `actions.ts` name the fields their own following lines
assign, and `dataResolver`'s two event tallies are
`Record<string, number>`, which is what they are. Where the value is
genuinely whatever an author wrote — a parsed YAML mapping, a skill
context — it stays `any`.

The one structural addition is `Frontmatter`, the return of
`parseFrontmatter`. Its no-frontmatter early return hands back a literal
`{}`, so TypeScript took the common shape of the two returns, which has
no properties; that single empty object is what made twenty-five later
readings of `data` look wrong. Typing the return also resolved four
pre-existing errors in this file and four more that had cascaded into
`lib/agents/registry.js`, so the project total is 16, below the 20 this
phase started from. Nothing was suppressed to get there.

Measured against `e73929f`:

  typecheck   16 errors, down from 20; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  33/33 byte-identical, all of Phase 11 so far

CORRECTION to the previous two commits. Both claim the migrated files
emit "byte-identical minified JavaScript". That check was broken when it
ran and proved nothing: it passed `--loader=js`/`--loader=ts` to esbuild
on named files, and esbuild accepts `--loader` without an extension only
for stdin. Both sides errored, both outputs were empty, and `cmp` found
two empty files equal. Eighteen "IDENTICAL" lines meant eighteen pairs of
nothing.

Repaired here and re-run over all 33 files. Two further things had to
change for the check to mean anything. It now proves it can detect a
difference before it is trusted, against a pair of files differing in one
character. And it compares with `--minify-whitespace --minify-syntax`
rather than `--minify`: full minification renames locals, and esbuild's
choice of names shifts with token counts, so twelve files differed only
in whether a binding was called `g` or `u` — alpha-equivalent, at
identical byte counts. Stripping comments and whitespace while keeping
identifiers is the comparison that answers the actual question.

The result is that the substantive claim was true throughout, and is now
actually evidenced: all 33 files erase to byte-identical JavaScript. It
was never the only evidence either — the production bundle hash and the
1691-check suite were compared in every batch, both valid, and both
unchanged.

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:20:41 +05:30
e73929f47e refactor(ts-migration): Phase 11 batch 2 — the skills-layer leaves
The eight modules under `src/lib/skills/` that import nothing from their
own layer, plus `uiConfig`, which imports only `surfaces`. Renamed and
annotated; no logic touched.

All eight emit byte-identical minified JavaScript, and the production
bundle still hashes to `74d17e2d…`. Four of them are R100 — not one
character changed beyond the extension.

The annotations are four fixes of the same two kinds:

  - `Object.entries<any>` / `Object.values<any>` at three sites. Passing
    an `any` value to either yields `unknown`, not `any`, because
    inference into the union parameter of their signatures does not
    distribute — so `weightsOf`'s entries arrived unsortable and two
    `reduce` accumulators arrived un-addable. The explicit type argument
    restores what JavaScript had. No cast, no runtime change.
  - `FlowReply` as `assignmentPreview`'s return type. Two of its five
    branches genuinely return no `followUp` — "already fully staffed"
    and "nobody is both qualified and free" are answers with nothing to
    offer next — so `followUp` is optional, which is what `headcountSet`
    has always passed through.

One inert `/** @param {any} */` in `saveFeedback` became a real
annotation. Left as a comment it would have read as if it still did
something.

Measured against `eaa677f`, all unchanged:

  typecheck   20 errors, same files
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  emitted JS  8/8 byte-identical

One number moved and it is a reporting artifact, recorded here so it is
not misread next time: ESLint's file count went 269 -> 261. `src/lib/**`
is in `ignores` for both config blocks and always has been, so no rule
has ever run on these files. A `.js` file there is still walked by
ESLint's default `**/*.js` glob and then ignored, which produces an entry
with zero messages; a `.ts` file matches no `files` pattern, so it is
never walked and produces no entry at all. Confirmed directly: linting
`registry.js` reports nothing, linting `yaml.ts` reports "File ignored
because no matching configuration was supplied." Zero rules applied
before, zero after. The counts that carry signal — 0 errors, 289
warnings — did not move.

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:55:59 +05:30
eaa677f815 refactor(ts-migration): Phase 11 batch 1 — the UI-node engine
Renames the ten modules under `src/lib/ui/` to TypeScript and annotates
them. No logic is touched: no reordered statements, no changed defaults,
no altered branches, no renamed locals, no edited strings.

The proof is mechanical rather than argued. esbuild's output for each of
the ten files, minified, is byte-for-byte what the `.js` file produced at
`ecf5e75`, and the production bundle hashes to `74d17e2d…` before and
after. `operations` and `patch` are pure renames — R100, not one
character changed.

What the annotations actually are:

  - Four `/** @type */` and `@param` JSDoc hatches the author had already
    written, restated as real annotations. These stop applying at the
    extension boundary, which is where most of the errors came from.
    `makeNode` keeps its declared `@returns {any}`; dropping it in favour
    of an inferred shape would have quietly narrowed a contract the
    author had deliberately left open.
  - `NodeTypeRegistry.types` as `declare`, not a field declaration. A
    plain one would emit a `defineProperty` under
    `useDefineForClassFields`, i.e. a change to the shipped JS. `declare`
    emits nothing, which the per-file comparison above confirms.
  - Two accumulators (`wants`, `params`) given the shape their own
    following lines build.
  - `Object.entries<any>(…)` at two sites. Inferring `any` into the
    union parameter of `entries` yields `unknown`, not `any`, so the
    rule objects arrived unreadable; an explicit type argument restores
    what JavaScript had, without a cast.
  - `UiEditMatch`, an open interface, as `matchUiEdit`'s return.

`UiEditMatch` is the one judgement call and it is deliberately weak.
`kind` is optional and the rest is an index signature, because the
nineteen return shapes share field names carrying different meanings and
the suite reads these objects in around forty places. Writing the real
discriminated union is a schema this phase has no mandate to invent, and
`kind` stays `string` rather than a literal union partly so that no
node-type name is ever written into a type — the engine check forbids
exactly that.

`kind` is optional for a reason worth recording: at run time the planners
guard with `if (subject.kind) return subject`, so a `kind`-less object
never escapes. TypeScript cannot see it, because every `kind` widens to
`string` and a property that is `string` in every member is not a
discriminant, so truthiness narrowing leaves a shape the function cannot
produce. Marking it required would have been true of the runtime and
rejected at five return sites, and the only fixes are edits to agent
logic. The weaker claim is the honest one.

Renaming these files also surfaced twenty errors in `uiEdit.js`, which is
still JavaScript: `.js` and `.ts` infer this union differently. Verified
as a property of the rename and not of any edit, by compiling the
verbatim `ecf5e75` contents under a `.ts` extension — same twenty. The
return annotation clears them.

Measured against `ecf5e75`, all unchanged:

  typecheck   20 errors, same files      (no new error anywhere)
  lint        exit 0, 269 files, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  emitted JS  10/10 byte-identical

The 7 failures and the `owliver-baseline.mjs` drift both pre-date this
commit — they are the parallel feature session's, present at `ecf5e75`
and measured there before this batch was applied. No baseline artifact
is touched; recapture waits until the agent migration is complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:50:33 +05:30
ecf5e75d76 chore(ts-migration): migrate the app shell, routing and the last non-agent pages
Phase 10: `App`, `main`, `index.html`, and the four remaining `src/pages` files.

ROUTING IS PROVEN UNCHANGED, not assumed. The 49 `<Route>` elements were
fingerprinted before the rename and compared after: byte-identical, all 44
paths, all 13 `<Navigate>` redirects, the same nesting under `ProtectedRoute`
and `AdminRoute`, and the same provider order - AuthProvider, then
QueryClientProvider, then Router, with ScrollToTop and AuthenticatedApp inside
and both toasters as siblings. There is no lazy loading to preserve; there never
was any.

Two coordinated edits the rename forced. `main` imported `@/App.jsx` by explicit
extension, which stops resolving the moment `App` is `.tsx`; it is now
extensionless `@/App`. `index.html` pointed its module script at
`/src/main.jsx`; it points at `/src/main.tsx`. Both are required, and missing
either would have been a blank page rather than a type error.

ONE GENUINE SEMANTIC DIFFERENCE, INVESTIGATED AND ACCEPTED. esbuild elides
unused imports under the TypeScript loader but keeps them under the JavaScript
one, so `App` drops thirteen imports: `Layout`, `Overview`, `Positions`,
`Candidates`, `HiredHistory`, `TalentPool`, `UserTracking`, `Analytics`,
`Profile`, `WorkerProfile`, `KrowIdentity`, `Owliver` and `DesignSystem`. All
thirteen are dead - each has zero JSX uses in `App`, because every route that
once rendered them now `<Navigate>`s to an `/admin/*` equivalent. Before
accepting it I checked that none of the thirteen modules can do anything when
evaluated: no bare side-effect imports, no top-level calls, and every top-level
binding a literal or a function declaration. The consequence is 942 fewer bytes
in the index chunk and thirteen modules no longer evaluated at startup. Nothing
observable changes, and the routes those pages are reached through are
unaffected - they are reached through the `/admin` tree, which is untouched.

That is the first time in this migration the production bundle has changed for
a reason other than a comment, so it is recorded here rather than left to be
noticed later.

`CreatePosition` needed the only real typing. `vetting_criteria` is a `jsonb`
column holding the five weighting percentages the page edits, typed `unknown` by
the registry, and both the total and the three render sites read through it.
`onDone` is called with the created id, so it takes arguments - the generator
had classed it as zero-arg, and that rule is now narrowed to `onClose` alone.

The generator's entity-import rule was narrowed first, as instructed: it now
counts a type as used only when it appears in a type position inside a generated
interface, rather than anywhere in the file text. That is what produced four
unused-import lint errors in the previous batch.

Verified: tsc 21 -> 20, set-difference showing one removed and none added; zero
errors in any of the six files; all four pages emit byte-identical JavaScript;
route fingerprint identical; npm test 1684/1691 with the same seven failures;
Owliver baseline 59/59; lint 0 errors; no deferred agent-region file touched;
baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:18:59 +05:30
fcdaa32f4d chore(ts-migration): migrate the non-agent pages to TypeScript
Phase 9, first batch: 18 pages. Seventeen emit byte-identical JavaScript, the
eighteenth differs only by a JSDoc cast becoming a real annotation, and the
production bundle is byte-identical to be49184.

SCOPE, decided by imports. Twenty-two of the forty pages import `lib/skills`,
`lib/agents`, `ai-assistant`, `components/skills`, `ui-tree` or `ui-editor`, and
are deferred to Phase 11 with the rest of that region. `Owliver.jsx` is in THIS
batch despite the name: it imports only `krowHooks`, `krowScore`, `krowAi` and
`ui/button`, and is a profile-building conversation page rather than any part of
the agent runtime.

`DesignSystem` is the headline result. It was the single worst file in Phase 6 -
111 errors when the design system was first renamed - and it arrived here with
none, because those were never its errors: they were the primitives' inferred
props, and fixing them at the source fixed every consumer.

Three pages needed real work, and each was the author's own intent made
explicit:

  - `admin/Login` carried `useState(/** @type {{email?: string, password?: string}} */ ({}))`.
    JSDoc casts stop applying in a `.tsx` file, so that became a real type
    argument, and `validate`'s accumulator - built empty and filled per failed
    rule - needed the same shape. The login flow itself is untouched: the
    generic 401 message, the 429 branch and the `remember` field all stand.

  - `Owliver` gets `new Promise<void>`, because its `resolve()` takes no
    argument.

  - `Candidates` names the element type of a `Set` built from `any[]`, which
    otherwise infers `Set<unknown>` and makes every option a `ReactNode` error.

LINT CAUGHT A REGRESSION THE OTHER CHECKS DID NOT. The generator adds an entity
import when it sees the type NAME anywhere in the file, which for four pages
with no props at all left an import nothing used - four `unused-imports` ERRORS,
taking `npm run lint` from exit 0 to exit 1 while typecheck, tests, the bundle
and seventeen of eighteen emit comparisons all stayed green. Removed. The
generator's import rule is too eager and wants narrowing before the next batch.

Verified: tsc 21 -> 21, set-difference showing zero introduced and zero removed;
zero errors in any of the 18 pages; lint back to 0 errors and 289 warnings;
npm test 1684/1691 with the same seven failures; Owliver baseline 59/59;
production bundle byte-identical; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 12:47:04 +05:30
be49184006 chore(ts-migration): migrate root components and layouts to TypeScript
Phase 8, third batch: the three root components and both layouts. All five emit
byte-identical JavaScript and the production bundle is byte-identical to
3d5f54b.

Small batch, and almost all of it was the rename. `ScrollToTop`,
`UserNotRegisteredError` and both layouts take no props at all - the layouts
render through `<Outlet />` - so inference already described them.

`ProtectedRoute` gains the only real interface, and it clears a standing error.
Both its props have fallbacks - `fallback` defaults to a spinner and
`unauthenticatedElement` falls through `??` to the login redirect - and its one
call site is a bare `<ProtectedRoute />` in `App.jsx`. Typed optional, which is
what the defaults already said, that call site stops being an error: tsc goes
22 -> 21 with nothing introduced.

The auth boundary itself is untouched. The `authError.type === 'user_not_registered'`
branch, the `<Navigate to="/admin/login" state={{ from }}>` redirect and the
`isLoadingAuth || !authChecked` gate are all exactly as they were - which
matters, because `AdminLayout` is one of the files `skill-check.mjs` reads as
source text, and it is read through the resolver added in 02a2ab0.

Verified: tsc 22 -> 21, set-difference showing one removed and none added; zero
errors in any of the five; all five emit byte-identical JavaScript; production
bundle byte-identical; npm test 1684/1691 with the same seven failures; Owliver
baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 12:42:06 +05:30
3d5f54bd5b chore(ts-migration): migrate components/forge and components/admin to TypeScript
Phase 8, second batch: 16 files. All 16 emit byte-identical JavaScript and the
production bundle is byte-identical to e7e1e98.

`ProfileView` and `CourseView` move from `components/krow/types.ts` to
`@/types/views`, because `components/forge` reads the same `jsonb` columns off
the same courses and profiles. `components/krow/types.ts` stays as a re-export
so that folder's imports are untouched. Two copies of one narrowing would be
two things to keep in step.

SCOPE, decided by imports rather than by folder name. `components/skills` was in
the batch as requested and is DEFERRED: it imports `lib/skills/registry`,
`lib/agents/runtime`, `ai-assistant/PageContext` and `ai-assistant/AgentContext`,
which makes it agent region by the same test this batch used. `ui-tree` and
`ui-editor` are deferred for the same reason - all eleven files drive the UI-node
system, which is Owliver's "move this card" capability. `forge` was checked and
kept: despite the name, it imports nothing from `lib/skills`, `lib/agents` or
`ai-assistant`. It is the worker learning product.

Twenty-six components now declare real props. Two corrections to the pattern
came out of this batch, both from call sites:

  - Optionality. Batch 1 made a prop required when it had no default. That is
    wrong here: `AdminPage` has seventeen call sites and most pass only `title`.
    Required is now reserved for entity-typed props and `children` - what a
    component genuinely cannot render without - and everything else is optional,
    which is what the JavaScript always allowed.

  - Callback arity. `() => void` was too strict: `onOpen`, `onCreate` and
    `onBrowse` are called WITH arguments, and `University` passes a `useState`
    setter straight through, which has one parameter and is therefore not
    assignable to a zero-parameter type. Callbacks take `(...args: any[])`.

`RoleGlyph`'s `GLYPHS` table gets `[RegExp, ComponentType<any>][]` - the same
widening `aiEngine`'s router had, where the element becomes the union of both
positions and neither `pattern.test` nor `<Icon />` works. The author had
already written that exact type as a JSDoc comment; it is now the real
annotation and the comment is gone.

`ForgeHeader` receives `onBrowse` and destructures `_onBrowse`, so the prop is
passed and silently dropped - the same shape as `TalentHero`'s `jobRecs` in the
previous batch. Recorded rather than changed.

Two automated passes were reverted rather than shipped. One added `?` to object
members inside component bodies, not just interface fields, producing
`TS1162: An object member cannot be declared optional` - it was rerun scoped to
`interface XProps` blocks. The other was the generator itself, which annotated
only the FIRST component in each file and so missed `SectionTitle` in
`PageShell`; rewritten to walk every match and splice in reverse, it went from
14 components to 26 and took the error count from 93 to 38.

Two runtime imports were caught by the emitted-JavaScript check and would not
have been caught any other way. The generator added `import * as React` to
`RoleGlyph` for a type-only reference - a real import in the bundle - now
`import type { ComponentType }`. Fixing that, I then removed the React import
`PageShell` genuinely had; restored.

Verified: tsc 22 -> 22, set-difference showing zero introduced and zero removed;
zero errors in any of the 16 files; all 16 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 11:54:53 +05:30
e7e1e9873f chore(ts-migration): migrate components/krow to TypeScript with real prop types
Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.

This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.

Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.

TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:

  1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
     Query's `mutate` returns `void`, so that `.then` throws at run time, and
     `runMatch` is reachable from a button. `mutateAsync` is what the code
     means. Swapping it turns a crash into a working feature, which is a
     product change, not a migration one - so it is cast to compile and left
     behaving exactly as it did. This one deserves a fix on its own terms.

  2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
     `worker_profiles` has no `location` column - the registry has none and the
     API cannot send one, so the row has never rendered. Recorded as an
     optional field on `ProfileView` with a note, the same treatment as
     `user.avatar_url` in Phase 7.

Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.

One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.

Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 11:39:58 +05:30
543da9d4e7 chore(ts-migration): migrate hooks and auth context to TypeScript
Phase 7. Four files, and the production bundle is byte-identical.

This is the phase where the generated entity types finally pay off. React Query
v5 infers a `mutationFn`'s parameter as `void` when nothing constrains it, so
every destructuring in `krowHooks` needed its shape written down - and those
shapes are real contracts, not guesses. Thirteen mutations now name what they
take, reusing `JobApplication`, `JobPosting`, `WorkerProfile` and `Course` from
`@/types/entities`: `useHireCandidate` takes `{ application, job }`,
`useCompleteCourse` takes `{ profile, course, quizScore }`. Five `{ id, data }`
mutations share one `IdPatch`, where `data` stays `any` on purpose - a PATCH
body is whichever fields the caller is changing, and naming a subset would
describe one call site rather than the endpoint.

Two local types absorb places where an object gains fields after it is built,
which TypeScript does not allow on a literal. `AssignmentEntry` declares
`application_id` and `application` as the alternatives they are - an existing
application named by id, or one described for the server to file in the same
transaction. `LearningProfile` narrows four `jsonb` columns the learning
mutations append to and spread; what the elements hold is still unstated,
because it still is.

`AuthContextValue` writes down the twelve keys every consumer reads. Two are
permanently inert and say so. `user` is `any` rather than `User`, and that is a
narrow, documented exception: `auth.me()` resolves either to the server record
or to the localStorage mirror over `DEMO_USER`, and `admin/Profile` reads
`user.avatar_url`, which is neither a column on `users` nor in the `/me`
projection - so typing it `User` would be accurate about the server and would
turn an always-undefined read into a compile error in a file this phase does not
touch.

`authError` is typed `{ type?: string } | null` rather than `null`, and
TypeScript is the reason. Typed as the provider actually behaves - always
`null` - it made `ProtectedRoute`'s `authError.type === 'user_not_registered'`
a property access on `never`: correct, and a report that the branch cannot be
reached in this build. The branch and `UserNotRegisteredError` are real, so what
a consumer may be handed is what is written down. That this build never produces
one is current behaviour, not the contract.

`use-size` gets a `Size` interface and a typed ref - a small, entirely clear
contract.

Thirteen JSDoc `@param {any}` comments became real annotations. That was not
cosmetic: left in place they changed esbuild's parenthesisation under the `.ts`
loader and put two redundant bytes into the production bundle. Converting them
brought the bundle back to byte-identical, which is how the difference was found
at all.

Verified: tsc 35 -> 35, set-difference showing zero introduced and zero removed;
zero errors in any Phase 7 file; production bundle byte-identical to d440036;
npm test 1684/1691 with the same seven failures; lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 00:26:24 +05:30
d440036211 chore(ts-migration): migrate UI primitives and design system to TypeScript
Phase 6. 57 files: 23 vendored shadcn primitives, 30 design-system components,
4 charts. Plus `src/components/ds/props.ts`, which is types only.

Renaming these alone took typecheck from 37 to 1008, and the reason is worth
recording because it is the shape of every remaining phase.

These components had NO prop contract. No PropTypes, no validation: in the
JavaScript every prop was optional and every extra prop was spread onto the
underlying element. TypeScript infers a destructured parameter WITHOUT a default
as REQUIRED, so the moment the files became `.tsx` it invented a rule the
components never had and rejected several hundred call sites that have always
worked. That is the compiler describing its own inference, not a defect it
found.

Three mechanical fixes, each restoring a contract that already existed:

  - 57 JSDoc `@type {React.ForwardRefExoticComponent<any>}` annotations become
    real TypeScript annotations. Those comments were the previous authors'
    deliberate compatibility types; JSDoc stops applying in a `.tsx` file, so
    converting them preserves an intent that was already written down.

  - 61 `React.forwardRef(...)` calls gain `<any, any>`. Without generics `ref`
    infers `ForwardedRef<unknown>`, which no element's `Ref<T>` accepts - so
    every primitive that forwards a ref to a `div` failed on the ref, not the
    props.

  - 78 component signatures take `DsProps`, a documented alias for
    `Record<string, any>`. It exists so the decision is recorded once and is
    greppable when someone tightens it, rather than being 78 bare `any`s with
    no explanation between them. The prop NAMES are not lost: every component
    still destructures them by name, which is where a reader looks.

Four files needed real types rather than compatibility ones. `ds/toast` takes
react-hot-toast's own `ToastOptions`, which narrows `position` to its
`ToastPosition` union instead of widening to `string` - the widening was what
made all six calls unassignable. `ds/Pagination`'s page range is genuinely
`(number | string)[]`, because it interleaves page numbers with '…' markers that
the renderer tests for. `ds/Field` narrows `children.props` at three reads, and
`ds/Avatar` needed the ref generic.

Two of my own automated passes were wrong and were caught rather than shipped. A
props-interface generator dropped alternating props, because non-overlapping
regex matches consume the separating comma - it made things worse (83 file
errors to 146) and was reverted wholesale. A second pass missed every
multi-line signature whose defaults contain a `)`, such as `onClose = () => {}`;
that needed a brace matcher rather than a character class.

56 of 57 files emit byte-identical JavaScript. The one exception is `ds/toast`,
where a JSDoc type CAST - `/** @type {ToastPosition} */ ('bottom-center')` -
became a real annotation, so the emitted output loses a comment and a pair of
now-redundant parentheses. The value is `"bottom-center"` either way; the
minified outputs differ only in esbuild's choice of mangled local names.

Verified: tsc 37 -> 35, set-difference showing zero introduced and two removed;
zero errors remain in any Phase 6 file; npm test 1684/1691 with the same seven
failures; lint 0 errors; build succeeds with the API origin inlined; baseline
artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:59:49 +05:30
2b8f5746bd chore(ts-migration): migrate domain logic to TypeScript
Phase 5. Twelve modules under `src/lib` and `src/lib/admin`. All twelve emit
byte-identical JavaScript; eight needed no annotation at all.

Where the generated entity types fit, they are used. `positionModel` is typed
against `JobPosting` — and that is where TypeScript earned its keep. Annotating
the label functions made `experienceLabel`'s `years === ''` guard a comparison
the compiler called impossible, because the registry types
`min_experience_years` as `number`, which is correct for a record the API has
returned. The guard is not dead: the same functions are handed drafts, and an
untouched numeric form input yields `''` — which is why `toPositionPayload`
coerces all five numerics with `Number(...)`.

So the module now has two types rather than one. `PositionRecord` is a saved
posting with the registry's column types; `PositionDraft` widens the five
numerics to `number | string` and is taken by `toPositionPayload` alone. The
one comparison the split cannot express keeps its guard and carries a cast with
the reason written next to it. Deleting a live guard to satisfy a type would be
the type rewriting the code.

`workforce` keeps its records as `any`: 574 lines of demand and availability
arithmetic over profiles, postings, assignments and staff read largely through
jsonb columns the registry does not describe. What IS described is the module's
own contract — the `WorkforceContext` option bag and the `Availability` result,
whose two shapes differ by whether a worker's commitments are known.

Two of my own type declarations were too narrow and were caught by the
set-difference rather than by inspection. `activitySignals`' accumulator seeds
`{ email, name, count, privileged }` and I had named only the two counters;
`WorkforceContext` omitted `profiles` and `courses`, which `PositionDetail`
passes in a single call with three more. The bag now carries an index signature,
because that is what the call site assumes: callers hand the whole thing over
and each function picks what it needs.

`skillGraph` gains a `SkillLevel` interface with an optional `earned`, set in a
second pass that stops at the first incomplete rung — so the levels above the
gap never receive it, and optional is the honest description.

Verified: tsc 40 -> 37, zero introduced; all twelve emitted outputs
byte-identical; npm test 1684/1691 with the same seven failures; lint 0 errors;
build succeeds with the API origin inlined; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:46:26 +05:30
3e654c2bf7 chore(ts-migration): migrate seed fixtures to TypeScript
Phase 4c, completing `src/api`. Both files emit byte-identical JavaScript -
86,831 and 6,449 bytes - and neither needed a single annotation: they are data
and pure helpers, and inference already describes them.

Neither reaches the production bundle. No module under `src` imports either one;
`base44Client` mentions `attendanceSeed` in a comment and nothing more. They
exist for `skill-check.mjs`, `owliver-capture.mjs` and `seed-fixture.mjs`, which
is why this was the safest phase in the whole migration and why it was left
until the transport layer was done.

`npm run seed:check` still answers "seed.json is stale", which it has since
before this migration began - the backend fixture drifted from `src/api/seed`
independently of any of this. What matters here is that it ANSWERS: the
generator loaded the renamed module through the Phase 0 resolver rather than
failing to find it.

tsc unchanged at 40, npm test 1684/1691 with the same seven failures, lint 0
errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:00:29 +05:30
1133eca16a chore(ts-migration): migrate base44 client to TypeScript
Phase 4b-ii, and the last file in the transport layer. Type-only: the plain
emitted JavaScript is byte-identical at 9,302 bytes.

This file owns the session, so the annotations stay at the edges and nothing
about the auth model moves. `SESSION_KEY`, the localStorage mirror, the three
module-level mutables, the shared hydration promise, the `{ ...user }` spreads,
`window.location.href` on logout and `window.location.reload()` in
`resetDemoData` are all exactly as they were.

Nine sites, eight of them parameter types. The ninth is `hydration`, which needs
`Promise<any> | null` because the runtime already assigns both: the shared first
`GET /me`, then `null` once it has been consumed or superseded by a login.
Inference would have fixed it at `Promise<any>` and rejected the assignments
that make the sharing work.

`entities` is deliberately NOT annotated, and neither is `createEntity`'s
return. A probe run during the inspection showed what annotating it costs:
`Record<EntityName, EntityClient<any>>` makes `list()` return `Promise<any[]>`
where inference gives `Promise<any>`, and `agentStore.js` does
`qc.getQueryData(KEY) || (await ...list(...))`. React Query types
`getQueryData` as `unknown`; `unknown || any` collapses to `any` while
`unknown || any[]` stays `unknown`, and `.find` on the next line stops
compiling. Two new errors in a file this phase does not migrate. The generated
record types and `EntityClientFor<K>` are ready for the phase that migrates the
forty-five call sites in `krowHooks.js`.

The auth returns are likewise left inferred rather than typed `User`. That was
checked, not assumed: `admin/Profile.jsx` reads `user.avatar_url`, which is
neither a column on `users` nor part of the `/me` projection in `me.go` - it is
always undefined at run time. Typing the return would have turned a latent dead
branch into two compile errors in a file this phase does not touch. Worth
knowing about separately; not this commit's business.

One stale comment is kept verbatim - `@param {any} request` above
`owliver.suggestions`, which names a parameter that does not exist. Removing it
was the only remaining difference in the emitted output, and byte-identity is
worth more here than tidying a comment. Flagged for a later docs pass.

Verified: tsc 42 -> 40 with a set-difference showing two removed and none added;
npm test 1684/1691, the same seven failures as the parent commit (six documented
as deliberate in scripts/__baseline__/README.md, one the stale backend fixture);
lint 0 errors; build succeeds with the API origin inlined. This is also the
first run of the suite with a source-text-inspected file migrated - the harness
fix in 02a2ab0 is what makes it possible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:59:15 +05:30
02a2ab05ef chore(ts-migration): resolve source-text reads across .js/.jsx/.ts/.tsx
The check suite consumes source files through two entirely separate channels,
and Phase 0 only hardened one of them.

`withSourceResolution` wraps `ssrLoadModule`, which covers the 159 module loads.
It does not and cannot see the other 30 sites, which read source as TEXT through
`readFileSync` to assert structural facts - "the panel imports no local
suggestion ranker", "no runtime path writes a definition". Renaming
`base44Client.js` to `.ts` is what surfaced the difference: ENOENT in the middle
of a suite that had been passing, from a line no grep for `ssrLoadModule` would
ever have found.

`resolveSourcePath()` in `ssr-resolve.mjs` reuses the existing `candidatesFor()`
ordering - the written path first, then `.ts`, then `.tsx` - and returns a path
relative to the root, so every call site keeps its `join(ROOT, ...)` as it was.
An unresolvable path comes back unchanged, which keeps the two absence
assertions honest: a check proving `store.js` is GONE still asks about the path
it means, and now also notices if the file returns under another extension.

Thirty-seven lines change, each a one-for-one replacement. No assertion text, no
record() message, no ordering, no logic.

The audit found the reads in four shapes, and two of them a path grep cannot
see:

  - direct       readFileSync(join(ROOT, 'src/x.jsx'), 'utf8')
  - via a const  const P = join(ROOT, 'src/x.jsx')
  - dir + name   ['node.js', 'patch.js'].map((f) => readFileSync(join(ROOT, 'src/lib/ui', f)))
  - path array   for (const f of files) readFileSync(join(ROOT, f))

The third and fourth hide thirteen filenames in adjacent arrays, which is why
the first estimate of this work was twenty-two files and the real number is
thirty-five.

One directory scan also filtered `/\.jsx?$/` over `src/pages/admin` and
`src/components/agents`. That one does not crash - it quietly matches nothing
once those directories are TypeScript, and the check passes having inspected an
empty set. Widened to `/\.[jt]sx?$/`. A silent shrink is worse than a failure,
and CI's FLOOR of 900 would not have caught it.

Deliberately NOT touched, because they are correctly extension-specific: the
`dist/assets` filter reads built bundles, which are `.js` whatever the source
was; `scripts/stubs/react-hot-toast.js` and `scripts/browser-flows.js` are
scripts, not migrated source; and every comment that mentions a `.js` filename
says something true about a file that still has that name.

Proven rather than assumed. `npm test` reports 1684/1691 before and after, the
same seven failures. Then `src/api/base44Client.js` - the exact file whose
rename broke the suite - was renamed to `.ts`, the suite re-run, and the check
that reads it as text passed: "the app does not import the seed fixture". The
rename was reverted and the file verified byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:56:45 +05:30
dca184289e feat(hiring): final-selection queue, honest seat counts, and human interviews
Authored in a parallel session alongside the TypeScript migration; committed
separately so the two never share a commit. No TypeScript migration file is
included here.

Candidates becomes the queue of hiring decisions waiting on a person, rather
than a second Talent Pool listing every application the org ever took. Final
selection is DERIVED - there is no `final_selection` value in the
`application_status` enum and none is added. The fact it reads is the existence
of an interview row, a NOT NULL foreign key, rather than
`job_applications.interview_id`, which the schema keeps as an unconstrained soft
reference precisely so it may dangle. `status = 'interview'` is set both when an
interview is arranged and when one is completed, so status alone cannot tell a
queue of people who have been interviewed from a queue of people merely booked
in.

Seats on a position are counted from the employment records instead of a stored
column. A `filled` counter would be a second source of truth, and the day it
disagreed with `staff` nothing could say which was lying. Someone who has left
frees their seat, and over-hiring floors at zero rather than going negative.

`DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented
people so Hired History read as a history rather than as three rows; the padding
reached Analytics too, where "total hires" counted eight against a database
holding three. Hires now come only from `staff`.

Both paths that file an application on somebody's behalf now carry
`worker_profile_id`, the link back to the talent-pool record. The column is
nullable, so omitting it saved cleanly and failed silently: the application
belonged to an email address rather than to a person, and the hire it became
could not be traced back to the profile it came from.

`HiredChronology` used to `return null` with no hires, taking the `chronology`
node identity out of the DOM with it - so on an honest empty dataset the section
could not be addressed by Owliver or the layout editor at all. It now renders an
empty state inside the section it keeps.

Nine new checks cover the above; `npm test` reports 1684/1691.

SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md`
documents each with verified tag counts. Five are the `DEMO_FILL` removal: the
Hired History and Analytics baselines were captured while the padding was in
effect and, because they render with queries disabled, the padding is all they
contain. The sixth is this change to what Candidates says. Do not regenerate
those baselines to clear them - two of the checks exist to prove the UI node
tree migration added exactly two `<div>`s, and that proof needs the baseline to
be pre-migration markup. Recapturing now would write post-migration markup into
a file named `pre-migration` and the check would compare the current render
against itself forever. The debt is held until the migration work lands, when
both files are recaptured together.

The seventh failure, the stale backend seed fixture, predates all of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:52:51 +05:30
64140c7add chore(ts-migration): migrate ai engine to TypeScript
Phase 4b-i. Type-only, and the production bundle proves it: built from this
commit's parent and from this commit, all six chunk hashes match. The plain
emitted JavaScript is byte-identical at 34,095 bytes.

Four annotation sites, no logic touched:

  - `ROLE_TITLES` and `AVAILABILITY_TOKENS` get `[RegExp, string][]`. Left to
    inference the element widens to `string | RegExp`, which has no `.test`.
    Explicit tuples rather than `as const`, which would also have worked and
    would additionally have made the arrays readonly - a change to the type this
    module publishes for no benefit it uses.

  - `ROUTES` gets `[RegExp, (prompt: string) => any, number][]`, because all
    three positions are used for what they are: `.test()` on the first, a call
    on the second, `think(ms)` on the third.

  - `invokeLLM` gets a real parameter type. It inferred `{ prompt?: string }`
    from its own destructuring defaults, and that single inference was
    responsible for nine errors in files this commit does not touch - eight in
    `krowAi.js`, one in `provingGround.ts` - every one of them a caller passing
    `response_json_schema` or `model`, which the real integration accepts.
    Naming the options type fixes all nine from here.

  - `uploadFile` gets `{ file?: File }`.

The return type of `invokeLLM` stays `Promise<any>`, deliberately. The ten
handlers behind the router return ten different shapes, and `krowAi.js` branches
on the result at run time - `typeof res === 'string' ? res : res.text || String(res)`
- which a precise union would reject on every branch without a `.text`. The
looseness is the contract, not an omission.

`InvokeLLMOptions` is exported as a type only; the runtime exports are still
exactly `invokeLLM` and `uploadFile`.

Verified in isolation from the parallel feature work (f96f128 plus this file):
tsc 53 -> 37, a set-difference against the baseline showing sixteen removed and
none added; skill-check 1641/1642 with only the known stale-fixture failure;
Owliver baseline 59/59; lint 0 errors; build succeeds with the API origin
inlined; the nine Owliver baseline artifacts unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 17:23:28 +05:30
f96f128839 chore(ts-migration): migrate API transport to TypeScript
Phase 4a: `demoUser` and `httpClient`. Type-only. The production bundle is
byte-identical - built from this commit's parent and from this commit, all six
chunk hashes match.

`httpClient` is the contract boundary, so the annotations are deliberately
conservative:

  - `KrowApiError` is a CAST, not a class. The three error sites still build a
    plain `Error` and assign `.name`, `.status`, `.code` and `.details` onto it
    exactly as before. `class KrowApiError extends Error` would have read
    better and changed three things that callers depend on: the prototype
    chain, `instanceof`, and how `name` comes to be set.

  - `RESOURCE_PATHS` becomes `Record<EntityName, EntityResourcePath>`. That is
    the first thing in the repo to check the eighteen entity names against the
    eighteen in `ENTITY_NAMES`; until now the two lists agreed only by habit,
    and a divergence would have surfaced as `base44.entities.Whatever` being
    undefined with nothing to say why.

  - `createEntity`'s parameter stays `string` and its return stays inferred.
    Annotating the return `EntityClient<any>` was tried and reverted: it makes
    `list()` return `Promise<any[]>` where inference gives `Promise<any>`, and
    `agentStore.js` does `qc.getQueryData(KEY) || (await ...list(...))`. React
    Query types `getQueryData` as `unknown`; `unknown || any` collapses to
    `any`, `unknown || any[]` stays `unknown`, and `.find` on the next line
    stopped compiling. Two new errors in a file this phase does not migrate,
    for no gain. `EntityClientFor<K>` is ready for the phase that migrates
    those consumers.

Unchanged and verified in the emitted output: `credentials: 'include'`, both
header branches on `body === undefined`, the URLSearchParams query encoding with
its repeated-array and undefined-omission rules, the `payload.data` unwrap that
drops `meta`, the verbatim server message, `status: 0` / `code: 'unreachable'`
for a transport failure, the `-created_date` and `limit` defaults, path
construction through `encodeURIComponent`, and `bulkCreate`'s sequential
`this.create` loop.

`DEMO_USER` is annotated `User`, which does real work: unannotated,
`role: 'admin'` widens to `string` and the default shape did not satisfy the
type the app uses for the thing it defaults.

Verified in isolation from the parallel feature work (d1425f9 plus these two
files): tsc 64 -> 53, the eleven removed being exactly this file's, and a
set-difference against the baseline showing none added. skill-check 1641/1642
with only the known stale-fixture failure, Owliver baseline 59/59, lint 0
errors, build succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 12:16:16 +05:30
d1425f974c chore(ts-migration): add generated entity types
Phase 3.5. Entity record shapes, generated from the backend's resource
registry rather than transcribed from it. Type-only: every touched file emits
byte-identical JavaScript, and the two type modules emit nothing at all.

`scripts/gen-entity-types.mjs` reads
`krow-backend/go-api/internal/domain/resources_gen.go` - itself generated out of
information_schema, so it cannot drift from the migrations - and writes
`src/types/entities.generated.ts`: 15 resources, 298 columns. It checks for
drift by default and rewrites with --write, the same arrangement seed-fixture.mjs
uses, and skips cleanly when the backend is not checked out beside this repo.

Field types come from `Column.SelectExpr()` in `domain/resource.go`, which is
what the read projection actually emits, not from the Postgres type. The two
differ: uuid and citext are cast to text, numeric to float8, dates and
timestamps to formatted strings, and - the case that justifies generating rather
than typing by hand - `user_activity.id` is an identity bigint cast to text, so
it arrives as a STRING. Written by hand it would have been called a number, and
nothing would have contradicted that until a comparison quietly stopped
matching.

The five Phase 3 leaf utilities swap their `any` placeholders for these records,
as `Partial<...>`: each takes `= {}` or guards every read because it renders
before the query resolves, and requiring the whole record would force those
defaults out - a behaviour change in a scoring path.

jsonb is where the generator stops. Thirteen columns across eight entities are
typed `unknown`, correctly: what sits inside a jsonb column is not in
information_schema and nothing on the backend declares it. Where a module reads
through one it is narrowed to `unknown[]`, `any[]` or `any` - what kind of value
it is, and nothing about its contents. An earlier draft declared the fields
these modules read off them; it was removed. That would have been inventing a
schema the database does not hold, with the compiler then defending the guess.

Not wired into skill-check.mjs: that file is being changed concurrently by
unrelated feature work. Adding `"types:check": "node scripts/gen-entity-types.mjs"`
beside the existing seed:check is the natural next step and is deliberately left
for when that file is quiet.

Verified in isolation from the parallel feature work (commit 1775395 plus these
eight files only): tsc 64 errors with an unchanged histogram, skill-check
1641/1642 with only the known stale-fixture failure, Owliver baseline 59/59,
lint 0 errors, production build succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-15 11:49:56 +05:30
1775395256 chore(ts-migration): establish TypeScript migration checkpoint
Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.

Phase 0 - harness hardening, before any rename:
  - scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
    paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
    rename would have silently destroyed the 1642-check suite that guards the
    Owliver flow.
  - eslint.config.js gains a TypeScript block. Its `files` globs listed only
    {js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
    `eslint .` went on exiting 0 - the quietest failure mode available.
  - MIGRATION_BASELINE.md records the measured starting point, including the
    pre-existing seed-fixture failure and the already-broken standalone
    owliver-baseline.mjs, so neither is later mistaken for migration damage.

Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.

Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.

Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.

Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-11 15:52:01 +05:30
346 changed files with 6573 additions and 1153 deletions

209
MIGRATION_BASELINE.md Normal file
View File

@@ -0,0 +1,209 @@
# TypeScript migration — baseline
The state of this repository **immediately before** the first source file was
renamed, measured rather than assumed. Everything the migration does is checked
against these numbers; a figure that moves without a phase claiming it is a
regression.
Measured on 2026-09-11, on `main` with a clean tree, before any change in
Milestone 1.
## The numbers
| Check | Command | Baseline |
|---|---|---|
| Types | `npm run typecheck` | **FAILS — 71 errors across 27 files** |
| Lint | `npm run lint` | **PASSES**, exit 0 |
| Lint (with warnings) | `npx eslint .` | 289 problems — 0 errors, 289 warnings |
| Files linted | `npx eslint . --format json` | 302 |
| Behaviour | `npm test` | **1641 / 1642 checks passed** |
| Build | `npm run build` | succeeds |
| Owliver, via the suite | `npm test` section 21 | **all green** — schema, skills, routes, and per-context skills, suggestions, prompts and intent routing |
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | **FAILS — pre-existing, see below** |
### The 71 type errors, by code
| Code | Count | What it is |
|---|---|---|
| TS2339 | 38 | Property does not exist on an inferred type |
| TS2741 | 9 | Missing required prop in JSX |
| TS2353 | 9 | Unknown property in an object literal |
| TS2345 | 6 | Argument type mismatch |
| TS2554 | 2 | Wrong argument count |
| TS2362 / TS2363 | 4 | Arithmetic on a non-number |
| TS2739 / TS2559 / TS2349 | 3 | Missing props / no common props / not callable |
These are **pre-existing**. They were not introduced by the migration and the
migration is not obliged to fix them; they are the backlog it has to avoid
adding to. Six of them are `import.meta` errors that Phase 1 resolves as a side
effect of configuring `vite/client`.
Note that `npm run typecheck` **is not run by CI** (`.github/workflows/ci.yml`
runs lint, test, build and the seed check). That is how 71 errors accumulated
without anyone seeing them. Wiring it in is the last phase of the migration, not
the first — it cannot be a gate until it is green.
## The standalone Owliver script is already broken
`node scripts/owliver-baseline.mjs` fails before any of this began — verified by
stashing every change and running it on a clean `main`, where it fails
identically. It is **not** caused by the migration.
```
Failed to scan for dependencies from entries:
index.html
scripts/__baseline__/activity-page.pre-migration.html
… the other seven snapshots …
✘ [ERROR] The server is being restarted or closed. Request is outdated [plugin vite:dep-scan]
Owliver behaviour has DRIFTED from the baseline.
```
Two things are going on, and neither is a real drift. Vite's dependency scanner
treats every `*.html` under the project root as an entry point, so the eight
captured SSR snapshots in `scripts/__baseline__/` are scanned as if they were
pages of the app. The script then calls `server.close()` as soon as
`captureBaseline` returns, while that scan is still running, and the scan
reports that it was cancelled. The capture comes back incomplete and the
comparison calls it drift.
**The coverage itself is not lost.** `skill-check.mjs` imports the same
`captureBaseline` and the same `BASELINE_PATH` and asserts against them in
section 21, and it does enough other work afterwards that the scan finishes. Run
`npm test` and the whole section is green:
```
── Owliver behaviour baseline ──
[ ok ] baseline schema matches — expected 1, got 1
[ ok ] every skill that existed before the agent layer still registers — 23 kept, 1 added since
[ ok ] every route that resolved before resolves the same way — 18 unchanged
[ ok ] controlCenter: keeps every skill it had — 7 skill(s), unchanged
…per context: page key, skills, suggestions, prompts, intent routing
```
So **`npm test` is the Owliver drift check for the duration of the migration**,
and the standalone script is a convenience wrapper that needs repairing on its
own terms — excluding `scripts/__baseline__` from the scan, or awaiting the
optimizer before closing. That repair is not part of the migration: it changes
a script's behaviour, and this milestone's whole claim is that it changed none.
## The one failing check
```
1641/1642 checks passed
Failed:
- the backend fixture is in step with this seed (stale — run `npm run seed:fixture`)
```
`scripts/__baseline__` is intact and `owliver-baseline.mjs` matches; this is not
an Owliver failure. `seed/fixtures/seed.json` in the sibling `krow-backend`
checkout has drifted from `src/api/seed.js`.
**This failure predates the migration and is unrelated to it. It is recorded
here so that it is never mistaken for something the migration caused, and it is
deliberately left alone** — regenerating the fixture would write into another
repository and change what the backend seeds, which is a product change wearing
a migration's clothes. Fix it separately, on its own terms.
## What "unchanged" means for the Owliver / agent flow
The agent flow is ~112 files and 38% of the codebase, and it is converted last,
by rename and type annotation only. The evidence that it still behaves
identically is already in the repository and needs nothing new:
- `npm test` — 1642 checks covering skill discovery, routing, context
resolution and SSR-rendered markup.
- `scripts/__baseline__/owliver-baseline.json` — contexts, skill ids and routes.
- `scripts/__baseline__/*.pre-migration.html` — eight pages of byte-level SSR
output.
- `node scripts/owliver-baseline.mjs` — the drift check.
- CI's `FLOOR = 900` — fails if the suite silently shrinks.
**These artifacts are read-only for the duration of the migration.** If the
baseline drifts, revert the change. Regenerating it to clear a red check records
the regression instead of catching it, which is worse than having no baseline —
`owliver-baseline.mjs` says as much in its own header.
`src/agents/**/*.md` and `src/skills/**/*.md` are likewise untouched throughout:
they are the definitions the flow is built from, not code being migrated.
## How to re-measure
```bash
npm run typecheck; echo "exit=$?"
npm run typecheck 2>&1 | grep -c "error TS"
npm run lint; echo "exit=$?"
npx eslint . --format json | python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
npm test
node scripts/owliver-baseline.mjs
npm run build
git diff --stat -M
```
---
## 2026-09-20 — closing addendum: the migration is complete
Everything above this line is the record as measured on 2026-09-11 and is left
exactly as it was written. The numbers in it describe `main` before the first
rename; they are the thing the migration was checked against, so correcting them
to today's figures would destroy the comparison rather than update it. What
follows is the other end of that comparison.
### Final state, measured on the `ts-migration` branch
| Check | Command | 2026-09-11 baseline | Now |
|---|---|---|---|
| Types | `npm run typecheck` | FAILS — 71 errors / 27 files | **0 errors**, 321 files in the program |
| Lint | `npm run lint` | PASSES, exit 0 | PASSES, exit 0 |
| Lint (with warnings) | `npx eslint .` | 0 errors, 289 warnings | 0 errors, 289 warnings |
| Behaviour | `npm test` | 1641 / 1642 | **1693 / 1693** |
| Build | `npm run build` | succeeds | succeeds |
| Bundle | `dist/assets/index-*.js` | — | `74d17e2d5cafdd6f88eaf6d89ffdab11` |
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | FAILS — pre-existing | **matches the baseline** |
| Backend fixture | `npm run seed:check` | FAILS — stale | **in step** |
No `.js` or `.jsx` remains under `src/`: 103 `.ts`, 216 `.tsx` and 2 `.d.ts`.
`src/agents/**/*.md` and `src/skills/**/*.md` are untouched, as promised.
### The three items this document left open, and where they were closed
**The standalone Owliver script.** Recorded above as failing before any of this
began, verified by running it on a clean `main`. The byte-exact comparison was
reporting one real difference: the baseline held 23 skills and the runtime
resolved 24, because `create-employee-role` was added after the baseline was
last written and nobody recaptured it. `npm test` tolerated it — its check
asserts only that nothing was *removed*, and read "23 kept, 1 added since" — so
the drift was visible only to the stricter script. Refreshed in `34505d7`:
three inserted lines, no deletions, routes byte-identical at 18 and all eleven
contexts unchanged. The check now reads "24 kept, 0 added since".
**The backend seed fixture.** Recorded above as predating the migration and
deliberately left alone. It stayed that way for good reason: the generator was a
version behind the seeder and emitted no `users` key, so regenerating would have
dropped `employer@krow.app` from every fresh seed and left nobody to sign in as
to reach the employer console. Fixed in `3ddacf2` by teaching the generator to
emit the key, not by overwriting the fixture — with that change the generator
reproduces the committed fixture byte for byte, so `seed.json` in the sibling
repository was never written to at all.
**The HTML render baselines.** Recorded above as read-only for the duration of
the migration, and that held: they were untouched through every phase. Three of
them — Hired History, Analytics and Candidates — were recaptured in `878a235`,
after the migration, because a parallel feature removed demo data those files had
captured while it was still in effect. They are renamed `*.render.html`, since a
file called `pre-migration` holding post-feature markup is a lie in the filename.
The other five still hold genuine pre-migration markup, still pass, and keep the
name. One check that could no longer be satisfied was replaced by three that read
the render directly; `scripts/__baseline__/README.md` has the detail.
### Commits
3ddacf2 fix(seed): emit the users array the backend seeder reads
878a235 test(baseline): recapture the three HTML baselines that held invented people
34505d7 chore: refresh owliver baseline
Strictness remains as debt rather than a blocker: `strict: true` reports 4438
errors, `noImplicitAny` 3317 and `strictNullChecks` 1794, while the committed
configuration reports none. None of it affects emitted output — the production
bundle hash is unchanged — so it is hardening work, not migration work.

View File

@@ -19,22 +19,22 @@ Recharts + MUI X Charts · react-hot-toast.
```
src/
main.jsx entry — mounts <App/>, imports index.css
App.jsx providers + the entire route table
main.tsx entry — mounts <App/>, imports index.css
App.tsx providers + the entire route table
index.css design tokens (HSL CSS variables) + utility layers
api/ the backend seam — see below
base44Client.js the contract the production app talks to
base44Client.ts the contract the production app talks to
store.js in-memory entity store, mirrored to localStorage
aiEngine.js local, deterministic InvokeLLM
seed.js demo dataset
attendanceSeed.js shift/attendance dataset
aiEngine.ts local, deterministic InvokeLLM
seed.ts demo dataset
attendanceSeed.ts shift/attendance dataset
lib/ domain logic and data hooks
krowHooks.js React Query hooks over the entity store
krowAi.js AI workflows
krowScore.js score engine
AuthContext.jsx auth provider
krowHooks.ts React Query hooks over the entity store
krowAi.ts AI workflows
krowScore.ts score engine
AuthContext.tsx auth provider
admin/ admin session, permissions, position insights
skills/ the Owliver skill system (registry, resolver, flows)
agents/ the agent system (registry, runtime, lifecycle)
@@ -79,37 +79,37 @@ The app opens on **`/admin/login`**. Everything else lives under `/admin/*` behi
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
| `/admin/workspace/skill-development` | Skill Development |
| `*` | `lib/PageNotFound.jsx` |
| `*` | `lib/PageNotFound.tsx` |
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
equivalent, id preserved. Static route segments are declared before dynamic ones so
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
> **Note.** `App.jsx` still imports a set of pre-redesign Employer/Talent pages
> **Note.** `App.tsx` still imports a set of pre-redesign Employer/Talent pages
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
> and `layouts/Layout.jsx`. None of them are mounted on a route — they are unreachable at runtime
> and `layouts/Layout.tsx`. None of them are mounted on a route — they are unreachable at runtime
> and retained pending a decision on whether to re-route or remove them.
## The backend seam
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps that
The production app talks to a Base44 backend through `src/api/base44Client.ts`. This demo keeps that
module path, export name and full method contract, and swaps only the transport:
| Contract | Demo implementation |
| --- | --- |
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.ts` — local, deterministic |
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
| `base44.auth.*` | seeded employer/admin user, always signed in |
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
## The AI engine
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
`api/aiEngine.js` reimplements it locally: it recognises each workflow by the phrase its prompt opens
`api/aiEngine.ts` reimplements it locally: it recognises each workflow by the phrase its prompt opens
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
@@ -124,8 +124,8 @@ Two registries discover their definitions from markdown at build time:
| Registry | Glob | Files |
| --- | --- | --- |
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
| `lib/agents/registry.ts` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
registry silently return nothing — no build error, no import failure, just an empty registry. Add
@@ -133,9 +133,9 @@ definitions by dropping a new `.md` file into the right folder; nothing else nee
## Demo data
Seeded in `src/api/seed.js` — positions, applicants, AI-scored candidates, interviews, hires,
Seeded in `src/api/seed.ts` — positions, applicants, AI-scored candidates, interviews, hires,
talent-pool profiles, Proving Ground courses and an activity log; shift data in
`src/api/attendanceSeed.js`.
`src/api/attendanceSeed.ts`.
Edits persist to `localStorage`. To restore the shipped dataset:
@@ -163,12 +163,12 @@ Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config
| Body | Inter |
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.js`.
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.ts`.
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
`glass-card`; the page background is the `gradient-mesh` utility.
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
`App.jsx`.
`App.tsx`.
## Scripts
@@ -179,8 +179,8 @@ Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<H
| `npm run preview` | Serve the build |
| `npm run lint` | ESLint — currently clean |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
### The test harness
@@ -191,17 +191,41 @@ mocks. It also diffs Owliver's behaviour across eleven page contexts against a c
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.jsx`,
`src/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
`src/layouts/AdminLayout.tsx` and a handful of agent files. Move any of them and update
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
### Known-failing checks
### Check status
Two checks fail on a clean checkout and are tracked as separate work, not regressions:
Everything above passes on a clean checkout: `npm test` is 1693/1693 and
`npm run typecheck` reports no errors.
- `npm test` — 834/835 pass; `the seeded overtime climb is found` fails.
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
`lib/skills/*`, `lib/positionModel.js` and a few components.
This section used to list two known failures — 834/835 on the suite, and 59 type
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
gone, and they were unrelated to each other.
The suite failure was `the seeded overtime climb is found`. It asserted against
the live calendar: the shifts are generated by counting back from the day the
suite runs, so the oldest week in the window thinned as the week wore on and
inflated the baseline every later week was compared against. The climb was
reported Sunday through Thursday and vanished on Friday and Saturday. That was a
product defect rather than a flaky assertion, and it was fixed in
`src/lib/attendance.ts` (commit `88c412f`) by dropping a leading week rostered
well below the usual — only from the front, so a genuine collapse in the middle
is still a finding. `the seeded overtime climb is detectable on every day of the
week` re-runs the detector against 28 consecutive anchors and is what keeps it
fixed.
The type errors were resolved by the TypeScript migration.
A third check, `the backend fixture is in step with this seed`, failed for its
own reasons and is recorded here because it is easily confused with the above:
`seed.json` in the sibling backend had drifted from this seed module. Fixed in
commit `3ddacf2` by teaching the generator to emit the `users` array the Go
seeder reads, rather than by overwriting the fixture. All three pass.
The heading is kept rather than deleted so that the absence of failures is
stated rather than merely implied.
## Deployment

View File

@@ -2,7 +2,7 @@
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",

View File

@@ -3,16 +3,68 @@ import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginUnusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
/**
* The rule set, written once and applied to JavaScript and TypeScript alike.
*
* Shared rather than duplicated because the two blocks below differ in exactly
* one thing — which parser reads the file — and a rule that applied to `.jsx`
* but not to its `.tsx` successor would make the TypeScript migration look like
* it was tidying the code up. It is not; it renames files and adds types.
*/
const rules = {
"no-unused-vars": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": [
"error",
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
],
"react-hooks/rules-of-hooks": "error",
};
const plugins = {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
};
const settings = { react: { version: "detect" } };
/**
* Which files are linted.
*
* Unchanged from what this config has always covered — `src/lib` and
* `src/components/ui` stay out of it — with `ts` and `tsx` added to every
* pattern. That addition is the point: ESLint matches on extension, so the
* moment a `.jsx` file became `.tsx` it would have dropped out of the run
* silently, and `eslint .` would have gone on exiting 0 while linting less and
* less of the codebase. A lint that passes because it checked nothing is worse
* than one that fails.
*/
const directories = ["src/components", "src/pages", "src/layouts", "src/hooks"];
const ignores = ["src/lib/**/*", "src/components/ui/**/*"];
const filesWith = (extensions) =>
directories.map((directory) => `${directory}/**/*.{${extensions}}`);
export default [
{
files: [
"src/components/**/*.{js,mjs,cjs,jsx}",
"src/pages/**/*.{js,mjs,cjs,jsx}",
"src/layouts/**/*.{js,mjs,cjs,jsx}",
"src/hooks/**/*.{js,mjs,cjs,jsx}",
],
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
files: filesWith("js,mjs,cjs,jsx"),
ignores,
...pluginJs.configs.recommended,
...pluginReact.configs.flat.recommended,
languageOptions: {
@@ -20,42 +72,50 @@ export default [
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
ecmaFeatures: { jsx: true },
},
},
settings: {
react: {
version: "detect",
settings,
plugins,
rules,
},
/**
* The same block for TypeScript, with the TypeScript parser.
*
* A separate block rather than one widened glob, so that nothing about how
* the existing JavaScript is parsed or reported changes on the day this
* lands. `typescript-eslint`'s parser accepts plain JavaScript too, and
* merging the two would have been shorter — but it would also have quietly
* re-parsed 200-odd existing files, and this phase is meant to be provably
* inert.
*
* Deliberately NOT type-aware (no `projectService`): type errors are
* `npm run typecheck`'s job, and asking ESLint to build a program as well
* would make every lint run pay for it twice.
*/
{
files: filesWith("ts,tsx,mts,cts"),
ignores,
...pluginReact.configs.flat.recommended,
languageOptions: {
globals: globals.browser,
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
},
settings,
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
rules: {
"no-unused-vars": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": [
"error",
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
],
"react-hooks/rules-of-hooks": "error",
...rules,
/* TypeScript resolves identifiers itself and reports the ones it cannot,
with better messages and without ESLint's browser/node globals list
needing to be right. Leaving the core rule on would report every `type`
and `interface` name as undefined. */
"no-undef": "off",
},
},
];

View File

@@ -12,6 +12,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext", "dom"],
"target": "esnext",
"checkJs": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
}

333
package-lock.json generated
View File

@@ -57,6 +57,7 @@
"puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.70.0",
"vite": "^6.1.0"
}
},
@@ -3378,6 +3379,301 @@
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
"integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/type-utils": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.70.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
"integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.70.0",
"@typescript-eslint/types": "^8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
"integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
"integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
"integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
"integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.70.0",
"@typescript-eslint/tsconfig-utils": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
"integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
"integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
@@ -9650,6 +9946,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -9774,6 +10083,30 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
"integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.70.0",
"@typescript-eslint/parser": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@@ -11,7 +11,7 @@
"test": "node scripts/skill-check.mjs",
"seed:fixture": "node scripts/seed-fixture.mjs --write",
"seed:check": "node scripts/seed-fixture.mjs",
"typecheck": "tsc -p ./jsconfig.json",
"typecheck": "tsc -p ./tsconfig.json",
"preview": "vite preview",
"test:browser": "node scripts/browser-check.mjs"
},
@@ -65,6 +65,7 @@
"puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.70.0",
"vite": "^6.1.0"
}
}

View File

@@ -5,6 +5,79 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
Regenerating it is a deliberate act, and the reason belongs here.
## 2026-09-20 — the three HTML baselines that held invented people were recaptured
Settled. The section that stood here said six checks failed on purpose and
listed the evidence; this is that debt being paid.
`hiringRecords.js` used to pad the hires list with five invented people
(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
padding reached Analytics too, where "total hires" counted eight against a
database holding three. It was removed, and because these baselines render with
queries disabled, the padding was *all* they contained. Candidates separately
became the final-selection queue, which changed what its empty state says.
Verified before regenerating, so the drift was known rather than assumed. Tag
counts for Hired History, baseline -> now:
<td 40 -> 0 five table rows of people who were never hired
<span 51 -> 13
<div 78 -> 39
<p 26 -> 7
<svg 20 -> 8
Every difference was content that had been fabricated. Analytics lost 269 class
attributes and 238 words to the same cause. Candidates gained exactly one class
— the description paragraph under "Nobody is awaiting a decision". No styling
rule, ordering rule or structural rule changed on any of the three.
**Recaptured and renamed**, because a file called `pre-migration` that holds
post-`dca1842` markup is a lie in the filename:
hired-history.pre-migration.html -> hired-history.render.html
analytics.pre-migration.html -> analytics.render.html
candidates.pre-migration.html -> candidates.render.html
`activity-page`, `candidates-analysis`, `control-center`, `positions` and
`talent-pool` still hold genuine pre-migration markup, still pass, and keep the
name that says so. The `PAGES` table in `skill-check.mjs` now carries each
baseline's FILENAME rather than deriving one suffix for all of them.
**The migration proof was not thrown away with the baseline.** One check —
`Hired History added only identity wrappers` — compared tag tallies to assert
that migrating the page added exactly two `<div>`s and changed nothing else.
That was true, and it was checkable only while the DATA was frozen as well: the
predicate subtracts one whole render from another, so removing the invented
hires moved every count and the arithmetic stopped describing wrappers at all.
Recapturing would not have rescued it — with the baseline equal to the render
the delta is zero, and a predicate demanding two can never hold again. Left in
place it would have stayed red for a new reason, which is worse than failing
for the old one.
It is replaced by three checks that read the render itself and need no frozen
file, so they keep holding as the page's content changes:
Hired History wraps exactly the node types registered to wrap
Hired History identities are unique and name composed nodes
Hired History identity wrappers carry no styling
Together these say what the tally said — `UiTreeRenderer` encloses a type
registered `wrap: true` in `<div {...attrs}>`, every other type takes the
attributes on its own root element, and a wrapper contributes identity and no
styling. Hired History composes five nodes; two are registered to wrap and
produce the two divs, two carry their identity on their own `<section>`, and
`hired-extensions-top` is an extension slot that renders nothing while no skill
is attached to it. The second check therefore asserts uniqueness and no strays
rather than one identity per composed node — a one-to-one rule would be
asserting that every extension slot is always filled.
`carries node identity in the DOM` was described here as needing pre-migration
markup. It does not: it reads only the live render, names `chronology` and
`records` directly, and is unaffected by any of this. It still passes.
`npm test` is 1693/1693.
## 2026-08-27 — the seed gained the three statuses nothing exercised
`application_status` has seven values. The fixture produced four: `applied`,
@@ -63,3 +136,48 @@ with it. It now names the page's topics in a sentence.
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
That is why this regeneration was safe: the diff was read first, and it was one
cosmetic change on a path that only runs when no agent is configured at all.
## 2026-09-11 — Candidates became the final-selection queue
`candidates: shows the same words` now fails, and it is the only check this
change breaks.
The page used to list every application the org had ever taken — all seven
statuses at once — which made it a second Talent Pool rather than the queue of
hiring decisions waiting on a human. It now opens on final selection: interview
completed, not hired, not rejected. That is a change to what the page *says*, so
a check asserting the page says exactly what it said before was always going to
fail. There is no version of this work that leaves those words alone.
**What drifted, verified before leaving it failing.** The rendered delta is the
empty state and nothing else:
before "…0 of 0 candidates No candidates match your filters"
now "…0 of 0 candidates Nobody is awaiting a decision
Candidates arrive here once their interview is completed,
and leave once they are hired or declined."
Title, subtitle and toolbar meta are byte-identical. The old copy was not merely
different, it was untrue: with no filters applied there is nothing to clear, and
"no candidates match your filters" describes a filter that was never set.
Tag and class counts, baseline → now:
class="…" 26 -> 27 one inserted: the description paragraph
One insertion, nothing changed and nothing dropped — which is why
`candidates: paints the same styled elements, in the same order` and
`candidates: carries node identity in the DOM` both still pass. Those two are
the migration proof; only the words moved.
The new stage filter options cost nothing here. `FilterSelect` is a Radix
`Select`, so its options live in a portal that is closed in static markup, and
`SelectValue` renders empty on the server — the baseline contains neither the
old option labels nor the new ones.
**Regenerated on 2026-09-20**, with the other two — see the entry at the top of
this file. It was held until then because `candidates.pre-migration.html` was
load-bearing for the UI node tree proof, and recapturing it earlier would have
written post-migration markup into a file named `pre-migration`. The file is now
`candidates.render.html` and the proof it was holding up has been replaced by
three checks that read the render directly.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@
"bartending-training",
"candidate-analysis",
"candidate-search",
"create-employee-role",
"create-position",
"customer-service-training",
"executive-summary",
@@ -161,6 +162,7 @@
"pageKey": "positions",
"route": "/admin/positions",
"skills": [
"create-employee-role",
"create-position",
"hiring-activity-assistant",
"staffing-risk"
@@ -707,6 +709,7 @@
"pageKey": "talent-pool",
"route": "/admin/talent-pool",
"skills": [
"create-employee-role",
"talent-pool-analysis"
],
"suggestions": [

View File

@@ -0,0 +1,209 @@
/**
* Generates `src/types/entities.generated.ts` from the backend's resource
* registry.
*
* node scripts/gen-entity-types.mjs # check: exits non-zero if stale
* node scripts/gen-entity-types.mjs --write # regenerate
*
* The same arrangement as `seed-fixture.mjs`, and for the same reason: two
* descriptions of one shape, maintained by hand, only ever fail quietly. Here
* the two are the columns Postgres actually has and the fields this app
* believes it will be sent.
*
* ## Why generate rather than transcribe
*
* `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated,
* out of `information_schema`, and says so: "Column names, types, enum values
* and nullability are read out of information_schema so they cannot drift from
* the migrations." It is the closest thing to the database that can be read
* without connecting to one — 15 resources, 298 columns, every one carrying its
* kind, its nullability and its permitted values.
*
* Two hundred and ninety-eight fields retyped by hand would be two hundred and
* ninety-eight chances to be confidently wrong, and a type that is wrong is
* worse than no type: it is a claim the compiler will defend.
*
* ## Where the TypeScript types come from
*
* Not from the Postgres type — from `Column.SelectExpr()` in
* `domain/resource.go`, which is what the read projection actually emits. The
* two differ, and the differences are the whole point of reading the code
* rather than the schema:
*
* uuid ::text -> string (never a byte array)
* numeric ::float8 -> number (never pgtype.Numeric)
* date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp)
* timestamptz to_char(…ISO with ms…) -> string
* citext ::text -> string
* bigint ::text -> string ← user_activity.id only
* int (uncast) -> number
*
* That last one is the case worth the whole exercise: `user_activity.id` is an
* identity bigint and arrives as a STRING, because every id the frontend
* handles is an opaque string. A hand-written interface would have called it a
* number, and nothing would have contradicted that until a comparison silently
* stopped matching.
*
* Nullability is the column's: `NotNull: true` becomes a required field, its
* absence becomes `| null`. Every column is emitted because the projection
* emits every column — `Repo.selectList()` maps over `res.Columns` with no
* filter, and §4.1 of the API contract states the record is complete.
*
* ## What this does NOT generate
*
* Three of the eighteen entities the client knows are absent from the registry
* because they are not served by the generic entity machinery: AgentDefinition
* and SkillDefinition have dedicated handlers in `httpserver/definitions.go`,
* and the user is `httpserver/me.go`. Their shapes are hand-written in
* `src/types/user.ts` and `src/types/entities.ts`. This file does not invent
* them.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
/** The registry, read from the sibling checkout. */
export const REGISTRY_PATH = join(
process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go');
export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts');
/**
* Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's
* Postgres type. `pgType` is needed because two kinds cast conditionally.
*/
function tsTypeFor(kind, pgType, enumValues) {
switch (kind) {
case 'KindUUID': return 'string'; // ::text
case 'KindString': return 'string'; // citext also ::text
case 'KindBool': return 'boolean';
case 'KindFloat': return 'number'; // ::float8
case 'KindInt': return pgType === 'bigint' ? 'string' : 'number';
case 'KindDate': return 'string'; // 'YYYY-MM-DD'
case 'KindTimestamp': return 'string'; // ISO-8601 with ms
case 'KindTextArray': return 'string[]';
case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | ');
case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own
default: throw new Error(`unmapped Kind: ${kind}`);
}
}
/** Parses the Go registry into `[{ name, path, table, columns }]`. */
export function parseRegistry(source) {
const resources = [];
/* Each resource opens with a lone `{` one tab in, then a `Name:` line two
tabs in. Splitting on that opener gives one block per resource; the header
match rejects anything that is not one. */
const blocks = source.split(/\n\t\{\n/).slice(1);
for (const block of blocks) {
const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/);
if (!head) continue;
const [, name, path, table] = head;
const columns = [];
const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g;
let m;
while ((m = colRe.exec(block)) !== null) {
const [, colName, kind, pgType, rest] = m;
/* `rest` is already cut at the first `}`, which for an enum column is
the one closing `[]string{...}` — so the values are in it but the
brace is not. Requiring a closing brace here matched nothing and
silently produced empty unions. */
const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/);
const enumValues = enumMatch
? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1])
: [];
columns.push({
name: colName,
kind,
pgType,
notNull: /NotNull: true/.test(rest),
readOnly: /ReadOnly: true/.test(rest),
required: /Required: true/.test(rest),
enumValues,
});
}
if (columns.length) resources.push({ name, path, table, columns });
}
return resources;
}
const FLAGS = (c) => {
const notes = [];
if (c.readOnly) notes.push('server-owned');
if (c.required) notes.push('required on create');
return notes.length ? ` /** ${notes.join('; ')}. */\n` : '';
};
/** One resource -> one exported interface. */
function renderInterface(res) {
const fields = res.columns.map((c) => {
const ts = tsTypeFor(c.kind, c.pgType, c.enumValues);
/* A nullable column returns JSON null, so the field is present and null
rather than absent. `?:` would describe a key that can be missing, which
is not what the projection does. */
const type = c.notNull ? ts : `${ts} | null`;
return `${FLAGS(c)} ${c.name}: ${type};`;
}).join('\n');
return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`;
}
export function render(resources) {
const banner = `/**
* GENERATED FILE — DO NOT EDIT BY HAND.
*
* Regenerate with: node scripts/gen-entity-types.mjs --write
* Source of truth: krow-backend/go-api/internal/domain/resources_gen.go
* (itself generated from information_schema)
*
* Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the
* read projection emits — not the raw Postgres type. See the generator's header
* for the mapping and for why the two differ.
*
* ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns.
*/
`;
const interfaces = resources.map(renderInterface).join('\n\n');
const names = resources.map((r) => ` | '${r.name}'`).join('\n');
const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n');
const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */
export type GeneratedEntityName =
${names};
/** Entity name -> its record type, for looking a record up by name. */
export interface GeneratedEntityRecords {
${mapEntries}
}
`;
return `${banner}\n${interfaces}\n\n${tail}`;
}
/* ── CLI ──────────────────────────────────────────────────────────────────── */
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
if (!existsSync(REGISTRY_PATH)) {
console.log('krow-backend is not checked out beside this repo; entity types not checked here.');
console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.');
process.exit(0);
}
const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8'));
if (!resources.length) {
console.error('parsed no resources out of resources_gen.go — has its shape changed?');
process.exit(1);
}
const generated = render(resources);
if (process.argv.includes('--write')) {
writeFileSync(OUTPUT_PATH, generated);
const cols = resources.reduce((n, r) => n + r.columns.length, 0);
console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`);
} else if (!existsSync(OUTPUT_PATH)) {
console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write');
process.exit(1);
} else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) {
console.error('entities.generated.ts is stale — it no longer matches the backend registry.');
console.error('Run: node scripts/gen-entity-types.mjs --write');
process.exit(1);
} else {
console.log('entities.generated.ts is in step with the backend registry.');
}
}

View File

@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createServer } from 'vite';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { withSourceResolution } from './ssr-resolve.mjs';
const ROOT = process.cwd();
@@ -26,6 +27,11 @@ const server = await createServer({
logLevel: 'error',
});
/* Resolves the capture's module paths whatever extension they carry during the
TypeScript migration. The comparison this script performs is unaffected: it
loads the same modules and serialises the same values. */
withSourceResolution(server, ROOT);
const captured = await captureBaseline(server);
await server.close();

View File

@@ -18,6 +18,7 @@ import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { withSourceResolution } from './ssr-resolve.mjs';
/**
* The design system reads `window` when its modules evaluate, which is a
@@ -97,6 +98,9 @@ if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
logLevel: 'error',
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
});
/* The page path arrives on the command line with whatever extension it has
today; during the TypeScript migration that may be .jsx or .tsx. */
withSourceResolution(server, process.cwd());
try {
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
writeFileSync(out, html);

View File

@@ -20,6 +20,7 @@ import { createServer } from 'vite';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { withSourceResolution } from './ssr-resolve.mjs';
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
@@ -30,10 +31,31 @@ export const GENERATED_NOTE =
export function fixtureFrom(seedModule) {
const { ShiftRecord, ...entities } = seedModule.seedData;
/* First key, so it is the first thing anyone opening the file reads. The Go
loader unmarshals into a struct of demoUser + entities and ignores the
rest, so this costs nothing on the reading side. */
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
/* `_generated` first, so it is the first thing anyone opening the file reads.
`users` is the channel the seeder actually reads — `internal/seeder`
unmarshals into `{demoUser, users, entities}` and writes every account in
`users`, falling back to `demoUser` alone when the key is absent. That
fallback is why `demoUser` stays beside it rather than being replaced: a
fixture written here still seeds correctly against a backend that predates
the list, it simply seeds one account instead of two.
Emitting only `demoUser` is what this generator used to do, and it was a
version behind: the fixture on disk carries the employer account, the
seeder reads it from `users`, and regenerating without this key would drop
`employer@krow.app` from every fresh seed — leaving nobody to sign in as
to reach the employer console, which is the exact gap the account was
added to close.
`entities.User` carries the same list. The seeder ignores it, and it is
emitted because `seedData` is copied wholesale; the two are the same array
rather than two places to keep in step. */
return {
_generated: GENERATED_NOTE,
demoUser: seedModule.DEMO_USER,
users: seedModule.seedData.User,
entities,
};
}
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = await createServer({
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
});
/* `buildFixture` loads `/src/api/seed.js`, which the TypeScript migration
will rename. The fixture it generates is unchanged either way. */
withSourceResolution(server, process.cwd());
const built = await buildFixture(server);
await server.close();

View File

@@ -19,6 +19,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs';
import { resolveSourcePath, withSourceResolution } from './ssr-resolve.mjs';
const ROOT = process.cwd();
const results = [];
@@ -41,6 +42,12 @@ const server = await createServer({
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
});
/* Every `ssrLoadModule` path below names a file by its current extension. While
the TypeScript migration is under way that extension changes underneath them,
so the loader resolves .js/.jsx/.ts/.tsx rather than each call site having to
know which one a module is on today. The path as written is tried first. */
withSourceResolution(server, ROOT);
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
@@ -1611,10 +1618,10 @@ record('...and stops asking a backend that does not have the route',
now does — and the failure mode of it returning is not an error but a second
opinion silently outranking the server's. */
record('the panel imports no local suggestion ranker',
!existsSync(join(ROOT, 'src/components/ai-assistant/matchPrompts.js')),
!existsSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/matchPrompts.js'))),
'matchPrompts.js removed');
const panelSource = readFileSync(join(ROOT, 'src/components/ai-assistant/KrowAssistant.jsx'), 'utf8');
const panelSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/KrowAssistant.jsx')), 'utf8');
record('...and does not rank, score or sort suggestions itself',
!/rankPrompts|\.sort\(|score\(/.test(panelSource),
'no ranking in the panel');
@@ -1623,10 +1630,10 @@ record('...it asks the API instead',
/* The production data client must not carry the fixture. */
record('the app does not import the seed fixture',
!/from '\.\/seed'/.test(readFileSync(join(ROOT, 'src/api/base44Client.js'), 'utf8')),
!/from '\.\/seed'/.test(readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')),
'base44Client reads demoUser.js, not seed.js');
record('the local entity store is gone',
!existsSync(join(ROOT, 'src/api/store.js')),
!existsSync(join(ROOT, resolveSourcePath('src/api/store.js'))),
'store.js removed');
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
@@ -2951,7 +2958,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
})().pass);
record('ownership: the rule is the one the page actually uses', (() => {
const source = readFileSync(join(ROOT, 'src/components/skills/SkillSurface.jsx'), 'utf8');
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/skills/SkillSurface.jsx')), 'utf8');
return {
pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source)
&& /from '@\/lib\/agents\/runtime'/.test(source),
@@ -2962,7 +2969,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
record('ownership: the panel reads agents from the definitions store', (() => {
/* The live failure: this file read `preferences.customAgents`, which authored
agents had already moved out of, so no attachment was ever visible here. */
const source = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentContext.jsx'), 'utf8');
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentContext.jsx')), 'utf8');
return {
pass: /useAgentDefinitions\(\)/.test(source)
&& /sourcesFrom\(/.test(source)
@@ -3781,7 +3788,7 @@ record('agent starters carry no capability, so they cannot address an unoffered
* Read out of `App.jsx` rather than asserted against a written list, so a route
* that is renamed or removed fails here rather than in someone's browser.
*/
const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
const appSource = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
/**
* The switcher, if there still is one.
@@ -3793,7 +3800,7 @@ const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
* ENOENT and stopped the whole run here, taking every section after it with it.
* Absent is a state to report, not to crash on.
*/
const SWITCHER_PATH = join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx');
const SWITCHER_PATH = join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentSwitcher.jsx'));
const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null;
/* Only live navigations count: a disabled control goes nowhere by design. */
@@ -3829,7 +3836,7 @@ record('every route the agent switcher navigates to exists',
* keeping — offered somewhere, and the route resolves — so it is asserted
* against where the control actually is.
*/
const agentsListSource = readFileSync(join(ROOT, 'src/pages/admin/WorkspaceAgents.jsx'), 'utf8');
const agentsListSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/WorkspaceAgents.jsx')), 'utf8');
record('creating an agent is reachable, and its screen exists',
/navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource)
&& routed.has('/admin/workspace/agents/new'),
@@ -4490,7 +4497,7 @@ record('every category offered by the picker matches at least one skill',
const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new'];
const appRoutes = new Set(
[...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
[...readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
.map((m) => m[1])
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
);
@@ -4516,7 +4523,7 @@ record('every surface route is registered in the router',
record('the dynamic agent route is registered after the static one',
(() => {
const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
const source = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id');
})(),
'so `agents/new` cannot be read as an agent whose id is "new"');
@@ -4703,8 +4710,8 @@ record('...and never answered from the configure page itself',
* Asserted structurally: the configure screen must not import or define a chat.
* The panel it gets is the one the Admin shell already mounts.
*/
const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8');
const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8');
const detailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/AgentDetail.jsx')), 'utf8');
const configureSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentConfigure.jsx')), 'utf8');
/* The property is right and the old pattern was too blunt. It matched any
occurrence of the substring, so `useAssistantPanel` — the hook for talking to
@@ -4728,18 +4735,18 @@ record('the configure screen defines no chat of its own',
record('the panel is mounted once, by the shell',
/* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a
different component and legitimately present. */
(readFileSync(join(ROOT, 'src/layouts/AdminLayout.jsx'), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
(readFileSync(join(ROOT, resolveSourcePath('src/layouts/AdminLayout.jsx')), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
/* The switcher is gone — see the note above. What replaced it is a label, and
the property worth keeping is that it stayed one: a header that can be
clicked is a second place to choose an agent. */
const badgeSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentBadge.jsx'), 'utf8');
const badgeSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentBadge.jsx')), 'utf8');
record('the panel header names the agent and does not switch it',
!/<button|onClick=|navigate\(/.test(badgeSource),
'a label, not a control');
record('PageContext is untouched',
!/agentConfigure/.test(readFileSync(join(ROOT, 'src/components/ai-assistant/PageContext.jsx'), 'utf8')));
!/agentConfigure/.test(readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/PageContext.jsx')), 'utf8')));
/* ── The configure surface declares no placements ───────────────────────── */
@@ -4774,7 +4781,7 @@ record('no skill declares the configure page',
* **It stays Krow.** The palette is the tokens, and nothing else.
*/
const canvasSource = readFileSync(join(ROOT, 'src/components/agents/AgentCanvas.jsx'), 'utf8');
const canvasSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentCanvas.jsx')), 'utf8');
const agentUi = detailSource + configureSource + canvasSource;
/* One surface with sections inside it, not four cards side by side. */
@@ -4915,10 +4922,10 @@ const canvasImporters = [
...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]),
...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]),
]
.filter(([, f]) => /\.jsx?$/.test(f))
.filter(([, f]) => /\.[jt]sx?$/.test(f))
.filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f))
.filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/
.test(readFileSync(join(ROOT, dir, f), 'utf8')))
.test(readFileSync(join(ROOT, resolveSourcePath(`${dir}/${f}`)), 'utf8')))
.map(([dir, f]) => `${dir}/${f}`);
record('the workspace treatment is used by the configure screen alone',
canvasImporters.length === 0,
@@ -5952,6 +5959,354 @@ record('an assigned candidate counts as hired',
records.HIRED_STATUSES.includes('assigned') &&
records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired'));
/* ── Final selection, and seats ──────────────────────────────────────────
*
* Final selection is DERIVED — there is no `final_selection` value in the
* `application_status` enum and none is added. The fact it reads is the
* existence of an interview row, which is a NOT NULL foreign key, rather than
* `job_applications.interview_id`, which the schema keeps as an unconstrained
* soft reference precisely so it may dangle.
*
* That distinction is the whole point: `status = 'interview'` is set both when
* an interview is arranged and when one is completed, so status alone cannot
* tell a queue of people who have been interviewed from a queue of people who
* have merely been booked in.
*/
const fsApps = [
{ id: 'a-applied', status: 'applied' },
{ id: 'a-booked', status: 'interview' }, // arranged, never sat
{ id: 'a-done', status: 'interview' }, // sat it
{ id: 'a-hired', status: 'hired' },
{ id: 'a-assigned', status: 'assigned' },
{ id: 'a-rejected', status: 'rejected' },
];
const fsInterviews = [
{ id: 'i1', application_id: 'a-done' },
{ id: 'i2', application_id: 'a-hired' },
{ id: 'i3', application_id: 'a-rejected' },
];
const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id);
record('final selection holds only the interviewed and undecided',
queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty');
record('...so an interview merely arranged does not qualify',
!records.isFinalSelection(fsApps[1], fsInterviews),
'status interview with no interview row');
record('...and a decided application never reappears in the queue',
['a-hired', 'a-assigned', 'a-rejected']
.every((id) => !queue.includes(id)),
'hired, assigned and rejected all excluded');
record('...while the interview record, not interview_id, is what is read',
!records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []),
'a soft reference alone proves nothing');
/* Seats are counted from the employment records. A `filled` column would be a
second source of truth, and nothing could say which one was right. */
const seatPosting = { id: 'p1', headcount: 2 };
const seatStaff = [
{ id: 's1', job_posting_id: 'p1', status: 'active' },
{ id: 's2', job_posting_id: 'p1', status: 'onboarding' },
{ id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens
{ id: 's4', job_posting_id: 'p2', status: 'active' }, // another position
];
record('seats filled are counted from the staff records',
records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`);
record('...someone who has left frees their seat',
records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1,
'inactive staff do not hold a seat');
record('...a full position reports no remaining seats, never a negative number',
records.isFullyStaffed(seatPosting, seatStaff)
&& records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0,
'over-hiring floors at zero');
/* ── The Candidates page IS the final-selection queue ─────────────────────
*
* The derivation above is only worth having if the page that decides hires
* actually opens on it. Candidates used to list every application the org had
* ever taken — all seven statuses at once — which made it a second Talent Pool
* rather than a queue of decisions waiting.
*
* Two rules these assertions hold in place, both of them safety rules rather
* than presentation ones:
*
* The evidence is the interview ROW. `interview_id` is a soft reference the
* schema deliberately leaves unconstrained, and it dangles in real data.
*
* The score never gates. `ai_score`, `verdict` and `hire_recommendation`
* assist a recruiter; a human makes the selection. A candidate who scored
* zero and sat the interview is still a decision somebody owes them.
*/
record('a candidate who scored zero still reaches the queue',
records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 },
[{ id: 'i9', application_id: 'a-zero' }]),
'screening is not a gate on the decision');
{
/* Comments are stripped first: these assert what the page DOES, and a
comment explaining the bug that was fixed must not read as the bug. */
const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const candidatesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/Candidates.jsx')), 'utf8'));
const nodesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/candidates/nodes.jsx')), 'utf8'));
/* Read from source rather than imported: the page pulls in the interview
modal, which touches `window` at module scope, and standing up a DOM to
read one string constant would be a worse test than reading the string. */
const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1];
record('the candidate page opens on the final-selection queue',
defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc),
String(defaultStage));
/* The interview rows were fetched and then thrown away — `useInterviews();`
on its own line, its result unbound — which is why the queue could not be
derived on the one page that needed it. */
record('the candidate page keeps the interview rows it fetches',
/(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc),
'useInterviews() is bound, not discarded');
/* The filter compared `a.stage`. Applications carry `status`; no application
has ever had a `stage` property, so every stage selection emptied the
page. */
record('the stage filter reads a field applications actually carry',
!/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc),
'status, not an undefined stage');
/* Narrowing the default view is only safe if nothing becomes unreachable.
`shortlisted` and `assigned` were absent from these options, so the rows
holding them could not be found by any selection. */
const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)]
.map((m) => m[1]);
const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st));
record('every application status is still reachable from the stage filter',
unreachable.length === 0, unreachable.join(', ') || 'all seven offered');
record('...and the queue itself is offered alongside them',
offered.includes('final') && offered.includes('all'),
'final selection and all stages');
}
/* ── Human interviews leave a trace, and only completion counts ───────────
*
* A human interview used to persist nothing at all: the modal collected a
* type, a date, a time and notes, announced "Interview scheduled!", and dropped
* every one of them. So the final-selection queue could only ever hold people
* an AI had interviewed, and anyone seen by a person was invisible to it.
*
* What changed is where each fact lives, and the split is the safety property:
*
* booking → a `user_activity` row. Advances nobody.
* completion→ an `ai_interviews` row. The evidence, unchanged since Step 6.
*
* `isFinalSelection` is not touched by any of this and is not expected to be.
* These assertions exist to prove that a booking cannot masquerade as an
* interview, which is the one way this work could put a candidate in front of a
* hiring decision that nobody has actually met.
*/
const human = await server.ssrLoadModule('/src/lib/humanInterviews.js');
{
const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' };
const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString();
const bookedEvent = {
event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1),
metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' },
};
const completedRow = { id: 'i-human', application_id: 'a-human' };
/* Booked, and nothing else. */
record('a booked human interview is recorded',
human.humanInterviewState(app, [bookedEvent], []) === 'scheduled');
record('...and the booking keeps what was entered',
(() => {
const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent]));
return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep';
})(), 'type, date, time and notes survive');
record('...but booking alone is NOT final selection',
!records.isFinalSelection(app, []),
'no interview row, no decision to make');
/* Held. */
record('a completed human interview is recorded',
human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed');
record('...and a completed human interview DOES reach final selection',
records.isFinalSelection(app, [completedRow]));
record('...even with no score, because a human interview measures none',
records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]),
'absence of an AI score is not absence of an interview');
/* Not held. Both outcomes are events, and neither is an interview. */
for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) {
const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }];
record(`an interview ${label} leaves no completion record`,
human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show'));
record(`...so ${label} never reaches final selection`,
!records.isFinalSelection(app, []));
}
/* A decided application is out regardless of how it was interviewed. */
for (const status of ['hired', 'assigned', 'rejected']) {
record(`a ${status} application leaves the queue even with a human interview`,
!records.isFinalSelection({ ...app, status }, [completedRow]));
}
/* The Step 6 guard, restated against a human booking: a booking may well set
interview_id one day, and it must still prove nothing on its own. */
record('a dangling interview_id still does not qualify a human interview',
!records.isFinalSelection({ ...app, interview_id: 'nope' }, []),
'the row is the evidence, not the reference');
/* An event claiming completion with no row behind it. The log is not the
record, and treating it as one would reintroduce exactly the soft-reference
problem interview_id already demonstrates. */
record('a completion EVENT without an interview row is not completion',
human.humanInterviewState(app,
[{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none',
'user_activity is a log, not the evidence');
}
/* ── Nothing about AI is invented for a human interview ───────────────────
*
* The completed-interview record lives in `ai_interviews`, which is currently
* reused as the durable completion record for both flows. Reusing the table is
* a deliberate decision; filling in its AI columns with plausible numbers no
* machine produced would not be. A fabricated score or verdict would be read by
* analytics, by the candidate profile and by a recruiter as a measurement.
*
* `overall_interview_score` matters twice over: the server copies it onto the
* application only when the request mentions it, so sending a 0 would overwrite
* a real screening score with one this interview never took.
*/
{
const hooksSrc = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
const complete = hooksSrc.slice(
hooksSrc.indexOf('export function useCompleteHumanInterview'),
hooksSrc.indexOf('export function useRecordInterviewNotHeld')
);
const schedule = hooksSrc.slice(
hooksSrc.indexOf('export function useScheduleHumanInterview'),
hooksSrc.indexOf('export function useCompleteHumanInterview')
);
record('the human completion path exists at all', complete.length > 0 && schedule.length > 0);
const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores',
'hire_recommendation', 'messages', 'ai_score']
.filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete));
record('a human interview fabricates no AI data',
fabricated.length === 0, fabricated.join(', ') || 'no AI column is written');
record('...and the interviewer\'s own assessment is only sent when given',
/if \(assessment\) record\.verdict = assessment;/.test(complete),
'verdict is human-entered or left to the schema');
record('...while the facts it does write are the ones it knows',
/application_id: application\.id/.test(complete)
&& /job_posting_id: application\.job_posting_id/.test(complete));
/* The rule the whole step turns on. */
record('scheduling never writes an interview record',
!/AIInterview|useCreateInterview|createInterview/.test(schedule),
'a booking touches user_activity only');
record('...and completion goes through the existing interview client',
/createInterview\.mutateAsync/.test(complete),
'no second interview API');
/* Not held writes an event and nothing else — no row, and no invented
application status to go with it. */
const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld'));
record('a cancelled or unattended interview writes no record and no new status',
!/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld),
'an event, and nothing else');
}
/* The AI flow is untouched: it still writes its own record at completion, and
still does it through the same client this now shares. */
{
const aiSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/AIInterviewModal.jsx')), 'utf8');
record('the AI interview still records itself on completion',
/const finishInterview = useCallback/.test(aiSrc)
&& /createInterview\.mutateAsync\(\{/.test(aiSrc)
&& /messages: finalMessages/.test(aiSrc),
'unchanged by the human path');
}
/* The modal is no longer theatre. */
{
const modalSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/ScheduleInterviewModal.jsx')), 'utf8');
record('scheduling an interview persists it',
/useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc));
record('...the props its six call sites pass are unchanged',
/\{ open, onClose, application, position \}/.test(modalSrc),
'open, onClose, application — position optional');
record('...and a failed save is reported rather than celebrated',
/setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc),
'the old version always claimed success');
}
/* Every path that files an application on somebody's behalf must carry the
link back to the talent-pool record. The column is nullable, so omitting it
saves cleanly and fails silently: the application belongs to an email address
instead of to a person, and the hire it becomes cannot be traced back to the
profile it came from. Both call sites are asserted because they were written
at different times and only one of them is on the position page. */
const positionDetailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/PositionDetail.jsx')), 'utf8');
const hooksSource = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
record('admitting talent to a position links the application to the profile',
/worker_profile_id: profile\.id/.test(positionDetailSource),
'PositionDetail.admitTalent');
record('...and so does assigning a worker straight from the pool',
/worker_profile_id: worker\.profile\?\.id/.test(hooksSource),
'useAssignWorkers filing an application');
/* ── One person, many positions ──────────────────────────────────────────
*
* The two profile routes answer different questions and must stay separate.
* `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every
* position they are up for. `/admin/candidates/:id` is that person FOR ONE
* POSITION — a `job_applications` row, with its score, interview and decision.
*
* One screen doing both is how a product ends up with a candidate record per
* vacancy, which is exactly what `worker_profiles` exists to prevent.
*/
record('the person and the application have separate routes',
routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'),
[...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', '));
/* Which applications are this person's. The foreign key is the answer; email is
the fallback for rows written before the key was being set, and it has to be
there or every application already in the database detaches from its person. */
const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' };
const whoApps = [
{ id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' },
{ id: 'by-email', worker_profile_id: null, email: 'maria@example.com' },
{ id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' },
{ id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' },
];
const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id);
record('an application finds its person by foreign key',
whoIds.includes('by-key'), 'worker_profile_id');
record('...and falls back to email, case-insensitively, for older rows',
whoIds.includes('by-email') && whoIds.includes('by-email-case'),
'citext identity, as worker_profiles already asserts');
record('...without collecting somebody else',
!whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', '));
record('...and asks for nobody when there is no profile',
records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything');
/* The reported symptom: a talent pool you could read and not use. */
const talentPoolSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/talent-pool/nodes.jsx')), 'utf8');
record('a talent pool row opens that person',
/onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource),
'DataTable wires the handler only when it is given one');
record('...and no action pretends to open something it does not',
!/toast\.info\(`Opening/.test(talentPoolSource),
'the "view profile" toast is gone');
record('...and a person can be put in front of a vacancy from the pool',
/setConsidering\(p\)/.test(talentPoolSource)
&& /AddToPositionModal/.test(talentPoolSource),
'the step that was missing between pool and position');
/* "Screened and waiting on a decision" includes the shortlisted. This counted
`ai_screened` alone, which read as correct only while nothing was ever
shortlisted — the first shortlisted candidate dropped out of the count in
@@ -7340,7 +7695,7 @@ console.log('\n── Candidates vs Talent Pool ──');
...registryMod.nodeRegistry.list(),
];
const engine = ['node.js', 'registry.js', 'operations.js', 'validate.js', 'patch.js', 'inspect.js']
.map((f) => readFileSync(join(ROOT, 'src/lib/ui', f), 'utf8'));
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/lib/ui/${f}`)), 'utf8'));
const offences = [];
engine.forEach((source, i) => {
@@ -7567,7 +7922,7 @@ console.log('\n── Candidates vs Talent Pool ──');
entry is only created by a `register` call that was handed a reference.
A node naming a type nobody registered draws nothing — asserted above —
so there is no string that can become code. */
const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8')
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
return !/\bimport\s*\(/.test(source)
@@ -7752,7 +8107,7 @@ console.log('\n── Candidates vs Talent Pool ──');
record('applying writes only the uiLayouts key, so no other preference moves', (() => {
/* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint
shallow-merges, so `customSkills` and the rest survive untouched. */
const source = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8');
const source = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
const fn = source.slice(source.indexOf('export function useUiLayouts'), source.indexOf('export function useJobPostings'));
const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim());
return writes.length === 1 && writes[0].startsWith('uiLayouts:');
@@ -7852,7 +8207,7 @@ console.log('\n── Candidates vs Talent Pool ──');
record('an operation that would be refused is never stored', (() => {
/* `propose` validates before it previews, so a refused change never becomes
a preview and therefore never reaches an apply. */
const provider = readFileSync(join(ROOT, 'src/components/ui-tree/UiEditingProvider.jsx'), 'utf8');
const provider = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiEditingProvider.jsx')), 'utf8');
const proposeBody = provider.slice(provider.indexOf('const propose'), provider.indexOf('/** Throw the experiment away'));
return /applyOperation\(/.test(proposeBody)
&& /if \(!result\.ok\)/.test(proposeBody)
@@ -7870,7 +8225,7 @@ console.log('\n── Candidates vs Talent Pool ──');
];
const offences = [];
for (const f of files) {
const code = readFileSync(join(ROOT, f), 'utf8')
const code = readFileSync(join(ROOT, resolveSourcePath(f)), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) {
if (code.includes(bad)) offences.push(`${f}: ${bad}`);
@@ -8124,7 +8479,7 @@ console.log('\n── Candidates vs Talent Pool ──');
})().pass);
record('the language layer names no page and no component', (() => {
const code = readFileSync(join(ROOT, 'src/lib/ui/intent.js'), 'utf8')
const code = readFileSync(join(ROOT, resolveSourcePath('src/lib/ui/intent.js')), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()];
const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(code));
@@ -8474,22 +8829,69 @@ console.log('\n── Candidates vs Talent Pool ──');
const classes = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
const words = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const tally = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
.map((t) => t.toLowerCase())
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
const hiredNow = stripControls(await draw('/src/pages/admin/HiredHistory.jsx', 'hired-history', '/admin/hired'));
const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.pre-migration.html'), 'utf8');
const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.render.html'), 'utf8');
record('Hired History paints the same styled elements, in the same order',
classes(hiredNow) === classes(hiredBefore));
record('Hired History shows the same words', words(hiredNow) === words(hiredBefore));
record('Hired History added only identity wrappers', (() => {
const b = tally(hiredBefore);
const n = tally(hiredNow);
const changed = [...new Set([...Object.keys(b), ...Object.keys(n)])].filter((t) => (b[t] || 0) !== (n[t] || 0));
return { pass: changed.join(',') === '<div,</div' && n['<div'] - b['<div'] === 2, detail: changed.join(', ') };
})().pass);
/**
* What the node tree adds to a page's markup, proved from the render itself.
*
* This replaces a tag tally against the captured baseline, which asserted
* that migrating Hired History added exactly two `<div>`s and changed
* nothing else. That was true, and it was only checkable while the page's
* DATA was frozen too: the comparison subtracts one whole render from
* another, so removing the invented hires moved every count and the
* arithmetic stopped describing the wrappers at all. Recapturing does not
* rescue it either — with the baseline equal to the render the tag delta is
* zero, and a predicate demanding a delta of two can never hold again.
*
* So the property is asserted directly instead of inferred from a
* subtraction. `UiTreeRenderer` gives a node its identity one of two ways:
* a type registered `wrap: true` is enclosed in `<div {...attrs}>`, and
* every other type takes the attributes on its own root element. The three
* checks below say exactly that, and they keep saying it as the page's
* content changes — which the tally could not.
*/
const hiredComposed = compMod.composePage('hired-history', ctx5).tree;
const hiredNodes = nodeMod5.walk(hiredComposed);
const hiredWrapped = hiredNodes.filter((n) => reg5.get(n.type)?.wrap === true);
/* The attribute list of every `<div>` that carries an identity. */
const hiredWrappers = [...hiredNow.matchAll(/<div([^>]*\bdata-ui-node="[^"]+"[^>]*)>/g)]
.map((m) => m[1]);
const hiredIds = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
record('Hired History wraps exactly the node types registered to wrap',
hiredWrappers.length === hiredWrapped.length,
`${hiredWrappers.length} wrapper div(s) for ${hiredWrapped.length} wrapping type(s): `
+ `${hiredWrapped.map((n) => n.id).join(', ') || 'none'}`);
/* Unique, and every one a node the page actually composed.
NOT "one per composed node": an extension slot with no skills attached
renders nothing at all, which is correct and is why `hired-extensions-top`
has no identity here. Asserting a one-to-one mapping would be asserting
that every slot is always filled. What must hold is that nothing is
addressed twice and nothing is addressed that the page did not compose —
either would break the editor's and Owliver's ability to name a node. */
const hiredComposedIds = new Set(hiredNodes.map((n) => n.id));
const hiredDuplicated = hiredIds.filter((id, i) => hiredIds.indexOf(id) !== i);
const hiredStrays = hiredIds.filter((id) => !hiredComposedIds.has(id));
const hiredSilent = hiredNodes.filter((n) => !hiredIds.includes(n.id)).map((n) => `${n.id} (${n.type})`);
record('Hired History identities are unique and name composed nodes',
hiredDuplicated.length === 0 && hiredStrays.length === 0,
`${hiredIds.length} of ${hiredNodes.length} composed node(s) addressed`
+ `${hiredSilent.length ? `; rendering nothing: ${hiredSilent.join(', ')}` : ''}`
+ `${hiredDuplicated.length ? `; DUPLICATED: ${hiredDuplicated.join(', ')}` : ''}`
+ `${hiredStrays.length ? `; STRAY: ${hiredStrays.join(', ')}` : ''}`);
/* Identity, not presentation. A wrapper that picked up a class would be
styling the page from the tree, which is the one thing wrapping a node
must never do — it is why `nodeAttrs` emits `data-` attributes only. */
record('Hired History identity wrappers carry no styling',
hiredWrappers.every((attrs) => !/\bclass=/.test(attrs)),
hiredWrappers.map((a) => a.trim()).join(' | ') || 'no wrappers');
record('Hired History carries node identity in the DOM', (() => {
const ids = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
return ids.includes('chronology') && ids.includes('records');
@@ -8718,11 +9120,17 @@ console.log('\n── Candidates vs Talent Pool ──');
const words = (h) => h.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const PAGES = [
['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis'],
['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
/* The fourth column is the baseline FILE, not a stem. Three pages were
recaptured after the final-selection work changed what they render and
are named `.render.html`; the other two still hold genuine
pre-migration markup and keep the name that says so. Deriving the
filename from the page would force both sets to share one suffix, and
whichever suffix won would be a lie about half of them. */
['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates.render.html'],
['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool.pre-migration.html'],
['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics.render.html'],
['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis.pre-migration.html'],
['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center.pre-migration.html'],
];
for (const [page, modulePath, route, baseline] of PAGES) {
@@ -8734,7 +9142,7 @@ console.log('\n── Candidates vs Talent Pool ──');
React.createElement(UiEditingProvider, { page },
React.createElement(Page))))
));
const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}.pre-migration.html`), 'utf8');
const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}`), 'utf8');
/**
* A migration may INSERT a wrapper class — grouping three siblings under
@@ -9163,7 +9571,7 @@ console.log('\n── Candidates vs Talent Pool ──');
record('the editor never evaluates a string as code', (() => {
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
.map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8'))
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
.join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
return !/\beval\s*\(/.test(files) && !/new\s+Function/.test(files)
@@ -9172,7 +9580,7 @@ console.log('\n── Candidates vs Talent Pool ──');
record('the editor contains no page name and no component branch', (() => {
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
.map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8'))
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
.join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...nreg.list()];
@@ -9385,7 +9793,7 @@ console.log('\n── Candidates vs Talent Pool ──');
* `scripts/browser-flows.js`, and only held to its contract here.
*/
record('every node is rendered inside a boundary', (() => {
const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8');
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8');
return {
pass: /<UiNodeBoundary node=\{node\}>/.test(source)
&& /import \{ UiNodeBoundary \}/.test(source),

108
scripts/ssr-resolve.mjs Normal file
View File

@@ -0,0 +1,108 @@
/**
* Extension-agnostic SSR module loading, for the duration of the TypeScript
* migration.
*
* Every script in here addresses modules by literal path — `ssrLoadModule(
* '/src/lib/skills/registry.js')` — about a hundred and seventy times across
* `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and
* `seed-fixture.mjs`. That is fine while every source file is JavaScript and
* fatal the moment one is not: renaming `registry.js` to `registry.ts` turns
* the check suite's very first load into a failure, and the suite is the only
* evidence the Owliver flow still behaves the way it did.
*
* Rewriting all those call sites would be a large, noisy, error-prone diff
* against the file that guards the migration — exactly the wrong thing to
* disturb. So the loader is wrapped once instead and the call sites keep the
* paths they already have, which stay readable as the names of real files.
*
* Resolution is by existence on disk, not by catching a failed load. A load
* that fails for a real reason — a syntax error, a bad import inside the module
* — must surface as itself; retrying under another extension would bury it
* behind a second, more confusing error about a file that was never there.
*
* The path as written is always tried first, so while a module is still
* JavaScript this changes nothing at all.
*
* This file is temporary. When `src` holds no `.js` or `.jsx` any more, the
* call sites can be renamed in one pass and this wrapper deleted.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
/**
* The candidate paths for one module specifier, in the order they are tried.
*
* Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a
* `.mjs` file, something under `/node_modules` — is returned untouched, because
* nothing in this migration renames it.
*
* `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js`
* file here contains JSX today — none of the 93 does, checked with a parser
* rather than a guess — but because one may be renamed that way: the only
* `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven
* sibling `nodes.jsx` files, and whoever converts that directory will
* reasonably want all eight to end in `.tsx`. The extra candidate costs one
* `existsSync` that answers no.
*/
export function candidatesFor(path) {
const specifier = String(path);
if (!/\.jsx?$/.test(specifier)) return [specifier];
const stem = specifier.replace(/\.jsx?$/, '');
return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])];
}
/**
* Wraps `server.ssrLoadModule` so it finds a module whichever of the four
* extensions it currently carries.
*
* Mutates and returns the server, so it reads as one line after `createServer`
* and every later call — including the dynamically-built paths, which is why
* this is done here rather than at the call sites — goes through it.
*
* `root` is where the leading-slash paths are rooted; it defaults to the
* process's working directory, which is what every caller here uses.
*/
export function withSourceResolution(server, root = process.cwd()) {
const load = server.ssrLoadModule.bind(server);
server.ssrLoadModule = (path, options) => {
for (const candidate of candidatesFor(path)) {
if (existsSync(join(root, candidate.replace(/^\//, '')))) {
return load(candidate, options);
}
}
/* Nothing on disk under any extension. Load the path as written so the
error names what the caller actually asked for. */
return load(path, options);
};
return server;
}
/**
* The same resolution, for source read as TEXT rather than loaded as a module.
*
* `skill-check.mjs` asserts structural facts by reading source files and
* matching against their contents — "the panel imports no local suggestion
* ranker", "no runtime path writes a definition". Those reads go through
* `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never
* sees them: it wraps the loader, and this is a second, entirely separate
* channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference,
* as an ENOENT in the middle of a suite that had been passing.
*
* Takes and returns a path RELATIVE to the project root, so the call site keeps
* its `join(ROOT, …)` exactly as it was:
*
* readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')
*
* Unresolvable paths come back unchanged, so the resulting error still names
* the file the caller asked for rather than a candidate it invented. That also
* keeps the deliberately absent ones honest: a check asserting a file is GONE
* gets the path it asked about, and `existsSync` still answers false.
*/
export function resolveSourcePath(path, root = process.cwd()) {
for (const candidate of candidatesFor(path)) {
if (existsSync(join(root, candidate))) return candidate;
}
return path;
}

View File

@@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
import AdminHiredHistory from '@/pages/admin/HiredHistory';
import AdminTalentPool from '@/pages/admin/TalentPool';
import AdminTalentProfile from '@/pages/admin/TalentProfile';
import AdminAnalytics from '@/pages/admin/Analytics';
import AdminActivity from '@/pages/admin/Activity';
import AdminProfile from '@/pages/admin/Profile';
@@ -107,6 +108,12 @@ const AuthenticatedApp = () => {
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
<Route path="hired" element={<AdminHiredHistory />} />
<Route path="talent-pool" element={<AdminTalentPool />} />
{/* The person, keyed by worker profile — distinct from
`candidates/:id`, which is one person FOR ONE POSITION. Two
routes because they answer different questions; one screen
would push the product back towards a candidate record per
vacancy, which is the thing `worker_profiles` exists to stop. */}
<Route path="talent/:id" element={<AdminTalentProfile />} />
<Route path="university" element={<University />} />
<Route path="university/:id" element={<CourseDetail />} />
<Route path="analytics" element={<AdminAnalytics />} />

View File

@@ -13,7 +13,7 @@
* and results that actually respond to the data instead of being canned.
*/
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
/** Simulated model latency so streaming/pending UI behaves as designed. */
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -21,39 +21,39 @@ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
/* ── Prompt field readers ──────────────────────────────────────────────── */
/** Reads a `Label: value` line out of a prompt. */
function field(prompt, label) {
function field(prompt: string, label: string): string {
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
return match ? match[1].trim() : '';
}
function numField(prompt, label) {
function numField(prompt: string, label: string): number {
const raw = field(prompt, label);
const match = raw.match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : 0;
}
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
function listField(prompt, label) {
function listField(prompt: string, label: string): string[] {
const raw = field(prompt, label);
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
function quotedBlock(prompt) {
function quotedBlock(prompt: string): string {
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
return match ? match[1].trim() : '';
}
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
const ENGLISH_RANK: Record<string, number> = { basic: 1, conversational: 2, fluent: 3, native: 4 };
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
const isSpanish = (prompt: string) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
/* ── 1. Candidate screening ────────────────────────────────────────────── */
function screenCandidate(prompt) {
function screenCandidate(prompt: string) {
// Weights are stated inline: "experience 25%, english 20%, …"
const weightOf = (name) => {
const weightOf = (name: string) => {
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
return match ? Number(match[1]) : 20;
};
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
/* ── 2. Job description generator ──────────────────────────────────────── */
const RESPONSIBILITY_LIBRARY = {
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
Bartender: [
'Set up and break down a full service bar',
'Pour to spec at event pace without sacrificing presentation',
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
'Complete close-out duties before leaving',
];
function generateJobDescription(prompt) {
function generateJobDescription(prompt: string) {
const title = field(prompt, 'Role Title');
const category = field(prompt, 'Role Category');
const minYears = numField(prompt, 'Min Experience');
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
];
function buildResume(prompt) {
function buildResume(prompt: string) {
const text = quotedBlock(prompt);
const lower = text.toLowerCase();
@@ -343,14 +343,14 @@ function buildResume(prompt) {
const INTERVIEW_QUESTIONS = {
en: [
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
(role: string) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
],
es: [
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
(role: string) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
],
};
function interviewQuestion(prompt) {
function interviewQuestion(prompt: string) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
const OWLIVER_QUESTIONS = {
en: [
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
(name: string) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
() => `That's everything I needed. Anything you want to add before I put this together?`,
],
es: [
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
(name: string) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
],
};
function owliverQuestion(prompt) {
function owliverQuestion(prompt: string) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/you are on question (\d+)/);
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
/** Scores a transcript on how substantive the candidate's own answers are. */
function readTranscript(prompt) {
function readTranscript(prompt: string) {
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
const answers = transcript
.split('\n')
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
return { answers, words, avgWords, specifics };
}
function evaluateInterview(prompt) {
function evaluateInterview(prompt: string) {
const { answers, avgWords, specifics } = readTranscript(prompt);
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
const fast = fastMatch ? Number(fastMatch[1]) : 0;
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
/* ── 6. Talent matching ────────────────────────────────────────────────── */
function matchTalent(prompt) {
function matchTalent(prompt: string) {
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
let pool = [];
/* Whatever the caller embedded in the prompt's talent-pool block, as
`JSON.parse` hands it back. Not `WorkerProfile[]`: this is a simulated
engine reading a blob out of free text, and the rows carry computed
fields like `match_score` that no entity declares. Typing it as a
record would assert a shape nothing validates. */
let pool: any[] = [];
try {
pool = block ? JSON.parse(block[1]) : [];
} catch {
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
: Math.min(20, (p.experience_years || 0) * 3);
const certs = (p.certifications || []).map((c) => c.toLowerCase());
const certs = (p.certifications || []).map((c: string) => c.toLowerCase());
const certFit = requiredCerts.length
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
? (requiredCerts.filter((r) => certs.some((c: string) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
: (certs.length ? 12 : 5);
const scoreFit = ((p.krow_score || 0) / 100) * 20;
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
/** Job titles a worker might name for themselves, most specific first. */
const ROLE_TITLES = [
/* Explicit tuple, not `as const`: the entries are read as a pair — `.find(([re]) => re.test(…))`
then `role[1]` — and without it the element widens to `string | RegExp`, which has no `.test`.
`as const` would also work and would additionally make the array and its entries `readonly`,
a change to the type this module publishes for no benefit it uses. */
const ROLE_TITLES: [RegExp, string][] = [
[/banquet captain|captain/, 'Banquet Captain'],
[/executive chef/, 'Executive Chef'],
[/sous chef/, 'Sous Chef'],
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
[/manager/, 'Manager'],
];
const AVAILABILITY_TOKENS = [
/** Same pairing, same reason — matched by regex, collected by label. */
const AVAILABILITY_TOKENS: [RegExp, string][] = [
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
[/evening|night|noche|tarde/i, 'Evenings'],
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
[/on.?call|flexible/i, 'On-Call'],
];
function buildCareerDna(prompt) {
function buildCareerDna(prompt: string) {
const transcript = quotedBlock(prompt);
const lower = transcript.toLowerCase();
const { answers, avgWords, specifics } = (() => {
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
function evaluateChallenge(prompt) {
function evaluateChallenge(prompt: string) {
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
const criteria = criteriaMatch
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
@@ -743,7 +753,7 @@ function evaluateChallenge(prompt) {
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
// Spread the overall score across the rubric so criteria are not identical.
const rubric = {};
const rubric: Record<string, number> = {};
criteria.forEach((criterion, i) => {
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
});
@@ -768,7 +778,15 @@ function evaluateChallenge(prompt) {
* Recognizes each workflow by the phrase its prompt opens with. Order matters
* only in that every branch is mutually exclusive by design.
*/
const ROUTES = [
/* `[pattern, handler, latencyMs]`. Spelled out because all three positions are used for what
they are: `.test()` on the first, a call on the second, and `think(ms)` on the third. Left to
inference the element becomes the union of all three and none of those work.
The handler returns `any` deliberately. The ten handlers below return ten different shapes,
which is the point of a router — and `krowAi.js` relies on it: one caller does
`typeof res === 'string' ? res : res.text || String(res)`, which a precise union would
reject on every branch that has no `.text`. */
const ROUTES: [RegExp, (prompt: string) => any, number][] = [
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
[/Extract and infer a structured resume/m, buildResume, 1200],
@@ -782,8 +800,32 @@ const ROUTES = [
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
];
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
/**
* What a caller may send. Every field optional, because the destructuring default `= {}`
* says a call with no argument at all is legal, and it is one this module answers.
*
* `model` and `file_urls` are not read here — the local engine routes on the prompt and
* ignores both — but callers send them, and a parameter type that omitted them would reject
* eight call sites in `krowAi.js` and one in `provingGround.ts` for passing a field the real
* integration accepts. Declaring them describes the call this function is a drop-in for.
*/
export interface InvokeLLMOptions {
prompt?: string;
response_json_schema?: any;
model?: string;
file_urls?: string[];
}
/**
* Drop-in replacement for `integrations.Core.InvokeLLM`.
*
* The return stays `any` on purpose. See the note on `ROUTES`: the shape depends on which
* handler matched, and narrowing it here would break callers that branch on the result at
* run time rather than by type.
*/
export async function invokeLLM(
{ prompt = '', response_json_schema: schema }: InvokeLLMOptions = {},
): Promise<any> {
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
if (!route) {
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
}
/** Drop-in replacement for `integrations.Core.UploadFile`. */
export async function uploadFile({ file } = {}) {
export async function uploadFile({ file }: { file?: File } = {}) {
await think(500);
if (!file) return { file_url: '' };
// A blob URL keeps uploaded media viewable for the rest of the session

View File

@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
* Local rather than UTC because a shift belongs to the day it was worked in the
* place it was worked, and `periodRange` windows on local day boundaries too.
*/
function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) {
function daysAgo(n: number, hour = 9, minute = 0, anchor = new Date()) {
const d = new Date(anchor.getTime());
d.setDate(d.getDate() - n);
d.setHours(hour, minute, 0, 0);
return d;
}
const round1 = (n) => Math.round(n * 10) / 10;
const round2 = (n) => Math.round(n * 100) / 100;
const round1 = (n: number) => Math.round(n * 10) / 10;
const round2 = (n: number) => Math.round(n * 100) / 100;
const HOUR = 60 * 60 * 1000;
/**
@@ -105,10 +105,21 @@ const ROSTER = [
* Returning a plain record keeps every rule visible in one place instead of
* spread across the generator.
*/
const BEHAVIOUR = {
/**
* One worker's shift outcome, as the rules below return it.
*
* `i` is the index of the shift counting back from the anchor, `weekday` the
* day it falls on. Rules that ignore the weekday take one argument, which is
* why the second is optional here.
*/
type ShiftBehaviour = (i: number, weekday?: number) => {
status: string; minutesLate: number; overtime: number; notes: string;
};
const BEHAVIOUR: Record<string, ShiftBehaviour> = {
/* Reliable. One late arrival every couple of months, and overtime only on
the nights events actually overrun. */
staff_marco: (i, weekday) => ({
staff_marco: (i: number, weekday?: number) => ({
status: i === 14 ? 'late' : 'present',
minutesLate: i === 14 ? 9 : 0,
/* Friday and Saturday events overrun; midweek ones do not. */
@@ -124,7 +135,7 @@ const BEHAVIOUR = {
* the six weeks before it — a change big enough to be worth surfacing and
* specific enough to act on.
*/
staff_marcus: (i) => {
staff_marcus: (i: number) => {
if (i === 2 || i === 7) {
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
}
@@ -145,7 +156,7 @@ const BEHAVIOUR = {
* covers, on the three busiest shifts of each week. A steady climb rather
* than a spike, which is exactly the shape that hides in a table of totals.
*/
staff_antoine: (i, weekday) => {
staff_antoine: (i: number, weekday?: number) => {
const weekIndex = Math.floor(i / 5);
const busy = weekday === 4 || weekday === 5 || weekday === 6;
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
@@ -154,8 +165,8 @@ const BEHAVIOUR = {
};
/** Every shift date for one worker, most recent first. */
function shiftOffsets(weekdays, anchor) {
const offsets = [];
function shiftOffsets(weekdays: number[], anchor: Date) {
const offsets: number[] = [];
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
const day = daysAgo(offset, 9, 0, anchor).getDay();
if (weekdays.includes(day)) offsets.push(offset);
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
return offsets;
}
const pad = (n) => String(n).padStart(2, '0');
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const pad = (n: number) => String(n).padStart(2, '0');
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
/**
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
* the point of this collection; a caller passes one only to hold the window
* still — see buildShiftsAt.
*/
function buildShifts(anchor = new Date()) {
const records = [];
function buildShifts(anchor: Date = new Date()) {
const records: any[] = [];
for (const worker of ROSTER) {
const offsets = shiftOffsets(worker.weekdays, anchor);
@@ -251,6 +262,6 @@ export const SHIFT_RECORDS = buildShifts();
* shifts built around one day and bucketed around another describe two
* different windows.
*/
export function buildShiftsAt(anchor) {
export function buildShiftsAt(anchor: Date) {
return buildShifts(anchor);
}

View File

@@ -22,6 +22,7 @@
*/
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import type { PreferencesUpdateResult } from '@/types/user';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER } from './demoUser';
@@ -143,14 +144,18 @@ let authenticated = false;
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
* app opens on the login page and this request is how it finds that out.
*/
let hydration = request('GET', '/me')
/* `Promise<any> | null`, because the runtime already assigns both: the shared
first request, then `null` once it has been consumed or superseded by a
login. Inference alone would fix the type at `Promise<any>` and reject the
two `hydration = null` assignments that make the sharing work. */
let hydration: Promise<any> | null = request('GET', '/me')
.then((user) => {
currentUser = user;
authenticated = true;
cacheUser();
return user;
})
.catch(() => {
.catch((): null => {
authenticated = false;
return null;
});
@@ -204,7 +209,11 @@ const auth = {
* by design: telling the two apart would say whether an address is
* registered. The caller shows that message as-is.
*/
async login({ email, password, rememberMe = false }) {
async login({ email, password, rememberMe = false }: {
email: string;
password: string;
rememberMe?: boolean;
}) {
const user = await request('POST', '/auth/login', {
body: { email, password, remember_me: Boolean(rememberMe) },
});
@@ -215,7 +224,7 @@ const auth = {
return { ...user };
},
async updateMe(patch) {
async updateMe(patch: any) {
const user = await request('PATCH', '/me', { body: patch });
currentUser = user;
cacheUser();
@@ -247,7 +256,7 @@ const auth = {
* question the shape exists to answer is now answered by whether this
* function resolved at all.
*/
async updatePreferences(patch) {
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
const preferences = await request('PATCH', '/me/preferences', { body: patch });
currentUser = { ...currentUser, preferences };
cacheUser();
@@ -274,7 +283,7 @@ const auth = {
* reach the API must still leave the browser signed out locally, and the
* cookie it keeps will be refused by every request it is sent on.
*/
async logout(redirectTo = '/admin/login') {
async logout(redirectTo: string = '/admin/login') {
try {
await request('POST', '/auth/logout');
} catch {
@@ -320,7 +329,7 @@ const workflows = {
* across from the application by the server, because it is already the truth
* about this person and retyping it here is how the two records drift apart.
*/
async hire(applicationId, body = {}) {
async hire(applicationId: string, body: any = {}) {
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
body,
});
@@ -336,7 +345,7 @@ const workflows = {
* inside the same transaction; a worker taken straight from the talent pool
* with neither is placed without one rather than given an invented one.
*/
async assign(jobPostingId, workers = []) {
async assign(jobPostingId: string, workers: any[] = []) {
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
body: { workers },
});
@@ -368,7 +377,7 @@ const workflows = {
*/
const owliver = {
/** @param {any} request */
async suggestions({ page, query = '' } = {}) {
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
if (!page) return [];
const typed = String(query || '').trim();
const data = await request('GET', '/owliver/suggestions', {
@@ -388,7 +397,7 @@ const integrations = {
};
const analytics = {
track({ eventName, properties } = {}) {
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
if (import.meta.env.DEV) {
console.debug('[krow-demo] analytics', eventName, properties || {});
}

View File

@@ -1,3 +1,5 @@
import type { User } from '@/types/user';
/**
* The shape of a signed-in user, before the server has answered.
*
@@ -22,7 +24,7 @@
* the server has never stored still has to resolve to something rather than to
* `undefined`.
*/
export const DEMO_USER = {
export const DEMO_USER: User = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
@@ -37,3 +39,29 @@ export const DEMO_USER = {
emailDigest: true,
},
};
/**
* The employer account, and the reason there is a second one at all.
*
* `policy.go` has always had three roles — admin, employer, talent — and the
* fixture had one administrator, so two thirds of the authorization table was
* never exercised and there was nobody to sign in as to reach the employer
* console. This account is what makes that path reachable.
*
* Unlike `DEMO_USER` this is not a default shape for the first render: nothing
* in the running app reads it. It exists so that `seedData.User` names both
* accounts, which is what the backend seeder writes. See `seed.ts`.
*/
export const EMPLOYER_USER: User = {
id: 'user_employer',
full_name: 'Jordan Blake',
email: 'employer@krow.app',
role: 'employer',
account_type: 'employer',
created_date: '2026-06-01T09:00:00.000Z',
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};

View File

@@ -27,6 +27,9 @@
* did not answer.
*/
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
import type { EntityName, EntityResourcePath } from '@/types/entities';
/**
* Where the API lives.
*
@@ -93,7 +96,13 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
* `user-activity` is three special cases wearing a trench coat, and a wrong
* guess here is a 404 at runtime instead of a mistake anyone can see.
*/
const RESOURCE_PATHS = {
/* `Record<EntityName, EntityResourcePath>` is doing two checks at once, and
both were previously unavailable. Every one of the eighteen names must be
present as a key — so this map and `ENTITY_NAMES` in `base44Client.js` can no
longer drift apart silently — and every value must be one of the declared
paths, so a typo in a resource segment is a compile error rather than a 404
at run time. The object itself is unchanged, key for key. */
const RESOURCE_PATHS: Record<EntityName, EntityResourcePath> = {
JobPosting: 'job-postings',
JobApplication: 'job-applications',
AIInterview: 'ai-interviews',
@@ -128,7 +137,7 @@ const RESOURCE_PATHS = {
* `got === undefined` against real values and matched nothing, and sending the
* string "undefined" would be a filter on a value no column holds.
*/
function queryString(params) {
function queryString(params: Record<string, unknown>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
@@ -153,9 +162,14 @@ function queryString(params) {
* ride along as properties — new information a local store never had, and
* additive, so nothing that only reads `.message` notices.
*/
function apiError(status, payload) {
function apiError(status: number, payload: ApiErrorResponse | null): KrowApiError {
const body = payload?.error;
const error = new Error(body?.message || `Request failed with status ${status}`);
/* Cast, not `class KrowApiError extends Error`. The properties below are
assigned onto a plain Error exactly as they always were, so the prototype
chain, `instanceof Error`, and the `error.name === 'KrowApiError'` test
callers use all behave identically. A subclass would change all three, and
the emitted JavaScript with it. */
const error = new Error(body?.message || `Request failed with status ${status}`) as KrowApiError;
error.name = 'KrowApiError';
error.status = status;
error.code = body?.code || 'internal';
@@ -171,10 +185,14 @@ function apiError(status, payload) {
* reads it (§4.2), and surfacing it would mean changing what the six methods
* return, which is the one thing Phase 2D must not do.
*/
async function request(method, path, { query, body } = {}) {
async function request(
method: string,
path: string,
{ query, body }: { query?: Record<string, unknown>; body?: unknown } = {},
) {
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
let response;
let response: Response;
try {
response = await fetch(url, {
method,
@@ -199,7 +217,7 @@ async function request(method, path, { query, body } = {}) {
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
`(${method} ${path})`
);
) as KrowApiError;
error.name = 'KrowApiError';
error.status = 0;
error.code = 'unreachable';
@@ -221,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
if (!response.ok) throw apiError(response.status, payload);
if (payload === null) {
const error = new Error(`${method} ${path} returned no JSON body`);
const error = new Error(`${method} ${path} returned no JSON body`) as KrowApiError;
error.name = 'KrowApiError';
error.status = response.status;
error.code = 'internal';
@@ -242,7 +260,7 @@ export { request };
* before the first login and after a session expires — so callers need to tell
* it apart from a real failure rather than treating both as "something broke".
*/
export function isUnauthenticated(error) {
export function isUnauthenticated(error: any) {
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
}
@@ -256,13 +274,33 @@ export function isUnauthenticated(error) {
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
* them rather than passing their own.
*/
export function createEntity(name) {
const path = RESOURCE_PATHS[name];
export function createEntity(name: string) {
/* `name` stays `string`, and the cast below is deliberate. Typing the
parameter as `EntityName` would be tighter and would immediately break the
one caller — `base44Client.js` builds the entity map by mapping over a
plain string array — which is a file this phase does not touch.
The return type is left inferred, and that is a decision rather than an
omission. Annotating it `EntityClient<any>` was tried and reverted: it
makes `list()` return `Promise<any[]>` where inference gives
`Promise<any>`, and two call sites in `src/lib/agents/agentStore.js` do
const rows = qc.getQueryData(KEY) || (await …list('-created_date', 200));
React Query types `getQueryData` as `unknown`, and `unknown || any`
collapses to `any` while `unknown || any[]` stays `unknown` — so `.find`
on the next line stopped compiling. Two new errors in a file this phase is
not migrating, for no gain: the surface is structurally identical either
way, and the emitted JavaScript is byte-for-byte the same.
`EntityClientFor<K>` and the generated record types are ready for the phase
that migrates those consumers. Nothing here has to change then. */
const path = RESOURCE_PATHS[name as EntityName];
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
const base = `/${path}`;
return {
entityName: name,
entityName: name as EntityName,
/**
* `sort` is always sent, even when empty. `?sort=` is not the same as
@@ -281,19 +319,19 @@ export function createEntity(name) {
return request('GET', base, { query: { ...query, sort, limit } });
},
async get(id) {
async get(id: string) {
return request('GET', `${base}/${encodeURIComponent(id)}`);
},
async create(data) {
async create(data: any) {
return request('POST', base, { body: data });
},
async update(id, data) {
async update(id: string, data: any) {
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
},
async delete(id) {
async delete(id: string) {
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
},
@@ -305,8 +343,8 @@ export function createEntity(name) {
* identical — the first rejection stops the run and the records before it
* are already written.
*/
async bulkCreate(records = []) {
const created = [];
async bulkCreate(records: any[] = []) {
const created: any[] = [];
for (const record of records) created.push(await this.create(record));
return created;
},

View File

@@ -9,9 +9,9 @@
*/
import { SHIFT_RECORDS } from './attendanceSeed';
import { DEMO_USER } from './demoUser';
import { DEMO_USER, EMPLOYER_USER } from './demoUser';
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
const iso = (date: string) => new Date(`${date}T09:00:00.000Z`).toISOString();
/* ── Reference data ────────────────────────────────────────────────────── */
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
/* ── Applications ──────────────────────────────────────────────────────── */
const breakdown = (over) => ({
const breakdown = (over?: Record<string, number>) => ({
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
@@ -356,13 +356,13 @@ const breakdown = (over) => ({
});
/** An applicant who has not been screened yet — every AI field stays empty. */
const unscreened = (over) => ({
const unscreened = (over?: Record<string, any>) => ({
status: 'applied',
ai_score: 0,
certifications: [],
availability: [],
skills: [],
companies_worked: [],
certifications: [] as string[],
availability: [] as string[],
skills: [] as string[],
companies_worked: [] as string[],
client_rating: 0,
created_date: iso('2026-08-04'),
updated_date: iso('2026-08-04'),
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
certifications: ['ServSafe', 'CPR / First Aid'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.9,
professional_summary:
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
certifications: ['Guard Card', 'CPR / First Aid'],
availability: ['Weekends', 'Evenings', 'Overnight'],
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.8,
professional_summary:
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
@@ -514,7 +514,7 @@ const JOB_APPLICATIONS = [
certifications: ['Guard Card'],
availability: ['Weekends', 'Evenings'],
skills: ['Access control', 'Incident reporting'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Venue security officer transitioning into private event work.',
job_posting_id: 'job_security',
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
availability: ['Weekdays', 'Evenings'],
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.7,
professional_summary:
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
@@ -605,7 +605,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekends', 'Evenings'],
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.6,
professional_summary:
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
@@ -639,7 +639,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekends'],
skills: ['Banquet service', 'Tray service', 'Table resets'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.4,
professional_summary: 'Banquet server with three years of seated dinner service.',
selfie_url: 'https://i.pravatar.cc/240?img=44',
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekends', 'Evenings'],
skills: ['Table service'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Restaurant server moving into event work.',
job_posting_id: 'job_server_fine',
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'],
availability: ['Weekends', 'Evenings'],
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.2,
professional_summary: 'Event bartender with three years of high-volume weekend service.',
job_posting_id: 'job_bartender',
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'],
availability: ['Evenings'],
skills: ['Bar setup', 'Wine service'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.0,
professional_summary: 'Barback stepping up to bartending at private events.',
job_posting_id: 'job_bartender',
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekends'],
skills: [],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Looking for a first hospitality role.',
job_posting_id: 'job_bartender',
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
certifications: ['Forklift Operator'],
availability: ['Weekdays', 'Early mornings'],
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 3.9,
professional_summary: 'Warehouse picker with forklift certification.',
job_posting_id: 'job_picker',
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekdays'],
skills: ['Order picking'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
job_posting_id: 'job_picker',
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends'],
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
job_posting_id: 'job_banquet',
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Hotel banquet captain with six years directing event floors.',
job_posting_id: 'job_banquet',
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
/* ── AI interviews ─────────────────────────────────────────────────────── */
const transcript = (candidate, role) => [
const transcript = (candidate: string, role: string) => [
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
problem_solving: 69, personality: 77, communication_style: 76,
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-05'),
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-05'),
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-03'),
@@ -1015,7 +1015,7 @@ const STAFF = [
ai_score: 92,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
{
@@ -1031,7 +1031,7 @@ const STAFF = [
ai_score: 94,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
{
@@ -1047,7 +1047,7 @@ const STAFF = [
ai_score: 97,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
];
@@ -1085,7 +1085,7 @@ const COURSES = [
{ criterion: 'Protects the table experience', weight: 30 },
],
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [
{
question: 'A guest flags an allergy mid-service. What happens first?',
@@ -1127,7 +1127,7 @@ const COURSES = [
{ criterion: 'Escalates appropriately', weight: 30 },
],
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [
{
question: 'A guest shows clear signs of intoxication. What do you do?',
@@ -1151,7 +1151,7 @@ const COURSES = [
proof_skill: 'Hazard Identification',
skill_id: 'food_safety',
target_level: 'beginner',
required_level: null,
required_level: null as string | null,
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
challenge: {
@@ -1164,7 +1164,7 @@ const COURSES = [
{ criterion: 'Notes obstruction hazards', weight: 20 },
],
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [
{
question: 'What is the danger zone for cold-held food?',
@@ -1201,7 +1201,7 @@ const COURSES = [
],
},
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
quiz: [],
quiz: [] as unknown[],
pass_score: 80,
status: 'active',
created_date: iso('2026-06-16'),
@@ -1222,7 +1222,17 @@ const COURSES = [
* what Owliver checks. `required_level` is the rung below, which is what makes
* the ladder a ladder rather than a menu.
*/
const MODULE_TABLE = [
/**
* One training module per row: skill, rung, title, minutes, challenge type,
* description, and the criteria it is marked against.
*
* Written out as a tuple because a bare array of mixed literals infers as an
* array of the UNION of its column types, so destructuring a row below gives
* every field `string | number | string[]` — and `SKILL_CATEGORY[skill_id]`
* then refuses a key that might be an array. The tuple says which column is
* which, which is what the reader already assumes.
*/
const MODULE_TABLE: [string, string, string, number, string, string, string[]][] = [
/* Server — the full four-rung ladder. */
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
@@ -1283,7 +1293,7 @@ const MODULE_TABLE = [
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
];
const SKILL_CATEGORY = {
const SKILL_CATEGORY: Record<string, string> = {
server: 'Service',
bartending: 'Bar',
leadership: 'Leadership',
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
/** Level → the rung below it, which is what a module requires before it opens. */
const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
const requiredLevelFor = (level: string) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
/** Difficulty is the existing field; level drives it so the two never disagree. */
const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
const DIFFICULTY_FOR: Record<string, string> = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
const XP_FOR: Record<string, number> = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
const PROMPT_FOR = {
const PROMPT_FOR: Record<string, (title: string) => string> = {
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
};
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
const TRAINING_MODULES = MODULE_TABLE.map(
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
@@ -1331,8 +1341,8 @@ const TRAINING_MODULES = MODULE_TABLE.map(
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
quiz: [],
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [] as unknown[],
pass_score: 70,
status: 'active',
created_date: iso('2026-06-20'),
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
/** A module id from its skill and title, so profiles read like the ladder does. */
const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
const mod = (skill: string, title: string) => `mod_${skill}_${slug(title)}`;
/**
* Completed training on a worker profile.
@@ -1352,7 +1362,7 @@ const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
* Titles are looked up rather than repeated, so a renamed module cannot leave a
* profile claiming a course that no longer goes by that name.
*/
const completions = (ids, date, score = 88) =>
const completions = (ids: string[], date: string, score = 88) =>
ids.map((id) => ({
course_id: id,
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
earned_badges: [
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
],
capabilities: [],
capabilities: [] as unknown[],
shifts_completed: 6,
attendance_score: 92,
performance_score: 0,
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
'2026-08-02',
89
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 38,
attendance_score: 96,
performance_score: 88,
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
'2026-07-30',
85
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 21,
attendance_score: 93,
performance_score: 81,
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
'2026-08-05',
76
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 4,
attendance_score: 100,
performance_score: 0,
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1980,8 +1990,12 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
* candidate, interview and shift record below — into the production bundle.
* The fixtures here are for the test scripts, and they still read
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
*
* `EMPLOYER_USER` travels the same way. It is seeded, never rendered: the
* backend writes both accounts so the employer console has somebody to sign
* in as, and nothing in the running app imports it.
*/
export { DEMO_USER } from './demoUser';
export { DEMO_USER, EMPLOYER_USER } from './demoUser';
/* ── Assignments ───────────────────────────────────────────────────────────
Who is on which position, and until when — the record that turns a hire into
@@ -2014,7 +2028,7 @@ const ASSIGNMENTS = [
worker_email: 'marco.rivera@email.com',
worker_name: 'Marco Rivera',
starts_at: iso('2026-07-01'),
ends_at: null,
ends_at: null as string | null,
status: 'active',
source: 'seed',
match_score: 92,
@@ -2041,5 +2055,5 @@ export const seedData = {
Assignment: ASSIGNMENTS,
ShiftRecord: SHIFT_RECORDS,
Evidence: EVIDENCE,
User: [DEMO_USER],
User: [DEMO_USER, EMPLOYER_USER],
};

View File

@@ -21,7 +21,12 @@ const DefaultFallback = () => (
* The attempted path travels in location state so signing in returns the
* visitor to where they were going.
*/
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
interface ProtectedRouteProps {
fallback?: any;
unauthenticatedElement?: any;
}
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }: ProtectedRouteProps) {
const location = useLocation();
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
const signedOut = unauthenticatedElement ?? (

View File

@@ -17,8 +17,17 @@ import { cn } from '@/lib/utils';
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
/** @param {any} props */
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
interface AdminPageProps {
title?: string;
subtitle?: string;
meta?: any;
actions?: any;
tabs?: any;
children: React.ReactNode;
className?: string;
}
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
return (
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
@@ -57,8 +66,15 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
* Sentence-case and small rather than a heavy all-caps overline: on a page with
* four or five sections, loud labels compete with the data they introduce.
*/
/** @param {any} props */
export function SectionTitle({ id, title, meta, action, className }) {
interface SectionTitleProps {
id?: any;
title?: string;
meta?: any;
action?: any;
className?: string;
}
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
{/* The count wraps under the section name rather than competing with it
@@ -91,8 +107,15 @@ export function SectionTitle({ id, title, meta, action, className }) {
* primary action is pushed to the end, so every management page has the same
* control geometry.
*/
/** @param {any} props */
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
interface ToolbarProps {
search?: any;
filters?: any;
actions?: any;
meta?: any;
className?: string;
}
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">

View File

@@ -1,3 +1,4 @@
import type { ComponentType } from 'react';
import {
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
} from 'lucide-react';
@@ -19,8 +20,10 @@ import { cn } from '@/lib/utils';
*/
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
/** @type {[RegExp, React.ComponentType<any>][]} */
const GLYPHS = [
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
both positions, so `pattern.test` and `<Icon />` both stop working — the same
shape as `aiEngine`'s route table. */
const GLYPHS: [RegExp, ComponentType<any>][] = [
[/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine],
[/server|wait|host|banquet|concierge/i, ConciergeBell],
@@ -42,8 +45,15 @@ const SIZES = {
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
};
/** @param {any} props */
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
interface RoleGlyphProps {
category?: any;
title?: string;
size?: any;
aiGenerated?: any;
className?: string;
}
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }: RoleGlyphProps) {
const Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md;

View File

@@ -44,7 +44,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
* holds a ~380px track on the right of every Admin page, so below `xl` the
* remaining width belongs to the editor and the rail becomes a strip above it.
*/
export function Workspace({ rail, children, className }) {
export function Workspace({ rail, children, className }: any) {
return (
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
@@ -64,8 +64,7 @@ export function Workspace({ rail, children, className }) {
* height without measuring it in JavaScript. Content stays mounted, which is
* what keeps a section addressable by the rail while it is closed.
*/
/** @param {any} props */
export function Collapse({ open, children, className }) {
export function Collapse({ open, children, className }: any) {
return (
<div
className={cn(
@@ -100,8 +99,7 @@ export function Collapse({ open, children, className }) {
* Provides clear section navigation and leaf anchor shortcuts across the
* configuration workspace.
*/
/** @param {any} props */
export function Rail({ sections, activeId, onSelect }) {
export function Rail({ sections, activeId, onSelect }: any) {
const activeSection = sections.find((s) => s.id === activeId);
return (
@@ -181,11 +179,10 @@ export function Rail({ sections, activeId, onSelect }) {
/**
* One section of the document.
*/
/** @param {any} props */
export function DocSection({
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
children,
}) {
}: any) {
return (
<section
id={id}
@@ -257,8 +254,7 @@ export function DocSection({
}
/** A labelled field in the document body. */
/** @param {any} props */
export function DocField({ id, label, hint, children, className }) {
export function DocField({ id, label, hint, children, className }: any) {
const reactId = React.useId();
const controlId = id ? `${id}-control` : reactId;
@@ -270,7 +266,7 @@ export function DocField({ id, label, hint, children, className }) {
>
{label}
</label>
{React.isValidElement(children)
{React.isValidElement<any>(children)
? React.cloneElement(children, { id: children.props.id || controlId })
: children}
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
@@ -284,8 +280,7 @@ export function DocField({ id, label, hint, children, className }) {
* One notch quieter than a section title and one louder than a row, which is
* exactly the level of the thing it names.
*/
/** @param {any} props */
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
return (
<div
id={id}
@@ -310,8 +305,7 @@ export function GroupHead({ id, icon: Icon, title, count, action, className }) {
* competing with the section that holds them; as rows they read as a list,
* which is what they are.
*/
/** @param {any} props */
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
return (
<li
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
@@ -352,10 +346,9 @@ export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLa
* a switch in a button makes one press mean two things. A setting that needs
* space gets a chevron and opens underneath.
*/
/** @param {any} props */
export function SettingRow({
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
}) {
}: any) {
const expandable = Boolean(children);
const body = (
@@ -412,8 +405,7 @@ export function SettingRow({
}
/** A page an agent covers, or one it could. */
/** @param {any} props */
export function ScopeChip({ active, children, ...props }) {
export function ScopeChip({ active, children, ...props }: any) {
return (
<button
type="button"
@@ -443,8 +435,7 @@ export function ScopeChip({ active, children, ...props }) {
/* ── Identity ────────────────────────────────────────────────────────────── */
/** The icons an agent may wear, as a picker. */
/** @param {any} props */
export function IconPicker({ icons, value, onChange }) {
export function IconPicker({ icons, value, onChange }: any) {
return (
<div className="grid grid-cols-5 gap-1.5">
{icons.map((icon) => {
@@ -486,8 +477,7 @@ export function IconPicker({ icons, value, onChange }) {
* same three things the switcher renders — icon, name, description — and
* nothing it does not.
*/
/** @param {any} props */
export function AgentPreview({ name, description, icon }) {
export function AgentPreview({ name, description, icon }: any) {
const Icon = agentIconFor(icon);
return (

View File

@@ -94,7 +94,6 @@ function useActiveSection(ids) {
return [active, setActive];
}
/** @param {any} props */
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
/* Attaching and detaching a skill goes through the caller's existing write
@@ -107,7 +106,7 @@ export function AgentConfigure({
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [],
}) {
}: any) {
/* Sections are open by default: this is a document, and one that greets its
author with four closed headers hides the thing they came to write. Closing
is for focus, not for the initial reading. */

View File

@@ -22,8 +22,7 @@ import { cn } from '@/lib/utils';
* Real-time usage analytics computed directly from the local telemetry log.
*/
/** @param {any} props */
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
return (
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
<div className="flex items-center justify-between gap-2">
@@ -45,8 +44,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
);
}
/** @param {any} props */
export function AgentInsightsPanel({ agentId, agentName }) {
export function AgentInsightsPanel({ agentId, agentName }: any) {
const preferences = usePreferences();
const [records] = React.useState(() => readHistory());

View File

@@ -46,8 +46,7 @@ const CLASSIFICATION_COPY = {
combined: 'Requires both structured records and knowledge retrieval.',
};
/** @param {any} props */
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId

View File

@@ -43,7 +43,6 @@ import { SkillDetails } from './SkillDetails';
const OWLIVER = 'owliver';
const BOARD = 'board';
/** @param {any} props */
export function AgentSkillWorkspace({
fields,
customSkills = [],
@@ -55,7 +54,7 @@ export function AgentSkillWorkspace({
busy = false,
pendingId = null,
dirty = false,
}) {
}: any) {
const [kind, setKind] = React.useState(OWLIVER);
const [query, setQuery] = React.useState('');
const [boardQuery, setBoardQuery] = React.useState('');

View File

@@ -174,7 +174,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
);
}
/** @param {any} props */
export function AgentTree({
agentName,
agentIcon,
@@ -191,7 +190,7 @@ export function AgentTree({
onToggleBranch,
busy = false,
className,
}) {
}: any) {
const Glyph = agentIconFor(agentIcon);
return (

View File

@@ -159,11 +159,10 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
);
}
/** @param {any} props */
export function BoardSkillList({
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
attachedIds = null, onAttach = null,
}) {
}: any) {
const searchId = React.useId();
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);

View File

@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
*/
/** @type {any} */
export const SkillCard = React.memo(/** @param {any} props */ ({
export const SkillCard = React.memo(({
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
}) => {
}: any) => {
const Glyph = glyphForGroup(entry.group);
const samplePrompt = entry.suggestions?.[0]?.label;

View File

@@ -23,8 +23,7 @@ import { SkillCard } from './SkillCard';
* has, at every width, with no media query to get wrong.
*/
/** @param {any} props */
function CategoryFilter({ groups, value, onChange, total }) {
function CategoryFilter({ groups, value, onChange, total }: any) {
return (
<div
role="group"
@@ -60,7 +59,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
);
}
/** @param {any} props */
export function SkillCatalog({
entries,
groups,
@@ -76,7 +74,7 @@ export function SkillCatalog({
testableIds,
busy = false,
pendingId = null,
}) {
}: any) {
const searchId = React.useId();
return (

View File

@@ -82,11 +82,10 @@ function TestState({ test, entryId }) {
* button needs the same `surface` this renders, and two copies of that would be
* two answers to "what am I about to enable".
*/
/** @param {any} props */
export function CapabilityBrief({
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
canTest, target, onTest, test, busy = false, showTest = true,
}) {
}: any) {
const { shapes, described } = entry.capabilities;
return (
@@ -252,11 +251,10 @@ export function CapabilityBrief({
);
}
/** @param {any} props */
export function SkillDetails({
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
fields = null, onRunTest = null, test = null,
}) {
}: any) {
/**
* The surface this would run on.
*

View File

@@ -45,8 +45,7 @@ const OPTIONS = [
export const supportedSurfaces = (type) =>
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
/** @param {any} props */
export function SurfaceSelect({ type, value, onChange, name }) {
export function SurfaceSelect({ type, value, onChange, name }: any) {
return (
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
{OPTIONS.map((option) => {

View File

@@ -57,8 +57,7 @@ function ConstrainedTag() {
);
}
/** @param {any} props */
export function AgentBadge({ page }) {
export function AgentBadge({ page }: any) {
const { agent, covers } = useActiveAgent();
return (

View File

@@ -86,9 +86,8 @@ function FeedbackControls({ feedback, onFeedback }) {
}
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
feedback = null, onFeedback = null }) => {
feedback = null, onFeedback = null }: any) => {
if (role === 'user') {
return (
<div className="flex justify-end">

View File

@@ -268,7 +268,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
const setWidthState: any = React.useCallback((next) => {
const clamped = clampWidth(next);
setWidth(clamped);
clearTimeout(setWidthState.timer);

View File

@@ -15,8 +15,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
* It self-hides on pages with no assistant, so a page can render it
* unconditionally and never needs to know the placement rules.
*/
/** @param {any} props */
export function AssistantTrigger({ className = '', size = 'default' }) {
export function AssistantTrigger({ className = '', size = 'default' }: any) {
const { supported, isOpen, toggle, context } = useAssistantPanel();
if (!supported) return null;

View File

@@ -1080,9 +1080,6 @@ export default function KrowAssistant({
>
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);

View File

@@ -23,14 +23,13 @@ import { cn } from '@/lib/utils';
*
* Arrow keys move between chips, so the whole set is one tab stop.
*/
/** @param {any} props */
export function PromptChips({
prompts = [], onSelect, max = 0, align = 'center', className = '',
/* Taken out of the tab order while the row is collapsed but still mounted:
a chip inside a zero-height container is invisible, and a Tab that lands on
something invisible is a keyboard user losing their place. */
focusable = true,
}) {
}: any) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;

View File

@@ -17,7 +17,6 @@ import { cn } from '@/lib/utils';
* gain — the suggestion chips already teach the range of what can be asked, and
* they do it without a timer or a rerender every four seconds.
*/
/** @param {any} props */
export function PromptInput({
value,
onChange,
@@ -28,7 +27,7 @@ export function PromptInput({
autoFocus = false,
align = 'left',
className = '',
}) {
}: any) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);

View File

@@ -121,14 +121,14 @@ const TONE_BADGE = {
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
const TextBlock = React.memo(({ block }: any) => (
<p className="text-body-sm leading-relaxed text-ink-2">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
const HeadingBlock = React.memo(({ block }: any) => (
<div className="space-y-0.5">
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
@@ -137,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
HeadingBlock.displayName = 'HeadingBlock';
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
const KpisBlock = React.memo(({ block }: any) => (
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
{block.items.map((item) => (
<div
@@ -172,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
const StatusBlock = React.memo(({ block }: any) => (
<ul className="space-y-1.5">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
@@ -204,7 +204,7 @@ const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
const MetersBlock = React.memo(({ block }: any) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
@@ -226,7 +226,7 @@ MetersBlock.displayName = 'MetersBlock';
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
const TableBlock = React.memo(({ block }: any) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
@@ -287,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
TableBlock.displayName = 'TableBlock';
/** Stage progression, widths relative to the largest stage. */
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
const FunnelBlock = React.memo(({ block }: any) => {
const max = Math.max(...block.steps.map((s) => s.count), 1);
return (
@@ -314,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
const ListBlock = React.memo(({ block }: any) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
@@ -357,7 +357,7 @@ ListBlock.displayName = 'ListBlock';
* clickable and doing nothing. An item may carry a hint *and* an action: the
* hint explains the record, the action opens it.
*/
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
const InsightsBlock = React.memo(({ block, onPrompt }: any) => (
<div className="space-y-2">
{block.items.map((item, i) => {
const surface = cn(
@@ -434,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
const ActionsBlock = React.memo(({ block }: any) => (
<ol className="space-y-2">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
@@ -453,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
ActionsBlock.displayName = 'ActionsBlock';
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
const BadgesBlock = React.memo(({ block }: any) => (
<div className="flex flex-wrap gap-1.5">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
@@ -464,7 +464,7 @@ const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
const TimelineBlock = React.memo(({ block }: any) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
@@ -497,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
TimelineBlock.displayName = 'TimelineBlock';
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
const NoteBlock = React.memo(({ block }: any) => (
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
<Inline value={block.text} />
</p>
@@ -514,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
const SkillSectionBlock = React.memo(({ block }: any) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
@@ -564,7 +564,7 @@ SkillSectionBlock.displayName = 'SkillSectionBlock';
* - **Not pretend to be finished.** Once approved, the block stays visible
* and says so. Replacing it with a tick would lose what was agreed to.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const ConfirmationBlock = React.memo(({ block, onConfirm }: any) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
@@ -673,7 +673,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt, onConfirm }: any) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];

View File

@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
export const text = (value) => value && { type: 'text', text: value };
/** A section label inside a response. */
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
export const heading = (value, sub?) => value && { type: 'heading', text: value, sub };
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
export const kpis = (items) => items?.length && { type: 'kpis', items };

View File

@@ -15,7 +15,7 @@ import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular) =>
export const plural = (n, word, irregular?) =>
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
@@ -215,7 +215,7 @@ export function buildFacts({
),
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
bands: Object.values(profiles.reduce((acc, p) => {
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
const { label } = getScoreBand(p.krow_score || 0);
acc[label] ||= { label, count: 0 };
acc[label].count += 1;
@@ -241,7 +241,9 @@ export function buildFacts({
};
});
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
}>>((acc, h) => {
const name = h[key] || 'Unspecified';
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
acc[name].count += 1;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
@@ -104,7 +105,7 @@ function getDeptColor(index, avgScore) {
}
/** @param {any} props */
export function DepartmentPerformance({ items = [], className = '' }) {
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
const [activeDept, setActiveDept] = useState(null);

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React from 'react';
import { BarChart } from '@mui/x-charts';
import { ChevronRight } from 'lucide-react';
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
* component derives no figures of its own.
*/
/** @param {any} props */
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
if (!stages.length) return null;
const total = stages[0]?.count || 1;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React from 'react';
import { LineChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
* `points: [{ label, hires, cumulative }]` — supplied by the page.
*/
/** @param {any} props */
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
const enough = points.length >= 2;
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/components/ds/Avatar';
@@ -30,7 +31,7 @@ export function ActivityCard({
variant = 'row',
onClick,
className,
}) {
}: DsProps) {
const iconTones = {
soft: 'bg-krow-blue-tint text-krow-blue',
brand: 'bg-krow-blue text-white',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority';
@@ -40,7 +41,7 @@ const ICON_COLORS = {
* that persists is noise.
*/
/** @param {any} props */
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
const Icon = icon || ICONS[tone];
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
@@ -41,8 +42,7 @@ export function initialsFrom(name = '') {
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const Avatar = React.forwardRef(
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
* people in a tight row (applicants on a position, endorsers on a skill).
*/
/** @param {any} props */
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }: DsProps) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
* white box that ignores the token set.
*/
/** @param {any} props */
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }: DsProps) {
if (!active || !payload?.length) return null;
return (
@@ -71,7 +72,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
* A legend that reads as part of the card rather than as chart furniture.
* Items: `{ label, color, value? }`.
*/
export function ChartLegend({ items = [], className }) {
export function ChartLegend({ items = [], className }: DsProps) {
return (
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
{items.map((item) => (
@@ -118,7 +119,7 @@ export function ChartContainer({
raw = false,
footer,
className,
}) {
}: DsProps) {
if (loading) return <SkeletonChart className={className} height={height} />;
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -60,7 +61,7 @@ export function DataTable({
className,
rowClassName,
stickyHeader = false,
}) {
}: DsProps) {
/* ── Sorting ─────────────────────────────────────────────────────────── */
/* Row height follows the account's density preference. Only the vertical
padding changes — same columns, same type, same behaviour. */

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
@@ -41,7 +42,7 @@ export function Drawer({
className,
/** Hides the header for a fully custom panel (the mobile nav does this). */
bare = false,
}) {
}: DsProps) {
const isVertical = side === 'top' || side === 'bottom';
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -25,7 +26,7 @@ export function EmptyState({
variant = 'empty',
size = 'default',
className,
}) {
}: DsProps) {
const renderAction = (spec, buttonVariant) => {
if (!spec) return null;
if (React.isValidElement(spec)) return spec;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -19,7 +20,7 @@ export function Field({
inline = false,
className,
children,
}) {
}: DsProps) {
const reactId = React.useId();
const id = htmlFor || reactId;
const hintId = hint ? `${id}-hint` : undefined;
@@ -27,11 +28,15 @@ export function Field({
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
// Only clone when the child is a single element that can accept the wiring.
/* `isValidElement` narrows to `ReactElement<unknown>`, whose `props` is
`unknown` — so reading `children.props.id` to preserve a caller's own id
does not compile. The cast says what the guard has already established and
what the runtime relies on: this is an element with props. */
const control = React.isValidElement(children)
? React.cloneElement(children, {
id: children.props.id || id,
'aria-describedby': children.props['aria-describedby'] || describedBy,
'aria-invalid': error ? true : children.props['aria-invalid'],
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
id: (children.props as Record<string, any>).id || id,
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
})
: children;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -37,7 +38,7 @@ export function FilterBar({
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className = '',
}) {
}: DsProps) {
const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => {

View File

@@ -9,8 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
* `label`: an icon-only control with no accessible name is a bug, so the API
* makes it impossible to omit. The label doubles as the tooltip.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const IconButton = React.forwardRef(
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {
xs: 'icon-xs',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ export function InsightRow({
onClick = null,
actionLabel = 'Review',
className = '',
}) {
}: DsProps) {
const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div';
@@ -107,6 +108,6 @@ export function InsightRow({
/** The list container — dividers between rows, nothing else. */
/** @param {any} props */
export function InsightList({ children, className = '' }) {
export function InsightList({ children, className = '' }: DsProps) {
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
@@ -32,7 +33,7 @@ export function KpiCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.brand;
if (loading) {
@@ -112,7 +113,7 @@ export function MetricCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.neutral;
if (loading) {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -19,7 +20,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
*/
/** @param {any} props */
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
export function Grid({ cols = 3, gap = 'default', className, children, ...props }: DsProps) {
const columns = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
@@ -38,7 +39,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
/** @param {any} props */
export function Stack({ space = 'default', className, children, ...props }) {
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
const spacing = {
xs: 'space-y-2',
sm: 'space-y-3',
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
* with its content at consistent spacing.
*/
/** @param {any} props */
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }: DsProps) {
return (
<section className={cn('space-y-3', className)}>
{title && (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
* Spinner — the only spinner in the system.
*/
/** @param {any} props */
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
export function Spinner({ size = 'default', className = '', label = 'Loading' }: DsProps) {
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
return (
<Loader2
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
* final layout prevents the content jump a spinner always causes.
*/
/** @param {any} props */
export function LoadingState({ message = '', size = 'default', className = '' }) {
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
return (
<div
className={cn(
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
* card headers, or beside a control that is refreshing.
*/
/** @param {any} props */
export function InlineLoading({ message = 'Loading…', className = '' }) {
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" />
@@ -59,7 +60,7 @@ export function InlineLoading({ message = 'Loading…', className = '' }) {
* height stable so the page does not jump. Used by DataTable when refetching.
*/
/** @param {any} props */
export function LoadingOverlay({ show = false, message = '', className = '' }) {
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
if (!show) return null;
return (
<div

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
}
/** @param {any} props */
export function MetricStrip({ items = [], columns, className, loading = false }) {
export function MetricStrip({ items = [], columns, className, loading = false }: DsProps) {
const count = columns ?? Math.min(items.length, 6);
const gridCols = {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
@@ -39,7 +40,7 @@ export function Modal({
className,
/** Blocks closing — use while a submit is in flight. */
busy = false,
}) {
}: DsProps) {
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
@@ -100,7 +101,7 @@ export function ConfirmModal({
tone = 'destructive',
icon = null,
busy = false,
}) {
}: DsProps) {
return (
<Modal
open={open}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -25,7 +26,7 @@ export function MultiSelect({
className = '',
align = 'start',
emptyMessage = 'No options',
}) {
}: DsProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -22,7 +23,7 @@ export function PageHeader({
size = 'title',
animate = true,
className,
}) {
}: DsProps) {
const Wrapper = animate ? motion.div : 'div';
const motionProps = animate
? {
@@ -68,7 +69,7 @@ export function PageHeader({
* A dashboard-style greeting header where part of the title is emphasized —
* "Good day. Let's hire smarter."
*/
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
export function HeroHeader({ lead, highlight, subtitle, actions, className }: DsProps) {
return (
<motion.div
initial={{ opacity: 0, y: -12 }}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -24,7 +25,10 @@ export function buildPageRange(current, total, siblings = 1) {
const showLeftEllipsis = left > 2;
const showRightEllipsis = right < total - 1;
const pages = [1];
/* Page numbers with '…' markers between them — a real mixed array, which is
why the renderer tests `entry === '…'` before treating it as a number.
Inference took the seed `[1]` as `number[]` and rejected the markers. */
const pages: (number | string)[] = [1];
if (showLeftEllipsis) pages.push('…');
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
if (showRightEllipsis) pages.push('…');
@@ -47,7 +51,7 @@ export function Pagination({
onPageSizeChange,
pageSizeOptions = [10, 25, 50, 100],
className,
}) {
}: DsProps) {
if (pageCount <= 1 && !onPageSizeChange) return null;
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -45,7 +46,7 @@ export function ProgressBar({
valueSuffix = '',
className,
trackClassName,
}) {
}: DsProps) {
const pct = clamp((Number(value) / max) * 100);
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
@@ -110,7 +111,7 @@ export function ProgressRing({
/** Small text below the label. */
footnote,
className,
}) {
}: DsProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
@@ -178,7 +179,7 @@ export function ProgressRing({
* at a glance without relying on colour.
*/
/** @param {any} props */
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }: DsProps) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
@@ -243,7 +244,7 @@ export function ProgressSteps({ steps = [], current = 0, className = '', onStepC
* dimensions are listed with a weight and a value.
*/
/** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2">

View File

@@ -10,8 +10,7 @@ import { inputVariants } from '@/components/ui/input';
* without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const SearchInput = React.forwardRef(
export const SearchInput: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
(
{
value,

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -21,7 +22,7 @@ export function SectionHeader({
onViewAll,
viewAllLabel = 'View all',
className,
}) {
}: DsProps) {
if (level === 'overline') {
return (
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -10,7 +11,7 @@ import { cn } from '@/lib/utils';
* `prefers-reduced-motion` (see index.css).
*/
/** @param {any} props */
export function Skeleton({ className = '', radius = 'md', ...props }) {
export function Skeleton({ className = '', radius = 'md', ...props }: DsProps) {
return (
<div
aria-hidden="true"
@@ -33,7 +34,7 @@ export function Skeleton({ className = '', radius = 'md', ...props }) {
/** Paragraph placeholder. The last line is short, as real text is. */
/** @param {any} props */
export function SkeletonText({ lines = 3, className = '' }) {
export function SkeletonText({ lines = 3, className = '' }: DsProps) {
return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => (
@@ -48,7 +49,7 @@ export function SkeletonText({ lines = 3, className = '' }) {
/** Matches KpiCard: icon tile, big number, caption. */
/** @param {any} props */
export function SkeletonKpi({ className = '' }) {
export function SkeletonKpi({ className = '' }: DsProps) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
@@ -60,7 +61,7 @@ export function SkeletonKpi({ className = '' }) {
/** Matches a list row: avatar, two lines of text, trailing value. */
/** @param {any} props */
export function SkeletonRow({ className = '' }) {
export function SkeletonRow({ className = '' }: DsProps) {
return (
<div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" />
@@ -75,7 +76,7 @@ export function SkeletonRow({ className = '' }) {
/** Matches a card in a grid: title, meta, stat row. */
/** @param {any} props */
export function SkeletonCard({ className = '' }) {
export function SkeletonCard({ className = '' }: DsProps) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
<div className="flex items-center gap-3 mb-4">
@@ -100,7 +101,7 @@ export function SkeletonCard({ className = '' }) {
/** Matches DataTable: header row plus n body rows. */
/** @param {any} props */
export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) {
return (
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
@@ -121,7 +122,7 @@ export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
/** Matches ChartContainer: title, legend, plot area. */
/** @param {any} props */
export function SkeletonChart({ className = '', height = 240 }) {
export function SkeletonChart({ className = '', height = 240 }: DsProps) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
<Skeleton className="h-3.5 w-40 mb-2" />
@@ -137,7 +138,7 @@ export function SkeletonChart({ className = '', height = 240 }) {
/** n SkeletonRows inside a surface — the common "loading a list" case. */
/** @param {any} props */
export function SkeletonList({ rows = 5, className = '' }) {
export function SkeletonList({ rows = 5, className = '' }: DsProps) {
return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
@@ -78,7 +79,7 @@ const DOT_COLOR = {
* much work; it also gives colour-blind users a second cue alongside the label.
*/
/** @param {any} props */
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) {
const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral';
const resolvedLabel = label || entry.label || humanize(status);

Some files were not shown because too many files have changed in this diff Show More