ccdfed5fea
* feat: voucher linking, recovery ops, and salary overrides Adds reversible/correction-style write paths that customers and agents have been asking for, plus per-run salary employee overrides. Invoice → voucher linking - POST /api/invoices/[id]/link-to-voucher and GET /api/invoices/[id]/voucher-candidates - lib/invoices/voucher-matching.ts with full + pg test coverage - LinkVoucherPicker UI in PaymentBookingDialog - pending_operations.operation_type expanded with link_invoice_voucher (medium risk) and a (journal_entry_id, invoice_id) unique guard - MCP: gnubok_find_voucher_candidates_for_invoice and gnubok_link_invoice_to_voucher tools SIE undo - POST /api/import/sie/[id]/undo + undo_sie_import RPC - sie_imports.status gains 'undone' - ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED Edit-recreate journal entries - POST /api/bookkeeping/journal-entries/[id]/edit-recreate - Bookkeeping detail page wires it into the existing edit flow Delete-last-voucher clears IB link - Trigger + pg test ensure deleting the last voucher of a period nulls the opening_balance_journal_entry_id link so a re-import lands cleanly Salary employee overrides - salary_run_employees gains per-run override fields + migration - lib/salary/effective-values.ts centralises resolved values; all payslip, payment, AGI, KU, and booking routes read through it - SalaryOverridePanel on the employee detail page Account classifier - lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it - backfill-import-accounts script updated Misc - toast: minor styling tweak - AGI generate-declaration: respect effective values - structured-errors: new LINK_INVOICE_VOUCHER namespace Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add link_invoice_voucher operation type to pending_operations * feat: refactor salary run calculations and update error handling for SIE imports * fix: PR review feedback on voucher linking and SIE recovery pg-real (blocking): - tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed UPDATE — journal_entries has no posted_at column. - lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher before closing the fiscal period so enforce_period_lock doesn't block the INSERT during setup. voucher-matching error codes and rollback: - Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice UPDATE / payment INSERT failures. Previously these returned LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher auto-rejects on transient DB errors. - Log rollback failures explicitly so an invoice left in a half-linked state (advanced status, no payment row) surfaces for manual reconciliation instead of disappearing silently. resyncNextPeriodOpeningBalance ordering: - Create the new IB first, relink the period FK, then storno the old IB. Previously the storno ran first; if createJournalEntry failed the next period was left with a reversed IB and nothing to replace it, and executeSIEImport swallows the error as a non-fatal warning. replace_period_opening_balance_link: - Tighten role check to owner/admin (was owner/admin/member). Matches delete_last_voucher and undo_sie_import. Data minimisation: - /api/invoices/[id]/voucher-candidates and the matching MCP tools now project only the invoice and customer fields the matcher reads, instead of returning the full customer row. Schema bounds: - SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to catch typos before they reach the ledger or AGI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): supply user_id when seeding voucher_sequences voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in 20260330130000). The previous test seed only set company_id / fiscal_period_id / voucher_series, which made the seed fail with a constraint violation on the latest pg-real run. Pass the same userId used elsewhere in the seed helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): scope delete-last-voucher RPC assertions inside the tx withUserContext always ROLLBACKs, so any DELETE the RPC performs is discarded when the callback returns. The previous test then queried journal_entries via a fresh getPool() connection that only saw the pre-RPC committed seed state — hence "expected '1' to be '0'". Move every post-RPC assertion (entry count, period FK clear, opening_balances_set flip, audit log entry, sie_imports clear) inside the same withUserContext callback so they observe the uncommitted state before ROLLBACK fires. Also fix the sie_imports INSERT: the column is `filename`, not `file_name`, and `sie_type` is NOT NULL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): assert against the IB-marker audit row directly DELETE on journal_entries fires two audit_log writes: the generic write_audit_log() trigger row ("Deleted journal_entries record") and the delete_last_voucher RPC's explicit "(was period IB)" entry. Both land at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1 returned the trigger row non-deterministically in CI. Switch to a presence check with a LIKE filter on the IB marker so the test verifies what it actually cares about — that the RPC's IB-aware audit row exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(db): set company_id on delete_last_voucher audit_log rows 20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly into audit_log without setting company_id. audit_log's SELECT policy filters company_id IN user_company_ids(), so those rows landed with company_id=NULL and were invisible to every reader — only the generic write_audit_log() trigger row remained visible. That broke BFL audit- trail intent: the "(was period IB)" provenance row was never readable. Republish delete_last_voucher with p_company_id populated on both audit_log INSERTs (draft path and posted path). Behavior is otherwise unchanged; the pg-real test for the IB-clear flow now sees the RPC-written marker row as expected. 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>
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
|
|
// Normalize CRLF → LF so frontmatter parsing and body inlining are
|
|
// platform-independent (Windows checkouts ship .md files with CRLF unless
|
|
// .gitattributes forces LF, which it doesn't for *.md).
|
|
function normalizeLineEndings(text: string): string {
|
|
return text.replace(/\r\n/g, '\n')
|
|
}
|
|
|
|
// ── 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 = normalizeLineEndings(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 = normalizeLineEndings(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'
|
|
}
|