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
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:

Expand All @@ -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 <path> --pinmind-run <run-id>` 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 <path> --pinmind-run <run-id>` 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.

Expand Down
4 changes: 3 additions & 1 deletion bin/skillstate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand All @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions skills/skillstate-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Do not replay an interrupted action automatically. Inspect the working tree and
```bash
skillstate checkpoint --workspace <path> --pinmind-run <run-id>
skillstate checkpoint-show --workspace <path>
skillstate checkpoint-verify --workspace <path>
skillstate audit-verify --workspace <path>
```

Expand Down
55 changes: 46 additions & 9 deletions src/checkpoint.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/util.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 3 additions & 6 deletions test/checkpoint.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -14,15 +13,13 @@ 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);
return { root, workspace, runId, run };
}

test.after(async () => {
delete process.env.XDG_STATE_HOME;
await Promise.all(cleanups.map(root => rm(root, { recursive: true, force: true })));
});

Expand All @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions test/packaging-hook.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion test/skillstate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }));

Expand Down
Loading