feat(mcp): P2 hygiene — honest category suggestions, skill-reference lint, cadence copy (#882)

* feat(mcp): counterparty-tied category suggestions + no_signal (P2-1)

suggest_categories padded every transaction with a company-wide
category-frequency fallback at <=0.5 confidence — an identical four-way
spread on 20+/24 items that agents correctly reported as pure noise
(agent.feedback). Real signal came from memory atoms and query_journal.

- History is now counterparty-keyed: buildMerchantHistory groups past
  categorized transactions by normalized merchant; the engine only
  surfaces history for THIS transaction's merchant, with provenance
  ('Bokförd N gånger tidigare för denna motpart') and occurrence-scaled
  confidence (0.56 at 1x, capped 0.85). No global padding — an empty
  list is the honest answer.
- The MCP tool returns no_signal_transaction_ids for transactions where
  NO source matched, steering agents to investigate (query_journal)
  instead of pattern-matching on unrelated rows.
- Both callers (REST suggest-categories route + MCP tool) share the new
  helpers, so web UI and agents improve together.

Part of dev_docs/mcp_optimization_plan.md (P2-1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): dangling-reference validation in skills:check + fix 10 dangling links (P2-2)

skills:generate/check now fail when an atom SKILL.md links a
references/*.md that does not exist on disk — a dangling pointer ships
a 404 to every agent that follows it (the weekly-booking-check
incident, agent.feedback).

The validator immediately caught 10 live dangling links in 4 atoms,
three distinct flavors:
- filename typo: swedish-asset-accounting/references/depreciaton.md
  renamed to depreciation.md (the link was right, the file misspelled)
- link mismatch: swedish-e-invoicing linked market-providers-pricing.md;
  the file is market-provider-pricing.md (link fixed)
- unauthored plans: single-shareholder-ab-fmb TODOs and reklambyra's
  'planerad utbyggnad' section used resolvable references/ paths for
  files that were never written — rephrased as plans without paths

Seed migration regenerated (4 atoms bumped, renamed reference child).

Part of dev_docs/mcp_optimization_plan.md (P2-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(events): align agent-feedback review cadence copy (P2-4)

gnubok_feedback replies 'we aggregate signal weekly'; the event-log
handler comment said quarterly. One of them was lying — weekly wins
(the mcp_optimization_plan triage is the living example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 11:48:29 +02:00
committed by GitHub
parent b27f6cdb04
commit 678f2ccffd
12 changed files with 17731 additions and 46 deletions
@@ -166,11 +166,11 @@ Konton som **inte** används för denna vertikal trots vanliga missförstånd: 1
## Overflow-filer (planerad utbyggnad)
Token-budget för denna SKILL.md är hållen under 15k. Föreslagna overflow-filer för djupare detaljer:
Token-budget för denna SKILL.md är hållen under 15k. Föreslagna overflow-ämnen för djupare detaljer (inga av dessa referensfiler är skapade ännu):
- **references/principal-vs-agent-mediainkop.md** — Beslutsmatris med IFRS 15 B37-indikatorer, K3 23.2-tillämpning, exempelposteringar för principal/agent/utlägg, mall för principal-/agent-analys per kund.
- **references/a-sink-influencer-utlandsbetalningar.md** — A-SINK vs SINK-beslut, AGI-rapportering, skatteavtal (OECD art. 17), formulärflöden (SKV 520, SINK-ansökan), KU70 till utländska mottagare.
- **references/sponsring-motprestationsbedomning.md** — Mall för sponsoravtal med marknadsvärdering per komponent, checklista för dokumentation, fullständig praxisgenomgång (RÅ 1976 ref. 127 I/II, RÅ 2000 ref. 31 I/II, HFD 2014 ref. 62, HFD 2018 ref. 55, KR Göteborg 2024-12), SOU 2026:5-bevakning.
- **references/url-upphovsratt-byraavtal.md** — Branschmallar för upphovsrätts-överlåtelse i byrå-kundavtal, NJA 2010 s. 559-tolkning, DSM-direktivets §§ i URL (15 a15 c, 29, 29 de, 48 bd, 52 iu), AD 2002 nr 87.
- **references/ai-content-och-ai-act-byraer.md** — EU AI Act-tidslinje (art. 4, art. 50), TDM-opt-out under 15 a § URL, AI-klausulbibliotek för byråavtal, bokföring av AI-prenumerationer och API-konsumtion (5420 vs 6540), C-250/25-bevakning.
- **references/pagaende-arbeten-konsultrorelse.md** — K2 6.136.24 och K3 23.1823.37 jämförelse, IL 17 kap 2332 §§, RÅ 2006 ref. 28 frikoppling, Srf U 14 + U 15, BFNAR 2025:1-kvittningsförbud, exempelflöden för retainer, kampanj fast pris och blandade uppdrag.
- **principal-vs-agent-mediainkop** — Beslutsmatris med IFRS 15 B37-indikatorer, K3 23.2-tillämpning, exempelposteringar för principal/agent/utlägg, mall för principal-/agent-analys per kund.
- **a-sink-influencer-utlandsbetalningar** — A-SINK vs SINK-beslut, AGI-rapportering, skatteavtal (OECD art. 17), formulärflöden (SKV 520, SINK-ansökan), KU70 till utländska mottagare.
- **sponsring-motprestationsbedomning** — Mall för sponsoravtal med marknadsvärdering per komponent, checklista för dokumentation, fullständig praxisgenomgång (RÅ 1976 ref. 127 I/II, RÅ 2000 ref. 31 I/II, HFD 2014 ref. 62, HFD 2018 ref. 55, KR Göteborg 2024-12), SOU 2026:5-bevakning.
- **url-upphovsratt-byraavtal** — Branschmallar för upphovsrätts-överlåtelse i byrå-kundavtal, NJA 2010 s. 559-tolkning, DSM-direktivets §§ i URL (15 a15 c, 29, 29 de, 48 bd, 52 iu), AD 2002 nr 87.
- **ai-content-och-ai-act-byraer** — EU AI Act-tidslinje (art. 4, art. 50), TDM-opt-out under 15 a § URL, AI-klausulbibliotek för byråavtal, bokföring av AI-prenumerationer och API-konsumtion (5420 vs 6540), C-250/25-bevakning.
- **pagaende-arbeten-konsultrorelse** — K2 6.136.24 och K3 23.1823.37 jämförelse, IL 17 kap 2332 §§, RÅ 2006 ref. 28 frikoppling, Srf U 14 + U 15, BFNAR 2025:1-kvittningsförbud, exempelflöden för retainer, kampanj fast pris och blandade uppdrag.
@@ -74,5 +74,5 @@ This modifier composes with:
## References
- See `references/k10-walkthrough.md` for a step-by-step K10 example (TODO — user to author).
- See `references/loneunderlag-2026.md` for the 2026 reformed parameters (TODO — user to author).
- Planned reference (not yet authored): a step-by-step K10 walkthrough example.
- Planned reference (not yet authored): the 2026 reformed löneunderlag parameters.
+1 -1
View File
@@ -20,7 +20,7 @@ Use the table below to decide which reference file(s) to read. Multiple files of
| UBL 2.1 invoice structure, EN 16931 BT-* business terms, mandatory header (CustomizationID, ProfileID, InvoiceTypeCode), UNCL5305 VAT category codes (S/Z/E/AE/K/G/O), calculation rules (BR-CO-13/15/17, BR-S-08 etc.), document type identifiers, BIS suite (Billing, Self-Billing, Catalogue, Despatch Advice, Invoice Response, MLR, MLS), Peppol BIS 4.0 / PINT convergence | `references/peppol-bis-billing.md` |
| Peppol 4-corner architecture, AS4 v2.0 transport, SMP/SML lookup with NAPTR/SHA-256 algorithm, SBDH v1.2 with C1 country code, PKI G3 certificates, becoming a certified Access Point or Service Provider, OpenPeppol membership tiers and pricing, Peppol Testbed conformance | `references/peppol-network.md` |
| Sweden-specific CIUS rules (SE-R-005 "Godkänd för F-skatt", SE-R-006 VAT rate restriction, SE-R-008/009 Bankgiro, SE-R-010 Plusgiro, SE-R-011 PaymentMeansCode 30, SE-R-013 Luhn orgnr), Swedish VAT (25/12/6/0%) encoding, OCR reference (BT-83), Bankgiro/Plusgiro PaymentMeans encoding, ROT/RUT and grön teknik handling, BAS-kontoplan postings for AR/AP, faktureringsmetoden vs kontantmetoden, multi-currency with TaxCurrencyCode, BT-10 BuyerReference per-buyer formats, Peppol identifier schemes (0007/0088/0192/0184/0037/0208/0204), F-skatt registration | `references/swedish-cius-and-specifics.md` |
| Choosing between Pagero/InExchange/Crediflow/Visma Autoinvoice/Maventa/Qvalia/Tietoevry/Basware/OpusCapita/Hogia/Ropo Capital/Storecove, market shares, pricing benchmarks (per-document, monthly minimums), DIGG Peppol traffic statistics, how Fortnox/Bokio/SpeedLedger/Björn Lundén white-label their Peppol layer, API capabilities of major providers | `references/market-providers-pricing.md` |
| Choosing between Pagero/InExchange/Crediflow/Visma Autoinvoice/Maventa/Qvalia/Tietoevry/Basware/OpusCapita/Hogia/Ropo Capital/Storecove, market shares, pricing benchmarks (per-document, monthly minimums), DIGG Peppol traffic statistics, how Fortnox/Bokio/SpeedLedger/Björn Lundén white-label their Peppol layer, API capabilities of major providers | `references/market-provider-pricing.md` |
| Consumer e-faktura: Bankgirot e-faktura privat, EFA / e-giro format, Anslutningsärende/Anmälningsärende, bank participants, Kivra digital mailbox (volumes, pricing, ownership, Tink/Swish integration), Min Myndighetspost, distinction between consumer rails and Peppol | `references/consumer-and-b2c.md` |
| Comparing Sweden to Belgium (2026 decentralised Peppol mandate), France (PA/PPF 2026-2027), Germany (XRechnung phased 2025-2028), Italy (SDI clearance), Poland (KSeF Feb/Apr 2026), Romania (e-Factura), Norway (proposed 2028), Spain, ViDA cross-border 1 July 2030 mandate, ViDA 2035 alignment deadline for legacy CTC regimes, predicting Sweden's likely model | `references/european-mandates.md` |
| Implementing e-invoicing in software: open-source libraries (Oxalis-NG, Oxalis-AS4, Helger phase4 / phoss-smp / peppol-commons / phive / ph-ubl), test environments, common rejection patterns (BR-CO-15 rounding, BT-10 missing, encoding bugs), build-vs-buy economics, when to use Storecove vs own AP, validation stack in CI, the recommended Accounted phased plan, strategic positioning vs Crediflow/InExchange-dependent incumbents | `references/implementation-guide.md` |
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType } from '@/types'
@@ -48,22 +48,19 @@ export async function POST(request: Request) {
.eq('is_active', true)
.order('priority', { ascending: false })
// Build category history from user's past categorized transactions
// Counterparty-keyed history from past categorized transactions — the
// suggestion engine only surfaces history tied to the SAME merchant
// (global frequency padding produced identical low-confidence spreads).
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.select('category, merchant_name')
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const categoryHistory: Record<string, number> = {}
if (historicalTxns) {
for (const tx of historicalTxns) {
categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
}
}
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
// Fetch entity type for template matching
const { data: settings } = await supabase
@@ -84,7 +81,7 @@ export async function POST(request: Request) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
categoryHistory
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
+27 -10
View File
@@ -78,7 +78,7 @@ import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/book
import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getSuggestedCategories } from '@/lib/transactions/category-suggestions'
import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from '@/lib/transactions/category-suggestions'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { renderToBuffer } from '@react-pdf/renderer'
@@ -4435,7 +4435,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_suggest_categories',
title: 'Suggest Transaction Categories',
description: 'Suggest categories for uncategorized transactions using mapping rules, pattern matching, history, and counterparty templates. Up to 20 transactions per call.',
description: 'Suggest categories for uncategorized transactions using mapping rules, patterns, counterparty history and templates. Up to 20 per call. no_signal_transaction_ids = nothing matched; investigate via gnubok_query_journal instead of guessing.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4454,8 +4454,13 @@ export const tools: McpTool[] = [
properties: {
suggestions: { type: 'object' },
counterparty_matches: { type: 'object' },
no_signal_transaction_ids: {
type: 'array',
items: { type: 'string' },
description: 'Transactions where no source (rule, pattern, counterparty history, template) matched. An honest empty — do not infer categories from the other rows; investigate the counterparty (e.g. gnubok_query_journal) instead.',
},
},
required: ['suggestions', 'counterparty_matches'],
required: ['suggestions', 'counterparty_matches', 'no_signal_transaction_ids'],
},
annotations: {
readOnlyHint: true,
@@ -4487,19 +4492,19 @@ export const tools: McpTool[] = [
.order('priority', { ascending: false })
// Build category history from past categorizations
// Counterparty-keyed history: the engine only surfaces history tied to
// the SAME merchant — global frequency padding produced the identical
// ~0.5 four-way spread agents reported as pure noise (P2-1).
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.select('category, merchant_name')
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const categoryHistory: Record<string, number> = {}
for (const tx of historicalTxns || []) {
if (tx.category) categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
}
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
// Batch counterparty template matching
const counterpartyMatches = await findCounterpartyTemplatesBatch(
@@ -4512,7 +4517,8 @@ export const tools: McpTool[] = [
for (const tx of transactions) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction, mappingRules ?? [], categoryHistory
tx as Transaction, mappingRules ?? [],
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
)
const cpMatch = counterpartyMatches.get(tx.id)
@@ -4529,7 +4535,18 @@ export const tools: McpTool[] = [
}
}
return { suggestions, counterparty_matches: counterpartyResult }
// Honest absence beats fabricated confidence: mark transactions where
// NO source produced a suggestion so agents investigate instead of
// pattern-matching on unrelated rows (P2-1).
const noSignal = transactions
.filter((tx) => (suggestions[tx.id]?.length ?? 0) === 0 && !counterpartyResult[tx.id])
.map((tx) => tx.id)
return {
suggestions,
counterparty_matches: counterpartyResult,
no_signal_transaction_ids: noSignal,
}
},
},
+2 -1
View File
@@ -53,7 +53,8 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
// whether a loaded atom helps or hurts.
'mcp.skill_loaded',
// Agent self-reported feedback — surfaces "this tool was missing", "this
// description was wrong", etc. Quarterly review → roadmap.
// description was wrong", etc. Reviewed weekly (matches the gnubok_feedback
// reply copy); triage → dev_docs/mcp_optimization_plan.md.
'agent.feedback',
// Bank connection consent lifecycle — required audit trail per ASVS V16
// and GDPR Art.30 (records of processing) for PSD2 consent decisions.
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
buildMerchantHistory,
getSuggestedCategories,
merchantHistoryFor,
} from '../category-suggestions'
import type { Transaction } from '@/types'
/**
* P2-1 (mcp_optimization_plan): suggestions must carry signal tied to THIS
* transaction. The old company-wide frequency fallback emitted an identical
* ~0.5 four-way spread on every transaction — noise agents correctly
* distrusted. History is now counterparty-keyed with provenance; when no
* source matches, the honest answer is an empty list.
*/
const tx = (overrides: Partial<Transaction> = {}): Transaction =>
({
id: 'tx-1',
company_id: 'company-1',
date: '2026-06-01',
description: 'KORTKÖP POLARN O PYRET',
amount: -500,
currency: 'SEK',
merchant_name: 'Polarn O. Pyret',
...overrides,
}) as Transaction
describe('buildMerchantHistory / merchantHistoryFor', () => {
const rows = [
{ merchant_name: 'Polarn O. Pyret', category: 'expense_office' },
{ merchant_name: 'polarn o. pyret', category: 'expense_office' },
{ merchant_name: 'Polarn O. Pyret', category: 'expense_consumables' },
{ merchant_name: 'DNB Bank', category: 'expense_bank_fees' },
{ merchant_name: null, category: 'expense_other' },
{ merchant_name: 'Ghost AB', category: null },
]
it('groups case-insensitively by merchant and ignores null merchants/categories', () => {
const map = buildMerchantHistory(rows)
expect(merchantHistoryFor(map, 'POLARN O. PYRET')).toEqual({
expense_office: 2,
expense_consumables: 1,
})
expect(merchantHistoryFor(map, 'DNB Bank')).toEqual({ expense_bank_fees: 1 })
expect(merchantHistoryFor(map, 'Unknown Vendor')).toEqual({})
expect(merchantHistoryFor(map, null)).toEqual({})
})
})
describe('getSuggestedCategories — counterparty history', () => {
it('returns an empty list (not a fabricated spread) when nothing matches', () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Helt Okänd Motpart', description: 'XYZ 123' }),
[],
{},
)
expect(result).toEqual([])
})
it('surfaces merchant history with provenance and occurrence-scaled confidence', () => {
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
expense_office: 3,
expense_consumables: 1,
})
expect(result.length).toBe(2)
expect(result[0]).toMatchObject({
category: 'expense_office',
source: 'history',
confidence: Math.min(0.85, 0.5 + 3 * 0.06),
})
expect(result[0].match_reason).toMatch(/3 gånger tidigare för denna motpart/)
expect(result[1].category).toBe('expense_consumables')
expect(result[1].match_reason).toMatch(/1 gång tidigare/)
})
it('caps history confidence at 0.85', () => {
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
expense_office: 50,
})
expect(result[0].confidence).toBe(0.85)
})
it('filters history to the transaction direction', () => {
const result = getSuggestedCategories(
tx({ amount: 1000, description: 'XYZ 123' }), // income direction
[],
{ expense_office: 5 },
)
expect(result).toEqual([])
})
})
+48 -6
View File
@@ -34,13 +34,51 @@ const CATEGORY_LABELS: Record<string, string> = {
}
/**
* Get suggested categories for a transaction
* Combines mapping rules, pattern matching, and user history
* Counterparty-keyed history: normalized merchant name -> category counts.
* Built once per request from the caller's recent categorized transactions.
*/
export type MerchantHistoryMap = Map<string, Record<string, number>>
function normalizeMerchantKey(name: string | null | undefined): string {
return (name ?? '').toLowerCase().trim()
}
export function buildMerchantHistory(
rows: Array<{ merchant_name: string | null; category: string | null }>,
): MerchantHistoryMap {
const map: MerchantHistoryMap = new Map()
for (const row of rows) {
const key = normalizeMerchantKey(row.merchant_name)
if (!key || !row.category) continue
const bucket = map.get(key) ?? {}
bucket[row.category] = (bucket[row.category] || 0) + 1
map.set(key, bucket)
}
return map
}
export function merchantHistoryFor(
map: MerchantHistoryMap,
merchantName: string | null | undefined,
): Record<string, number> {
const key = normalizeMerchantKey(merchantName)
return key ? (map.get(key) ?? {}) : {}
}
/**
* Get suggested categories for a transaction.
* Combines mapping rules, pattern matching, and counterparty history.
*
* merchantHistory is the category history FOR THIS TRANSACTION'S counterparty
* (see buildMerchantHistory/merchantHistoryFor) — never a company-wide
* frequency map. Global padding produced identical ~0.5 four-way spreads on
* every transaction, which agents correctly read as no signal
* (mcp_optimization_plan P2-1); an empty result is the honest answer.
*/
export function getSuggestedCategories(
transaction: Transaction,
mappingRules: MappingRule[],
categoryHistory: Record<string, number>
merchantHistory: Record<string, number>
): SuggestedCategory[] {
const suggestions: SuggestedCategory[] = []
const seen = new Set<string>()
@@ -104,8 +142,9 @@ export function getSuggestedCategories(
})
}
// 3. User history (most commonly used categories)
const historyEntries = Object.entries(categoryHistory)
// 3. Counterparty history — categories this merchant was booked as before.
// Confidence scales with occurrences and the reason carries provenance.
const historyEntries = Object.entries(merchantHistory)
.sort(([, a], [, b]) => b - a)
.filter(([cat]) => !seen.has(cat))
@@ -120,8 +159,11 @@ export function getSuggestedCategories(
category: cat as TransactionCategory,
label: CATEGORY_LABELS[cat] || cat,
account: getExpenseAccountForCategory(cat as TransactionCategory),
confidence: Math.min(0.5, count / 20),
// 1 previous booking -> 0.56, capped at 0.85 (history informs, a human
// or counterparty template confirms).
confidence: Math.min(0.85, 0.5 + count * 0.06),
source: 'history',
match_reason: `Bokförd ${count} gång${count === 1 ? '' : 'er'} tidigare för denna motpart`,
})
}
+9 -9
View File
@@ -24,14 +24,14 @@
"version": 3
},
"horizontal/swedish-asset-accounting": {
"hash": "f7c79332c0fbc24f391aac3aeb1a01327bb803d11864dd68e52e37a6df2e8588",
"version": 2
"hash": "8f2e88b7d1b1d1f309c8533e8d05b8d0504a1c21d52a4d3257127eb25a52767f",
"version": 3
},
"horizontal/swedish-asset-accounting/accounts-and-registry": {
"hash": "b33d044b36d290aebb3677ae82020e0890aff3664070f2d1ec510f38b63793f4",
"version": 1
},
"horizontal/swedish-asset-accounting/depreciaton": {
"horizontal/swedish-asset-accounting/depreciation": {
"hash": "f764c19f11c8613ba5014ca2767ddb1ae2926928e04c3abb43444ba27c286aec",
"version": 1
},
@@ -40,8 +40,8 @@
"version": 1
},
"horizontal/swedish-e-invoicing": {
"hash": "bbcb94de81be7663384085104811a801ed6a12aa37cb2a34eefc6d0ba41267e6",
"version": 4
"hash": "0483ca6d461699a9651ab6e5b7484dfcc2442f4746f3a0fada4dd97269942496",
"version": 5
},
"horizontal/swedish-e-invoicing/consumer-and-b2c": {
"hash": "886cf35d3663b88d3b5bc8ca1766e14f073eb3de1f9e75aa55479582e638f32c",
@@ -328,8 +328,8 @@
"version": 1
},
"modifier/single-shareholder-ab-fmb": {
"hash": "d79be2b50e33774453b8d6baaec59b79fd4bb7771550cec00f7edd7afedfb30a",
"version": 2
"hash": "f2398ee0bbf5e76e796a986f89b2f56e518bcc8830997fec197fac563e20960b",
"version": 3
},
"vertical/bygg-hantverk": {
"hash": "9670e3515b607176efbdab05be8fc0d76f531ddb1fee8b2d70cbf78802ef571a",
@@ -424,8 +424,8 @@
"version": 1
},
"vertical/reklambyra-marknadsforing": {
"hash": "6d67fd8ab8c1032168d072025241a0157d68f13f87b92be5e3a84e554265afee",
"version": 1
"hash": "4eb3a4770caa13d16d693e4ec92aa63c5097e223913a7adc08528051e41b72ee",
"version": 2
},
"vertical/software-saas-ai": {
"hash": "63fd0c3f1ddfde9978cd072ee5c76f89900acc5807d5085fa626e611b28a81cd",
+24
View File
@@ -160,6 +160,30 @@ async function main() {
process.exit(1)
}
// Dangling references/ links (mcp_optimization_plan P2-2): an atom body
// pointing at a references/ file that does not exist ships a 404 to every
// agent that follows it. Fails BOTH modes so a dangling pointer can never
// reach the registry. Top-level atoms only — reference children live inside
// references/ themselves, so relative links would double-resolve.
const refLinkRe = /(?:\]\(|\b)\.?\/?(references\/[A-Za-z0-9._/-]+\.md)/g
const danglingRefs: string[] = []
for (const atom of atoms) {
if (atom.parent_atom_id) continue
const baseDir = dirname(join(ROOT, atom.body_path))
const seen = new Set<string>()
for (const m of atom.body.matchAll(refLinkRe)) {
const rel = m[1]
if (seen.has(rel)) continue
seen.add(rel)
if (!existsSync(join(baseDir, rel))) danglingRefs.push(`${atom.id}: ${rel}`)
}
}
if (danglingRefs.length > 0) {
console.error('✗ dangling references/ link(s) in atom bodies — create the file or remove the pointer:')
for (const d of danglingRefs) console.error(` ${d}`)
process.exit(1)
}
const manifest = loadManifest()
const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)]))
File diff suppressed because it is too large Load Diff