@fuzzle/opencode-accountant 0.4.0 → 0.4.1-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,12 +122,21 @@ providers:
122
122
  currencyField: Currency
123
123
  skipRows: 9
124
124
  delimiter: ';'
125
- renamePattern: 'transactions-ubs-{account-number}.csv'
125
+ renamePattern: 'ubs-{account-number}-transactions-{from-date}-to-{until-date}.csv'
126
126
  metadata:
127
127
  - field: account-number
128
128
  row: 0
129
129
  column: 1
130
130
  normalize: spaces-to-dashes
131
+ - field: from-date
132
+ row: 2
133
+ column: 1
134
+ - field: until-date
135
+ row: 3
136
+ column: 1
137
+ - field: closing-balance
138
+ row: 5
139
+ column: 1
131
140
  currencies:
132
141
  CHF: chf
133
142
  EUR: eur
@@ -226,7 +235,14 @@ The `import-pipeline` tool is the single entry point for importing bank statemen
226
235
 
227
236
  #### Rules File Matching
228
237
 
229
- The tool matches CSV files to their rules files by parsing the `source` directive in each `.rules` file. For example, if `ubs-account.rules` contains:
238
+ The tool matches CSV files to their rules files using multiple methods:
239
+
240
+ 1. **Source directive matching** (primary): Parses the `source` directive in each `.rules` file and supports glob patterns
241
+ 2. **Filename matching** (fallback): If path matching fails, matches based on the rules filename prefix
242
+
243
+ **Example using source directive:**
244
+
245
+ If `ubs-account.rules` contains:
230
246
 
231
247
  ```
232
248
  source ../../import/pending/ubs/chf/transactions.csv
@@ -234,7 +250,13 @@ source ../../import/pending/ubs/chf/transactions.csv
234
250
 
235
251
  The tool will use that rules file when processing `transactions.csv`.
236
252
 
237
- **Note:** The `source` path should match your configured `{paths.pending}` directory structure.
253
+ **Example using filename matching:**
254
+
255
+ If you have a rules file named `ubs-1234-567890.rules` and a CSV file named `ubs-1234-567890-transactions-2026-01-05-to-2026-01-31.csv`, the tool will automatically match them based on the common prefix `ubs-1234-567890`.
256
+
257
+ This is particularly useful when CSV files move between directories (e.g., from `pending/` to `done/`) or when maintaining exact source paths is impractical.
258
+
259
+ **Note:** Name your rules files to match the prefix of your CSV files for automatic matching.
238
260
 
239
261
  See the hledger documentation for details on rules file format and syntax.
240
262
 
@@ -265,13 +287,13 @@ Configure metadata extraction in `providers.yaml`:
265
287
 
266
288
  ```yaml
267
289
  metadata:
268
- - field: closing_balance
290
+ - field: closing-balance
269
291
  row: 5
270
292
  column: 1
271
- - field: from_date
293
+ - field: from-date
272
294
  row: 2
273
295
  column: 1
274
- - field: until_date
296
+ - field: until-date
275
297
  row: 3
276
298
  column: 1
277
299
  ```
@@ -97,15 +97,77 @@ The `import-pipeline` tool provides an **atomic, safe workflow** using git workt
97
97
  - Deletes processed CSV files from main repo's import/incoming
98
98
  - Cleans up the worktree
99
99
  4. **Handle Failures**: If any step fails (e.g., unknown postings found):
100
- - Worktree is discarded, main branch remains untouched
100
+ - Worktree is preserved by default at `/tmp/import-worktree-<uuid>` for debugging
101
+ - Main branch remains untouched
101
102
  - Review error output for unknown postings with full CSV row data
102
103
  - Update rules file with `if` directives to match the transaction
103
104
  - Re-run `import-pipeline`
104
105
 
106
+ ### Error Recovery and Worktree Preservation
107
+
108
+ **Default Behavior:**
109
+
110
+ - On success: Worktrees are automatically cleaned up
111
+ - On error: Worktrees are preserved in `/tmp/import-worktree-<uuid>` for debugging
112
+ - Worktrees in `/tmp` are automatically cleaned up on system reboot
113
+
114
+ **Manual Recovery from Failed Import:**
115
+
116
+ If an import fails and the worktree is preserved, you can:
117
+
118
+ 1. **Inspect the worktree:**
119
+
120
+ ```bash
121
+ cd /tmp/import-worktree-<uuid>
122
+ hledger check # Validate journal
123
+ hledger balance # Check balances
124
+ cat ledger/2026.journal # View imported transactions
125
+ ```
126
+
127
+ 2. **Continue the import manually:**
128
+
129
+ ```bash
130
+ cd /tmp/import-worktree-<uuid>
131
+ # Fix any issues (edit rules, fix transactions, etc.)
132
+ git add .
133
+ git commit -m "Fix import issues"
134
+ git checkout main
135
+ git merge --no-ff import-<uuid>
136
+ ```
137
+
138
+ 3. **Clean up when done:**
139
+
140
+ ```bash
141
+ git worktree remove /tmp/import-worktree-<uuid>
142
+ ```
143
+
144
+ 4. **Or use the cleanup tool:**
145
+ ```bash
146
+ cleanup-worktrees # Removes worktrees >24h old
147
+ cleanup-worktrees --all true # Removes all import worktrees
148
+ cleanup-worktrees --dryRun true # Preview without removing
149
+ ```
150
+
151
+ **Logs:**
152
+
153
+ - Every import run generates a detailed log: `.memory/import-<timestamp>.md`
154
+ - Log includes all commands, output, timing, and errors
155
+ - Log path is included in import-pipeline output
156
+ - Review the log to understand what failed and why
157
+
158
+ **Force Cleanup on Error:**
159
+ If you prefer the old behavior (always cleanup, even on error):
160
+
161
+ ```bash
162
+ import-pipeline --keepWorktreeOnError false
163
+ ```
164
+
105
165
  ### Rules Files
106
166
 
107
167
  - The location of the rules files is configured in `config/import/providers.yaml`
108
- - Match CSV to rules file via the `source` directive in each `.rules` file
168
+ - Match CSV to rules file via the `source` directive in each `.rules` file, with automatic fallback to filename-based matching
169
+ - **Filename matching example:** If the rules file is named `ubs-1234-567890.rules`, it will automatically match CSV files like `ubs-1234-567890-transactions-2026-01.csv` based on the common prefix. This works even when CSV files move between directories.
170
+ - When account detection fails, recommend users either fix their `source` directive or rename their rules file to match the CSV filename prefix
109
171
  - Use field names from the `fields` directive for matching
110
172
  - Unknown account pattern: `income:unknown` (positive amounts) / `expenses:unknown` (negative amounts)
111
173
 
package/dist/index.js CHANGED
@@ -17509,7 +17509,8 @@ function validatePaths(paths) {
17509
17509
  pending: pathsObj.pending,
17510
17510
  done: pathsObj.done,
17511
17511
  unrecognized: pathsObj.unrecognized,
17512
- rules: pathsObj.rules
17512
+ rules: pathsObj.rules,
17513
+ logs: pathsObj.logs
17513
17514
  };
17514
17515
  }
17515
17516
  function validateDetectionRule(providerName, index, rule) {
@@ -17639,6 +17640,9 @@ function loadImportConfig(directory) {
17639
17640
  for (const [name, config2] of Object.entries(providersObj)) {
17640
17641
  providers[name] = validateProviderConfig(name, config2);
17641
17642
  }
17643
+ if (!paths.logs) {
17644
+ paths.logs = ".memory";
17645
+ }
17642
17646
  return { paths, providers };
17643
17647
  }
17644
17648
 
@@ -17907,15 +17911,35 @@ function isInWorktree(directory) {
17907
17911
  return false;
17908
17912
  }
17909
17913
  }
17910
- async function withWorktree(directory, operation) {
17914
+ async function withWorktree(directory, operation, options) {
17911
17915
  let createdWorktree = null;
17916
+ let operationSucceeded = false;
17917
+ const logger = options?.logger;
17918
+ const keepOnError = options?.keepOnError ?? true;
17912
17919
  try {
17920
+ logger?.logStep("Create Worktree", "start");
17913
17921
  createdWorktree = createImportWorktree(directory);
17922
+ logger?.logStep("Create Worktree", "success", `Path: ${createdWorktree.path}`);
17923
+ logger?.info(`Branch: ${createdWorktree.branch}`);
17924
+ logger?.info(`UUID: ${createdWorktree.uuid}`);
17914
17925
  const result = await operation(createdWorktree);
17926
+ operationSucceeded = true;
17915
17927
  return result;
17916
17928
  } finally {
17917
17929
  if (createdWorktree) {
17918
- removeWorktree(createdWorktree, true);
17930
+ if (operationSucceeded) {
17931
+ logger?.logStep("Cleanup Worktree", "start");
17932
+ removeWorktree(createdWorktree, true);
17933
+ logger?.logStep("Cleanup Worktree", "success", "Worktree removed");
17934
+ } else if (!keepOnError) {
17935
+ logger?.warn("Operation failed, but keepOnError=false, removing worktree");
17936
+ removeWorktree(createdWorktree, true);
17937
+ } else {
17938
+ logger?.warn("Operation failed, worktree preserved for debugging");
17939
+ logger?.info(`Worktree path: ${createdWorktree.path}`);
17940
+ logger?.info(`To clean up manually: git worktree remove ${createdWorktree.path}`);
17941
+ logger?.info(`To list all worktrees: git worktree list`);
17942
+ }
17919
17943
  }
17920
17944
  }
17921
17945
  }
@@ -23458,6 +23482,18 @@ function findRulesForCsv(csvPath, mapping) {
23458
23482
  return rulesFile;
23459
23483
  }
23460
23484
  }
23485
+ const csvBasename = path9.basename(csvPath);
23486
+ const matches = [];
23487
+ for (const rulesFile of Object.values(mapping)) {
23488
+ const rulesBasename = path9.basename(rulesFile, ".rules");
23489
+ if (csvBasename.startsWith(rulesBasename)) {
23490
+ matches.push({ rulesFile, prefixLength: rulesBasename.length });
23491
+ }
23492
+ }
23493
+ if (matches.length > 0) {
23494
+ matches.sort((a, b) => b.prefixLength - a.prefixLength);
23495
+ return matches[0].rulesFile;
23496
+ }
23461
23497
  return null;
23462
23498
  }
23463
23499
 
@@ -24016,7 +24052,7 @@ async function processCsvFile(csvFile, rulesMapping, directory, hledgerExecutor)
24016
24052
  transactionYear
24017
24053
  };
24018
24054
  }
24019
- async function importStatements(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
24055
+ async function importStatements(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree, _logger) {
24020
24056
  const restrictionError = checkAccountantAgent(agent, "import statements");
24021
24057
  if (restrictionError) {
24022
24058
  return restrictionError;
@@ -24240,10 +24276,11 @@ function determineClosingBalance(csvFile, config2, options, relativeCsvPath, rul
24240
24276
  metadata = undefined;
24241
24277
  }
24242
24278
  let closingBalance = options.closingBalance;
24243
- if (!closingBalance && metadata?.closing_balance) {
24244
- const { closing_balance, currency } = metadata;
24245
- closingBalance = closing_balance;
24246
- if (currency && !closingBalance.includes(currency)) {
24279
+ if (!closingBalance && metadata?.["closing-balance"]) {
24280
+ const closingBalanceValue = metadata["closing-balance"];
24281
+ const currency = metadata.currency;
24282
+ closingBalance = closingBalanceValue;
24283
+ if (currency && closingBalance && !closingBalance.includes(currency)) {
24247
24284
  closingBalance = `${currency} ${closingBalance}`;
24248
24285
  }
24249
24286
  }
@@ -24324,7 +24361,7 @@ function tryExtractClosingBalanceFromCSV(csvFile, rulesDir) {
24324
24361
  "balance",
24325
24362
  "Balance",
24326
24363
  "BALANCE",
24327
- "closing_balance",
24364
+ "closing-balance",
24328
24365
  "Closing Balance",
24329
24366
  "account_balance",
24330
24367
  "Account Balance",
@@ -24516,8 +24553,8 @@ It must be run inside an import worktree (use import-pipeline for the full workf
24516
24553
  }
24517
24554
  });
24518
24555
  // src/tools/import-pipeline.ts
24519
- import * as fs13 from "fs";
24520
- import * as path12 from "path";
24556
+ import * as fs14 from "fs";
24557
+ import * as path13 from "path";
24521
24558
 
24522
24559
  // src/utils/accountDeclarations.ts
24523
24560
  import * as fs12 from "fs";
@@ -24634,6 +24671,158 @@ function ensureAccountDeclarations(yearJournalPath, accounts) {
24634
24671
  };
24635
24672
  }
24636
24673
 
24674
+ // src/utils/logger.ts
24675
+ import fs13 from "fs/promises";
24676
+ import path12 from "path";
24677
+
24678
+ class MarkdownLogger {
24679
+ buffer = [];
24680
+ logPath;
24681
+ context = {};
24682
+ autoFlush;
24683
+ sectionDepth = 0;
24684
+ constructor(config2) {
24685
+ this.autoFlush = config2.autoFlush ?? true;
24686
+ this.context = config2.context || {};
24687
+ const filename = config2.filename || `import-${this.getTimestamp()}.md`;
24688
+ this.logPath = path12.join(config2.logDir, filename);
24689
+ this.buffer.push(`# Import Pipeline Log`);
24690
+ this.buffer.push(`**Started**: ${new Date().toLocaleString()}`);
24691
+ this.buffer.push("");
24692
+ }
24693
+ startSection(title, level = 2) {
24694
+ this.buffer.push("");
24695
+ this.buffer.push(`${"#".repeat(level + 1)} ${title}`);
24696
+ this.buffer.push(`**Started**: ${this.getTime()}`);
24697
+ this.buffer.push("");
24698
+ this.sectionDepth++;
24699
+ }
24700
+ endSection() {
24701
+ if (this.sectionDepth > 0) {
24702
+ this.buffer.push("");
24703
+ this.buffer.push("---");
24704
+ this.buffer.push("");
24705
+ this.sectionDepth--;
24706
+ }
24707
+ }
24708
+ info(message) {
24709
+ this.buffer.push(message);
24710
+ if (this.autoFlush)
24711
+ this.flushAsync();
24712
+ }
24713
+ warn(message) {
24714
+ this.buffer.push(`\u26A0\uFE0F **WARNING**: ${message}`);
24715
+ if (this.autoFlush)
24716
+ this.flushAsync();
24717
+ }
24718
+ error(message, error45) {
24719
+ this.buffer.push(`\u274C **ERROR**: ${message}`);
24720
+ if (error45) {
24721
+ const errorStr = error45 instanceof Error ? error45.message : String(error45);
24722
+ this.buffer.push("");
24723
+ this.buffer.push("```");
24724
+ this.buffer.push(errorStr);
24725
+ if (error45 instanceof Error && error45.stack) {
24726
+ this.buffer.push("");
24727
+ this.buffer.push(error45.stack);
24728
+ }
24729
+ this.buffer.push("```");
24730
+ this.buffer.push("");
24731
+ }
24732
+ if (this.autoFlush)
24733
+ this.flushAsync();
24734
+ }
24735
+ debug(message) {
24736
+ this.buffer.push(`\uD83D\uDD0D ${message}`);
24737
+ if (this.autoFlush)
24738
+ this.flushAsync();
24739
+ }
24740
+ logStep(stepName, status, details) {
24741
+ const icon = status === "success" ? "\u2705" : status === "error" ? "\u274C" : "\u25B6\uFE0F";
24742
+ const statusText = status.charAt(0).toUpperCase() + status.slice(1);
24743
+ this.buffer.push(`**${stepName}**: ${icon} ${statusText}`);
24744
+ if (details) {
24745
+ this.buffer.push(` ${details}`);
24746
+ }
24747
+ this.buffer.push("");
24748
+ if (this.autoFlush)
24749
+ this.flushAsync();
24750
+ }
24751
+ logCommand(command, output) {
24752
+ this.buffer.push("```bash");
24753
+ this.buffer.push(`$ ${command}`);
24754
+ if (output) {
24755
+ this.buffer.push("");
24756
+ const lines = output.trim().split(`
24757
+ `);
24758
+ if (lines.length > 50) {
24759
+ this.buffer.push(...lines.slice(0, 50));
24760
+ this.buffer.push(`... (${lines.length - 50} more lines omitted)`);
24761
+ } else {
24762
+ this.buffer.push(output.trim());
24763
+ }
24764
+ }
24765
+ this.buffer.push("```");
24766
+ this.buffer.push("");
24767
+ if (this.autoFlush)
24768
+ this.flushAsync();
24769
+ }
24770
+ logResult(data) {
24771
+ this.buffer.push("```json");
24772
+ this.buffer.push(JSON.stringify(data, null, 2));
24773
+ this.buffer.push("```");
24774
+ this.buffer.push("");
24775
+ if (this.autoFlush)
24776
+ this.flushAsync();
24777
+ }
24778
+ setContext(key, value) {
24779
+ this.context[key] = value;
24780
+ }
24781
+ async flush() {
24782
+ if (this.buffer.length === 0)
24783
+ return;
24784
+ try {
24785
+ await fs13.mkdir(path12.dirname(this.logPath), { recursive: true });
24786
+ await fs13.writeFile(this.logPath, this.buffer.join(`
24787
+ `), "utf-8");
24788
+ } catch {}
24789
+ }
24790
+ getLogPath() {
24791
+ return this.logPath;
24792
+ }
24793
+ flushAsync() {
24794
+ this.flush().catch(() => {});
24795
+ }
24796
+ getTimestamp() {
24797
+ return new Date().toISOString().replace(/:/g, "-").split(".")[0];
24798
+ }
24799
+ getTime() {
24800
+ return new Date().toLocaleTimeString();
24801
+ }
24802
+ }
24803
+ function createLogger(config2) {
24804
+ return new MarkdownLogger(config2);
24805
+ }
24806
+ function createImportLogger(directory, worktreeId, provider) {
24807
+ const context = {};
24808
+ if (worktreeId)
24809
+ context.worktreeId = worktreeId;
24810
+ if (provider)
24811
+ context.provider = provider;
24812
+ const logger = createLogger({
24813
+ logDir: path12.join(directory, ".memory"),
24814
+ autoFlush: true,
24815
+ context
24816
+ });
24817
+ if (worktreeId)
24818
+ logger.info(`**Worktree ID**: ${worktreeId}`);
24819
+ if (provider)
24820
+ logger.info(`**Provider**: ${provider}`);
24821
+ logger.info(`**Repository**: ${directory}`);
24822
+ logger.info("");
24823
+ return logger;
24824
+ }
24825
+
24637
24826
  // src/tools/import-pipeline.ts
24638
24827
  class NoTransactionsError extends Error {
24639
24828
  constructor() {
@@ -24673,8 +24862,8 @@ function buildCommitMessage(provider, currency, fromDate, untilDate, transaction
24673
24862
  return `${parts.join(" ")}${dateRange}${txStr}`;
24674
24863
  }
24675
24864
  function cleanupIncomingFiles(worktree, context) {
24676
- const incomingDir = path12.join(worktree.mainRepoPath, "import/incoming");
24677
- if (!fs13.existsSync(incomingDir)) {
24865
+ const incomingDir = path13.join(worktree.mainRepoPath, "import/incoming");
24866
+ if (!fs14.existsSync(incomingDir)) {
24678
24867
  return;
24679
24868
  }
24680
24869
  const importStep = context.result.steps.import;
@@ -24689,11 +24878,11 @@ function cleanupIncomingFiles(worktree, context) {
24689
24878
  for (const fileResult of importResult.files) {
24690
24879
  if (!fileResult.csv)
24691
24880
  continue;
24692
- const filename = path12.basename(fileResult.csv);
24693
- const filePath = path12.join(incomingDir, filename);
24694
- if (fs13.existsSync(filePath)) {
24881
+ const filename = path13.basename(fileResult.csv);
24882
+ const filePath = path13.join(incomingDir, filename);
24883
+ if (fs14.existsSync(filePath)) {
24695
24884
  try {
24696
- fs13.unlinkSync(filePath);
24885
+ fs14.unlinkSync(filePath);
24697
24886
  deletedCount++;
24698
24887
  } catch (error45) {
24699
24888
  console.error(`[ERROR] Failed to delete ${filename}: ${error45 instanceof Error ? error45.message : String(error45)}`);
@@ -24704,9 +24893,13 @@ function cleanupIncomingFiles(worktree, context) {
24704
24893
  console.log(`[INFO] Cleaned up ${deletedCount} file(s) from import/incoming/`);
24705
24894
  }
24706
24895
  }
24707
- async function executeClassifyStep(context, worktree) {
24896
+ async function executeClassifyStep(context, worktree, logger) {
24897
+ logger?.startSection("Step 2: Classify Transactions");
24898
+ logger?.logStep("Classify", "start");
24708
24899
  if (context.options.skipClassify) {
24900
+ logger?.info("Classification skipped (skipClassify: true)");
24709
24901
  context.result.steps.classify = buildStepResult(true, "Classification skipped (skipClassify: true)");
24902
+ logger?.endSection();
24710
24903
  return;
24711
24904
  }
24712
24905
  const inWorktree = () => true;
@@ -24716,18 +24909,23 @@ async function executeClassifyStep(context, worktree) {
24716
24909
  let message = success2 ? "Classification complete" : "Classification had issues";
24717
24910
  if (classifyParsed.unrecognized?.length > 0) {
24718
24911
  message = `Classification complete with ${classifyParsed.unrecognized.length} unrecognized file(s)`;
24912
+ logger?.warn(`${classifyParsed.unrecognized.length} unrecognized file(s)`);
24719
24913
  }
24914
+ logger?.logStep("Classify", success2 ? "success" : "error", message);
24720
24915
  const details = {
24721
24916
  success: success2,
24722
24917
  unrecognized: classifyParsed.unrecognized,
24723
24918
  classified: classifyParsed
24724
24919
  };
24725
24920
  context.result.steps.classify = buildStepResult(success2, message, details);
24921
+ logger?.endSection();
24726
24922
  }
24727
- async function executeAccountDeclarationsStep(context, worktree) {
24923
+ async function executeAccountDeclarationsStep(context, worktree, logger) {
24924
+ logger?.startSection("Step 3: Check Account Declarations");
24925
+ logger?.logStep("Check Accounts", "start");
24728
24926
  const config2 = context.configLoader(worktree.path);
24729
- const pendingDir = path12.join(worktree.path, config2.paths.pending);
24730
- const rulesDir = path12.join(worktree.path, config2.paths.rules);
24927
+ const pendingDir = path13.join(worktree.path, config2.paths.pending);
24928
+ const rulesDir = path13.join(worktree.path, config2.paths.rules);
24731
24929
  const csvFiles = findCsvFiles(pendingDir, context.options.provider, context.options.currency);
24732
24930
  if (csvFiles.length === 0) {
24733
24931
  context.result.steps.accountDeclarations = buildStepResult(true, "No CSV files to process", {
@@ -24758,7 +24956,7 @@ async function executeAccountDeclarationsStep(context, worktree) {
24758
24956
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
24759
24957
  accountsAdded: [],
24760
24958
  journalUpdated: "",
24761
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24959
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24762
24960
  });
24763
24961
  return;
24764
24962
  }
@@ -24781,7 +24979,7 @@ async function executeAccountDeclarationsStep(context, worktree) {
24781
24979
  context.result.steps.accountDeclarations = buildStepResult(false, "Could not determine transaction year from CSV files", {
24782
24980
  accountsAdded: [],
24783
24981
  journalUpdated: "",
24784
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24982
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24785
24983
  });
24786
24984
  return;
24787
24985
  }
@@ -24792,19 +24990,28 @@ async function executeAccountDeclarationsStep(context, worktree) {
24792
24990
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
24793
24991
  accountsAdded: [],
24794
24992
  journalUpdated: "",
24795
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24993
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24796
24994
  });
24797
24995
  return;
24798
24996
  }
24799
24997
  const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
24800
- const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path12.relative(worktree.path, yearJournalPath)}` : "All required accounts already declared";
24998
+ const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path13.relative(worktree.path, yearJournalPath)}` : "All required accounts already declared";
24999
+ logger?.logStep("Check Accounts", "success", message);
25000
+ if (result.added.length > 0) {
25001
+ for (const account of result.added) {
25002
+ logger?.info(` - ${account}`);
25003
+ }
25004
+ }
24801
25005
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
24802
25006
  accountsAdded: result.added,
24803
- journalUpdated: path12.relative(worktree.path, yearJournalPath),
24804
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
25007
+ journalUpdated: path13.relative(worktree.path, yearJournalPath),
25008
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24805
25009
  });
25010
+ logger?.endSection();
24806
25011
  }
24807
- async function executeDryRunStep(context, worktree) {
25012
+ async function executeDryRunStep(context, worktree, logger) {
25013
+ logger?.startSection("Step 4: Dry Run Import");
25014
+ logger?.logStep("Dry Run", "start");
24808
25015
  const inWorktree = () => true;
24809
25016
  const dryRunResult = await importStatements(worktree.path, context.agent, {
24810
25017
  provider: context.options.provider,
@@ -24813,20 +25020,30 @@ async function executeDryRunStep(context, worktree) {
24813
25020
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24814
25021
  const dryRunParsed = JSON.parse(dryRunResult);
24815
25022
  const message = dryRunParsed.success ? `Dry run passed: ${dryRunParsed.summary?.totalTransactions || 0} transactions ready` : `Dry run failed: ${dryRunParsed.summary?.unknown || 0} unknown account(s)`;
25023
+ logger?.logStep("Dry Run", dryRunParsed.success ? "success" : "error", message);
25024
+ if (dryRunParsed.summary?.totalTransactions) {
25025
+ logger?.info(`Found ${dryRunParsed.summary.totalTransactions} transactions`);
25026
+ }
24816
25027
  context.result.steps.dryRun = buildStepResult(dryRunParsed.success, message, {
24817
25028
  success: dryRunParsed.success,
24818
25029
  summary: dryRunParsed.summary
24819
25030
  });
24820
25031
  if (!dryRunParsed.success) {
25032
+ logger?.error("Dry run found unknown accounts or errors");
25033
+ logger?.endSection();
24821
25034
  context.result.error = "Dry run found unknown accounts or errors";
24822
25035
  context.result.hint = "Add rules to categorize unknown transactions, then retry";
24823
25036
  throw new Error("Dry run failed");
24824
25037
  }
24825
25038
  if (dryRunParsed.summary?.totalTransactions === 0) {
25039
+ logger?.endSection();
24826
25040
  throw new NoTransactionsError;
24827
25041
  }
25042
+ logger?.endSection();
24828
25043
  }
24829
- async function executeImportStep(context, worktree) {
25044
+ async function executeImportStep(context, worktree, logger) {
25045
+ logger?.startSection("Step 5: Import Transactions");
25046
+ logger?.logStep("Import", "start");
24830
25047
  const inWorktree = () => true;
24831
25048
  const importResult = await importStatements(worktree.path, context.agent, {
24832
25049
  provider: context.options.provider,
@@ -24835,17 +25052,23 @@ async function executeImportStep(context, worktree) {
24835
25052
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24836
25053
  const importParsed = JSON.parse(importResult);
24837
25054
  const message = importParsed.success ? `Imported ${importParsed.summary?.totalTransactions || 0} transactions` : `Import failed: ${importParsed.error || "Unknown error"}`;
25055
+ logger?.logStep("Import", importParsed.success ? "success" : "error", message);
24838
25056
  context.result.steps.import = buildStepResult(importParsed.success, message, {
24839
25057
  success: importParsed.success,
24840
25058
  summary: importParsed.summary,
24841
25059
  error: importParsed.error
24842
25060
  });
24843
25061
  if (!importParsed.success) {
25062
+ logger?.error("Import failed", new Error(importParsed.error || "Unknown error"));
25063
+ logger?.endSection();
24844
25064
  context.result.error = `Import failed: ${importParsed.error || "Unknown error"}`;
24845
25065
  throw new Error("Import failed");
24846
25066
  }
25067
+ logger?.endSection();
24847
25068
  }
24848
- async function executeReconcileStep(context, worktree) {
25069
+ async function executeReconcileStep(context, worktree, logger) {
25070
+ logger?.startSection("Step 6: Reconcile Balance");
25071
+ logger?.logStep("Reconcile", "start");
24849
25072
  const inWorktree = () => true;
24850
25073
  const reconcileResult = await reconcileStatement(worktree.path, context.agent, {
24851
25074
  provider: context.options.provider,
@@ -24855,6 +25078,11 @@ async function executeReconcileStep(context, worktree) {
24855
25078
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24856
25079
  const reconcileParsed = JSON.parse(reconcileResult);
24857
25080
  const message = reconcileParsed.success ? `Balance reconciled: ${reconcileParsed.actualBalance}` : `Balance mismatch: expected ${reconcileParsed.expectedBalance}, got ${reconcileParsed.actualBalance}`;
25081
+ logger?.logStep("Reconcile", reconcileParsed.success ? "success" : "error", message);
25082
+ if (reconcileParsed.success) {
25083
+ logger?.info(`Actual: ${reconcileParsed.actualBalance}`);
25084
+ logger?.info(`Expected: ${reconcileParsed.expectedBalance}`);
25085
+ }
24858
25086
  context.result.steps.reconcile = buildStepResult(reconcileParsed.success, message, {
24859
25087
  success: reconcileParsed.success,
24860
25088
  actualBalance: reconcileParsed.actualBalance,
@@ -24863,29 +25091,40 @@ async function executeReconcileStep(context, worktree) {
24863
25091
  error: reconcileParsed.error
24864
25092
  });
24865
25093
  if (!reconcileParsed.success) {
25094
+ logger?.error("Reconciliation failed", new Error(reconcileParsed.error || "Balance mismatch"));
25095
+ logger?.endSection();
24866
25096
  context.result.error = `Reconciliation failed: ${reconcileParsed.error || "Balance mismatch"}`;
24867
25097
  context.result.hint = "Check for missing transactions or incorrect rules";
24868
25098
  throw new Error("Reconciliation failed");
24869
25099
  }
25100
+ logger?.endSection();
24870
25101
  }
24871
- async function executeMergeStep(context, worktree) {
25102
+ async function executeMergeStep(context, worktree, logger) {
25103
+ logger?.startSection("Step 7: Merge to Main");
25104
+ logger?.logStep("Merge", "start");
24872
25105
  const importDetails = context.result.steps.import?.details;
24873
25106
  const reconcileDetails = context.result.steps.reconcile?.details;
24874
25107
  if (!importDetails || !reconcileDetails) {
24875
25108
  throw new Error("Import or reconcile step not completed before merge");
24876
25109
  }
24877
25110
  const commitInfo = {
24878
- fromDate: reconcileDetails.metadata?.from_date,
24879
- untilDate: reconcileDetails.metadata?.until_date
25111
+ fromDate: reconcileDetails.metadata?.["from-date"],
25112
+ untilDate: reconcileDetails.metadata?.["until-date"]
24880
25113
  };
24881
25114
  const transactionCount = importDetails.summary?.totalTransactions || 0;
24882
25115
  const commitMessage = buildCommitMessage(context.options.provider, context.options.currency, commitInfo.fromDate, commitInfo.untilDate, transactionCount);
24883
25116
  try {
25117
+ logger?.info(`Commit message: "${commitMessage}"`);
24884
25118
  mergeWorktree(worktree, commitMessage);
25119
+ logger?.logStep("Merge", "success", "Merged to main branch");
24885
25120
  const mergeDetails = { commitMessage };
24886
25121
  context.result.steps.merge = buildStepResult(true, `Merged to main: "${commitMessage}"`, mergeDetails);
24887
25122
  cleanupIncomingFiles(worktree, context);
25123
+ logger?.endSection();
24888
25124
  } catch (error45) {
25125
+ logger?.logStep("Merge", "error");
25126
+ logger?.error("Merge to main branch failed", error45);
25127
+ logger?.endSection();
24889
25128
  const message = `Merge failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
24890
25129
  context.result.steps.merge = buildStepResult(false, message);
24891
25130
  context.result.error = "Merge to main branch failed";
@@ -24903,6 +25142,13 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24903
25142
  if (restrictionError) {
24904
25143
  return restrictionError;
24905
25144
  }
25145
+ const logger = createImportLogger(directory, undefined, options.provider);
25146
+ logger.startSection("Import Pipeline", 1);
25147
+ logger.info(`Provider filter: ${options.provider || "all"}`);
25148
+ logger.info(`Currency filter: ${options.currency || "all"}`);
25149
+ logger.info(`Skip classify: ${options.skipClassify || false}`);
25150
+ logger.info(`Keep worktree on error: ${options.keepWorktreeOnError ?? true}`);
25151
+ logger.info("");
24906
25152
  const result = {
24907
25153
  success: false,
24908
25154
  steps: {}
@@ -24917,33 +25163,47 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24917
25163
  };
24918
25164
  try {
24919
25165
  return await withWorktree(directory, async (worktree) => {
25166
+ logger.setContext("worktreeId", worktree.uuid);
25167
+ logger.setContext("worktreePath", worktree.path);
24920
25168
  result.worktreeId = worktree.uuid;
24921
25169
  result.steps.worktree = buildStepResult(true, `Created worktree at ${worktree.path}`, {
24922
25170
  path: worktree.path,
24923
25171
  branch: worktree.branch
24924
25172
  });
25173
+ logger.startSection("Step 1: Sync Files");
25174
+ logger.logStep("Sync Files", "start");
24925
25175
  try {
24926
25176
  const config2 = configLoader(directory);
24927
25177
  const syncResult = syncCSVFilesToWorktree(directory, worktree.path, config2.paths.import);
24928
25178
  if (syncResult.synced.length === 0 && syncResult.errors.length === 0) {
25179
+ logger.logStep("Sync Files", "success", "No CSV files to sync");
24929
25180
  result.steps.sync = buildStepResult(true, "No CSV files to sync", {
24930
25181
  synced: []
24931
25182
  });
24932
25183
  } else if (syncResult.errors.length > 0) {
25184
+ logger.warn(`Synced ${syncResult.synced.length} file(s) with ${syncResult.errors.length} error(s)`);
24933
25185
  result.steps.sync = buildStepResult(true, `Synced ${syncResult.synced.length} file(s) with ${syncResult.errors.length} error(s)`, { synced: syncResult.synced, errors: syncResult.errors });
24934
25186
  } else {
25187
+ logger.logStep("Sync Files", "success", `Synced ${syncResult.synced.length} CSV file(s)`);
25188
+ for (const file2 of syncResult.synced) {
25189
+ logger.info(` - ${file2}`);
25190
+ }
24935
25191
  result.steps.sync = buildStepResult(true, `Synced ${syncResult.synced.length} CSV file(s) to worktree`, { synced: syncResult.synced });
24936
25192
  }
25193
+ logger.endSection();
24937
25194
  } catch (error45) {
25195
+ logger.logStep("Sync Files", "error");
25196
+ logger.error("Failed to sync CSV files", error45);
25197
+ logger.endSection();
24938
25198
  const errorMsg = error45 instanceof Error ? error45.message : String(error45);
24939
25199
  result.steps.sync = buildStepResult(false, `Failed to sync CSV files: ${errorMsg}`, { synced: [], errors: [{ file: "unknown", error: errorMsg }] });
24940
25200
  }
24941
25201
  try {
24942
- await executeClassifyStep(context, worktree);
24943
- await executeAccountDeclarationsStep(context, worktree);
24944
- await executeDryRunStep(context, worktree);
24945
- await executeImportStep(context, worktree);
24946
- await executeReconcileStep(context, worktree);
25202
+ await executeClassifyStep(context, worktree, logger);
25203
+ await executeAccountDeclarationsStep(context, worktree, logger);
25204
+ await executeDryRunStep(context, worktree, logger);
25205
+ await executeImportStep(context, worktree, logger);
25206
+ await executeReconcileStep(context, worktree, logger);
24947
25207
  try {
24948
25208
  const config2 = configLoader(directory);
24949
25209
  const cleanupResult = cleanupProcessedCSVFiles(directory, config2.paths.import);
@@ -24968,7 +25228,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24968
25228
  }
24969
25229
  });
24970
25230
  }
24971
- await executeMergeStep(context, worktree);
25231
+ await executeMergeStep(context, worktree, logger);
24972
25232
  const existingCleanup = result.steps.cleanup;
24973
25233
  if (existingCleanup) {
24974
25234
  existingCleanup.message += ", worktree cleaned up";
@@ -24978,10 +25238,30 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24978
25238
  };
24979
25239
  }
24980
25240
  const transactionCount = context.result.steps.import?.details?.summary?.totalTransactions || 0;
25241
+ logger.startSection("Summary");
25242
+ logger.info(`\u2705 Import completed successfully`);
25243
+ logger.info(`Total transactions imported: ${transactionCount}`);
25244
+ if (context.result.steps.reconcile?.details?.actualBalance) {
25245
+ logger.info(`Balance reconciliation: \u2705 Matched (${context.result.steps.reconcile.details.actualBalance})`);
25246
+ }
25247
+ logger.info(`Log file: ${logger.getLogPath()}`);
25248
+ logger.endSection();
24981
25249
  return buildSuccessResult4(result, `Successfully imported ${transactionCount} transaction(s)`);
24982
25250
  } catch (error45) {
24983
- result.steps.cleanup = buildStepResult(true, "Worktree cleaned up after failure (CSV files preserved for retry)", {
24984
- cleanedAfterFailure: true,
25251
+ const worktreePath = context.result.steps.worktree?.details?.path;
25252
+ const keepWorktree = options.keepWorktreeOnError ?? true;
25253
+ logger.error("Pipeline step failed", error45);
25254
+ if (keepWorktree && worktreePath) {
25255
+ logger.warn(`Worktree preserved at: ${worktreePath}`);
25256
+ logger.info(`To continue manually: cd ${worktreePath}`);
25257
+ logger.info(`To clean up: git worktree remove ${worktreePath}`);
25258
+ }
25259
+ logger.info(`Log file: ${logger.getLogPath()}`);
25260
+ result.steps.cleanup = buildStepResult(true, keepWorktree ? `Worktree preserved for debugging (CSV files preserved for retry)` : "Worktree cleaned up after failure (CSV files preserved for retry)", {
25261
+ cleanedAfterFailure: !keepWorktree,
25262
+ worktreePreserved: keepWorktree,
25263
+ worktreePath,
25264
+ preserveReason: keepWorktree ? "error occurred" : undefined,
24985
25265
  csvCleanup: { deleted: [] }
24986
25266
  });
24987
25267
  if (error45 instanceof NoTransactionsError) {
@@ -24992,11 +25272,18 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24992
25272
  }
24993
25273
  return buildErrorResult5(result, result.error, result.hint);
24994
25274
  }
25275
+ }, {
25276
+ keepOnError: options.keepWorktreeOnError ?? true,
25277
+ logger
24995
25278
  });
24996
25279
  } catch (error45) {
25280
+ logger.error("Pipeline failed", error45);
24997
25281
  result.steps.worktree = buildStepResult(false, `Failed to create worktree: ${error45 instanceof Error ? error45.message : String(error45)}`);
24998
25282
  result.error = "Failed to create worktree";
24999
25283
  return buildErrorResult5(result, result.error);
25284
+ } finally {
25285
+ logger.endSection();
25286
+ await logger.flush();
25000
25287
  }
25001
25288
  }
25002
25289
  var import_pipeline_default = tool({
@@ -25011,25 +25298,40 @@ This tool orchestrates the full import workflow in an isolated git worktree:
25011
25298
  4. **Import**: Imports transactions to the journal
25012
25299
  5. **Reconcile**: Validates closing balance matches CSV metadata
25013
25300
  6. **Merge**: Merges worktree to main with --no-ff
25014
- 7. **Cleanup**: Removes worktree
25301
+ 7. **Cleanup**: Removes worktree (or preserves on error)
25015
25302
 
25016
25303
  **Safety Features:**
25017
25304
  - All changes happen in isolated worktree
25018
- - If any step fails, worktree is discarded (main branch untouched)
25305
+ - If any step fails, worktree is preserved by default for debugging
25019
25306
  - Balance reconciliation ensures data integrity
25020
25307
  - Atomic commit with merge --no-ff preserves history
25021
25308
 
25309
+ **Worktree Cleanup:**
25310
+ - On success: Worktree is always cleaned up
25311
+ - On error (default): Worktree is kept at /tmp/import-worktree-<uuid> for debugging
25312
+ - On error (--keepWorktreeOnError false): Worktree is removed (old behavior)
25313
+ - Manual cleanup: git worktree remove /tmp/import-worktree-<uuid>
25314
+ - Auto cleanup: System reboot (worktrees are in /tmp)
25315
+
25316
+ **Logging:**
25317
+ - All operations are logged to .memory/import-<timestamp>.md
25318
+ - Log includes full command output, timing, and error details
25319
+ - Log path is included in tool output for easy access
25320
+ - NO console output (avoids polluting OpenCode TUI)
25321
+
25022
25322
  **Usage:**
25023
25323
  - Basic: import-pipeline (processes all pending CSVs)
25024
25324
  - Filtered: import-pipeline --provider ubs --currency chf
25025
25325
  - With manual balance: import-pipeline --closingBalance "CHF 1234.56"
25026
- - Skip classify: import-pipeline --skipClassify true`,
25326
+ - Skip classify: import-pipeline --skipClassify true
25327
+ - Always cleanup: import-pipeline --keepWorktreeOnError false`,
25027
25328
  args: {
25028
25329
  provider: tool.schema.string().optional().describe('Filter by provider (e.g., "ubs", "revolut")'),
25029
25330
  currency: tool.schema.string().optional().describe('Filter by currency (e.g., "chf", "eur")'),
25030
25331
  closingBalance: tool.schema.string().optional().describe("Manual closing balance override (if not in CSV metadata)"),
25031
25332
  account: tool.schema.string().optional().describe("Manual account override (auto-detected from rules file if not provided)"),
25032
- skipClassify: tool.schema.boolean().optional().describe("Skip the classify step (default: false)")
25333
+ skipClassify: tool.schema.boolean().optional().describe("Skip the classify step (default: false)"),
25334
+ keepWorktreeOnError: tool.schema.boolean().optional().describe("Keep worktree on error for debugging (default: true)")
25033
25335
  },
25034
25336
  async execute(params, context) {
25035
25337
  const { directory, agent } = context;
@@ -25038,21 +25340,22 @@ This tool orchestrates the full import workflow in an isolated git worktree:
25038
25340
  currency: params.currency,
25039
25341
  closingBalance: params.closingBalance,
25040
25342
  account: params.account,
25041
- skipClassify: params.skipClassify
25343
+ skipClassify: params.skipClassify,
25344
+ keepWorktreeOnError: params.keepWorktreeOnError
25042
25345
  });
25043
25346
  }
25044
25347
  });
25045
25348
  // src/tools/init-directories.ts
25046
- import * as fs14 from "fs";
25047
- import * as path13 from "path";
25349
+ import * as fs15 from "fs";
25350
+ import * as path14 from "path";
25048
25351
  async function initDirectories(directory) {
25049
25352
  try {
25050
25353
  const config2 = loadImportConfig(directory);
25051
25354
  const directoriesCreated = [];
25052
25355
  const gitkeepFiles = [];
25053
- const importBase = path13.join(directory, "import");
25054
- if (!fs14.existsSync(importBase)) {
25055
- fs14.mkdirSync(importBase, { recursive: true });
25356
+ const importBase = path14.join(directory, "import");
25357
+ if (!fs15.existsSync(importBase)) {
25358
+ fs15.mkdirSync(importBase, { recursive: true });
25056
25359
  directoriesCreated.push("import");
25057
25360
  }
25058
25361
  const pathsToCreate = [
@@ -25062,20 +25365,20 @@ async function initDirectories(directory) {
25062
25365
  { key: "unrecognized", path: config2.paths.unrecognized }
25063
25366
  ];
25064
25367
  for (const { path: dirPath } of pathsToCreate) {
25065
- const fullPath = path13.join(directory, dirPath);
25066
- if (!fs14.existsSync(fullPath)) {
25067
- fs14.mkdirSync(fullPath, { recursive: true });
25368
+ const fullPath = path14.join(directory, dirPath);
25369
+ if (!fs15.existsSync(fullPath)) {
25370
+ fs15.mkdirSync(fullPath, { recursive: true });
25068
25371
  directoriesCreated.push(dirPath);
25069
25372
  }
25070
- const gitkeepPath = path13.join(fullPath, ".gitkeep");
25071
- if (!fs14.existsSync(gitkeepPath)) {
25072
- fs14.writeFileSync(gitkeepPath, "");
25073
- gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
25373
+ const gitkeepPath = path14.join(fullPath, ".gitkeep");
25374
+ if (!fs15.existsSync(gitkeepPath)) {
25375
+ fs15.writeFileSync(gitkeepPath, "");
25376
+ gitkeepFiles.push(path14.join(dirPath, ".gitkeep"));
25074
25377
  }
25075
25378
  }
25076
- const gitignorePath = path13.join(importBase, ".gitignore");
25379
+ const gitignorePath = path14.join(importBase, ".gitignore");
25077
25380
  let gitignoreCreated = false;
25078
- if (!fs14.existsSync(gitignorePath)) {
25381
+ if (!fs15.existsSync(gitignorePath)) {
25079
25382
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25080
25383
  /incoming/*.csv
25081
25384
  /incoming/*.pdf
@@ -25093,7 +25396,7 @@ async function initDirectories(directory) {
25093
25396
  .DS_Store
25094
25397
  Thumbs.db
25095
25398
  `;
25096
- fs14.writeFileSync(gitignorePath, gitignoreContent);
25399
+ fs15.writeFileSync(gitignorePath, gitignoreContent);
25097
25400
  gitignoreCreated = true;
25098
25401
  }
25099
25402
  const parts = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",