From b78b55eb1231ce01b71b4c846bc0253ab6900db6 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:13:39 +0200 Subject: [PATCH 1/4] fix(guards): session learner runs its model call without GNU timeout session-learner.sh piped the transcript into `timeout 90 claude -p`. Stock macOS has no `timeout`, so the command was not found (logged only to .learn.log), the model never ran, and the opt-in learner recorded nothing on a Mac. A shared forge_timeout in _guardlib.sh runs `timeout`, else Homebrew's `gtimeout`, else a bash watchdog. The watchdog keeps stdin (`<&0`: a background job without job control otherwise reads /dev/null) and sends its own output to /dev/null so a caller's $(...) never waits on its sleep. The learner's forge_lock reclaim relies on the 90 s cap, which the watchdog keeps. learn-consolidate.sh --llm now uses the same helper instead of its local one. Tests build a PATH with no timeout/gtimeout from symlinks, which reproduces stock macOS on the Linux runners too: forge_timeout keeps stdin and the exit status and kills an overrun; the real session-learner hook calls a stub claude and appends its lesson. Checked by hand in Git Bash: the unfixed hook logs "timeout: command not found" and never calls the stub. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++ bin/learn-consolidate.sh | 15 ++--- global/guards/_guardlib.sh | 27 +++++++++ global/guards/session-learner.sh | 2 +- test/guards.test.js | 97 +++++++++++++++++++++++++++++++- 5 files changed, 140 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfec0eb..703c9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **The session learner works on macOS.** `session-learner.sh` wrapped its model call in GNU + `timeout`, which stock macOS does not ship. The missing command failed silently (only + `.learn.log` saw it), so the opt-in learner never recorded a lesson on a Mac. It now calls a + shared `forge_timeout` in `_guardlib.sh`: `timeout`, else Homebrew's `gtimeout`, else a + bash watchdog that keeps the 90 s cap the learner's re-entrancy lock relies on. + `learn-consolidate.sh --llm`, which 1.1.0 fixed on macOS by dropping the limit when + `timeout` is missing, uses the same helper and so keeps its 180 s cap everywhere. + ## [1.1.0] - 2026-09-22 ### Added diff --git a/bin/learn-consolidate.sh b/bin/learn-consolidate.sh index d5e6c65..c3a66cd 100755 --- a/bin/learn-consolidate.sh +++ b/bin/learn-consolidate.sh @@ -59,17 +59,14 @@ include secrets/tokens/PII. Output only the markdown, no preamble. LESSONS: $all" -# `timeout` is GNU coreutils: stock macOS has none (Homebrew coreutils installs it as -# `gtimeout`). Calling a missing `timeout` failed silently here (stderr is discarded), so -# claude never ran and every --llm run on a Mac ended in "response too short". -limited() { - if command -v timeout >/dev/null 2>&1; then timeout 180 "$@" - elif command -v gtimeout >/dev/null 2>&1; then gtimeout 180 "$@" - else "$@"; fi -} +# forge_timeout: `timeout`, else `gtimeout`, else a bash watchdog. A bare `timeout` is +# missing on stock macOS and failed silently here (stderr is discarded), so claude never +# ran and every --llm run on a Mac ended in "response too short". +# shellcheck source=/dev/null +. "$ROOT/global/guards/_guardlib.sh" # Uses your logged-in session (slower startup, but authed). Weekly/cron task. -out="$(printf '%s' "$prompt" | limited claude -p --model haiku 2>/dev/null)" +out="$(printf '%s' "$prompt" | forge_timeout 180 claude -p --model haiku 2>/dev/null)" out="$(printf '%s' "$out" | sed '/^[[:space:]]*$/d')" # Guard: never overwrite/delete on an error or empty/too-short response. diff --git a/global/guards/_guardlib.sh b/global/guards/_guardlib.sh index e8497b6..ddc6c53 100755 --- a/global/guards/_guardlib.sh +++ b/global/guards/_guardlib.sh @@ -62,6 +62,33 @@ forge_field() { esac } +# forge_timeout [args…] — run a command for at most seconds, stdin and +# exit status passed through. `timeout` is GNU coreutils and stock macOS has none (Homebrew +# installs it as `gtimeout`); calling it bare there fails with "command not found", and with +# stderr discarded the model call silently never ran. Neither found → a bash watchdog. +forge_timeout() { + local secs="$1" + shift + if command -v timeout > /dev/null 2>&1; then + timeout "$secs" "$@" + return + fi + if command -v gtimeout > /dev/null 2>&1; then + gtimeout "$secs" "$@" + return + fi + # `<&0` keeps stdin: without job control a background job otherwise reads /dev/null. + "$@" <&0 & + local pid=$! + # The watchdog's output goes to /dev/null so a caller's `$(…)` never waits on its sleep. + (sleep "$secs" && kill -TERM "$pid" 2> /dev/null) > /dev/null 2>&1 & + local dog=$! + local rc=0 + wait "$pid" || rc=$? + kill "$dog" 2> /dev/null || true + return "$rc" +} + # forge_lock — return 0 if the lock was acquired, 1 if already held. # Atomic via mkdir; auto-released on process exit; reclaims locks older than 60s. forge_lock() { diff --git a/global/guards/session-learner.sh b/global/guards/session-learner.sh index 0b8115a..0924660 100755 --- a/global/guards/session-learner.sh +++ b/global/guards/session-learner.sh @@ -61,7 +61,7 @@ Format each lesson as one markdown bullet starting with '- '. TRANSCRIPT: $transcript" - out="$(printf '%s' "$prompt" | timeout 90 claude -p --model "$MODEL" 2>>"$LOG")" + out="$(printf '%s' "$prompt" | forge_timeout 90 claude -p --model "$MODEL" 2>>"$LOG")" out="$(printf '%s' "$out" | sed '/^[[:space:]]*$/d')" if [ -n "$out" ] && ! printf '%s' "$out" | grep -qix 'none'; then { diff --git a/test/guards.test.js b/test/guards.test.js index d8fcfd7..b9e1aeb 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; @@ -397,3 +405,90 @@ test("protect-paths fails CLOSED on an unparsable payload (B6)", () => { assert.equal(r.status, 2, "an unparsable payload blocks"); assert.match(r.stderr, /fail closed/i); }); + +// ── forge_timeout: `timeout` is GNU coreutils and stock macOS has none, so the session +// learner's bare `timeout 90 claude …` never ran the model there. These tests build a PATH +// with no `timeout`/`gtimeout` (symlinks to the tools the scripts need), which reproduces +// stock macOS on any POSIX runner. Windows Git Bash ships `timeout`, and symlinks need +// elevation there, so both tests skip on win32. +const noTimeoutSkip = process.platform === "win32" && "symlinked PATH (Git Bash ships timeout)"; + +/** @param {string[]} tools */ +function pathWithoutTimeout(tools) { + const bin = mkdtempSync(join(tmpdir(), "forge-notimeout-")); + for (const t of tools) { + const real = execFileSync("bash", ["-c", `command -v ${t}`], { encoding: "utf8" }).trim(); + symlinkSync(real, join(bin, t)); + } + symlinkSync(process.execPath, join(bin, "node")); + return bin; +} + +test("forge_timeout without timeout/gtimeout: stdin, exit status and the time limit hold", { + skip: noTimeoutSkip, +}, () => { + const bin = pathWithoutTimeout(["bash", "sh", "cat", "sleep", "dirname"]); + const script = [ + `. "${join(guards, "_guardlib.sh")}"`, + 'if command -v timeout >/dev/null || command -v gtimeout >/dev/null; then echo "HAS-TIMEOUT"; fi', + "printf 'hello' | forge_timeout 5 cat; echo", + "forge_timeout 5 sh -c 'exit 3'; echo \"rc=$?\"", + 's=$SECONDS; out="$(forge_timeout 1 sleep 8)"; echo "overrun=$? secs=$((SECONDS - s))"', + ].join("\n"); + const r = spawnSync(join(bin, "bash"), ["-c", script], { + env: { PATH: bin, HOME: tmpdir() }, + encoding: "utf8", + }); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stdout, /HAS-TIMEOUT/, "the PATH really has no timeout"); + assert.match(r.stdout, /^hello$/m, "stdin reaches the command (a bare `&` would read /dev/null)"); + assert.match(r.stdout, /rc=3/, "the command's exit status passes through"); + const m = /overrun=(\d+) secs=(\d+)/.exec(r.stdout); + assert.ok(m, r.stdout); + assert.notEqual(Number(m[1]), 0, "an overrun is reported as a failure"); + assert.ok( + Number(m[2]) <= 4, + `killed at the limit, and $(…) does not wait on the watchdog (${m[2]}s)`, + ); +}); + +test("session-learner calls the model on a PATH with no timeout (stock macOS)", { + skip: noTimeoutSkip, +}, async () => { + const bin = pathWithoutTimeout([ + ...["bash", "cat", "grep", "wc", "tail", "sed", "date", "mkdir", "touch"], + ...["basename", "dirname", "find", "rmdir", "sleep"], + ]); + const home = mkdtempSync(join(tmpdir(), "forge-learner-home-")); + const calls = join(home, "calls.log"); + writeFileSync( + join(bin, "claude"), + `#!/bin/sh\necho called >> "${calls}"\necho "- Rebuild the atlas after renaming a module."\n`, + ); + chmodSync(join(bin, "claude"), 0o755); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, '{"type":"user","message":"rename the module"}\n'); + const lockDir = mkdtempSync(join(tmpdir(), "forge-learner-lock-")); + const r = spawnSync(join(bin, "bash"), [join(guards, "session-learner.sh")], { + input: JSON.stringify({ transcript_path: transcript, cwd: join(home, "shop") }), + env: { + PATH: bin, + HOME: home, + TMPDIR: lockDir, + ENABLE_SESSION_LEARNING: "1", + SESSION_LEARN_MIN: "1", + }, + encoding: "utf8", + }); + assert.equal(r.status, 0, r.stderr); + // The model call runs detached, so wait (up to 10s) for the lesson it appends. + const learned = join(home, ".claude", "skills", "learned"); + const lessons = () => { + const f = existsSync(learned) && readdirSync(learned).find((n) => /^lessons-.*\.md$/.test(n)); + return f ? readFileSync(join(learned, f), "utf8") : ""; + }; + for (let i = 0; i < 100 && !/Rebuild the atlas/.test(lessons()); i++) + await new Promise((ok) => setTimeout(ok, 100)); + assert.ok(existsSync(calls), "the model stub was called"); + assert.match(lessons(), /Rebuild the atlas/, "the lesson was appended"); +}); From 339a8690c8a741c1f77bf125f5f7d63481ed35be Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:13:39 +0200 Subject: [PATCH 2/4] fix(learn): read learned lessons from $HOME, where the learner writes them The session learner is a bash hook and writes $HOME/.claude/skills/learned. learnedDir() used node's homedir(), which on Windows reads USERPROFILE, so a Git Bash HOME that differed from it sent learn-consolidate to another folder. learnedDir() now follows HOME when set (POSIX homedir() already did). The hermetic test required every env var src reads to be unset under test. HOME is sandboxed by test/_setup.js, not unset, so the test now accepts HOME/USERPROFILE while they point away from the real home, and still fails the moment one holds it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +++++ src/learn_consolidate.js | 6 +++++- test/hermetic.test.js | 11 ++++++++++- test/learn_consolidate.test.js | 15 +++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 703c9c5..09fcb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `learn-consolidate.sh --llm`, which 1.1.0 fixed on macOS by dropping the limit when `timeout` is missing, uses the same helper and so keeps its 180 s cap everywhere. +- **Lesson consolidation reads the folder the session learner writes to on Windows.** The + learner (a bash hook) writes under `$HOME`, but node's `homedir()` reads `USERPROFILE` on + Windows, so a Git Bash `HOME` that differed from it made `learn-consolidate` look in the + wrong place. `learnedDir()` now follows `HOME` when it is set. + ## [1.1.0] - 2026-09-22 ### Added diff --git a/src/learn_consolidate.js b/src/learn_consolidate.js index f2a8326..430620d 100644 --- a/src/learn_consolidate.js +++ b/src/learn_consolidate.js @@ -36,7 +36,11 @@ import { epochDay } from "./util.js"; /** Same τ as ledger.clusters — "these two say the same thing". */ export const CONSOLIDATE_TAU = 0.7; export const GENERAL = "General"; -export const learnedDir = () => join(homedir(), ".claude", "skills", "learned"); +/** Where the session learner writes: `$HOME/.claude/skills/learned` (it is a bash hook, and + * the `--llm` path is bash too). On POSIX `homedir()` already follows HOME; on Windows it + * reads USERPROFILE instead, so a Git Bash HOME that differs from USERPROFILE made this + * path read a different folder than the one the lessons were written to. */ +export const learnedDir = () => join(process.env.HOME || homedir(), ".claude", "skills", "learned"); const norm = (s) => String(s) diff --git a/test/hermetic.test.js b/test/hermetic.test.js index 41a00fd..eeda88d 100644 --- a/test/hermetic.test.js +++ b/test/hermetic.test.js @@ -23,11 +23,20 @@ test("_setup ran: $HOME is a sandbox, not the developer's home", () => { // deliberately kept: only TERM=dumb is meaningful in src/fmt.js and it forces colour off, // which already matches a non-TTY test process. const NOT_SCRUBBED = new Set(["TERM", "TOKEN", "X"]); +// Sandboxed rather than scrubbed: _setup points these at an empty test home (pinned by the +// test above), so a src read of them sees that home, never the developer's. They count as a +// leak again the moment one holds the real home. +const SANDBOXED = new Set(["HOME", "USERPROFILE"]); test("every env var src reads is scrubbed (the denylist cannot drift from envVarsRead)", () => { + const realHome = userInfo().homedir; const leaked = [...envVarsRead()].filter( // FORGE_LLM_HTTP is set BY _setup on purpose: it forces the keyless HTTP runner. - (v) => !NOT_SCRUBBED.has(v) && v !== "FORGE_LLM_HTTP" && process.env[v] !== undefined, + (v) => + !NOT_SCRUBBED.has(v) && + v !== "FORGE_LLM_HTTP" && + !(SANDBOXED.has(v) && process.env[v] !== realHome) && + process.env[v] !== undefined, ); assert.deepEqual(leaked, [], `not scrubbed by test/_setup.js: ${leaked.join(", ")}`); }); diff --git a/test/learn_consolidate.test.js b/test/learn_consolidate.test.js index 8d9a9d4..174a1e3 100644 --- a/test/learn_consolidate.test.js +++ b/test/learn_consolidate.test.js @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url"; import { consolidateDir, consolidateLearned, + learnedDir, ledgerClaimsFor, parseLearned, renderConsolidated, @@ -168,6 +169,20 @@ test("consolidateDir archives originals, rewrites CONSOLIDATED.md, removes month assert.equal(consolidateDir({ dir: tmp() }).row, "nothing"); }); +// The bash learner writes under $HOME; on Windows node's homedir() reads USERPROFILE, so the +// consolidator read a different folder whenever Git Bash's HOME differed from it. +test("learnedDir follows HOME, the folder the bash session learner writes to", () => { + const saved = process.env.HOME; + const home = tmp("forge-learn-homevar-"); + try { + process.env.HOME = home; + assert.equal(learnedDir(), join(home, ".claude", "skills", "learned")); + } finally { + if (saved === undefined) delete process.env.HOME; + else process.env.HOME = saved; + } +}); + // A STUB `claude` sits first on PATH, so no real model is ever called from the suite: the // default path must never invoke it, and the opt-in `--llm` path reaches the stub only. test("the script runs deterministically with no model call; --llm is opt-in", { From 154246e18b8b77df03d3db2fe586fab4c6ea7e44 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:13:39 +0200 Subject: [PATCH 3/4] fix(pricing): Sonnet 5 stays at $2/$10 per million tokens model_tiers.json scheduled Sonnet 5 to rise from $2/$10 to $3/$15 on 2026-09-01. Anthropic cancelled that increase: the pricing page (checked 2026-09-22) lists $2/$10 as the standard price. Since 2026-09-01 forge priced Sonnet 5 50% high, and the cost report, which reads the flat price, always had. Sonnet 5 is now a flat $2/$10 and pricingVerified is 2026-09-22 (all four tier prices re-checked). The universal router's registry entry matches. priceOf/allPricePairs take an optional table so the date-window logic keeps a test of its own with a synthetic schedule. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +++++ data/models.json | 10 ++++---- src/model_tiers.js | 12 ++++++---- src/model_tiers.json | 15 +++--------- test/model_tiers.test.js | 50 ++++++++++++++++++++++++++++++++-------- 5 files changed, 62 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fcb48..166a701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Windows, so a Git Bash `HOME` that differed from it made `learn-consolidate` look in the wrong place. `learnedDir()` now follows `HOME` when it is set. +- **Sonnet 5 is priced at $2/$10 per million tokens again.** Anthropic made the launch price + the standard price and cancelled the $3/$15 increase scheduled for 2026-09-01, so from + that date the tier table (and the cost report, which reads its flat price) overstated + Sonnet 5 by 50%. `src/model_tiers.json` is re-verified against the pricing page + (`pricingVerified` 2026-09-22), and the universal router's registry entry matches. + ## [1.1.0] - 2026-09-22 ### Added diff --git a/data/models.json b/data/models.json index 9085022..e1067bc 100644 --- a/data/models.json +++ b/data/models.json @@ -148,9 +148,9 @@ "run_model_id": null, "benchmark_run": null, "evidence": null, - "price_in": 3, - "price_out": 15, - "price_source": "forgekit src/model_tiers.json (pricingVerified 2026-07-17)", + "price_in": 2, + "price_out": 10, + "price_source": "https://platform.claude.com/docs/en/about-claude/pricing (checked 2026-09-22: the $2/$10 launch price is now standard; the scheduled $3/$15 was cancelled)", "providers": { "anthropic": "claude-sonnet-5" }, @@ -165,7 +165,7 @@ "evidence": null, "price_in": 5, "price_out": 25, - "price_source": "forgekit src/model_tiers.json (pricingVerified 2026-07-17)", + "price_source": "forgekit src/model_tiers.json (pricingVerified 2026-09-22)", "providers": { "anthropic": "claude-opus-4-8" }, @@ -180,7 +180,7 @@ "evidence": null, "price_in": 10, "price_out": 50, - "price_source": "forgekit src/model_tiers.json (pricingVerified 2026-07-17)", + "price_source": "forgekit src/model_tiers.json (pricingVerified 2026-09-22)", "providers": { "anthropic": "claude-fable-5" }, diff --git a/src/model_tiers.js b/src/model_tiers.js index 0186788..b192a47 100644 --- a/src/model_tiers.js +++ b/src/model_tiers.js @@ -26,10 +26,11 @@ const today = () => new Date().toISOString().slice(0, 10); * (steady-state). This is why a single `pricingVerified` date is no longer enough (P0-12). * @param {string} key model key (haiku/sonnet/opus/fable) * @param {string} [date] ISO date; defaults to today + * @param {Record} [models] the tier table; defaults to model_tiers.json * @returns {{inCost:number, outCost:number}|null} */ -export function priceOf(key, date = today()) { - const m = MODELS[key]; +export function priceOf(key, date = today(), models = MODELS) { + const m = models[key]; if (!m) return null; for (const w of m.prices || []) { if (date >= w.effectiveFrom && (!w.effectiveUntil || date <= w.effectiveUntil)) { @@ -40,10 +41,11 @@ export function priceOf(key, date = today()) { } /** Every distinct price pair across flat + scheduled windows — used by the docs check so a - * documented introductory/standard price isn't flagged as stale. */ -export function allPricePairs() { + * documented introductory/standard price isn't flagged as stale. + * @param {Record} [models] the tier table; defaults to model_tiers.json */ +export function allPricePairs(models = MODELS) { const pairs = []; - for (const m of Object.values(MODELS)) { + for (const m of Object.values(models)) { pairs.push({ inCost: m.inCost, outCost: m.outCost }); for (const w of m.prices || []) pairs.push({ inCost: w.inCost, outCost: w.outCost }); } diff --git a/src/model_tiers.json b/src/model_tiers.json index 43fd1b7..b73bff6 100644 --- a/src/model_tiers.json +++ b/src/model_tiers.json @@ -1,6 +1,6 @@ { "pricingCurrency": "USD", - "pricingVerified": "2026-07-17", + "pricingVerified": "2026-09-22", "models": { "haiku": { "id": "claude-haiku-4-5-20251001", @@ -14,17 +14,8 @@ "id": "claude-sonnet-5", "name": "Sonnet 5", "tier": "medium", - "inCost": 3, - "outCost": 15, - "prices": [ - { - "effectiveFrom": "2026-06-30", - "effectiveUntil": "2026-08-31", - "inCost": 2, - "outCost": 10 - }, - { "effectiveFrom": "2026-09-01", "inCost": 3, "outCost": 15 } - ], + "inCost": 2, + "outCost": 10, "use": "refactoring, feature work, tests, code review (the default)" }, "opus": { diff --git a/test/model_tiers.test.js b/test/model_tiers.test.js index 5a39ea7..76bffc5 100644 --- a/test/model_tiers.test.js +++ b/test/model_tiers.test.js @@ -2,22 +2,54 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { allPricePairs, priceOf } from "../src/model_tiers.js"; +// A synthetic table, so the window logic stays tested whether or not a real model currently +// carries a schedule. +const SCHEDULED = { + intro: { + inCost: 3, + outCost: 15, + prices: [ + { effectiveFrom: "2026-06-30", effectiveUntil: "2026-08-31", inCost: 2, outCost: 10 }, + { effectiveFrom: "2026-09-01", inCost: 3, outCost: 15 }, + ], + }, + flat: { inCost: 1, outCost: 5 }, +}; + test("priceOf resolves the active pricing window by date (P0-12)", () => { - // Sonnet 5 introductory pricing runs through 2026-08-31, then the standard rate. - assert.deepEqual(priceOf("sonnet", "2026-07-17"), { inCost: 2, outCost: 10 }, "intro window"); - assert.deepEqual(priceOf("sonnet", "2026-08-31"), { inCost: 2, outCost: 10 }, "intro boundary"); - assert.deepEqual(priceOf("sonnet", "2026-09-01"), { inCost: 3, outCost: 15 }, "standard window"); + assert.deepEqual(priceOf("intro", "2026-07-17", SCHEDULED), { inCost: 2, outCost: 10 }); + assert.deepEqual( + priceOf("intro", "2026-08-31", SCHEDULED), + { inCost: 2, outCost: 10 }, + "boundary", + ); + assert.deepEqual(priceOf("intro", "2026-09-01", SCHEDULED), { inCost: 3, outCost: 15 }); + assert.deepEqual( + priceOf("intro", "2026-06-01", SCHEDULED), + { inCost: 3, outCost: 15 }, + "before any window → flat", + ); }); test("priceOf falls back to flat cost for a model with no schedule", () => { assert.deepEqual(priceOf("haiku", "2026-07-17"), { inCost: 1, outCost: 5 }); + assert.deepEqual(priceOf("flat", "2026-07-17", SCHEDULED), { inCost: 1, outCost: 5 }); assert.equal(priceOf("nope"), null); }); +// Anthropic made Sonnet 5's launch price of $2/$10 the standard price; the increase to $3/$15 +// scheduled for 2026-09-01 was cancelled (platform.claude.com pricing page, checked 2026-09-22). +test("Sonnet 5 stays at $2/$10 after 2026-09-01 (the scheduled increase was cancelled)", () => { + assert.deepEqual(priceOf("sonnet", "2026-07-17"), { inCost: 2, outCost: 10 }); + assert.deepEqual(priceOf("sonnet", "2026-09-22"), { inCost: 2, outCost: 10 }); +}); + test("allPricePairs includes both scheduled and flat prices", () => { - const pairs = allPricePairs(); - const has = (i, o) => pairs.some((p) => p.inCost === i && p.outCost === o); - assert.ok(has(2, 10), "intro sonnet price present"); - assert.ok(has(3, 15), "standard sonnet price present"); - assert.ok(has(1, 5), "haiku flat price present"); + const has = (pairs, i, o) => pairs.some((p) => p.inCost === i && p.outCost === o); + const synthetic = allPricePairs(SCHEDULED); + assert.ok(has(synthetic, 2, 10), "a scheduled window's price is included"); + assert.ok(has(synthetic, 3, 15), "the flat price is included"); + const real = allPricePairs(); + assert.ok(has(real, 1, 5), "haiku flat price present"); + assert.ok(has(real, 2, 10), "sonnet price present"); }); From c5227db129c054e4445f8e24ed5f562a5bc6fd0d Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:13:40 +0200 Subject: [PATCH 4/4] ci(release): give npm up to 10 minutes, revalidating, after publish v1.0.0 and v1.1.0 both shipped (npm serves them, the GitHub Releases exist) yet their release jobs failed the post-publish check, which tried 6 times 10 s apart: - v1.0.0: publish finished 02:17:23, check gave up 02:18:27; the packument's Last-Modified was 02:20:02 (159 s after publish). - v1.1.0: publish finished 13:12:17, check gave up 13:13:23; Last-Modified 13:16:29 (252 s after publish). The registry also serves packuments with Cache-Control max-age=300, so a retry could re-read the copy cached by the idempotency check's `npm view`. The check now tries 40 times 15 s apart with --prefer-online. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 11 ++++++++--- CHANGELOG.md | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d20cde1..cb51bb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,10 +113,15 @@ jobs: if [ "$HAS_NPM_TOKEN" = "true" ]; then PKG=$(node -p "require('./package.json').name") VER=$(node -p "require('./package.json').version") + # Up to 10 minutes, revalidating every time. v1.0.0 and v1.1.0 both shipped yet + # failed here after 60 s: their packuments' Last-Modified came 159 s and 252 s + # after the publish finished. The registry also serves packuments with + # max-age=300, and the publish step's `npm view` has just cached the pre-publish + # one, so without --prefer-online a retry can re-read it. ok=0 - for _ in 1 2 3 4 5 6; do - if npm view "$PKG@$VER" version >/dev/null 2>&1; then ok=1; break; fi - sleep 10 # let the registry reflect a just-published version + for _ in $(seq 1 40); do + if npm view "$PKG@$VER" version --prefer-online >/dev/null 2>&1; then ok=1; break; fi + sleep 15 done if [ "$ok" = "1" ]; then echo "npm $PKG@$VER: present." diff --git a/CHANGELOG.md b/CHANGELOG.md index 166a701..f9214ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Sonnet 5 by 50%. `src/model_tiers.json` is re-verified against the pricing page (`pricingVerified` 2026-09-22), and the universal router's registry entry matches. +- **The release job no longer fails while npm catches up.** v1.0.0 and v1.1.0 both shipped + but went red: the post-publish check gave npm 60 s, and each packument's `Last-Modified` + came 159 s and 252 s after publish, respectively. The registry also serves packuments with + `max-age=300`, so a retry could re-read the copy cached by the pre-publish `npm view`. The + check now waits up to 10 minutes and passes `--prefer-online`. + ## [1.1.0] - 2026-09-22 ### Added