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 bfec0eb..f9214ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ 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. + +- **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. + +- **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. + +- **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 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/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/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/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/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/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"); +}); 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", { 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"); });