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 + +

+ # 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. @@ -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/); +});