Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

FP&A Tooling Agent UX — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Improve agent UX for FP&A Excel analysis by adding excel/profile and excel/find, removing excel/summary, renaming the context tool, and rewriting tool descriptions to funnel agents toward the Data Map.

Architecture: Three host-side functions added to functions.clj (profile, find-in-workbook, col-index->letter), wired through sandbox.clj into SCI. Tool descriptions updated in fpna.clj. TDD throughout.

Tech Stack: Clojure, FastExcel (streaming reader), SCI (sandbox), clojure.test


Task 1: Add col-index->letter Helper

The excel/find function needs to convert 0-based column indices back to Excel letters (e.g., 0→"A", 27→"AB"). This is the inverse of the existing col-letter->index.

Files:

Step 1: Write the failing test

In functions_test.clj, add after the test-col-letter->index test:

(deftest test-col-index->letter
  (let [col-index->letter @#'excel.fn/col-index->letter]
    (testing "single-letter columns"
      (is (= "A" (col-index->letter 0)))
      (is (= "B" (col-index->letter 1)))
      (is (= "Z" (col-index->letter 25))))
    (testing "double-letter columns"
      (is (= "AA" (col-index->letter 26)))
      (is (= "AB" (col-index->letter 27)))
      (is (= "AZ" (col-index->letter 51)))
      (is (= "BA" (col-index->letter 52)))
      (is (= "ZZ" (col-index->letter 701))))
    (testing "triple-letter columns"
      (is (= "AAA" (col-index->letter 702))))))

Step 2: Run test to verify it fails

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: ERROR — col-index->letter doesn't exist.

Step 3: Write minimal implementation

In functions.clj, add after the col-letter->index function (after line 77):

(defn ^:private col-index->letter
  "Converts a 0-based column index to Excel column letters. 0=A, 1=B, 25=Z, 26=AA."
  ^String [^long idx]
  (loop [n   (inc idx)
         acc ""]
    (if (pos? n)
      (let [r (mod (dec n) 26)]
        (recur (quot (dec n) 26) (str (char (+ 65 r)) acc)))
      acc)))

Step 4: Run test to verify it passes

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass, 0 failures, 0 errors.

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add col-index->letter helper for Excel column conversion"

Task 2: Add excel/find to functions.clj

Implement the host-side find-in-workbook function that searches all sheets for a case-insensitive substring match.

Files:

Step 1: Write the failing tests

In functions_test.clj, add before the test-named-ranges-filters-hidden test:

(deftest test-find-in-workbook
  (let [bs (create-test-workbook {"P&L"    [["Category" "Jan" "Feb"]
                                             ["Umsatzerlöse" 100 200]
                                             ["Materialaufwand" 50 80]]
                                  "Budget" [["Item" "Amount"]
                                            ["Umsatzerlöse (Plan)" 300]
                                            ["Miete" 120]]})
        wb (workbook-from bs)]
    (try
      (testing "finds substring across sheets, case-insensitive"
        (let [results (excel.fn/find-in-workbook wb "umsatz")]
          (is (= 2 (count results)))
          (is (= #{"P&L" "Budget"} (set (map :sheet results))))
          (is (every? #(re-find #"(?i)umsatz" (:value %)) results))))
      (testing "returns correct cell addresses"
        (let [results (excel.fn/find-in-workbook wb "Materialaufwand")]
          (is (= 1 (count results)))
          (is (= "A3" (:cell (first results))))
          (is (= "P&L" (:sheet (first results))))))
      (testing "skips numeric cells"
        (let [results (excel.fn/find-in-workbook wb "100")]
          (is (= 0 (count results)))))
      (testing "returns empty vec for no matches"
        (is (= [] (excel.fn/find-in-workbook wb "nonexistent"))))
      (finally (.close wb)))))


(deftest test-find-in-workbook-truncation
  (testing "caps results at 100 with truncation marker"
    (let [;; Create a sheet with 110 rows all containing "match"
          rows (vec (cons ["Header"] (mapv (fn [i] [(str "match-" i)]) (range 110))))
          bs   (create-test-workbook {"Sheet1" rows})
          wb   (workbook-from bs)]
      (try
        (let [results (excel.fn/find-in-workbook wb "match")]
          (is (= 101 (count results)))
          (is (= true (:truncated (last results)))))
        (finally (.close wb))))))

Step 2: Run tests to verify they fail

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: ERROR — find-in-workbook doesn't exist.

Step 3: Write minimal implementation

In functions.clj, add after the read-range function (after line 149). This function needs the col-index->letter helper from Task 1:

(def ^:private find-max-results 100)


(defn find-in-workbook
  "Searches all sheets for cells containing `search-str` (case-insensitive substring).

  Returns a vector of {:sheet s :cell ref :value v} maps. Caps at 100 results;
  if truncated, appends {:truncated true} as the last element."
  [^ReadableWorkbook workbook ^String search-str]
  (let [needle (.toLowerCase search-str)]
    (loop [sheets  (iterator-seq (.iterator (.getSheets workbook)))
           results (transient [])]
      (if-let [^Sheet sheet (first sheets)]
        (let [sheet-name (.getName sheet)
              results    (with-open [stream (.openStream sheet)]
                           (loop [rows    (iterator-seq (.iterator stream))
                                  results results]
                             (if-let [^Row row (first rows)]
                               (let [results (loop [col-idx 0
                                                    results results]
                                               (if (< col-idx (.getCellCount row))
                                                 (let [^Cell cell (.getCell row (int col-idx))
                                                       text       (when cell
                                                                    (let [v (util.excel/read-cell-value cell)]
                                                                      (when (string? v) v)))]
                                                   (if (and text (.contains (.toLowerCase text) needle))
                                                     (let [results (conj! results
                                                                          {:sheet sheet-name
                                                                           :cell  (str (col-index->letter col-idx)
                                                                                       (inc (- (.getRowNum row) 1)))
                                                                           :value text})]
                                                       (if (>= (count results) find-max-results)
                                                         (reduced results)
                                                         (recur (inc col-idx) results)))
                                                     (recur (inc col-idx) results)))
                                                 results))]
                                 (if (and (not (reduced? results))
                                          (>= (count results) find-max-results))
                                   results
                                   (recur (rest rows)
                                          (if (reduced? results) @results results))))
                               results)))]
          (if (>= (count results) find-max-results)
            (persistent! (conj! results {:truncated true}))
            (recur (rest sheets) results)))
        (persistent! results)))))

Note: FastExcel Row.getRowNum() is 1-based, so the cell address uses (.getRowNum row) directly (no need to add 1).

Step 4: Run tests to verify they pass

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass.

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add excel/find cross-sheet substring search"

Task 3: Add excel/profile to functions.clj

Implement the host-side profile function that returns a complete sheet profile in one call.

Files:

Step 1: Write the failing tests

In functions_test.clj, add before the test-find-in-workbook test:

(deftest test-profile
  (let [;; Build a workbook with merged regions and named ranges
        wb-poi  (XSSFWorkbook.)
        out     (ByteArrayOutputStream.)
        sh      (.createSheet wb-poi "P&L")
        _       (.createSheet wb-poi "Budget")]
    ;; Row 0 (header): 2 cells
    (let [r (.createRow sh 0)]
      (.setCellValue (.createCell r 0) "Category")
      (.setCellValue (.createCell r 1) "Jan"))
    ;; Rows 1-12: 3 cells each (13 total rows, so sample-rows = rows 2-11 = 10 rows)
    (doseq [i (range 1 13)]
      (let [r (.createRow sh i)]
        (.setCellValue (.createCell r 0) (str "Item" i))
        (.setCellValue (.createCell r 1) (double (* i 10)))
        (.setCellValue (.createCell r 2) (double (* i 20)))))
    ;; Merged region
    (.addMergedRegion sh (org.apache.poi.ss.util.CellRangeAddress. 0 0 0 1))
    ;; Named ranges: one scoped to P&L, one to Budget, one workbook-level
    (let [n1 (.createName wb-poi)]
      (.setNameName n1 "Revenue")
      (.setRefersToFormula n1 "'P&L'!$A$2:$A$10"))
    (let [n2 (.createName wb-poi)]
      (.setNameName n2 "BudgetItems")
      (.setRefersToFormula n2 "Budget!$A$1:$A$5")
      (.setSheetIndex n2 1))
    (let [n3 (.createName wb-poi)]
      (.setNameName n3 "GlobalRange")
      (.setRefersToFormula n3 "'P&L'!$B$1:$B$10"))
    (.write wb-poi out)
    (.close wb-poi)
    (let [bs         (.toByteArray out)
          wb         (workbook-from bs)
          sheet-names (excel.fn/sheets wb)]
      (try
        (let [result (excel.fn/profile wb bs sheet-names "P&L" 0)]
          (testing "row-count and column-count"
            (is (= 13 (:row-count result)))
            (is (= 3 (:column-count result))))
          (testing "headers padded to max width"
            (is (= ["Category" "Jan" nil] (:headers result))))
          (testing "sample-rows are rows 2-11 (max 10)"
            (is (= 10 (count (:sample-rows result))))
            (is (= "Item1" (ffirst (:sample-rows result)))))
          (testing "merged-regions included"
            (is (= 1 (count (:merged-regions result))))
            (is (= "A1:B1" (:range (first (:merged-regions result))))))
          (testing "named-ranges filtered: includes P&L-scoped and workbook-scoped, excludes Budget-scoped"
            (let [names (set (map :name (:named-ranges result)))]
              (is (contains? names "Revenue"))
              (is (contains? names "GlobalRange"))
              (is (not (contains? names "BudgetItems"))))))
        (finally (.close wb))))))


(deftest test-profile-empty-sheet
  (let [bs (create-test-workbook {"Empty" []})
        wb (workbook-from bs)
        sheet-names (excel.fn/sheets wb)]
    (try
      (let [result (excel.fn/profile wb bs sheet-names "Empty" 0)]
        (is (= 0 (:row-count result)))
        (is (= 0 (:column-count result)))
        (is (= [] (:headers result)))
        (is (= [] (:sample-rows result)))
        (is (= [] (:merged-regions result))))
      (finally (.close wb)))))


(deftest test-profile-few-rows
  (testing "sample-rows returns only available data rows when < 10"
    (let [bs (create-test-workbook {"Sheet1" [["H1" "H2"]
                                               ["A" "B"]
                                               ["C" "D"]]})
          wb (workbook-from bs)
          sheet-names (excel.fn/sheets wb)]
      (try
        (let [result (excel.fn/profile wb bs sheet-names "Sheet1" 0)]
          (is (= 2 (count (:sample-rows result))))
          (is (= [["A" "B"] ["C" "D"]] (:sample-rows result))))
        (finally (.close wb))))))

Step 2: Run tests to verify they fail

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: ERROR — profile doesn't exist.

Step 3: Write minimal implementation

In functions.clj, add after the summary function (after line 59):

(def ^:private profile-sample-rows 10)


(defn profile
  "Returns a complete profile of a single sheet.

  Combines dimensions, headers, sample rows, merged regions, and named ranges
  into one map. `sheet-name` is the sheet name, `sheet-index` is its 0-based
  index. `xlsx-bytes` are needed for merged-regions (StAX). `all-sheet-names`
  is the full workbook sheet list (for named-range scope resolution)."
  [^ReadableWorkbook workbook ^bytes xlsx-bytes all-sheet-names
   ^String sheet-name ^long sheet-index]
  (let [^Sheet sheet (.orElse (.findSheet workbook sheet-name) nil)
        _            (when-not sheet
                       (throw (ex-info (str "Sheet not found: " sheet-name)
                                       {:sheet-name sheet-name})))]
    (with-open [stream (.openStream sheet)]
      (let [iter      (.iterator stream)
            first-row (when (.hasNext iter) ^Row (.next iter))
            ;; Collect sample rows (up to profile-sample-rows after header)
            sample    (loop [n       0
                             rows    (transient [])
                             max-cols (if first-row (.getCellCount first-row) 0)]
                        (if (and (.hasNext iter) (< n profile-sample-rows))
                          (let [^Row r (.next iter)]
                            (recur (inc n)
                                   (conj! rows r)
                                   (max max-cols (.getCellCount r))))
                          ;; Continue counting remaining rows + max-cols
                          (loop [total    (+ (if first-row 1 0) n)
                                 max-cols max-cols]
                            (if (.hasNext iter)
                              (let [^Row r (.next iter)]
                                (recur (inc total) (max max-cols (.getCellCount r))))
                              {:rows      (persistent! rows)
                               :row-count total
                               :max-cols  max-cols}))))
            {:keys [rows row-count max-cols]} sample
            read-row  (fn [^Row row width]
                        (mapv (fn [col-idx]
                                (util.excel/read-cell-value
                                 (when (< col-idx (.getCellCount row))
                                   (.getCell row (int col-idx)))))
                              (range width)))
            regions   (try
                        (merged-regions xlsx-bytes sheet-name sheet-index)
                        (catch Exception _ []))
            all-named (named-ranges xlsx-bytes all-sheet-names)
            sheet-named (filterv (fn [{:keys [refers-to scope]}]
                                   (or (= scope :workbook)
                                       (= scope sheet-name)
                                       (and (string? refers-to)
                                            (.contains ^String refers-to sheet-name))))
                                 all-named)]
        {:row-count      row-count
         :column-count   max-cols
         :headers        (if first-row (read-row first-row max-cols) [])
         :sample-rows    (mapv #(read-row % max-cols) rows)
         :merged-regions regions
         :named-ranges   sheet-named}))))

Step 4: Run tests to verify they pass

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass.

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add excel/profile for single-call sheet profiling"

Task 4: Wire profile and find into Sandbox, Remove summary

Update the SCI sandbox to expose excel/profile and excel/find, and remove excel/summary.

Files:

Step 1: Write the failing tests

In sandbox_test.clj, add after the test-evaluate-loop-over-variable-width-rows test:

(def ^:private profile-bytes
  (test-xlsx-bytes {"Revenue" [["Month" "Amount"]
                                ["Jan" 100]
                                ["Feb" 200]]}))


(deftest test-evaluate-profile
  (testing "excel/profile returns sheet structure"
    (let [result (edn/read-string
                  (sandbox/evaluate-excel
                   "(excel/profile \"Revenue\")" profile-bytes))]
      (is (map? result))
      (is (= 3 (:row-count result)))
      (is (= 2 (:column-count result)))
      (is (= ["Month" "Amount"] (:headers result)))
      (is (= 2 (count (:sample-rows result))))
      (is (vector? (:merged-regions result)))
      (is (vector? (:named-ranges result))))))


(deftest test-evaluate-find
  (testing "excel/find searches across sheets"
    (let [bytes  (test-xlsx-bytes {"Sheet1" [["Revenue" "Cost"]
                                              ["Umsatz" 100]]
                                   "Sheet2" [["Umsatzerlöse" 200]]})
          result (edn/read-string
                  (sandbox/evaluate-excel
                   "(excel/find \"umsatz\")" bytes))]
      (is (vector? result))
      (is (= 2 (count result)))
      (is (every? #(contains? % :sheet) result))
      (is (every? #(contains? % :cell) result))
      (is (every? #(contains? % :value) result)))))


(deftest test-evaluate-summary-removed
  (testing "excel/summary is no longer available"
    (let [result (edn/read-string
                  (sandbox/evaluate-excel "(excel/summary)" sample-bytes))]
      (is (:error result)))))

Step 2: Run tests to verify they fail

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: test-evaluate-profile and test-evaluate-find fail (functions not wired), test-evaluate-summary-removed fails (summary still works).

Step 3: Write minimal implementation

In sandbox.clj, make these changes:

  1. Replace excel/summary with excel/profile and excel/find in allowed-symbols (line 56):
     ;; Replace this line:
     excel/sheets excel/summary excel/read excel/merged-regions excel/named-ranges
     ;; With:
     excel/sheets excel/profile excel/read excel/find excel/merged-regions excel/named-ranges
  1. In build-sci-opts (lines 66-77), replace the summary binding and add profile + find:
  (let [sheet-names (excel.functions/sheets workbook)
        excel-ns    {'sheets         (fn [] sheet-names)
                     'profile        (fn [sheet-name]
                                       (let [idx (.indexOf ^java.util.List sheet-names sheet-name)]
                                         (when (= -1 idx)
                                           (throw (ex-info (str "Sheet not found: " sheet-name)
                                                           {:sheet-name sheet-name})))
                                         (excel.functions/profile workbook file-bytes sheet-names
                                                                  sheet-name idx)))
                     'read           (fn
                                       ([range-str] (excel.functions/read-range workbook range-str {}))
                                       ([range-str opts] (excel.functions/read-range workbook range-str opts)))
                     'find           (fn [search-str]
                                       (excel.functions/find-in-workbook workbook search-str))
                     'merged-regions (fn [sheet-name]
                                       (let [idx (.indexOf ^java.util.List sheet-names sheet-name)]
                                         (when (= -1 idx)
                                           (throw (ex-info (str "Sheet not found: " sheet-name)
                                                           {:sheet-name sheet-name})))
                                         (excel.functions/merged-regions file-bytes sheet-name idx)))
                     'named-ranges   (fn [] (excel.functions/named-ranges file-bytes sheet-names))}]

Step 4: Run tests to verify they pass

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass. Note that test-evaluate-summary (the old test from before) will now fail since summary is removed — update it:

In sandbox_test.clj, find test-evaluate-summary and delete it (it's superseded by test-evaluate-summary-removed and test-evaluate-profile).

Re-run tests to confirm all pass.

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/fpna/excel/sandbox.clj test/com/getorcha/link/mcp/tools/fpna/excel/sandbox_test.clj
git commit -m "feat: wire excel/profile and excel/find into sandbox, remove excel/summary"

Task 5: Update Tool Descriptions

Rename orcha-fpna-contextorcha-fpna-data-map, rewrite all tool descriptions.

Files:

Step 1: No test needed — this is a description-only change

Step 2: Update fpna.clj

Replace the ::context defmethod (lines 10-19):

(defmethod tools/-tool ::context [_]
  {:name        "orcha-fpna-data-map"
   :description "Retrieve the financial data map for a legal entity — a structured index of all known financial data sources (budget, payroll, AR, revenues, cash flow, travel expenses) with file locations, sheet names, and cell ranges. Returns one of three statuses: 'boot' (no data map exists — returns the discovery protocol to create one), 'draft' (partial data map from a previous session — returns protocol + draft for continuation), or 'ready' (complete data map for downstream use). You MUST call this before using orcha-fpna-excel or orcha-fpna-list-files."
   :inputSchema {:type       "object"
                 :properties {"legal_entity_id" {:type        "string"
                                                  :description "UUID of the legal entity. Optional if the identity has access to only one legal entity."
                                                  :format      "uuid"}}
                 :required   []}
   :handler     fpna.context/handle-fpna-context
   :scope       "fpna:read"})

Update ::list-files description (lines 22-38) — prepend the MUST line:

   :description "IMPORTANT: You MUST call orcha-fpna-data-map first. If a data map already exists, it tells you where all financial data lives — you may not need to list files at all.

List files in a legal entity's FP&A data directory. Returns file names, sizes, modification dates, and types. Use `file_type` to filter (e.g., 'xlsx'). Use `path` to navigate subdirectories. Use `include_summary` to get Excel sheet names, column headers, and row counts for triage."

Replace ::excel description (lines 65-88) with the new function reference. The full description:

   :description "IMPORTANT: You MUST call orcha-fpna-data-map first to check if the data you need has already been mapped. The data map tells you which file, sheet, and cell range contains each financial data point. Do not scan files manually if a data map already exists.

Execute Clojure code to analyze an Excel file. The code runs in a sandboxed environment with these functions available:

**excel/sheets** `()` — Returns vector of sheet names.
  `(excel/sheets)` => `[\"Sheet1\" \"Sheet2\"]`

**excel/profile** `(sheet-name)` — Returns a complete profile of a sheet: row-count, column-count (true max width), headers (first row), sample-rows (rows 2-11), merged-regions, and named-ranges scoped to that sheet. Use this to understand a sheet's structure in one call.
  `(excel/profile \"Sheet1\")` => `{:row-count 50 :column-count 5 :headers [...] :sample-rows [[...] ...] :merged-regions [...] :named-ranges [...]}`

**excel/read** `(range)` or `(range opts)` — Reads cells. Range uses Excel notation: \"A1\", \"A1:D10\", \"Sheet1!A1:D10\". Always returns a 2D vector, padding short rows with nil.
  `(excel/read \"A1\")` => `[[42]]`
  `(excel/read \"A1:C2\")` => `[[1 2 3] [4 5 6]]`
  With `{:metadata? true}`, values become `{:value v :formula \"...\" :format \"...\"}`.

**excel/find** `(search-string)` — Case-insensitive substring search across all sheets. Returns up to 100 matches as [{:sheet s :cell ref :value v} ...].
  `(excel/find \"Revenue\")` => `[{:sheet \"P&L\" :cell \"A5\" :value \"Revenue\"}]`

**excel/merged-regions** `(sheet-name)` — Returns merged cell ranges (no values).
  `(excel/merged-regions \"Sheet1\")` => `[{:range \"B1:F1\"} {:range \"A3:A8\"}]`

**excel/named-ranges** `()` — Returns named ranges in the workbook.
  `(excel/named-ranges)` => `[{:name \"Revenue\" :refers-to \"Sheet1!$B$2:$B$50\" :scope :workbook}]`

**Available Clojure core:** map, filter, remove, reduce, mapv, filterv, into, get, get-in, assoc, dissoc, update, select-keys, keys, vals, merge, zipmap, group-by, sort-by, frequencies, first, second, last, rest, next, nth, ffirst, take, drop, take-while, drop-while, concat, cons, conj, distinct, flatten, reverse, partition, partition-by, interleave, interpose, count, empty?, not-empty, contains?, some, some?, every?, vector, hash-map, hash-set, set, list, vec, seq, range, str, subs, let, fn, do, ->, ->>, as->, cond->, cond->>, some->, some->>, +, -, *, /, inc, dec, mod, rem, quot, max, min, abs, <, >, <=, >=, =, not=, compare, and, or, not, if, when, when-let, if-let, cond, condp, case, apply, partial, comp, complement, every-pred, identity, re-find, re-matches, re-seq, nil?, string?, number?, integer?, double?, keyword?, map?, vector?, set?, seq?, coll?, boolean?, true?, false?, zero?, pos?, neg?, even?, odd?, Math/floor, Math/ceil, Math/round, Math/pow, clojure.string/split, clojure.string/join, clojure.string/replace, clojure.string/trim, clojure.string/lower-case, clojure.string/upper-case, clojure.string/includes?, clojure.string/starts-with?, clojure.string/ends-with?.

**Not available:** No IO, no Java interop, no def/defn, no loop/recur, no repeat/repeatedly, no atoms. 30s timeout. Max file size 50 MB."

Step 3: Commit

git add src/com/getorcha/link/mcp/tools/fpna.clj
git commit -m "feat: rename context to data-map, rewrite tool descriptions for agent funnel"

Task 6: Remove summary from functions.clj and Clean Up

Remove the summary function from functions.clj and remove or update tests that depend on it.

Files:

Step 1: Remove summary function

In functions.clj, delete the entire summary function (lines 32-59).

Step 2: Update tests

In functions_test.clj:

Step 3: Run all tests

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass.

Step 4: Lint

clj-kondo --lint src test dev

Expected: 0 errors, 0 warnings.

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "chore: remove excel/summary, superseded by excel/profile"

Task 7: Final Verification

Run the full test suite for both namespaces and lint.

Step 1: Run all FP&A tests

clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]' 2>&1 | grep -A 3 -E "(FAIL in|ERROR in|Ran .* tests)"

Expected: all tests pass, 0 failures, 0 errors.

Step 2: Lint

clj-kondo --lint src test dev

Expected: 0 errors, 0 warnings.