From 777f0df3349c1bdca34be7960baff124c5a10e7a Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 02:14:19 +0000 Subject: [PATCH] fix(context-bill): --exact says when a tree with skills had nothing to send; doc and alias-test fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- lib/context-bill.ts | 14 +++++++++++--- test/context-bill.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e12e3a4..3105954f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ **`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens). ### Fixed -- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement, and its notice says how many of the files it sends sit outside the tree behind symlinks (on an install, the checkout's skill files). A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. `--exact` on a path that does not exist now stops with `No such tree` before any request, a tree with no skills sends nothing and writes no egress receipt, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. `--help` now says `--exact` sends the `.md` files the bill prices, including files reached through symlinks. +- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement, and its notice says how many of the files it sends sit outside the tree behind symlinks (on an install, the checkout's skill files). A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. `--exact` on a path that does not exist now stops with `No such tree` before any request, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. An existing tree with no skills sends nothing and writes no egress receipt, and a tree whose skills have no markdown file to send says the offline estimate was used. `--help` now says `--exact` sends the `.md` files the bill prices, including files reached through symlinks. ### Tests -- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's outside-the-tree count, a skill whose `SKILL.md` is first walked under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), and a reference file with frontmatter (no frontmatter request for it). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix. +- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's outside-the-tree count, a skill whose `SKILL.md` another skill's walk reaches first under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), and a reference file with frontmatter (no frontmatter request for it). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix. ### Gate integrity - No threshold, budget, timeout, trial count, case kind or assertion changed. The bill of this repo's own tree, which the context-budget ratchet reads, is byte-for-byte unchanged. diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 22b23ade6..0cd882868 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -179,8 +179,10 @@ function sumTokens(entries: { tokens: number }[]): number { /** * Cache key for a .md file's frontmatter block, which is a slice, not a whole - * file. The bill looks it up through a SKILL.md path; --exact measures it for - * every walked file, so a SKILL.md first walked under another name still has one. + * file. The bill looks it up through a SKILL.md path; --exact measures it once + * per real SKILL.md the bill prices, keyed by the first path the send set + * reached that file by, so a SKILL.md first walked under another name still + * has one. */ function frontmatterKey(mdPath: string): string { return `${mdPath}#frontmatter`; @@ -1074,7 +1076,13 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) calibration?: Calibration; onDone?: () => void; }> => { - if (!set || set.files.length === 0) return {}; + if (!set) return {}; + if (set.files.length === 0) { + if (findSkillDirs(path.resolve(tree)).length) { + stderr.write(`--exact: no markdown skill file to measure under ${tree}; nothing was sent, so the bill uses the offline estimate.\n`); + } + return {}; + } const treeReal = fs.realpathSync(tree); const outside = set.files.filter((f) => !encloses(treeReal, fs.realpathSync(f))).length; stderr.write( diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 19e1879dd..4f729daae 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -979,6 +979,31 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(receipts).toEqual([]); }); + it("says the estimate was used when a tree has skills but nothing --exact can send", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nothing-to-send-")); + fs.mkdirSync(path.join(tmp, "qa")); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.txt"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(tmp, "qa", "SKILL.txt"), path.join(tmp, "qa", "SKILL.md")); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nothing-to-send-egress-")); + const stderr = capture(); + const calls: { body: string }[] = []; + const code = await contextBillMain([tmp, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome: home, + }); + const receipts = fs.readdirSync(home, { recursive: true }); + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + expect(code).toBe(0); + expect(stderr.text()).toContain("offline estimate"); + expect(calls.length).toBe(0); + expect(receipts).toEqual([]); + }); + it("measures frontmatter only where the bill prices it", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fm-priced-")); fs.mkdirSync(path.join(tmp, "qa", "references"), { recursive: true }); @@ -1034,11 +1059,13 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () const skills = path.join(tmp, "skills"); fs.mkdirSync(path.join(skills, "aaa"), { recursive: true }); fs.mkdirSync(path.join(skills, "zskill"), { recursive: true }); + fs.writeFileSync(path.join(skills, "aaa", "SKILL.md"), "---\nname: aaa\ndescription: d\n---\n# a\n"); fs.writeFileSync(path.join(skills, "aaa", "notes.md"), "---\nname: zskill\ndescription: d\n---\n# z\n"); fs.symlinkSync(path.join(skills, "aaa", "notes.md"), path.join(skills, "zskill", "SKILL.md")); const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); const bill = buildBill(skills, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); - expect(bill.skills.map((s) => s.name)).toEqual(["zskill"]); + expect(bill.skills.map((s) => s.name)).toEqual(["aaa", "zskill"]); + expect(measured.counts.has(path.join(skills, "aaa", "notes.md#frontmatter"))).toBe(true); expect([...measured.missedKeys]).toEqual([]); fs.rmSync(tmp, { recursive: true, force: true }); });