Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)

* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 11:27:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Emil
parent a9b43ebeb7
commit f53725b20a
243 changed files with 48104 additions and 1543 deletions
+434
View File
@@ -0,0 +1,434 @@
{
"horizontal/swedish-accounting-compliance": {
"hash": "d2e40320fdfebb396da20eb50a6b4a0250a4a73bfa19a5ef71cdb2f723c10a43",
"version": 2
},
"horizontal/swedish-accounting-compliance/bas-kontoplan": {
"hash": "7e953d87cf9d34ed19b59979e86ae9b7e1cb92e1d15b9cc74817702618481260",
"version": 1
},
"horizontal/swedish-accounting-compliance/bfl-bfnar": {
"hash": "2240a0e67af22450468f5bcd85ddf2037fd3ed37e8db662afff6cbaf93d4b398",
"version": 1
},
"horizontal/swedish-accounting-compliance/changes-2025-2026": {
"hash": "c99a22584f8bd696eb28bf166ba16c890019a69222ce06e6a289f3f6dbe7908f",
"version": 1
},
"horizontal/swedish-accounting-compliance/sie4": {
"hash": "0b73718716d3aab442c25bb0d0fa8057087481fe86f6792e28403dd03fb5af11",
"version": 1
},
"horizontal/swedish-accounting-compliance/skatteverket": {
"hash": "defc2ca882c2a000d08446ae5413e3b77bf6a5d6d03c34b709bc2dd35209ebca",
"version": 1
},
"horizontal/swedish-asset-accounting": {
"hash": "f7c79332c0fbc24f391aac3aeb1a01327bb803d11864dd68e52e37a6df2e8588",
"version": 2
},
"horizontal/swedish-asset-accounting/accounts-and-registry": {
"hash": "b33d044b36d290aebb3677ae82020e0890aff3664070f2d1ec510f38b63793f4",
"version": 1
},
"horizontal/swedish-asset-accounting/depreciaton": {
"hash": "f764c19f11c8613ba5014ca2767ddb1ae2926928e04c3abb43444ba27c286aec",
"version": 1
},
"horizontal/swedish-asset-accounting/leasing-and-disposal": {
"hash": "485d7f1d4669f718dbba8611a3644fc3b307b22fb59448af65eebce8466c5b8a",
"version": 1
},
"horizontal/swedish-e-invoicing": {
"hash": "349bdeea6971f88cb20e77bf90684aa185492706aa15b224ffea231ab3a93513",
"version": 2
},
"horizontal/swedish-e-invoicing/consumer-and-b2c": {
"hash": "886cf35d3663b88d3b5bc8ca1766e14f073eb3de1f9e75aa55479582e638f32c",
"version": 1
},
"horizontal/swedish-e-invoicing/european-mandates": {
"hash": "6a309d7f2c2d4bf5a8f6ee76b09757569804f7812d154b4b422e59885da8874d",
"version": 1
},
"horizontal/swedish-e-invoicing/implementation-guide": {
"hash": "0f6fa7db0a91815c92bc2939fd619a6dc6b4d914bca05ca5bcad9657651f1e91",
"version": 1
},
"horizontal/swedish-e-invoicing/legal-and-regulatory": {
"hash": "3b059844f55972292071a7ecf23050df209d1a8cee64d7345a75315c52a376a4",
"version": 1
},
"horizontal/swedish-e-invoicing/market-provider-pricing": {
"hash": "046ed818abb136c920ab4651220fb0ecd65ec2fee4e71faf2ba120cef0d425d2",
"version": 1
},
"horizontal/swedish-e-invoicing/peppol-bis-billing": {
"hash": "71605dec0d6fe35175a2c40492fccdf04bf6dff9e1aa773e24fad449bdabd460",
"version": 1
},
"horizontal/swedish-e-invoicing/peppol-network": {
"hash": "117c87eae7b594f15d2491d157668a5f59bb160caa915750136ebd29cd233ad8",
"version": 1
},
"horizontal/swedish-e-invoicing/swedish-cius-and-specifics": {
"hash": "80c683c22da2449b0183a0db62377890686b8763bb80c049bac62b8dd8ac3809",
"version": 1
},
"horizontal/swedish-financial-reporting": {
"hash": "c3b40427af4e279e92b65b4e048286589218cd5d0d0ae814f54e7297e87f8855",
"version": 2
},
"horizontal/swedish-financial-reporting/arsredovisning-structure": {
"hash": "ae4789f2e72d8b372aafdf0ff0a0211a871b1dd62be76be836fe3cb66d6fe276",
"version": 1
},
"horizontal/swedish-financial-reporting/bolagsverket-filing": {
"hash": "893fd5c00be8f4c8c4a2cfc4058c071b7821f290f9527240cfba6bccb1760cdc",
"version": 1
},
"horizontal/swedish-financial-reporting/ink2-form-logic": {
"hash": "66b2a41ee2217089af8022c3ca36894a4df727536769ea84f77facb337ebe1d1",
"version": 1
},
"horizontal/swedish-invoice-compliance": {
"hash": "281eb51a191bf9c6f7db3604ebd9bae1bdac1fd99c2ef20385d3e6ba40c8e315",
"version": 2
},
"horizontal/swedish-invoice-compliance/invoice-rules": {
"hash": "6d6b51ce14d1d28a780909b2e1513062c4433dbb282e3c21d764732d7346e716",
"version": 1
},
"horizontal/swedish-payroll": {
"hash": "28532c63032c129eb17d6e0409625c6d6b8146c90ba6b1b7cfb111c9d58df9f2",
"version": 2
},
"horizontal/swedish-payroll/agi-filing": {
"hash": "91aa490472fafff434088301dd193ed474589b36ae22bdaab640b42bc3b3c255",
"version": 1
},
"horizontal/swedish-payroll/bas-7xxx": {
"hash": "6e7167788b3d2f9442163e29b77e79703bfc3394ff90223d2d9d096554638437",
"version": 1
},
"horizontal/swedish-payroll/benefits": {
"hash": "bb4ead2f45691bc84c09b7433e3b2c719c93b54f702978cfd362b125ae9c8ca2",
"version": 1
},
"horizontal/swedish-payroll/deductions-lonevaxling": {
"hash": "9d05b0e114ed2bc10285a0c304e5f488ee37a4977975336a76c91c1f312f0137",
"version": 1
},
"horizontal/swedish-payroll/f-skatt": {
"hash": "f8d2bd87e76497e7c7e5fbb65de5c77e28f75b603608341a8d8f05f94e8bb974",
"version": 1
},
"horizontal/swedish-payroll/ob-overtime": {
"hash": "ba6b5dbdbb8e00edb9c592aae2185980ca52aa20888434bc5761ca307de712dd",
"version": 1
},
"horizontal/swedish-payroll/sick-pay": {
"hash": "ba6b5dbdbb8e00edb9c592aae2185980ca52aa20888434bc5761ca307de712dd",
"version": 1
},
"horizontal/swedish-payroll/social-charges": {
"hash": "390fac79462f93ea8e95f00821e7659d1e2f6663907fc281ed0b5b3e87220451",
"version": 1
},
"horizontal/swedish-payroll/tax-tables": {
"hash": "7fbd2be587104f26caf18d28e058d6e9ac5ea3fb167a0990650442b1ffccd9b5",
"version": 1
},
"horizontal/swedish-payroll/travel-expenses": {
"hash": "3ed4af7d164d2a3877ded04115081ea5c837b1397c966b40c9e09284c8f4b4bf",
"version": 1
},
"horizontal/swedish-payroll/vacation-pay": {
"hash": "9a29f4e333b51cd85074d8650705d09dfcba42301c61a508a7b0967ee9d4a5e8",
"version": 1
},
"horizontal/swedish-project-accounting": {
"hash": "4d5c7ea60260de2534e3f65a0b9ca130461319548a186f45fa319b0033e9ae51",
"version": 2
},
"horizontal/swedish-project-accounting/accounts-and-entries": {
"hash": "16dba1ef3553d06dcd34bbb7d95c34f4af1738222fb08145f2ed511d176b1b89",
"version": 1
},
"horizontal/swedish-project-accounting/implementation-patterns": {
"hash": "db59db720c4df2eaacbdcd0b3f36e02172f3763ac6949865123ac29db7966c3b",
"version": 1
},
"horizontal/swedish-project-accounting/k2-k3-revenue-recognition": {
"hash": "6065ee97e019543b89dc42ee422ea3139d6dd84097b882c353423bfb3b25094e",
"version": 1
},
"horizontal/swedish-project-accounting/sie4-project-dimensions": {
"hash": "b92b0c58ee793c15abb46ed81f2fbd47abdbecbc3f0ffe986d19e7331c373e94",
"version": 1
},
"horizontal/swedish-project-accounting/tax-and-grants": {
"hash": "b43e2655a415ad59ebd5b87e8e505adf80b821bc96487fac9e542eaa2cdfbdad",
"version": 1
},
"horizontal/swedish-sie-import-export": {
"hash": "92840a5bb969cea4d0200ed7027389d003d064061c5cb01d21e59efa83849f23",
"version": 2
},
"horizontal/swedish-sie-import-export/bas-sru": {
"hash": "8c778098571f76425cd5754581a10697bd3fc81ec887e9287790e5620ccd08cc",
"version": 1
},
"horizontal/swedish-sie-import-export/encoding": {
"hash": "eaa520fe47638fd521d27d0f9d6315d5c15f47eed2b4df408c9e9addba10c7d8",
"version": 1
},
"horizontal/swedish-sie-import-export/record-types": {
"hash": "1a83c6c67eea2476bef8c13e1d96e4817b9313b2566ca4a1c91456975087e84e",
"version": 1
},
"horizontal/swedish-sie-import-export/validation-rules": {
"hash": "8b08c3d09757fe261bbab469e8d30cb51b960f423d57ada622cadd17a92c9598",
"version": 1
},
"horizontal/swedish-sru-filing": {
"hash": "357d19064da31b0a6092e62af64e24be5e8bbc664ca55c7803b4d4b2ffc73811",
"version": 2
},
"horizontal/swedish-sru-filing/sru-codes": {
"hash": "e295059a8d92b9a5530bbdc15fc33e96b3fb61618d0e99f397cf61cc4c48cd0f",
"version": 1
},
"horizontal/swedish-tax-planning": {
"hash": "1b6b6f1bc104b4e76f0d2ddc6973565d435e9e81471a2cb2246851f0d1cf1277",
"version": 2
},
"horizontal/swedish-tax-planning/312-regler": {
"hash": "f6fd1e6edb45cc31ec990586b08f9e68aa09be15b6e072874c0770bde941a649",
"version": 1
},
"horizontal/swedish-tax-planning/kapitalforsakring": {
"hash": "3119126a58c9c3729d34303cb5f36c27c75e664e5048544fea2e24a6ac71d3fd",
"version": 1
},
"horizontal/swedish-tax-planning/koncernbidrag": {
"hash": "a3870a1cdec023285cb7b6939dab1f31034c878c9dc9b986c737fc7a6852f14a",
"version": 1
},
"horizontal/swedish-tax-planning/overavskrivningar": {
"hash": "77b6f5219e5313c027684271552d799fba8156e6665a9ff16def93ba6caac6de",
"version": 1
},
"horizontal/swedish-tax-planning/periodiseringsfond": {
"hash": "b76ea9e0127040855f722e756ee2bbf4018821c1b8d93496cc2225a09c1a1f38",
"version": 1
},
"horizontal/swedish-tax-planning/ranteavdragsbegransningar": {
"hash": "b34bd57865b790dfa9a8165b04bac06549d197c18fac56894dc4e7fad22745a7",
"version": 1
},
"horizontal/swedish-tax-planning/strategy-and-interactions": {
"hash": "58f4e825c927998b60a7ef47ec9f75ea58bf801727b6c621662cda6654773019",
"version": 1
},
"horizontal/swedish-vat": {
"hash": "cfdb000938ba89ea58a60769b013beda69f669dedebd85c0d6fe509f428f4d45",
"version": 2
},
"horizontal/swedish-vat/vat-compliance-reference": {
"hash": "12cf0d3b485b506dc52ad44a251e1c4507948ad3378cd70210ef972b9f482c87",
"version": 1
},
"horizontal/swedish-year-end-closing": {
"hash": "6099bc7b5b8bd94425b16fef3e897c75598b2d52eed1e624cfce2473f36b8664",
"version": 2
},
"horizontal/swedish-year-end-closing/closing-process": {
"hash": "bc02a629f554369053b9d8d61f306430783d2c14137582a67bfc39b32527241e",
"version": 1
},
"horizontal/swedish-year-end-closing/journal-entries": {
"hash": "afa2ebaa5d1c6619448179110c5b009e57c0aad67ea700083664bb87d632d91e",
"version": 1
},
"horizontal/swedish-year-end-closing/k2-vs-k3": {
"hash": "78d68d301e15cb37f46b656ae2a77199914b2ac054b50ce43bac05aaea77d46c",
"version": 1
},
"horizontal/swedish-year-end-closing/legal-framework": {
"hash": "b58c1e5137e325e1e5cb0a10da6b053985d9603b1feecf56d4440da0f3b0ffe3",
"version": 1
},
"horizontal/swedish-year-end-closing/pitfalls-and-rates": {
"hash": "55afdde0c657b3f0f9c7c16b3c0c1f904dfe78ea4800ee899feee72059400848",
"version": 1
},
"horizontal/swedish-year-end-closing/reporting-and-filing": {
"hash": "b914ff7e07f65f552ac040aa982689157edd34dd8a8290dab683a250a12a253b",
"version": 1
},
"horizontal/swedish-year-end-closing/tax-calculations": {
"hash": "f7859df2a3974933e315d05ff80ec84970e7f1deddbf1f4c5330a816a1bff884",
"version": 1
},
"modifier/holding-ab": {
"hash": "ad2ce0cba4f120ef1c66dd71cdfad7c15559a3997bb1749992b16d61273bde92",
"version": 2
},
"modifier/holding-ab/312-holding": {
"hash": "0f5d92aaa8c172f79971b880cd5a0c4ad29fdeede95dc26bb2504cab25c278df",
"version": 1
},
"modifier/holding-ab/aktieagartillskott": {
"hash": "6c9ad013b732e3c028e0ad0374ee0ffb8675eb30b5504cb23e879fd5a0cd6540",
"version": 1
},
"modifier/holding-ab/koncernbidrag": {
"hash": "c9b34d1000b2b7970602c5efc6708e98afe5afb0d0fea9170374daca8325fbec",
"version": 1
},
"modifier/holding-ab/kupongskatt-cfc": {
"hash": "9784d44f7732a6bc12f8f640fe4b7990b3a78af70d8d751204692a570f5fb319",
"version": 1
},
"modifier/holding-ab/moms-holdingbolag": {
"hash": "000bec98d76fc0f5f8f0f2745db4a29044b24b0977314b232b291d3873b68e31",
"version": 1
},
"modifier/holding-ab/naringsbetingade-andelar": {
"hash": "341473d967b35c0618953213f7ea654a1aa6198105746ff4eb40196369868a4e",
"version": 1
},
"modifier/holding-ab/omstrukturering": {
"hash": "585d7f3292468c5895ffea8184f78bd7056f0592c65833b7b87425fbe002bc59",
"version": 1
},
"modifier/holding-ab/ranteavdrag": {
"hash": "2621981aa238f641772b7f6e236acfba8d1aa7ffd7ea918120ecd833689c4a02",
"version": 1
},
"modifier/mixed-verksamhet": {
"hash": "7513494b7e1e518c621bc20e0d59d8929a1f96318e8c152640565556ae94bf1a",
"version": 2
},
"modifier/mixed-verksamhet/fordelningsnyckel-metoder": {
"hash": "8beb7be4148b9f6881531100b95ae3ddac6e3cda4d284249797708dc1d3b79c7",
"version": 1
},
"modifier/mixed-verksamhet/frivillig-skattskyldighet-fastighet": {
"hash": "9fe81b60f940287c8584357022bb1ba1affdc0b49beac813b5857b539b43efbf",
"version": 1
},
"modifier/mixed-verksamhet/holdingbolag-avdragsratt": {
"hash": "14d2fae686b5cf4d8448cec178052d6333a6ccc1f14509c3a34a1486a80adea4",
"version": 1
},
"modifier/mixed-verksamhet/jamkning-mechanics": {
"hash": "6adaa3dbafba65127569f1a773ca08040376b1fbe43c3f1f3048ecf90c3fdf1d",
"version": 1
},
"modifier/single-shareholder-ab-fmb": {
"hash": "6a8888230079627a508bf533a38590c498f9270db037d2828dab6a98ac7655f3",
"version": 1
},
"vertical/bygg-hantverk": {
"hash": "9670e3515b607176efbdab05be8fc0d76f531ddb1fee8b2d70cbf78802ef571a",
"version": 2
},
"vertical/bygg-hantverk/kollektivavtal": {
"hash": "63c6f62b621c47509fda41746ec38b713ee7fbe3fe057e2311bd8287813f4eb2",
"version": 1
},
"vertical/bygg-hantverk/momsregler": {
"hash": "ec1da7e6aca0da11b611c6d05a034576a950dd42127e367ca03f3e67f0e851bb",
"version": 1
},
"vertical/bygg-hantverk/pagaende-arbeten": {
"hash": "0d7d089f053880bfba251ef07473a4511054f5cc64fa6176180acf482128441e",
"version": 1
},
"vertical/bygg-hantverk/praxis": {
"hash": "333b7c49cbae60940fc53e865df3834c1ae09e9dec106cc2087d886ababce800",
"version": 1
},
"vertical/bygg-hantverk/rot-faktura-exempel": {
"hash": "793c954cb822ca625524262c5856843d448d249360054117f559924a91a2c270",
"version": 1
},
"vertical/e-handel": {
"hash": "c0817de10778d297abb6e7206a6a861f0e41c1bc4f936f477c9afeeb8800baff",
"version": 2
},
"vertical/e-handel/dac7-rapportering": {
"hash": "68c9ca5cf9532f2e4b8b3087ff743fe5e304acee5bdde697f1fbdf1ff9180b11",
"version": 1
},
"vertical/e-handel/dropshipping-kedjetransaktioner": {
"hash": "87caae9d6e4842a7c12f850c00ee16c8e415929999db0870dc54f51e87338857",
"version": 1
},
"vertical/e-handel/kollektivavtal-lager-e-handel": {
"hash": "499445a62ce28353857ba803f459bedb98ce8c0bdb6e791b241f509258cb6b16",
"version": 1
},
"vertical/e-handel/konsumentratt": {
"hash": "2be4d363c517b1e21b0d2f1739e714a4a240f299f2f654bf2fd141388ffee84d",
"version": 1
},
"vertical/e-handel/marketplace-deemed-supplier": {
"hash": "75bae0bd3260acbcf9b56f34e00151bb75829bbc0439a32e793952a9fecb0aff",
"version": 1
},
"vertical/e-handel/oss-ioss-mekanik": {
"hash": "801d1fd42a0d4127b61275c70d03168e760b7e9e81efd18adcf103f6bf2d2986",
"version": 1
},
"vertical/e-handel/payment-providers": {
"hash": "c93f268997bdf6c2753b40785bd5f908a3811709f37a1334af766d213603c29b",
"version": 1
},
"vertical/e-handel/skv-stallningstaganden-katalog": {
"hash": "425ac3bc34873f680d457c7909575870a18e469d705f627b7ef2b4cc978fa658",
"version": 1
},
"vertical/e-handel/vouchers-presentkort": {
"hash": "f634e054cb9b4013ba9d0551a5cf43ad58e7cd98de701a3759bd1acda8f89fd0",
"version": 1
},
"vertical/konsult-it": {
"hash": "2efeeb03361323e2389d7128395bd91226364d84009c63b1b39025b066121b14",
"version": 2
},
"vertical/konsult-it/3-12-rules": {
"hash": "4b61a79c690e5e079590fadfb85a899488486c1108c402847c31c3523d53599c",
"version": 1
},
"vertical/konsult-it/consultant-vs-employee": {
"hash": "72617d75adea253db2e12647fc6ec7a6eecfa21e57fcb553cf699a97c40a4253",
"version": 1
},
"vertical/konsult-it/cross-border-payroll": {
"hash": "587e018c1936e73cc220d48b358e09a5508f5912141686042af08b26b02003e2",
"version": 1
},
"vertical/konsult-it/electronic-services-classification": {
"hash": "dcd82bc64dce24c5feab9c1a70ad63d43193be5ad5fab530677b665259b2abc0",
"version": 1
},
"vertical/konsult-it/invoice-templates": {
"hash": "14666336b3572372131063cf6a33ded049b20b1d501a1fdd4b2b3654b52cfdf8",
"version": 1
},
"vertical/konsult-it/software-capitalization": {
"hash": "a659446e3022e6ebc9124bec11dfa98753bd1dcfcd5edde786515b84f830d633",
"version": 1
},
"vertical/reklambyra-marknadsforing": {
"hash": "6d67fd8ab8c1032168d072025241a0157d68f13f87b92be5e3a84e554265afee",
"version": 1
},
"vertical/software-saas-ai": {
"hash": "63fd0c3f1ddfde9978cd072ee5c76f89900acc5807d5085fa626e611b28a81cd",
"version": 1
}
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { discoverAtoms, type DiscoveredAtom } from '../lib/atom-discovery'
// Runs against the real .claude/skills tree (deterministic, committed content).
// Vitest's cwd is the repo root.
let atoms: DiscoveredAtom[]
async function load(): Promise<DiscoveredAtom[]> {
if (!atoms) atoms = await discoverAtoms(process.cwd())
return atoms
}
describe('discoverAtoms — reference children', () => {
it('emits top-level skills with parent_atom_id null', async () => {
const all = await load()
const vat = all.find((a) => a.id === 'horizontal/swedish-vat')
expect(vat, 'swedish-vat skill should be discovered').toBeDefined()
expect(vat!.parent_atom_id).toBeNull()
})
it('emits one child atom per references/*.md, linked to its parent', async () => {
const all = await load()
const child = all.find(
(a) => a.id === 'horizontal/swedish-vat/vat-compliance-reference',
)
expect(child, 'reference child should be discovered').toBeDefined()
expect(child!.parent_atom_id).toBe('horizontal/swedish-vat')
expect(child!.tier).toBe('horizontal')
// Child body is the raw reference file (its own heading), not the SKILL.md.
expect(child!.body).toContain('# Swedish VAT (Moms) Complete Compliance Reference')
expect(child!.estimated_tokens).toBeGreaterThan(0)
})
it('appends a Loadable references footer to parents that have references', async () => {
const all = await load()
const vat = all.find((a) => a.id === 'horizontal/swedish-vat')!
expect(vat.body).toContain('## Loadable references')
// The footer bridges the router filename to the loadable child id.
expect(vat.body).toContain(
'gnubok_load_skill("horizontal/swedish-vat/vat-compliance-reference")',
)
})
it('does not put the footer inside reference children themselves', async () => {
const all = await load()
for (const a of all.filter((x) => x.parent_atom_id !== null)) {
expect(a.body, `child ${a.id} should not carry the footer`).not.toContain(
'## Loadable references',
)
}
})
it('every child parent_atom_id resolves to a real top-level skill', async () => {
const all = await load()
const topLevelIds = new Set(all.filter((a) => a.parent_atom_id === null).map((a) => a.id))
const children = all.filter((a) => a.parent_atom_id !== null)
expect(children.length).toBeGreaterThan(0)
for (const c of children) {
expect(topLevelIds.has(c.parent_atom_id!), `${c.id} → ${c.parent_atom_id}`).toBe(true)
}
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { buildMigrationSql } from '../generate-skill-bodies'
import type { DiscoveredAtom } from '../lib/atom-discovery'
function atom(overrides: Partial<DiscoveredAtom>): DiscoveredAtom {
return {
id: 'horizontal/test',
tier: 'horizontal',
slug: 'test',
title: 'Test',
description: 'desc',
sni_prefixes: [],
trigger_signals: {},
estimated_tokens: 1,
body_path: '.claude/skills/test/SKILL.md',
body: 'body',
parent_atom_id: null,
frontmatter_version: 1,
schema_version: 1,
...overrides,
}
}
describe('buildMigrationSql', () => {
it('dollar-quotes bodies with backticks, $$, quotes, and the default tag — round-trips intact', () => {
const tricky = 'Body with `code`, $$dollars$$, \'single\' and "double" quotes, and a $gb$ tag.'
const sql = buildMigrationSql([atom({ body: tricky, description: tricky })], { 'horizontal/test': 1 })
// The content survives verbatim inside the SQL...
expect(sql).toContain(tricky)
// ...because the tag escalated past the embedded $gb$.
expect(sql).toMatch(/\$gb0\$/)
expect(sql).toContain('ON CONFLICT (id) DO UPDATE')
expect(sql).toContain("NOTIFY pgrst, 'reload schema'")
})
it('emits a text[] literal for sni_prefixes and jsonb for trigger_signals', () => {
const sql = buildMigrationSql(
[atom({ sni_prefixes: ['62.01', '62.02'], trigger_signals: { foo: 'bar' } })],
{ 'horizontal/test': 1 },
)
expect(sql).toContain("ARRAY['62.01', '62.02']::text[]")
expect(sql).toContain('::jsonb')
expect(sql).toContain('{"foo":"bar"}')
})
it('escapes single quotes in short text fields (title/id)', () => {
const sql = buildMigrationSql([atom({ title: "O'Brien & Co" })], { 'horizontal/test': 1 })
expect(sql).toContain("'O''Brien & Co'")
})
it('emits parent_atom_id — NULL for top-level skills, a quoted id for reference children', () => {
const sql = buildMigrationSql(
[
atom({ id: 'horizontal/swedish-vat', parent_atom_id: null }),
atom({
id: 'horizontal/swedish-vat/vat-compliance-reference',
parent_atom_id: 'horizontal/swedish-vat',
}),
],
{
'horizontal/swedish-vat': 1,
'horizontal/swedish-vat/vat-compliance-reference': 1,
},
)
// Column wired into both the INSERT list and the conflict update.
expect(sql).toContain(', body, parent_atom_id, version,')
expect(sql).toContain('parent_atom_id = EXCLUDED.parent_atom_id')
// Parent row carries a literal NULL; child row carries the parent id.
expect(sql).toMatch(/\$gb\$body\$gb\$,\n {4}NULL,/)
expect(sql).toContain("'horizontal/swedish-vat',\n 1,")
})
it('omits is_active / mcp_exposed from the upsert so manual flips survive re-seed', () => {
const sql = buildMigrationSql([atom({})], { 'horizontal/test': 1 })
// The INSERT column list takes column defaults for these on first insert...
const columnList = sql.match(/INSERT INTO public\.agent_atom_registry\s*\n\s*\(([^)]*)\)/)?.[1] ?? ''
expect(columnList).not.toContain('mcp_exposed')
expect(columnList).not.toContain('is_active')
// ...and the DO UPDATE SET must not overwrite them on conflict.
const updateClause = sql.slice(sql.indexOf('DO UPDATE SET'))
expect(updateClause).not.toContain('mcp_exposed')
expect(updateClause).not.toContain('is_active')
})
})
+59
View File
@@ -0,0 +1,59 @@
/**
* One-shot script that exports the registry-derived docs content (errors +
* reference) as static TypeScript modules into the gnubok-website repo.
*
* Run with `npx tsx scripts/export-docs-to-website.mts`. Re-run whenever
* structured-errors or the v1 endpoint registry materially changes.
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const errors = await import('@/lib/docs/content/errors')
const reference = await import('@/lib/docs/content/reference')
const buildErrorReferenceMd = errors.buildErrorReferenceMd ?? (errors as any).default?.buildErrorReferenceMd
const buildResourcePages = reference.buildResourcePages ?? (reference as any).default?.buildResourcePages
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd ?? (reference as any).default?.buildReferenceOverviewMd
if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd) {
console.error('Missing builder exports. Inspect:', {
errorsKeys: Object.keys(errors),
referenceKeys: Object.keys(reference),
})
process.exit(1)
}
const WEBSITE = resolve('/Users/jakobwennberg/gnubok-website')
function write(rel: string, content: string) {
const out = resolve(WEBSITE, rel)
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, content)
console.log(`wrote ${out} (${content.length} chars)`)
}
const errorsMd = buildErrorReferenceMd()
write(
'lib/docs/content/errors.generated.ts',
`// AUTO-GENERATED from erp-base — do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\nexport const ERRORS_MD = ${JSON.stringify(errorsMd)}\n`,
)
const refOverview = buildReferenceOverviewMd()
const refPages = buildResourcePages()
const slugs = refPages.map((p: { slug: string }) => p.slug)
const pagesPayload = refPages.map((p: { slug: string; label: string; description: string; markdown: string }) => ({
slug: p.slug,
label: p.label,
description: p.description,
markdown: p.markdown,
}))
write(
'lib/docs/content/reference.generated.ts',
`// AUTO-GENERATED from erp-base — do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\n\nexport const REFERENCE_OVERVIEW_MD = ${JSON.stringify(refOverview)}\n\nexport interface ResourcePage {\n slug: string\n label: string\n description: string\n markdown: string\n}\n\nexport const RESOURCE_SLUGS: readonly string[] = ${JSON.stringify(
slugs,
)} as const\n\nexport const RESOURCE_PAGES: ResourcePage[] = ${JSON.stringify(pagesPayload, null, 2)}\n\nexport function findResourcePage(slug: string): ResourcePage | undefined {\n return RESOURCE_PAGES.find((p) => p.slug === slug)\n}\n`,
)
console.log('done.')
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env npx tsx
/**
* Generate a Supabase seed migration that inlines every atom SKILL.md body into
* agent_atom_registry.body.
*
* Why a generated migration (not a runtime/seed-script write):
* - Vercel's build has no DB, so prebuild seeding can't write.
* - Skill bodies must reach prod via the one deploy path the project trusts:
* supabase/migrations applied on deploy. The SQL is generated, never hand-authored.
* - It also fixes the "registry never seeded" case (the manual seed may never have
* run in prod) — the migration populates the rows on deploy.
*
* Determinism:
* - Atoms are emitted sorted by id; bodies are dollar-quoted with a collision-proof
* tag; `version` is derived from a committed content-hash manifest
* (scripts/.skill-body-manifest.json) so it only bumps when a SKILL.md changes.
* - A no-change run emits NOTHING (byte-identical repo). A change emits ONE new
* timestamped migration (append-only — we never edit an existing migration).
*
* Usage:
* npx tsx scripts/generate-skill-bodies.ts # emit a migration if skills changed
* npx tsx scripts/generate-skill-bodies.ts --check # CI guard: exit 1 if a skill changed
* # without a regenerated migration
*/
import { createHash } from 'node:crypto'
import { readdir, writeFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { discoverAtoms, type DiscoveredAtom } from './lib/atom-discovery'
const __filename = fileURLToPath(import.meta.url)
const ROOT = dirname(dirname(__filename))
const MIGRATIONS_DIR = join(ROOT, 'supabase', 'migrations')
const MANIFEST_PATH = join(ROOT, 'scripts', '.skill-body-manifest.json')
const checkOnly = process.argv.includes('--check')
interface ManifestEntry {
hash: string
version: number
}
type Manifest = Record<string, ManifestEntry>
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf8').digest('hex')
}
function loadManifest(): Manifest {
if (!existsSync(MANIFEST_PATH)) return {}
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest
} catch {
return {}
}
}
async function saveManifest(manifest: Manifest): Promise<void> {
// Sorted keys for stable diffs.
const sorted: Manifest = {}
for (const id of Object.keys(manifest).sort()) sorted[id] = manifest[id]
await writeFile(MANIFEST_PATH, JSON.stringify(sorted, null, 2) + '\n', 'utf8')
}
/** Wrap a string in a dollar-quote tag guaranteed not to appear inside it. */
function dollarQuote(s: string): string {
let tag = '$gb$'
let n = 0
while (s.includes(tag)) {
tag = `$gb${n}$`
n++
}
return `${tag}${s}${tag}`
}
/** Single-quoted SQL text literal (apostrophes doubled). For short, simple values. */
function sqlStr(s: string): string {
return `'${s.replace(/'/g, "''")}'`
}
function sqlTextArray(arr: string[]): string {
if (arr.length === 0) return `'{}'::text[]`
return `ARRAY[${arr.map(sqlStr).join(', ')}]::text[]`
}
/** Next migration timestamp = max existing 14-digit prefix + 1 (guarantees ordering). */
async function nextMigrationTimestamp(): Promise<string> {
const files = await readdir(MIGRATIONS_DIR)
// 14-digit timestamps (max ~1e14) are well within Number.MAX_SAFE_INTEGER (~9e15).
let max = 0
for (const f of files) {
const m = /^(\d{14})_/.exec(f)
if (m) {
const n = Number(m[1])
if (n > max) max = n
}
}
return String(max + 1).padStart(14, '0')
}
function buildValuesRow(atom: DiscoveredAtom, version: number): string {
const triggerJson = JSON.stringify(atom.trigger_signals ?? {})
return [
' (',
` ${sqlStr(atom.id)},`,
` ${sqlStr(atom.tier)},`,
` ${sqlStr(atom.title)},`,
` ${dollarQuote(atom.description)},`,
` ${sqlTextArray(atom.sni_prefixes)},`,
` ${dollarQuote(triggerJson)}::jsonb,`,
` ${atom.estimated_tokens},`,
` ${sqlStr(atom.body_path)},`,
` ${dollarQuote(atom.body)},`,
` ${atom.parent_atom_id ? sqlStr(atom.parent_atom_id) : 'NULL'},`,
` ${version},`,
` ${atom.schema_version}`,
' )',
].join('\n')
}
export function buildMigrationSql(atoms: DiscoveredAtom[], versions: Record<string, number>): string {
const header = `-- AUTO-GENERATED by scripts/generate-skill-bodies.ts — DO NOT EDIT BY HAND.
-- Regenerate with \`npm run skills:generate\` after editing any .claude/skills/**/SKILL.md.
--
-- Seeds agent_atom_registry rows with their SKILL.md body so the MCP server and the
-- in-app agent read skill content from the DB (works on Vercel, Docker, self-hosted).
-- Idempotent: ON CONFLICT refreshes content fields but leaves is_active and
-- mcp_exposed under manual control (they take column defaults on first insert).
`
const values = atoms.map((a) => buildValuesRow(a, versions[a.id])).join(',\n')
const insert = `INSERT INTO public.agent_atom_registry
(id, tier, title, description, sni_prefixes, trigger_signals, estimated_tokens, body_path, body, parent_atom_id, version, schema_version)
VALUES
${values}
ON CONFLICT (id) DO UPDATE SET
tier = EXCLUDED.tier,
title = EXCLUDED.title,
description = EXCLUDED.description,
sni_prefixes = EXCLUDED.sni_prefixes,
trigger_signals = EXCLUDED.trigger_signals,
estimated_tokens = EXCLUDED.estimated_tokens,
body_path = EXCLUDED.body_path,
body = EXCLUDED.body,
parent_atom_id = EXCLUDED.parent_atom_id,
version = EXCLUDED.version,
schema_version = EXCLUDED.schema_version,
updated_at = now();
`
return `${header}\n${insert}\nNOTIFY pgrst, 'reload schema';\n`
}
async function main() {
const atoms = await discoverAtoms(ROOT)
if (atoms.length === 0) {
console.error('No atoms discovered under .claude/skills/ — refusing to emit an empty seed.')
process.exit(1)
}
const manifest = loadManifest()
const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)]))
// Drift = added, changed, or removed atoms vs. the committed manifest.
const added: string[] = []
const changed: string[] = []
for (const [id, hash] of onDisk) {
const prev = manifest[id]
if (!prev) added.push(id)
else if (prev.hash !== hash) changed.push(id)
}
const removed = Object.keys(manifest).filter((id) => !onDisk.has(id))
const hasDrift = added.length > 0 || changed.length > 0 || removed.length > 0
if (checkOnly) {
if (!hasDrift) {
console.log(`✓ skill bodies up to date (${atoms.length} atoms).`)
return
}
console.error('✗ skill bodies are STALE — a SKILL.md changed without regenerating the seed migration.')
if (added.length) console.error(` added: ${added.join(', ')}`)
if (changed.length) console.error(` changed: ${changed.join(', ')}`)
if (removed.length) console.error(` removed: ${removed.join(', ')}`)
console.error('\nRun `npm run skills:generate` and commit the emitted migration.')
process.exit(1)
}
if (!hasDrift) {
console.log(`✓ no skill changes (${atoms.length} atoms) — nothing to generate.`)
return
}
// Compute versions: bump only changed atoms; new atoms start at 1.
const newManifest: Manifest = {}
const versions: Record<string, number> = {}
for (const atom of atoms) {
const hash = onDisk.get(atom.id)!
const prev = manifest[atom.id]
const version = !prev ? 1 : prev.hash === hash ? prev.version : prev.version + 1
versions[atom.id] = version
newManifest[atom.id] = { hash, version }
}
const ts = await nextMigrationTimestamp()
const fileName = `${ts}_seed_agent_atom_bodies.sql`
const filePath = join(MIGRATIONS_DIR, fileName)
await writeFile(filePath, buildMigrationSql(atoms, versions), 'utf8')
await saveManifest(newManifest)
console.log(`Wrote supabase/migrations/${fileName} (${atoms.length} atoms).`)
if (added.length) console.log(` added: ${added.join(', ')}`)
if (changed.length) console.log(` changed: ${changed.join(', ')}`)
if (removed.length) console.log(` removed (dropped from manifest, row left as-is in DB): ${removed.join(', ')}`)
}
// Only run when invoked directly (not when imported by tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+371
View File
@@ -0,0 +1,371 @@
/**
* Shared atom discovery + SKILL.md frontmatter parsing.
*
* Used by both:
* - scripts/seed-agent-atom-registry.ts (dev/manual: writes the registry directly)
* - scripts/generate-skill-bodies.ts (production: emits a seed migration)
*
* Keeping discovery in one place means the two paths can never drift on which
* skills count as atoms, how titles/tokens are derived, or how frontmatter is read.
*
* Tiers discovered (the curated set — swarm-* and other Claude-Code-only skills
* are intentionally NOT matched here, so they never become atoms):
* horizontal — `.claude/skills/swedish-*\/SKILL.md` (regulatory)
* vertical — `.claude/skills/industry/<slug>\/SKILL.md` (industry)
* modifier — `.claude/skills/modifier/<slug>\/SKILL.md` (cross-cutting)
*/
import { readdir, readFile, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
export type Tier = 'horizontal' | 'vertical' | 'modifier'
export interface DiscoveredAtom {
/** Stable id shaped as "<tier>/<slug>" (e.g. "horizontal/swedish-vat"). */
id: string
tier: Tier
slug: string
title: string
description: string
sni_prefixes: string[]
trigger_signals: Record<string, unknown>
/**
* Token estimate over the SKILL.md content ONLY — the unit actually loaded
* into the system prompt / returned by gnubok_load_skill. (We deliberately do
* NOT count references/*.md, which are not read at runtime.)
*/
estimated_tokens: number
/**
* Repo-relative path to the body source — SKILL.md for top-level skills,
* the references/*.md file for reference children (provenance + dev-fallback).
*/
body_path: string
/**
* Body inlined into the DB. For a top-level skill: the raw SKILL.md content
* (frontmatter included) with a "Loadable references" footer appended when the
* skill has any. For a reference child: the raw references/*.md content.
*/
body: string
/**
* NULL for a top-level skill; the parent skill's id for a reference child.
* Reference rows are hidden from every catalog (the metadata index, the MCP
* skill list, the composer atom index, the settings panel) by a
* `parent_atom_id IS NULL` filter — they reach the model only via an explicit
* gnubok_load_skill(<child id>) call after the parent SKILL.md is loaded.
*/
parent_atom_id: string | null
/** Version declared in frontmatter, or 1. The generator may override this. */
frontmatter_version: number
schema_version: number
}
// ── Frontmatter parsing ────────────────────────────────────────────────
// SKILL.md files use YAML frontmatter with `name`, `description`, and optionally
// `tier`, `sni_prefixes`, `trigger_signals`, `estimated_tokens`, `version`. We
// parse only the keys we care about — js-yaml is not in deps.
interface Frontmatter {
raw: string
name?: string
title?: string
description?: string
tier?: Tier
sniPrefixes?: string[]
triggerSignals?: Record<string, unknown>
estimatedTokens?: number
version?: number
}
function extractFrontmatter(content: string): Frontmatter | null {
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return null
const raw = match[1]
return {
raw,
name: parseScalar(raw, 'name'),
title: parseScalar(raw, 'title'),
description: parseScalar(raw, 'description'),
tier: parseScalar(raw, 'tier') as Tier | undefined,
sniPrefixes: parseArray(raw, 'sni_prefixes'),
triggerSignals: parseInlineObject(raw, 'trigger_signals'),
estimatedTokens: parseNumber(raw, 'estimated_tokens'),
version: parseNumber(raw, 'version'),
}
}
// Handles `key: value`, `key: "quoted"`, `key: >`+folded, `key: |`+literal.
function parseScalar(yaml: string, key: string): string | undefined {
const inline = new RegExp(`^${escapeKey(key)}:\\s*(.*)$`, 'm').exec(yaml)
if (!inline) return undefined
const head = inline[1].trim()
if (head === '>' || head === '|' || head === '>-' || head === '|-') {
const after = yaml.slice(inline.index + inline[0].length).split('\n')
const lines: string[] = []
for (const line of after) {
if (line.length === 0) continue
if (/^\s/.test(line)) {
lines.push(line.trim())
} else {
break
}
}
return head.startsWith('>') ? lines.join(' ') : lines.join('\n')
}
return unquote(head)
}
function parseNumber(yaml: string, key: string): number | undefined {
const v = parseScalar(yaml, key)
if (v == null) return undefined
const n = Number(v)
return Number.isFinite(n) ? n : undefined
}
function parseArray(yaml: string, key: string): string[] | undefined {
const inline = new RegExp(`^${escapeKey(key)}:\\s*\\[(.*)\\]\\s*$`, 'm').exec(yaml)
if (inline) {
return inline[1]
.split(',')
.map((s) => unquote(s.trim()))
.filter(Boolean)
}
return undefined
}
function parseInlineObject(yaml: string, key: string): Record<string, unknown> | undefined {
// POC: only recognize `trigger_signals: {}` or absent. Deep parsing is deferred.
const line = new RegExp(`^${escapeKey(key)}:\\s*\\{\\s*\\}\\s*$`, 'm').exec(yaml)
if (line) return {}
return undefined
}
function escapeKey(key: string): string {
return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function unquote(s: string): string {
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1)
}
return s
}
// ── Token estimation ──────────────────────────────────────────────────
// Chars/4 baseline (Anthropic guidance for English). Swedish text inflates on
// Opus 4.7's tokenizer — re-measure post-POC.
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4)
}
// ── Title derivation ──────────────────────────────────────────────────
export function deriveTitle(slug: string): string {
// 'swedish-vat' → 'Swedish VAT'; 'swedish-year-end-closing' → 'Swedish Year-End Closing'
return slug
.split('-')
.map((w) => (w === 'vat' || w === 'sru' || w === 'sie' ? w.toUpperCase() : capitalize(w)))
.join(' ')
}
function capitalize(s: string): string {
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
}
// ── Discovery ─────────────────────────────────────────────────────────
/**
* Scan `<rootDir>/.claude/skills/` and return one DiscoveredAtom per skill,
* sorted by id for deterministic output. Skills without frontmatter or without
* a description are skipped (with a warning).
*/
export async function discoverAtoms(rootDir: string): Promise<DiscoveredAtom[]> {
const skillsDir = join(rootDir, '.claude', 'skills')
const rows: DiscoveredAtom[] = []
const entries = await readdir(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Horizontal: top-level swedish-* directory
if (entry.name.startsWith('swedish-')) {
rows.push(...(await readAtom(rootDir, 'horizontal', entry.name, join(skillsDir, entry.name))))
continue
}
// Vertical / modifier: subdirectories under those names
if (entry.name === 'industry' || entry.name === 'modifier') {
const tier: Tier = entry.name === 'industry' ? 'vertical' : 'modifier'
const tierDir = join(skillsDir, entry.name)
const subs = await readdir(tierDir, { withFileTypes: true })
for (const sub of subs) {
if (!sub.isDirectory()) continue
rows.push(...(await readAtom(rootDir, tier, sub.name, join(tierDir, sub.name))))
}
}
}
rows.sort((a, b) => a.id.localeCompare(b.id))
return rows
}
/**
* Read one skill directory into a top-level atom plus one child atom per
* references/*.md file. Returns an empty array if the SKILL.md is missing or
* lacks the frontmatter we require.
*/
async function readAtom(
rootDir: string,
tier: Tier,
slug: string,
dir: string
): Promise<DiscoveredAtom[]> {
const skillPath = join(dir, 'SKILL.md')
try {
await stat(skillPath)
} catch {
return []
}
const content = await readFile(skillPath, 'utf8')
const fm = extractFrontmatter(content)
if (!fm) {
console.warn(` skipped ${relative(rootDir, skillPath)} — no frontmatter`)
return []
}
if (!fm.description) {
console.warn(` skipped ${relative(rootDir, skillPath)} — missing description`)
return []
}
const parentId = `${tier}/${slug}`
const childTier: Tier = fm.tier ?? tier
const refs = await readReferenceFiles(dir)
// Bridge the SKILL.md router (which points at dead `references/*.md` paths at
// runtime) to the loadable child ids the model can actually call. Appended to
// the parent body so it ships in the seeded DB body, visible only once the
// skill itself is loaded.
const body = refs.length > 0 ? content + buildReferencesFooter(parentId, refs) : content
const parent: DiscoveredAtom = {
id: parentId,
tier: childTier,
slug,
title: fm.title ?? deriveTitle(slug),
description: fm.description,
sni_prefixes: fm.sniPrefixes ?? [],
trigger_signals: fm.triggerSignals ?? {},
// Estimate over the loaded unit (SKILL.md + footer), not the whole
// directory — references are loaded separately and budgeted on their own row.
estimated_tokens: fm.estimatedTokens ?? estimateTokens(body),
body_path: relative(rootDir, skillPath),
body,
parent_atom_id: null,
frontmatter_version: fm.version ?? 1,
schema_version: 1,
}
const children: DiscoveredAtom[] = refs.map((r) => ({
id: `${parentId}/${r.slug}`,
tier: childTier,
slug: `${slug}/${r.slug}`,
title: r.descriptor || deriveTitle(r.slug),
description: r.descriptor
? `${r.descriptor} — reference for ${parent.title}`
: `Reference for ${parent.title}`,
sni_prefixes: [],
trigger_signals: {},
estimated_tokens: estimateTokens(r.body),
body_path: relative(rootDir, r.absPath),
body: r.body,
parent_atom_id: parentId,
frontmatter_version: 1,
schema_version: 1,
}))
return [parent, ...children]
}
// ── Reference discovery ───────────────────────────────────────────────
// A skill's deep material lives in <skillDir>/references/**.md. Each file
// becomes a hidden child atom loadable by id; the bytes never count toward the
// parent's token budget and never appear in any catalog listing.
interface ReferenceFile {
/** Absolute path on disk (provenance + dev-fallback anchor). */
absPath: string
/** Path as the SKILL.md router writes it, e.g. "references/bfl-bfnar.md". */
relPath: string
/** Child-id suffix derived from the path, e.g. "bfl-bfnar". */
slug: string
/** Raw file content — the child's DB-inlined body. */
body: string
/** First ATX heading (or first line), used as a human-readable label. */
descriptor: string
}
async function readReferenceFiles(skillDir: string): Promise<ReferenceFile[]> {
const refsDir = join(skillDir, 'references')
try {
await stat(refsDir)
} catch {
return []
}
const files = (await walkMarkdown(refsDir)).sort()
const out: ReferenceFile[] = []
for (const absPath of files) {
const body = await readFile(absPath, 'utf8')
const relFromRefs = relative(refsDir, absPath).split(sep).join('/')
out.push({
absPath,
relPath: `references/${relFromRefs}`,
slug: relFromRefs.replace(/\.md$/i, '').replace(/\//g, '-'),
body,
descriptor: firstHeadingOrLine(body),
})
}
return out
}
async function walkMarkdown(dir: string): Promise<string[]> {
const out: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const p = join(dir, e.name)
if (e.isDirectory()) out.push(...(await walkMarkdown(p)))
else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) out.push(p)
}
return out
}
function firstHeadingOrLine(md: string): string {
const lines = md.split('\n')
for (const line of lines) {
const h = /^#{1,3}\s+(.+?)\s*#*$/.exec(line.trim())
if (h) return h[1].trim()
}
for (const line of lines) {
const t = line.trim()
if (t.length > 0) return t.slice(0, 100)
}
return ''
}
function buildReferencesFooter(parentId: string, refs: ReferenceFile[]): string {
const lines = [
'',
'---',
'',
'## Loadable references',
'',
'The reference files named above are NOT included in this body. When a question genuinely needs that depth, load the specific one on demand with `gnubok_load_skill` — and only then:',
'',
]
for (const r of refs) {
const label = r.descriptor ? ` — ${r.descriptor}` : ''
lines.push(`- \`${r.relPath}\` → \`gnubok_load_skill("${parentId}/${r.slug}")\`${label}`)
}
return '\n' + lines.join('\n') + '\n'
}
+289
View File
@@ -0,0 +1,289 @@
-- One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
-- Run from the Supabase Studio SQL editor (Project Settings -> SQL Editor).
-- Equivalent of scripts/remap-krister-bas96-to-bas2025.ts but executed
-- entirely server-side, so it doesn't need the DB password.
--
-- Before running:
-- 1. Take a Supabase backup (Database -> Backups -> Create backup).
-- 2. Read this entire file. The identity check is at line ~50.
-- 3. Make sure no fiscal period is closed/locked (the script aborts if so).
--
-- Safety:
-- * Identity is hard-coded (ks@sundlingwarn.com + company name contains "sundling").
-- * The whole DO block is one transaction. Any RAISE EXCEPTION rolls back.
-- * Grand debit/credit invariant is checked at the end -- mismatch -> rollback.
-- * Only Krister's company_id is written to. Every UPDATE/INSERT/DELETE filters by it.
--
-- After running, watch the "Notices" panel below the editor for progress and the
-- final summary. If the DO block errors out, the whole transaction rolls back.
BEGIN;
DO $remap$
DECLARE
-- ────────────────────────────────────────────────────────────────
-- Hard-coded identity (no override). Aborts if either doesn't match.
-- ────────────────────────────────────────────────────────────────
v_expected_email constant text := 'ks@sundlingwarn.com';
v_expected_fragment constant text := 'cesu'; -- Krister's holding company: CeSu Invest AB
v_user_id uuid;
v_user_email text;
v_company_id uuid;
v_company_name text;
v_owner_count int;
-- counters
v_inserted_accounts int := 0;
v_updated_lines bigint := 0;
v_deleted_accounts int := 0;
v_locked_periods int;
v_old_id uuid;
v_target_id uuid;
v_line_count bigint;
-- invariants
v_debit_before numeric;
v_credit_before numeric;
v_debit_after numeric;
v_credit_after numeric;
m record; -- mapping iterator
BEGIN
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- ────────────────────────────────────────────────────────────────
-- 1. Resolve user
-- ────────────────────────────────────────────────────────────────
SELECT id, email INTO v_user_id, v_user_email
FROM auth.users
WHERE LOWER(email) = LOWER(v_expected_email);
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'No auth.users row for email %', v_expected_email;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 2. Resolve company (owner/admin role)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_owner_count
FROM public.company_members
WHERE user_id = v_user_id AND role IN ('owner', 'admin');
IF v_owner_count = 0 THEN
RAISE EXCEPTION 'User % owns/admins no companies', v_user_id;
ELSIF v_owner_count > 1 THEN
RAISE EXCEPTION
'User % owns/admins % companies -- this script supports exactly one. '
'Add a WHERE c.id = ''<uuid>'' filter below to pick one explicitly.',
v_user_id, v_owner_count;
END IF;
SELECT c.id, c.name INTO v_company_id, v_company_name
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = v_user_id AND cm.role IN ('owner', 'admin');
-- ────────────────────────────────────────────────────────────────
-- 3. Identity assertions (hard checks; no override)
-- ────────────────────────────────────────────────────────────────
IF LOWER(v_user_email) <> LOWER(v_expected_email) THEN
RAISE EXCEPTION 'Identity check FAILED: email % != expected %', v_user_email, v_expected_email;
END IF;
IF POSITION(LOWER(v_expected_fragment) IN LOWER(v_company_name)) = 0 THEN
RAISE EXCEPTION 'Identity check FAILED: company "%" does not contain "%"',
v_company_name, v_expected_fragment;
END IF;
RAISE NOTICE 'Resolved user : % (%)', v_user_email, v_user_id;
RAISE NOTICE 'Resolved company: % (%)', v_company_name, v_company_id;
-- ────────────────────────────────────────────────────────────────
-- 4. Period lock check (bypass GUC does NOT unlock periods)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_locked_periods
FROM public.fiscal_periods
WHERE company_id = v_company_id AND (is_closed = true OR locked_at IS NOT NULL);
IF v_locked_periods > 0 THEN
RAISE EXCEPTION 'Refusing to run: % closed/locked fiscal periods exist for this company',
v_locked_periods;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 5. Pre-flight grand totals (invariant)
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_before, v_credit_before
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
RAISE NOTICE 'Pre-flight totals: debit=% credit=%', v_debit_before, v_credit_before;
-- ────────────────────────────────────────────────────────────────
-- 5b. Rename every source account to a __mig__ prefix so target lookups
-- can never collide with an empty source row (handles the 1360 swap:
-- old 1360 -> 1760 AND old 1630 -> new 1360 in the same run).
-- ────────────────────────────────────────────────────────────────
UPDATE public.chart_of_accounts
SET account_number = '__mig__' || account_number
WHERE company_id = v_company_id
AND account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1360','1623','1624','1625','1626','1627','1628','1629',
'1630','1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- ────────────────────────────────────────────────────────────────
-- 6. Iterate mappings: INSERT target if missing, move lines, count.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT * FROM (VALUES
-- Bank och likvida medel
('1040', '1930', 'Företagskonto', 1, 'asset', 'debit', '19'),
('1050', '1940', 'Likviditetskonto', 1, 'asset', 'debit', '19'),
('1051', '1941', 'Valutakonto GBP', 1, 'asset', 'debit', '19'),
('1052', '1942', 'Valutakonto EUR', 1, 'asset', 'debit', '19'),
('1053', '1943', 'Fasträntekonto', 1, 'asset', 'debit', '19'),
('1055', '1944', 'Sparkonto SBAB', 1, 'asset', 'debit', '19'),
-- Värdepapper / placeringar
('1056', '1361', 'Depå Carnegie', 1, 'asset', 'debit', '13'),
('1060', '1385', 'Kapitalförsäkring (Avanza)', 1, 'asset', 'debit', '13'),
('1061', '1386', 'Kapitalförsäkring (Movestic)', 1, 'asset', 'debit', '13'),
('1210', '1510', 'Kundfordringar', 1, 'asset', 'debit', '15'),
('1360', '1760', 'Upplupna ränteintäkter', 1, 'asset', 'debit', '17'),
('1623', '1330', 'Andelar i intresseföretag', 1, 'asset', 'debit', '13'),
('1624', '1311', 'Andelar i dotterföretag — Divigen', 1, 'asset', 'debit', '13'),
('1625', '1350', 'Andelar i andra företag', 1, 'asset', 'debit', '13'),
('1626', '1351', 'Andelar i andra utländska företag', 1, 'asset', 'debit', '13'),
('1627', '1352', 'Andelar — Impilo', 1, 'asset', 'debit', '13'),
('1628', '1353', 'Andelar — Röko', 1, 'asset', 'debit', '13'),
('1629', '1354', 'Andelar — Altor V', 1, 'asset', 'debit', '13'),
('1630', '1360', 'Aktiefonder (HB Microcap)', 1, 'asset', 'debit', '13'),
('1631', '1355', 'Andelar — Altor VI', 1, 'asset', 'debit', '13'),
('1632', '1356', 'Andelar — Impilo Orphan', 1, 'asset', 'debit', '13'),
-- Skatt och moms (2210 + 2211 merged into 1630 Skattekonto)
('2210', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2211', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2330', '2941', 'Upplupna lagstadgade soc. avgifter', 2, 'liability', 'credit', '29'),
('2480', '2650', 'Redovisningskonto för moms', 2, 'liability', 'credit', '26'),
('2510', '2710', 'Personalens källskatt', 2, 'liability', 'credit', '27'),
-- Övriga skulder och reserver
('2690', '2890', 'Övriga kortfristiga skulder', 2, 'liability', 'credit', '28'),
('2864', '2126', 'Periodiseringsfond avsatt vid taxering 2026', 2, 'equity', 'credit', '21'),
-- Eget kapital
('2991', '2081', 'Aktiekapital', 2, 'equity', 'credit', '20'),
('2992', '2086', 'Reservfond', 2, 'equity', 'credit', '20'),
('2997', '2091', 'Balanserat resultat', 2, 'equity', 'credit', '20'),
('2999', '2099', 'Årets resultat', 2, 'equity', 'credit', '20')
) AS t(old_number, new_number, new_name, account_class, account_type, normal_balance, account_group)
LOOP
-- Find existing old account (now under the __mig__ prefix)
SELECT id INTO v_old_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = '__mig__' || m.old_number;
IF v_old_id IS NULL THEN
RAISE NOTICE ' skip %: old account not in chart (already migrated?)', m.old_number;
CONTINUE;
END IF;
-- Find existing target account, or INSERT it
SELECT id INTO v_target_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = m.new_number;
IF v_target_id IS NULL THEN
INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account)
VALUES
(v_user_id, v_company_id, m.new_number, m.new_name, m.account_class,
m.account_group, m.account_type, m.normal_balance, 'full_bas', true, false)
RETURNING id INTO v_target_id;
v_inserted_accounts := v_inserted_accounts + 1;
RAISE NOTICE ' insert account % %', m.new_number, m.new_name;
END IF;
-- Idempotent no-op
IF v_target_id = v_old_id THEN
CONTINUE;
END IF;
-- Move lines old -> target (scoped by parent journal_entries.company_id)
WITH moved AS (
UPDATE public.journal_entry_lines l
SET account_id = v_target_id, account_number = m.new_number
FROM public.journal_entries je
WHERE l.journal_entry_id = je.id
AND je.company_id = v_company_id
AND l.account_id = v_old_id
RETURNING l.id
)
SELECT COUNT(*) INTO v_line_count FROM moved;
v_updated_lines := v_updated_lines + v_line_count;
RAISE NOTICE ' remap % -> % (% lines moved)', m.old_number, m.new_number, v_line_count;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 7. (skipped) account_balances was dropped in migration
-- 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
-- ────────────────────────────────────────────────────────────────
-- ────────────────────────────────────────────────────────────────
-- 8. Delete the __mig__-prefixed source rows now that they have no lines.
-- Safety: refuses to delete if any line still references one.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT id, account_number
FROM public.chart_of_accounts
WHERE company_id = v_company_id
AND LEFT(account_number, 7) = '__mig__'
LOOP
SELECT COUNT(*) INTO v_line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = m.id AND je.company_id = v_company_id;
IF v_line_count <> 0 THEN
RAISE EXCEPTION 'Refusing to delete migrate-source account % (%) -- % lines still reference it',
m.account_number, m.id, v_line_count;
END IF;
DELETE FROM public.chart_of_accounts WHERE id = m.id AND company_id = v_company_id;
v_deleted_accounts := v_deleted_accounts + 1;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 9. Post-flight invariant check
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_after, v_credit_after
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
IF v_debit_before <> v_debit_after OR v_credit_before <> v_credit_after THEN
RAISE EXCEPTION
'INVARIANT BROKEN: grand debit/credit totals diverged. '
'Before D=% C=%, After D=% C=%. Rolling back.',
v_debit_before, v_credit_before, v_debit_after, v_credit_after;
END IF;
RAISE NOTICE '─────────────────────────────────────────';
RAISE NOTICE 'Done.';
RAISE NOTICE ' Inserted accounts : %', v_inserted_accounts;
RAISE NOTICE ' Updated lines : %', v_updated_lines;
RAISE NOTICE ' Deleted accounts : %', v_deleted_accounts;
RAISE NOTICE ' Grand totals OK : debit=% credit=%', v_debit_after, v_credit_after;
RAISE NOTICE '─────────────────────────────────────────';
END
$remap$;
-- Change the next line to ROLLBACK for a dry run, COMMIT to apply.
COMMIT;
+575
View File
@@ -0,0 +1,575 @@
#!/usr/bin/env npx tsx
/**
* One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
*
* Context: Krister imported a SIE from SPCS into gnubok. Balances are correct
* but account numbers use BAS96, which gnubok's reports interpret against
* BAS2025 -- so classification in BR/RR is wrong. Krister only has IB data
* and is travelling, giving us a clean window to fix the chart before he
* enters real vouchers.
*
* Strategy (UUID-based, no UPDATE on chart_of_accounts.account_number):
* 1. Resolve the company from auth.users by email + company_members.
* 2. Hard-check the resolved company's name contains EXPECTED_COMPANY_NAME_FRAGMENT.
* 3. Snapshot chart_of_accounts; build (oldId -> targetId) plan, inserting
* target rows where missing.
* 4. In one transaction with SET LOCAL gnubok.allow_delete='true':
* - INSERT new chart_of_accounts rows for target numbers that don't
* exist yet.
* - UPDATE journal_entry_lines.account_id/account_number from old UUIDs
* to target UUIDs.
* - DELETE old chart_of_accounts rows that no longer have lines.
* 5. Pre/post per-account-class debit/credit totals must match.
*
* Why pg directly: the immutability bypass GUC is transaction-local
* (current_setting('gnubok.allow_delete', true)). supabase-js issues
* each call on its own pooled connection, so the flag wouldn't persist.
*
* Usage:
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts # dry run
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts --commit # apply
* Flags: --email <addr> overrides the default, --company-id <uuid> picks
* one when the user owns multiple companies.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { Pool, type PoolClient } from 'pg'
import readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
// ──────────────────────────────────────────────────────────────────
// Hard-coded identity. The script aborts if these don't match.
// No --force, no override.
// ──────────────────────────────────────────────────────────────────
const EXPECTED_EMAIL = 'ks@sundlingwarn.com'
const EXPECTED_COMPANY_NAME_FRAGMENT = 'cesu' // Krister's holding company: CeSu Invest AB
const CONFIRM_PHRASE = 'remap krister'
// ──────────────────────────────────────────────────────────────────
// Mapping (BAS96 -> BAS2025), agreed with Krister 2026-05-15.
// Order does not matter -- mapping is keyed by old account UUID.
// ──────────────────────────────────────────────────────────────────
type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense'
type NormalBalance = 'debit' | 'credit'
interface Mapping {
oldNumber: string
newNumber: string
newName: string
accountClass: number
accountType: AccountType
normalBalance: NormalBalance
accountGroup: string | null
}
const MAPPINGS: ReadonlyArray<Mapping> = [
// Bank och likvida medel
{ oldNumber: '1040', newNumber: '1930', newName: 'Företagskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1050', newNumber: '1940', newName: 'Likviditetskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1051', newNumber: '1941', newName: 'Valutakonto GBP', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1052', newNumber: '1942', newName: 'Valutakonto EUR', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1053', newNumber: '1943', newName: 'Fasträntekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1055', newNumber: '1944', newName: 'Sparkonto SBAB', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
// Värdepapper och placeringar
{ oldNumber: '1056', newNumber: '1361', newName: 'Depå Carnegie', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1060', newNumber: '1385', newName: 'Kapitalförsäkring (Avanza)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1061', newNumber: '1386', newName: 'Kapitalförsäkring (Movestic)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1210', newNumber: '1510', newName: 'Kundfordringar', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '15' },
{ oldNumber: '1360', newNumber: '1760', newName: 'Upplupna ränteintäkter', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '17' },
{ oldNumber: '1623', newNumber: '1330', newName: 'Andelar i intresseföretag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1624', newNumber: '1311', newName: 'Andelar i dotterföretag — Divigen', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1625', newNumber: '1350', newName: 'Andelar i andra företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1626', newNumber: '1351', newName: 'Andelar i andra utländska företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1627', newNumber: '1352', newName: 'Andelar — Impilo', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1628', newNumber: '1353', newName: 'Andelar — Röko', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1629', newNumber: '1354', newName: 'Andelar — Altor V', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1630', newNumber: '1360', newName: 'Aktiefonder (HB Microcap)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1631', newNumber: '1355', newName: 'Andelar — Altor VI', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1632', newNumber: '1356', newName: 'Andelar — Impilo Orphan', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
// Skatt och moms (merge: 2210 + 2211 -> 1630 Skattekonto)
{ oldNumber: '2210', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2211', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2330', newNumber: '2941', newName: 'Upplupna lagstadgade soc. avgifter', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '29' },
{ oldNumber: '2480', newNumber: '2650', newName: 'Redovisningskonto för moms', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '26' },
{ oldNumber: '2510', newNumber: '2710', newName: 'Personalens källskatt', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '27' },
// Övriga skulder och reserver
{ oldNumber: '2690', newNumber: '2890', newName: 'Övriga kortfristiga skulder', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '28' },
{ oldNumber: '2864', newNumber: '2126', newName: 'Periodiseringsfond avsatt vid taxering 2026', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '21' },
// Eget kapital
{ oldNumber: '2991', newNumber: '2081', newName: 'Aktiekapital', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2992', newNumber: '2086', newName: 'Reservfond', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2997', newNumber: '2091', newName: 'Balanserat resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2999', newNumber: '2099', newName: 'Årets resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
]
// ──────────────────────────────────────────────────────────────────
// Args
// ──────────────────────────────────────────────────────────────────
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMMIT = process.argv.includes('--commit')
const EMAIL_OVERRIDE = arg('email')
const COMPANY_ID_OVERRIDE = arg('company-id')
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error(
'Missing DATABASE_URL. Set it to the Supabase Postgres connection string ' +
'(Project Settings -> Database -> Connection string -> URI, with the service password).',
)
process.exit(1)
}
const targetEmail = EMAIL_OVERRIDE ?? EXPECTED_EMAIL
// ──────────────────────────────────────────────────────────────────
// Identity resolution
// ──────────────────────────────────────────────────────────────────
interface UserRow { id: string; email: string }
interface CompanyRow { id: string; name: string; entity_type: string | null }
async function resolveUser(client: PoolClient): Promise<UserRow> {
const res = await client.query<UserRow>(
`SELECT id, email FROM auth.users WHERE LOWER(email) = LOWER($1) LIMIT 2`,
[targetEmail],
)
if (res.rows.length === 0) throw new Error(`No auth.users row for email ${targetEmail}`)
if (res.rows.length > 1) throw new Error(`Multiple auth.users rows for email ${targetEmail} -- aborting`)
return res.rows[0]
}
async function resolveCompany(client: PoolClient, userId: string): Promise<CompanyRow> {
const res = await client.query<CompanyRow>(
`SELECT c.id, c.name, c.entity_type
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = $1 AND cm.role IN ('owner', 'admin')
ORDER BY c.created_at ASC`,
[userId],
)
if (res.rows.length === 0) {
throw new Error(`User ${userId} owns/admins no companies`)
}
if (COMPANY_ID_OVERRIDE) {
const pick = res.rows.find(r => r.id === COMPANY_ID_OVERRIDE)
if (!pick) {
throw new Error(
`--company-id ${COMPANY_ID_OVERRIDE} is not among this user's owned companies: ` +
res.rows.map(r => `${r.id} (${r.name})`).join(', '),
)
}
return pick
}
if (res.rows.length > 1) {
const list = res.rows.map(r => ` ${r.id} ${r.name}`).join('\n')
throw new Error(
`User ${userId} owns/admins multiple companies -- pick one with --company-id <uuid>:\n${list}`,
)
}
return res.rows[0]
}
function assertIdentity(user: UserRow, company: CompanyRow): void {
if (user.email.toLowerCase() !== EXPECTED_EMAIL.toLowerCase()) {
throw new Error(
`Identity check FAILED: resolved user email ${user.email} != expected ${EXPECTED_EMAIL}`,
)
}
if (!company.name.toLowerCase().includes(EXPECTED_COMPANY_NAME_FRAGMENT.toLowerCase())) {
throw new Error(
`Identity check FAILED: resolved company "${company.name}" does not contain "${EXPECTED_COMPANY_NAME_FRAGMENT}"`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan construction
// ──────────────────────────────────────────────────────────────────
interface AccountSnapshotRow {
id: string
account_number: string
account_name: string
account_class: number
account_type: AccountType
normal_balance: NormalBalance
}
interface PlanItem {
mapping: Mapping
oldId: string
targetId: string | null // null means INSERT new row
targetExisted: boolean // true if a row with newNumber already existed
lineCountEstimate: number // # of journal_entry_lines that will be moved
}
async function snapshotAccounts(client: PoolClient, companyId: string): Promise<Map<string, AccountSnapshotRow>> {
const res = await client.query<AccountSnapshotRow>(
`SELECT id, account_number, account_name, account_class, account_type, normal_balance
FROM public.chart_of_accounts
WHERE company_id = $1`,
[companyId],
)
const map = new Map<string, AccountSnapshotRow>()
for (const r of res.rows) map.set(r.account_number, r)
return map
}
async function countLines(client: PoolClient, accountId: string, companyId: string): Promise<number> {
// Scope by company_id via parent journal_entries to defend against any
// accidental cross-tenant account_id reuse (should be impossible since
// UUIDs are unique, but a free defense-in-depth check).
const res = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[accountId, companyId],
)
return Number(res.rows[0]?.n ?? '0')
}
async function buildPlan(client: PoolClient, companyId: string): Promise<PlanItem[]> {
const snapshot = await snapshotAccounts(client, companyId)
const items: PlanItem[] = []
for (const m of MAPPINGS) {
const src = snapshot.get(m.oldNumber)
if (!src) continue // already migrated or never existed
const tgt = snapshot.get(m.newNumber)
const lineCount = await countLines(client, src.id, companyId)
items.push({
mapping: m,
oldId: src.id,
targetId: tgt?.id ?? null,
targetExisted: !!tgt,
lineCountEstimate: lineCount,
})
}
return items
}
// ──────────────────────────────────────────────────────────────────
// Pre/post invariants. The remap reclassifies accounts (that's the whole
// point), so per-class sums shift -- the merge 2210+2211 -> 1630 moves
// money from class 2 to class 1. The invariant that MUST hold is the
// company-wide debit/credit sum: the script never touches debit_amount
// or credit_amount on any line, so those sums must be byte-identical
// before/after. Per-class breakdown is informational.
// ──────────────────────────────────────────────────────────────────
interface GrandTotal { total_debit: string; total_credit: string }
interface ClassTotal { account_class: number; account_number: string | null; total_debit: string; total_credit: string }
async function grandTotals(client: PoolClient, companyId: string): Promise<GrandTotal> {
const res = await client.query<GrandTotal>(
`SELECT COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1`,
[companyId],
)
return res.rows[0]
}
async function classTotals(client: PoolClient, companyId: string): Promise<ClassTotal[]> {
const res = await client.query<ClassTotal>(
`SELECT coa.account_class,
NULL::text AS account_number,
COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE je.company_id = $1
GROUP BY coa.account_class
ORDER BY coa.account_class`,
[companyId],
)
return res.rows
}
function grandTotalsEqual(a: GrandTotal, b: GrandTotal): boolean {
return a.total_debit === b.total_debit && a.total_credit === b.total_credit
}
// ──────────────────────────────────────────────────────────────────
// Period lock pre-flight
// ──────────────────────────────────────────────────────────────────
async function assertNoLockedPeriods(client: PoolClient, companyId: string): Promise<void> {
const res = await client.query<{ name: string; is_closed: boolean; locked_at: string | null }>(
`SELECT name, is_closed, locked_at::text
FROM public.fiscal_periods
WHERE company_id = $1 AND (is_closed = true OR locked_at IS NOT NULL)`,
[companyId],
)
if (res.rows.length > 0) {
const list = res.rows.map(r => ` ${r.name} (closed=${r.is_closed}, locked_at=${r.locked_at ?? '—'})`).join('\n')
throw new Error(
`Refusing to run: ${res.rows.length} fiscal_periods are closed/locked. ` +
`gnubok.allow_delete does not bypass period locks. Unlock first, or escalate:\n${list}`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan execution (inside one transaction)
// ──────────────────────────────────────────────────────────────────
interface ExecResult {
inserted: number
updatedLines: number
deletedAccounts: number
newTargetIds: Map<string, string> // newNumber -> id
}
async function executePlan(
client: PoolClient,
companyId: string,
ownerUserId: string,
plan: PlanItem[],
): Promise<ExecResult> {
await client.query("SELECT set_config('gnubok.allow_delete', 'true', true)")
const result: ExecResult = { inserted: 0, updatedLines: 0, deletedAccounts: 0, newTargetIds: new Map() }
// Phase 1: INSERT all missing target accounts, dedup by newNumber.
const newNumbersNeeded = new Map<string, Mapping>()
for (const p of plan) {
if (!p.targetExisted && !newNumbersNeeded.has(p.mapping.newNumber)) {
newNumbersNeeded.set(p.mapping.newNumber, p.mapping)
}
}
for (const [, m] of newNumbersNeeded) {
const ins = await client.query<{ id: string }>(
`INSERT INTO public.chart_of_accounts (
user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', true, false)
RETURNING id`,
[ownerUserId, companyId, m.newNumber, m.newName, m.accountClass, m.accountGroup, m.accountType, m.normalBalance],
)
result.newTargetIds.set(m.newNumber, ins.rows[0].id)
result.inserted++
}
// Phase 2: resolve every plan item's final targetId.
for (const p of plan) {
if (!p.targetId) {
const inserted = result.newTargetIds.get(p.mapping.newNumber)
if (!inserted) throw new Error(`Internal: no inserted id for new account ${p.mapping.newNumber}`)
p.targetId = inserted
}
}
// Phase 3: move journal lines from old account_id -> targetId.
for (const p of plan) {
if (!p.targetId) throw new Error('Internal: missing targetId')
if (p.targetId === p.oldId) continue // idempotent no-op
// Defense-in-depth: confirm old account still belongs to this company.
const own = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[p.oldId, companyId],
)
if (Number(own.rows[0].n) !== 1) {
throw new Error(
`Pre-write check failed: old account ${p.oldId} (${p.mapping.oldNumber}) not owned by company ${companyId}`,
)
}
const upd = await client.query<{ id: string }>(
`UPDATE public.journal_entry_lines AS l
SET account_id = $1, account_number = $2
FROM public.journal_entries AS je
WHERE l.journal_entry_id = je.id
AND je.company_id = $3
AND l.account_id = $4
RETURNING l.id`,
[p.targetId, p.mapping.newNumber, companyId, p.oldId],
)
result.updatedLines += upd.rowCount ?? 0
}
// Phase 4: account_balances was dropped in migration
// 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
// Phase 5: delete old accounts that no longer have any lines.
const oldIds = Array.from(new Set(plan.map(p => p.oldId)))
for (const oldId of oldIds) {
const remaining = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[oldId, companyId],
)
if (Number(remaining.rows[0].n) !== 0) {
throw new Error(`Refusing to delete account ${oldId}: ${remaining.rows[0].n} lines still reference it`)
}
const del = await client.query(
`DELETE FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[oldId, companyId],
)
result.deletedAccounts += del.rowCount ?? 0
}
return result
}
// ──────────────────────────────────────────────────────────────────
// Pretty-print plan
// ──────────────────────────────────────────────────────────────────
function printPlan(plan: PlanItem[]): void {
console.log('\nRemap plan:')
const renames = plan.filter(p => p.mapping.oldNumber !== p.mapping.newNumber)
const merges = new Map<string, PlanItem[]>()
for (const p of plan) {
const k = p.mapping.newNumber
if (!merges.has(k)) merges.set(k, [])
merges.get(k)!.push(p)
}
const lineWidth = 6
for (const p of renames) {
const tag = p.targetExisted ? 'merge into existing' : 'rename'
console.log(
` ${p.mapping.oldNumber.padEnd(lineWidth)} ` +
`-> ${p.mapping.newNumber.padEnd(lineWidth)} ` +
`${p.mapping.newName.padEnd(48)} ` +
`(${p.lineCountEstimate} lines, ${tag})`,
)
}
const mergeTargets = [...merges.entries()].filter(([, items]) => items.length > 1)
if (mergeTargets.length > 0) {
console.log('\nMerges (multiple old -> one new):')
for (const [newNumber, items] of mergeTargets) {
console.log(` ${items.map(i => i.mapping.oldNumber).join(' + ')} -> ${newNumber}`)
}
}
const newAccounts = new Set(plan.filter(p => !p.targetExisted).map(p => p.mapping.newNumber))
if (newAccounts.size > 0) {
console.log(`\nNew chart_of_accounts rows to insert: ${newAccounts.size}`)
for (const n of [...newAccounts].sort()) {
const m = plan.find(p => p.mapping.newNumber === n)!.mapping
console.log(` ${n} ${m.newName}`)
}
}
}
// ──────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────
async function main() {
const pool = new Pool({ connectionString: databaseUrl, max: 2 })
const client = await pool.connect()
try {
console.log('─────────────────────────────────────────────────────────')
console.log('BAS96 -> BAS2025 remap (one-off, Krister Sundling)')
console.log('─────────────────────────────────────────────────────────')
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('Email:', targetEmail)
const user = await resolveUser(client)
const company = await resolveCompany(client, user.id)
assertIdentity(user, company)
console.log('User :', `${user.email} (${user.id})`)
console.log('Co. :', `${company.name} (${company.id}, ${company.entity_type ?? '?'})`)
await assertNoLockedPeriods(client, company.id)
const plan = await buildPlan(client, company.id)
if (plan.length === 0) {
console.log('\nNothing to remap -- no BAS96 source accounts found. (Already migrated?)')
return
}
printPlan(plan)
const totalLines = plan.reduce((n, p) => n + p.lineCountEstimate, 0)
console.log(`\nTotal journal_entry_lines that will move: ${totalLines}`)
const grandBefore = await grandTotals(client, company.id)
console.log(`\nPre-flight grand totals (must be unchanged by remap):`)
console.log(` total_debit=${grandBefore.total_debit} total_credit=${grandBefore.total_credit}`)
console.log(`\nPre-flight per-class totals (these WILL shift as accounts are reclassified):`)
const classBefore = await classTotals(client, company.id)
for (const r of classBefore) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
if (!COMMIT) {
console.log('\n[dry-run] No changes made. Re-run with --commit to apply.')
return
}
const rl = readline.createInterface({ input, output })
const phrase = await rl.question(
`\nAbout to apply the remap above for ${company.name} (${company.id}).\n` +
`Type '${CONFIRM_PHRASE}' to proceed: `,
)
rl.close()
if (phrase.trim().toLowerCase() !== CONFIRM_PHRASE) {
console.log('Confirmation phrase did not match. Aborting.')
process.exit(1)
}
// Single transaction: bypass flag is transaction-local.
await client.query('BEGIN')
let result: ExecResult
try {
result = await executePlan(client, company.id, user.id, plan)
const grandAfter = await grandTotals(client, company.id)
console.log(`\nPost-flight grand totals (still inside transaction):`)
console.log(` total_debit=${grandAfter.total_debit} total_credit=${grandAfter.total_credit}`)
if (!grandTotalsEqual(grandBefore, grandAfter)) {
throw new Error(
`INVARIANT BROKEN: grand debit/credit sums diverge after remap. ` +
`Before debit=${grandBefore.total_debit} credit=${grandBefore.total_credit}; ` +
`After debit=${grandAfter.total_debit} credit=${grandAfter.total_credit}. Rolling back.`,
)
}
const classAfter = await classTotals(client, company.id)
console.log('\nPost-flight per-class totals (reclassified -- shifts expected):')
for (const r of classAfter) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
}
console.log('\n─────────────────────────────────────────────────────────')
console.log('Done.')
console.log('─────────────────────────────────────────────────────────')
console.log(`Inserted accounts : ${result.inserted}`)
console.log(`Updated lines : ${result.updatedLines}`)
console.log(`Deleted accounts : ${result.deletedAccounts}`)
console.log('\nNext: open the balance sheet and trial balance in gnubok as Krister to confirm classification.')
} finally {
client.release()
await pool.end()
}
}
main().catch(err => {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
process.exit(1)
})
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env npx tsx
/**
* Seed / sync the agent_atom_registry table from SKILL.md files on disk.
*
* DEV / MANUAL path only. It writes the registry directly with the service role,
* which needs DB access — so it is NOT part of prebuild. Production gets skill
* bodies via the generated seed migration (scripts/generate-skill-bodies.ts),
* which ships in supabase/migrations and runs on deploy.
*
* Discovery + frontmatter parsing live in scripts/lib/atom-discovery.ts so this
* and the generator can never drift on which skills count as atoms.
*
* Usage:
* npx tsx scripts/seed-agent-atom-registry.ts # apply
* npx tsx scripts/seed-agent-atom-registry.ts --dry # print plan only
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { fileURLToPath } from 'node:url'
import { dirname, relative, join } from 'node:path'
import { discoverAtoms } from './lib/atom-discovery'
const __filename = fileURLToPath(import.meta.url)
const ROOT = dirname(dirname(__filename))
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(supabaseUrl, serviceRoleKey)
const dryRun = process.argv.includes('--dry')
async function main() {
console.log(`Scanning ${relative(process.cwd(), join(ROOT, '.claude', 'skills'))}`)
const atoms = await discoverAtoms(ROOT)
if (atoms.length === 0) {
console.log('No atoms discovered.')
return
}
// Map discovery → registry rows. We intentionally OMIT mcp_exposed so that a
// manual kill-switch flip survives a re-seed (the column default applies on
// first insert; ON CONFLICT leaves it untouched). `body` is inlined so runtime
// reads from the DB rather than disk.
const rows = atoms.map((a) => ({
id: a.id,
tier: a.tier,
title: a.title,
description: a.description,
sni_prefixes: a.sni_prefixes,
trigger_signals: a.trigger_signals,
estimated_tokens: a.estimated_tokens,
body_path: a.body_path,
body: a.body,
parent_atom_id: a.parent_atom_id,
version: a.frontmatter_version,
is_active: true,
schema_version: a.schema_version,
}))
console.log(`\nFound ${rows.length} atoms:\n`)
for (const r of rows) {
console.log(` [${r.tier.padEnd(10)}] ${r.id.padEnd(40)} ${r.estimated_tokens.toString().padStart(6)} tokens`)
}
if (dryRun) {
console.log('\n--dry: skipping write.')
return
}
console.log('\nUpserting...')
const { error } = await supabase.from('agent_atom_registry').upsert(rows, { onConflict: 'id' })
if (error) {
console.error('Upsert failed:', error)
process.exit(1)
}
console.log(`Upserted ${rows.length} rows into agent_atom_registry.`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env npx tsx
/**
* Smoke test: confirm the MCP server extension registers its tools into the
* core agentToolRegistry when ensureInitialized() runs. Run after any change
* that touches the extension loader or the agent tool surface.
*
* Usage: npx tsx scripts/smoke-agent-tools.ts
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { ensureInitialized } from '../lib/init'
import { agentToolRegistry } from '../lib/agent/tools/registry'
ensureInitialized()
const all = agentToolRegistry.getAll()
console.log(`agentToolRegistry has ${all.length} tools.`)
// Spot-check the tools the V1 intents need.
const expected = [
'gnubok_search_tools',
'gnubok_list_skills',
'gnubok_load_skill',
'gnubok_categorize_transaction',
'gnubok_get_counterparty_templates',
'gnubok_match_transaction_to_invoice',
'gnubok_get_document_content',
]
let missing = 0
for (const name of expected) {
const found = agentToolRegistry.has(name)
console.log(` ${found ? '✓' : '✗'} ${name}`)
if (!found) missing++
}
if (missing > 0) {
console.error(`\n${missing} expected tool(s) missing.`)
process.exit(1)
}
console.log('\nAll expected tools registered.')
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env npx tsx
/**
* Smoke test: send a 1-token request to Bedrock with both the Opus and
* Sonnet model ids the agent uses. Confirms AWS creds + region work and
* the models are enabled on the account before we exercise the full chat
* loop with real user data.
*
* Usage: npx tsx scripts/smoke-bedrock.ts
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { getAnthropic, OPUS_MODEL, SONNET_MODEL } from '../lib/agent/composer/client'
async function ping(model: string): Promise<void> {
const client = getAnthropic()
const start = Date.now()
try {
const resp = await client.messages.create({
model,
max_tokens: 10,
messages: [{ role: 'user', content: 'Säg "hej" på svenska.' }],
})
const text = resp.content
.filter((b) => b.type === 'text')
.map((b) => (b as { type: 'text'; text: string }).text)
.join('')
console.log(` ✓ ${model} — ${Date.now() - start}ms — "${text.trim()}"`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(` ✗ ${model} — ${message}`)
process.exitCode = 1
}
}
async function main() {
console.log(`Region: ${process.env.AWS_REGION || 'eu-north-1'}`)
console.log('Pinging Bedrock for both agent models…\n')
await ping(SONNET_MODEL)
await ping(OPUS_MODEL)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+103
View File
@@ -0,0 +1,103 @@
-- Verification queries for the BAS96 -> BAS2025 remap on CeSu Invest AB.
-- Run each block separately in the Supabase SQL editor, or all together
-- and click through the result tabs.
-- ────────────────────────────────────────────────────────────────
-- 1. No leftover __mig__ rows? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mig_rows_remaining
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND LEFT(coa.account_number, 7) = '__mig__';
-- ────────────────────────────────────────────────────────────────
-- 2. No leftover BAS96 numbers in chart_of_accounts? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT coa.account_number, coa.account_name
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- (Note: 1360 and 1630 are intentionally OMITTED here because they exist
-- as legitimate BAS2025 targets after the remap.)
-- ────────────────────────────────────────────────────────────────
-- 3. No leftover BAS96 numbers in journal_entry_lines? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT l.account_number, COUNT(*) AS line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB'
AND l.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
)
GROUP BY l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 4. account_id <-> account_number consistency on every line.
-- Should return 0 -- every line's account_number must match the
-- chart_of_accounts row it points to.
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mismatched_lines
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number <> l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 5. Grand totals -- debits = credits and look plausible.
-- ────────────────────────────────────────────────────────────────
SELECT
SUM(l.debit_amount) AS total_debit,
SUM(l.credit_amount) AS total_credit,
SUM(l.debit_amount) - SUM(l.credit_amount) AS debit_minus_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB';
-- ────────────────────────────────────────────────────────────────
-- 6. Per-account breakdown (post-remap) — spot-check the BAS2025 numbers.
-- ────────────────────────────────────────────────────────────────
SELECT
coa.account_number,
coa.account_name,
COALESCE(SUM(l.debit_amount), 0) AS debit_sum,
COALESCE(SUM(l.credit_amount), 0) AS credit_sum,
COUNT(l.id) AS line_count
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
LEFT JOIN public.journal_entry_lines l ON l.account_id = coa.id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1311','1330','1350','1351','1352','1353','1354','1355','1356',
'1360','1361','1385','1386','1510','1630','1760',
'1930','1940','1941','1942','1943','1944',
'2081','2086','2091','2099','2126','2650','2710','2890','2941'
)
GROUP BY coa.account_number, coa.account_name
ORDER BY coa.account_number;
-- ────────────────────────────────────────────────────────────────
-- 7. Summary headcount: chart_of_accounts for CeSu Invest AB.
-- ────────────────────────────────────────────────────────────────
SELECT
COUNT(*) AS total_accounts,
COUNT(*) FILTER (WHERE account_class = 1) AS class_1_assets,
COUNT(*) FILTER (WHERE account_class = 2) AS class_2_eq_liab,
COUNT(*) FILTER (WHERE LEFT(account_number, 7) = '__mig__') AS migration_leftovers
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB';