@fuzzle/opencode-accountant 0.4.1 → 0.4.2

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.
@@ -97,11 +97,71 @@ 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`
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
  }
@@ -24028,7 +24052,7 @@ async function processCsvFile(csvFile, rulesMapping, directory, hledgerExecutor)
24028
24052
  transactionYear
24029
24053
  };
24030
24054
  }
24031
- 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) {
24032
24056
  const restrictionError = checkAccountantAgent(agent, "import statements");
24033
24057
  if (restrictionError) {
24034
24058
  return restrictionError;
@@ -24529,8 +24553,8 @@ It must be run inside an import worktree (use import-pipeline for the full workf
24529
24553
  }
24530
24554
  });
24531
24555
  // src/tools/import-pipeline.ts
24532
- import * as fs13 from "fs";
24533
- import * as path12 from "path";
24556
+ import * as fs14 from "fs";
24557
+ import * as path13 from "path";
24534
24558
 
24535
24559
  // src/utils/accountDeclarations.ts
24536
24560
  import * as fs12 from "fs";
@@ -24647,6 +24671,158 @@ function ensureAccountDeclarations(yearJournalPath, accounts) {
24647
24671
  };
24648
24672
  }
24649
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
+
24650
24826
  // src/tools/import-pipeline.ts
24651
24827
  class NoTransactionsError extends Error {
24652
24828
  constructor() {
@@ -24686,8 +24862,8 @@ function buildCommitMessage(provider, currency, fromDate, untilDate, transaction
24686
24862
  return `${parts.join(" ")}${dateRange}${txStr}`;
24687
24863
  }
24688
24864
  function cleanupIncomingFiles(worktree, context) {
24689
- const incomingDir = path12.join(worktree.mainRepoPath, "import/incoming");
24690
- if (!fs13.existsSync(incomingDir)) {
24865
+ const incomingDir = path13.join(worktree.mainRepoPath, "import/incoming");
24866
+ if (!fs14.existsSync(incomingDir)) {
24691
24867
  return;
24692
24868
  }
24693
24869
  const importStep = context.result.steps.import;
@@ -24702,11 +24878,11 @@ function cleanupIncomingFiles(worktree, context) {
24702
24878
  for (const fileResult of importResult.files) {
24703
24879
  if (!fileResult.csv)
24704
24880
  continue;
24705
- const filename = path12.basename(fileResult.csv);
24706
- const filePath = path12.join(incomingDir, filename);
24707
- if (fs13.existsSync(filePath)) {
24881
+ const filename = path13.basename(fileResult.csv);
24882
+ const filePath = path13.join(incomingDir, filename);
24883
+ if (fs14.existsSync(filePath)) {
24708
24884
  try {
24709
- fs13.unlinkSync(filePath);
24885
+ fs14.unlinkSync(filePath);
24710
24886
  deletedCount++;
24711
24887
  } catch (error45) {
24712
24888
  console.error(`[ERROR] Failed to delete ${filename}: ${error45 instanceof Error ? error45.message : String(error45)}`);
@@ -24717,9 +24893,13 @@ function cleanupIncomingFiles(worktree, context) {
24717
24893
  console.log(`[INFO] Cleaned up ${deletedCount} file(s) from import/incoming/`);
24718
24894
  }
24719
24895
  }
24720
- async function executeClassifyStep(context, worktree) {
24896
+ async function executeClassifyStep(context, worktree, logger) {
24897
+ logger?.startSection("Step 2: Classify Transactions");
24898
+ logger?.logStep("Classify", "start");
24721
24899
  if (context.options.skipClassify) {
24900
+ logger?.info("Classification skipped (skipClassify: true)");
24722
24901
  context.result.steps.classify = buildStepResult(true, "Classification skipped (skipClassify: true)");
24902
+ logger?.endSection();
24723
24903
  return;
24724
24904
  }
24725
24905
  const inWorktree = () => true;
@@ -24729,18 +24909,23 @@ async function executeClassifyStep(context, worktree) {
24729
24909
  let message = success2 ? "Classification complete" : "Classification had issues";
24730
24910
  if (classifyParsed.unrecognized?.length > 0) {
24731
24911
  message = `Classification complete with ${classifyParsed.unrecognized.length} unrecognized file(s)`;
24912
+ logger?.warn(`${classifyParsed.unrecognized.length} unrecognized file(s)`);
24732
24913
  }
24914
+ logger?.logStep("Classify", success2 ? "success" : "error", message);
24733
24915
  const details = {
24734
24916
  success: success2,
24735
24917
  unrecognized: classifyParsed.unrecognized,
24736
24918
  classified: classifyParsed
24737
24919
  };
24738
24920
  context.result.steps.classify = buildStepResult(success2, message, details);
24921
+ logger?.endSection();
24739
24922
  }
24740
- 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");
24741
24926
  const config2 = context.configLoader(worktree.path);
24742
- const pendingDir = path12.join(worktree.path, config2.paths.pending);
24743
- 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);
24744
24929
  const csvFiles = findCsvFiles(pendingDir, context.options.provider, context.options.currency);
24745
24930
  if (csvFiles.length === 0) {
24746
24931
  context.result.steps.accountDeclarations = buildStepResult(true, "No CSV files to process", {
@@ -24771,7 +24956,7 @@ async function executeAccountDeclarationsStep(context, worktree) {
24771
24956
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
24772
24957
  accountsAdded: [],
24773
24958
  journalUpdated: "",
24774
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24959
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24775
24960
  });
24776
24961
  return;
24777
24962
  }
@@ -24794,7 +24979,7 @@ async function executeAccountDeclarationsStep(context, worktree) {
24794
24979
  context.result.steps.accountDeclarations = buildStepResult(false, "Could not determine transaction year from CSV files", {
24795
24980
  accountsAdded: [],
24796
24981
  journalUpdated: "",
24797
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24982
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24798
24983
  });
24799
24984
  return;
24800
24985
  }
@@ -24805,19 +24990,28 @@ async function executeAccountDeclarationsStep(context, worktree) {
24805
24990
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
24806
24991
  accountsAdded: [],
24807
24992
  journalUpdated: "",
24808
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24993
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path13.relative(worktree.path, f))
24809
24994
  });
24810
24995
  return;
24811
24996
  }
24812
24997
  const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
24813
- 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
+ }
24814
25005
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
24815
25006
  accountsAdded: result.added,
24816
- journalUpdated: path12.relative(worktree.path, yearJournalPath),
24817
- 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))
24818
25009
  });
25010
+ logger?.endSection();
24819
25011
  }
24820
- 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");
24821
25015
  const inWorktree = () => true;
24822
25016
  const dryRunResult = await importStatements(worktree.path, context.agent, {
24823
25017
  provider: context.options.provider,
@@ -24826,20 +25020,30 @@ async function executeDryRunStep(context, worktree) {
24826
25020
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24827
25021
  const dryRunParsed = JSON.parse(dryRunResult);
24828
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
+ }
24829
25027
  context.result.steps.dryRun = buildStepResult(dryRunParsed.success, message, {
24830
25028
  success: dryRunParsed.success,
24831
25029
  summary: dryRunParsed.summary
24832
25030
  });
24833
25031
  if (!dryRunParsed.success) {
25032
+ logger?.error("Dry run found unknown accounts or errors");
25033
+ logger?.endSection();
24834
25034
  context.result.error = "Dry run found unknown accounts or errors";
24835
25035
  context.result.hint = "Add rules to categorize unknown transactions, then retry";
24836
25036
  throw new Error("Dry run failed");
24837
25037
  }
24838
25038
  if (dryRunParsed.summary?.totalTransactions === 0) {
25039
+ logger?.endSection();
24839
25040
  throw new NoTransactionsError;
24840
25041
  }
25042
+ logger?.endSection();
24841
25043
  }
24842
- async function executeImportStep(context, worktree) {
25044
+ async function executeImportStep(context, worktree, logger) {
25045
+ logger?.startSection("Step 5: Import Transactions");
25046
+ logger?.logStep("Import", "start");
24843
25047
  const inWorktree = () => true;
24844
25048
  const importResult = await importStatements(worktree.path, context.agent, {
24845
25049
  provider: context.options.provider,
@@ -24848,17 +25052,23 @@ async function executeImportStep(context, worktree) {
24848
25052
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24849
25053
  const importParsed = JSON.parse(importResult);
24850
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);
24851
25056
  context.result.steps.import = buildStepResult(importParsed.success, message, {
24852
25057
  success: importParsed.success,
24853
25058
  summary: importParsed.summary,
24854
25059
  error: importParsed.error
24855
25060
  });
24856
25061
  if (!importParsed.success) {
25062
+ logger?.error("Import failed", new Error(importParsed.error || "Unknown error"));
25063
+ logger?.endSection();
24857
25064
  context.result.error = `Import failed: ${importParsed.error || "Unknown error"}`;
24858
25065
  throw new Error("Import failed");
24859
25066
  }
25067
+ logger?.endSection();
24860
25068
  }
24861
- async function executeReconcileStep(context, worktree) {
25069
+ async function executeReconcileStep(context, worktree, logger) {
25070
+ logger?.startSection("Step 6: Reconcile Balance");
25071
+ logger?.logStep("Reconcile", "start");
24862
25072
  const inWorktree = () => true;
24863
25073
  const reconcileResult = await reconcileStatement(worktree.path, context.agent, {
24864
25074
  provider: context.options.provider,
@@ -24868,6 +25078,11 @@ async function executeReconcileStep(context, worktree) {
24868
25078
  }, context.configLoader, context.hledgerExecutor, inWorktree);
24869
25079
  const reconcileParsed = JSON.parse(reconcileResult);
24870
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
+ }
24871
25086
  context.result.steps.reconcile = buildStepResult(reconcileParsed.success, message, {
24872
25087
  success: reconcileParsed.success,
24873
25088
  actualBalance: reconcileParsed.actualBalance,
@@ -24876,12 +25091,17 @@ async function executeReconcileStep(context, worktree) {
24876
25091
  error: reconcileParsed.error
24877
25092
  });
24878
25093
  if (!reconcileParsed.success) {
25094
+ logger?.error("Reconciliation failed", new Error(reconcileParsed.error || "Balance mismatch"));
25095
+ logger?.endSection();
24879
25096
  context.result.error = `Reconciliation failed: ${reconcileParsed.error || "Balance mismatch"}`;
24880
25097
  context.result.hint = "Check for missing transactions or incorrect rules";
24881
25098
  throw new Error("Reconciliation failed");
24882
25099
  }
25100
+ logger?.endSection();
24883
25101
  }
24884
- 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");
24885
25105
  const importDetails = context.result.steps.import?.details;
24886
25106
  const reconcileDetails = context.result.steps.reconcile?.details;
24887
25107
  if (!importDetails || !reconcileDetails) {
@@ -24894,11 +25114,17 @@ async function executeMergeStep(context, worktree) {
24894
25114
  const transactionCount = importDetails.summary?.totalTransactions || 0;
24895
25115
  const commitMessage = buildCommitMessage(context.options.provider, context.options.currency, commitInfo.fromDate, commitInfo.untilDate, transactionCount);
24896
25116
  try {
25117
+ logger?.info(`Commit message: "${commitMessage}"`);
24897
25118
  mergeWorktree(worktree, commitMessage);
25119
+ logger?.logStep("Merge", "success", "Merged to main branch");
24898
25120
  const mergeDetails = { commitMessage };
24899
25121
  context.result.steps.merge = buildStepResult(true, `Merged to main: "${commitMessage}"`, mergeDetails);
24900
25122
  cleanupIncomingFiles(worktree, context);
25123
+ logger?.endSection();
24901
25124
  } catch (error45) {
25125
+ logger?.logStep("Merge", "error");
25126
+ logger?.error("Merge to main branch failed", error45);
25127
+ logger?.endSection();
24902
25128
  const message = `Merge failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
24903
25129
  context.result.steps.merge = buildStepResult(false, message);
24904
25130
  context.result.error = "Merge to main branch failed";
@@ -24916,6 +25142,13 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24916
25142
  if (restrictionError) {
24917
25143
  return restrictionError;
24918
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("");
24919
25152
  const result = {
24920
25153
  success: false,
24921
25154
  steps: {}
@@ -24930,33 +25163,47 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24930
25163
  };
24931
25164
  try {
24932
25165
  return await withWorktree(directory, async (worktree) => {
25166
+ logger.setContext("worktreeId", worktree.uuid);
25167
+ logger.setContext("worktreePath", worktree.path);
24933
25168
  result.worktreeId = worktree.uuid;
24934
25169
  result.steps.worktree = buildStepResult(true, `Created worktree at ${worktree.path}`, {
24935
25170
  path: worktree.path,
24936
25171
  branch: worktree.branch
24937
25172
  });
25173
+ logger.startSection("Step 1: Sync Files");
25174
+ logger.logStep("Sync Files", "start");
24938
25175
  try {
24939
25176
  const config2 = configLoader(directory);
24940
25177
  const syncResult = syncCSVFilesToWorktree(directory, worktree.path, config2.paths.import);
24941
25178
  if (syncResult.synced.length === 0 && syncResult.errors.length === 0) {
25179
+ logger.logStep("Sync Files", "success", "No CSV files to sync");
24942
25180
  result.steps.sync = buildStepResult(true, "No CSV files to sync", {
24943
25181
  synced: []
24944
25182
  });
24945
25183
  } else if (syncResult.errors.length > 0) {
25184
+ logger.warn(`Synced ${syncResult.synced.length} file(s) with ${syncResult.errors.length} error(s)`);
24946
25185
  result.steps.sync = buildStepResult(true, `Synced ${syncResult.synced.length} file(s) with ${syncResult.errors.length} error(s)`, { synced: syncResult.synced, errors: syncResult.errors });
24947
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
+ }
24948
25191
  result.steps.sync = buildStepResult(true, `Synced ${syncResult.synced.length} CSV file(s) to worktree`, { synced: syncResult.synced });
24949
25192
  }
25193
+ logger.endSection();
24950
25194
  } catch (error45) {
25195
+ logger.logStep("Sync Files", "error");
25196
+ logger.error("Failed to sync CSV files", error45);
25197
+ logger.endSection();
24951
25198
  const errorMsg = error45 instanceof Error ? error45.message : String(error45);
24952
25199
  result.steps.sync = buildStepResult(false, `Failed to sync CSV files: ${errorMsg}`, { synced: [], errors: [{ file: "unknown", error: errorMsg }] });
24953
25200
  }
24954
25201
  try {
24955
- await executeClassifyStep(context, worktree);
24956
- await executeAccountDeclarationsStep(context, worktree);
24957
- await executeDryRunStep(context, worktree);
24958
- await executeImportStep(context, worktree);
24959
- 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);
24960
25207
  try {
24961
25208
  const config2 = configLoader(directory);
24962
25209
  const cleanupResult = cleanupProcessedCSVFiles(directory, config2.paths.import);
@@ -24981,7 +25228,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24981
25228
  }
24982
25229
  });
24983
25230
  }
24984
- await executeMergeStep(context, worktree);
25231
+ await executeMergeStep(context, worktree, logger);
24985
25232
  const existingCleanup = result.steps.cleanup;
24986
25233
  if (existingCleanup) {
24987
25234
  existingCleanup.message += ", worktree cleaned up";
@@ -24991,10 +25238,30 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24991
25238
  };
24992
25239
  }
24993
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();
24994
25249
  return buildSuccessResult4(result, `Successfully imported ${transactionCount} transaction(s)`);
24995
25250
  } catch (error45) {
24996
- result.steps.cleanup = buildStepResult(true, "Worktree cleaned up after failure (CSV files preserved for retry)", {
24997
- 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,
24998
25265
  csvCleanup: { deleted: [] }
24999
25266
  });
25000
25267
  if (error45 instanceof NoTransactionsError) {
@@ -25005,11 +25272,18 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
25005
25272
  }
25006
25273
  return buildErrorResult5(result, result.error, result.hint);
25007
25274
  }
25275
+ }, {
25276
+ keepOnError: options.keepWorktreeOnError ?? true,
25277
+ logger
25008
25278
  });
25009
25279
  } catch (error45) {
25280
+ logger.error("Pipeline failed", error45);
25010
25281
  result.steps.worktree = buildStepResult(false, `Failed to create worktree: ${error45 instanceof Error ? error45.message : String(error45)}`);
25011
25282
  result.error = "Failed to create worktree";
25012
25283
  return buildErrorResult5(result, result.error);
25284
+ } finally {
25285
+ logger.endSection();
25286
+ await logger.flush();
25013
25287
  }
25014
25288
  }
25015
25289
  var import_pipeline_default = tool({
@@ -25024,25 +25298,40 @@ This tool orchestrates the full import workflow in an isolated git worktree:
25024
25298
  4. **Import**: Imports transactions to the journal
25025
25299
  5. **Reconcile**: Validates closing balance matches CSV metadata
25026
25300
  6. **Merge**: Merges worktree to main with --no-ff
25027
- 7. **Cleanup**: Removes worktree
25301
+ 7. **Cleanup**: Removes worktree (or preserves on error)
25028
25302
 
25029
25303
  **Safety Features:**
25030
25304
  - All changes happen in isolated worktree
25031
- - If any step fails, worktree is discarded (main branch untouched)
25305
+ - If any step fails, worktree is preserved by default for debugging
25032
25306
  - Balance reconciliation ensures data integrity
25033
25307
  - Atomic commit with merge --no-ff preserves history
25034
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
+
25035
25322
  **Usage:**
25036
25323
  - Basic: import-pipeline (processes all pending CSVs)
25037
25324
  - Filtered: import-pipeline --provider ubs --currency chf
25038
25325
  - With manual balance: import-pipeline --closingBalance "CHF 1234.56"
25039
- - Skip classify: import-pipeline --skipClassify true`,
25326
+ - Skip classify: import-pipeline --skipClassify true
25327
+ - Always cleanup: import-pipeline --keepWorktreeOnError false`,
25040
25328
  args: {
25041
25329
  provider: tool.schema.string().optional().describe('Filter by provider (e.g., "ubs", "revolut")'),
25042
25330
  currency: tool.schema.string().optional().describe('Filter by currency (e.g., "chf", "eur")'),
25043
25331
  closingBalance: tool.schema.string().optional().describe("Manual closing balance override (if not in CSV metadata)"),
25044
25332
  account: tool.schema.string().optional().describe("Manual account override (auto-detected from rules file if not provided)"),
25045
- 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)")
25046
25335
  },
25047
25336
  async execute(params, context) {
25048
25337
  const { directory, agent } = context;
@@ -25051,21 +25340,22 @@ This tool orchestrates the full import workflow in an isolated git worktree:
25051
25340
  currency: params.currency,
25052
25341
  closingBalance: params.closingBalance,
25053
25342
  account: params.account,
25054
- skipClassify: params.skipClassify
25343
+ skipClassify: params.skipClassify,
25344
+ keepWorktreeOnError: params.keepWorktreeOnError
25055
25345
  });
25056
25346
  }
25057
25347
  });
25058
25348
  // src/tools/init-directories.ts
25059
- import * as fs14 from "fs";
25060
- import * as path13 from "path";
25349
+ import * as fs15 from "fs";
25350
+ import * as path14 from "path";
25061
25351
  async function initDirectories(directory) {
25062
25352
  try {
25063
25353
  const config2 = loadImportConfig(directory);
25064
25354
  const directoriesCreated = [];
25065
25355
  const gitkeepFiles = [];
25066
- const importBase = path13.join(directory, "import");
25067
- if (!fs14.existsSync(importBase)) {
25068
- fs14.mkdirSync(importBase, { recursive: true });
25356
+ const importBase = path14.join(directory, "import");
25357
+ if (!fs15.existsSync(importBase)) {
25358
+ fs15.mkdirSync(importBase, { recursive: true });
25069
25359
  directoriesCreated.push("import");
25070
25360
  }
25071
25361
  const pathsToCreate = [
@@ -25075,20 +25365,20 @@ async function initDirectories(directory) {
25075
25365
  { key: "unrecognized", path: config2.paths.unrecognized }
25076
25366
  ];
25077
25367
  for (const { path: dirPath } of pathsToCreate) {
25078
- const fullPath = path13.join(directory, dirPath);
25079
- if (!fs14.existsSync(fullPath)) {
25080
- fs14.mkdirSync(fullPath, { recursive: true });
25368
+ const fullPath = path14.join(directory, dirPath);
25369
+ if (!fs15.existsSync(fullPath)) {
25370
+ fs15.mkdirSync(fullPath, { recursive: true });
25081
25371
  directoriesCreated.push(dirPath);
25082
25372
  }
25083
- const gitkeepPath = path13.join(fullPath, ".gitkeep");
25084
- if (!fs14.existsSync(gitkeepPath)) {
25085
- fs14.writeFileSync(gitkeepPath, "");
25086
- 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"));
25087
25377
  }
25088
25378
  }
25089
- const gitignorePath = path13.join(importBase, ".gitignore");
25379
+ const gitignorePath = path14.join(importBase, ".gitignore");
25090
25380
  let gitignoreCreated = false;
25091
- if (!fs14.existsSync(gitignorePath)) {
25381
+ if (!fs15.existsSync(gitignorePath)) {
25092
25382
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25093
25383
  /incoming/*.csv
25094
25384
  /incoming/*.pdf
@@ -25106,7 +25396,7 @@ async function initDirectories(directory) {
25106
25396
  .DS_Store
25107
25397
  Thumbs.db
25108
25398
  `;
25109
- fs14.writeFileSync(gitignorePath, gitignoreContent);
25399
+ fs15.writeFileSync(gitignorePath, gitignoreContent);
25110
25400
  gitignoreCreated = true;
25111
25401
  }
25112
25402
  const parts = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",