From 8dd7442806d18e7d49f2c908aafaea323648f8d7 Mon Sep 17 00:00:00 2001 From: iammedved <271581472+iammedved@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:39:13 +0300 Subject: [PATCH] fix: keep passive checkpoints inside the workspace --- .codex-plugin/plugin.json | 2 +- README.md | 8 ++--- bin/skillstate.mjs | 4 ++- package-lock.json | 4 +-- package.json | 2 +- skills/skillstate-runtime/SKILL.md | 1 + src/checkpoint.mjs | 55 +++++++++++++++++++++++++----- src/util.mjs | 2 +- test/checkpoint.test.mjs | 9 ++--- test/packaging-hook.test.mjs | 4 +-- test/skillstate.test.mjs | 2 +- 11 files changed, 65 insertions(+), 28 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3bd276b..cb8b2aa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-skillstate", - "version": "0.2.1-experimental", + "version": "0.2.2-experimental", "description": "Passive bounded checkpoints for long tasks controlled by Pinmind.", "author": { "name": "Codex Skillstate" diff --git a/README.md b/README.md index 355a942..200c0e4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It is not a claim that Codex App history is erased or that token use is reduced. After the public repository and tag exist: ```bash -codex plugin marketplace add iammedved/codex-skillstate --ref v0.2.1-experimental +codex plugin marketplace add iammedved/codex-skillstate --ref v0.2.2-experimental ``` In Codex, install **Codex Skillstate** together with Pinmind, restart Codex App, then start a new chat. Skillstate has no autonomous prompt hook and cannot implicitly become a second controller. Pinmind decides when a task needs durable state and calls `skillstate checkpoint` at clean phase boundaries. @@ -23,7 +23,7 @@ node scripts/install-personal.mjs --runtime-only skillstate --help ``` -For Codex App plugin users, `--runtime-only` is the correct global installation: it copies the release to `~/.local/share/codex-skillstate/0.2.1-experimental` and creates `~/.local/bin/skillstate`, without creating a second personal copy of the Skill. The installer recognizes any prior managed Skillstate marker, stages the new runtime before switching it in, and keeps replaced managed copies as timestamped backups. +For Codex App plugin users, `--runtime-only` is the correct global installation: it copies the release to `~/.local/share/codex-skillstate/0.2.2-experimental` and creates `~/.local/bin/skillstate`, without creating a second personal copy of the Skill. The installer recognizes any prior managed Skillstate marker, stages the new runtime before switching it in, and keeps replaced managed copies as timestamped backups. Use `--with-skill` only when using the CLI without the Codex plugin: @@ -41,13 +41,13 @@ To remove a legacy personal Skill installed with `--with-skill`, add `--with-ski ## When to use it -Use `skillstate checkpoint --workspace --pinmind-run ` when Pinmind selects durable state. Repeating an unchanged checkpoint is idempotent. `skillstate checkpoint-show` and `skillstate audit-verify` inspect the latest record and its audit chain. The legacy `hybrid` and `strict` controller modes are manual only and must not run beneath Pinmind. +Use `skillstate checkpoint --workspace --pinmind-run ` when Pinmind selects durable state. Repeating an unchanged checkpoint is idempotent. `skillstate checkpoint-show` and `skillstate checkpoint-verify` inspect the latest record and its audit chain. Checkpoint data stays under the workspace's ignored `.pinmind/skillstate/` directory, so ordinary workspace-write sandboxes need no home-directory permission. The legacy `hybrid` and `strict` controller modes are manual only and must not run beneath Pinmind. Skillstate is not an authorization mechanism. Version 0.2.x always refuses push, PR creation or merge, deploy, publish, messages, payments, production changes, and credential changes. An authorized external effect must happen as a separate reviewed step. An interrupted action is quarantined; inspect the working tree and confirm recovery before proceeding. ## Scope and caveats -This `0.2.1-experimental` release uses only Node.js standard-library code. Its 51 automated tests exercise passive Pinmind checkpoints, no-progress protection, the internal protocol, packaging metadata, installer upgrades, and fake-Codex integration. A strict read-only smoke test previously passed on Codex CLI `0.147.0`; other host builds should repeat that smoke test before relying on the legacy controller for operational work. +This `0.2.2-experimental` release uses only Node.js standard-library code. Its 51 automated tests exercise workspace-local passive Pinmind checkpoints, no-progress protection, the internal protocol, packaging metadata, installer upgrades, and fake-Codex integration. A strict read-only smoke test previously passed on Codex CLI `0.147.0`; other host builds should repeat that smoke test before relying on the legacy controller for operational work. Paused `0.1` runs are not migrated to protocol `0.2`; finish or recover them with the preserved `0.1.0-experimental` runtime before starting a new `0.2` run. diff --git a/bin/skillstate.mjs b/bin/skillstate.mjs index 77290fc..46ccf9f 100755 --- a/bin/skillstate.mjs +++ b/bin/skillstate.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { VERSION } from '../src/util.mjs'; import { verifyAudit } from '../src/audit.mjs'; -import { checkpointPinmind, showPinmindCheckpoint } from '../src/checkpoint.mjs'; +import { checkpointPinmind, showPinmindCheckpoint, verifyPinmindCheckpoint } from '../src/checkpoint.mjs'; import { doctor, loadState, recover, run } from '../src/runtime.mjs'; const usage = `skillstate ${VERSION} @@ -12,6 +12,7 @@ Usage: skillstate doctor [--workspace PATH] [--controller codex|pinmind] [--pinmind-path PATH] skillstate checkpoint --workspace PATH --pinmind-run RUN_ID skillstate checkpoint-show --workspace PATH + skillstate checkpoint-verify --workspace PATH skillstate run --workspace PATH (--task TEXT | --task-file FILE | --resume) [--mode strict|hybrid] [--sandbox read-only|workspace-write] [--controller codex|pinmind] [--pinmind-path PATH] [--max-steps N] skillstate show --workspace PATH skillstate audit-verify --workspace PATH @@ -38,6 +39,7 @@ export async function main(argv = process.argv.slice(2)) { if (command === 'doctor') return doctor({ workspace, controller: options.controller, pinmindPath: options.pinmindPath }); if (command === 'checkpoint') return checkpointPinmind(workspace, options.pinmindRun); if (command === 'checkpoint-show') return showPinmindCheckpoint(workspace); + if (command === 'checkpoint-verify') return verifyPinmindCheckpoint(workspace); if (command === 'show') return loadState(workspace); if (command === 'audit-verify') return verifyAudit(workspace); if (command === 'recover') return recover(workspace, Boolean(options.confirm)); diff --git a/package-lock.json b/package-lock.json index 82587cd..0b7dd75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-skillstate", - "version": "0.2.1-experimental", + "version": "0.2.2-experimental", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-skillstate", - "version": "0.2.1-experimental", + "version": "0.2.2-experimental", "license": "MIT", "bin": { "skillstate": "bin/skillstate.mjs" diff --git a/package.json b/package.json index 2fa02a9..8fbfe4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-skillstate", - "version": "0.2.1-experimental", + "version": "0.2.2-experimental", "description": "A bounded, stateless controller loop for Codex CLI.", "type": "module", "bin": { diff --git a/skills/skillstate-runtime/SKILL.md b/skills/skillstate-runtime/SKILL.md index 887abf8..ce6f12b 100644 --- a/skills/skillstate-runtime/SKILL.md +++ b/skills/skillstate-runtime/SKILL.md @@ -25,6 +25,7 @@ Do not replay an interrupted action automatically. Inspect the working tree and ```bash skillstate checkpoint --workspace --pinmind-run skillstate checkpoint-show --workspace +skillstate checkpoint-verify --workspace skillstate audit-verify --workspace ``` diff --git a/src/checkpoint.mjs b/src/checkpoint.mjs index aeef30c..deb32ca 100644 --- a/src/checkpoint.mjs +++ b/src/checkpoint.mjs @@ -1,15 +1,51 @@ -import { lstat, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; +import { appendFile, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import { appendAudit, runDir } from './audit.mjs'; -import { acquireWorkspaceLock, releaseWorkspaceLock } from './runtime.mjs'; -import { sha256 } from './util.mjs'; +import { sha256, stableJson } from './util.mjs'; const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const MAX_STATE_BYTES = 128 * 1024; -export const checkpointPath = workspace => path.join(runDir(workspace), 'pinmind-checkpoint.json'); +const checkpointDir = workspace => path.join(workspace, '.pinmind', 'skillstate'); +export const checkpointPath = workspace => path.join(checkpointDir(workspace), 'checkpoint.json'); +export const checkpointAuditPath = workspace => path.join(checkpointDir(workspace), 'audit.jsonl'); + +async function withCheckpointLock(workspace, work) { + const directory = checkpointDir(workspace); + await mkdir(directory, { recursive: true }); + const [root, resolved, stat] = await Promise.all([realpath(workspace), realpath(directory), lstat(directory)]); + if (!stat.isDirectory() || path.relative(root, resolved).startsWith('..')) throw new Error('Pinmind Skillstate directory escapes the workspace'); + const lock = path.join(resolved, 'checkpoint.lock'); + let handle; + try { handle = await open(lock, 'wx', 0o600); } + catch (error) { if (error.code === 'EEXIST') throw new Error('Pinmind checkpoint is busy'); throw error; } + try { return await work(); } + finally { await handle.close(); await unlink(lock).catch(error => { if (error.code !== 'ENOENT') throw error; }); } +} + +async function readCheckpointAudit(workspace) { + let lines; + try { lines = (await readFile(checkpointAuditPath(workspace), 'utf8')).trim().split('\n').filter(Boolean); } + catch (error) { if (error.code === 'ENOENT') return { ok: true, entries: 0, hash: '0'.repeat(64) }; throw error; } + let previousHash = '0'.repeat(64); + for (let index = 0; index < lines.length; index++) { + let entry; + try { entry = JSON.parse(lines[index]); } catch { return { ok: false, index, error: 'invalid JSONL' }; } + const { hash, ...unsigned } = entry; + if (entry.previousHash !== previousHash || hash !== sha256(stableJson(unsigned))) return { ok: false, index, error: 'hash-chain mismatch' }; + previousHash = hash; + } + return { ok: true, entries: lines.length, hash: previousHash }; +} + +async function appendCheckpointAudit(workspace, data) { + const current = await readCheckpointAudit(workspace); + if (!current.ok) throw new Error(`Pinmind checkpoint audit is invalid at entry ${current.index}`); + const entry = { at: new Date().toISOString(), event: 'pinmind_checkpoint', data, previousHash: current.hash }; + entry.hash = sha256(stableJson(entry)); + await appendFile(checkpointAuditPath(workspace), `${JSON.stringify(entry)}\n`, { mode: 0o600 }); +} async function readContainedFile(workspace, relative, { optional = false } = {}) { const file = path.join(workspace, relative); @@ -37,8 +73,7 @@ async function readLatest(workspace) { export async function checkpointPinmind(workspace, runId) { if (!RUN_ID.test(runId ?? '')) throw new Error('--pinmind-run must be a safe run id'); - const lock = await acquireWorkspaceLock(workspace); - try { + return withCheckpointLock(workspace, async () => { const relativeState = path.join('.pinmind', 'runs', runId, 'state.json'); const [stateFile, activeFile] = await Promise.all([ readContainedFile(workspace, relativeState), @@ -67,11 +102,13 @@ export async function checkpointPinmind(workspace, runId) { recordedAt: new Date().toISOString(), }; await atomicWrite(checkpointPath(workspace), checkpoint); - await appendAudit(workspace, 'pinmind_checkpoint', { runId, phase: checkpoint.phase, stateSha256: source.stateSha256 }); + await appendCheckpointAudit(workspace, { runId, phase: checkpoint.phase, stateSha256: source.stateSha256 }); return { ...checkpoint, unchanged: false }; - } finally { await releaseWorkspaceLock(lock); } + }); } +export const verifyPinmindCheckpoint = workspace => readCheckpointAudit(workspace); + export async function showPinmindCheckpoint(workspace) { const checkpoint = await readLatest(workspace); if (!checkpoint) throw new Error('no Pinmind checkpoint exists for this workspace'); diff --git a/src/util.mjs b/src/util.mjs index ccfe0a0..e6d4f90 100644 --- a/src/util.mjs +++ b/src/util.mjs @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { lstat, realpath } from 'node:fs/promises'; import path from 'node:path'; -export const VERSION = '0.2.1-experimental'; +export const VERSION = '0.2.2-experimental'; export const sha256 = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : stableJson(value)).digest('hex'); export const stableJson = value => JSON.stringify(sort(value)); export const runId = () => randomUUID(); diff --git a/test/checkpoint.test.mjs b/test/checkpoint.test.mjs index c1d3535..45faa5a 100644 --- a/test/checkpoint.test.mjs +++ b/test/checkpoint.test.mjs @@ -4,8 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { auditPath, verifyAudit } from '../src/audit.mjs'; -import { checkpointPinmind, showPinmindCheckpoint } from '../src/checkpoint.mjs'; +import { checkpointAuditPath, checkpointPinmind, showPinmindCheckpoint, verifyPinmindCheckpoint } from '../src/checkpoint.mjs'; const cleanups = []; @@ -14,7 +13,6 @@ async function fixture(runId = 'run-one') { const workspace = path.join(root, 'workspace'); const run = path.join(workspace, '.pinmind', 'runs', runId); await mkdir(run, { recursive: true }); - process.env.XDG_STATE_HOME = path.join(root, 'state'); await writeFile(path.join(workspace, '.pinmind', 'active.json'), JSON.stringify({ runId })); await writeFile(path.join(run, 'state.json'), JSON.stringify({ runId, status: 'active', phase: 'execute', currentContractVersion: 1, updatedAt: '2026-09-01T00:00:00.000Z' })); cleanups.push(root); @@ -22,7 +20,6 @@ async function fixture(runId = 'run-one') { } test.after(async () => { - delete process.env.XDG_STATE_HOME; await Promise.all(cleanups.map(root => rm(root, { recursive: true, force: true }))); }); @@ -35,8 +32,8 @@ test('Pinmind checkpoint records bounded metadata and is idempotent', async () = assert.equal(first.unchanged, false); const second = await checkpointPinmind(workspace, runId); assert.equal(second.unchanged, true); - assert.equal((await readFile(auditPath(workspace), 'utf8')).trim().split('\n').length, 1); - assert.equal((await verifyAudit(workspace)).ok, true); + assert.equal((await readFile(checkpointAuditPath(workspace), 'utf8')).trim().split('\n').length, 1); + assert.equal((await verifyPinmindCheckpoint(workspace)).ok, true); await writeFile(path.join(run, 'state.json'), JSON.stringify({ runId, status: 'active', phase: 'verify', currentContractVersion: 1, updatedAt: '2026-09-01T00:01:00.000Z' })); assert.equal((await checkpointPinmind(workspace, runId)).phase, 'verify'); assert.equal((await showPinmindCheckpoint(workspace)).phase, 'verify'); diff --git a/test/packaging-hook.test.mjs b/test/packaging-hook.test.mjs index b7eebc1..1ce5404 100644 --- a/test/packaging-hook.test.mjs +++ b/test/packaging-hook.test.mjs @@ -12,10 +12,10 @@ const execFileAsync = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const install = path.join(root, 'scripts', 'install-personal.mjs'); -test('release metadata uses one 0.2.1-experimental version', async () => { +test('release metadata uses one 0.2.2-experimental version', async () => { const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const plugin = JSON.parse(await readFile(path.join(root, '.codex-plugin', 'plugin.json'), 'utf8')); - assert.equal(VERSION, '0.2.1-experimental'); + assert.equal(VERSION, '0.2.2-experimental'); assert.equal(packageJson.version, VERSION); assert.equal(plugin.version, VERSION); }); diff --git a/test/skillstate.test.mjs b/test/skillstate.test.mjs index 2cfc1ff..f39c632 100644 --- a/test/skillstate.test.mjs +++ b/test/skillstate.test.mjs @@ -25,7 +25,7 @@ async function fixture() { } test.after(async () => { delete process.env.XDG_STATE_HOME; delete process.env.SKILLSTATE_FAKE_LOG; delete process.env.SKILLSTATE_FAKE_MODE; await Promise.all(cleanups.map(item => rm(item, { recursive: true, force: true }))); }); -test('01 exposes the experimental version', () => assert.equal(VERSION, '0.2.1-experimental')); +test('01 exposes the experimental version', () => assert.equal(VERSION, '0.2.2-experimental')); test('02 parses CLI flags', () => assert.deepEqual(parseArgs(['run', '--mode', 'strict', '--resume']), { command: 'run', options: { mode: 'strict', resume: true } }));