From 6c3cf1baac93a794d37065da8b95340e14305126 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 26 Oct 2026 03:57:52 -0700 Subject: [PATCH] v1.91.242.0 fix(context-bill): bill installed skills whose SKILL.md is a symlink (GSTA-97) (#3423) * fix(context-bill): follow symlinked SKILL.md files and skill dirs so a real install is billed (GSTA-97) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): walk both links to one directory so the walk never depends on readdir order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): describe the loop guard as shipped Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a root named '..x' no longer lets a parent link climb out encloses() tested rel.startsWith('..'), which also matches a first segment such as '..skills', so a link to the parent of a root under such a name was followed out of the root. Compare the '..' segment exactly. Adds that case plus a loop to a directory below the walk root, which only the ancestor guard stops (found by /ship pre-landing review). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a .md-named link counts only when its target is markdown walkMd checked only the link's own name, so a link such as notes.md -> a non-markdown file was walked and, under --exact, its content was sent to count_tokens. Require the resolved target to end in .md as well. Adds tests for --exact on the installed layout (every billed file is measured; nothing behind gstack/ or a .md-named link to another file is sent) and for a symlinked foreign-host file (found by /ship review). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): walk each real directory once so link fan-out cannot explode walkMd guarded only the current path, and walked every link to the same directory, so links that fan out into each other grew the walk exponentially; under --exact every duplicate path was another count_tokens POST on the user's key. Expand each real directory and return each real file once per walk, under the first lexical path reached with entries sorted. Refuse a walk past 200,000 entries with an error instead of truncating. --exact sends each real file once and prices every lexical key that resolves to it from that measurement. Found by the /ship Step 11 adversarial reviews (Claude and GPT). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): fence walks at the billed root, measure every walked frontmatter, count outside files in the --exact notice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a container skill keeps files a nested skill links to Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact names a missing tree before any request Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact sends only files the bill prices Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact refuses every missing tree before sending, and the notice counts what is sent Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): exactSendFiles does not swallow a realpath error Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact walks only what the bill walks, sends nothing for a skill-less tree, and measures only priced frontmatter Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * test(context-bill): a skill-less tree prints no estimate line under --exact Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a container skips only nested dirs whose SKILL.md is a file, as discovery does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): skill discovery refuses a tree past the entry cap too Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): walk directories through their realpaths so a deep link chain cannot drop files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact reads only the file each walk approved Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact sends only the skills root and the linked gstack checkout; anything else is named and estimated Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (context-bill run-4 cycle 1) Checkout detection needs a gstack checkout outside the tree; identity taken during the walk and re-checked by realpath; discovery and calibration read through realpaths; refused-dir collapse is linear; all-refused wording; stale comments; tests for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (context-bill run-4 cycle 2) The gstack checkout is recognised by .git, VERSION, setup and a SKILL.md named gstack, so the bin/dev-setup layout counts and other repos do not; walk contract documented on walkMdFiles; tests for the same-file parent move, a gstack-shaped folder without .git, another project repo, and dev-setup. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a root container skips a nested skill reached through a directory link at any depth Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact refuses a walked file swapped for a FIFO instead of blocking on open Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact resolves a billed path one link at a time, so a shared deep chain keeps its measurements Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): --exact reuses a measurement only for the same realpath, size and mtime it measured Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(context-bill): a bill with any estimated item under --exact is labelled mixed and keeps its error band, in --json and text Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(context-bill): --exact also sends files inside gstack's render folder ($GSTACK_STATE_ROOT/render), judged by realpath Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): the repo bill keeps its skills, bytes and rounded tokens; raw token floats differ in the last digit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(context-bill): CHANGELOG and README for the render folder, mixed labels, measurement identity, FIFO refusal and container fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (context-bill run-5 cycle 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (context-bill run-5 cycle 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (context-bill run-5 cycle 3) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.242.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 + README.md | 2 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/context-bill.ts | 639 ++++++++++++++---- package.json | 2 +- test/context-bill.test.ts | 1151 +++++++++++++++++++++++++++++++- 7 files changed, 1685 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa85cd59..669c43cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.242.0] - 2026-10-26 + +**`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. It sends only files inside the tree, inside the gstack checkout that the tree's `gstack/` entry links to (a git checkout with `VERSION`, `setup` and a `SKILL.md` named `gstack`; `bin/dev-setup`'s `.claude/skills/gstack -> ` counts), or inside gstack's render folder `$GSTACK_STATE_ROOT/render` (where setup renders the `SKILL.md` files that gbrain, `--claude-model` and per-install renders link to; trusted only when `render` is a real directory, not a link). Each file is judged by its realpath, so a link out of one of those folders is refused too; anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout and how many from the render folder. A bill in which `--exact` had to estimate any item (a refused file, or one that changed after it was measured) says so: `tokenSource` starts with `mixed:` and names the count, `tokenEstimateErrorPct` keeps the estimate's 40% band, and the text output says the counts are mixed instead of measured. A measurement is reused only for a file that still has the realpath, inode, size and mtime it was measured with, so a link retargeted or a file rewritten after the measurement (during `--diff`, say) is estimated and counted as estimated instead of inheriting the old count. Billed paths are matched to their measurement one link at a time, so a second skill sharing a link chain deeper than the kernel's limit is still priced from the one measurement. Files are opened without blocking, so a file swapped for a FIFO is refused instead of hanging the run. An empty `.md` file and a `SKILL.md` without frontmatter are measured as zero tokens without a request, and a whitespace-only `.md` file is not sent and is estimated (so the bill says mixed), because `count_tokens` rejects content with no non-whitespace text: a blank file used to drop every measurement with a 400, and an empty one turned a fully measured bill into a mixed one. When every file `--exact` could send is blank, nothing is sent, no receipt is written, and the notice says so (and whether other files were refused). The sending notice is printed after the files are read, just before the receipt, and counts exactly the files sent. A state root that is missing, unreadable or not a directory just means there is no render folder to trust; the bill still runs. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. 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. The walk and skill discovery read each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files or skills silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. The root container also skips a nested skill that a directory link reaches at any depth (discovery already bills it), so `refs/alias -> ../sub` no longer counts `sub`'s files a third time in TOTAL. `--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, a tree whose skills have no markdown file to send says the offline estimate was used, and a tree whose every billed file was refused says so. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. + +### 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 count of files from the gstack checkout, 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), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a `gstack/` entry that is not a gstack checkout (a plain folder, a bare `.git` folder, another project's repo, or a gstack-shaped folder without `.git`: nothing behind it counts as the checkout), a `bin/dev-setup` tree inside the gstack repo (its skill files are sent), refused files in nested and sibling folders (each folder named once), every billed file refused (said so), a link retargeted, a file replaced by a link or a hard link, or a parent folder swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked, billed and, under `--exact`, measured), a skill 45 directory links deep (discovered), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), a tree with no skills under `--exact` (no estimate line), a root container with a `refs/alias -> ../sub` link and a link to an outside folder holding a skill (neither nested skill billed to the container), a file swapped for a FIFO after the walk (refused within the subprocess timeout, where it used to hang), two skills sharing a 50-deep link chain (both priced from one measurement, nothing estimated), a `SKILL.md` link retargeted to a bigger file and a reference rewritten in place after measurement (both estimated and counted as such), a bill with a refused skill (`mixed:` in `--json` and in text), a file replaced by one of the same size and mtime (estimated), a skill without frontmatter plus an empty reference under a `count_tokens` stand-in that rejects empty content (still fully measured), a render folder that is itself a link (not trusted), a tree whose only file is empty, alone and beside a refused link (no request, no receipt, and a notice naming which case it is), a whitespace-only reference under a stand-in that rejects it (not sent, estimated, the bill labelled mixed, and the notice counting only the file sent), the render folder found through `GSTACK_STATE_ROOT` with no state root passed in, a state root that is a file (the bill still runs, measured), a git checkout inside a skill or linked from one (not billed; this case passes on main and fails when the walk's `.git` check is removed), and a render install (its `SKILL.md` and sections sent from `$GSTACK_STATE_ROOT/render`, a link from there to a documents file refused and listed). 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, except the hard-link and parent-swap cases, which pin the identity check added with the realpath reads (they fail when that check is removed). + +### 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, has the same skills, bytes and rounded token figures as on main. Four raw `totalMdTokens` values in `--json` differ in the last floating-point digit, because each skill's files are now summed in sorted order. + ## [1.91.241.0] - 2026-10-26 **The weekly OSV scan on main is green again, so the weekly release cut can run.** The 2026-10-26 scan ([run 40288353843](https://github.com/garrytan/gstack/actions/runs/40288353843), #3462) found 14 fixable vulnerabilities in six packages across both lockfiles, and the release workflow skips any main commit with a red check run. Every one is bumped to its fixed release; no application code changed, because gstack imports none of these packages directly (the MCP SDK and zod arrive through `@anthropic-ai/claude-agent-sdk`, body-parser through its express, fast-uri through ajv, ip-address through socks and express-rate-limit, dompurify through mermaid). diff --git a/README.md b/README.md index 9ef2ef590..4caf849b3 100644 --- a/README.md +++ b/README.md @@ -400,7 +400,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-model-benchmark` | **Cross-model benchmark** — run the same prompt through Claude, GPT (via Codex CLI), and Gemini; compare latency, tokens, cost, and (optionally) LLM-judge quality score. Auth detected per provider, unavailable providers skip cleanly. Output as table, JSON, or markdown. `--dry-run` validates flags + auth without spending API calls. | | `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. | | `gstack-egress` | **Egress receipt auditor** — every gstack-initiated off-machine send writes a tamper-evident, hash-chained receipt to `~/.gstack/security/egress.jsonl` before the send. `list` shows what gstack attempted to send and to which host, `grants` shows the standing consent settings plus the exact command that revokes each, `verify` recomputes the hash chain and exits 3 on tamper (catches edits, reordering, and mid-chain deletion; truncating or deleting the ledger itself is out of scope — it's a forensic log, not tamper-proof storage). | -| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends file text off-machine; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). | +| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends the text of the billed `.md` files inside the tree, the linked gstack checkout or gstack's render folder off-machine, names and estimates anything else a symlink reaches, labels a part-estimated bill `mixed`; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). | | `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. | | `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: ``. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. | | `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](docs/memorable-workflow-memory.md). | diff --git a/VERSION b/VERSION index b08862def..6562810a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.241.0 +1.91.242.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 5388bd6cc..d0076cc8f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.241.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.242.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/context-bill.ts b/lib/context-bill.ts index b264e72c9..0127c21f5 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -18,13 +18,16 @@ * Token figures come from one of two sources, always named in the output: * ESTIMATE (default, offline) bytes / TOKEN_DIVISOR, calibrated against real * count_tokens measurements. - * EXACT (--exact, opt-in) Anthropic's count_tokens for every file the - * bill touches. Sends file content off-machine, - * so it is never implicit: an egress receipt is - * written before the POSTs (sink - * 'context-bill-exact'), and if the receipt - * cannot be written the run degrades to the - * offline estimate with a warning instead of + * EXACT (--exact, opt-in) Anthropic's count_tokens for every billed .md + * file inside the tree, the linked gstack + * checkout or $GSTACK_STATE_ROOT/render; anything + * else a symlink reaches is named and estimated, + * and the bill is labelled mixed. Sends file + * content off-machine, so it is never implicit: + * an egress receipt is written before the POSTs + * (sink 'context-bill-exact'), and if the + * receipt cannot be written the run degrades to + * the offline estimate with a warning instead of * sending unrecorded. * Both bytes and tokens are always shown, and the estimate's measured error * band is printed with it. The tool never writes state anywhere (the egress @@ -34,6 +37,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { writeReceipt } from "./egress-receipt"; +import { resolveStateRoot } from "./state-root"; const FORCED_PHRASE = "for every invocation"; // Backticked reference in prose. `<...>` is excluded: a path template such as @@ -43,6 +47,17 @@ const PROSE_REF = /`(references\/[^`<>]+\.md)`/g; const ROUTER_KEYS = new Set(["name", "description", "version", "allowed-tools", "triggers", "preamble-tier"]); // Skill-shaped files other hosts drop into scanner scope. const FOREIGN_SKILL_FILE = /^(skill\.(ya?ml|json)|agents?\.md|\.cursorrules|\.windsurfrules)$/i; +/** Directory entries one walkMdFiles or findSkillDirs call may examine before it refuses the tree. */ +const WALK_ENTRY_CAP = 200_000; +/** tokenSource prefix of a bill that --exact priced partly by estimate. */ +const MIXED_PREFIX = "mixed: "; + +function entryCapError(dir: string, cap: number): Error { + return new Error( + `${dir} has more than ${cap} entries to walk; a symlink in it probably points at a large tree. ` + + `Nothing was billed or sent. Remove or narrow that link and run again.`, + ); +} /** * Bytes per token, per content class, fitted to real count_tokens measurements. @@ -175,9 +190,15 @@ function sumTokens(entries: { tokens: number }[]): number { return entries.reduce((n, e) => n + e.tokens, 0); } -/** Cache key for a SKILL.md's frontmatter block, which is a slice, not a whole file. */ -function frontmatterKey(skillMdPath: string): string { - return `${skillMdPath}#frontmatter`; +/** + * 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 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`; } function parseFrontmatter(text: string): { bytes: number; keys: string[]; block: string } { @@ -195,55 +216,197 @@ function parseFrontmatter(text: string): { bytes: number; keys: string[]; block: return { bytes: Buffer.byteLength(block, "utf8"), keys, block }; } -/** - * Every .md file under a tree, for the on-disk total and for exact - * measurement. Skips node_modules and dot-directories: a skills tree that is - * also a repo checkout (dev symlink installs) would otherwise bill its - * dependency tree and CI state as skill content. - */ -export function walkMd(dir: string): string[] { - const out: string[] = []; - let entries: fs.Dirent[]; +/** Whether a path is a regular file once symlinks are followed; a dangling link is not. */ +function isFile(p: string): boolean { try { - entries = fs.readdirSync(dir, { withFileTypes: true }); + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +/** + * What a directory entry is once symlinks are followed. Installed skills are + * symlinks into the gstack checkout (`review/SKILL.md -> /review/SKILL.md`, + * `office-hours/sections -> /office-hours/sections`), so a plain + * Dirent type check sees none of them. Dangling links and anything that is + * neither a file nor a directory are null. + */ +function entryKind(p: string, e: fs.Dirent): "file" | "dir" | null { + if (e.isFile()) return "file"; + if (e.isDirectory()) return "dir"; + if (!e.isSymbolicLink()) return null; + try { + const st = fs.statSync(p); + return st.isFile() ? "file" : st.isDirectory() ? "dir" : null; + } catch { + return null; + } +} + +/** + * The realpath of `p`, resolved one component at a time the way the walk + * reads directories, so a lexical path that crosses more links than the + * kernel's limit still resolves. + */ +function realpathStepwise(p: string): string { + const abs = path.resolve(p); + const { root } = path.parse(abs); + let real = fs.realpathSync(root); + for (const segment of abs.slice(root.length).split(path.sep)) { + if (segment) real = fs.realpathSync(path.join(real, segment)); + } + return real; +} + +/** + * Whether directory `outer` contains (or is) `inner`, both realpaths. A + * followed directory symlink that contains the tree being walked (`..`, `/`, + * `$HOME`) would climb out of that tree, so walkers refuse it. + */ +function encloses(outer: string, inner: string): boolean { + const rel = path.relative(outer, inner); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); +} + +/** walkMdFiles(dir)'s lexical paths, with no billedRoot fence or nested-skill skip. Only tests call it; the bill and --exact use walkMdFiles. */ +export function walkMd(dir: string): string[] { + return walkMdFiles(dir).map((f) => f.path); +} + +export interface WalkedFile { + /** The lexical path under the walked dir; the key the bill and --exact use. */ + path: string; + /** The file's realpath when it was walked. */ + real: string; + /** The file's identity, taken without following links when it was walked; --exact reads only that same file. */ + dev: number; + ino: number; + /** Its size and mtime when it was walked; a measurement is reused only for a file that still has them. */ + size: number; + mtimeMs: number; +} + +/** + * Every .md file under a tree, with its realpath and identity, for the + * on-disk total and for exact measurement. Skips node_modules and + * dot-directories: a skills tree that is also a repo checkout (dev symlink + * installs) would otherwise bill its dependency tree and CI state as skill + * content. A child repo checkout (the + * `gstack/` link inside ~/.claude/skills) is skipped for the same reason. + * + * Symlinked files and directories are followed, and every returned path stays + * the lexical path under `dir`, so buildBill and --exact key the same files. + * Each real directory is expanded once and each real file returned once per + * walk, under the first lexical path reached with entries in sorted order: a + * skill loads a file once however many links reach it, and links that fan out + * into each other cannot multiply the walk. A directory that encloses `dir`, + * or `billedRoot` (the tree a skill is billed under), is never entered. A link + * named *.md counts only when its target is a .md file too, so --exact never + * sends a non-markdown file through a markdown-named link. More than + * WALK_ENTRY_CAP entries is an error, never a silent cut. + * + * With `skipNestedSkills`, a top-level subdirectory holding its own SKILL.md is + * not entered, so a container skill keeps the files it shares with a nested + * skill instead of losing them to whichever path sorted first. The root + * container also skips a nested skill reached through a directory link at any + * depth: discovery bills that skill on its own, and the plain walk this + * replaced never followed directory links, so counting it here too would bill + * it twice. + * + * Every directory is read through its realpath, so a lookup crosses at most + * one link however deep the lexical path runs: a chain of links never hits + * the kernel's link-depth limit (ELOOP) and silently loses the files past it. + */ +export function walkMdFiles(dir: string, opts: { billedRoot?: string; skipNestedSkills?: boolean } = {}): WalkedFile[] { + const out: WalkedFile[] = []; + let rootReal: string; + let billedReal: string; + try { + rootReal = fs.realpathSync(dir); + billedReal = fs.realpathSync(opts.billedRoot ?? dir); } catch { return out; } - for (const e of entries) { - if (e.name.startsWith(".") || e.name === "node_modules") continue; - const p = path.join(dir, e.name); - if (e.isDirectory()) out.push(...walkMd(p)); - else if (e.isFile() && e.name.endsWith(".md")) out.push(p); - } + const seen = new Set([rootReal]); + const container = rootReal === billedReal; + let entryCount = 0; + const walk = (current: string, currentReal: string, viaLink: boolean) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(currentReal, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const e of entries) { + if (++entryCount > WALK_ENTRY_CAP) throw entryCapError(dir, WALK_ENTRY_CAP); + if (e.name.startsWith(".") || e.name === "node_modules") continue; + const viaReal = path.join(currentReal, e.name); + const kind = entryKind(viaReal, e); + if (kind === "file") { + if (!e.name.endsWith(".md")) continue; + } else if ( + kind !== "dir" || + fs.existsSync(path.join(viaReal, ".git")) || + (opts.skipNestedSkills && + (current === dir || (container && (viaLink || e.isSymbolicLink()))) && + isFile(path.join(viaReal, "SKILL.md"))) + ) { + continue; + } + let real: string; + try { + real = fs.realpathSync(viaReal); + } catch { + continue; + } + if (seen.has(real)) continue; + const p = path.join(current, e.name); + if (kind === "file") { + if (!real.endsWith(".md")) continue; + let st: fs.Stats; + try { + st = fs.lstatSync(real); + } catch { + continue; + } + if (!st.isFile()) continue; + seen.add(real); + out.push({ path: p, real, dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs }); + continue; + } + if (encloses(real, rootReal) || encloses(real, billedReal)) continue; + seen.add(real); + walk(p, real, viaLink || e.isSymbolicLink()); + } + }; + walk(dir, rootReal, false); return out; } -function totalMd(dir: string, tokensOf: TokensOf): { bytes: number; tokens: number } { +function totalMd(dir: string, tokensOf: TokensOf, billedRoot: string): { bytes: number; tokens: number } { let bytes = 0; let tokens = 0; - for (const p of walkMd(dir)) { - // A skill dir that CONTAINS other skill dirs (the gstack root skill wraps - // the whole tree) must not swallow its children's files: each nested - // skill reports its own totalMd, and the grand total sums per-skill - // figures — counting them here again double-counted every nested skill - // in the TOTAL line (v1.63 deferred polish, fixed in fork port wave 2). - const rel = path.relative(dir, p); - const topSeg = rel.split(path.sep)[0]; - if ( - topSeg && - topSeg !== rel && // p is inside a subdirectory - fs.existsSync(path.join(dir, topSeg, "SKILL.md")) - ) { - continue; - } - const b = bytesOf(p) ?? 0; + // A skill dir that CONTAINS other skill dirs (the gstack root skill wraps + // the whole tree) must not swallow its children's files: each nested + // skill reports its own totalMd, and the grand total sums per-skill + // figures — counting them here again double-counted every nested skill + // in the TOTAL line (v1.63 deferred polish, fixed in fork port wave 2). + for (const { path: p, real } of walkMdFiles(dir, { billedRoot, skipNestedSkills: true })) { + const b = bytesOf(real) ?? 0; bytes += b; tokens += tokensOf(p, b); } return { bytes, tokens }; } -export function parseSkill(skillDir: string, name: string, tokensOf: TokensOf = estimateTokensOf): SkillBill { +export function parseSkill( + skillDir: string, + name: string, + tokensOf: TokensOf = estimateTokensOf, + billedRoot: string = skillDir, +): SkillBill { const skillMdPath = path.join(skillDir, "SKILL.md"); const text = fs.readFileSync(skillMdPath, "utf8"); const skillMdBytes = bytesOf(skillMdPath) ?? 0; @@ -275,14 +438,14 @@ export function parseSkill(skillDir: string, name: string, tokensOf: TokensOf = // Foreign-host skill files sitting next to SKILL.md. const foreignFiles: { path: string; bytes: number; tokens: number }[] = []; for (const entry of fs.readdirSync(skillDir, { withFileTypes: true })) { - if (entry.isFile() && FOREIGN_SKILL_FILE.test(entry.name)) { - const abs = path.join(skillDir, entry.name); + const abs = path.join(skillDir, entry.name); + if (FOREIGN_SKILL_FILE.test(entry.name) && entryKind(abs, entry) === "file") { const bytes = bytesOf(abs) ?? 0; foreignFiles.push({ path: entry.name, bytes, tokens: tokensOf(abs, bytes) }); } } - const total = totalMd(skillDir, tokensOf); + const total = totalMd(skillDir, tokensOf, billedRoot); const eagerBytes = skillMdBytes + sumBytes(forcedRefs); const eagerTokens = skillMdTokens + sumTokens(forcedRefs); return { @@ -333,52 +496,59 @@ export function parseSkill(skillDir: string, name: string, tokensOf: TokensOf = * repo checkout (`gstack/`, with .git). Its nested SKILL.md files are the * repo's sources, not installed skills of the tree being billed. * - * Directory symlinks are followed (setup's shell glob follows them, so a - * symlinked skill like connect-chrome/ is real scanner load); a realpath - * seen-set breaks cycles. + * Symlinks are followed. Directory links: setup's shell glob follows them, so + * a symlinked skill like connect-chrome/ is real scanner load. File links: + * setup installs every skill as a real directory whose SKILL.md is a symlink + * into the checkout, so skipping them billed a real install as two skills. + * A realpath seen-set breaks cycles, a link to a directory that encloses the + * root is never followed, and every returned path stays under the root. */ -export function findSkillDirs(root: string): string[] { +export function findSkillDirs(root: string, entryCap: number = WALK_ENTRY_CAP): string[] { const out: string[] = []; const visited = new Set(); - const walk = (dir: string, isRoot: boolean) => { + const rootDir = path.resolve(root); + let entryCount = 0; + let rootReal: string; + try { + rootReal = fs.realpathSync(rootDir); + } catch { + return out; + } + // Each directory is read through its realpath (see walkMdFiles), so a deep + // chain of links cannot hit the kernel's link limit and drop a skill. + const walk = (dir: string, real: string, isRoot: boolean) => { let entries: fs.Dirent[]; try { - entries = fs.readdirSync(dir, { withFileTypes: true }); + entries = fs.readdirSync(real, { withFileTypes: true }); } catch { return; } - if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) { + if (entries.some((e) => e.name === "SKILL.md" && entryKind(path.join(real, e.name), e) === "file")) { out.push(dir); // Two symlinked paths to the same skill dir are BOTH billed (each is // real scanner load); only container recursion below is cycle-guarded. if (!isRoot) return; } // Cycle guard for container recursion (a symlink loop of directories). - let real: string; - try { - real = fs.realpathSync(dir); - } catch { - return; - } if (visited.has(real)) return; visited.add(real); for (const e of entries) { + if (++entryCount > entryCap) throw entryCapError(rootDir, entryCap); if (e.name.startsWith(".") || e.name === "node_modules") continue; - const child = path.join(dir, e.name); - let isDir = e.isDirectory(); - if (!isDir && e.isSymbolicLink()) { - try { - isDir = fs.statSync(child).isDirectory(); - } catch { - continue; // dangling symlink - } + const viaReal = path.join(real, e.name); + if (entryKind(viaReal, e) !== "dir") continue; + if (fs.existsSync(path.join(viaReal, ".git"))) continue; // repo checkout, not a skill + let childReal: string; + try { + childReal = fs.realpathSync(viaReal); + } catch { + continue; } - if (!isDir) continue; - if (fs.existsSync(path.join(child, ".git"))) continue; // repo checkout, not a skill - walk(child, false); + if (encloses(childReal, rootReal)) continue; // would climb out of the root + walk(path.join(dir, e.name), childReal, false); } }; - walk(path.resolve(root), true); + walk(rootDir, rootReal, true); return out.sort(); } @@ -404,25 +574,32 @@ export interface Bill { export function buildBill( root: string, - { tokensOf = estimateTokensOf, tokenSource, calibration }: { + { tokensOf = estimateTokensOf, tokenSource, calibration, missedKeys }: { tokensOf?: TokensOf; tokenSource?: string; calibration?: Calibration; + /** Keys a measured tokensOf had to estimate; filled while the bill is priced. */ + missedKeys?: ReadonlySet; } = {}, ): Bill { const resolved = path.resolve(root); if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`); const skills = findSkillDirs(resolved).map((dir) => - parseSkill(dir, path.relative(resolved, dir) || path.basename(resolved), tokensOf), + parseSkill(dir, path.relative(resolved, dir) || path.basename(resolved), tokensOf, resolved), ); const total = skills.reduce((n, s) => n + s.totalMdBytes, 0); const totalTokens = skills.reduce((n, s) => n + s.totalMdTokens, 0); + const estimated = tokenSource ? (missedKeys?.size ?? 0) : 0; return { root: resolved, // Named so a reader never has to guess whether a figure was measured. - tokenSource: tokenSource ?? "estimate: calibrated bytes/token per content class", + tokenSource: !tokenSource + ? "estimate: calibrated bytes/token per content class" + : estimated + ? `${MIXED_PREFIX}${tokenSource}, with ${estimated} item(s) estimated offline` + : tokenSource, tokenEstimate: TOKEN_DIVISORS, - tokenEstimateErrorPct: tokenSource ? 0 : TOKEN_ESTIMATE_ERROR_PCT, + tokenEstimateErrorPct: tokenSource && !estimated ? 0 : TOKEN_ESTIMATE_ERROR_PCT, // Present only under --exact: how far the offline estimate was off, per file. ...(calibration ? { calibration } : {}), skills, @@ -605,6 +782,12 @@ export function tokenDisclaimer(bill: Pick `${k} /${v}`).join(", "); return ( `Token counts are ESTIMATES: bytes divided per content class (${per}), calibrated against ` + @@ -685,17 +868,132 @@ async function countTokens(text: string, { model, apiKey, fetchImpl }: CountToke return json.input_tokens; } +export interface ExactSendSet { + /** Each real file once, under the first lexical path a skill's walk reaches it by. */ + files: WalkedFile[]; + /** Files the bill walks outside the root, the gstack checkout and gstack's render folder: never sent, estimated offline. */ + refused: WalkedFile[]; + /** Realpath of the gstack checkout the root's gstack/ entry links to, when there is one. */ + checkout?: string; + /** Realpath of gstack's render folder ($GSTACK_STATE_ROOT/render) when it is a real directory; a render link is not trusted. */ + render?: string; + /** Realpaths of the SKILL.md files whose frontmatter the bill prices. */ + frontmatter: Set; +} + +/** The realpath of `dir` when it is a real directory, not a link; otherwise (missing, unreadable, a file) undefined. */ +function realDirectory(dir: string): string | undefined { + try { + return fs.lstatSync(dir).isDirectory() ? fs.realpathSync(dir) : undefined; + } catch { + return undefined; + } +} + +/** A git checkout with VERSION, setup and a SKILL.md named gstack. */ +function isGstackCheckout(dir: string): boolean { + if (!fs.existsSync(path.join(dir, ".git"))) return false; + if (!["VERSION", "setup", "SKILL.md"].every((name) => isFile(path.join(dir, name)))) return false; + const { block } = parseFrontmatter(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8")); + return /^name:\s*gstack\s*$/m.test(block); +} + +/** + * What --exact sends: the same per-skill walks totalMd bills, with the same + * fences, so nothing is walked or sent that no skill bills. Of those, only + * files inside the root or inside the gstack checkout the skills link into + * (the root's gstack/ entry, when it is a git checkout whose SKILL.md is named + * gstack and which has VERSION and setup) or inside gstack's render folder + * ($GSTACK_STATE_ROOT/render, where setup renders the SKILL.md files some + * installs link to; trusted only as a real directory, not a link) are sent; anything else a link reaches is refused and + * estimated offline. Each file is judged by its realpath, so a link out of + * one of those folders is refused too. + * + * The root is resolved before keying. buildBill resolves its root, so a + * relative root here would produce keys that never match and every lookup + * would fall back to the estimate. + */ +export function exactSendSet(root: string, stateRoot: string = resolveStateRoot()): ExactSendSet { + const resolved = path.resolve(root); + if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`); + const files: WalkedFile[] = []; + const refused: WalkedFile[] = []; + const frontmatter = new Set(); + const seen = new Set(); + const rootReal = fs.realpathSync(resolved); + const gstackEntry = path.join(resolved, "gstack"); + const checkout = isGstackCheckout(gstackEntry) ? fs.realpathSync(gstackEntry) : undefined; + const render = realDirectory(path.join(stateRoot, "render")); + const approved = [rootReal, checkout, render].filter((d): d is string => d !== undefined); + for (const dir of findSkillDirs(resolved)) { + frontmatter.add(fs.realpathSync(path.join(dir, "SKILL.md"))); + for (const file of walkMdFiles(dir, { billedRoot: resolved, skipNestedSkills: true })) { + if (seen.has(file.real)) continue; + seen.add(file.real); + if (!approved.some((d) => encloses(d, file.real))) { + refused.push(file); + continue; + } + files.push(file); + } + } + return { files, refused, frontmatter, checkout, render }; +} + +/** + * Reads the exact file the walk approved: its realpath, opened without + * following a final link, and only if it is still the same regular file at + * the same realpath. A link retargeted, a file replaced, or a parent directory + * swapped for a link after the walk is refused, so nothing the bill did not + * walk can be sent. The open does not block, so a file swapped for a FIFO is + * refused as not a regular file instead of hanging the run. + */ +function readWalkedFile({ path: file, real, dev, ino, size, mtimeMs }: WalkedFile, root: string): string { + const changed = () => new Error(`${file} changed after it was walked; nothing from ${root} was sent. Run again.`); + let fd: number; + try { + fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0)); + } catch { + throw changed(); + } + try { + const st = fs.fstatSync(fd); + if ( + !st.isFile() || + st.dev !== dev || + st.ino !== ino || + st.size !== size || + st.mtimeMs !== mtimeMs || + fs.realpathSync(real) !== real + ) { + throw changed(); + } + return fs.readFileSync(fd, "utf8"); + } finally { + fs.closeSync(fd); + } +} + +function fileIdentity(real: string, { dev, ino, size, mtimeMs }: { dev: number; ino: number; size: number; mtimeMs: number }): string { + return `${real}\0${dev}\0${ino}\0${size}\0${mtimeMs}`; +} + export interface ExactMeasurement { tokenSource: string; tokensOf: TokensOf; + /** Texts sent to count_tokens (file bodies plus frontmatter blocks). */ measuredFiles: number; counts: Map; + /** The realpath each measured file was read from, by its key. */ + realOf: Map; /** Keys priced by estimate because measurement missed them. Read after buildBill. */ missedKeys: Set; } /** - * Measures every text the bill will bill for. Returns a `tokensOf` lookup. + * Measures every text in the send set: what the bill prices inside the root, + * the linked gstack checkout or gstack's render folder. Returns a `tokensOf` + * lookup. * * count_tokens prices a whole request, so it includes a fixed message envelope. * That envelope is measured once and subtracted, leaving the tokens each file's @@ -708,11 +1006,15 @@ export interface ExactMeasurement { */ export async function measureExactTokens( root: string, - { model, apiKey, fetchImpl = fetch, onProgress, egressHome }: { + { sendSet, model, apiKey, fetchImpl = fetch, onProgress, onSend, egressHome }: { + /** The send set from exactSendSet(root), when the caller already computed it. */ + sendSet?: ExactSendSet; model: string; apiKey: string; fetchImpl?: FetchLike; onProgress?: (done: number, total: number) => void; + /** Called with the files about to be sent, after they are read and before the receipt; not called when nothing is sent. */ + onSend?: (files: WalkedFile[]) => void; egressHome?: string; }, ): Promise { @@ -725,18 +1027,71 @@ export async function measureExactTokens( const opts: CountTokensOptions = { model, apiKey, fetchImpl }; const texts = new Map(); - // Resolve before keying. buildBill resolves its root, so a relative root here - // would produce keys that never match and every lookup would fall back to the - // estimate -- exact mode silently degrading to the thing it replaces. - for (const file of walkMd(path.resolve(root))) { - const text = fs.readFileSync(file, "utf8"); - texts.set(file, text); - if (path.basename(file) === "SKILL.md") { - const fm = parseFrontmatter(text); - if (fm.block) texts.set(frontmatterKey(file), fm.block); - } + // count_tokens rejects content with no non-whitespace text, so such texts + // are not sent: an empty one is measured as zero tokens, and a + // whitespace-only one is left unmeasured, so the bill estimates it and says + // so. When nothing is left to send, no receipt is written and no request is + // made. + const counts = new Map(); + // The send set holds each real file once, so each is sent once. The bill keys + // the same file by every lexical path it is billed under; measuredAs maps a + // real file, at the inode, size and mtime it was walked with, back to the + // one key that was measured. + const measuredAs = new Map(); + const realOf = new Map(); + const { files, frontmatter } = sendSet ?? exactSendSet(root); + for (const walked of files) { + const { path: file, real } = walked; + const text = readWalkedFile(walked, root); + measuredAs.set(fileIdentity(real, walked), file); + realOf.set(file, real); + if (/\S/.test(text)) texts.set(file, text); + else if (text === "") counts.set(file, 0); + if (!frontmatter.has(real)) continue; + const fm = parseFrontmatter(text); + if (fm.block) texts.set(frontmatterKey(file), fm.block); + else counts.set(frontmatterKey(file), 0); } + // A key the send set does not hold (a non-.md foreign-host file, or a file + // refused as outside the root, the checkout and the render folder) falls back to the + // estimate rather than billing zero. So does a key whose file is no longer + // the one measured (a link retargeted, or the file rewritten, since the walk). + // Misses are counted, not swallowed: a bill that is part-measured and + // part-estimated must not present itself as measured. + const missed = new Set(); + const fmSuffix = frontmatterKey(""); + const measuredKey = (key: string): string | undefined => { + const isFm = key.endsWith(fmSuffix); + let real: string; + let st: fs.Stats; + try { + real = realpathStepwise(isFm ? key.slice(0, -fmSuffix.length) : key); + st = fs.lstatSync(real); + } catch { + return undefined; + } + const lexical = measuredAs.get(fileIdentity(real, st)); + return lexical === undefined ? undefined : isFm ? frontmatterKey(lexical) : lexical; + }; + const tokensOf: TokensOf = (key, bytes) => { + const alias = measuredKey(key); + const exact = alias === undefined ? undefined : counts.get(alias); + if (exact !== undefined) return exact; + missed.add(key); + return estimateTokensOf(key, bytes); + }; + const measurement: ExactMeasurement = { + tokenSource: `count_tokens (${model})`, + tokensOf, + measuredFiles: 0, + counts, + realOf, + missedKeys: missed, + }; + if (texts.size === 0) return measurement; + onSend?.(files.filter((f) => texts.has(f.path))); + // Receipt-before-send. Content-free: file count + total bytes only. try { let totalBytes = 0; @@ -760,7 +1115,6 @@ export async function measureExactTokens( // One-char body: subtracting its single content token leaves the envelope. const envelope = (await countTokens("x", opts)) - 1; - const counts = new Map(); const keys = [...texts.keys()]; let next = 0; let done = 0; @@ -773,24 +1127,8 @@ export async function measureExactTokens( } }; await Promise.all(Array.from({ length: Math.min(EXACT_CONCURRENCY, keys.length) }, worker)); - - // A key the walk never saw (a non-.md foreign-host file) falls back to the - // estimate rather than billing zero. Misses are counted, not swallowed: a bill - // that is part-measured and part-estimated must not present itself as measured. - const missed = new Set(); - const tokensOf: TokensOf = (key, bytes) => { - const exact = counts.get(key); - if (exact !== undefined) return exact; - missed.add(key); - return estimateTokensOf(key, bytes); - }; - return { - tokenSource: `count_tokens (${model})`, - tokensOf, - measuredFiles: counts.size, - counts, - missedKeys: missed, - }; + measurement.measuredFiles = texts.size; + return measurement; } export interface Calibration { @@ -812,11 +1150,11 @@ export interface Calibration { * Estimate-vs-measured residual per file. This is what makes the divisor * auditable: run --exact and the tool grades its own offline estimate. */ -export function calibrationTable(counts: Map, root: string): Calibration { +export function calibrationTable(counts: Map, root: string, realOf?: Map): Calibration { const rows: Calibration["rows"] = []; for (const [key, tokens] of counts) { if (key.endsWith("#frontmatter") || tokens === 0) continue; - const bytes = bytesOf(key); + const bytes = bytesOf(realOf?.get(key) ?? key); if (bytes == null) continue; // Grade the estimate the tool actually uses, class divisor included. const estimated = Math.round(estimateTokensOf(key, bytes)); @@ -868,7 +1206,11 @@ const USAGE = "\n" + " --exact measure tokens with Anthropic's count_tokens instead of\n" + " estimating. Off by default: it sends the content of every\n" + - " .md file in the tree to api.anthropic.com. Needs\n" + + " .md file the bill prices inside the tree, inside\n" + + " the gstack checkout its gstack/ entry links to, or\n" + + " inside gstack's render folder ($GSTACK_STATE_ROOT/render)\n" + + " to api.anthropic.com. Files that symlinks reach anywhere\n" + + " else are named, not sent, and estimated offline. Needs\n" + " ANTHROPIC_API_KEY; passing --exact is the consent. An\n" + " egress receipt is written before the send (see\n" + " gstack-egress); if it cannot be written, the run falls\n" + @@ -887,6 +1229,7 @@ export interface MainOptions { apiKey?: string; fetchImpl?: FetchLike; egressHome?: string; + stateRoot?: string; } export async function contextBillMain(argv: string[], options: MainOptions = {}): Promise { @@ -917,29 +1260,81 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) // Exact mode is the only path that leaves the machine. Announce what is sent // before sending it, and degrade to the estimate rather than failing the run. - const exactFor = async (tree: string): Promise<{ + const sendSet = (tree: string) => (flags.exact ? exactSendSet(tree, options.stateRoot) : undefined); + const exactFor = async (tree: string, set: ExactSendSet | undefined): Promise<{ tokensOf?: TokensOf; tokenSource?: string; + missedKeys?: ReadonlySet; calibration?: Calibration; onDone?: () => void; }> => { - if (!flags.exact) return {}; - const files = walkMd(tree).length; - stderr.write( - `--exact: sending the content of ${files} .md file(s) under ${tree} to ` + - `api.anthropic.com for count_tokens (${flags.exactModel}). No other data leaves this machine.\n`, - ); + if (!set) return {}; + if (set.refused.length) { + const outsideDirs: string[] = []; + const kept = new Set(); + for (const d of [...new Set(set.refused.map((f) => path.dirname(f.real)))].sort()) { + let covered = false; + for (let a = d; !covered; a = path.dirname(a)) { + covered = kept.has(a); + if (path.dirname(a) === a) break; + } + if (covered) continue; + kept.add(d); + outsideDirs.push(d); + } + stderr.write( + `--exact: not sending ${set.refused.length} .md file(s) that symlinks reach outside ${tree}, the gstack checkout and gstack's render folder ` + + `(under ${outsideDirs.join(", ")}); they are estimated offline:\n` + + set.refused.map((f) => ` ${path.relative(tree, f.path)}\n`).join(""), + ); + } + if (set.files.length === 0) { + if (set.refused.length) { + stderr.write(`--exact: every billed .md file is outside ${tree}, the gstack checkout and gstack's render folder; nothing was sent, so the bill uses the offline estimate.\n`); + } else if (set.frontmatter.size) { + stderr.write(`--exact: no markdown skill file to measure under ${tree}; nothing was sent, so the bill uses the offline estimate.\n`); + } + return {}; + } + let announced = false; + const announce = (sent: WalkedFile[]) => { + announced = true; + const treeReal = fs.realpathSync(tree); + const outsideTree = sent.filter((f) => !encloses(treeReal, f.real)); + const inCheckout = outsideTree.filter((f) => set.checkout && encloses(set.checkout, f.real)).length; + const inRender = outsideTree.length - inCheckout; + const from = [ + inCheckout ? `${inCheckout} of them in the gstack checkout ${set.checkout}` : "", + inRender ? `${inRender} in gstack's render folder ${set.render}` : "", + ].filter(Boolean); + stderr.write( + `--exact: sending the content of ${sent.length} .md file(s) under ${tree}` + + (from.length ? ` (${from.join(", ")})` : "") + + ` to api.anthropic.com for count_tokens (${flags.exactModel}). No other data leaves this machine.\n`, + ); + }; try { const measured = await measureExactTokens(tree, { + sendSet: set, model: flags.exactModel, apiKey: options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "", fetchImpl: options.fetchImpl, + onSend: announce, egressHome: options.egressHome, }); + if (!announced) { + stderr.write( + `--exact: every .md file --exact could send under ${tree} is blank` + + (set.refused.length ? `, and the rest are outside the folders it sends from` : "") + + `; nothing was sent, so the bill uses the offline estimate.\n`, + ); + return {}; + } return { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, - calibration: calibrationTable(measured.counts, tree), + missedKeys: measured.missedKeys, + calibration: calibrationTable(measured.counts, tree, measured.realOf), onDone: () => { if (measured.missedKeys.size) { stderr.write( @@ -964,8 +1359,10 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) } const treeA = path.resolve(cwd, positional[0]); const treeB = path.resolve(cwd, positional[1]); - const optsA = await exactFor(treeA); - const optsB = await exactFor(treeB); + const setA = sendSet(treeA); + const setB = sendSet(treeB); + const optsA = await exactFor(treeA, setA); + const optsB = await exactFor(treeB, setB); const diff = diffBills(buildBill(treeA, optsA), buildBill(treeB, optsB)); optsA.onDone?.(); optsB.onDone?.(); @@ -980,7 +1377,7 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) ); return 2; } - const exactOpts = await exactFor(tree); + const exactOpts = await exactFor(tree, sendSet(tree)); const bill = buildBill(tree, exactOpts); exactOpts.onDone?.(); if (bill.skills.length === 0) { diff --git a/package.json b/package.json index 06cead090..8211913d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.241", + "version": "1.91.242", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 82afda6d9..b8a34f2b5 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -16,6 +16,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { canRevokeWrites } from "./helpers/fs-caps"; +import { bunIsolationArgs } from "../lib/bun-isolation"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -42,6 +43,13 @@ import { skillCensus } from "./helpers/skill-census"; const ROOT = path.join(import.meta.dir, ".."); const TREE_A = path.join(import.meta.dir, "fixtures", "context-bill", "tree-a"); +function markCheckout(dir: string): void { + fs.mkdirSync(path.join(dir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(dir, "VERSION"), "1.0.0\n"); + fs.writeFileSync(path.join(dir, "setup"), "#!/bin/sh\n"); + fs.writeFileSync(path.join(dir, "SKILL.md"), "---\nname: gstack\ndescription: g\n---\n# gstack\n"); +} + function fileBytes(...segments: string[]): number { return fs.statSync(path.join(...segments)).size; } @@ -157,7 +165,7 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => { // Regression pin for the v1.63 double-count: totalMd on a skill dir that // CONTAINS other skill dirs (the gstack root wraps the whole tree) used to // swallow the children's .md bytes too, so the TOTAL line billed every - // nested skill twice. A revert of the topSeg/SKILL.md skip in totalMd + // nested skill twice. Dropping skipNestedSkills from totalMd's walkMdFiles call // (lib/context-bill.ts) must fail here. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-")); fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n"); @@ -187,6 +195,25 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("skips a git checkout inside or linked from a skill, as it skips the root's gstack/ checkout", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-repo-")); + fs.mkdirSync(path.join(tmp, "qa", "vendor", "lib", ".git"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(tmp, "qa", "vendor", "lib", "README.md"), "VENDORED ".repeat(500)); + const ext = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-repo-ext-")); + fs.mkdirSync(path.join(ext, ".git")); + fs.writeFileSync(path.join(ext, "NOTES.md"), "LINKED-REPO"); + fs.symlinkSync(ext, path.join(tmp, "qa", "linked")); + const walked = walkMd(path.join(tmp, "qa")).map((f) => path.relative(tmp, f)); + const billed = buildBill(tmp).skills[0].totalMdBytes; + const skillBytes = fileBytes(tmp, "qa", "SKILL.md"); + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(ext, { recursive: true, force: true }); + expect(walked).toEqual([path.join("qa", "SKILL.md")]); + expect(billed).toBe(skillBytes); + }); + it("walkMd skips node_modules and dot-directories", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-walk-")); fs.writeFileSync(path.join(tmp, "real.md"), "x"); @@ -232,6 +259,263 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () fs.rmSync(tmp, { recursive: true, force: true }); }); + it("bills an installed tree whose SKILL.md files and section dirs are symlinks into the checkout", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-installed-")); + // The gstack checkout lives outside the skills root, as on a real install. + const checkout = path.join(tmp, "checkout"); + markCheckout(checkout); + for (const name of ["review", "office-hours"]) { + fs.mkdirSync(path.join(checkout, name, "sections"), { recursive: true }); + fs.writeFileSync( + path.join(checkout, name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n${"body ".repeat(200)}\n`, + ); + fs.writeFileSync(path.join(checkout, name, "sections", "flow.md"), "s".repeat(3_000)); + } + // setup's layout: a real dir per skill, SKILL.md and sections/ as links, + // plus a gstack/ link to the checkout itself. + const skills = path.join(tmp, "skills"); + for (const name of ["review", "office-hours"]) { + fs.mkdirSync(path.join(skills, name), { recursive: true }); + fs.symlinkSync(path.join(checkout, name, "SKILL.md"), path.join(skills, name, "SKILL.md")); + fs.symlinkSync(path.join(checkout, name, "sections"), path.join(skills, name, "sections")); + } + fs.symlinkSync(checkout, path.join(skills, "gstack")); + // Negative controls: a dangling SKILL.md link and a SKILL.md link to a + // directory are not skills. + fs.mkdirSync(path.join(skills, "dangling")); + fs.symlinkSync(path.join(checkout, "missing", "SKILL.md"), path.join(skills, "dangling", "SKILL.md")); + fs.mkdirSync(path.join(skills, "dirlink")); + fs.symlinkSync(path.join(checkout, "review", "sections"), path.join(skills, "dirlink", "SKILL.md")); + + const bill = buildBill(skills); + expect(bill.skills.map((s) => s.name).sort()).toEqual(["office-hours", "review"]); + for (const s of bill.skills) { + const target = path.join(checkout, s.name); + expect(s.frontmatterBytes).toBe(frontmatterBytes(path.join(target, "SKILL.md"))); + expect(s.skillMdBytes).toBe(fileBytes(target, "SKILL.md")); + // The linked sections/ dir is part of the skill's on-disk total. + expect(s.totalMdBytes).toBe(fileBytes(target, "SKILL.md") + fileBytes(target, "sections", "flow.md")); + } + // Every path the bill (and --exact) keys on stays under the skills root. + for (const file of walkMd(skills)) expect(file.startsWith(skills + path.sep)).toBe(true); + // dirlink/SKILL.md links to review/sections: the one real file is walked + // once, under the first name reached in sorted order. + expect(walkMd(skills).map((f) => path.relative(skills, f)).sort()).toEqual([ + path.join("dirlink", "SKILL.md", "flow.md"), + path.join("office-hours", "SKILL.md"), + path.join("office-hours", "sections", "flow.md"), + path.join("review", "SKILL.md"), + ]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("flags a symlinked foreign-host file, but not a dangling link or a directory with that name", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-foreign-")); + const checkout = path.join(tmp, "checkout", "qa"); + fs.mkdirSync(checkout, { recursive: true }); + fs.writeFileSync(path.join(checkout, "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(checkout, "agents.md"), "a".repeat(100)); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.symlinkSync(path.join(checkout, "SKILL.md"), path.join(skills, "qa", "SKILL.md")); + fs.symlinkSync(path.join(checkout, "agents.md"), path.join(skills, "qa", "agents.md")); + fs.symlinkSync(path.join(tmp, "missing"), path.join(skills, "qa", ".cursorrules")); + fs.mkdirSync(path.join(skills, "qa", "skill.json")); + + const qa = buildBill(skills).skills.find((s) => s.name === "qa")!; + expect(qa.foreignFiles.map((f) => [f.path, f.bytes])).toEqual([["agents.md", 100]]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("never follows a link that loops or climbs out of the root", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-escape-")); + // A sibling skill outside the root, reachable only through a parent link. + fs.mkdirSync(path.join(tmp, "outside")); + fs.writeFileSync(path.join(tmp, "outside", "SKILL.md"), "---\nname: outside\ndescription: d\n---\n# o\n"); + fs.writeFileSync(path.join(tmp, "outside", "SECRET.md"), "x".repeat(10_000)); + const root = path.join(tmp, "skills"); + fs.mkdirSync(path.join(root, "qa", "references"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(root, "qa", "references", "guide.md"), "g".repeat(500)); + fs.symlinkSync(root, path.join(root, "self")); + fs.symlinkSync(tmp, path.join(root, "parent")); + fs.symlinkSync(path.join(root, "qa"), path.join(root, "qa", "references", "loop")); + fs.symlinkSync(tmp, path.join(root, "qa", "references", "up")); + // A loop to a directory below the walk root: only the ancestor guard stops it. + fs.symlinkSync(path.join(root, "qa", "references"), path.join(root, "qa", "references", "again")); + + expect(findSkillDirs(root).map((d) => path.relative(root, d))).toEqual(["qa"]); + expect(walkMd(path.join(root, "qa")).map((f) => path.relative(root, f)).sort()).toEqual([ + path.join("qa", "SKILL.md"), + path.join("qa", "references", "guide.md"), + ]); + const bill = buildBill(root); + expect(bill.totals.totalMdBytes).toBe(fileBytes(root, "qa", "SKILL.md") + fileBytes(root, "qa", "references", "guide.md")); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("walks a fan-out of links once per real directory and file", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fanout-")); + // L0..L11 sit outside the root; each level holds two links to the next, so + // a walk that expands every link reaches leaf.md by 2^12 paths. + const depth = 12; + for (let d = 0; d <= depth; d++) fs.mkdirSync(path.join(tmp, `L${d}`)); + fs.writeFileSync(path.join(tmp, `L${depth}`, "leaf.md"), "l".repeat(300)); + for (let d = 0; d < depth; d++) { + for (const name of ["a", "b"]) fs.symlinkSync(path.join(tmp, `L${d + 1}`), path.join(tmp, `L${d}`, name)); + } + const root = path.join(tmp, "skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(tmp, "L0"), path.join(root, "qa", "refs")); + + const leaf = path.join("qa", "refs", ...Array(depth).fill("a"), "leaf.md"); + expect(walkMd(root).map((f) => path.relative(root, f))).toEqual([path.join("qa", "SKILL.md"), leaf]); + const bill = buildBill(root); + expect(bill.totals.totalMdBytes).toBe(fileBytes(root, "qa", "SKILL.md") + 300); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("keeps a symlinked skill that lives outside the root from climbing to the root's parent", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fence-")); + const home = path.join(tmp, "home"); + const root = path.join(home, "skills"); + fs.mkdirSync(path.join(root, "real"), { recursive: true }); + fs.writeFileSync(path.join(root, "real", "SKILL.md"), "---\nname: real\ndescription: d\n---\n# real\n"); + fs.writeFileSync(path.join(home, "private.md"), "p".repeat(1_000)); + const ext = path.join(tmp, "opt", "ext"); + fs.mkdirSync(ext, { recursive: true }); + fs.writeFileSync(path.join(ext, "SKILL.md"), "---\nname: ext\ndescription: d\n---\n# ext\n"); + fs.symlinkSync(home, path.join(ext, "up")); + fs.symlinkSync(ext, path.join(root, "ext")); + + const ext_ = buildBill(root).skills.find((s) => s.name === "ext")!; + expect(ext_.totalMdBytes).toBe(fileBytes(ext, "SKILL.md")); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("keeps a container skill's own files when a nested skill sorted first links to them", () => { + if (process.platform === "win32") return; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-container-")); + fs.writeFileSync(path.join(root, "SKILL.md"), "---\nname: c\ndescription: d\n---\n# c\n"); + fs.mkdirSync(path.join(root, "shared")); + fs.writeFileSync(path.join(root, "shared", "big.md"), "b".repeat(4_000)); + fs.mkdirSync(path.join(root, "a-skill")); + fs.writeFileSync(path.join(root, "a-skill", "SKILL.md"), "---\nname: a\ndescription: d\n---\n# a\n"); + fs.symlinkSync(path.join(root, "shared"), path.join(root, "a-skill", "shared")); + + const bill = buildBill(root); + const container = bill.skills.find((s) => s.name === path.basename(root))!; + const nested = bill.skills.find((s) => s.name === "a-skill")!; + expect(container.totalMdBytes).toBe(fileBytes(root, "SKILL.md") + 4_000); + expect(nested.totalMdBytes).toBe(fileBytes(root, "a-skill", "SKILL.md") + 4_000); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("keeps a root container from billing a nested skill that a deeper directory link reaches", () => { + if (process.platform === "win32") return; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-container-deep-")); + fs.writeFileSync(path.join(root, "SKILL.md"), "---\nname: c\ndescription: d\n---\n# c\n"); + fs.mkdirSync(path.join(root, "sub")); + fs.writeFileSync(path.join(root, "sub", "SKILL.md"), "---\nname: s\ndescription: d\n---\n# s\n"); + fs.writeFileSync(path.join(root, "sub", "big.md"), "b".repeat(10_000)); + fs.mkdirSync(path.join(root, "refs")); + fs.writeFileSync(path.join(root, "refs", "note.md"), "n"); + fs.symlinkSync(path.join(root, "sub"), path.join(root, "refs", "alias")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-container-outside-")); + fs.mkdirSync(path.join(outside, "deep")); + fs.writeFileSync(path.join(outside, "deep", "SKILL.md"), "---\nname: o\ndescription: d\n---\n# o\n"); + fs.writeFileSync(path.join(outside, "deep", "o.md"), "o".repeat(5_000)); + fs.mkdirSync(path.join(root, "linked")); + fs.symlinkSync(outside, path.join(root, "linked", "via")); + + const bill = buildBill(root); + const byName = Object.fromEntries(bill.skills.map((s) => [s.name, s])); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + expect(Object.keys(byName).sort()).toEqual([path.basename(root), path.join("linked", "via", "deep"), path.join("refs", "alias"), "sub"].sort()); + expect(byName[path.basename(root)].totalMdBytes).toBe("---\nname: c\ndescription: d\n---\n# c\n".length + 1); + expect(byName[path.join("refs", "alias")].totalMdBytes).toBe(byName.sub.totalMdBytes); + }); + + it("walks a chain of links deeper than the kernel's link limit without losing files", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-deep-chain-")); + const depth = 50; + for (let i = 0; i < depth; i++) { + fs.mkdirSync(path.join(tmp, "levels", `L${i}`), { recursive: true }); + fs.writeFileSync(path.join(tmp, "levels", `L${i}`, `f${i}.md`), "x"); + } + for (let i = 0; i < depth - 1; i++) { + fs.symlinkSync(path.join(tmp, "levels", `L${i + 1}`), path.join(tmp, "levels", `L${i}`, "next")); + } + const root = path.join(tmp, "skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(tmp, "levels", "L0"), path.join(root, "qa", "next")); + + expect(walkMd(path.join(root, "qa")).length).toBe(depth + 1); + const qa = buildBill(root).skills.find((s) => s.name === "qa")!; + expect(qa.totalMdBytes).toBe(fileBytes(root, "qa", "SKILL.md") + depth); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("discovers a skill behind a chain of directory links deeper than the kernel's limit", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-deep-skill-")); + const root = path.join(tmp, "root"); + fs.mkdirSync(root); + const depth = 45; + for (let i = 0; i < depth; i++) fs.mkdirSync(path.join(tmp, `d${i}`)); + fs.symlinkSync(path.join(tmp, "d0"), path.join(root, "c0")); + for (let i = 0; i < depth - 1; i++) fs.symlinkSync(path.join(tmp, `d${i + 1}`), path.join(tmp, `d${i}`, `c${i + 1}`)); + fs.writeFileSync(path.join(tmp, `d${depth - 1}`, "SKILL.md"), "---\nname: deep\ndescription: d\n---\n# deep\n"); + const dirs = findSkillDirs(root); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(dirs.length).toBe(1); + }); + + it("refuses skill discovery past its entry cap instead of walking on", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-discovery-cap-")); + fs.mkdirSync(path.join(root, "vendor")); + for (let i = 0; i < 10; i++) fs.mkdirSync(path.join(root, "vendor", `d${i}`)); + expect(() => findSkillDirs(root, 5)).toThrow(/more than 5 entries to walk/); + expect(findSkillDirs(root)).toEqual([]); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("bills a subdirectory whose SKILL.md is a directory to its container, as discovery does not count it as a skill", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-skilldir-")); + fs.writeFileSync(path.join(root, "SKILL.md"), "---\nname: c\ndescription: d\n---\n# c\n"); + fs.mkdirSync(path.join(root, "sub", "SKILL.md"), { recursive: true }); + fs.writeFileSync(path.join(root, "sub", "notes.md"), "n".repeat(500)); + const bill = buildBill(root); + fs.rmSync(root, { recursive: true, force: true }); + expect(bill.skills.map((s) => s.name)).toEqual([path.basename(root)]); + expect(bill.skills[0].totalMdBytes).toBe(bill.skills[0].skillMdBytes + 500); + }); + + it("refuses a parent link when the root's own name starts with '..'", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-dotdot-")); + fs.mkdirSync(path.join(tmp, "outside")); + fs.writeFileSync(path.join(tmp, "outside", "SKILL.md"), "---\nname: outside\ndescription: d\n---\n# o\n"); + const root = path.join(tmp, "..skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(tmp, path.join(root, "up")); + + expect(findSkillDirs(root).map((d) => path.relative(root, d))).toEqual(["qa"]); + expect(walkMd(root).map((f) => path.relative(root, f))).toEqual([path.join("qa", "SKILL.md")]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("prefers ./skills, then .agents/skills, then .claude/skills, project before user", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-detect-")); const home = path.join(tmp, "home"); @@ -436,10 +720,15 @@ describe("--budget", () => { describe("--exact (opt-in measurement; offline here via an injected fetch)", () => { let egressHome: string; + let savedStateRoot: string | undefined; beforeAll(() => { egressHome = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-")); + savedStateRoot = process.env.GSTACK_STATE_ROOT; + process.env.GSTACK_STATE_ROOT = path.join(egressHome, "state"); }); afterAll(() => { + if (savedStateRoot === undefined) delete process.env.GSTACK_STATE_ROOT; + else process.env.GSTACK_STATE_ROOT = savedStateRoot; try { fs.chmodSync(path.join(egressHome, "security"), 0o700); } catch {} fs.rmSync(egressHome, { recursive: true, force: true }); }); @@ -554,6 +843,63 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(measured.missedKeys.size).toBe(0); }); + it("on an installed layout, measures every billed file and sends nothing behind gstack/ or a .md-named link to another file", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-installed-")); + const checkout = path.join(tmp, "checkout"); + markCheckout(checkout); + fs.mkdirSync(path.join(checkout, "qa"), { recursive: true }); + fs.writeFileSync(path.join(checkout, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(checkout, "README.md"), "checkout only"); + fs.writeFileSync(path.join(tmp, "notes.txt"), "not markdown"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.symlinkSync(path.join(checkout, "qa", "SKILL.md"), path.join(skills, "qa", "SKILL.md")); + fs.symlinkSync(path.join(tmp, "notes.txt"), path.join(skills, "qa", "notes.md")); + fs.symlinkSync(checkout, path.join(skills, "gstack")); + + const calls: { body: string }[] = []; + const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome }); + const sent = calls.map((c) => JSON.parse(c.body).messages[0].content); + expect(sent).not.toContain("checkout only"); + expect(sent).not.toContain("not markdown"); + const bill = buildBill(skills, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + expect(bill.skills.map((s) => s.name)).toEqual(["qa"]); + expect([...measured.missedKeys]).toEqual([]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("sends each real file once and prices every link to it with that measurement", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-once-")); + const checkout = path.join(tmp, "checkout"); + fs.mkdirSync(path.join(checkout, "shared"), { recursive: true }); + fs.mkdirSync(path.join(checkout, "twin")); + fs.writeFileSync(path.join(checkout, "twin", "SKILL.md"), "---\nname: twin\ndescription: d\n---\n# twin\n"); + fs.writeFileSync(path.join(checkout, "shared", "guide.md"), "SHARED-GUIDE ".repeat(20)); + const skills = path.join(tmp, "skills"); + for (const name of ["a", "b"]) { + fs.mkdirSync(path.join(skills, name), { recursive: true }); + fs.symlinkSync(path.join(checkout, "twin", "SKILL.md"), path.join(skills, name, "SKILL.md")); + fs.symlinkSync(path.join(checkout, "shared"), path.join(skills, name, "refs")); + } + markCheckout(checkout); + fs.symlinkSync(checkout, path.join(skills, "gstack")); + + const calls: { body: string }[] = []; + const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome }); + const sent = calls.map((c) => JSON.parse(c.body).messages[0].content as string); + expect(sent.filter((t) => t.startsWith("SHARED-GUIDE")).length).toBe(1); + expect(sent.filter((t) => t.includes("# twin")).length).toBe(1); + const bill = buildBill(skills, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + expect(bill.skills.map((s) => s.name)).toEqual(["a", "b"]); + expect([...measured.missedKeys]).toEqual([]); + const [a, b] = bill.skills; + expect(a.totalMdTokens).toBe(b.totalMdTokens); + expect(a.frontmatterTokens).toBe(b.frontmatterTokens); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("counts anything it could not measure instead of passing it off as measured", async () => { const measured = await measureExactTokens(TREE_A, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome, @@ -646,6 +992,809 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(bill.calibration.rows.length).toBeGreaterThan(0); }); + it("says how many of the files it sends come from the gstack checkout", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-notice-")); + fs.mkdirSync(path.join(tmp, "checkout", "qa", "sections"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "checkout", "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(tmp, "checkout", "qa", "sections", "flow.md"), "f"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.symlinkSync(path.join(tmp, "checkout", "qa", "SKILL.md"), path.join(skills, "qa", "SKILL.md")); + fs.symlinkSync(path.join(tmp, "checkout", "qa", "sections"), path.join(skills, "qa", "sections")); + fs.writeFileSync(path.join(skills, "qa", "notes.md"), "n"); + markCheckout(path.join(tmp, "checkout")); + fs.symlinkSync(path.join(tmp, "checkout"), path.join(skills, "gstack")); + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(), + egressHome, + }); + expect(code).toBe(0); + expect(stderr.text()).toContain("sending the content of 3 .md file(s)"); + expect(stderr.text()).toContain(`(2 of them in the gstack checkout ${fs.realpathSync(path.join(tmp, "checkout"))})`); + expect(stderr.text()).not.toContain("not sending"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("sends nothing the bill does not price, even what the root walk reaches", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-send-set-")); + const skills = path.join(tmp, "skills"); + const ext = path.join(tmp, "ext"); + fs.mkdirSync(skills); + fs.mkdirSync(path.join(ext, "cc"), { recursive: true }); + fs.mkdirSync(path.join(ext, "private")); + fs.writeFileSync(path.join(ext, "cc", "SKILL.md"), "---\nname: cc\ndescription: d\n---\n# cc\n"); + fs.writeFileSync(path.join(ext, "private", "notes.md"), "PRIVATE-NOTES"); + fs.symlinkSync(ext, path.join(ext, "cc", "up")); + fs.symlinkSync(path.join(ext, "cc"), path.join(skills, "cc")); + markCheckout(ext); + fs.symlinkSync(ext, path.join(skills, "gstack")); + const calls: { body: string }[] = []; + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + expect(code).toBe(0); + expect(stderr.text()).not.toContain("--exact unavailable"); + expect(calls.some((c) => c.body.includes("# cc"))).toBe(true); + expect(calls.some((c) => c.body.includes("PRIVATE-NOTES"))).toBe(false); + expect(stderr.text()).toContain("sending the content of 1 .md file(s)"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("walks for --exact only what the bill walks, so no file is keyed through a link no skill bills", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-send-walk-")); + const dev = path.join(tmp, "dev"); + for (const name of ["m", "zz"]) { + fs.mkdirSync(path.join(dev, name), { recursive: true }); + fs.writeFileSync(path.join(dev, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n# ${name}\n`); + } + fs.mkdirSync(path.join(dev, "other")); + fs.writeFileSync(path.join(dev, "other", "n1.md"), "n"); + fs.symlinkSync(dev, path.join(dev, "m", "up")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(skills); + fs.symlinkSync(path.join(dev, "m"), path.join(skills, "m")); + fs.symlinkSync(path.join(dev, "zz"), path.join(skills, "zz")); + markCheckout(dev); + fs.symlinkSync(dev, path.join(skills, "gstack")); + const calls: { body: string }[] = []; + const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome }); + const keys = [...measured.counts.keys()].map((k) => path.relative(skills, k)).sort(); + expect(keys).toEqual([ + path.join("m", "SKILL.md"), + path.join("m", "SKILL.md#frontmatter"), + path.join("zz", "SKILL.md"), + path.join("zz", "SKILL.md#frontmatter"), + ]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("sends nothing and writes no receipt for an existing tree with no skills", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-no-skills-")); + fs.mkdirSync(path.join(tmp, "docs")); + fs.writeFileSync(path.join(tmp, "docs", "a.md"), "a"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-no-skills-egress-")); + const out = capture(); + const calls: { body: string }[] = []; + const code = await contextBillMain([tmp, "--exact"], { + stdout: out.stream, + stderr: out.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(2); + expect(out.text()).toContain("No SKILL.md files found"); + expect(out.text()).not.toContain("--exact: sending"); + expect(out.text()).not.toContain("no markdown skill file"); + expect(calls.length).toBe(0); + 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 }); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(tmp, "qa", "references", "r.md"), "---\ntitle: ref\n---\nbody\n"); + const calls: { body: string }[] = []; + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(measured.counts.has(path.join(tmp, "qa", "references", "r.md#frontmatter"))).toBe(false); + expect(calls.length).toBe(4); + }); + + it("sends only the files the walk approved, even when links or files change during --diff", async () => { + if (process.platform === "win32") return; + const run = async (swap: (store: string, qa: string) => void) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-swap-")); + for (const tree of ["a", "b"]) { + fs.mkdirSync(path.join(tmp, tree, "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, tree, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + } + const store = path.join(tmp, "b", "store"); + fs.mkdirSync(store); + fs.writeFileSync(path.join(store, "safe.md"), "safe"); + fs.writeFileSync(path.join(store, "secret.env"), "SYNTHETIC_PRIVATE_NONMARKDOWN"); + const qa = path.join(tmp, "b", "qa"); + fs.symlinkSync(path.join(store, "safe.md"), path.join(qa, "ref.md")); + const calls: { body: string }[] = []; + const inner = fakeFetch(calls) as unknown as (u: string, i: { body: string }) => Promise; + let swapped = false; + const fetchImpl = (async (u: string, i: { body: string }) => { + if (!swapped) { + swapped = true; + swap(store, qa); + } + return inner(u, i); + }) as never; + const err = capture(); + const code = await contextBillMain(["--diff", path.join(tmp, "a"), path.join(tmp, "b"), "--exact", "--json"], { + stdout: capture().stream, + stderr: err.stream, + apiKey: "sk-test", + fetchImpl, + egressHome, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + return { code, calls, err: err.text() }; + }; + + const retargeted = await run((store, qa) => { + fs.unlinkSync(path.join(qa, "ref.md")); + fs.symlinkSync(path.join(store, "secret.env"), path.join(qa, "ref.md")); + }); + expect(retargeted.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + + const replaced = await run((store) => { + fs.unlinkSync(path.join(store, "safe.md")); + fs.symlinkSync(path.join(store, "secret.env"), path.join(store, "safe.md")); + }); + expect(replaced.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(replaced.code).toBe(1); + expect(replaced.err).toContain("changed after it was walked"); + + const hardlinked = await run((store) => { + fs.unlinkSync(path.join(store, "safe.md")); + fs.linkSync(path.join(store, "secret.env"), path.join(store, "safe.md")); + }); + expect(hardlinked.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(hardlinked.code).toBe(1); + expect(hardlinked.err).toContain("changed after it was walked"); + + const parentSwapped = await run((store) => { + const decoy = `${store}-decoy`; + fs.mkdirSync(decoy); + fs.writeFileSync(path.join(decoy, "safe.md"), "SYNTHETIC_PRIVATE_DECOY"); + fs.renameSync(store, `${store}-old`); + fs.symlinkSync(decoy, store); + }); + expect(parentSwapped.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(parentSwapped.code).toBe(1); + expect(parentSwapped.err).toContain("changed after it was walked"); + + const sameFileMoved = await run((store) => { + fs.renameSync(store, `${store}-old`); + fs.symlinkSync(`${store}-old`, store); + }); + expect(sameFileMoved.code).toBe(1); + expect(sameFileMoved.err).toContain("changed after it was walked"); + }); + + it("never reuses a measurement for a file retargeted or rewritten after it was measured", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-stale-")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "small.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(tmp, "big.md"), "---\nname: qa\ndescription: d\n---\n" + "z".repeat(60_000)); + fs.symlinkSync(path.join(tmp, "small.md"), path.join(skills, "qa", "SKILL.md")); + fs.writeFileSync(path.join(skills, "qa", "ref.md"), "r"); + const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + fs.unlinkSync(path.join(skills, "qa", "SKILL.md")); + fs.symlinkSync(path.join(tmp, "big.md"), path.join(skills, "qa", "SKILL.md")); + fs.writeFileSync(path.join(skills, "qa", "ref.md"), "r".repeat(9_000)); + const bill = buildBill(skills, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + fs.rmSync(tmp, { recursive: true, force: true }); + const skillMd = path.join(skills, "qa", "SKILL.md"); + const ref = path.join(skills, "qa", "ref.md"); + expect(measured.missedKeys.has(skillMd)).toBe(true); + expect(measured.missedKeys.has(ref)).toBe(true); + expect(bill.skills[0].skillMdTokens).toBeGreaterThan(10_000); + expect(bill.skills[0].totalMdTokens).toBeGreaterThan(12_000); + }); + + it("never reuses a measurement for a file replaced by one with the same size and mtime", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-same-stat-")); + fs.mkdirSync(path.join(tmp, "qa")); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const ref = path.join(tmp, "qa", "ref.md"); + fs.writeFileSync(ref, "a".repeat(3_000)); + const mtime = new Date("2026-01-01T00:00:00Z"); + fs.utimesSync(ref, mtime, mtime); + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + const replacement = path.join(tmp, "qa", ".replacement"); + fs.writeFileSync(replacement, "b".repeat(3_000)); + fs.utimesSync(replacement, mtime, mtime); + fs.renameSync(replacement, ref); + buildBill(tmp, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, missedKeys: measured.missedKeys }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(measured.missedKeys.has(ref)).toBe(true); + }); + + it("keeps a bill measured when a skill has no frontmatter or a reference is empty", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-zero-")); + fs.mkdirSync(path.join(tmp, "alpha")); + fs.writeFileSync(path.join(tmp, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: d\n---\n# alpha\n"); + fs.writeFileSync(path.join(tmp, "alpha", "empty.md"), ""); + fs.mkdirSync(path.join(tmp, "beta")); + fs.writeFileSync(path.join(tmp, "beta", "SKILL.md"), "# beta has no frontmatter\n"); + const inner = fakeFetch() as unknown as (u: string, i: { body: string }) => Promise; + const strict = (async (u: string, i: { body: string }) => + !/\S/.test(JSON.parse(i.body).messages[0].content) + ? { ok: false, status: 400, text: async () => "messages: text content blocks must contain non-whitespace text" } + : inner(u, i)) as never; + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: strict, egressHome }); + const bill = buildBill(tmp, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, missedKeys: measured.missedKeys }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect([...measured.missedKeys]).toEqual([]); + expect(bill.tokenEstimateErrorPct).toBe(0); + expect(bill.tokenSource).toBe("count_tokens (m)"); + }); + + it("sends nothing and writes no receipt when every file it could send is blank", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-blank-")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), ""); + const home = path.join(tmp, "egress"); + const calls: { body: string }[] = []; + const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome: home }); + buildBill(skills, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, missedKeys: measured.missedKeys }); + const run = async () => { + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome: home, + }); + return { code, err: stderr.text() }; + }; + const blank = await run(); + fs.mkdirSync(path.join(tmp, "outside")); + fs.writeFileSync(path.join(tmp, "outside", "doc.md"), "OUTSIDE-DOC"); + fs.symlinkSync(path.join(tmp, "outside"), path.join(skills, "qa", "notes")); + const withRefused = await run(); + const receipts = listReceipts(home).length; + fs.rmSync(tmp, { recursive: true, force: true }); + expect(calls).toEqual([]); + expect(receipts).toBe(0); + expect([...measured.missedKeys]).toEqual([]); + expect(blank.code).toBe(0); + expect(blank.err).not.toContain("sending the content"); + expect(blank.err).toContain(`every .md file --exact could send under ${skills} is blank; nothing was sent`); + expect(withRefused.code).toBe(0); + expect(withRefused.err).toContain(`is blank, and the rest are outside the folders it sends from; nothing was sent`); + }); + + it("estimates a whitespace-only file instead of sending it, and counts only the files it sends", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-whitespace-")); + fs.mkdirSync(path.join(tmp, "qa")); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(tmp, "qa", "spaces.md"), " \n".repeat(4_000)); + const calls: { body: string }[] = []; + const inner = fakeFetch(calls) as unknown as (u: string, i: { body: string }) => Promise; + const strict = (async (u: string, i: { body: string }) => + !/\S/.test(JSON.parse(i.body).messages[0].content) + ? { ok: false, status: 400, text: async () => "messages: text content blocks must contain non-whitespace text" } + : inner(u, i)) as never; + const stdout = capture(); + const stderr = capture(); + const code = await contextBillMain([tmp, "--exact", "--json"], { + stdout: stdout.stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: strict, + egressHome, + }); + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: strict, egressHome }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(code).toBe(0); + expect(stderr.text()).toContain("sending the content of 1 .md file(s)"); + const bill = JSON.parse(stdout.text()); + expect(bill.tokenSource).toStartWith("mixed: "); + expect(bill.skills[0].totalMdTokens).toBeGreaterThan(1_000); + expect(measured.measuredFiles).toBe(2); + }); + + it("trusts $GSTACK_STATE_ROOT/render when no state root is passed in", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-render-env-")); + const saved = process.env.GSTACK_STATE_ROOT; + const calls: { body: string }[] = []; + const stderr = capture(); + let code: number; + try { + process.env.GSTACK_STATE_ROOT = path.join(tmp, "state"); + const rendered = path.join(tmp, "state", "render", "claude", "qa"); + fs.mkdirSync(rendered, { recursive: true }); + fs.writeFileSync(path.join(rendered, "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# RENDER-ENV\n"); + fs.mkdirSync(path.join(tmp, "skills", "qa"), { recursive: true }); + fs.symlinkSync(path.join(rendered, "SKILL.md"), path.join(tmp, "skills", "qa", "SKILL.md")); + code = await contextBillMain([path.join(tmp, "skills"), "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + } finally { + process.env.GSTACK_STATE_ROOT = saved; + fs.rmSync(tmp, { recursive: true, force: true }); + } + expect(code).toBe(0); + expect(calls.some((c) => c.body.includes("RENDER-ENV"))).toBe(true); + expect(stderr.text()).toContain("in gstack's render folder"); + }); + + it("still bills the tree when the state root is not a readable directory", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-stateroot-file-")); + const stateRoot = path.join(tmp, "statefile"); + fs.writeFileSync(stateRoot, "not a directory"); + fs.mkdirSync(path.join(tmp, "skills", "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "skills", "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const stdout = capture(); + const code = await contextBillMain([path.join(tmp, "skills"), "--exact", "--json"], { + stdout: stdout.stream, + stderr: capture().stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(), + egressHome, + stateRoot, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(code).toBe(0); + expect(JSON.parse(stdout.text()).tokenSource).toBe("count_tokens (claude-opus-4-5)"); + }); + + it("refuses a walked file swapped for a FIFO instead of hanging", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fifo-")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(skills, "qa", "a.md"), "A"); + const script = path.join(tmp, "probe.ts"); + fs.writeFileSync( + script, + [ + `import fs from "node:fs";`, + `import { spawnSync } from "node:child_process";`, + `import { exactSendSet, measureExactTokens } from ${JSON.stringify(path.join(ROOT, "lib", "context-bill.ts"))};`, + `const root = ${JSON.stringify(skills)};`, + `const sendSet = exactSendSet(root, ${JSON.stringify(path.join(tmp, "state"))});`, + `fs.unlinkSync(${JSON.stringify(path.join(skills, "qa", "a.md"))});`, + `spawnSync("mkfifo", [${JSON.stringify(path.join(skills, "qa", "a.md"))}], { timeout: 5_000 });`, + `if (!fs.lstatSync(${JSON.stringify(path.join(skills, "qa", "a.md"))}).isFIFO()) { console.log("NOFIFO"); process.exit(0); }`, + `const fetchImpl = (async () => ({ ok: true, json: async () => ({ input_tokens: 9 }) })) as never;`, + `try { await measureExactTokens(root, { sendSet, model: "m", apiKey: "sk-test", fetchImpl, egressHome: ${JSON.stringify(tmp)} }); console.log("SENT"); }`, + `catch (error) { console.log("REFUSED " + (error as Error).message); }`, + ].join("\n"), + ); + const result = Bun.spawnSync([process.execPath, ...bunIsolationArgs(), script], { timeout: 15_000 }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).not.toContain("NOFIFO"); + expect(result.stdout.toString()).toContain("REFUSED"); + expect(result.stdout.toString()).toContain("changed after it was walked"); + }); + + it("sends files in the root and the linked gstack checkout, and refuses and names anything else a link reaches", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-")); + const checkout = path.join(tmp, "checkout"); + markCheckout(checkout); + fs.mkdirSync(path.join(checkout, "qa", "sections"), { recursive: true }); + fs.writeFileSync(path.join(checkout, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# CHECKOUT-QA\n"); + fs.writeFileSync(path.join(checkout, "qa", "sections", "flow.md"), "CHECKOUT-FLOW"); + const documents = path.join(tmp, "Documents"); + fs.mkdirSync(path.join(documents, "vault"), { recursive: true }); + fs.writeFileSync(path.join(documents, "vault", "diary.md"), "PRIVATE-DIARY"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.symlinkSync(checkout, path.join(skills, "gstack")); + fs.symlinkSync(path.join(checkout, "qa", "SKILL.md"), path.join(skills, "qa", "SKILL.md")); + fs.symlinkSync(path.join(checkout, "qa", "sections"), path.join(skills, "qa", "sections")); + fs.symlinkSync(documents, path.join(skills, "qa", "notes")); + + const calls: { body: string }[] = []; + const stdout = capture(); + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: stdout.stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + const err = stderr.text(); + const documentsReal = fs.realpathSync(path.join(documents, "vault")); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(code).toBe(0); + expect(calls.some((c) => c.body.includes("CHECKOUT-QA"))).toBe(true); + expect(calls.some((c) => c.body.includes("CHECKOUT-FLOW"))).toBe(true); + expect(calls.some((c) => c.body.includes("PRIVATE-DIARY"))).toBe(false); + expect(err).toContain(`not sending 1 .md file(s)`); + expect(err).toContain(documentsReal); + expect(err).toContain(path.join("qa", "notes", "vault", "diary.md")); + expect(err).toContain("estimated"); + const bill = JSON.parse(stdout.text()); + const skillMd = "---\nname: qa\ndescription: d\n---\n# CHECKOUT-QA\n"; + expect(bill.skills[0].totalMdBytes).toBe(skillMd.length + "CHECKOUT-FLOW".length + "PRIVATE-DIARY".length); + }); + + it("labels a bill with any estimated item as mixed, in --json and in text", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-mixed-")); + const outside = path.join(tmp, "outside", "ext"); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, "SKILL.md"), "---\nname: ext\ndescription: d\n---\n" + "e".repeat(6_000)); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(outside, path.join(skills, "ext")); + const run = async (args: string[]) => { + const stdout = capture(); + const code = await contextBillMain([skills, "--exact", ...args], { + stdout: stdout.stream, + stderr: capture().stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(), + egressHome, + }); + return { code, out: stdout.text() }; + }; + const json = await run(["--json"]); + const text = await run([]); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(json.code).toBe(0); + const bill = JSON.parse(json.out); + expect(bill.tokenSource).toStartWith("mixed: count_tokens"); + expect(bill.tokenSource).toContain("estimated offline"); + expect(bill.tokenEstimateErrorPct).toBeGreaterThan(0); + expect(text.out).toContain("Token counts are MIXED"); + expect(text.out).not.toContain("Token counts measured with"); + }); + + async function exactRun(skills: string) { + const calls: { body: string }[] = []; + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + return { code, calls, err: stderr.text() }; + } + + it("sends a render install's files from gstack's render folder, and refuses a link out of it", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-render-")); + const stateRoot = path.join(tmp, "state"); + const rendered = path.join(stateRoot, "render", "claude", "review"); + fs.mkdirSync(path.join(rendered, "sections"), { recursive: true }); + fs.writeFileSync(path.join(rendered, "SKILL.md"), "---\nname: review\ndescription: d\n---\n# RENDER-SKILL\n"); + fs.writeFileSync(path.join(rendered, "sections", "a.md"), "RENDER-SECTION"); + fs.mkdirSync(path.join(tmp, "Documents")); + fs.writeFileSync(path.join(tmp, "Documents", "diary.md"), "PRIVATE-DIARY"); + fs.symlinkSync(path.join(tmp, "Documents", "diary.md"), path.join(rendered, "notes.md")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "review"), { recursive: true }); + for (const name of ["SKILL.md", "sections", "notes.md"]) fs.symlinkSync(path.join(rendered, name), path.join(skills, "review", name)); + + const calls: { body: string }[] = []; + const stdout = capture(); + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: stdout.stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + stateRoot, + }); + const renderReal = fs.realpathSync(path.join(stateRoot, "render")); + fs.rmSync(tmp, { recursive: true, force: true }); + const err = stderr.text(); + expect(code).toBe(0); + expect(calls.some((c) => c.body.includes("RENDER-SKILL"))).toBe(true); + expect(calls.some((c) => c.body.includes("RENDER-SECTION"))).toBe(true); + expect(calls.some((c) => c.body.includes("PRIVATE-DIARY"))).toBe(false); + expect(err).toContain(`2 in gstack's render folder ${renderReal}`); + expect(err).toContain("not sending 1 .md file(s)"); + expect(err).toContain(` ${path.join("review", "notes.md")}\n`); + expect(JSON.parse(stdout.text()).tokenSource).toStartWith("mixed: "); + }); + + it("does not trust a render folder that is itself a link", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-render-link-")); + const elsewhere = path.join(tmp, "elsewhere"); + fs.mkdirSync(elsewhere); + fs.writeFileSync(path.join(elsewhere, "notes.md"), "PRIVATE-NOTES"); + const stateRoot = path.join(tmp, "state"); + fs.mkdirSync(stateRoot); + fs.symlinkSync(elsewhere, path.join(stateRoot, "render")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(elsewhere, "notes.md"), path.join(skills, "qa", "notes.md")); + const calls: { body: string }[] = []; + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + stateRoot, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(code).toBe(0); + expect(calls.some((c) => c.body.includes("PRIVATE-NOTES"))).toBe(false); + expect(stderr.text()).toContain("not sending 1 .md file(s)"); + }); + + it("names each outside directory once, collapsing nested ones, and lists every refused file", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-dirs-")); + const docs = path.join(tmp, "Documents"); + fs.mkdirSync(path.join(docs, "vault", "sub"), { recursive: true }); + fs.mkdirSync(path.join(docs, "vault-b"), { recursive: true }); + fs.writeFileSync(path.join(docs, "vault", "a.md"), "A"); + fs.writeFileSync(path.join(docs, "vault", "sub", "b.md"), "B"); + fs.writeFileSync(path.join(docs, "vault-b", "c.md"), "C"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(docs, "vault"), path.join(skills, "qa", "v1")); + fs.symlinkSync(path.join(docs, "vault-b"), path.join(skills, "qa", "v2")); + const vault = fs.realpathSync(path.join(docs, "vault")); + const vaultB = fs.realpathSync(path.join(docs, "vault-b")); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.err).toContain(`not sending 3 .md file(s)`); + expect(run.err).toContain(`(under ${vault}, ${vaultB})`); + for (const f of [path.join("qa", "v1", "a.md"), path.join("qa", "v1", "sub", "b.md"), path.join("qa", "v2", "c.md")]) { + expect(run.err).toContain(` ${f}\n`); + } + }); + + it("does not take a gstack/ entry as the checkout unless it is a gstack checkout", async () => { + if (process.platform === "win32") return; + const cases: Array<(tmp: string, skills: string, docs: string) => void> = [ + (_tmp, skills, docs) => fs.symlinkSync(docs, path.join(skills, "gstack")), + (_tmp, skills, docs) => { + fs.mkdirSync(path.join(docs, ".git")); + fs.symlinkSync(docs, path.join(skills, "gstack")); + }, + (tmp, skills) => { + fs.mkdirSync(path.join(tmp, ".git")); + fs.writeFileSync(path.join(tmp, "VERSION"), "1.0.0\n"); + fs.writeFileSync(path.join(tmp, "setup"), "#!/bin/sh\n"); + fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: my-project\ndescription: p\n---\n# p\n"); + fs.symlinkSync(tmp, path.join(skills, "gstack")); + }, + (_tmp, skills, docs) => { + markCheckout(docs); + fs.rmSync(path.join(docs, ".git"), { recursive: true }); + fs.symlinkSync(docs, path.join(skills, "gstack")); + }, + ]; + for (const plant of cases) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-not-checkout-")); + const docs = path.join(tmp, "Documents"); + fs.mkdirSync(path.join(docs, "vault"), { recursive: true }); + fs.writeFileSync(path.join(docs, "vault", "diary.md"), "PRIVATE-DIARY"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(docs, "vault"), path.join(skills, "qa", "notes")); + plant(tmp, skills, docs); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.code).toBe(0); + expect(run.calls.some((c) => c.body.includes("PRIVATE-DIARY"))).toBe(false); + expect(run.err).toMatch(/not sending \d+ \.md file\(s\)/); + expect(run.err).toContain("diary.md"); + expect(run.err).not.toContain("in the gstack checkout"); + } + }); + + it("sends a dev-setup tree's files from the gstack checkout that contains it", async () => { + if (process.platform === "win32") return; + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-dev-setup-")); + markCheckout(repo); + fs.mkdirSync(path.join(repo, "review")); + fs.writeFileSync(path.join(repo, "review", "SKILL.md"), "---\nname: review\ndescription: d\n---\n# REVIEW-SKILL\n"); + const skills = path.join(repo, ".claude", "skills"); + fs.mkdirSync(path.join(skills, "review"), { recursive: true }); + fs.symlinkSync(repo, path.join(skills, "gstack")); + fs.symlinkSync(path.join(repo, "review", "SKILL.md"), path.join(skills, "review", "SKILL.md")); + const run = await exactRun(skills); + fs.rmSync(repo, { recursive: true, force: true }); + expect(run.code).toBe(0); + expect(run.calls.some((c) => c.body.includes("REVIEW-SKILL"))).toBe(true); + expect(run.err).not.toContain("not sending"); + }); + + it("says every billed file was refused, not that there was none, when nothing is left to send", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-all-refused-")); + fs.mkdirSync(path.join(tmp, "ext", "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "ext", "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(skills); + fs.symlinkSync(path.join(tmp, "ext", "qa"), path.join(skills, "qa")); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.calls.length).toBe(0); + expect(run.err).toContain("not sending 1 .md file(s)"); + expect(run.err).toContain("every billed .md file is outside"); + expect(run.err).not.toContain("no markdown skill file"); + }); + + it("--exact measures every file behind a link chain deeper than the kernel's limit", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-deep-")); + const levels = path.join(tmp, "levels"); + const depth = 50; + for (let i = 0; i < depth; i++) { + fs.mkdirSync(path.join(levels, `L${i}`), { recursive: true }); + fs.writeFileSync(path.join(levels, `L${i}`, `f${i}.md`), `F${i}-${"x".repeat(30)}`); + } + for (let i = 0; i < depth - 1; i++) fs.symlinkSync(path.join(levels, `L${i + 1}`), path.join(levels, `L${i}`, "next")); + markCheckout(levels); + const root = path.join(tmp, "skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(levels, "L0"), path.join(root, "qa", "next")); + fs.symlinkSync(levels, path.join(root, "gstack")); + const measured = await measureExactTokens(root, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + buildBill(root, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + const rows = calibrationTable(measured.counts, root, measured.realOf).rows.length; + fs.rmSync(tmp, { recursive: true, force: true }); + expect([...measured.missedKeys]).toEqual([]); + expect([...measured.counts.keys()].filter((k) => !k.endsWith("#frontmatter")).length).toBe(depth + 1); + expect(rows).toBe(depth + 1); + }); + + it("prices a second skill's links into a chain deeper than the kernel's limit from the one measurement", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-shared-deep-")); + const levels = path.join(tmp, "levels"); + const depth = 50; + for (let i = 0; i < depth; i++) { + fs.mkdirSync(path.join(levels, `L${i}`), { recursive: true }); + fs.writeFileSync(path.join(levels, `L${i}`, `f${i}.md`), `F${i}-${"x".repeat(30)}`); + } + for (let i = 0; i < depth - 1; i++) fs.symlinkSync(path.join(levels, `L${i + 1}`), path.join(levels, `L${i}`, "next")); + markCheckout(levels); + const root = path.join(tmp, "skills"); + for (const name of ["qa", "qb"]) { + fs.mkdirSync(path.join(root, name), { recursive: true }); + fs.writeFileSync(path.join(root, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n# ${name}\n`); + fs.symlinkSync(path.join(levels, "L0"), path.join(root, name, "next")); + } + fs.symlinkSync(levels, path.join(root, "gstack")); + const measured = await measureExactTokens(root, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + const bill = buildBill(root, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect([...measured.missedKeys]).toEqual([]); + const [qa, qb] = ["qa", "qb"].map((n) => bill.skills.find((s) => s.name === n)!); + expect(qb.totalMdBytes).toBe(qa.totalMdBytes); + expect(qb.totalMdTokens).toBe(qa.totalMdTokens); + }); + + it("names a missing tree, without a raw ENOENT or any request", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-missing-")); + const out = capture(); + const calls: { body: string }[] = []; + const code = await contextBillMain([path.join(parent, "absent"), "--exact"], { + stdout: out.stream, + stderr: out.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + fs.rmSync(parent, { recursive: true, force: true }); + expect(code).toBe(1); + expect(out.text()).toContain("No such tree:"); + expect(out.text()).not.toContain("ENOENT"); + expect(calls.length).toBe(0); + }); + + it("--diff with a missing second tree sends nothing from the first", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-diff-missing-")); + fs.mkdirSync(path.join(parent, "a", "qa"), { recursive: true }); + fs.writeFileSync(path.join(parent, "a", "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const out = capture(); + const calls: { body: string }[] = []; + const code = await contextBillMain(["--diff", path.join(parent, "a"), path.join(parent, "absent"), "--exact"], { + stdout: out.stream, + stderr: out.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + fs.rmSync(parent, { recursive: true, force: true }); + expect(code).toBe(1); + expect(out.text()).toContain("No such tree:"); + expect(calls.length).toBe(0); + }); + + it("measures a skill's frontmatter even when its file was first walked under another name", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fm-alias-")); + 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(["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 }); + }); + it("degrades to the estimate when exact mode is unavailable, naming the code", async () => { const stdout = capture(); const stderr = capture();