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: 2 additions & 0 deletions .github/workflows/proofline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
with:
node-version: 22
- run: git config --global user.email [email protected] && git config --global user.name ci
- name: Install the pinned dev dependencies (agentkey, hivevault) from the committed lockfile
run: npm ci --ignore-scripts
- name: Proofline self-gate (independent re-verification; hooks cannot be skipped here)
# A fresh checkout has no pinned base and, after a push, origin/main is HEAD: gate against the previous tip.
run: node bin/proofline.mjs gate --ci ${{ github.event_name == 'push' && format('--base {0}', github.event.before) || '' }}
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Proofline rejects: "done" without a commit, test names or measured numbers; numb

- Skip/only/todo tests, raise timeouts, delete tests or assertions, edit fixtures to pass (unless the fixture is the proven root cause, with proof).
- Lower a target, cap or test size, or touch thresholds/commands/criteria in `proofline.json` (detected; BLOCKED until the owner approves).
- `--no-verify`, bypass env vars, skip flags. There is no bypass. If CI runs `proofline gate --ci`, it re-runs the gate.
- Skip the git hooks (the no-verify flag on commit or push), set bypass env vars or pass skip flags. There is no bypass. If CI runs `proofline gate --ci`, it re-runs the gate.
- Say "done" for anything that is not PASS on the pushed HEAD.
- Defer. Only the owner approves a deferral.

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`).

## Unreleased

### Added
- `proofline --version` (and `-v`) prints the installed version and exits 0. On a subcommand `--version` is still an unknown flag.
- The README and the package carry the Proofline mark (`docs/logo.svg`, `docs/logo-white.svg`, PNGs at 512 and 128).

### Fixed
- `proofline setup` no longer ends in "Next step: proofline setup" when `AGENTKEY_HOME` is unset or `agentkey` is not on PATH. Each refusal says what the variable or the command is for and prints the exact command that provides it (`export AGENTKEY_HOME=...`, `npm install --save-dev @optitransfer/agentkey`). `proofline contract sign` does the same. The README documents both before the short path.
- The `AGENTS.md` the README tells you to copy no longer trips the `no_verify` rule of a fresh contract on the first `verify`: its "Never" list names the hook-skipping flag in words.
- The owner-signature test ran agentkey from a path on one laptop. It now runs the `@optitransfer/agentkey` devDependency, and CI installs the lockfile with `npm ci --ignore-scripts` before the self-gate.
- The repository moved to `github.com/Optitransfer/proofline`; `homepage`, `repository` and `bugs` in package.json point there. The old URL redirects.

## 0.2.0-next.1 (2026-10-17)

### Added
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-white.svg">
<img src="docs/logo.svg" alt="Proofline" width="96" height="96">
</picture>
</p>

# Proofline

[![proofline](https://github.com/Optitransfer/proofline/actions/workflows/proofline.yml/badge.svg)](https://github.com/Optitransfer/proofline/actions/workflows/proofline.yml) [![npm](https://img.shields.io/npm/v/@optitransfer/proofline/next?label=npm%40next)](https://www.npmjs.com/package/@optitransfer/proofline)

**The agent says done. Proofline verifies and validates the work against a V&V contract, to assure agent performance.**

A coding agent reports that a task is finished. Proofline does not take the report: it holds the definition of done as a contract a machine can check, hands the agent one task at a time, re-runs every check itself when the agent reports, and lets a commit through only when the evidence says PASS. The agent gets a corrective prompt that names the file and the line to fix next, never a verdict it can argue with. Zero dependencies, Node 22+, Apache-2.0.
Expand Down Expand Up @@ -116,7 +125,7 @@ PROOFLINE CORRECTIVE INSTRUCTION for TASK-1 (Reject an empty body): gate FAIL
DECISION: FAIL
```

Exit codes: `0` PASS, `1` FAIL, `2` BLOCKED, `3` usage. Every command takes `--json`. The published package is `@optitransfer/proofline` at `0.2.0-next.0` on the `next` dist-tag: `npx @optitransfer/proofline@next`.
Exit codes: `0` PASS, `1` FAIL, `2` BLOCKED, `3` usage. Every command takes `--json`. The published package is `@optitransfer/proofline` at `0.2.0-next.1` on the `next` dist-tag: `npx @optitransfer/proofline@next`. `npx proofline --version` prints the version you have.

## How you use it, start to finish

Expand All @@ -126,6 +135,17 @@ Exit codes: `0` PASS, `1` FAIL, `2` BLOCKED, `3` usage. Every command takes `--j

That is the short path: four commands, in that order. The diagram and the table below are the manual path.

`proofline setup` needs two things from your shell, and stops before it writes anything when either is missing, naming the command that provides it:

- `AGENTKEY_HOME`: the directory that holds your owner key. Proofline never picks one for you. For example `export AGENTKEY_HOME="$HOME/.proofline-owner"`, in your shell profile, because `proofline contract sign` reads it too.
- `agentkey` on PATH: the owner key is made with [agentkey](https://github.com/mgillr/agentkey). `npm install --save-dev @optitransfer/agentkey` and run Proofline through `npx`, or `npm install -g @optitransfer/agentkey`.

```bash
export AGENTKEY_HOME="$HOME/.proofline-owner"
npm install --save-dev @optitransfer/proofline@next @optitransfer/agentkey
npx proofline setup
```

Once `guard/owner.pub` is pinned, `proofline contract sign` writes `proofline.json.sig` and `proofline contract verify-sig` checks it. `proofline contract propose` writes `proposals/<sha256>.diff` and prints the hash. An unsigned or altered contract is refused with `contract not owner-signed`.

Three parties: **you** (the owner of the repository), **your agent** (Claude Code, Cursor, a CI job), and **Proofline** (the hooks and the gate in your repository). The agent builds; Proofline checks; you decide twice, once on the rules and once on the result.
Expand Down
7 changes: 7 additions & 0 deletions bin/proofline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ for (let i = 0; i < argv.length; i++) {
if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) flags[k] = v; else if (!SWITCHES.includes(k) && argv[i + 1] && !argv[i + 1].startsWith('--')) flags[k] = argv[++i]; else flags[k] = true; }
else pos.push(a);
}
// --version (or -v) on its own prints the installed package version and exits 0, before any repository is read.
if ((pos.length === 0 && flags.version === true && Object.keys(flags).length === 1) || (argv.length === 1 && argv[0] === '-v')) {
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
process.stdout.write(`${pkg.version}\n`);
process.exit(0);
}
const root = flags.root ? flags.root : P.findRoot();
const json = !!flags.json;
const print = (obj, text) => { process.stdout.write((json || text === undefined ? JSON.stringify(obj, null, 2) : text) + '\n'); };
Expand Down Expand Up @@ -73,6 +79,7 @@ const HELP = `proofline: independent V&V and delivery controller
hook stop read a Stop hook payload on stdin; one receipt for new commits, or silence
connect claude-code merge a Stop hook into ~/.claude/settings.json, after any hook already there
mcp MCP server on stdio
--version print the installed version
Global: --json --root <dir> Exit: 0 PASS, 1 FAIL, 2 BLOCKED, 3 usage`;

const GLOBAL_FLAGS = ['json', 'root'];
Expand Down
Binary file added docs/logo-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/logo-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/logo-white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions package-lock.json

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

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
"nodejs",
"cli"
],
"homepage": "https://github.com/mgillr/proofline",
"homepage": "https://github.com/Optitransfer/proofline",
"repository": {
"type": "git",
"url": "git+https://github.com/mgillr/proofline.git"
"url": "git+https://github.com/Optitransfer/proofline.git"
},
"bugs": {
"url": "https://github.com/mgillr/proofline/issues"
"url": "https://github.com/Optitransfer/proofline/issues"
},
"author": "mgillr",
"license": "Apache-2.0",
Expand All @@ -57,6 +57,7 @@
"mcp": "node bin/proofline.mjs mcp"
},
"devDependencies": {
"@optitransfer/agentkey": "0.1.1",
"hivevault": "0.3.3"
}
}
18 changes: 14 additions & 4 deletions src/guided.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ const WATCH_INTERVAL_MS = 1000;

const refuse = (why, next) => new Error(`${why}\nNext step: ${next}`);

// setup needs two things from the shell. Each refusal says what it is for and the exact commands that provide it.
const noKeyHome = () => refuse(
'AGENTKEY_HOME is not set, so no key directory was opened. It names the directory that holds your owner key; proofline never picks one for you',
'export AGENTKEY_HOME="$HOME/.proofline-owner" (add it to your shell profile, contract sign reads it too), then run proofline setup again',
);
const noAgentkey = () => refuse(
'agentkey is not on PATH. proofline setup creates the owner key with agentkey (@optitransfer/agentkey)',
'npm install --save-dev @optitransfer/agentkey and run npx proofline setup, or npm install -g @optitransfer/agentkey, then run proofline setup again',
);

function pause(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
Expand Down Expand Up @@ -50,9 +60,9 @@ function hooksReady(root) {

function createOwnerKey(env) {
const home = env.AGENTKEY_HOME;
if (typeof home !== 'string' || !home.trim()) throw refuse('AGENTKEY_HOME is not set, so no key directory was opened', 'proofline setup');
if (typeof home !== 'string' || !home.trim()) throw noKeyHome();
const bin = agentkeyBin(env);
if (!bin) throw refuse('agentkey is not on PATH', 'proofline setup');
if (!bin) throw noAgentkey();
const r = spawnSync(bin, ['init', 'owner'], { encoding: 'utf8', env: { ...env, AGENTKEY_HOME: home } });
if (r.error || r.status !== 0) throw refuse('agentkey init owner failed', 'proofline setup');
const key = path.join(home, 'keys', 'owner.key');
Expand Down Expand Up @@ -111,8 +121,8 @@ export function setup(root, { agent = 'none', env = process.env } = {}) {
if (agent !== 'none' && agent !== 'claude-code') throw refuse(`unknown agent ${agent}`, 'proofline setup --agent none');
if (!git(['rev-parse', '--show-toplevel'], root).ok) throw refuse('not a git repository', 'git init');
const approvals = path.resolve(root, '.proofline', 'approvals');
if (typeof env.AGENTKEY_HOME !== 'string' || !env.AGENTKEY_HOME.trim()) throw refuse('AGENTKEY_HOME is not set, so no key directory was opened', 'proofline setup');
if (!agentkeyBin(env)) throw refuse('agentkey is not on PATH', 'proofline setup');
if (typeof env.AGENTKEY_HOME !== 'string' || !env.AGENTKEY_HOME.trim()) throw noKeyHome();
if (!agentkeyBin(env)) throw noAgentkey();
checkOwnerPin(root, env);
try {
init(root, { hooks: true, approvals });
Expand Down
4 changes: 2 additions & 2 deletions src/signature.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ export function verifyContractSignature(root) {
export function signContract(root, env = process.env) {
const home = env.AGENTKEY_HOME;
if (typeof home !== 'string' || !home.trim()) {
throw new Error('AGENTKEY_HOME is not set, so no key directory was opened\nNext step: proofline contract sign');
throw new Error('AGENTKEY_HOME is not set, so no key directory was opened. It names the directory that holds the owner key proofline setup created\nNext step: export AGENTKEY_HOME=<that directory>, then run proofline contract sign again');
}
const bin = agentkeyBin(env);
if (!bin) throw new Error('agentkey is not on PATH\nNext step: proofline contract sign');
if (!bin) throw new Error('agentkey is not on PATH. The owner signature is made with agentkey (@optitransfer/agentkey)\nNext step: npm install --save-dev @optitransfer/agentkey and run npx proofline contract sign, or npm install -g @optitransfer/agentkey, then run proofline contract sign again');
const hash = canonicalHash(root);
const r = spawnSync(bin, ['sign', 'owner', '--type', PAYLOAD_TYPE], {
input: hash, encoding: 'utf8', env: { ...env, AGENTKEY_HOME: home },
Expand Down
25 changes: 25 additions & 0 deletions test/agents_md_scan.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// The AGENTS.md the README tells users to copy must pass the forbidden scan of a freshly initialised contract.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as P from '../src/index.mjs';
import { scanForbidden } from '../src/forbidden.mjs';

test('copying the shipped AGENTS.md into a repository trips no forbidden rule', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-agents-'));
const git = (...a) => execFileSync('git', a, { cwd: root, encoding: 'utf8' }).trim();
git('init', '-q', '-b', 'main');
git('config', 'user.email', '[email protected]');
git('config', 'user.name', 't');
fs.writeFileSync(path.join(root, 'README.md'), 'x\n');
git('add', '-A');
git('commit', '-q', '-m', 'base');
const base = git('rev-parse', 'HEAD');
fs.copyFileSync(path.resolve(import.meta.dirname, '..', 'AGENTS.md'), path.join(root, 'AGENTS.md'));
git('add', '-A');
const rules = P.C.skeleton('t').forbidden;
assert.deepEqual(scanForbidden(root, base, rules), []);
});
1 change: 1 addition & 0 deletions test/cli_unknown_flags.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Regression for #33: every subcommand refuses a flag it does not accept.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
Expand Down
3 changes: 2 additions & 1 deletion test/contract_sig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import assert from 'node:assert/strict';
import * as P from '../src/index.mjs';
import { repo, commitBeforeBase, BIN } from './helpers.mjs';

const AGENTKEY = '/Users/imac27/programme-b/agentkey/bin/agentkey.mjs';
// The real agentkey CLI from the @optitransfer/agentkey devDependency, so this test runs in any clone and in CI.
const AGENTKEY = path.resolve(import.meta.dirname, '../node_modules/@optitransfer/agentkey/bin/agentkey.mjs');

function agentEnv() {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-home-'));
Expand Down
1 change: 1 addition & 0 deletions test/hook_base_repin.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Regression for #22: a later start moves the pinned base.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
Expand Down
1 change: 1 addition & 0 deletions test/hook_fresh_worktree.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Regression for #23: a push from a worktree with no task evidence is still gated.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
Expand Down
1 change: 1 addition & 0 deletions test/hook_push_range.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Regressions for #31 (a rebased branch re-gated tasks already on main) and #35 (a forged remote-tracking ref skipped the gate).
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
Expand Down
1 change: 1 addition & 0 deletions test/hook_push_sha.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Regression for #21: the pre-push hook gates the commit being pushed, not the checked-out HEAD.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
Expand Down
59 changes: 59 additions & 0 deletions test/setup_guidance.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// proofline setup and contract sign name the exact commands that provide what they need, never just "run setup again".
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import test from 'node:test';
import assert from 'node:assert/strict';
import { BIN } from './helpers.mjs';

function emptyRepo() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-guide-'));
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: root });
return root;
}

// A PATH that holds git and nothing named agentkey, whatever the machine has installed.
function pathWithoutAgentkey() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-path-'));
const git = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
fs.symlinkSync(git, path.join(dir, 'git'));
return dir;
}

function run(args, env) {
return spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', env });
}

test('setup without AGENTKEY_HOME says what it is for and how to set it', () => {
const root = emptyRepo();
const env = { ...process.env };
delete env.AGENTKEY_HOME;
const r = run(['setup', '--root', root], env);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /AGENTKEY_HOME is not set/);
assert.match(r.stderr, /holds your owner key/);
assert.match(r.stderr, /Next step: export AGENTKEY_HOME=/);
assert.equal(fs.existsSync(path.join(root, 'proofline.json')), false);
});

test('setup without agentkey on PATH names the package and the install command', () => {
const root = emptyRepo();
const keyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-ak-'));
const env = { ...process.env, AGENTKEY_HOME: keyHome, PATH: pathWithoutAgentkey() };
const r = run(['setup', '--root', root], env);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /agentkey is not on PATH/);
assert.match(r.stderr, /npm install --save-dev @optitransfer\/agentkey/);
assert.equal(fs.existsSync(path.join(root, 'proofline.json')), false);
});

test('contract sign without AGENTKEY_HOME names the variable to export', () => {
const root = emptyRepo();
const env = { ...process.env };
delete env.AGENTKEY_HOME;
const r = run(['contract', 'sign', '--root', root], env);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /AGENTKEY_HOME is not set/);
assert.match(r.stderr, /Next step: export AGENTKEY_HOME=/);
});
25 changes: 25 additions & 0 deletions test/version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// proofline --version prints the package version, so a user can say which build they run.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import assert from 'node:assert/strict';
import { BIN } from './helpers.mjs';

const VERSION = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, '..', 'package.json'), 'utf8')).version;

test('--version and -v print the package version and exit 0, outside any repository', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-ver-'));
for (const flag of ['--version', '-v']) {
const r = spawnSync(process.execPath, [BIN, flag], { encoding: 'utf8', cwd });
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, `${VERSION}\n`);
}
});

test('--version on a subcommand is still an unknown flag', () => {
const r = spawnSync(process.execPath, [BIN, 'status', '--version'], { encoding: 'utf8' });
assert.equal(r.status, 2);
assert.match(r.stderr, /unknown flag --version/);
});
Loading