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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **One store per project, and a bounded touch trail (found 2026-10-27 reading a live
install; all CLI-enforced, each test seen red against the unpatched tree):**
- **One base for every caller.** `baseDir()` honored `$CLAUDE_PLUGIN_DATA`, which Claude
Code sets for hooks and the MCP server but never for the model's own shell. Hook
writes therefore landed in a shadow store the CLI never read, and the `stop-check`
nag read that shadow store too, so `/ratchet:compile` could never clear it. The base
is now `$RATCHET_DATA_DIR`, else `~/.ratchet`, for all callers. Stores left under
`$CLAUDE_PLUGIN_DATA` are no longer read and are never moved or deleted. On the
machine where this was found, all 992 of them held only hook touch rows.
- **Hooks never create a store.** `session-start` initialized a store, and `stop-check`
loaded one, in every directory a session opened in. Hooks now act only on a store
that already exists, so a project gets a store only where someone runs ratchet.
- **`touchedFiles` is bounded.** Every edit appended a row and rewrote the whole list,
and nothing trimmed it: one store reached 22,761 rows (4.4 MB). Each path now keeps
only its newest touch, newest last, capped at `TOUCHED_FILES_MAX` (500). The cap
applies to the hook, `ratchet touch` and `state append touchedFiles`, and an
oversized trail collapses on the next touch. The receipt's "touched since compile"
set is unchanged, because it only ever read each path's newest touch.

- **Safe-core hardening (three live defects found by the 4c design review rounds 2–3,
2026-08-01; all CLI-enforced, each with a falsifier (H1–H5) seen red against the
unpatched tree):**
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,14 +523,18 @@ ratchet status

### State location

State survives plugin updates. It is written to, in order of preference:
State survives plugin updates. Every caller (the CLI, the hooks and the MCP server) writes to
one base:

1. `$CLAUDE_PLUGIN_DATA` — set by Claude Code for enabled plugins.
2. `$RATCHET_DATA_DIR` — override it yourself.
3. `~/.ratchet` — fallback.
1. `$RATCHET_DATA_DIR` — override it yourself.
2. `~/.ratchet` — default.

`$CLAUDE_PLUGIN_DATA` is not used: Claude Code sets it for hooks and the MCP server but not
for the model's own shell, so honoring it would split one project into two stores.

State is scoped per project (by working-directory path), so multiple repos never collide in
one shared data directory.
one shared data directory. Hooks only update a store that already exists; they never create
one.

---

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Torque Loop is a Claude Code plugin and a **zero-dependency**, local Node.js CLI
network surface and no runtime dependencies. The most relevant areas for security review are:

- **Local file & state handling** — the CLI reads and writes state under the resolved data
directory (`$CLAUDE_PLUGIN_DATA`, `$RATCHET_DATA_DIR`, or `~/.ratchet`).
directory (`$RATCHET_DATA_DIR`, or `~/.ratchet`).
- **Hooks** — the bundled hooks (`hooks/hooks.json`) run on session events. By design they
never run tests or edits on their own; a hook that executes something unexpected is a bug
worth reporting.
Expand Down
19 changes: 9 additions & 10 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,7 @@ function run(argv) {
const file = sub;
if (!file) throw new Error('usage: ratchet touch <file>');
assertMayWrite('touch');
mutate(cwd, 'touch', (s) => {
s.touchedFiles.push({ path: file, at: schemas.nowIso() });
s.dirty = true;
});
mutate(cwd, 'touch', (s) => verbs.recordTouch(s, file));
return out(`touched ${file}`);
}

Expand Down Expand Up @@ -627,9 +624,13 @@ function cmdExport(cwd, sub) {

function cmdHook(cwd, sub) {
try {
// Hooks fire in every project the plugin is enabled in, so they act only on
// a store that already exists. Creating one here left a store behind in
// every directory a session ever opened in — 992 empty ones on one machine.
const hasStore = fs.existsSync(state.statePath(cwd));
switch (sub) {
case 'session-start': {
state.initProject(cwd);
if (hasStore) state.initProject(cwd);
return; // silent
}
case 'post-edit': {
Expand All @@ -641,19 +642,17 @@ function cmdHook(cwd, sub) {
} catch (_e) {
/* ignore malformed */
}
if (file) {
if (file && hasStore) {
// A hook fires while the session's own commands are running, so this
// is the write most likely to race one. It goes through the boundary
// like every other mutation — and, like every other mutation, it is
// refused for a propose-only agent (which leaves no footprint at all).
mutate(cwd, 'hook post-edit', (s) => {
s.touchedFiles.push({ path: file, at: schemas.nowIso() });
s.dirty = true;
});
mutate(cwd, 'hook post-edit', (s) => verbs.recordTouch(s, file));
}
return; // silent, non-blocking
}
case 'stop-check': {
if (!hasStore) return; // ratchet never ran here: nothing to compile, nothing to nag
const s = state.loadState(cwd);
const stale = s.dirty && (!s.lastCompileAt || s.lastCompileAt < s.updatedAt);
if (stale) {
Expand Down
11 changes: 7 additions & 4 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ const schemas = require('./schemas');
const wal = require('./wal');

// ---------------------------------------------------------------------------
// Location. State survives plugin updates when CLAUDE_PLUGIN_DATA is set.
// Everything is scoped per-project so multiple repos never collide in one
// shared plugin data directory.
// Location. One base for every caller: RATCHET_DATA_DIR, else ~/.ratchet.
// CLAUDE_PLUGIN_DATA is deliberately ignored. Claude Code sets it for hooks and
// the MCP server but never for the model's own shell, so honoring it split one
// project into two stores: hook writes landed in a shadow store the CLI never
// read. ~/.ratchet already survives plugin updates.
// Everything is scoped per-project so multiple repos never collide in one base.
// ---------------------------------------------------------------------------

function baseDir() {
const explicit = process.env.RATCHET_DATA_DIR || process.env.CLAUDE_PLUGIN_DATA;
const explicit = process.env.RATCHET_DATA_DIR;
if (explicit && explicit.trim()) return explicit.trim();
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
return path.join(home, '.ratchet');
Expand Down
31 changes: 31 additions & 0 deletions src/verbs.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,39 @@ function appendItem(s, collection, item, mintId) {
}
const record = { id: item.id || mintId(schemas.STATE_COLLECTIONS[collection], 'record'), at: schemas.nowIso(), ...item };
s[collection].push(record);
if (collection === 'touchedFiles') boundTouched(s);
s.dirty = true;
return { record };
}

// touchedFiles is a trail, not a log. The receipt reads it as the set of paths
// changed since the last compile, so only each path's newest touch carries
// information. Unbounded, the post-edit hook grew one store to 22,761 rows
// (4.4 MB) and rewrote all of it on every edit. Keep one row per path, newest
// last, at most TOUCHED_FILES_MAX of them; an oversized trail collapses on the
// next touch.
const TOUCHED_FILES_MAX = 500;

function boundTouched(s) {
const seen = new Set();
const kept = [];
for (let i = s.touchedFiles.length - 1; i >= 0 && kept.length < TOUCHED_FILES_MAX; i--) {
const f = s.touchedFiles[i];
const key = f && f.path;
if (key && seen.has(key)) continue; // an older touch of a path already kept
if (key) seen.add(key);
kept.push(f);
}
s.touchedFiles = kept.reverse();
}

// The hook's and `ratchet touch`'s one write.
function recordTouch(s, file) {
s.touchedFiles.push({ path: file, at: schemas.nowIso() });
boundTouched(s);
s.dirty = true;
}

// `state close`: transition one openLoop or assumption to its terminal status.
// Parking is a close too: it stops the nagging, but it STILL DRAINS, because a
// parked question is an unanswered question with an owner, not an answered
Expand Down Expand Up @@ -170,6 +199,8 @@ module.exports = {
setScalar,
BIRTH_STATUS,
appendItem,
TOUCHED_FILES_MAX,
recordTouch,
closeRecord,
compileDone,
fogAlreadyOnRecord,
Expand Down
100 changes: 99 additions & 1 deletion test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,13 @@ ok('a propose-only hook says it is propose-only, not that the state is unreadabl
const proj = path.join(tmp, 'hook-propose-only');
fs.rmSync(proj, { recursive: true, force: true });
fs.mkdirSync(proj, { recursive: true });
const r = spawnSync(process.execPath, [path.resolve(__dirname, '..', 'bin', 'ratchet'), 'hook', 'post-edit'], {
// The store must exist: a hook in a project with no store records nothing, so
// there would be no write for the guard to refuse.
const bin = path.resolve(__dirname, '..', 'bin', 'ratchet');
const init = { ...process.env, RATCHET_DATA_DIR: path.join(proj, '.data') };
delete init.RATCHET_AGENT;
assert.strictEqual(spawnSync(process.execPath, [bin, 'init'], { cwd: proj, env: init, encoding: 'utf8' }).status, 0);
const r = spawnSync(process.execPath, [bin, 'hook', 'post-edit'], {
cwd: proj,
env: { ...process.env, RATCHET_DATA_DIR: path.join(proj, '.data'), RATCHET_AGENT: 'ratchet-builder' },
input: JSON.stringify({ tool_input: { file_path: 'README.md' } }),
Expand All @@ -1042,6 +1048,98 @@ ok('a propose-only hook says it is propose-only, not that the state is unreadabl
assert.ok(!/closure state unreadable/.test(text), `and must not claim the record is unreadable; got: ${text.trim()}`);
});

ok('hooks and the CLI resolve one store, whether or not CLAUDE_PLUGIN_DATA is set', () => {
// Claude Code sets CLAUDE_PLUGIN_DATA for hooks and the MCP server, never for
// the model's own shell. Honoring it put every hook write in a shadow store
// the CLI never read: one project grew 22,761 touch rows there while its real
// record sat untouched under ~/.ratchet.
const { spawnSync } = require('child_process');
const bin = path.resolve(__dirname, '..', 'bin', 'ratchet');
const proj = path.join(tmp, 'one-base');
fs.rmSync(proj, { recursive: true, force: true });
fs.mkdirSync(proj, { recursive: true });
const home = path.join(proj, '.home');
const pluginData = path.join(proj, '.plugin-data');
const shell = { ...process.env, HOME: home, USERPROFILE: home, RATCHET_EVOLVE_LOG: path.join(proj, 'evolve-log.jsonl') };
delete shell.RATCHET_DATA_DIR;
delete shell.RATCHET_AGENT;
delete shell.CLAUDE_PLUGIN_DATA;
const hook = { ...shell, CLAUDE_PLUGIN_DATA: pluginData };

assert.strictEqual(spawnSync(process.execPath, [bin, 'state', 'set', 'objective', 'one base'], { cwd: proj, env: shell, encoding: 'utf8' }).status, 0);
const r = spawnSync(process.execPath, [bin, 'hook', 'post-edit'], {
cwd: proj, env: hook, input: JSON.stringify({ tool_input: { file_path: 'README.md' } }), encoding: 'utf8',
});
assert.strictEqual(r.status, 0, r.stderr);

assert.ok(!fs.existsSync(path.join(pluginData, 'projects')), 'the hook must not open a second store under CLAUDE_PLUGIN_DATA');
const projects = path.join(home, '.ratchet', 'projects');
const stores = fs.readdirSync(projects);
assert.strictEqual(stores.length, 1, `one store for one project — got: ${stores.join(', ')}`);
const s = JSON.parse(fs.readFileSync(path.join(projects, stores[0], 'state.json'), 'utf8'));
assert.strictEqual(s.objective, 'one base');
assert.deepStrictEqual(s.touchedFiles.map((f) => f.path), ['README.md'], 'the hook touch lands in the store the CLI wrote');
});

ok('hooks never create a store in a project that has none', () => {
// A hook fires in every project the plugin is enabled in. Creating a store
// there filled one plugin data dir with 992 empty stores; a store exists only
// where someone ran ratchet.
const { spawnSync } = require('child_process');
const bin = path.resolve(__dirname, '..', 'bin', 'ratchet');
const proj = path.join(tmp, 'hook-no-store');
fs.rmSync(proj, { recursive: true, force: true });
fs.mkdirSync(proj, { recursive: true });
const data = path.join(proj, '.data');
const env = { ...process.env, RATCHET_DATA_DIR: data };
delete env.RATCHET_AGENT;
for (const sub of ['session-start', 'post-edit', 'stop-check']) {
const r = spawnSync(process.execPath, [bin, 'hook', sub], {
cwd: proj, env, input: JSON.stringify({ tool_input: { file_path: 'README.md' } }), encoding: 'utf8',
});
assert.strictEqual(r.status, 0, `${sub} never breaks the session`);
assert.strictEqual(String(r.stderr || ''), '', `${sub} has nothing to say about a project ratchet never ran in`);
}
assert.ok(!fs.existsSync(path.join(data, 'projects')), 'no store was created');
});

ok('touchedFiles keeps one row per path, newest last, and stays bounded', () => {
// The receipt reads touchedFiles as the set of paths changed since the last
// compile, so only each path's newest touch carries information. Unbounded,
// every edit appended a row and rewrote the whole list.
const verbs = require('../src/verbs');
const max = verbs.TOUCHED_FILES_MAX;
const proj = path.join(tmp, 'touch-bound');
fs.rmSync(proj, { recursive: true, force: true });
fs.mkdirSync(proj, { recursive: true });
state.initProject(proj, { force: true });
inProject(proj, () => {
const s = state.loadState(proj);
s.touchedFiles = [{ path: 'dup.md', at: '2026-01-01T00:00:00.000Z' }];
for (let i = 0; i < max + 50; i++) s.touchedFiles.push({ path: `f${i}.md`, at: '2026-01-02T00:00:00.000Z' });
s.touchedFiles.push({ path: 'dup.md', at: '2026-01-03T00:00:00.000Z' });
state.saveState(proj, s);

say(() => cli.run(['node', 'ratchet', 'touch', 'new.md']));
let t = state.loadState(proj).touchedFiles;
assert.strictEqual(t.length, max, `an oversized trail collapses to the bound on the next touch — got ${t.length}`);
assert.strictEqual(t[t.length - 1].path, 'new.md', 'the newest touch is last');
const dups = t.filter((f) => f.path === 'dup.md');
assert.strictEqual(dups.length, 1, 'one row per path');
assert.strictEqual(dups[0].at, '2026-01-03T00:00:00.000Z', 'and it is the newest touch of that path');
assert.ok(!t.some((f) => f.path === 'f0.md'), 'the oldest paths fall off first');

say(() => cli.run(['node', 'ratchet', 'touch', 'new.md']));
t = state.loadState(proj).touchedFiles;
assert.strictEqual(t.filter((f) => f.path === 'new.md').length, 1, 'touching a path again moves it, never repeats it');

say(() => cli.run(['node', 'ratchet', 'state', 'append', 'touchedFiles', '{"path":"appended.md"}']));
t = state.loadState(proj).touchedFiles;
assert.strictEqual(t.length, max, 'state append is bounded by the same rule');
assert.strictEqual(t[t.length - 1].path, 'appended.md');
});
});

ok('score confidence leaves no write footprint for a propose-only agent', () => {
state.initProject(cwd, { force: true });
const s = state.loadState(cwd);
Expand Down
Loading