diff --git a/.github/workflows/proofline.yml b/.github/workflows/proofline.yml
index 741b115..8185258 100644
--- a/.github/workflows/proofline.yml
+++ b/.github/workflows/proofline.yml
@@ -16,6 +16,8 @@ jobs:
with:
node-version: 22
- run: git config --global user.email ci@proofline.local && 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) || '' }}
diff --git a/AGENTS.md b/AGENTS.md
index 5e07feb..183c076 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fca52c6..1fb9db7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index c14150e..a4a5d2b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,14 @@
+
+
+
+
+
+
+
# Proofline
+[](https://github.com/Optitransfer/proofline/actions/workflows/proofline.yml) [](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.
@@ -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
@@ -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/.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.
diff --git a/bin/proofline.mjs b/bin/proofline.mjs
index 8dc3301..483b62e 100755
--- a/bin/proofline.mjs
+++ b/bin/proofline.mjs
@@ -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'); };
@@ -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 Exit: 0 PASS, 1 FAIL, 2 BLOCKED, 3 usage`;
const GLOBAL_FLAGS = ['json', 'root'];
diff --git a/docs/logo-128.png b/docs/logo-128.png
new file mode 100644
index 0000000..3597e16
Binary files /dev/null and b/docs/logo-128.png differ
diff --git a/docs/logo-512.png b/docs/logo-512.png
new file mode 100644
index 0000000..ad2edd7
Binary files /dev/null and b/docs/logo-512.png differ
diff --git a/docs/logo-white.svg b/docs/logo-white.svg
new file mode 100644
index 0000000..ab1d54f
--- /dev/null
+++ b/docs/logo-white.svg
@@ -0,0 +1 @@
+
diff --git a/docs/logo.svg b/docs/logo.svg
new file mode 100644
index 0000000..4e0eb59
--- /dev/null
+++ b/docs/logo.svg
@@ -0,0 +1 @@
+
diff --git a/package-lock.json b/package-lock.json
index 9576c33..96df03d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,12 +12,34 @@
"proofline": "bin/proofline.mjs"
},
"devDependencies": {
+ "@optitransfer/agentkey": "0.1.1",
"hivevault": "0.3.3"
},
"engines": {
"node": ">=22"
}
},
+ "node_modules/@optitransfer/agentkey": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@optitransfer/agentkey/-/agentkey-0.1.1.tgz",
+ "integrity": "sha512-09+MOpbtsQULWj+GaTxV+WrF2M9QgQ49BEBWw7sJnMe/t8fgYkC2S4T89qOEwFjhKSFUb/Tj4i0BBFoGjpRpXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "agentkey": "bin/agentkey.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@sigstore/sign": "3.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@sigstore/sign": {
+ "optional": true
+ }
+ }
+ },
"node_modules/hivevault": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/hivevault/-/hivevault-0.3.3.tgz",
diff --git a/package.json b/package.json
index 014a076..936a85b 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -57,6 +57,7 @@
"mcp": "node bin/proofline.mjs mcp"
},
"devDependencies": {
+ "@optitransfer/agentkey": "0.1.1",
"hivevault": "0.3.3"
}
}
diff --git a/src/guided.mjs b/src/guided.mjs
index 0edf230..c8206ae 100644
--- a/src/guided.mjs
+++ b/src/guided.mjs
@@ -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);
}
@@ -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');
@@ -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 });
diff --git a/src/signature.mjs b/src/signature.mjs
index d493502..06f2ac5 100644
--- a/src/signature.mjs
+++ b/src/signature.mjs
@@ -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=, 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 },
diff --git a/test/agents_md_scan.test.mjs b/test/agents_md_scan.test.mjs
new file mode 100644
index 0000000..c76cd45
--- /dev/null
+++ b/test/agents_md_scan.test.mjs
@@ -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', 't@example.invalid');
+ 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), []);
+});
diff --git a/test/cli_unknown_flags.test.mjs b/test/cli_unknown_flags.test.mjs
index 72c9516..c3cf420 100644
--- a/test/cli_unknown_flags.test.mjs
+++ b/test/cli_unknown_flags.test.mjs
@@ -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';
diff --git a/test/contract_sig.test.mjs b/test/contract_sig.test.mjs
index 99c2746..7088f49 100644
--- a/test/contract_sig.test.mjs
+++ b/test/contract_sig.test.mjs
@@ -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-'));
diff --git a/test/hook_base_repin.test.mjs b/test/hook_base_repin.test.mjs
index 7c0709f..734678f 100644
--- a/test/hook_base_repin.test.mjs
+++ b/test/hook_base_repin.test.mjs
@@ -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';
diff --git a/test/hook_fresh_worktree.test.mjs b/test/hook_fresh_worktree.test.mjs
index 0ca54ec..cac6173 100644
--- a/test/hook_fresh_worktree.test.mjs
+++ b/test/hook_fresh_worktree.test.mjs
@@ -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';
diff --git a/test/hook_push_range.test.mjs b/test/hook_push_range.test.mjs
index 4620293..50bd74e 100644
--- a/test/hook_push_range.test.mjs
+++ b/test/hook_push_range.test.mjs
@@ -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';
diff --git a/test/hook_push_sha.test.mjs b/test/hook_push_sha.test.mjs
index 4dd7069..0b0135f 100644
--- a/test/hook_push_sha.test.mjs
+++ b/test/hook_push_sha.test.mjs
@@ -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';
diff --git a/test/setup_guidance.test.mjs b/test/setup_guidance.test.mjs
new file mode 100644
index 0000000..cb0711d
--- /dev/null
+++ b/test/setup_guidance.test.mjs
@@ -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=/);
+});
diff --git a/test/version.test.mjs b/test/version.test.mjs
new file mode 100644
index 0000000..dc7eaa8
--- /dev/null
+++ b/test/version.test.mjs
@@ -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/);
+});