@optitransfer/proofline 0.2.0-next.0 → 0.2.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/README.md +95 -2
- package/bin/proofline.mjs +206 -11
- package/docs/receipt.md +25 -0
- package/package.json +4 -1
- package/src/checks.mjs +12 -7
- package/src/contract.mjs +242 -1
- package/src/engine.mjs +77 -28
- package/src/guided.mjs +240 -0
- package/src/hook-stop.mjs +105 -0
- package/src/index.mjs +243 -15
- package/src/infer.mjs +173 -0
- package/src/inference.json +20 -0
- package/src/isolate.mjs +123 -0
- package/src/mcp.mjs +20 -5
- package/src/receipt.mjs +17 -0
- package/src/signature.mjs +110 -0
- package/src/vault.mjs +109 -0
- package/src/verify-receipt.mjs +109 -0
package/src/guided.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Guided flow: setup, task, contract approve, watch. Each refusal names the next command.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import * as C from './contract.mjs';
|
|
7
|
+
import * as E from './engine.mjs';
|
|
8
|
+
import { connectClaudeCode } from './hook-stop.mjs';
|
|
9
|
+
import { init, installHooks, approvalsDir, activeTask } from './index.mjs';
|
|
10
|
+
import { git, sha256 } from './util.mjs';
|
|
11
|
+
|
|
12
|
+
const TASK_SCOPE = 'src/**,bin/**,test/**,docs/**,README.md,CHANGELOG.md,package.json';
|
|
13
|
+
// A few seconds: one failing sample is visible, then the loop ends if the gate is still not PASS.
|
|
14
|
+
const WATCH_BOUND_MS = 6000;
|
|
15
|
+
const WATCH_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
const refuse = (why, next) => new Error(`${why}\nNext step: ${next}`);
|
|
18
|
+
|
|
19
|
+
function pause(ms) {
|
|
20
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** agentkey on PATH. AGENTKEY_HOME is required so the default key directory is never opened. */
|
|
24
|
+
function agentkeyBin(env) {
|
|
25
|
+
for (const dir of String(env.PATH || '').split(path.delimiter)) {
|
|
26
|
+
if (!dir) continue;
|
|
27
|
+
const f = path.join(dir, 'agentkey');
|
|
28
|
+
try {
|
|
29
|
+
const st = fs.statSync(f);
|
|
30
|
+
if (st.isFile() && (st.mode & 0o111)) return f;
|
|
31
|
+
} catch { /* keep looking */ }
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hooksReady(root) {
|
|
37
|
+
const hd = git(['rev-parse', '--git-path', 'hooks'], root);
|
|
38
|
+
if (!hd.ok) return false;
|
|
39
|
+
const dir = path.resolve(root, hd.out);
|
|
40
|
+
for (const name of ['pre-commit', 'pre-push']) {
|
|
41
|
+
try {
|
|
42
|
+
const f = path.join(dir, name);
|
|
43
|
+
const st = fs.statSync(f);
|
|
44
|
+
if (!st.isFile() || (st.mode & 0o111) === 0) return false;
|
|
45
|
+
if (!fs.readFileSync(f, 'utf8').includes('# installed by proofline')) return false;
|
|
46
|
+
} catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createOwnerKey(env) {
|
|
52
|
+
const home = env.AGENTKEY_HOME;
|
|
53
|
+
if (typeof home !== 'string' || !home.trim()) throw refuse('AGENTKEY_HOME is not set, so no key directory was opened', 'proofline setup');
|
|
54
|
+
const bin = agentkeyBin(env);
|
|
55
|
+
if (!bin) throw refuse('agentkey is not on PATH', 'proofline setup');
|
|
56
|
+
const r = spawnSync(bin, ['init', 'owner'], { encoding: 'utf8', env: { ...env, AGENTKEY_HOME: home } });
|
|
57
|
+
if (r.error || r.status !== 0) throw refuse('agentkey init owner failed', 'proofline setup');
|
|
58
|
+
const key = path.join(home, 'keys', 'owner.key');
|
|
59
|
+
let st = null;
|
|
60
|
+
try { st = fs.lstatSync(key); } catch { st = null; }
|
|
61
|
+
if (!st || !st.isFile() || st.isSymbolicLink()) throw refuse('the owner key was not created', 'proofline setup');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ownerPaths(root, env) {
|
|
65
|
+
return { pub: path.join(env.AGENTKEY_HOME, 'keys', 'owner.pub'), pin: path.resolve(root, 'guard', 'owner.pub') };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pinned bytes, or null when there is no pin. A pin that is not a regular file is refused. */
|
|
69
|
+
function readPin(pin) {
|
|
70
|
+
let st;
|
|
71
|
+
try { st = fs.lstatSync(pin); } catch (e) {
|
|
72
|
+
if (e.code === 'ENOENT') return null;
|
|
73
|
+
throw refuse(`${pin} cannot be read`, `ls -l ${pin}`);
|
|
74
|
+
}
|
|
75
|
+
if (!st.isFile()) throw refuse(`${pin} is not a regular file`, `ls -l ${pin}`);
|
|
76
|
+
return fs.readFileSync(pin);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pinMismatch = (pin, pub) => refuse(`${pin} does not hold the owner key at ${pub}, and a pinned owner key is never replaced`, 'AGENTKEY_HOME=<the directory that holds the pinned key> proofline setup');
|
|
80
|
+
|
|
81
|
+
/** Before anything is written: a pin that is there must hold the key AGENTKEY_HOME already has. */
|
|
82
|
+
function checkOwnerPin(root, env) {
|
|
83
|
+
const { pub, pin } = ownerPaths(root, env);
|
|
84
|
+
const have = readPin(pin);
|
|
85
|
+
if (!have) return;
|
|
86
|
+
let want = null;
|
|
87
|
+
try { want = fs.readFileSync(pub); } catch { want = null; }
|
|
88
|
+
if (!want || !have.equals(want)) throw pinMismatch(pin, pub);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** guard/owner.pub is the only key verifyContractSignature reads. Written once from the key setup created. */
|
|
92
|
+
function pinOwnerKey(root, env) {
|
|
93
|
+
const { pub, pin } = ownerPaths(root, env);
|
|
94
|
+
let want;
|
|
95
|
+
try { want = fs.readFileSync(pub); } catch { throw refuse(`the owner public key ${pub} was not created`, 'proofline setup'); }
|
|
96
|
+
const have = readPin(pin);
|
|
97
|
+
if (have) {
|
|
98
|
+
if (!have.equals(want)) throw pinMismatch(pin, pub);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
fs.mkdirSync(path.dirname(pin), { recursive: true });
|
|
102
|
+
fs.writeFileSync(pin, want, { flag: 'wx', mode: 0o644 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* init, hooks, the owner key, guard/owner.pub from that key, approvals at .proofline/approvals.
|
|
107
|
+
* Prints three lines. Refuses, without those lines, when a hook is missing.
|
|
108
|
+
* Refuses before init when guard/owner.pub holds a key other than the one in AGENTKEY_HOME.
|
|
109
|
+
*/
|
|
110
|
+
export function setup(root, { agent = 'none', env = process.env } = {}) {
|
|
111
|
+
if (agent !== 'none' && agent !== 'claude-code') throw refuse(`unknown agent ${agent}`, 'proofline setup --agent none');
|
|
112
|
+
if (!git(['rev-parse', '--show-toplevel'], root).ok) throw refuse('not a git repository', 'git init');
|
|
113
|
+
const approvals = path.resolve(root, '.proofline', 'approvals');
|
|
114
|
+
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');
|
|
115
|
+
if (!agentkeyBin(env)) throw refuse('agentkey is not on PATH', 'proofline setup');
|
|
116
|
+
checkOwnerPin(root, env);
|
|
117
|
+
try {
|
|
118
|
+
init(root, { hooks: true, approvals });
|
|
119
|
+
installHooks(root);
|
|
120
|
+
} catch {
|
|
121
|
+
throw refuse('a hook is missing', 'proofline setup');
|
|
122
|
+
}
|
|
123
|
+
if (!hooksReady(root)) throw refuse('a hook is missing', 'proofline setup');
|
|
124
|
+
createOwnerKey(env);
|
|
125
|
+
pinOwnerKey(root, env);
|
|
126
|
+
fs.mkdirSync(approvals, { recursive: true });
|
|
127
|
+
if (agent === 'claude-code') {
|
|
128
|
+
const connected = connectClaudeCode();
|
|
129
|
+
if (!connected.ok) throw refuse(connected.reason, 'proofline setup --agent claude-code');
|
|
130
|
+
}
|
|
131
|
+
return `${approvals}\nhooks installed\nproofline task\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function freshId(text) {
|
|
135
|
+
const have = new Set((JSON.parse(text).tasks || []).map((t) => t.id));
|
|
136
|
+
let id = 'TASK';
|
|
137
|
+
let n = 2;
|
|
138
|
+
while (have.has(id)) id = `TASK-${n++}`;
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Scaffold a task from plain words and write the proposal bytes. The contract file stays as it is. */
|
|
143
|
+
export function task(root, words) {
|
|
144
|
+
const title = String(words || '').trim();
|
|
145
|
+
if (!title) throw refuse('name the task in plain words', 'proofline task "<plain words>"');
|
|
146
|
+
const file = C.contractPath(root);
|
|
147
|
+
if (!fs.existsSync(file)) throw refuse('no contract yet', 'proofline setup');
|
|
148
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
149
|
+
let proposal;
|
|
150
|
+
try {
|
|
151
|
+
const spec = C.scaffoldTask({ title, scope: TASK_SCOPE, kind: 'feature', id: freshId(before) });
|
|
152
|
+
proposal = C.appendTaskText(before, [spec]);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw refuse(e.message, 'proofline task "<plain words>"');
|
|
155
|
+
}
|
|
156
|
+
const problems = C.validate(JSON.parse(proposal));
|
|
157
|
+
if (problems.length) throw refuse(`proposal is not valid: ${problems.join('; ')}`, 'proofline task "<plain words>"');
|
|
158
|
+
const hex = sha256(proposal);
|
|
159
|
+
const dir = path.join(root, '.proofline', 'proposals');
|
|
160
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
+
fs.writeFileSync(path.join(dir, hex), proposal);
|
|
162
|
+
return `${hex}\nproofline contract approve ${hex}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unified(current, next) {
|
|
166
|
+
const a = path.join(os.tmpdir(), `pl-now-${process.pid}`);
|
|
167
|
+
const b = path.join(os.tmpdir(), `pl-new-${process.pid}`);
|
|
168
|
+
fs.writeFileSync(a, current);
|
|
169
|
+
fs.writeFileSync(b, next);
|
|
170
|
+
const r = spawnSync('diff', ['-u', a, b], { encoding: 'utf8' });
|
|
171
|
+
fs.rmSync(a, { force: true });
|
|
172
|
+
fs.rmSync(b, { force: true });
|
|
173
|
+
return r.stdout || '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readAnswer() {
|
|
177
|
+
process.stderr.write('Apply? [y/N] ');
|
|
178
|
+
if (process.stdin.isTTY) {
|
|
179
|
+
const buf = Buffer.alloc(256);
|
|
180
|
+
let n = 0;
|
|
181
|
+
try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { n = 0; }
|
|
182
|
+
return buf.toString('utf8', 0, n).split(/\r?\n/, 1)[0].trim();
|
|
183
|
+
}
|
|
184
|
+
let raw = '';
|
|
185
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch { raw = ''; }
|
|
186
|
+
return (raw.split(/\r?\n/, 1)[0] ?? '').trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function proposalBytes(root, hash) {
|
|
190
|
+
const dir = path.join(root, '.proofline', 'proposals');
|
|
191
|
+
if (!hash) {
|
|
192
|
+
let names = [];
|
|
193
|
+
try { names = fs.readdirSync(dir).filter((n) => /^[0-9a-f]{64}$/.test(n)); } catch { names = []; }
|
|
194
|
+
if (names.length !== 1) throw refuse('name the proposal hash', 'proofline task "<plain words>"');
|
|
195
|
+
hash = names[0];
|
|
196
|
+
}
|
|
197
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw refuse('name the proposal hash', 'proofline contract approve');
|
|
198
|
+
const file = path.join(dir, hash);
|
|
199
|
+
let bytes;
|
|
200
|
+
try { bytes = fs.readFileSync(file); } catch { throw refuse(`no proposal ${hash}`, 'proofline task "<plain words>"'); }
|
|
201
|
+
if (sha256(bytes) !== hash) throw refuse(`proposal bytes do not match ${hash}`, 'proofline task "<plain words>"');
|
|
202
|
+
return { hash, bytes };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Show the diff, then apply only on y. The marker is a regular file named by the proposal sha256. */
|
|
206
|
+
export function approve(root, hash) {
|
|
207
|
+
if (!fs.existsSync(C.contractPath(root))) throw refuse('no contract yet', 'proofline setup');
|
|
208
|
+
const { hash: hex, bytes } = proposalBytes(root, hash);
|
|
209
|
+
const current = fs.readFileSync(C.contractPath(root));
|
|
210
|
+
const diff = unified(current, bytes);
|
|
211
|
+
const answer = readAnswer();
|
|
212
|
+
if (answer !== 'y' && answer !== 'Y') return `${diff}${diff.endsWith('\n') || diff === '' ? '' : '\n'}nothing applied\n`;
|
|
213
|
+
const dir = approvalsDir(root);
|
|
214
|
+
if (!dir) throw refuse('no approvals directory configured', 'proofline setup');
|
|
215
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
216
|
+
const marker = path.join(dir, hex);
|
|
217
|
+
let st = null;
|
|
218
|
+
try { st = fs.lstatSync(marker); } catch { st = null; }
|
|
219
|
+
if (st && (!st.isFile() || st.isSymbolicLink())) throw refuse(`marker ${marker} is not a regular file`, `proofline contract approve ${hex}`);
|
|
220
|
+
if (!st) fs.writeFileSync(marker, '', { flag: 'wx', mode: 0o644 });
|
|
221
|
+
fs.writeFileSync(C.contractPath(root), bytes);
|
|
222
|
+
return `${diff}${diff.endsWith('\n') || diff === '' ? '' : '\n'}applied ${hex}\n`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Print the task's criteria until the gate is PASS, or return non-zero when the bound ends. */
|
|
226
|
+
export function watch(root, taskArg) {
|
|
227
|
+
const id = taskArg || activeTask(root);
|
|
228
|
+
if (!id) throw refuse('no active task', 'proofline next');
|
|
229
|
+
const start = Date.now();
|
|
230
|
+
for (;;) {
|
|
231
|
+
let g;
|
|
232
|
+
try { g = E.gate(root, id); }
|
|
233
|
+
catch (e) { throw refuse(e.message, 'proofline next'); }
|
|
234
|
+
const lines = (g.results || []).map((r) => ` ${String(r.status).toUpperCase()} ${r.id} ${r.statement || ''}`.trimEnd());
|
|
235
|
+
process.stdout.write(`${g.task} ${g.decision}\n${lines.join('\n')}\n`);
|
|
236
|
+
if (g.decision === 'PASS') return 0;
|
|
237
|
+
if (Date.now() - start >= WATCH_BOUND_MS) return 1;
|
|
238
|
+
pause(Math.min(WATCH_INTERVAL_MS, WATCH_BOUND_MS - (Date.now() - start)));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Claude Code Stop hook: one receipt for the commits a session made, and none on the next run.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { buildReceipt } from './receipt.mjs';
|
|
7
|
+
|
|
8
|
+
export const STOP_COMMAND = 'proofline hook stop';
|
|
9
|
+
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sessionStart(transcriptPath) {
|
|
15
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
|
|
16
|
+
for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try {
|
|
19
|
+
const row = JSON.parse(line);
|
|
20
|
+
const ts = row.timestamp || row.ts;
|
|
21
|
+
if (typeof ts === 'string' && ts) return ts;
|
|
22
|
+
} catch { /* a later line may carry the time */ }
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function markerFile(root) {
|
|
28
|
+
return path.join(root, '.proofline', 'session');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readMarker(root) {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(markerFile(root), 'utf8')); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commitsSince(root, since) {
|
|
37
|
+
const out = git(root, ['rev-list', `${since}..HEAD`]);
|
|
38
|
+
return out ? out.split('\n').filter(Boolean) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commitsForSession(root, start, sessionId) {
|
|
42
|
+
const found = new Set();
|
|
43
|
+
const startMs = start ? Date.parse(start) : NaN;
|
|
44
|
+
if (!Number.isNaN(startMs)) {
|
|
45
|
+
const out = git(root, ['log', '--format=%H%x09%cI']);
|
|
46
|
+
for (const line of out.split('\n')) {
|
|
47
|
+
const [sha, when] = line.split('\t');
|
|
48
|
+
if (sha && Date.parse(when) >= startMs) found.add(sha);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (sessionId) {
|
|
52
|
+
try {
|
|
53
|
+
const out = git(root, ['log', '-F', '--format=%H', `--grep=Agent-Run: ${sessionId}`]);
|
|
54
|
+
for (const sha of out.split('\n')) if (sha) found.add(sha);
|
|
55
|
+
} catch { /* a git that rejects the flag still keeps the commits matched by time */ }
|
|
56
|
+
}
|
|
57
|
+
return [...found];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function hookStop(root, payload = {}) {
|
|
61
|
+
let head = '';
|
|
62
|
+
try { head = git(root, ['rev-parse', 'HEAD']); }
|
|
63
|
+
catch { return { receipts: [] }; }
|
|
64
|
+
const marker = readMarker(root);
|
|
65
|
+
if (marker?.receipted === head) return { receipts: [] };
|
|
66
|
+
let commits = [];
|
|
67
|
+
try {
|
|
68
|
+
commits = marker?.receipted
|
|
69
|
+
? commitsSince(root, marker.receipted)
|
|
70
|
+
: commitsForSession(root, sessionStart(payload.transcript_path), payload.session_id || '');
|
|
71
|
+
} catch { return { receipts: [] }; }
|
|
72
|
+
if (!commits.length) return { receipts: [] };
|
|
73
|
+
let base = marker?.receipted;
|
|
74
|
+
if (!base) {
|
|
75
|
+
try { base = git(root, ['rev-parse', `${commits[commits.length - 1]}^`]); }
|
|
76
|
+
catch { base = undefined; }
|
|
77
|
+
}
|
|
78
|
+
const receipt = await buildReceipt(root, { base, role: 'claude-code' });
|
|
79
|
+
fs.mkdirSync(path.dirname(markerFile(root)), { recursive: true });
|
|
80
|
+
fs.writeFileSync(markerFile(root), JSON.stringify({ session_id: payload.session_id || '', receipted: head }) + '\n');
|
|
81
|
+
return { receipts: [{ ...receipt, role: 'claude-code' }] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function connectClaudeCode(home = os.homedir()) {
|
|
85
|
+
const file = path.join(home, '.claude', 'settings.json');
|
|
86
|
+
let settings = {};
|
|
87
|
+
if (fs.existsSync(file)) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
90
|
+
catch { return { ok: false, file, reason: 'settings.json is not plain JSON, so nothing was changed' }; }
|
|
91
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
92
|
+
return { ok: false, file, reason: 'settings.json is not an object, so nothing was changed' };
|
|
93
|
+
}
|
|
94
|
+
settings = parsed;
|
|
95
|
+
}
|
|
96
|
+
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {};
|
|
97
|
+
const stop = Array.isArray(hooks.Stop) ? hooks.Stop.slice() : [];
|
|
98
|
+
if (!stop.some((entry) => JSON.stringify(entry).includes(STOP_COMMAND))) {
|
|
99
|
+
stop.push({ hooks: [{ type: 'command', command: STOP_COMMAND }] });
|
|
100
|
+
}
|
|
101
|
+
settings.hooks = { ...hooks, Stop: stop };
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
103
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
104
|
+
return { ok: true, file };
|
|
105
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Public API used by the CLI and the MCP server.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import * as C from './contract.mjs';
|
|
5
6
|
import * as E from './engine.mjs';
|
|
@@ -97,21 +98,147 @@ export function contractCreate(root, spec) {
|
|
|
97
98
|
return { task: t, valid: errs.length === 0, problems: errs };
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
export function taskNext(root, { owner } = {}) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
export function taskNext(root, { owner, base: explicitBase } = {}) {
|
|
102
|
+
const settled = settleLanded(root);
|
|
103
|
+
let c;
|
|
104
|
+
try { c = C.load(root); } catch { c = { tasks: [] }; }
|
|
105
|
+
const n = E.next(root, { owner, skip: c.tasks.map((t) => t.id).filter((id) => landedWhy(root, id)) });
|
|
106
|
+
if (n.task && (n.status === 'not_started' || explicitBase)) {
|
|
107
|
+
const loaded = C.load(root);
|
|
108
|
+
let base;
|
|
109
|
+
if (explicitBase) {
|
|
110
|
+
base = resolveBase(root, explicitBase);
|
|
111
|
+
if (!base) throw new Error(`base revision "${explicitBase}" not found`);
|
|
112
|
+
} else base = resolveBase(root, C.defaultBase(loaded)) || git(['rev-parse', 'HEAD'], root).out;
|
|
113
|
+
record(root, 'task_started', { task: n.task, base, ...(owner ? { owner } : {}) }); n.status = 'in_progress';
|
|
114
|
+
}
|
|
105
115
|
if (n.task) {
|
|
106
116
|
fs.writeFileSync(path.join(root, '.proofline', 'active'), n.task + '\n');
|
|
107
|
-
n.base =
|
|
117
|
+
n.base = E.latestStart(root, n.task);
|
|
108
118
|
if (n.base) n.brief += `\n\n## Base\nPinned at start: ${n.base}. The pin lives in the local .proofline/ log; in another clone, gate with \`--base ${n.base}\`, or end the task commit message with the trailer \`Proofline-Base: ${n.base}\`.`;
|
|
109
119
|
}
|
|
120
|
+
if (settled) {
|
|
121
|
+
n.landed = settled.message;
|
|
122
|
+
if (n.brief) n.brief = `${settled.message}\n\n${n.brief}`;
|
|
123
|
+
else n.message = n.message ? `${settled.message}\n\n${n.message}` : settled.message;
|
|
124
|
+
}
|
|
110
125
|
return n;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
128
|
export const activeTask = (root) => { try { return fs.readFileSync(path.join(root, '.proofline', 'active'), 'utf8').trim() || null; } catch { return null; } };
|
|
114
129
|
|
|
130
|
+
const activePath = (root) => path.join(root, '.proofline', 'active');
|
|
131
|
+
|
|
132
|
+
/** Task id named by a commit message: a (TASK-ID) subject suffix, else a Proofline-Task trailer. */
|
|
133
|
+
export function taskFromMessage(body, known) {
|
|
134
|
+
const subject = String(body || '').split('\n')[0];
|
|
135
|
+
const m = subject.match(/\(([A-Za-z][A-Za-z0-9-]*)\)\s*$/);
|
|
136
|
+
if (m && known.has(m[1])) return m[1];
|
|
137
|
+
const last = String(body || '').trim().split(/\n[ \t]*\n/).pop() || '';
|
|
138
|
+
for (const line of last.split('\n')) {
|
|
139
|
+
const t = line.match(/^Proofline-Task:[ \t]*(\S+)\s*$/);
|
|
140
|
+
if (t && known.has(t[1])) return t[1];
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Newest commit on origin/main that names `id`, or null. */
|
|
146
|
+
function namedHeadOnMain(root, id) {
|
|
147
|
+
const log = git(['log', 'origin/main', '-z', '--format=%H%x1f%B'], root);
|
|
148
|
+
if (!log.ok || !log.out) return null;
|
|
149
|
+
const known = new Set([id]);
|
|
150
|
+
for (const rec of log.out.split('\0')) {
|
|
151
|
+
const i = rec.indexOf('\x1f');
|
|
152
|
+
if (i < 0) continue;
|
|
153
|
+
const sha = rec.slice(0, i).trim();
|
|
154
|
+
if (taskFromMessage(rec.slice(i + 1), known)) return sha;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A pushed criterion PASS in the latest report.
|
|
161
|
+
* A PASS recorded at an older HEAD is stale once the task has new commits, so it does not count as landed.
|
|
162
|
+
* A report with no head (a recorded PASS and nothing else) still counts.
|
|
163
|
+
*/
|
|
164
|
+
function pushedPass(root, id) {
|
|
165
|
+
let head = null;
|
|
166
|
+
let pass = false;
|
|
167
|
+
try {
|
|
168
|
+
const j = JSON.parse(fs.readFileSync(path.join(root, '.proofline', 'reports', `${id}-latest.json`), 'utf8'));
|
|
169
|
+
pass = (j.results || []).some((r) => r.type === 'pushed' && r.status === 'pass');
|
|
170
|
+
if (typeof j.head === 'string' && /^[0-9a-f]{40}$/.test(j.head)) head = j.head;
|
|
171
|
+
} catch { return false; }
|
|
172
|
+
if (!pass) return false;
|
|
173
|
+
if (!head) {
|
|
174
|
+
const log = readLog(root);
|
|
175
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
176
|
+
const e = log[i];
|
|
177
|
+
if (e.type === 'gate' && e.data?.task === id && /^[0-9a-f]{40}$/.test(e.data.head || '')) { head = e.data.head; break; }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!head) return true;
|
|
181
|
+
return git(['rev-parse', 'HEAD'], root).out === head;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Why an active task has landed, or null. Does not touch the active file. */
|
|
185
|
+
export function landedWhy(root, id) {
|
|
186
|
+
let c;
|
|
187
|
+
try { c = C.load(root); } catch { return null; }
|
|
188
|
+
if (!c.tasks?.some((t) => t.id === id)) return null;
|
|
189
|
+
const sha = namedHeadOnMain(root, id);
|
|
190
|
+
if (sha) return { kind: 'head', sha };
|
|
191
|
+
if (pushedPass(root, id)) return { kind: 'pushed' };
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function landedMessage(id, why) {
|
|
196
|
+
if (why.kind === 'head') return `${id} landed: its head ${why.sha.slice(0, 8)} is an ancestor of origin/main. Cleared .proofline/active.`;
|
|
197
|
+
return `${id} landed: its pushed criterion has a PASS record. Cleared .proofline/active.`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Clear .proofline/active when that task has landed. Returns the sentence to report, or null. */
|
|
201
|
+
export function settleLanded(root) {
|
|
202
|
+
const id = activeTask(root);
|
|
203
|
+
if (!id) return null;
|
|
204
|
+
const why = landedWhy(root, id);
|
|
205
|
+
if (!why) return null;
|
|
206
|
+
fs.rmSync(activePath(root), { force: true });
|
|
207
|
+
return { task: id, message: landedMessage(id, why) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Server default-branch head for this push URL, from `git ls-remote` now. Never reads refs/remotes. */
|
|
211
|
+
function serverDefaultHead(root, pushUrl) {
|
|
212
|
+
if (typeof pushUrl !== 'string' || !pushUrl.trim()) return { ok: false };
|
|
213
|
+
const ls = git(['ls-remote', pushUrl, 'HEAD'], root);
|
|
214
|
+
if (!ls.ok) return { ok: false };
|
|
215
|
+
const sha = (ls.out.split(/\s+/)[0] || '');
|
|
216
|
+
if (!sha) return { ok: true, sha: '' };
|
|
217
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) return { ok: false };
|
|
218
|
+
return { ok: true, sha };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function commitsOnPushLine(root, line, pushUrl) {
|
|
222
|
+
const p = line.trim().split(/\s+/);
|
|
223
|
+
if (p.length < 4) return [];
|
|
224
|
+
const local = p[1];
|
|
225
|
+
const remote = p[3];
|
|
226
|
+
if (!/^[0-9a-f]{40}$/i.test(local) || /^0+$/.test(local)) return [];
|
|
227
|
+
// Existing branch: remote..local. New branch: every commit reachable from local.
|
|
228
|
+
// Already sent means reachable from the server default-branch head. A local
|
|
229
|
+
// remote-tracking ref is not that head. An unreadable server gates the whole range.
|
|
230
|
+
const range = /^0+$/.test(remote) ? [local] : [`${remote}..${local}`];
|
|
231
|
+
const listed = git(['rev-list', ...range], root);
|
|
232
|
+
if (!listed.ok) return [];
|
|
233
|
+
const all = listed.out ? listed.out.split('\n').filter(Boolean) : [];
|
|
234
|
+
const server = serverDefaultHead(root, pushUrl);
|
|
235
|
+
if (!server.ok || !server.sha) return all;
|
|
236
|
+
if (!git(['cat-file', '-e', `${server.sha}^{commit}`], root).ok) return all;
|
|
237
|
+
const filtered = git(['rev-list', ...range, '--not', server.sha], root);
|
|
238
|
+
if (!filtered.ok) return all;
|
|
239
|
+
return filtered.out ? filtered.out.split('\n').filter(Boolean) : [];
|
|
240
|
+
}
|
|
241
|
+
|
|
115
242
|
export function evidenceRecord(root, type, data, actor = 'agent') {
|
|
116
243
|
// Agent-recorded evidence is stored as a claim-class event; it is never used to pass a gate.
|
|
117
244
|
return record(root, `agent_${type}`, data, { actor });
|
|
@@ -159,6 +286,8 @@ export function approvalsDir(root) {
|
|
|
159
286
|
* A weakening passes only when an approval marker named with the sha256 of those bytes exists.
|
|
160
287
|
* What else is staged does not matter. There is no bypass. */
|
|
161
288
|
export function hookPreCommit(root) {
|
|
289
|
+
// Do not clear here. The commit under way is not HEAD yet, so a PASS recorded at the
|
|
290
|
+
// current head would look landed and the active task would be dropped before its own commit.
|
|
162
291
|
const c = C.load(root);
|
|
163
292
|
const head = git(['rev-parse', '--verify', '--quiet', 'HEAD'], root);
|
|
164
293
|
if (!head.ok) return { ok: true, msg: 'first commit' };
|
|
@@ -170,11 +299,16 @@ export function hookPreCommit(root) {
|
|
|
170
299
|
let cur; try { cur = JSON.parse(staged.toString('utf8')); } catch { return { ok: false, msg: 'staged proofline.json is not valid JSON' }; }
|
|
171
300
|
let base = null; const s = git(['show', 'HEAD:proofline.json'], root); if (s.ok) try { base = JSON.parse(s.out); } catch {}
|
|
172
301
|
w = C.weakenings(base, cur);
|
|
173
|
-
|
|
302
|
+
const fields = base && cur ? C.contractFieldChanges(base, cur) : [];
|
|
303
|
+
if (w.length || fields.length) {
|
|
174
304
|
// Same check as verify and gate: a regular file named with the sha256 of these bytes.
|
|
175
305
|
const mark = approvalMarker(root, staged);
|
|
176
306
|
if (mark.approved) w = [];
|
|
177
|
-
else
|
|
307
|
+
else {
|
|
308
|
+
note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n');
|
|
309
|
+
// One explanation line. A non-weakening edit is still the hash refusal.
|
|
310
|
+
w = [w.length ? w.join('; ') : 'canonical bytes differ from HEAD'];
|
|
311
|
+
}
|
|
178
312
|
}
|
|
179
313
|
} else if (git(['cat-file', '-e', 'HEAD:proofline.json'], root).ok) {
|
|
180
314
|
// Removed from the index. There are no staged bytes, so no marker name can approve the removal.
|
|
@@ -182,22 +316,116 @@ export function hookPreCommit(root) {
|
|
|
182
316
|
w = C.weakenings(base, { tasks: [] });
|
|
183
317
|
note = 'staged proofline.json is absent, so no approval marker can be bound to its bytes';
|
|
184
318
|
}
|
|
185
|
-
const
|
|
186
|
-
return { ok:
|
|
319
|
+
const problems = [...f.map((x) => `forbidden ${x.rule}: ${x.file}: ${x.line} (${x.why})`), ...w.map((x) => `contract weakened: ${x}`), ...(note ? [note] : [])];
|
|
320
|
+
return { ok: problems.length === 0, msg: problems.join('\n') };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const GIT_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_NAMESPACE', 'GIT_QUARANTINE_PATH'];
|
|
324
|
+
|
|
325
|
+
/** A pre-push hook exports GIT_DIR. Drop it so the gate sees the temporary worktree, then put it back. */
|
|
326
|
+
function withoutGitEnv(fn) {
|
|
327
|
+
const saved = {};
|
|
328
|
+
for (const k of GIT_ENV) {
|
|
329
|
+
if (Object.prototype.hasOwnProperty.call(process.env, k)) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
330
|
+
}
|
|
331
|
+
try { return fn(); }
|
|
332
|
+
finally {
|
|
333
|
+
for (const k of GIT_ENV) {
|
|
334
|
+
if (Object.prototype.hasOwnProperty.call(saved, k)) process.env[k] = saved[k];
|
|
335
|
+
else delete process.env[k];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
187
338
|
}
|
|
188
339
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Gate `id` at `sha` in a temporary worktree. The base is the most recent start pin in `root`,
|
|
342
|
+
* otherwise the parent of `sha`. The refusal names that base. Stale evidence in `root` is not copied.
|
|
343
|
+
*/
|
|
344
|
+
function gateAtCommit(root, sha, id) {
|
|
345
|
+
const pinned = E.latestStart(root, id);
|
|
346
|
+
const parent = git(['rev-parse', '--verify', '--quiet', `${sha}^`], root);
|
|
347
|
+
const baseRef = pinned || (parent.ok ? parent.out : null);
|
|
348
|
+
const named = baseRef || 'none';
|
|
349
|
+
if (!baseRef) return { ok: false, msg: `${id} at ${sha}: no base\nbase ${named}` };
|
|
350
|
+
return withoutGitEnv(() => {
|
|
351
|
+
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'proofline-push-'));
|
|
352
|
+
let added = false;
|
|
353
|
+
try {
|
|
354
|
+
const add = git(['worktree', 'add', '--detach', '--force', wt, sha], root);
|
|
355
|
+
if (!add.ok) return { ok: false, msg: `${id} at ${sha}: worktree failed: ${add.err}\nbase ${baseRef}` };
|
|
356
|
+
added = true;
|
|
357
|
+
let g;
|
|
358
|
+
try { g = E.gate(wt, id, { skipTypes: ['pushed'], base: baseRef }); }
|
|
359
|
+
catch (e) { return { ok: false, msg: `${id} at ${sha}: ${e.message}\nbase ${baseRef}` }; }
|
|
360
|
+
const used = g.base || baseRef;
|
|
361
|
+
if (g.decision !== 'PASS') return { ok: false, msg: `${id} at ${sha}: ${g.decision}\nbase ${used}\n${g.corrective_prompt}` };
|
|
362
|
+
return { ok: true, msg: `gated ${id} at ${sha}\nbase ${used}` };
|
|
363
|
+
} finally {
|
|
364
|
+
if (added) git(['worktree', 'remove', '--force', wt], root);
|
|
365
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* pre-push. A push is gated on the commit being pushed: the local sha from stdin, at that sha,
|
|
372
|
+
* not the checked-out HEAD. The task is the (TASK-ID) subject suffix or a Proofline-Task trailer.
|
|
373
|
+
* A commit that names none falls back to the active task. No active task and no named task is a refusal
|
|
374
|
+
* that prints `proofline next`. An active task that has landed is cleared and never gated.
|
|
375
|
+
* With no push lines (a direct call), the previous rule stands: the active task, else every in-progress or failed task.
|
|
376
|
+
*/
|
|
377
|
+
export function hookPrePush(root, { lines, pushUrl } = {}) {
|
|
378
|
+
// stdin lines are `<local ref> <local sha> <remote ref> <remote sha>`. The gated revision is that sha.
|
|
379
|
+
if (lines == null) {
|
|
380
|
+
let raw = '';
|
|
381
|
+
try { if (!process.stdin.isTTY) raw = fs.readFileSync(0, 'utf8'); } catch { raw = ''; }
|
|
382
|
+
lines = raw.split('\n').filter((l) => l.trim());
|
|
383
|
+
}
|
|
384
|
+
const settled = settleLanded(root);
|
|
191
385
|
const c = C.load(root);
|
|
192
386
|
const errs = C.validate(c);
|
|
193
|
-
|
|
387
|
+
const note = settled?.message || '';
|
|
388
|
+
const join = (...parts) => parts.flat().filter(Boolean).join('\n\n');
|
|
389
|
+
if (errs.length) return { ok: false, msg: join(note, 'contract invalid:\n' + errs.join('\n')) };
|
|
390
|
+
const haveLines = Array.isArray(lines) && lines.some((l) => l.trim());
|
|
391
|
+
if (haveLines) {
|
|
392
|
+
const known = new Set(c.tasks.map((t) => t.id));
|
|
393
|
+
const commits = [...new Set(lines.flatMap((l) => commitsOnPushLine(root, l, pushUrl)))];
|
|
394
|
+
if (commits.length) {
|
|
395
|
+
const active = activeTask(root);
|
|
396
|
+
const activeLive = active && !landedWhy(root, active) ? active : null;
|
|
397
|
+
const jobs = [];
|
|
398
|
+
for (const sha of commits) {
|
|
399
|
+
const body = git(['log', '-1', '--format=%B', sha], root).out || '';
|
|
400
|
+
const id = taskFromMessage(body, known);
|
|
401
|
+
if (id) jobs.push({ sha, id });
|
|
402
|
+
else if (activeLive) jobs.push({ sha, id: activeLive });
|
|
403
|
+
}
|
|
404
|
+
if (!jobs.length) {
|
|
405
|
+
const started = readLog(root).some((e) => e.type === 'task_started');
|
|
406
|
+
if (!started) return { ok: true, msg: join(note, 'No task has a start record. Next step: proofline next') };
|
|
407
|
+
return { ok: false, msg: join(note, 'no active task and no commit names a task.\nNext step: proofline next') };
|
|
408
|
+
}
|
|
409
|
+
const bad = [];
|
|
410
|
+
const good = [];
|
|
411
|
+
for (const job of jobs) {
|
|
412
|
+
if (!c.tasks.some((t) => t.id === job.id)) continue;
|
|
413
|
+
const g = gateAtCommit(root, job.sha, job.id);
|
|
414
|
+
if (!g.ok) bad.push(g.msg);
|
|
415
|
+
else if (g.msg) good.push(g.msg);
|
|
416
|
+
}
|
|
417
|
+
return { ok: bad.length === 0, msg: join(note, bad.length ? bad.join('\n\n') : good.join('\n\n')) };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
194
420
|
const st = E.taskStatus(root, c);
|
|
195
|
-
const
|
|
421
|
+
const active = activeTask(root);
|
|
422
|
+
let ids = active ? [active] : Object.keys(st).filter((k) => st[k].status === 'in_progress' || st[k].status === 'failed');
|
|
423
|
+
ids = ids.filter((id) => !landedWhy(root, id));
|
|
196
424
|
const out = [];
|
|
197
425
|
for (const id of ids) {
|
|
198
426
|
if (!c.tasks.some((t) => t.id === id)) continue;
|
|
199
427
|
const g = E.gate(root, id, { skipTypes: ['pushed'] });
|
|
200
428
|
if (g.decision !== 'PASS') out.push(`${id}: ${g.decision}\n${g.corrective_prompt}`);
|
|
201
429
|
}
|
|
202
|
-
return { ok: out.length === 0, msg: out.join('\n\n') };
|
|
430
|
+
return { ok: out.length === 0, msg: join(note, out.join('\n\n')) };
|
|
203
431
|
}
|