@bongos/core 1.19.242 → 1.19.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +25 -15
- package/docs/file-map.md +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-inventory.js +425 -0
- package/scripts/gds/smoke-batch-claim.sh +19 -3
- package/scripts/gds/smoke-batch-release.sh +12 -1
- package/src/bongos/llm-pricing.js +43 -6
- package/src/module-api.js +5 -1
- package/tests/copy_inventory.mjs +0 -0
- package/tests/llm-pricing.mjs +65 -3
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.244",
|
|
6
|
+
"core_contract": "1.19.244",
|
|
7
|
+
"source_commit": "41bd2c1c85734ca5037a53163d076727d440c940",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T23:17:48.329Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 312,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1329,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1662,
|
|
20
|
+
"tree_sha256": "d1832fb3f92654fd279af06314cb91b05664d23e20bd2b36124ba869b0c1eeb6",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1507,7 +1507,7 @@
|
|
|
1507
1507
|
{
|
|
1508
1508
|
"path": "docs/file-map.md",
|
|
1509
1509
|
"mode": "0000644",
|
|
1510
|
-
"sha256": "
|
|
1510
|
+
"sha256": "ffea58fedd395431492f6f9120c98b11403a37988722e5fa3f43bfdc05709922"
|
|
1511
1511
|
},
|
|
1512
1512
|
{
|
|
1513
1513
|
"path": "docs/handoff-template.md",
|
|
@@ -4347,12 +4347,12 @@
|
|
|
4347
4347
|
{
|
|
4348
4348
|
"path": "package-lock.json",
|
|
4349
4349
|
"mode": "0000644",
|
|
4350
|
-
"sha256": "
|
|
4350
|
+
"sha256": "cb2a6025e2f181d25b23c163ddabea64fbb2285f6e6e48e9d50742d1150ca8ae"
|
|
4351
4351
|
},
|
|
4352
4352
|
{
|
|
4353
4353
|
"path": "package.json",
|
|
4354
4354
|
"mode": "0000644",
|
|
4355
|
-
"sha256": "
|
|
4355
|
+
"sha256": "0cab22f1627a9ddb9faeba787e06dc1914e4ae0d8567ccea231265a563339ee2"
|
|
4356
4356
|
},
|
|
4357
4357
|
{
|
|
4358
4358
|
"path": "public-docs/index.html",
|
|
@@ -4869,6 +4869,11 @@
|
|
|
4869
4869
|
"mode": "0000644",
|
|
4870
4870
|
"sha256": "8ca6ee391837d7e1a45e69fc62c4a051f5e3d6060db507cdcbc7a343e2b03bd3"
|
|
4871
4871
|
},
|
|
4872
|
+
{
|
|
4873
|
+
"path": "scripts/gds/copy-inventory.js",
|
|
4874
|
+
"mode": "0000644",
|
|
4875
|
+
"sha256": "8eeeea6fb29101dbd4dc6ce874a8bc87dd1945434f5b6c13a04e7312cc449a4b"
|
|
4876
|
+
},
|
|
4872
4877
|
{
|
|
4873
4878
|
"path": "scripts/gds/coreB-carve-proof.sh",
|
|
4874
4879
|
"mode": "0000644",
|
|
@@ -5532,12 +5537,12 @@
|
|
|
5532
5537
|
{
|
|
5533
5538
|
"path": "scripts/gds/smoke-batch-claim.sh",
|
|
5534
5539
|
"mode": "0000644",
|
|
5535
|
-
"sha256": "
|
|
5540
|
+
"sha256": "d27deba8997676ed2224d2261316b3e60c0668bcde585f7efd0443d6ab150d89"
|
|
5536
5541
|
},
|
|
5537
5542
|
{
|
|
5538
5543
|
"path": "scripts/gds/smoke-batch-release.sh",
|
|
5539
5544
|
"mode": "0000644",
|
|
5540
|
-
"sha256": "
|
|
5545
|
+
"sha256": "57e6f1ce66cd4e5cb7d6bbbd409f074b137344ba7c281a122f8ad42243d76029"
|
|
5541
5546
|
},
|
|
5542
5547
|
{
|
|
5543
5548
|
"path": "scripts/gds/smoke-box.sh",
|
|
@@ -5772,7 +5777,7 @@
|
|
|
5772
5777
|
{
|
|
5773
5778
|
"path": "src/bongos/llm-pricing.js",
|
|
5774
5779
|
"mode": "0000644",
|
|
5775
|
-
"sha256": "
|
|
5780
|
+
"sha256": "a932e617ee66dabc49b4087baf6606eeb2074f70ebb3efa0723b1d9c92add290"
|
|
5776
5781
|
},
|
|
5777
5782
|
{
|
|
5778
5783
|
"path": "src/bongos/metrics.js",
|
|
@@ -5967,7 +5972,7 @@
|
|
|
5967
5972
|
{
|
|
5968
5973
|
"path": "src/module-api.js",
|
|
5969
5974
|
"mode": "0000644",
|
|
5970
|
-
"sha256": "
|
|
5975
|
+
"sha256": "a2f7371ccaf4bc1f558380c79440bfccf0e22f7856f910d881d9b251f038eb08"
|
|
5971
5976
|
},
|
|
5972
5977
|
{
|
|
5973
5978
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6484,6 +6489,11 @@
|
|
|
6484
6489
|
"mode": "0000644",
|
|
6485
6490
|
"sha256": "55bd70efd8067e39d6220d14f78c743ce80d8a8ff37988acf00136b5108dc004"
|
|
6486
6491
|
},
|
|
6492
|
+
{
|
|
6493
|
+
"path": "tests/copy_inventory.mjs",
|
|
6494
|
+
"mode": "0000644",
|
|
6495
|
+
"sha256": "3c1f0886799082734e72ceacba5212e7bc9b3f8081e834973383994ffb5eccab"
|
|
6496
|
+
},
|
|
6487
6497
|
{
|
|
6488
6498
|
"path": "tests/cosmos_token_contrast.mjs",
|
|
6489
6499
|
"mode": "0000644",
|
|
@@ -7332,7 +7342,7 @@
|
|
|
7332
7342
|
{
|
|
7333
7343
|
"path": "tests/llm-pricing.mjs",
|
|
7334
7344
|
"mode": "0000644",
|
|
7335
|
-
"sha256": "
|
|
7345
|
+
"sha256": "64f8bd94dc461ca9b4ef82fae929d49675231afa6a24d6d65ba2dbe8721efc89"
|
|
7336
7346
|
},
|
|
7337
7347
|
{
|
|
7338
7348
|
"path": "tests/local_preview.mjs",
|
package/docs/file-map.md
CHANGED
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
│ │ └── container-interior.md ← (#645) container building interior: layered industrial shell + aqua wave floor + boutique display; palette reconciled to ADR 0044
|
|
51
51
|
│ ├── file-map.md ← this file (§11 detail, split out V3.R86); the `.claude/skills/` + `.claude/scheduled-tasks/` trees are GENERATED (gen-file-map.js, ADR 0066) — do not hand-edit those blocks
|
|
52
52
|
│ ├── file-map.notes.json ← ADR 0066 ([#1276](https://example.com/builders#/task/1276)): the sidecar of one-line notes for each skill/routine that gen-file-map.js renders into file-map.md; the ONE place to edit those notes — a new skill with no note here fails CI
|
|
53
|
+
│ ├── copy-registry.json ← GENERATED (scripts/gds/copy-inventory.js): the machine-readable copy registry — 1,096 user-facing strings with stable ids, render paths, and duplicates. R02's flag queue attaches to these ids; regenerate + commit, never hand-edit
|
|
54
|
+
│ ├── copy-inventory.md ← GENERATED twin of copy-registry.json: the human-readable copy report, including the "what this cannot see" section (server-side messages, skill prose, Discord copy, runtime-composed strings, and the src/ui-vs-reality surface mismatch)
|
|
53
55
|
│ ├── handoff-template.md ← session-end template + Definition of Done
|
|
54
56
|
│ ├── google-chat-broadcast.md ← team-chat integration plan
|
|
55
57
|
│ ├── pms-v1-deploy.md ← step-by-step runbook to deploy PMS-V1 + status subdomain
|
|
@@ -175,6 +177,7 @@
|
|
|
175
177
|
│ ├── worktree.js ← (task 1002809) junction-safe worktree add/remove/list — outside-repo worktrees get a node_modules junction via node (no shell quoting), removed BEFORE `git worktree remove`
|
|
176
178
|
│ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
|
|
177
179
|
│ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
|
|
180
|
+
│ ├── copy-inventory.js ← R01 of goal 1000074 (the artist's loop): the COPY REGISTRY — every user-facing string across the hall / status / landing surfaces, each row naming the render path that shows it (html-text · html-attr · js-markup · js-notice · js-text-assign) + a stable id that survives a move. Writes docs/copy-registry.json + docs/copy-inventory.md; `--check` fails on drift. Precision over recall: a JS literal with no render path is not listed
|
|
178
181
|
│ ├── gen-diagrams.js ← generates the 5 onboarding diagrams (`.mmd`/`.svg`/`.png` + `assertions.json`) from templates in this file — never hand-edit a `.mmd`/assertions.json; runs on deploy
|
|
179
182
|
│ ├── gen-api-docs.js ← ADR 0109: generates `docs/api/openapi.json` + `docs/api-reference.md` from the live route files via `route-rank-check.js` introspection, so the API reference cannot drift from what the server actually mounts
|
|
180
183
|
│ ├── architect-audit.js ← MAS Phase 5 (ADR 0024): the Architect's bounded first-principles `--scope`/`--paths` audit CLI → `docs/audits/`
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.244",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.244",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.244",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/copy-inventory.js — the COPY REGISTRY (R01 of goal 1000074, the
|
|
5
|
+
// artist's loop; from idea 1000736).
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT IS FOR. The owner's ABI brief gives the artist the project's TEXT: no
|
|
8
|
+
// slop, story and ideology communicated, purpose and gravitas, re-text-writing.
|
|
9
|
+
// None of that is exercisable today, because the copy is scattered across 23 hall
|
|
10
|
+
// HTML files, 34 hall JS files, the status dashboard and a server-rendered landing
|
|
11
|
+
// template. This command is the inventory that makes it findable — read-only: no
|
|
12
|
+
// editing, no queue, no UI (those are R02/R03).
|
|
13
|
+
//
|
|
14
|
+
// DELIBERATELY IN THE gen-* FAMILY: pure-ish (fs only — no DB, no network, no
|
|
15
|
+
// clock, no randomness), deterministic byte-for-byte on the same tree, emitting one
|
|
16
|
+
// machine-readable registry and one human report. Same posture as gen-repo-map /
|
|
17
|
+
// gen-api-docs: a command you run and commit.
|
|
18
|
+
//
|
|
19
|
+
// THE SURFACE MISMATCH, RECORDED RATHER THAN ASSUMED AWAY. docs/design-contract.md
|
|
20
|
+
// says UI components live under `src/ui/<surface>/`, and that a surface directory is
|
|
21
|
+
// the unit a design adapter targets. On this tree `src/ui/` contains only
|
|
22
|
+
// `adapters/` — the real UI is modules/hall-ui/public and modules/status-ui/public.
|
|
23
|
+
// So this tool maps REAL paths onto contract-shaped surface ids and reports the
|
|
24
|
+
// mismatch in its own output. Do not "fix" that by pretending the contract layout
|
|
25
|
+
// exists.
|
|
26
|
+
//
|
|
27
|
+
// EVERY ROW NAMES ITS RENDER PATH (`origin`), which is what makes the inventory
|
|
28
|
+
// trustworthy: html-text, html-attr:<attr>, html-title, js-markup (text inside
|
|
29
|
+
// markup a script builds), js-notice (toast/alert/confirm), js-text-assign
|
|
30
|
+
// (.textContent/.innerText/.title). A string this tool cannot tie to one of those
|
|
31
|
+
// paths is not listed at all — the first cut filtered "literals that look like
|
|
32
|
+
// prose" and produced 2144 rows of mostly code, including `use strict` 34 times.
|
|
33
|
+
//
|
|
34
|
+
// Run: node scripts/gds/copy-inventory.js (writes both artifacts)
|
|
35
|
+
// node scripts/gds/copy-inventory.js --check (exit 1 if artifacts stale)
|
|
36
|
+
// node scripts/gds/copy-inventory.js --json (registry to stdout, no write)
|
|
37
|
+
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const path = require('node:path');
|
|
40
|
+
const crypto = require('node:crypto');
|
|
41
|
+
|
|
42
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
43
|
+
const REGISTRY_REL = path.join('docs', 'copy-registry.json');
|
|
44
|
+
const REPORT_REL = path.join('docs', 'copy-inventory.md');
|
|
45
|
+
|
|
46
|
+
// The surfaces this tool knows how to read. `id` is the contract-shaped surface
|
|
47
|
+
// name; `at` is where it REALLY lives. Adding a surface is a data edit — the point.
|
|
48
|
+
const SURFACES = [
|
|
49
|
+
{ id: 'builders-hall', kind: 'dir', at: path.join('modules', 'hall-ui', 'public'), exts: ['.html', '.js'] },
|
|
50
|
+
{ id: 'status-dashboard', kind: 'dir', at: path.join('modules', 'status-ui', 'public'), exts: ['.html', '.js'] },
|
|
51
|
+
// THE LANDING SURFACE IS public-landing/, NOT src/platform-server.js. The first
|
|
52
|
+
// cut pointed here at the server file, which only fs.readFileSync's these
|
|
53
|
+
// templates — so its JS patterns matched a handful of incidental literals and the
|
|
54
|
+
// page's ACTUAL copy (hero, meta/og text, every section) was silently absent
|
|
55
|
+
// while the registry reported a number. Caught by the grader on task 1003112.
|
|
56
|
+
{ id: 'landing', kind: 'dir', at: 'public-landing', exts: ['.html'] },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Attributes whose value is COPY a person reads (not wiring). The matchers are
|
|
60
|
+
// built ONCE — the first cut re-created all seven per markup literal.
|
|
61
|
+
const COPY_ATTRS = ['title', 'aria-label', 'placeholder', 'alt', 'data-page-title', 'aria-description'];
|
|
62
|
+
const COPY_ATTR_RES = COPY_ATTRS.map((attr) => ({ attr, re: new RegExp(`\\b${attr}\\s*=\\s*"([^"]{4,400})"`, 'gi') }));
|
|
63
|
+
|
|
64
|
+
// What this tool knowingly does not see. Printed in the report — an inventory that
|
|
65
|
+
// implies completeness it does not have is worse than a short one.
|
|
66
|
+
const BLIND_SPOTS = [
|
|
67
|
+
'Landing-page transforms: `public-landing/*.html` is scanned as authored, but the server rewrites it before a visitor sees it — `{{token}}` placeholders are filled from the branding pack, and `<!--apex-only:start--> … <!--apex-only:end-->` sections are STRIPPED on a child instance (src/platform-server.js `landingPage()`). So a row here may be copy that this instance never renders, and a rendered sentence may differ from its authored form.',
|
|
68
|
+
'Server-side messages: route failures (`res.fail` reasons), validation text and API error copy live in server JS and are not walked here. A person reads them, so widening to them is a deliberate later step, not an oversight to quietly fix.',
|
|
69
|
+
'Skill and playbook prose: `.claude/skills/**/SKILL.md` is read by agents and by builders and is arguably the largest body of project voice. Out of scope on purpose — it is not UI, and mixing it in would drown the UI signal.',
|
|
70
|
+
'Discord copy: outbound broadcast text is composed in `modules/discord/*.js` at send time. User-facing, but not part of any UI surface.',
|
|
71
|
+
'JS literals with no render path: a string is listed only when it can be tied to one — markup built in a script, a toast/alert/confirm argument, or a textContent/innerText/title assignment. Copy reaching the DOM by any other route (a helper that takes a message parameter, a string held in a config object) is invisible here, deliberately: the alternative is a queue where most rows are code.',
|
|
72
|
+
'Template literals containing a nested backtick: the literal scanner stops at the first closing backtick, so a nested template inside `${...}` truncates that one literal. Rare in this tree and cheap to spot (a row that ends mid-sentence); a real JS parser is the fix if it ever matters.',
|
|
73
|
+
'Runtime-composed strings: anything assembled from fragments or interpolated at render time is captured only as its literal parts. A sentence that exists only once the browser runs cannot be inventoried by reading the tree.',
|
|
74
|
+
'Generated pages: `docs/api-reference.md` and the OpenAPI surface are generated from route prose; that copy belongs to its generator, not to a designer.',
|
|
75
|
+
'The design-contract surface layout: `docs/design-contract.md` expects `src/ui/<surface>/`, which this tree does not use (`src/ui` holds only `adapters/`). Surface ids here are a MAPPING onto real paths, not evidence the contract layout exists.',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// A stable id survives the string MOVING: it hashes surface + normalized text,
|
|
79
|
+
// never file or line. That is what lets a flag (R02) stay attached across
|
|
80
|
+
// refactors — and two identical strings in one surface share an id, which is a
|
|
81
|
+
// feature: they are one piece of copy shown twice.
|
|
82
|
+
function stableId(surfaceId, text) {
|
|
83
|
+
const norm = text.replace(/\s+/g, ' ').trim();
|
|
84
|
+
return crypto.createHash('sha256').update(`${surfaceId} ${norm}`).digest('hex').slice(0, 12);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Locale-independent string order — see the note on the entries sort below.
|
|
88
|
+
function cmp(a, b) {
|
|
89
|
+
if (a === b) return 0;
|
|
90
|
+
return a < b ? -1 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Line lookup without rescanning. The first cut counted newlines from offset 0 for
|
|
94
|
+
// EVERY extracted string — O(n x matches) on the extractor's hottest loop (bounded
|
|
95
|
+
// at ~62 files / ~1096 matches today, but needlessly quadratic-shaped; the grader
|
|
96
|
+
// was right to flag it). One offset table per source, then a binary search.
|
|
97
|
+
function lineIndex(source) {
|
|
98
|
+
const starts = [0];
|
|
99
|
+
for (let i = 0; i < source.length; i += 1) if (source[i] === '\n') starts.push(i + 1);
|
|
100
|
+
return starts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function lineAt(starts, index) {
|
|
104
|
+
let lo = 0;
|
|
105
|
+
let hi = starts.length - 1;
|
|
106
|
+
while (lo < hi) {
|
|
107
|
+
const mid = (lo + hi + 1) >> 1;
|
|
108
|
+
if (starts[mid] <= index) lo = mid; else hi = mid - 1;
|
|
109
|
+
}
|
|
110
|
+
return lo + 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Does this look like prose a person reads, rather than code that happens to be a
|
|
114
|
+
// string? Conservative on purpose — a false positive costs an artist's attention.
|
|
115
|
+
function looksLikeCopy(raw) {
|
|
116
|
+
const s = String(raw).trim();
|
|
117
|
+
if (s.length < 4 || s.length > 400) return false;
|
|
118
|
+
if (!/[A-Za-z]{2}/.test(s)) return false; // needs actual letters
|
|
119
|
+
if (!/\s/.test(s) && !/[.!?]$/.test(s)) return false; // one bare word is a token
|
|
120
|
+
if (/^[/.#]/.test(s)) return false; // paths, selectors
|
|
121
|
+
if (/^https?:/i.test(s)) return false; // urls
|
|
122
|
+
if (/^[a-z0-9-]+$/.test(s)) return false; // css class / slug
|
|
123
|
+
if (/^[A-Z0-9_]+$/.test(s)) return false; // CONSTANT_NAME
|
|
124
|
+
if (/=>|\$\{|function\s*\(/.test(s)) return false; // code fragments
|
|
125
|
+
if (/^[\s\d.,:%$-]+$/.test(s)) return false; // numbers and units
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Strip <script>/<style> bodies so their contents are not read as page text.
|
|
130
|
+
// Replaced with spaces of equal length so every line number stays truthful.
|
|
131
|
+
function stripEmbedded(html) {
|
|
132
|
+
return html
|
|
133
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, (m) => m.replace(/[^\n]/g, ' '))
|
|
134
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, (m) => m.replace(/[^\n]/g, ' '));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fromHtml({ source, rel }) {
|
|
138
|
+
const out = [];
|
|
139
|
+
const cleaned = stripEmbedded(source);
|
|
140
|
+
// stripEmbedded preserves every newline, so one offset table serves both strings.
|
|
141
|
+
const starts = lineIndex(cleaned);
|
|
142
|
+
let m;
|
|
143
|
+
|
|
144
|
+
// 1. text nodes: everything between a '>' and the next '<'.
|
|
145
|
+
const textRe = />([^<>]+)</g;
|
|
146
|
+
while ((m = textRe.exec(cleaned)) !== null) {
|
|
147
|
+
if (!looksLikeCopy(m[1])) continue;
|
|
148
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. copy-bearing attributes.
|
|
152
|
+
for (const { attr, re } of COPY_ATTR_RES) {
|
|
153
|
+
re.lastIndex = 0;
|
|
154
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
155
|
+
if (!looksLikeCopy(m[1])) continue;
|
|
156
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. <title> is the page's own name — always copy.
|
|
161
|
+
const titleRe = /<title>([^<]{2,200})<\/title>/gi;
|
|
162
|
+
while ((m = titleRe.exec(source)) !== null) {
|
|
163
|
+
out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain' });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// JS copy is found by FOLLOWING RENDER PATHS, not by guessing at prose. The first
|
|
169
|
+
// cut of this file filtered "literals that look like sentences" and produced 2144
|
|
170
|
+
// rows of which most were code: `use strict` 34 times, CSS values, and fragments of
|
|
171
|
+
// template literals sliced at their quotes. Precision beats recall for a queue an
|
|
172
|
+
// artist actually reads, so this extractor only claims a string when it can name
|
|
173
|
+
// HOW a person sees it:
|
|
174
|
+
//
|
|
175
|
+
// • markup built in JS — any literal containing tags is parsed as HTML, so the
|
|
176
|
+
// TEXT inside it is captured (not the tags). Template interpolations become a
|
|
177
|
+
// `{…}` placeholder, which keeps a sentence readable while marking the hole.
|
|
178
|
+
// • toast() / alert() / confirm() arguments — shown verbatim to a person.
|
|
179
|
+
// • .textContent / .innerText / .title assignments — same.
|
|
180
|
+
//
|
|
181
|
+
// Everything else is skipped, and the report says so. A literal this tool cannot
|
|
182
|
+
// tie to a render path is not evidence of copy.
|
|
183
|
+
const PLACEHOLDER = '{…}';
|
|
184
|
+
|
|
185
|
+
// Template literals + quoted strings. Template bodies may contain ${...}; the
|
|
186
|
+
// no-nested-backtick limitation is real and recorded in BLIND_SPOTS.
|
|
187
|
+
const JS_LITERALS = /`([^`]*)`|'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"/g;
|
|
188
|
+
const UI_CALL = /\b(?:toast|alert|confirm)\s*\(\s*(?:`([^`]*)`|'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)")/g;
|
|
189
|
+
const TEXT_ASSIGN = /\.(?:textContent|innerText|title)\s*=\s*(?:`([^`]*)`|'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)")/g;
|
|
190
|
+
|
|
191
|
+
function unescapeLiteral(raw) {
|
|
192
|
+
return String(raw).replace(/\\(['"`\\])/g, '$1');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A literal that carries markup: extract what a person reads out of it.
|
|
196
|
+
function copyFromMarkupLiteral(body) {
|
|
197
|
+
const withHoles = body.replace(/\$\{[^{}]*\}/g, PLACEHOLDER);
|
|
198
|
+
const out = [];
|
|
199
|
+
let m;
|
|
200
|
+
const textRe = />([^<>]+)</g;
|
|
201
|
+
while ((m = textRe.exec(withHoles)) !== null) {
|
|
202
|
+
const t = m[1].replace(/\s+/g, ' ').trim();
|
|
203
|
+
if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
|
|
204
|
+
out.push(t);
|
|
205
|
+
}
|
|
206
|
+
for (const { re } of COPY_ATTR_RES) {
|
|
207
|
+
re.lastIndex = 0;
|
|
208
|
+
while ((m = re.exec(withHoles)) !== null) {
|
|
209
|
+
const t = m[1].replace(/\s+/g, ' ').trim();
|
|
210
|
+
if (!looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
|
|
211
|
+
out.push(t);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fromJs({ source, rel }) {
|
|
218
|
+
const out = [];
|
|
219
|
+
const starts = lineIndex(source);
|
|
220
|
+
const seen = new Set(); // (text, line) — one row per string per site
|
|
221
|
+
const push = (text, index, origin) => {
|
|
222
|
+
const line = lineAt(starts, index);
|
|
223
|
+
const key = `${text}\u0000${line}\u0000${origin}`;
|
|
224
|
+
if (seen.has(key)) return;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
out.push({ text, file: rel, line, origin, confidence: 'certain' });
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let m;
|
|
230
|
+
// 1. markup built in JS.
|
|
231
|
+
JS_LITERALS.lastIndex = 0;
|
|
232
|
+
while ((m = JS_LITERALS.exec(source)) !== null) {
|
|
233
|
+
const body = m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '');
|
|
234
|
+
if (!body || !/<[a-zA-Z/]/.test(body) || !/>/.test(body)) continue;
|
|
235
|
+
for (const t of copyFromMarkupLiteral(body)) push(t, m.index, 'js-markup');
|
|
236
|
+
}
|
|
237
|
+
// 2. strings shown directly to a person.
|
|
238
|
+
UI_CALL.lastIndex = 0;
|
|
239
|
+
while ((m = UI_CALL.exec(source)) !== null) {
|
|
240
|
+
const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
|
|
241
|
+
if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-notice');
|
|
242
|
+
}
|
|
243
|
+
TEXT_ASSIGN.lastIndex = 0;
|
|
244
|
+
while ((m = TEXT_ASSIGN.exec(source)) !== null) {
|
|
245
|
+
const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
|
|
246
|
+
if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-text-assign');
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function filesFor(surface, { fsImpl = fs } = {}) {
|
|
252
|
+
const abs = path.join(REPO_ROOT, surface.at);
|
|
253
|
+
if (surface.kind === 'file') return fsImpl.existsSync(abs) ? [surface.at] : [];
|
|
254
|
+
if (!fsImpl.existsSync(abs)) return [];
|
|
255
|
+
return fsImpl.readdirSync(abs)
|
|
256
|
+
.filter((n) => surface.exts.includes(path.extname(n)))
|
|
257
|
+
.sort()
|
|
258
|
+
.map((n) => path.join(surface.at, n));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildRegistry() {
|
|
262
|
+
const entries = [];
|
|
263
|
+
const surfaces = {};
|
|
264
|
+
for (const surface of SURFACES) {
|
|
265
|
+
const rels = filesFor(surface);
|
|
266
|
+
let count = 0;
|
|
267
|
+
for (const rel of rels) {
|
|
268
|
+
const source = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
|
269
|
+
const found = path.extname(rel) === '.html'
|
|
270
|
+
? fromHtml({ source, rel })
|
|
271
|
+
: fromJs({ source, rel });
|
|
272
|
+
for (const f of found) {
|
|
273
|
+
entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...f });
|
|
274
|
+
count += 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
surfaces[surface.id] = { at: surface.at, files: rels.length, strings: count };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Deterministic order, independent of walk order: surface, text, file, line.
|
|
281
|
+
// BYTE ORDER, NOT localeCompare: these artifacts are committed, and
|
|
282
|
+
// localeCompare's collation depends on the runtime's ICU data — a Node built with
|
|
283
|
+
// small-icu would reshuffle the file and every unrelated commit would carry the
|
|
284
|
+
// churn. `cmp` is boring on purpose.
|
|
285
|
+
entries.sort((a, b) => cmp(a.surface, b.surface)
|
|
286
|
+
|| cmp(a.text, b.text)
|
|
287
|
+
|| cmp(a.file, b.file)
|
|
288
|
+
|| a.line - b.line);
|
|
289
|
+
|
|
290
|
+
// Duplicate copy is the cheapest of the three signals R02 wants, and needs no
|
|
291
|
+
// judgement: the same normalized text in one surface, shown in more than one place.
|
|
292
|
+
const byId = new Map();
|
|
293
|
+
for (const e of entries) {
|
|
294
|
+
if (!byId.has(e.id)) byId.set(e.id, []);
|
|
295
|
+
byId.get(e.id).push(e);
|
|
296
|
+
}
|
|
297
|
+
const duplicates = [...byId.entries()]
|
|
298
|
+
.filter(([, list]) => list.length > 1)
|
|
299
|
+
.map(([id, list]) => ({
|
|
300
|
+
id, surface: list[0].surface, text: list[0].text,
|
|
301
|
+
occurrences: list.length, at: list.map((e) => `${e.file}:${e.line}`),
|
|
302
|
+
}))
|
|
303
|
+
.sort((a, b) => b.occurrences - a.occurrences || cmp(a.text, b.text));
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
schema: 1,
|
|
307
|
+
generator: 'scripts/gds/copy-inventory.js',
|
|
308
|
+
goal: 1000074,
|
|
309
|
+
counts: {
|
|
310
|
+
strings: entries.length,
|
|
311
|
+
unique_ids: byId.size,
|
|
312
|
+
certain: entries.filter((e) => e.confidence === 'certain').length,
|
|
313
|
+
heuristic: entries.filter((e) => e.confidence === 'heuristic').length,
|
|
314
|
+
duplicate_ids: duplicates.length,
|
|
315
|
+
},
|
|
316
|
+
surfaces,
|
|
317
|
+
blind_spots: BLIND_SPOTS,
|
|
318
|
+
duplicates,
|
|
319
|
+
entries,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderReport(reg) {
|
|
324
|
+
const L = [];
|
|
325
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
326
|
+
L.push('# Copy inventory — every string the product shows a person');
|
|
327
|
+
L.push('');
|
|
328
|
+
L.push('> **Generated** by `node scripts/gds/copy-inventory.js` (R01 of goal 1000074, the artist\'s loop).');
|
|
329
|
+
L.push('> Deterministic on a given tree — regenerate and commit rather than hand-editing.');
|
|
330
|
+
L.push('> Machine-readable twin: [`copy-registry.json`](copy-registry.json).');
|
|
331
|
+
L.push('');
|
|
332
|
+
L.push(`**${reg.counts.strings} strings** across ${Object.keys(reg.surfaces).length} surfaces · `
|
|
333
|
+
+ `${reg.counts.unique_ids} distinct pieces of copy · ${reg.counts.certain} certain, ${reg.counts.heuristic} heuristic · `
|
|
334
|
+
+ `${reg.counts.duplicate_ids} shown in more than one place.`);
|
|
335
|
+
L.push('');
|
|
336
|
+
L.push('## Surfaces');
|
|
337
|
+
L.push('');
|
|
338
|
+
L.push('| surface | lives at | files | strings |');
|
|
339
|
+
L.push('|---|---|---|---|');
|
|
340
|
+
for (const [id, s] of Object.entries(reg.surfaces)) {
|
|
341
|
+
L.push(`| \`${id}\` | \`${s.at}\` | ${s.files} | ${s.strings} |`);
|
|
342
|
+
}
|
|
343
|
+
L.push('');
|
|
344
|
+
L.push('## Confidence, and why it is on every row');
|
|
345
|
+
L.push('');
|
|
346
|
+
L.push('`certain` copy comes from markup only a person reads: an HTML text node, a `<title>`, or a copy-bearing attribute. `heuristic` copy is a JavaScript string literal that passed a prose filter — this tool does not parse JS, so a filtered literal is evidence, not proof. Treat a heuristic row as a candidate an artist confirms, and do not quote the total as "the number of strings in the product".');
|
|
347
|
+
L.push('');
|
|
348
|
+
L.push('## What this cannot see');
|
|
349
|
+
L.push('');
|
|
350
|
+
for (const b of reg.blind_spots) L.push(`- ${b}`);
|
|
351
|
+
L.push('');
|
|
352
|
+
if (reg.duplicates.length) {
|
|
353
|
+
L.push('## The same words, shown twice');
|
|
354
|
+
L.push('');
|
|
355
|
+
L.push('Identical copy within one surface. Sometimes right (a repeated button), sometimes two screens that should share one string.');
|
|
356
|
+
L.push('');
|
|
357
|
+
L.push('| id | shown | text | where |');
|
|
358
|
+
L.push('|---|---|---|---|');
|
|
359
|
+
for (const d of reg.duplicates.slice(0, 40)) {
|
|
360
|
+
L.push(`| \`${d.id}\` | ${d.occurrences}x | ${esc(d.text).slice(0, 90)} | ${d.at.slice(0, 3).join(', ')} |`);
|
|
361
|
+
}
|
|
362
|
+
if (reg.duplicates.length > 40) L.push(`| … | | _${reg.duplicates.length - 40} more in the JSON registry_ | |`);
|
|
363
|
+
L.push('');
|
|
364
|
+
}
|
|
365
|
+
for (const [surfaceId] of Object.entries(reg.surfaces)) {
|
|
366
|
+
const rows = reg.entries.filter((e) => e.surface === surfaceId);
|
|
367
|
+
if (!rows.length) continue;
|
|
368
|
+
L.push(`## \`${surfaceId}\` — ${rows.length} strings`);
|
|
369
|
+
L.push('');
|
|
370
|
+
L.push('| id | conf | text | where |');
|
|
371
|
+
L.push('|---|---|---|---|');
|
|
372
|
+
for (const e of rows) {
|
|
373
|
+
L.push(`| \`${e.id}\` | ${e.confidence === 'certain' ? 'certain' : 'heur'} | ${esc(e.text).slice(0, 110)} | \`${e.file}:${e.line}\` |`);
|
|
374
|
+
}
|
|
375
|
+
L.push('');
|
|
376
|
+
}
|
|
377
|
+
L.push('---');
|
|
378
|
+
L.push('');
|
|
379
|
+
L.push('Ids are stable across moves: a string keeps its id when it changes file or line, and loses it when its TEXT changes — which is exactly when a flag against it should be re-examined.');
|
|
380
|
+
L.push('');
|
|
381
|
+
return L.join('\n');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function write({ check = false } = {}) {
|
|
385
|
+
const reg = buildRegistry();
|
|
386
|
+
const json = `${JSON.stringify(reg, null, 2)}\n`;
|
|
387
|
+
const md = renderReport(reg);
|
|
388
|
+
const jsonPath = path.join(REPO_ROOT, REGISTRY_REL);
|
|
389
|
+
const mdPath = path.join(REPO_ROOT, REPORT_REL);
|
|
390
|
+
if (check) {
|
|
391
|
+
const stale = [];
|
|
392
|
+
for (const [p, want] of [[jsonPath, json], [mdPath, md]]) {
|
|
393
|
+
const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
394
|
+
if (have !== want) stale.push(path.relative(REPO_ROOT, p));
|
|
395
|
+
}
|
|
396
|
+
return { reg, stale };
|
|
397
|
+
}
|
|
398
|
+
fs.writeFileSync(jsonPath, json);
|
|
399
|
+
fs.writeFileSync(mdPath, md);
|
|
400
|
+
return { reg, written: [REGISTRY_REL, REPORT_REL] };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (require.main === module) {
|
|
404
|
+
const args = process.argv.slice(2);
|
|
405
|
+
if (args.includes('--json')) {
|
|
406
|
+
process.stdout.write(`${JSON.stringify(buildRegistry(), null, 2)}\n`);
|
|
407
|
+
} else if (args.includes('--check')) {
|
|
408
|
+
const { stale } = write({ check: true });
|
|
409
|
+
if (stale.length) {
|
|
410
|
+
console.error(`copy-inventory: STALE — ${stale.join(', ')} do not match the tree. Run: node scripts/gds/copy-inventory.js`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
console.log('copy-inventory: artifacts match the tree.');
|
|
414
|
+
} else {
|
|
415
|
+
const { reg, written } = write();
|
|
416
|
+
console.log(`copy-inventory: ${reg.counts.strings} strings (${reg.counts.certain} certain, ${reg.counts.heuristic} heuristic), `
|
|
417
|
+
+ `${reg.counts.duplicate_ids} duplicated -> wrote ${written.join(', ')}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
module.exports = {
|
|
422
|
+
SURFACES, COPY_ATTRS, BLIND_SPOTS, REGISTRY_REL, REPORT_REL,
|
|
423
|
+
cmp, stableId, looksLikeCopy, lineIndex, lineAt, stripEmbedded, fromHtml, fromJs, filesFor,
|
|
424
|
+
buildRegistry, renderReport, write,
|
|
425
|
+
};
|
|
@@ -70,6 +70,7 @@ print(json.dumps({
|
|
|
70
70
|
CREATED_TASKS=()
|
|
71
71
|
CREATED_CLAIMS=()
|
|
72
72
|
cleanup() {
|
|
73
|
+
local leaked=()
|
|
73
74
|
echo
|
|
74
75
|
echo "--- cleanup ---"
|
|
75
76
|
for cid in "${CREATED_CLAIMS[@]:-}"; do
|
|
@@ -79,10 +80,25 @@ cleanup() {
|
|
|
79
80
|
done
|
|
80
81
|
for tid in "${CREATED_TASKS[@]:-}"; do
|
|
81
82
|
[[ -z "${tid}" ]] && continue
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
83
|
+
# task 1002856: this used to POST /tasks/:id/promote {"status":"abandoned"},
|
|
84
|
+
# which is the backlog->ready transition and ignores a status field — so it
|
|
85
|
+
# never abandoned anything, and `>/dev/null 2>&1 || true` swallowed the
|
|
86
|
+
# failure. Every run since has leaked its fixtures as status='ready'
|
|
87
|
+
# (the 2026-05-11 batch, then 1001920-1001925 from 2026-07-03). The real
|
|
88
|
+
# endpoint is POST /tasks/:id/abandon {"reason": ...}.
|
|
89
|
+
if ! call POST "/api/gds/tasks/${tid}/abandon" \
|
|
90
|
+
'{"reason":"smoke cleanup (scripts/gds/smoke-batch-claim.sh)"}' >/dev/null 2>&1; then
|
|
91
|
+
leaked+=("${tid}")
|
|
92
|
+
fi
|
|
85
93
|
done
|
|
94
|
+
# Never silently leave debris: a failed cleanup is reported, not swallowed.
|
|
95
|
+
# The claimable feed filters `__smoke__%` by title, so a leak is a ledger
|
|
96
|
+
# accuracy problem (it inflates open-task counts and goal rollups), not a
|
|
97
|
+
# builder-facing one — but it still needs a human to clear it.
|
|
98
|
+
if [[ ${#leaked[@]} -gt 0 ]]; then
|
|
99
|
+
echo "WARN: could not abandon ${#leaked[@]} smoke task(s): ${leaked[*]}"
|
|
100
|
+
echo "WARN: clear them with: node scripts/gds/api.js POST /tasks/<id>/abandon --body '{\"reason\":\"smoke residue\"}'"
|
|
101
|
+
fi
|
|
86
102
|
echo "cleanup done"
|
|
87
103
|
}
|
|
88
104
|
trap cleanup EXIT
|
|
@@ -91,6 +91,7 @@ claim_task() {
|
|
|
91
91
|
CREATED_TASKS=()
|
|
92
92
|
CREATED_CLAIMS=()
|
|
93
93
|
cleanup() {
|
|
94
|
+
local leaked=()
|
|
94
95
|
echo
|
|
95
96
|
echo "--- cleanup ---"
|
|
96
97
|
for cid in "${CREATED_CLAIMS[@]:-}"; do
|
|
@@ -100,8 +101,18 @@ cleanup() {
|
|
|
100
101
|
done
|
|
101
102
|
for tid in "${CREATED_TASKS[@]:-}"; do
|
|
102
103
|
[[ -z "${tid}" ]] && continue
|
|
103
|
-
|
|
104
|
+
# task 1002856: same bug as smoke-batch-claim.sh — /tasks/:id/promote is the
|
|
105
|
+
# backlog->ready transition and ignores a status field, so this never
|
|
106
|
+
# abandoned anything and the redirect swallowed the failure.
|
|
107
|
+
if ! call POST "/api/gds/tasks/${tid}/abandon" \
|
|
108
|
+
'{"reason":"smoke cleanup (scripts/gds/smoke-batch-release.sh)"}' >/dev/null 2>&1; then
|
|
109
|
+
leaked+=("${tid}")
|
|
110
|
+
fi
|
|
104
111
|
done
|
|
112
|
+
if [[ ${#leaked[@]} -gt 0 ]]; then
|
|
113
|
+
echo "WARN: could not abandon ${#leaked[@]} smoke task(s): ${leaked[*]}"
|
|
114
|
+
echo "WARN: clear them with: node scripts/gds/api.js POST /tasks/<id>/abandon --body '{\"reason\":\"smoke residue\"}'"
|
|
115
|
+
fi
|
|
105
116
|
echo "cleanup done"
|
|
106
117
|
}
|
|
107
118
|
trap cleanup EXIT
|
|
@@ -49,15 +49,52 @@
|
|
|
49
49
|
// - opus = Claude Opus 5 / 4.8 / 4.7 ($5 in / $25 out) [re-verified]
|
|
50
50
|
// - sonnet = Claude Sonnet 4.6 ($3 in / $15 out) [re-verified]
|
|
51
51
|
// - haiku = Claude Haiku 4.5 ($1 in / $5 out) [re-verified]
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
52
|
+
// VERIFIED 2026-08-18 against the live models overview at platform.claude.com
|
|
53
|
+
// (task 1003001 — the task the note this replaces pointed at):
|
|
54
|
+
// - sonnet = Claude Sonnet 5 ($2 in / $10 out) [WAS $3/$15 = Sonnet 4.6's rate]
|
|
55
|
+
// - fable = Claude Fable 5 / Mythos 5 ($10/$50) [re-verified, unchanged]
|
|
56
|
+
// - opus = Claude Opus 5 / 4.8 / 4.7 ($5/$25) [re-verified, unchanged]
|
|
57
|
+
// - haiku = Claude Haiku 4.5 ($1/$5) [re-verified, unchanged]
|
|
58
|
+
// The live page states Sonnet 5 as $2/$10 flatly — NO introductory qualifier and
|
|
59
|
+
// no scheduled increase. (An earlier launch note describing $2/$10 as
|
|
60
|
+
// introductory through 2026-08-31 is superseded; a cached model table that still
|
|
61
|
+
// carries the qualifier is stale, which is precisely why this file re-verifies
|
|
62
|
+
// against the live page rather than a recalled figure.)
|
|
63
|
+
//
|
|
64
|
+
// WHY IT MATTERED, and why it was worth its own task: the grader's default worker
|
|
65
|
+
// model is claude-sonnet-5 (modules/grading/grader-rubric.json), so the panel is
|
|
66
|
+
// this platform's heaviest sonnet consumer, and ADR 0054 pays each shipped session
|
|
67
|
+
// round(true_cost_usd × 1.20). An over-priced sonnet row therefore OVER-PAID real
|
|
68
|
+
// credits — the mirror image of the fable bug recorded above, which under-paid.
|
|
69
|
+
//
|
|
70
|
+
// HISTORICAL ROWS ARE NOT RE-PRICED. cost_log rows written before today keep the
|
|
71
|
+
// dollars we believed at the time: the ledger is a record of what was charged and
|
|
72
|
+
// paid, and rewriting paid rows to a new rate would make the credit history
|
|
73
|
+
// disagree with the credit_log it produced. Expect a visible step change in
|
|
74
|
+
// cost-per-ship around 2026-08-18 for sonnet-heavy work; this note is its
|
|
75
|
+
// explanation.
|
|
76
|
+
//
|
|
77
|
+
// THE FAMILY↔MODEL BINDING IS NOW DATA (FAMILY_CURRENT_MODEL below) and pinned by
|
|
78
|
+
// tests/llm-pricing.mjs: a family whose current model is re-pinned cannot keep the
|
|
79
|
+
// old model's rate silently, which is exactly the failure this task fixed.
|
|
80
|
+
// The model each family's rate is quoted FROM. Data, not logic: nothing branches
|
|
81
|
+
// on it, but it makes the claim "this row is <model>'s price" checkable — the test
|
|
82
|
+
// asserts each family's rate against that model's published figures, so re-pinning
|
|
83
|
+
// a family to a newer model without re-pricing it reds the build (task 1003001).
|
|
84
|
+
// Legacy members of a family are knowingly mis-priced (the family-pricing trade-off
|
|
85
|
+
// documented above): claude-sonnet-4-6 is really $3/$15 and prices here as Sonnet 5.
|
|
86
|
+
const FAMILY_CURRENT_MODEL = Object.freeze({
|
|
87
|
+
fable: 'claude-fable-5',
|
|
88
|
+
opus: 'claude-opus-5',
|
|
89
|
+
sonnet: 'claude-sonnet-5',
|
|
90
|
+
haiku: 'claude-haiku-4-5',
|
|
91
|
+
});
|
|
92
|
+
|
|
56
93
|
const PRICES = {
|
|
57
94
|
// family : { input, output, cache_write_5m, cache_write_1h, cache_read } (USD / 1e6 tokens)
|
|
58
95
|
fable: { input: 10, output: 50, cache_write_5m: 12.5, cache_write_1h: 20, cache_read: 1 },
|
|
59
96
|
opus: { input: 5, output: 25, cache_write_5m: 6.25, cache_write_1h: 10, cache_read: 0.5 },
|
|
60
|
-
sonnet: { input:
|
|
97
|
+
sonnet: { input: 2, output: 10, cache_write_5m: 2.5, cache_write_1h: 4, cache_read: 0.2 },
|
|
61
98
|
haiku: { input: 1, output: 5, cache_write_5m: 1.25, cache_write_1h: 2, cache_read: 0.1 },
|
|
62
99
|
};
|
|
63
100
|
|
|
@@ -246,4 +283,4 @@ function componentsFromModelUsage(modelUsage) {
|
|
|
246
283
|
};
|
|
247
284
|
}
|
|
248
285
|
|
|
249
|
-
module.exports = { PRICES, familyFor, priceUsage, canonicalizeModelId, priceModelUsage, componentsFromModelUsage };
|
|
286
|
+
module.exports = { PRICES, FAMILY_CURRENT_MODEL, familyFor, priceUsage, canonicalizeModelId, priceModelUsage, componentsFromModelUsage };
|
package/src/module-api.js
CHANGED
|
@@ -911,8 +911,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
911
911
|
// landed since 1.19.240 with no explicit bump. run 32184475745. (task 1002620)
|
|
912
912
|
// 1.19.242 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
913
913
|
// landed since 1.19.241 with no explicit bump. run 32185198139. (task 1002620)
|
|
914
|
+
// 1.19.243 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
915
|
+
// landed since 1.19.242 with no explicit bump. run 32193666731. (task 1002620)
|
|
916
|
+
// 1.19.244 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
917
|
+
// landed since 1.19.243 with no explicit bump. run 32196511343. (task 1002620)
|
|
914
918
|
// ---------------------------------------------------------------------------
|
|
915
|
-
const CORE_VERSION = '1.19.
|
|
919
|
+
const CORE_VERSION = '1.19.244'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
916
920
|
|
|
917
921
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
918
922
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
Binary file
|
package/tests/llm-pricing.mjs
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
// Re-VERIFIED + extended 2026-08-16 (task 1002883): fable (Fable 5 / Mythos 5)
|
|
6
6
|
// $10/$50 — the family familyFor() used to miss, so Fable sessions priced at the
|
|
7
7
|
// opus fallback and cost-plus under-paid by half.
|
|
8
|
+
// Re-VERIFIED 2026-08-18 (task 1003001): sonnet is CLAUDE SONNET 5 at $2/$10 —
|
|
9
|
+
// the row had carried Sonnet 4.6's $3/$15, over-pricing the grader panel (whose
|
|
10
|
+
// default worker IS claude-sonnet-5) and therefore over-paying cost-plus credits.
|
|
11
|
+
// The family↔model binding is now data (FAMILY_CURRENT_MODEL) and is pinned below,
|
|
12
|
+
// so a future re-pin cannot keep an old model's rate silently.
|
|
8
13
|
// Run: node tests/llm-pricing.mjs
|
|
9
14
|
|
|
10
15
|
import { strict as assert } from 'node:assert';
|
|
11
16
|
import { createRequire } from 'node:module';
|
|
12
17
|
const require = createRequire(import.meta.url);
|
|
13
|
-
const { priceUsage, familyFor, PRICES, canonicalizeModelId, priceModelUsage } = require('../src/bongos/llm-pricing.js');
|
|
18
|
+
const { priceUsage, familyFor, PRICES, FAMILY_CURRENT_MODEL, canonicalizeModelId, priceModelUsage } = require('../src/bongos/llm-pricing.js');
|
|
14
19
|
|
|
15
20
|
let passed = 0, failed = 0;
|
|
16
21
|
function test(name, fn) {
|
|
@@ -101,11 +106,68 @@ test('opus 4.8: 1M input + 1M output = $5 + $25 = $30', () => {
|
|
|
101
106
|
assert.equal(r.family, 'opus');
|
|
102
107
|
});
|
|
103
108
|
|
|
104
|
-
test('sonnet input ($
|
|
105
|
-
assert.ok(near(priceUsage('claude-sonnet-
|
|
109
|
+
test('sonnet input ($2 — Sonnet 5) vs haiku output ($5) use their own rates', () => {
|
|
110
|
+
assert.ok(near(priceUsage('claude-sonnet-5', { input_tokens: M }).usd, 2));
|
|
106
111
|
assert.ok(near(priceUsage('claude-haiku-4-5', { output_tokens: M }).usd, 5));
|
|
107
112
|
});
|
|
108
113
|
|
|
114
|
+
test('the grader panel prices at Sonnet 5, not Sonnet 4.6 — 1M in + 1M out = $12', () => {
|
|
115
|
+
// The regression this task fixed, stated as money: the panel's default worker is
|
|
116
|
+
// claude-sonnet-5 (modules/grading/grader-rubric.json), and the row used to say
|
|
117
|
+
// $3/$15 = $18 for the same tokens. ADR 0054 pays round(true_cost × 1.20), so the
|
|
118
|
+
// extra $6 per million became real over-paid credits.
|
|
119
|
+
const r = priceUsage('claude-sonnet-5', { input_tokens: M, output_tokens: M });
|
|
120
|
+
assert.ok(near(r.usd, 12), `got ${r.usd}`);
|
|
121
|
+
assert.equal(r.family, 'sonnet');
|
|
122
|
+
assert.equal(r.unknown_model, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a LEGACY family member is knowingly mis-priced — sonnet 4.6 rides Sonnet 5 rates', () => {
|
|
126
|
+
// Family pricing assumes the CURRENT model in each family (the file says so).
|
|
127
|
+
// claude-sonnet-4-6 really costs $3/$15; here it prices at $2/$10. This test
|
|
128
|
+
// exists so that trade-off is a recorded decision rather than a surprise — if the
|
|
129
|
+
// platform ever runs a legacy model in earnest, this is the test that should fail
|
|
130
|
+
// first and force a per-model override.
|
|
131
|
+
assert.ok(near(priceUsage('claude-sonnet-4-6', { input_tokens: M }).usd, 2));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── the family↔model binding (task 1003001) ─────────────────────────────────
|
|
135
|
+
// Each family's rate is quoted from ONE named model. These literals are the
|
|
136
|
+
// published per-model figures, kept separate from PRICES on purpose: if someone
|
|
137
|
+
// re-pins a family to a newer model, the two copies disagree and this test names
|
|
138
|
+
// the model in its failure — the drift that shipped Sonnet 4.6's price under a
|
|
139
|
+
// Sonnet 5 workload cannot recur silently.
|
|
140
|
+
const PUBLISHED = {
|
|
141
|
+
'claude-fable-5': { input: 10, output: 50 },
|
|
142
|
+
'claude-opus-5': { input: 5, output: 25 },
|
|
143
|
+
'claude-sonnet-5': { input: 2, output: 10 },
|
|
144
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
test('every family rate matches the published price of the model it names', () => {
|
|
148
|
+
for (const [family, modelId] of Object.entries(FAMILY_CURRENT_MODEL)) {
|
|
149
|
+
const published = PUBLISHED[modelId];
|
|
150
|
+
assert.ok(published, `FAMILY_CURRENT_MODEL.${family} names ${modelId}, which this test has no published price for — add it (verified against the live models page), do not guess`);
|
|
151
|
+
assert.equal(PRICES[family].input, published.input, `${family} input should be ${modelId}'s $${published.input}`);
|
|
152
|
+
assert.equal(PRICES[family].output, published.output, `${family} output should be ${modelId}'s $${published.output}`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('every family names a model familyFor() actually resolves back to it', () => {
|
|
157
|
+
for (const [family, modelId] of Object.entries(FAMILY_CURRENT_MODEL)) {
|
|
158
|
+
assert.equal(familyFor(modelId), family,
|
|
159
|
+
`FAMILY_CURRENT_MODEL.${family} names ${modelId}, but familyFor('${modelId}') says ${familyFor(modelId)} — the binding and the matcher disagree`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('cache rates ride the published multipliers off each family base (1.25× / 2× / 0.1×)', () => {
|
|
164
|
+
for (const [family, p] of Object.entries(PRICES)) {
|
|
165
|
+
assert.ok(near(p.cache_write_5m, p.input * 1.25), `${family} 5m write should be 1.25× input`);
|
|
166
|
+
assert.ok(near(p.cache_write_1h, p.input * 2), `${family} 1h write should be 2× input`);
|
|
167
|
+
assert.ok(near(p.cache_read, p.input * 0.1), `${family} cache read should be 0.1× input`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
109
171
|
test('cache read is 0.1× input (opus $0.50 / 1M)', () => {
|
|
110
172
|
const r = priceUsage('claude-opus-4-8', { cache_read_input_tokens: M });
|
|
111
173
|
assert.ok(near(r.usd, 0.5), `got ${r.usd}`);
|