From 00988964a8731fb30e530b9584629ffcdc6edd28 Mon Sep 17 00:00:00 2001 From: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:15:16 +0200 Subject: [PATCH] feat: add bookkeeper skill Signed-off-by: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> --- skills/bookkeeper/SKILL.md | 82 ++++++ skills/bookkeeper/X.yaml | 190 ++++++++++++++ .../fixtures/ambiguous-needs-review.yaml | 29 +++ skills/bookkeeper/fixtures/clean-batch.yaml | 27 ++ skills/bookkeeper/run.mjs | 241 ++++++++++++++++++ 5 files changed, 569 insertions(+) create mode 100644 skills/bookkeeper/SKILL.md create mode 100644 skills/bookkeeper/X.yaml create mode 100644 skills/bookkeeper/fixtures/ambiguous-needs-review.yaml create mode 100644 skills/bookkeeper/fixtures/clean-batch.yaml create mode 100644 skills/bookkeeper/run.mjs diff --git a/skills/bookkeeper/SKILL.md b/skills/bookkeeper/SKILL.md new file mode 100644 index 00000000..e658b1fc --- /dev/null +++ b/skills/bookkeeper/SKILL.md @@ -0,0 +1,82 @@ +--- +name: bookkeeper +description: Categorize a bounded transaction batch against an existing chart of accounts and return a sealed, read-only reconciliation without inventing accounts or mutating a ledger. Use when an operator needs explainable GL mapping, anomaly flags, and an explicit needs-review lane. +registry_owner: ArgonautWorks +--- + +# Bookkeeper + +Use this skill to reconcile a supplied transaction batch against a supplied +chart of accounts. The result is evidence for review, not authority to post a +journal entry. This skill performs no ledger mutation, bank action, payment, +file write, network request, or downstream dispatch. + +## Operating model + +1. Validate every transaction, account, and prior-period boundary before + categorizing anything. Transaction identifiers and account codes must be + unique. Dates must be real ISO calendar dates and amounts must be finite, + non-zero numbers. +2. Treat `chart_of_accounts` as the entire account universe. Never create, + infer, rename, or substitute a GL account outside that input. +3. Honor a transaction's explicit `account_code` only when that exact code is + present in the chart. Otherwise compare normalized description tokens with + each account's name and bounded `keywords` list. +4. Categorize only when one account has a unique positive match. Record the + chart account code and name, confidence, reason, source transaction, and + matched terms. +5. Leave tied, unknown, or explicitly invalid account bindings unmatched. + Return `needs_review` with a concrete reason instead of guessing. +6. Flag duplicates and dates outside `prior_period.start_date` through + `prior_period.end_date`. These anomalies remain read-only observations. +7. Reconcile the batch by reporting matched and unmatched counts and amount + totals. Confirm the result's no-write proof before handing it to an + operator. + +## Inputs + +- `transactions[]`: objects with `id`, ISO `date`, `description`, finite + non-zero `amount`, and optional `currency` and `account_code`. +- `chart_of_accounts[]`: objects with unique `code`, `name`, optional `type`, + and optional `keywords[]`. All keywords are evidence supplied by the caller. +- `prior_period`: an object with inclusive ISO `start_date` and `end_date`, + plus optional prior reconciliation facts for operator context. + +Do not paste secrets, bank credentials, card data, or unredacted personal data +into these inputs. Use stable transaction identifiers and already-admitted +accounting evidence. + +## Outputs + +- `categorized[]`: only matched lines. Every item binds to one exact input + account and includes `confidence` and `reason`. +- `anomalies[]`: duplicate, out-of-period, or unmatched observations tied to a + transaction identifier. +- `reconciliation`: `matched`, `unmatched`, counts, amount totals, and the + overall `reconciled` or `needs_review` status. +- `needs_review`: whether human review is required and why. +- `read_only`: an explicit proof surface with `ledger_mutation: false`, empty + `writes`, empty `external_effects`, and zero write count. + +The receipt proves the bounded computation and output contract. It does not +prove a journal was posted because no posting occurs. + +## Recovery rules + +- Add an account to the chart only through the caller's normal chart-governance + process, then rerun with the updated chart. This skill never adds it. +- Resolve a tie by supplying an exact existing `account_code` on the source + transaction or by repairing the chart keywords. Do not lower the unique + match requirement. +- Correct invalid dates, amounts, duplicate identifiers, or period bounds at + the evidence source and rerun. Do not silently coerce them. +- A `needs_review` result is final for the supplied batch. No ledger write may + be inferred from it. + +## Agent rules + +- Never invent a GL account, transaction, amount, date, confidence, or reason. +- Never mutate a ledger or claim that a read-only result was booked. +- Never hide unmatched transactions to make reconciliation appear complete. +- Keep every categorized line traceable to its transaction and chart account. +- Return the sealed receipt with the result for independent verification. diff --git a/skills/bookkeeper/X.yaml b/skills/bookkeeper/X.yaml new file mode 100644 index 00000000..44aceca0 --- /dev/null +++ b/skills/bookkeeper/X.yaml @@ -0,0 +1,190 @@ +skill: bookkeeper +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: context + execution: execute + completion: runtime_receipt + requires_adapter: true + approval: none + +policy: + side_effects: none + sends_messages: denied + mints_authority: denied + persists_state: denied + secrets_required: false + +harness: + cases: + - name: sealed_clean_batch_reconciles_read_only + runner: reconcile + inputs: + transactions: + - { id: txn-001, date: "2026-07-03", description: AWS monthly cloud hosting, amount: -120, currency: USD } + - { id: txn-002, date: "2026-07-08", description: Stripe customer payout, amount: 850, currency: USD } + - { id: txn-003, date: "2026-07-12", description: Office Depot supplies, amount: -45, currency: USD } + chart_of_accounts: + - { code: "4000", name: Sales Revenue, type: revenue, keywords: [stripe, payout, sales] } + - { code: "6100", name: Cloud Hosting, type: expense, keywords: [aws, cloud, hosting] } + - { code: "6200", name: Office Supplies, type: expense, keywords: [office, depot, supplies] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31", opening_balance: 1000 } + expect: + status: sealed + output: + subset: + reconciliation: + matched: 3 + unmatched: 0 + status: reconciled + needs_review: + required: false + reasons: [] + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + external_effects: [] + receipt: { schema: runx.receipt.v1 } + + - name: refused_ambiguous_transactions_return_needs_review + runner: reconcile + inputs: + transactions: + - { id: txn-ambiguous, date: "2026-07-15", description: General monthly service, amount: -75, currency: USD } + chart_of_accounts: + - { code: "6100", name: General Services, type: expense, keywords: [general, service] } + - { code: "6200", name: Service Expenses, type: expense, keywords: [general, service] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31", opening_balance: 0 } + expect: + status: sealed + output: + subset: + reconciliation: + matched: 0 + unmatched: 1 + status: needs_review + needs_review: + required: true + reasons: [ambiguous_account_match] + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + external_effects: [] + receipt: { schema: runx.receipt.v1 } + + - name: explicit_existing_account_binding_is_explainable + runner: reconcile + inputs: + transactions: + - { id: txn-explicit, date: "2026-07-18", description: Annual service renewal, amount: -300, currency: USD, account_code: "6300" } + chart_of_accounts: + - { code: "6300", name: Software Subscriptions, type: expense, keywords: [software, subscription] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } + expect: + status: sealed + output: + subset: + reconciliation: + matched: 1 + unmatched: 0 + status: reconciled + read_only: + ledger_mutation: false + write_count: 0 + receipt: { schema: runx.receipt.v1 } + + - name: unknown_explicit_account_never_invents_gl_account + runner: reconcile + inputs: + transactions: + - { id: txn-unknown, date: "2026-07-20", description: New vendor charge, amount: -90, currency: USD, account_code: "9999" } + chart_of_accounts: + - { code: "6100", name: Cloud Hosting, type: expense, keywords: [cloud, hosting] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } + expect: + status: sealed + output: + subset: + reconciliation: + matched: 0 + unmatched: 1 + status: needs_review + needs_review: + required: true + reasons: [unknown_explicit_account] + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + receipt: { schema: runx.receipt.v1 } + + - name: out_of_period_anomaly_requires_review_without_write + runner: reconcile + inputs: + transactions: + - { id: txn-late, date: "2026-08-01", description: AWS cloud hosting, amount: -60, currency: USD } + chart_of_accounts: + - { code: "6100", name: Cloud Hosting, type: expense, keywords: [aws, cloud, hosting] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } + expect: + status: sealed + output: + subset: + reconciliation: + matched: 1 + unmatched: 0 + status: needs_review + needs_review: + required: true + reasons: [out_of_period] + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + external_effects: [] + receipt: { schema: runx.receipt.v1 } + + - name: invalid_amount_refuses_before_reconciliation + runner: reconcile + inputs: + transactions: + - { id: txn-zero, date: "2026-07-20", description: Invalid zero line, amount: 0, currency: USD } + chart_of_accounts: + - { code: "6100", name: Miscellaneous Expense, type: expense, keywords: [invalid] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } + expect: + status: failure + +runners: + reconcile: + default: true + type: cli-tool + command: /usr/bin/env + args: [node, run.mjs] + outputs: + categorized: array + anomalies: array + reconciliation: object + needs_review: object + read_only: object + artifacts: + named_emits: + reconciliation: reconciliation + inputs: + transactions: + type: array + required: true + description: Bounded transaction lines to categorize without mutation. + chart_of_accounts: + type: array + required: true + description: Complete allowed GL account universe for this run. + prior_period: + type: object + required: true + description: Inclusive reconciliation period and optional prior facts. diff --git a/skills/bookkeeper/fixtures/ambiguous-needs-review.yaml b/skills/bookkeeper/fixtures/ambiguous-needs-review.yaml new file mode 100644 index 00000000..977168ec --- /dev/null +++ b/skills/bookkeeper/fixtures/ambiguous-needs-review.yaml @@ -0,0 +1,29 @@ +name: bookkeeper-ambiguous-needs-review +kind: skill +target: .. +runner: reconcile +inputs: + transactions: + - { id: fixture-ambiguous, date: "2026-07-15", description: General service, amount: -80, currency: USD } + chart_of_accounts: + - { code: "6100", name: General Services, type: expense, keywords: [general, service] } + - { code: "6200", name: Service Expenses, type: expense, keywords: [general, service] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } +expect: + status: sealed + output: + subset: + reconciliation: + matched: 0 + unmatched: 1 + status: needs_review + needs_review: + required: true + reasons: [ambiguous_account_match] + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + external_effects: [] + receipt: { schema: runx.receipt.v1 } +metadata: { public_skill: bookkeeper, source_case: ambiguous-needs-review-no-write } diff --git a/skills/bookkeeper/fixtures/clean-batch.yaml b/skills/bookkeeper/fixtures/clean-batch.yaml new file mode 100644 index 00000000..da07b216 --- /dev/null +++ b/skills/bookkeeper/fixtures/clean-batch.yaml @@ -0,0 +1,27 @@ +name: bookkeeper-clean-batch +kind: skill +target: .. +runner: reconcile +inputs: + transactions: + - { id: fixture-aws, date: "2026-07-05", description: AWS hosting, amount: -100, currency: USD } + - { id: fixture-stripe, date: "2026-07-06", description: Stripe sales payout, amount: 500, currency: USD } + chart_of_accounts: + - { code: "4000", name: Sales Revenue, type: revenue, keywords: [stripe, sales, payout] } + - { code: "6100", name: Cloud Hosting, type: expense, keywords: [aws, hosting] } + prior_period: { start_date: "2026-07-01", end_date: "2026-07-31" } +expect: + status: sealed + output: + subset: + reconciliation: + matched: 2 + unmatched: 0 + status: reconciled + read_only: + ledger_mutation: false + write_count: 0 + writes: [] + external_effects: [] + receipt: { schema: runx.receipt.v1 } +metadata: { public_skill: bookkeeper, source_case: clean-read-only-reconciliation } diff --git a/skills/bookkeeper/run.mjs b/skills/bookkeeper/run.mjs new file mode 100644 index 00000000..a50d7243 --- /dev/null +++ b/skills/bookkeeper/run.mjs @@ -0,0 +1,241 @@ +import fs from "node:fs"; + +const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; +const inputs = JSON.parse(raw); +const result = reconcile(inputs); +process.stdout.write(`${JSON.stringify(result)}\n`); + +function reconcile(inputs) { + const transactions = requiredArray(inputs.transactions, "transactions"); + const chart = requiredArray(inputs.chart_of_accounts, "chart_of_accounts"); + const priorPeriod = requiredObject(inputs.prior_period, "prior_period"); + if (transactions.length === 0) throw new Error("transactions must not be empty"); + if (chart.length === 0) throw new Error("chart_of_accounts must not be empty"); + + const startDate = isoDate(priorPeriod.start_date, "prior_period.start_date"); + const endDate = isoDate(priorPeriod.end_date, "prior_period.end_date"); + if (startDate > endDate) throw new Error("prior_period.start_date must not follow end_date"); + + const accounts = new Map(); + for (const [index, value] of chart.entries()) { + const account = requiredObject(value, `chart_of_accounts[${index}]`); + const code = nonempty(account.code, `chart_of_accounts[${index}].code`); + if (accounts.has(code)) throw new Error(`duplicate chart account code: ${code}`); + const name = nonempty(account.name, `chart_of_accounts[${index}].name`); + const keywords = account.keywords === undefined + ? [] + : requiredArray(account.keywords, `chart_of_accounts[${index}].keywords`) + .map((keyword, keywordIndex) => nonempty( + keyword, + `chart_of_accounts[${index}].keywords[${keywordIndex}]`, + )); + const terms = new Set([...tokens(name), ...keywords.flatMap(tokens)]); + accounts.set(code, { code, name, type: text(account.type), terms }); + } + + const seenIds = new Set(); + const categorized = []; + const anomalies = []; + const unmatched = []; + + for (const [index, value] of transactions.entries()) { + const transaction = requiredObject(value, `transactions[${index}]`); + const id = nonempty(transaction.id, `transactions[${index}].id`); + if (seenIds.has(id)) throw new Error(`duplicate transaction id: ${id}`); + seenIds.add(id); + const date = isoDate(transaction.date, `transactions[${index}].date`); + const description = nonempty(transaction.description, `transactions[${index}].description`); + const amount = finiteNonzero(transaction.amount, `transactions[${index}].amount`); + const currency = transaction.currency === undefined + ? null + : nonempty(transaction.currency, `transactions[${index}].currency`).toUpperCase(); + + if (date < startDate || date > endDate) { + anomalies.push({ + transaction_id: id, + code: "out_of_period", + reason: `transaction date ${date} is outside ${startDate} through ${endDate}`, + }); + } + + const explicitCode = transaction.account_code === undefined + ? null + : nonempty(transaction.account_code, `transactions[${index}].account_code`); + if (explicitCode !== null) { + const account = accounts.get(explicitCode); + if (!account) { + unmatched.push(unmatchedLine(id, date, description, amount, currency, "unknown_explicit_account")); + anomalies.push({ + transaction_id: id, + code: "unknown_explicit_account", + reason: `account_code ${explicitCode} is not present in chart_of_accounts`, + }); + continue; + } + categorized.push(categorizedLine({ + id, + date, + description, + amount, + currency, + account, + confidence: 1, + reason: "exact account_code supplied and found in chart_of_accounts", + matchedTerms: [], + })); + continue; + } + + const descriptionTerms = new Set(tokens(description)); + const ranked = [...accounts.values()] + .map((account) => ({ + account, + matchedTerms: [...descriptionTerms].filter((term) => account.terms.has(term)).sort(), + })) + .filter((candidate) => candidate.matchedTerms.length > 0) + .sort((left, right) => right.matchedTerms.length - left.matchedTerms.length + || left.account.code.localeCompare(right.account.code)); + + if (ranked.length === 0) { + unmatched.push(unmatchedLine(id, date, description, amount, currency, "no_account_match")); + anomalies.push({ + transaction_id: id, + code: "no_account_match", + reason: "no chart account name or keyword matched the transaction description", + }); + continue; + } + if (ranked.length > 1 && ranked[0].matchedTerms.length === ranked[1].matchedTerms.length) { + unmatched.push(unmatchedLine(id, date, description, amount, currency, "ambiguous_account_match")); + anomalies.push({ + transaction_id: id, + code: "ambiguous_account_match", + reason: `multiple chart accounts tie at ${ranked[0].matchedTerms.length} matched term(s)`, + candidate_account_codes: ranked + .filter((candidate) => candidate.matchedTerms.length === ranked[0].matchedTerms.length) + .map((candidate) => candidate.account.code), + }); + continue; + } + + const winner = ranked[0]; + const confidence = Math.min(0.99, Math.round((0.7 + winner.matchedTerms.length * 0.1) * 100) / 100); + categorized.push(categorizedLine({ + id, + date, + description, + amount, + currency, + account: winner.account, + confidence, + reason: `unique chart match on: ${winner.matchedTerms.join(", ")}`, + matchedTerms: winner.matchedTerms, + })); + } + + const reasonCodes = [...new Set([ + ...unmatched.map((line) => line.reason_code), + ...anomalies.map((anomaly) => anomaly.code), + ])].sort(); + const reviewRequired = reasonCodes.length > 0; + const reconciliation = { + status: reviewRequired ? "needs_review" : "reconciled", + matched: categorized.length, + unmatched: unmatched.length, + transaction_count: transactions.length, + matched_amount: money(categorized.reduce((total, line) => total + line.transaction.amount, 0)), + unmatched_amount: money(unmatched.reduce((total, line) => total + line.amount, 0)), + unmatched_transactions: unmatched, + period: { start_date: startDate, end_date: endDate }, + }; + + return { + categorized, + anomalies, + reconciliation, + needs_review: { + required: reviewRequired, + reasons: reasonCodes, + reason: !reviewRequired + ? null + : `${unmatched.length} unmatched transaction(s) and ${anomalies.length} anomaly finding(s) require review`, + }, + read_only: { + ledger_mutation: false, + write_count: 0, + writes: [], + external_effects: [], + statement: "reconciliation artifact only; no journal entry was posted", + }, + }; +} + +function categorizedLine({ id, date, description, amount, currency, account, confidence, reason, matchedTerms }) { + return { + transaction: { id, date, description, amount, currency }, + account: { code: account.code, name: account.name, type: account.type }, + confidence, + reason, + matched_terms: matchedTerms, + }; +} + +function unmatchedLine(id, date, description, amount, currency, reasonCode) { + return { id, date, description, amount, currency, reason_code: reasonCode }; +} + +function tokens(value) { + return String(value) + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter((token) => token.length >= 2); +} + +function isoDate(value, name) { + const candidate = nonempty(value, name); + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) throw new Error(`${name} must be an ISO date`); + const date = new Date(`${candidate}T00:00:00Z`); + if (Number.isNaN(date.valueOf()) || date.toISOString().slice(0, 10) !== candidate) { + throw new Error(`${name} must be a real calendar date`); + } + return candidate; +} + +function finiteNonzero(value, name) { + if (typeof value !== "number" || !Number.isFinite(value) || value === 0) { + throw new Error(`${name} must be a finite non-zero number`); + } + return value; +} + +function requiredArray(value, name) { + if (!Array.isArray(value)) throw new Error(`${name} must be an array`); + return value; +} + +function requiredObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value; +} + +function nonempty(value, name) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} must be a non-empty string`); + } + return value.trim(); +} + +function text(value) { + return typeof value === "string" ? value : null; +} + +function money(value) { + return Math.round((value + Number.EPSILON) * 100) / 100; +}