liteagents 2.22.0 → 2.22.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/CHANGELOG.md CHANGED
@@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.22.1] - 2026-09-01
13
+
14
+ ### Fixed
15
+ - **`/docs-builder` — the trailing-newline phantom line is dropped from every line count.**
16
+ `text.split('\n')` returns a trailing empty element for any file ending in a newline, so
17
+ `lines.length` was one over the real count. That phantom line reached the index row's "N
18
+ lines" (every row +1), the last H2's line range (one line past EOF), `scan`'s outline.json
19
+ `s`/`e`/`lines`, the ledger's per-file line count, the cleanup cost estimate, and the PARTIAL
20
+ guard (a page one line short of `MIN_PAGE_LINES` passed as complete). Fixed with a
21
+ `splitLines()` helper applied at the 7 counting/bounding sites, deliberately not at the sites
22
+ that map-and-rejoin file text. `docs/index.md` regenerated. Mirrored across all four kits.
23
+ - **`/docs-builder` — an empty page now reports PARTIAL instead of crashing `plan`.** A
24
+ regression from the fix above: `splitLines()` returns `[]` for a 0-byte file where the raw
25
+ split returned `['']`, so `pageStatus`'s unguarded `lines[0].trim()` threw a `TypeError` and
26
+ took `plan` down with it. An empty `.md` page is reachable (a touched placeholder, or
27
+ page-writing interrupted). Fixed at the indexing site — 0 lines is the correct count for an
28
+ empty file — so `pageStatus` guards on `lines.length` instead. Mirrored across all four kits.
29
+
12
30
  ## [2.22.0] - 2026-09-01
13
31
 
14
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "liteagents",
3
- "version": "2.22.0",
3
+ "version": "2.22.1",
4
4
  "description": "AI development toolkit with 11 specialized agents and 18 commands including live-canvas UI design with click-to-annotate feedback. Simple one-question installer for Claude, Opencode, Ampcode, and Droid.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -103,6 +103,18 @@ function fenceMask(lines) {
103
103
  // prose), sentences()'s own regex strip, and checkCitations/checkLinks doing none at all — so
104
104
  // a page documenting the citation/link syntax INSIDE a fence got its own example flagged as
105
105
  // a real violation. One mechanism: mask with fenceMask(), drop the masked lines.
106
+ // `text.split('\n')` returns a trailing EMPTY element for any file ending in a newline —
107
+ // which is nearly every file — so `lines.length` is real_lines + 1. That phantom line reached
108
+ // the index row's "N lines", the last H2's range (one line past EOF) and scan()'s outline.json.
109
+ // Use this wherever lines are COUNTED or a range is BOUNDED. The raw `.split('\n')` is still
110
+ // correct where the array is mapped and re-joined back into file text (stripFences,
111
+ // replaceOutsideFences): dropping the element there would strip the file's final newline.
112
+ function splitLines(text) {
113
+ const lines = text.split('\n');
114
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
115
+ return lines;
116
+ }
117
+
106
118
  function stripFences(text) {
107
119
  const lines = text.split('\n');
108
120
  const mask = fenceMask(lines);
@@ -229,7 +241,7 @@ function scan(files) {
229
241
  if (!files.length) die('usage: docs-builder.cjs scan <file.md...>');
230
242
  const records = [];
231
243
  for (const f of files) {
232
- const lines = read(f).split('\n');
244
+ const lines = splitLines(read(f));
233
245
  const mask = fenceMask(lines);
234
246
  const { h1, heads } = headings(lines, mask);
235
247
  const h2s = heads.filter(h => h.lvl === 2);
@@ -522,8 +534,13 @@ const MIN_PAGE_LINES = 10;
522
534
  function pageStatus(file) {
523
535
  if (!fs.existsSync(file)) return 'TODO';
524
536
  const txt = fs.readFileSync(file, 'utf8');
525
- const lines = txt.split('\n');
526
- const hasFrontmatter = lines[0].trim() === '---' && lines.slice(1).some(l => l.trim() === '---');
537
+ const lines = splitLines(txt);
538
+ // splitLines() returns [] for a 0-byte file — 0 lines is the right COUNT, but it means
539
+ // lines[0] can be undefined, where the old raw split('\n') always yielded ['']. An empty
540
+ // page is reachable (a touched placeholder, or page-writing interrupted before it wrote
541
+ // anything) and must read as PARTIAL, not throw and take `plan` down with it.
542
+ const hasFrontmatter = lines.length > 0 && lines[0].trim() === '---'
543
+ && lines.slice(1).some(l => l.trim() === '---');
527
544
  return hasFrontmatter && lines.length >= MIN_PAGE_LINES ? 'done' : 'PARTIAL';
528
545
  }
529
546
 
@@ -732,7 +749,7 @@ const ARCHIVE_WARN_ROWS = 100; // stated default, not measured — see docs-buil
732
749
  // section a reader is being routed into, so its row stays H1 + line count + link only.
733
750
  function indexRow(rel, dest, includeH2) {
734
751
  const text = read(rel);
735
- const lines = text.split('\n');
752
+ const lines = splitLines(text);
736
753
  // Same headings()+fenceMask() path scan() uses -- no second parser -- so an H2 inside a
737
754
  // ``` fence is masked out here exactly as it is there.
738
755
  const mask = fenceMask(lines);
@@ -1398,7 +1415,7 @@ function ledger() {
1398
1415
  const head = git(['rev-parse', 'HEAD'], 'reading HEAD (is this a git repo?)');
1399
1416
  const docs = docFiles().map(f => ({
1400
1417
  path: f,
1401
- lines: read(f).split('\n').length,
1418
+ lines: splitLines(read(f)).length,
1402
1419
  sha256: sha(path.join(REPO, f)).slice(0, 16)
1403
1420
  }));
1404
1421
  const out = { sha: head, at: new Date().toISOString(),
@@ -1492,7 +1509,7 @@ function lint(files) {
1492
1509
  if (!files.length) die('usage: docs-builder.cjs lint <file.md...>');
1493
1510
  const sections = [];
1494
1511
  for (const f of files) {
1495
- const lines = read(f).split('\n');
1512
+ const lines = splitLines(read(f));
1496
1513
  const mask = fenceMask(lines);
1497
1514
  let cur = null;
1498
1515
  const close = i => { if (cur) { cur.e = i; cur.body = lines.slice(cur.s, i).join('\n'); } };
@@ -1665,7 +1682,7 @@ function isIncludeStub(lines) {
1665
1682
  // but its size. Oversized is now orthogonal to sorting: a product doc that's too big is
1666
1683
  // still a product doc.
1667
1684
  function classifyDoc(rel, text) {
1668
- const lines = text.split('\n');
1685
+ const lines = splitLines(text);
1669
1686
  const mask = fenceMask(lines);
1670
1687
  const { h1 } = headings(lines, mask);
1671
1688
  const snip = snippet(lines, mask, 0, lines.length, 200);
@@ -2214,7 +2231,7 @@ function cleanup(files) {
2214
2231
  + `would overwrite that split's still-in-flight outline.json/labels.json. Finish it `
2215
2232
  + `first: write its remaining pages, then re-run \`cleanup-apply ${inFlight} ...\` until `
2216
2233
  + `it archives — THEN run \`cleanup ${file}\`.`);
2217
- const lines = read(file).split('\n').length;
2234
+ const lines = splitLines(read(file)).length;
2218
2235
  const est = writeCostEstimate(1, lines);
2219
2236
  console.log(`${file}: ${lines} lines`);
2220
2237
  console.log(`est. write cost: $${est.toFixed(2)} (mid tier, floor assuming 1 page — the `
@@ -103,6 +103,18 @@ function fenceMask(lines) {
103
103
  // prose), sentences()'s own regex strip, and checkCitations/checkLinks doing none at all — so
104
104
  // a page documenting the citation/link syntax INSIDE a fence got its own example flagged as
105
105
  // a real violation. One mechanism: mask with fenceMask(), drop the masked lines.
106
+ // `text.split('\n')` returns a trailing EMPTY element for any file ending in a newline —
107
+ // which is nearly every file — so `lines.length` is real_lines + 1. That phantom line reached
108
+ // the index row's "N lines", the last H2's range (one line past EOF) and scan()'s outline.json.
109
+ // Use this wherever lines are COUNTED or a range is BOUNDED. The raw `.split('\n')` is still
110
+ // correct where the array is mapped and re-joined back into file text (stripFences,
111
+ // replaceOutsideFences): dropping the element there would strip the file's final newline.
112
+ function splitLines(text) {
113
+ const lines = text.split('\n');
114
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
115
+ return lines;
116
+ }
117
+
106
118
  function stripFences(text) {
107
119
  const lines = text.split('\n');
108
120
  const mask = fenceMask(lines);
@@ -229,7 +241,7 @@ function scan(files) {
229
241
  if (!files.length) die('usage: docs-builder.cjs scan <file.md...>');
230
242
  const records = [];
231
243
  for (const f of files) {
232
- const lines = read(f).split('\n');
244
+ const lines = splitLines(read(f));
233
245
  const mask = fenceMask(lines);
234
246
  const { h1, heads } = headings(lines, mask);
235
247
  const h2s = heads.filter(h => h.lvl === 2);
@@ -522,8 +534,13 @@ const MIN_PAGE_LINES = 10;
522
534
  function pageStatus(file) {
523
535
  if (!fs.existsSync(file)) return 'TODO';
524
536
  const txt = fs.readFileSync(file, 'utf8');
525
- const lines = txt.split('\n');
526
- const hasFrontmatter = lines[0].trim() === '---' && lines.slice(1).some(l => l.trim() === '---');
537
+ const lines = splitLines(txt);
538
+ // splitLines() returns [] for a 0-byte file — 0 lines is the right COUNT, but it means
539
+ // lines[0] can be undefined, where the old raw split('\n') always yielded ['']. An empty
540
+ // page is reachable (a touched placeholder, or page-writing interrupted before it wrote
541
+ // anything) and must read as PARTIAL, not throw and take `plan` down with it.
542
+ const hasFrontmatter = lines.length > 0 && lines[0].trim() === '---'
543
+ && lines.slice(1).some(l => l.trim() === '---');
527
544
  return hasFrontmatter && lines.length >= MIN_PAGE_LINES ? 'done' : 'PARTIAL';
528
545
  }
529
546
 
@@ -732,7 +749,7 @@ const ARCHIVE_WARN_ROWS = 100; // stated default, not measured — see docs-buil
732
749
  // section a reader is being routed into, so its row stays H1 + line count + link only.
733
750
  function indexRow(rel, dest, includeH2) {
734
751
  const text = read(rel);
735
- const lines = text.split('\n');
752
+ const lines = splitLines(text);
736
753
  // Same headings()+fenceMask() path scan() uses -- no second parser -- so an H2 inside a
737
754
  // ``` fence is masked out here exactly as it is there.
738
755
  const mask = fenceMask(lines);
@@ -1398,7 +1415,7 @@ function ledger() {
1398
1415
  const head = git(['rev-parse', 'HEAD'], 'reading HEAD (is this a git repo?)');
1399
1416
  const docs = docFiles().map(f => ({
1400
1417
  path: f,
1401
- lines: read(f).split('\n').length,
1418
+ lines: splitLines(read(f)).length,
1402
1419
  sha256: sha(path.join(REPO, f)).slice(0, 16)
1403
1420
  }));
1404
1421
  const out = { sha: head, at: new Date().toISOString(),
@@ -1492,7 +1509,7 @@ function lint(files) {
1492
1509
  if (!files.length) die('usage: docs-builder.cjs lint <file.md...>');
1493
1510
  const sections = [];
1494
1511
  for (const f of files) {
1495
- const lines = read(f).split('\n');
1512
+ const lines = splitLines(read(f));
1496
1513
  const mask = fenceMask(lines);
1497
1514
  let cur = null;
1498
1515
  const close = i => { if (cur) { cur.e = i; cur.body = lines.slice(cur.s, i).join('\n'); } };
@@ -1665,7 +1682,7 @@ function isIncludeStub(lines) {
1665
1682
  // but its size. Oversized is now orthogonal to sorting: a product doc that's too big is
1666
1683
  // still a product doc.
1667
1684
  function classifyDoc(rel, text) {
1668
- const lines = text.split('\n');
1685
+ const lines = splitLines(text);
1669
1686
  const mask = fenceMask(lines);
1670
1687
  const { h1 } = headings(lines, mask);
1671
1688
  const snip = snippet(lines, mask, 0, lines.length, 200);
@@ -2214,7 +2231,7 @@ function cleanup(files) {
2214
2231
  + `would overwrite that split's still-in-flight outline.json/labels.json. Finish it `
2215
2232
  + `first: write its remaining pages, then re-run \`cleanup-apply ${inFlight} ...\` until `
2216
2233
  + `it archives — THEN run \`cleanup ${file}\`.`);
2217
- const lines = read(file).split('\n').length;
2234
+ const lines = splitLines(read(file)).length;
2218
2235
  const est = writeCostEstimate(1, lines);
2219
2236
  console.log(`${file}: ${lines} lines`);
2220
2237
  console.log(`est. write cost: $${est.toFixed(2)} (mid tier, floor assuming 1 page — the `
@@ -103,6 +103,18 @@ function fenceMask(lines) {
103
103
  // prose), sentences()'s own regex strip, and checkCitations/checkLinks doing none at all — so
104
104
  // a page documenting the citation/link syntax INSIDE a fence got its own example flagged as
105
105
  // a real violation. One mechanism: mask with fenceMask(), drop the masked lines.
106
+ // `text.split('\n')` returns a trailing EMPTY element for any file ending in a newline —
107
+ // which is nearly every file — so `lines.length` is real_lines + 1. That phantom line reached
108
+ // the index row's "N lines", the last H2's range (one line past EOF) and scan()'s outline.json.
109
+ // Use this wherever lines are COUNTED or a range is BOUNDED. The raw `.split('\n')` is still
110
+ // correct where the array is mapped and re-joined back into file text (stripFences,
111
+ // replaceOutsideFences): dropping the element there would strip the file's final newline.
112
+ function splitLines(text) {
113
+ const lines = text.split('\n');
114
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
115
+ return lines;
116
+ }
117
+
106
118
  function stripFences(text) {
107
119
  const lines = text.split('\n');
108
120
  const mask = fenceMask(lines);
@@ -229,7 +241,7 @@ function scan(files) {
229
241
  if (!files.length) die('usage: docs-builder.cjs scan <file.md...>');
230
242
  const records = [];
231
243
  for (const f of files) {
232
- const lines = read(f).split('\n');
244
+ const lines = splitLines(read(f));
233
245
  const mask = fenceMask(lines);
234
246
  const { h1, heads } = headings(lines, mask);
235
247
  const h2s = heads.filter(h => h.lvl === 2);
@@ -522,8 +534,13 @@ const MIN_PAGE_LINES = 10;
522
534
  function pageStatus(file) {
523
535
  if (!fs.existsSync(file)) return 'TODO';
524
536
  const txt = fs.readFileSync(file, 'utf8');
525
- const lines = txt.split('\n');
526
- const hasFrontmatter = lines[0].trim() === '---' && lines.slice(1).some(l => l.trim() === '---');
537
+ const lines = splitLines(txt);
538
+ // splitLines() returns [] for a 0-byte file — 0 lines is the right COUNT, but it means
539
+ // lines[0] can be undefined, where the old raw split('\n') always yielded ['']. An empty
540
+ // page is reachable (a touched placeholder, or page-writing interrupted before it wrote
541
+ // anything) and must read as PARTIAL, not throw and take `plan` down with it.
542
+ const hasFrontmatter = lines.length > 0 && lines[0].trim() === '---'
543
+ && lines.slice(1).some(l => l.trim() === '---');
527
544
  return hasFrontmatter && lines.length >= MIN_PAGE_LINES ? 'done' : 'PARTIAL';
528
545
  }
529
546
 
@@ -732,7 +749,7 @@ const ARCHIVE_WARN_ROWS = 100; // stated default, not measured — see docs-buil
732
749
  // section a reader is being routed into, so its row stays H1 + line count + link only.
733
750
  function indexRow(rel, dest, includeH2) {
734
751
  const text = read(rel);
735
- const lines = text.split('\n');
752
+ const lines = splitLines(text);
736
753
  // Same headings()+fenceMask() path scan() uses -- no second parser -- so an H2 inside a
737
754
  // ``` fence is masked out here exactly as it is there.
738
755
  const mask = fenceMask(lines);
@@ -1398,7 +1415,7 @@ function ledger() {
1398
1415
  const head = git(['rev-parse', 'HEAD'], 'reading HEAD (is this a git repo?)');
1399
1416
  const docs = docFiles().map(f => ({
1400
1417
  path: f,
1401
- lines: read(f).split('\n').length,
1418
+ lines: splitLines(read(f)).length,
1402
1419
  sha256: sha(path.join(REPO, f)).slice(0, 16)
1403
1420
  }));
1404
1421
  const out = { sha: head, at: new Date().toISOString(),
@@ -1492,7 +1509,7 @@ function lint(files) {
1492
1509
  if (!files.length) die('usage: docs-builder.cjs lint <file.md...>');
1493
1510
  const sections = [];
1494
1511
  for (const f of files) {
1495
- const lines = read(f).split('\n');
1512
+ const lines = splitLines(read(f));
1496
1513
  const mask = fenceMask(lines);
1497
1514
  let cur = null;
1498
1515
  const close = i => { if (cur) { cur.e = i; cur.body = lines.slice(cur.s, i).join('\n'); } };
@@ -1665,7 +1682,7 @@ function isIncludeStub(lines) {
1665
1682
  // but its size. Oversized is now orthogonal to sorting: a product doc that's too big is
1666
1683
  // still a product doc.
1667
1684
  function classifyDoc(rel, text) {
1668
- const lines = text.split('\n');
1685
+ const lines = splitLines(text);
1669
1686
  const mask = fenceMask(lines);
1670
1687
  const { h1 } = headings(lines, mask);
1671
1688
  const snip = snippet(lines, mask, 0, lines.length, 200);
@@ -2214,7 +2231,7 @@ function cleanup(files) {
2214
2231
  + `would overwrite that split's still-in-flight outline.json/labels.json. Finish it `
2215
2232
  + `first: write its remaining pages, then re-run \`cleanup-apply ${inFlight} ...\` until `
2216
2233
  + `it archives — THEN run \`cleanup ${file}\`.`);
2217
- const lines = read(file).split('\n').length;
2234
+ const lines = splitLines(read(file)).length;
2218
2235
  const est = writeCostEstimate(1, lines);
2219
2236
  console.log(`${file}: ${lines} lines`);
2220
2237
  console.log(`est. write cost: $${est.toFixed(2)} (mid tier, floor assuming 1 page — the `
@@ -103,6 +103,18 @@ function fenceMask(lines) {
103
103
  // prose), sentences()'s own regex strip, and checkCitations/checkLinks doing none at all — so
104
104
  // a page documenting the citation/link syntax INSIDE a fence got its own example flagged as
105
105
  // a real violation. One mechanism: mask with fenceMask(), drop the masked lines.
106
+ // `text.split('\n')` returns a trailing EMPTY element for any file ending in a newline —
107
+ // which is nearly every file — so `lines.length` is real_lines + 1. That phantom line reached
108
+ // the index row's "N lines", the last H2's range (one line past EOF) and scan()'s outline.json.
109
+ // Use this wherever lines are COUNTED or a range is BOUNDED. The raw `.split('\n')` is still
110
+ // correct where the array is mapped and re-joined back into file text (stripFences,
111
+ // replaceOutsideFences): dropping the element there would strip the file's final newline.
112
+ function splitLines(text) {
113
+ const lines = text.split('\n');
114
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
115
+ return lines;
116
+ }
117
+
106
118
  function stripFences(text) {
107
119
  const lines = text.split('\n');
108
120
  const mask = fenceMask(lines);
@@ -229,7 +241,7 @@ function scan(files) {
229
241
  if (!files.length) die('usage: docs-builder.cjs scan <file.md...>');
230
242
  const records = [];
231
243
  for (const f of files) {
232
- const lines = read(f).split('\n');
244
+ const lines = splitLines(read(f));
233
245
  const mask = fenceMask(lines);
234
246
  const { h1, heads } = headings(lines, mask);
235
247
  const h2s = heads.filter(h => h.lvl === 2);
@@ -522,8 +534,13 @@ const MIN_PAGE_LINES = 10;
522
534
  function pageStatus(file) {
523
535
  if (!fs.existsSync(file)) return 'TODO';
524
536
  const txt = fs.readFileSync(file, 'utf8');
525
- const lines = txt.split('\n');
526
- const hasFrontmatter = lines[0].trim() === '---' && lines.slice(1).some(l => l.trim() === '---');
537
+ const lines = splitLines(txt);
538
+ // splitLines() returns [] for a 0-byte file — 0 lines is the right COUNT, but it means
539
+ // lines[0] can be undefined, where the old raw split('\n') always yielded ['']. An empty
540
+ // page is reachable (a touched placeholder, or page-writing interrupted before it wrote
541
+ // anything) and must read as PARTIAL, not throw and take `plan` down with it.
542
+ const hasFrontmatter = lines.length > 0 && lines[0].trim() === '---'
543
+ && lines.slice(1).some(l => l.trim() === '---');
527
544
  return hasFrontmatter && lines.length >= MIN_PAGE_LINES ? 'done' : 'PARTIAL';
528
545
  }
529
546
 
@@ -732,7 +749,7 @@ const ARCHIVE_WARN_ROWS = 100; // stated default, not measured — see docs-buil
732
749
  // section a reader is being routed into, so its row stays H1 + line count + link only.
733
750
  function indexRow(rel, dest, includeH2) {
734
751
  const text = read(rel);
735
- const lines = text.split('\n');
752
+ const lines = splitLines(text);
736
753
  // Same headings()+fenceMask() path scan() uses -- no second parser -- so an H2 inside a
737
754
  // ``` fence is masked out here exactly as it is there.
738
755
  const mask = fenceMask(lines);
@@ -1398,7 +1415,7 @@ function ledger() {
1398
1415
  const head = git(['rev-parse', 'HEAD'], 'reading HEAD (is this a git repo?)');
1399
1416
  const docs = docFiles().map(f => ({
1400
1417
  path: f,
1401
- lines: read(f).split('\n').length,
1418
+ lines: splitLines(read(f)).length,
1402
1419
  sha256: sha(path.join(REPO, f)).slice(0, 16)
1403
1420
  }));
1404
1421
  const out = { sha: head, at: new Date().toISOString(),
@@ -1492,7 +1509,7 @@ function lint(files) {
1492
1509
  if (!files.length) die('usage: docs-builder.cjs lint <file.md...>');
1493
1510
  const sections = [];
1494
1511
  for (const f of files) {
1495
- const lines = read(f).split('\n');
1512
+ const lines = splitLines(read(f));
1496
1513
  const mask = fenceMask(lines);
1497
1514
  let cur = null;
1498
1515
  const close = i => { if (cur) { cur.e = i; cur.body = lines.slice(cur.s, i).join('\n'); } };
@@ -1665,7 +1682,7 @@ function isIncludeStub(lines) {
1665
1682
  // but its size. Oversized is now orthogonal to sorting: a product doc that's too big is
1666
1683
  // still a product doc.
1667
1684
  function classifyDoc(rel, text) {
1668
- const lines = text.split('\n');
1685
+ const lines = splitLines(text);
1669
1686
  const mask = fenceMask(lines);
1670
1687
  const { h1 } = headings(lines, mask);
1671
1688
  const snip = snippet(lines, mask, 0, lines.length, 200);
@@ -2214,7 +2231,7 @@ function cleanup(files) {
2214
2231
  + `would overwrite that split's still-in-flight outline.json/labels.json. Finish it `
2215
2232
  + `first: write its remaining pages, then re-run \`cleanup-apply ${inFlight} ...\` until `
2216
2233
  + `it archives — THEN run \`cleanup ${file}\`.`);
2217
- const lines = read(file).split('\n').length;
2234
+ const lines = splitLines(read(file)).length;
2218
2235
  const est = writeCostEstimate(1, lines);
2219
2236
  console.log(`${file}: ${lines} lines`);
2220
2237
  console.log(`est. write cost: $${est.toFixed(2)} (mid tier, floor assuming 1 page — the `