Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 6 additions & 9 deletions bin/learn-consolidate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions data/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
},
Expand All @@ -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"
},
Expand Down
27 changes: 27 additions & 0 deletions global/guards/_guardlib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ forge_field() {
esac
}

# forge_timeout <secs> <cmd> [args…] — run a command for at most <secs> 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 <key> — 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() {
Expand Down
2 changes: 1 addition & 1 deletion global/guards/session-learner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 5 additions & 1 deletion src/learn_consolidate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions src/model_tiers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>} [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)) {
Expand All @@ -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<string, any>} [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 });
}
Expand Down
15 changes: 3 additions & 12 deletions src/model_tiers.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"pricingCurrency": "USD",
"pricingVerified": "2026-07-17",
"pricingVerified": "2026-09-22",
"models": {
"haiku": {
"id": "claude-haiku-4-5-20251001",
Expand All @@ -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": {
Expand Down
97 changes: 96 additions & 1 deletion test/guards.test.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
11 changes: 10 additions & 1 deletion test/hermetic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`);
});
Expand Down
15 changes: 15 additions & 0 deletions test/learn_consolidate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
import {
consolidateDir,
consolidateLearned,
learnedDir,
ledgerClaimsFor,
parseLearned,
renderConsolidated,
Expand Down Expand Up @@ -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", {
Expand Down
Loading
Loading