@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/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 0.2.0-next.1 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- A contract whose canonical bytes differ from its base is refused at pre-commit, verify, and gate unless the approval marker names the new bytes. The field-level weakening list stays the explanation, and a tightening is still not a weakening.
|
|
9
|
+
- The repository commits package-lock.json at lockfile version 3, made with npm install --package-lock-only --ignore-scripts, so a gate can install this revision from the revision itself.
|
|
10
|
+
- `verify` and `gate` stay on the host unless `--isolated` is passed. That mode runs command, red-green and metric checks with no network, a fresh HOME, and the repository read-only except paths the check declares. The image digest is `env.image` on the contract under test. Without it, `--isolated` refuses and names `env.image`. The receipt records `isolated`, `env_digest` and `heuristic`. grep, wiring and the forbidden scan are one layer, not a boundary.
|
|
11
|
+
- Every subcommand refuses a flag it does not accept, exits 2, and names the unknown flag and the accepted ones. Nothing runs after that refusal (#33).
|
|
12
|
+
- The pre-push hook leaves out a commit already on the server default branch. It reads that head with `git ls-remote` of the push URL at send time, for an existing branch and for a new branch. A local remote-tracking ref does not count. If the server cannot be read, every commit in the range is gated (#31, #35).
|
|
13
|
+
- The pre-push hook gates the commit being pushed, at that sha, in a temporary worktree. It reads the local sha from stdin and does not gate the checked-out HEAD. The base is the most recent `proofline next` pin for the task, or the parent of that commit, and a refusal prints the base it used. `proofline next --base <rev>` records a newer pin. A worktree with no evidence still gates, and stale evidence in the pushing worktree cannot block a commit that passes at its own sha (#21, #22, #23).
|
|
14
|
+
- `proofline contract sign` writes `proofline.json.sig`, a signature over the canonical contract by the agentkey owner role. `proofline contract verify-sig` checks it against `guard/owner.pub` only. Once that pin is present, `next`, `verify`, `gate`, `brief` and both hooks exit 2 with `contract not owner-signed` when the signature is missing, altered or from another key. `proofline contract propose` writes `proposals/<sha256>.diff` and prints the hash.
|
|
15
|
+
- `proofline setup` installs the hooks, creates the owner key through agentkey and sets approvals to `.proofline/approvals`. `proofline task` writes a proposal from plain words and prints its hash. `proofline contract approve` shows the diff and, on y, writes the marker and applies the bytes. `proofline watch` prints the active task until the gate is PASS.
|
|
16
|
+
- `proofline contract merge` appends a fragment's tasks and new owner rules, refuses a duplicate id, keeps the existing bytes and prints the sha256. `proofline contract coverage` prints each requirement id with the tasks that name it, or `MISSING`, and exits 1 on a gap or an unknown id.
|
|
17
|
+
- `proofline contract scaffold` prints a task with a red-then-green test, the suite, scope, pushed and the default stop rules. `contract validate` refuses a task with no stop rules, a task with no scope criterion, a criterion path outside that scope, a double-quoted `sh -c` or `bash -c` command that contains `$`, and a red-then-green criterion with no test file. A grep that already matches on the base revision is a warning that names that revision. `brief` and `steer` no longer throw when a loaded task has no stop rules (#15).
|
|
18
|
+
- `examples/demo/catch.mjs`: a short demo that catches a false report, a test that proves nothing, a skipped test with a raised timeout, a change outside the scope and a weakened contract, then passes the real work; its transcript is in the README.
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
21
|
+
- `proofline setup` writes `guard/owner.pub` from the owner key it creates, so `proofline contract sign` makes a signature that `verify-sig` checks without copying the key by hand. A pin that already holds those bytes is left alone. A pin that holds other bytes is refused before anything is written, and the refusal names both paths.
|
|
22
|
+
- An active task ends when it lands. `next`, `verify` and the hooks clear `.proofline/active` once that task's head is an ancestor of `origin/main` or its pushed criterion has a PASS record, and they say so. The pre-push hook gates the task the commits name. A push that names no task and has no active task is refused, and the refusal prints `proofline next` (#25).
|
|
8
23
|
- `contract create` keeps one level of `scope.allow` when the task file writes the scope as the contract does, `{ "allow": [...] }`, and `contract validate` refuses a scope that is not a list of patterns, so `next` cannot crash on it (#19).
|
|
9
24
|
- A failed `command` criterion's evidence names the failed tests. `out_tail` keeps the last 1,200 characters, where node's summary of repeated failures with their stacks held the last two or three names. The new `failing` field lists the failed test names found anywhere in the output (TAP `not ok` lines, the spec reporter's `✖ <name> (<ms>ms)` lines and the `failing tests:` section), without todos, skips or repeats, in order of first appearance and at most 100, with `failing_total` when there were more. The FAIL detail names the first 10, a flaky result keeps both runs' lists, and the gate report lists the names under Failing tests (#13).
|
|
10
25
|
- The forbidden scan no longer reads `proofline.json` or `.proofline/`: the commit that adds or amends the contract failed on its own deny-list. Contract changes stay covered by the weakening check, and the gate report says whether the contract was added, changed or unchanged vs the base (#4).
|
|
@@ -15,6 +30,11 @@ All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`)
|
|
|
15
30
|
|
|
16
31
|
### Added
|
|
17
32
|
- `@optitransfer/proofline@0.2.0-next.0` is the public package on the `next` dist-tag. `private` is false, the test script runs `node --test test/*.test.mjs`, and `files` lists what this revision ships. The sixty-second start installs that package and calls `npx proofline`. The package description, keywords, homepage, repository, bugs and author match the GitHub repository page.
|
|
33
|
+
- `proofline hook stop` writes one receipt for the commits a session made, and stays quiet when there is nothing new. `proofline connect claude-code` merges that hook into `~/.claude/settings.json` after the hooks already there.
|
|
34
|
+
- MCP tools `receipt` and `verify_receipt` return the same JSON the CLI prints with `--json`. `tools/list` shows nine tools.
|
|
35
|
+
- A hivevault digest can be attached to a receipt. L1 stores the digest and the frontier. L2 in ci mode stores only a commitment. No daemon leaves the receipt unchanged. `--vault` writes the note under `work/receipts/`.
|
|
36
|
+
- `verify-receipt` checks a DSSE receipt offline. Exit 0 is verified, 1 is a bad signature, a schema problem or a `--recompute` difference, 2 is an unknown key, and 3 is usage. `--sigstore` is the only path that loads that verifier.
|
|
37
|
+
- Zero-config inference reads suite commands from `src/inference.json` at the base revision. Nothing inferable returns an L0 receipt and exit 2. A missing toolchain is blocked, not a pass. A change to the test command is a `test_command_changed` finding, and the base command is what runs.
|
|
18
38
|
- The pre-commit hook accepts a contract change only when an approval marker is present. The marker is a file named with the sha256 of the staged `proofline.json` bytes, in the directory set by `init --approvals` and stored in `.proofline/config.json` (never committed). The refusal prints the weakenings, the sha256 and the marker path. A marker for other bytes does not count, what else is staged does not matter, and with no directory configured every contract change is refused. `verify` and `gate` use the same marker for the current `proofline.json` bytes: a regular file approves the weakenings, the report names the marker path, and the decision is not blocked.
|
|
19
39
|
- Opt-in result reuse: with `defaults.reuse_results` (validated, default `false`), a `command` or `metric` PASS on a clean tree is recorded in the evidence log and reused by a later `verify` or `gate` on the same HEAD, so `verify`, `gate` and the pre-push hook run the full suite once per commit. The key is the verifier, HEAD, `process.version` and the env vars the command names; only a PASS is stored; `defaults.reuse_max_age_h` (default 24) bounds its age; a metric is reused only within `max_load_per_cpu`. A result is reused only from an evidence entry still in an intact chain with the hash the cache file stored. Reused criteria say `reused from <time> (evidence entry <n>)` and are listed apart in the gate report and its evidence entry. `--fresh` on `verify` and `gate` (MCP: `fresh`) runs every check. Turning reuse on or off is not a weakening (#12).
|
|
20
40
|
- Metric evidence and report lines record the load before the run, the CPU count and the `node --version` the bench command resolves. Optional `defaults.max_load_per_cpu`: a metric run on a busier machine is BLOCKED (machine busy, with the numbers) and the bench is not run; raising or removing the limit is a weakening (#7).
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
|
+
`proofline verify --isolated` and `proofline gate --isolated` run command, red-green and metric checks in a container. Local use stays off unless that flag is passed. The image digest is `env.image` on the contract under test. Without it, `--isolated` refuses and names `env.image`.
|
|
8
|
+
|
|
7
9
|
```mermaid
|
|
8
10
|
flowchart LR
|
|
9
11
|
C["proofline.json<br/>tasks, criteria, scope,<br/>forbidden actions"] --> N["proofline next<br/>one task, its brief,<br/>the base revision pinned"]
|
|
@@ -21,6 +23,46 @@ flowchart LR
|
|
|
21
23
|
O["owner marker<br/>sha256 of the approved<br/>proofline.json"] -. "approves a<br/>contract change" .-> H1
|
|
22
24
|
```
|
|
23
25
|
|
|
26
|
+
## See it catch
|
|
27
|
+
|
|
28
|
+
`node examples/demo/catch.mjs` builds a throwaway repository with one task, then plays five ways an agent "finishes" without doing the work, and the real work last. Real output, shortened only where a line ran long:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
proofline next -> TASK-1: Saves return a durable receipt on the default path
|
|
32
|
+
|
|
33
|
+
1. The lie: no work, reported "done | abc1234 | app.test.mjs" -> FAIL
|
|
34
|
+
claim rejected: claimed "done" but the independent gate says FAIL: ...
|
|
35
|
+
claim rejected: commit abc1234 does not exist
|
|
36
|
+
TASK-1-AC01 FAIL test file test/durable.test.mjs not found
|
|
37
|
+
TASK-1-AC02 FAIL durableReceipt is not referenced from the default path (src/app.mjs; 2 files reachable). Built but not wired in.
|
|
38
|
+
|
|
39
|
+
2. The gap: a "durable" test that asserts what already held -> FAIL
|
|
40
|
+
TASK-1-AC01 FAIL test PASSES on base 412bc3d9 without the change: it does not prove the change (tests how, not what?)
|
|
41
|
+
|
|
42
|
+
3. The shortcut: test.skip and a timeout raised 2000 -> 20000 -> FAIL
|
|
43
|
+
forbidden: test_skip_only in test/app.test.mjs: test.skip('handle returns ok', { timeout: 20000 }, () => { assert.equal(handle('x').ok, true); });
|
|
44
|
+
forbidden: timeout_raise in test/app.test.mjs: 2000 → 20000
|
|
45
|
+
TASK-1-AC03 FAIL 1 skipped/todo > allowed 0
|
|
46
|
+
|
|
47
|
+
4. The stray change: README.md edited, outside the task's scope -> FAIL
|
|
48
|
+
TASK-1-AC04 FAIL changes outside the approved scope: README.md
|
|
49
|
+
|
|
50
|
+
5. The weakening: proofline.json edited so "suite green" runs `true`, then git commit -> REFUSED by the pre-commit hook
|
|
51
|
+
contract weakened: TASK-1/TASK-1-AC03 command changed: "node --test" → "true"
|
|
52
|
+
marker (no approvals directory configured)
|
|
53
|
+
|
|
54
|
+
6. The work: failing test first, durableReceipt wired in, committed through the hooks, pushed -> PASS
|
|
55
|
+
TASK-1-AC01 PASS red on base (exit 1), green now
|
|
56
|
+
TASK-1-AC02 PASS durableReceipt used on the default path: src/app.mjs
|
|
57
|
+
TASK-1-AC03 PASS exit 0 in 234 ms
|
|
58
|
+
TASK-1-AC04 PASS 2 changed files, all in scope
|
|
59
|
+
TASK-1-AC05 PASS HEAD f6234c72 is origin/main
|
|
60
|
+
claim accepted: "TASK-1 | done | f6234c7 | save is durable, test/durable.test.mjs | - | -"
|
|
61
|
+
evidence: 9 entries in the hash-chained log; report .proofline/reports/TASK-1-latest.md
|
|
62
|
+
|
|
63
|
+
Caught five, passed one.
|
|
64
|
+
```
|
|
65
|
+
|
|
24
66
|
## Sixty seconds
|
|
25
67
|
|
|
26
68
|
In the repository the agent works on:
|
|
@@ -76,6 +118,49 @@ DECISION: FAIL
|
|
|
76
118
|
|
|
77
119
|
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`.
|
|
78
120
|
|
|
121
|
+
## How you use it, start to finish
|
|
122
|
+
|
|
123
|
+
### Short path
|
|
124
|
+
|
|
125
|
+
`proofline setup` then `proofline task "<plain words>"` then `proofline contract approve` then `proofline watch`.
|
|
126
|
+
|
|
127
|
+
That is the short path: four commands, in that order. The diagram and the table below are the manual path.
|
|
128
|
+
|
|
129
|
+
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`.
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
```mermaid
|
|
134
|
+
sequenceDiagram
|
|
135
|
+
participant You
|
|
136
|
+
participant Agent
|
|
137
|
+
participant Proofline
|
|
138
|
+
You->>Proofline: 1. npx proofline init
|
|
139
|
+
You->>Agent: 2. the task, in plain words
|
|
140
|
+
Agent->>Proofline: 3. proofline contract create (the task as checks)
|
|
141
|
+
Proofline-->>You: the contract and its hash
|
|
142
|
+
You->>Proofline: 4. approve the hash
|
|
143
|
+
Agent->>Proofline: 5. proofline next, build, proofline verify (repeat)
|
|
144
|
+
Agent->>Proofline: 6. git commit, git push (the hooks gate both)
|
|
145
|
+
Agent-->>You: 7. "done", commit, tests
|
|
146
|
+
Proofline-->>You: 8. gate report and signed receipt
|
|
147
|
+
You->>You: merge
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| Step | Who | What happens | Command |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| 1 | You | Install Proofline in the repository once. It writes an empty `proofline.json`, installs the pre-commit and pre-push hooks and records where your approvals live. | `npm i -D @optitransfer/proofline@next` then `npx proofline init` |
|
|
153
|
+
| 2 | You | Describe the task to your agent in plain words. "Add per-client rate limiting, 100 requests a minute, 429 with Retry-After, every existing test stays green." | your agent's chat |
|
|
154
|
+
| 3 | Agent | Turn the words into a contract: a title, the files it may touch, and acceptance criteria that are checks, not sentences (a test that fails before and passes after, a grep for `Retry-After`, the suite green, changes in scope, pushed). The result is the contract's new bytes and their hash. The agent cannot put it into force. | `proofline contract create` |
|
|
155
|
+
| 4 | You | Read the contract (it is short and every line is checkable) and approve its hash. From now on the hooks accept only contract bytes you approved, so the agent cannot change the rules it is judged by. | approve the hash |
|
|
156
|
+
| 5 | Agent | Take the one active task with its brief, write the failing test first, then the code, and run the checks as often as it likes. Proofline re-runs every criterion itself and prints a corrective instruction for anything not yet true. | `proofline next`, `proofline verify TASK` |
|
|
157
|
+
| 6 | Agent | Commit and push. The pre-commit hook refuses the known shortcuts: skipped tests, raised timeouts, deleted assertions, a bypass flag. The pre-push hook runs the gate before anything leaves the machine; a FAIL stays local. | `git commit`, `git push` |
|
|
158
|
+
| 7 | Agent | Report: done, the commit, the tests. The report is a claim, not evidence. | one line |
|
|
159
|
+
| 8 | Proofline | The gate compares the claim with what it measured and writes the report: every criterion PASS or FAIL with the measured detail, and a signed receipt (through agentkey) saying which agent role produced the commit against which contract hash. A claim the evidence does not support is rejected. | `proofline gate TASK` |
|
|
160
|
+
| | You | Read the one page, not the diff. Merge. | |
|
|
161
|
+
|
|
162
|
+
What you never do: read the agent's prose for reassurance, trust a green checkmark the agent printed, or let the agent change the rules it is judged by.
|
|
163
|
+
|
|
79
164
|
## Why
|
|
80
165
|
|
|
81
166
|
An agent's report is a claim. Left to itself it says "done" for a test it skipped, a timeout it raised, a target it lowered, a commit it never pushed, or a number it did not measure. Reviewing every claim by hand does not scale past one agent, and asking a second model whether the first one is right only adds a second opinion.
|
|
@@ -108,11 +193,19 @@ Always on: forbidden-action scan of the diff (skip/only/todo, raised timeouts, n
|
|
|
108
193
|
|
|
109
194
|
Two or more builders can share one contract. A task may carry an `owner`, and `defaults.owners` may list the names (`contract validate` then refuses an owner outside the list). `next --owner <name>` (MCP: `task_next` with `owner`) hands out only that owner's tasks and unowned ones, never another owner's; the phase gate applies to that lane, and `depends_on` still waits on any owner's task. `status --owner <name>` and `brief <TASK> --owner <name>` take the same filter, and `brief` refuses another owner's task. Without `--owner`, nothing changes.
|
|
110
195
|
|
|
196
|
+
### Contract scaffold
|
|
197
|
+
|
|
198
|
+
`proofline contract scaffold --title ... --scope a,b [--kind feature|docs|release]` prints a task with the standard criteria and the default stop rules. It does not write the contract. A feature gets a red-then-green test and the suite; docs gets a grep; release gets one command. Scope and pushed are always there. `contract validate` refuses a task with no stop rules, a task with no scope criterion, a criterion whose file, path, test file or entry sits outside that scope, a double-quoted `sh -c` or `bash -c` command that contains `$`, and a red-then-green criterion with no test file. A grep whose pattern already matches on the base revision is a warning, and the warning names that revision. `brief` and `steer` still run when a loaded task has no stop rules.
|
|
199
|
+
|
|
200
|
+
`proofline contract merge <fragment.json> [--owners a=b,c=d]` appends the fragment's tasks and any owner rule that is not already there. A duplicate id is refused and the file is left untouched. Existing tasks and the bytes around them stay. The command prints the sha256 of the result. `proofline contract coverage <REQUIREMENTS.md> [--contracts a.json,b.json]` reads ids from lines of the form `- ID: text` and prints one line per id with the tasks that name it, or `MISSING`. A task that names an id the file does not list is `unknown`. The exit code is 1 when any id is missing or unknown.
|
|
201
|
+
|
|
111
202
|
|
|
112
203
|
### Git hooks
|
|
113
204
|
|
|
114
205
|
`init` installs `pre-commit` (forbidden actions and contract weakening vs HEAD) and `pre-push` (the active task must gate PASS). If the repository already has one of these hooks and it is not proofline's, `init` keeps it as `<hook>.proofline-prev` and says so. The installed hook runs it first, with the same arguments and stdin; if it fails, its exit code stands and the gate does not run. Running `init` again changes nothing. If both `<hook>` and `<hook>.proofline-prev` exist and neither is proofline's, `init` stops rather than lose one.
|
|
115
206
|
|
|
207
|
+
A push is gated on the commit being pushed. The hook reads that commit's local sha from stdin, checks the commit out in a temporary worktree, and gates it there. It does not gate the checked-out HEAD. A commit already on a remote default branch is left out. That head is read with `git ls-remote` of the push URL at send time, never from a local remote-tracking ref, for an existing branch and for a new branch. A commit on no remote is still gated. If the server cannot be read, every commit in the range is gated. The task is the `(TASK-ID)` suffix on the subject, or a `Proofline-Task` trailer. A commit that names no task is gated on the active task. The base is the most recent pin from `proofline next` for that task, otherwise the parent of the commit being pushed, and every refusal prints that base. A worktree with no evidence still gates. Evidence left in the worktree you push from cannot block a commit that passes at its own sha. An active task whose head is an ancestor of origin/main, or whose pushed criterion has a PASS record, has landed: `next`, `verify` and the hooks clear `.proofline/active` and say so, and a landed task is never gated. A push with no active task and no named task is refused, and the refusal prints `proofline next`. Before any task has a start record, a push that names no task passes and prints `proofline next`.
|
|
208
|
+
|
|
116
209
|
A contract change is refused unless an approval marker is present. `proofline init --approvals <dir>` (re-runnable) stores that directory in `.proofline/config.json`, which is never committed. The marker is a file in that directory named with the sha256 of the staged `proofline.json` bytes. The refusal prints the weakenings, the sha256 and the marker path. A marker named for any other bytes does not count, and what else the commit stages makes no difference. With no directory configured, every contract change is refused. There is no bypass.
|
|
117
210
|
|
|
118
211
|
`verify` and `gate` read the same marker for the current `proofline.json` bytes. When that marker is a regular file, the weakenings are an approved contract change: the report names the marker path, and the decision is not blocked. A missing marker, a marker for other bytes, or a symlink still blocks.
|
|
@@ -124,7 +217,7 @@ Every diff check (forbidden scan, `scope`, `test_red_green`, contract weakening)
|
|
|
124
217
|
|
|
125
218
|
1. `--base <rev>` on `verify` or `gate` (MCP: `base`);
|
|
126
219
|
2. the task's own `base` in the contract;
|
|
127
|
-
3. the base pinned
|
|
220
|
+
3. the most recent base pinned for the task (`proofline next` records it in the local `.proofline/` log, `proofline next --base <rev>` records a newer pin, and the brief prints the one that wins);
|
|
128
221
|
4. a `Proofline-Base: <sha>` trailer on the HEAD commit (optional; it must name an ancestor of HEAD, or the gate is BLOCKED);
|
|
129
222
|
5. the default: `defaults.base`, else `<defaults.remote>/<defaults.branch>` (`origin/main` when neither is set).
|
|
130
223
|
|
|
@@ -172,7 +265,7 @@ proofline/
|
|
|
172
265
|
├── src/engine.mjs verify, the corrective prompt, gate and its report, next and brief, phases
|
|
173
266
|
├── src/mcp.mjs the MCP server on stdio
|
|
174
267
|
├── src/index.mjs the API, init, hooks
|
|
175
|
-
├── examples/demo/
|
|
268
|
+
├── examples/demo/ demo.mjs (FAIL then PASS, npm run demo) and catch.mjs (five catches, one pass)
|
|
176
269
|
├── examples/demo-contract/ an example contract with one task; the self-gate validates it
|
|
177
270
|
├── docs/ the source design: scope, requirements, architecture, lifecycle, evidence, roadmap
|
|
178
271
|
├── plans/ definition of done, risk register, pilot plan
|
package/bin/proofline.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Proofline CLI. Exit codes: 0 PASS/ok, 1 FAIL, 2 BLOCKED, 3 usage error.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import * as P from '../src/index.mjs';
|
|
5
6
|
import { verifyChain, readLog } from '../src/evidence.mjs';
|
|
7
|
+
import { verifyReceipt } from '../src/verify-receipt.mjs';
|
|
8
|
+
import { buildReceipt } from '../src/receipt.mjs';
|
|
9
|
+
import { connectClaudeCode, hookStop } from '../src/hook-stop.mjs';
|
|
10
|
+
import * as G from '../src/guided.mjs';
|
|
11
|
+
import * as S from '../src/signature.mjs';
|
|
6
12
|
|
|
7
13
|
const argv = process.argv.slice(2);
|
|
8
14
|
const flags = {}; const pos = [];
|
|
@@ -16,17 +22,38 @@ const root = flags.root ? flags.root : P.findRoot();
|
|
|
16
22
|
const json = !!flags.json;
|
|
17
23
|
const print = (obj, text) => { process.stdout.write((json || text === undefined ? JSON.stringify(obj, null, 2) : text) + '\n'); };
|
|
18
24
|
const code = (d) => (d === 'PASS' ? 0 : d === 'BLOCKED' ? 2 : 1);
|
|
25
|
+
|
|
26
|
+
function ownerGate(root) {
|
|
27
|
+
const r = S.verifyContractSignature(root);
|
|
28
|
+
if (!r.armed || r.ok) return true;
|
|
29
|
+
console.error(`proofline: contract not owner-signed\n${r.detail}`);
|
|
30
|
+
process.exitCode = 2;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
19
33
|
const owner = () => { if (flags.owner === true) throw new Error('--owner needs a name'); return flags.owner; };
|
|
20
34
|
const base = () => { if (flags.base === true) throw new Error('--base needs a revision'); return flags.base; };
|
|
21
35
|
const claims = () => flags['claims-file'] ? fs.readFileSync(flags['claims-file'], 'utf8') : flags.claims === true ? fs.readFileSync(0, 'utf8') : flags.claims;
|
|
22
36
|
|
|
23
|
-
const HELP = `proofline
|
|
37
|
+
const HELP = `proofline: independent V&V and delivery controller
|
|
38
|
+
setup [--agent claude-code|none] init, hooks, the owner key, approvals at .proofline/approvals
|
|
39
|
+
task "<plain words>" write a proposal from those words and print its hash
|
|
40
|
+
contract approve [<hash>] show the diff; on y write the marker and apply the bytes
|
|
41
|
+
contract sign write proofline.json.sig with the owner key
|
|
42
|
+
contract verify-sig check the owner signature
|
|
43
|
+
contract propose write proposals/<sha256>.diff and print the hash
|
|
44
|
+
watch [TASK] print the active task until the gate is PASS
|
|
24
45
|
init [--no-hooks] [--approvals <dir>] create proofline.json, .proofline/, install git hooks;
|
|
25
46
|
--approvals sets the directory of owner markers (re-runnable)
|
|
26
47
|
contract create --from spec.json add a task contract (or --id --title [--phase] [--owner n] [--depends a,b] [--scope g1,g2])
|
|
27
|
-
contract
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
contract scaffold --title ... --scope a,b [--kind feature|docs|release] [--id TASK]
|
|
49
|
+
print a task with the standard criteria and stop rules
|
|
50
|
+
contract merge <fragment.json> [--owners a=b,c=d]
|
|
51
|
+
append tasks and new owner rules; prints the sha256
|
|
52
|
+
contract coverage <REQUIREMENTS.md> [--contracts a.json,b.json]
|
|
53
|
+
one line per requirement id, or MISSING
|
|
54
|
+
contract validate | show validate also warns when a grep already matches on the base revision
|
|
55
|
+
next [--owner <name>] [--base <rev>] the single next task with its full brief (marks it active);
|
|
56
|
+
--owner limits the lane; --base re-pins the base
|
|
30
57
|
brief <TASK> [--owner <name>] [--base <rev>] brief for a task, with the base verify will use
|
|
31
58
|
(refuses another owner's task)
|
|
32
59
|
verify <TASK> [--claims "line"] [--base <rev>] [--fresh] run every check independently; prints corrective prompt
|
|
@@ -38,13 +65,76 @@ const HELP = `proofline — independent V&V and delivery controller
|
|
|
38
65
|
report <TASK> latest gate report
|
|
39
66
|
status [--owner <name>] task + phase status, evidence chain
|
|
40
67
|
evidence record <type> --data '{..}' | evidence verify | evidence log
|
|
68
|
+
verify-receipt <envelope> [--keys <dir>] [--sigstore] [--recompute --base <sha>]
|
|
69
|
+
exit 0 verified, 1 failed or differs, 2 unknown key, 3 usage
|
|
70
|
+
receipt [--role <name>] [--base <rev>] [--vault]
|
|
71
|
+
one receipt for the revision; --vault writes the note when a daemon answers
|
|
41
72
|
hook pre-commit|pre-push (used by git hooks)
|
|
73
|
+
hook stop read a Stop hook payload on stdin; one receipt for new commits, or silence
|
|
74
|
+
connect claude-code merge a Stop hook into ~/.claude/settings.json, after any hook already there
|
|
42
75
|
mcp MCP server on stdio
|
|
43
76
|
Global: --json --root <dir> Exit: 0 PASS, 1 FAIL, 2 BLOCKED, 3 usage`;
|
|
44
77
|
|
|
78
|
+
const GLOBAL_FLAGS = ['json', 'root'];
|
|
79
|
+
const ACCEPTED_FLAGS = {
|
|
80
|
+
setup: ['agent'],
|
|
81
|
+
task: [],
|
|
82
|
+
init: ['approvals', 'no-hooks'],
|
|
83
|
+
'contract create': ['from', 'id', 'title', 'phase', 'owner', 'description', 'depends', 'scope'],
|
|
84
|
+
'contract merge': ['owners'],
|
|
85
|
+
'contract coverage': ['contracts'],
|
|
86
|
+
'contract scaffold': ['title', 'scope', 'kind', 'id'],
|
|
87
|
+
'contract approve': [],
|
|
88
|
+
'contract sign': [],
|
|
89
|
+
'contract verify-sig': [],
|
|
90
|
+
'contract propose': [],
|
|
91
|
+
'contract validate': ['base'],
|
|
92
|
+
'contract show': [],
|
|
93
|
+
next: ['owner', 'base'],
|
|
94
|
+
brief: ['owner', 'base'],
|
|
95
|
+
verify: ['claims', 'claims-file', 'base', 'fresh'],
|
|
96
|
+
gate: ['claims', 'claims-file', 'base', 'fresh', 'all', 'ci', 'skip-pushed'],
|
|
97
|
+
report: [],
|
|
98
|
+
status: ['owner'],
|
|
99
|
+
'evidence record': ['data'],
|
|
100
|
+
'evidence verify': [],
|
|
101
|
+
'evidence log': [],
|
|
102
|
+
'verify-receipt': ['keys', 'sigstore', 'recompute', 'base'],
|
|
103
|
+
receipt: ['base', 'role', 'task', 'vault'],
|
|
104
|
+
hook: [],
|
|
105
|
+
watch: [],
|
|
106
|
+
connect: [],
|
|
107
|
+
mcp: [],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function refuseUnknownFlags(cmd, sub) {
|
|
111
|
+
if (!cmd) return;
|
|
112
|
+
const key = sub ? `${cmd} ${sub}` : cmd;
|
|
113
|
+
const accepted = ACCEPTED_FLAGS[key] ?? ACCEPTED_FLAGS[cmd];
|
|
114
|
+
if (!accepted) return;
|
|
115
|
+
const allow = new Set([...GLOBAL_FLAGS, ...accepted]);
|
|
116
|
+
const bad = Object.keys(flags).filter((k) => !allow.has(k));
|
|
117
|
+
if (!bad.length) return;
|
|
118
|
+
const named = bad.map((k) => `--${k}`).join(', ');
|
|
119
|
+
const ok = [...allow].sort().map((k) => `--${k}`).join(', ');
|
|
120
|
+
console.error(`proofline: unknown flag ${named}\naccepted: ${ok}`);
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
|
|
45
124
|
try {
|
|
46
125
|
const [cmd, sub] = pos;
|
|
126
|
+
refuseUnknownFlags(cmd, sub);
|
|
47
127
|
switch (cmd) {
|
|
128
|
+
case 'setup': {
|
|
129
|
+
const agent = flags.agent === undefined ? 'none' : flags.agent;
|
|
130
|
+
if (agent === true) throw new Error('unknown agent\nNext step: proofline setup --agent none');
|
|
131
|
+
process.stdout.write(G.setup(root, { agent: String(agent) }));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'task': {
|
|
135
|
+
process.stdout.write(G.task(root, pos.slice(1).join(' ')));
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
48
138
|
case 'init': {
|
|
49
139
|
if (flags.approvals === true) throw new Error('--approvals needs a directory');
|
|
50
140
|
const r = P.init(root, { hooks: !flags['no-hooks'], approvals: flags.approvals });
|
|
@@ -58,21 +148,79 @@ try {
|
|
|
58
148
|
const out = specs.map((s) => P.contractCreate(root, s));
|
|
59
149
|
print(out, out.map((o) => `${o.task.id}: ${o.valid ? 'valid' : 'INCOMPLETE — ' + o.problems.join('; ')}`).join('\n'));
|
|
60
150
|
process.exitCode = out.every((o) => o.valid) ? 0 : 1;
|
|
61
|
-
} else if (sub === 'validate') {
|
|
151
|
+
} else if (sub === 'validate') {
|
|
152
|
+
const loaded = P.C.load(root);
|
|
153
|
+
const { errors, warnings } = P.C.lint(loaded, { root, base: base() || P.C.defaultBase(loaded) });
|
|
154
|
+
const lines = [...errors, ...warnings.map((w) => `warning: ${w}`)];
|
|
155
|
+
print({ valid: !errors.length, problems: errors, warnings }, lines.length ? lines.join('\n') : 'contract valid');
|
|
156
|
+
process.exitCode = errors.length ? 1 : 0;
|
|
157
|
+
} else if (sub === 'merge') {
|
|
158
|
+
const frag = pos[2];
|
|
159
|
+
if (!frag) throw new Error('contract merge needs a fragment file');
|
|
160
|
+
const owners = flags.owners && flags.owners !== true ? String(flags.owners) : '';
|
|
161
|
+
const out = P.C.mergeContract(root, path.resolve(root, frag), { owners });
|
|
162
|
+
print(out, out.sha256);
|
|
163
|
+
} else if (sub === 'coverage') {
|
|
164
|
+
const req = pos[2];
|
|
165
|
+
if (!req) throw new Error('contract coverage needs a requirements file');
|
|
166
|
+
const contracts = flags.contracts && flags.contracts !== true ? String(flags.contracts).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
167
|
+
const out = P.C.coverageReport(root, path.resolve(root, req), { contracts: contracts.map((f) => path.resolve(root, f)) });
|
|
168
|
+
print(out, out.lines.join('\n'));
|
|
169
|
+
process.exitCode = out.ok ? 0 : 1;
|
|
170
|
+
} else if (sub === 'scaffold') {
|
|
171
|
+
if (typeof flags.title !== 'string') throw new Error('scaffold needs --title');
|
|
172
|
+
if (typeof flags.scope !== 'string') throw new Error('scaffold needs --scope');
|
|
173
|
+
const kind = flags.kind && flags.kind !== true ? String(flags.kind) : 'feature';
|
|
174
|
+
const id = typeof flags.id === 'string' ? flags.id : 'TASK';
|
|
175
|
+
const task = P.C.scaffoldTask({ title: flags.title, scope: flags.scope, kind, id });
|
|
176
|
+
print(task, JSON.stringify(task, null, 2));
|
|
177
|
+
}
|
|
178
|
+
else if (sub === 'sign') process.stdout.write(S.signContract(root));
|
|
179
|
+
else if (sub === 'verify-sig') {
|
|
180
|
+
const r = S.verifyContractSignature(root);
|
|
181
|
+
if (!r.ok) { console.error(`proofline: contract not owner-signed\n${r.detail}`); process.exitCode = 2; }
|
|
182
|
+
else process.stdout.write(`${r.detail}\n`);
|
|
183
|
+
}
|
|
184
|
+
else if (sub === 'propose') process.stdout.write(S.proposeContract(root));
|
|
185
|
+
else if (sub === 'approve') {
|
|
186
|
+
process.stdout.write(G.approve(root, pos[2]));
|
|
187
|
+
}
|
|
62
188
|
else if (sub === 'show') print(P.C.load(root));
|
|
63
189
|
else { console.error(HELP); process.exitCode = 3; }
|
|
64
190
|
break;
|
|
65
191
|
}
|
|
66
|
-
case 'next': {
|
|
67
|
-
|
|
192
|
+
case 'next': {
|
|
193
|
+
if (!ownerGate(root)) break;
|
|
194
|
+
const n = P.taskNext(root, { owner: owner(), ...(flags.base === undefined ? {} : { base: base() }) }); print(n, n.brief || n.message); break;
|
|
195
|
+
}
|
|
196
|
+
case 'brief': {
|
|
197
|
+
if (!ownerGate(root)) break;
|
|
198
|
+
const b = P.taskBrief(root, sub, { owner: owner(), base: base() }); print({ brief: b }, b); break;
|
|
199
|
+
}
|
|
68
200
|
case 'verify': case 'gate': {
|
|
201
|
+
if (!ownerGate(root)) break;
|
|
202
|
+
const requested = sub;
|
|
203
|
+
const settled = P.settleLanded(root);
|
|
204
|
+
if (settled && !flags.all && !flags.ci && (!requested || requested === settled.task)) {
|
|
205
|
+
print({ landed: true, task: settled.task }, settled.message);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
69
208
|
const c = P.C.load(root);
|
|
70
|
-
const ids = flags.all ? c.tasks.map((t) => t.id) : flags.ci ? (c.ci?.tasks || []) : [
|
|
71
|
-
if (!ids.length) {
|
|
209
|
+
const ids = flags.all ? c.tasks.map((t) => t.id) : flags.ci ? (c.ci?.tasks || []) : [requested || P.activeTask(root)].filter(Boolean);
|
|
210
|
+
if (!ids.length) {
|
|
211
|
+
if (flags.ci) {
|
|
212
|
+
// Clearing ci.tasks is still a contract change. Refuse it before the empty-list short-circuit.
|
|
213
|
+
const refusal = P.E.unapprovedContractChange(root, base());
|
|
214
|
+
if (refusal) { print({ decision: 'BLOCKED', blocked: [refusal] }, `${refusal}\n\nDECISION: BLOCKED`); process.exitCode = 2; break; }
|
|
215
|
+
const e = P.C.validate(c); print({ decision: e.length ? 'FAIL' : 'PASS', problems: e }, e.length ? e.join('\n') : 'contract valid; no ci.tasks'); process.exitCode = e.length ? 1 : 0; break;
|
|
216
|
+
}
|
|
217
|
+
throw new Error('which task? (proofline next)');
|
|
218
|
+
}
|
|
72
219
|
const outs = ids.map((id) => P.E.gate(root, id, { claims: claims(), base: base(), fresh: flags.fresh === true, skipTypes: flags['skip-pushed'] || flags.ci ? ['pushed'] : [] }));
|
|
73
220
|
const worst = outs.some((o) => o.decision === 'BLOCKED') ? 'BLOCKED' : outs.some((o) => o.decision === 'FAIL') ? 'FAIL' : 'PASS';
|
|
221
|
+
const note = settled ? `${settled.message}\n\n` : '';
|
|
74
222
|
print(outs.length === 1 ? outs[0] : { decision: worst, gates: outs.map((o) => ({ task: o.task, decision: o.decision, failing: o.failing, blocked: o.blocked })) },
|
|
75
|
-
outs.map((o) => `== ${o.task}: ${o.decision}\n base: ${o.base_ref} = ${o.base?.slice(0, 8) || 'not found'} (${o.base_source})\n${o.results.map((r) => ` ${r.status.toUpperCase().padEnd(5)} ${r.id} [${r.type}] ${r.detail.split('\n')[0]}`).join('\n')}${o.forbidden.length ? '\n forbidden: ' + o.forbidden.map((f) => `${f.rule}@${f.file}`).join(', ') : ''}\n report: ${o.report}\n\n${o.corrective_prompt}`).join('\n\n') + `\n\nDECISION: ${worst}`);
|
|
223
|
+
note + outs.map((o) => `== ${o.task}: ${o.decision}\n base: ${o.base_ref} = ${o.base?.slice(0, 8) || 'not found'} (${o.base_source})\n${o.results.map((r) => ` ${r.status.toUpperCase().padEnd(5)} ${r.id} [${r.type}] ${r.detail.split('\n')[0]}`).join('\n')}${o.forbidden.length ? '\n forbidden: ' + o.forbidden.map((f) => `${f.rule}@${f.file}`).join(', ') : ''}\n report: ${o.report}\n\n${o.corrective_prompt}`).join('\n\n') + `\n\nDECISION: ${worst}`);
|
|
76
224
|
process.exitCode = code(worst);
|
|
77
225
|
break;
|
|
78
226
|
}
|
|
@@ -85,10 +233,57 @@ try {
|
|
|
85
233
|
else { console.error(HELP); process.exitCode = 3; }
|
|
86
234
|
break;
|
|
87
235
|
}
|
|
236
|
+
case 'verify-receipt': {
|
|
237
|
+
if (!sub) { console.error(HELP); process.exitCode = 3; break; }
|
|
238
|
+
const r = await verifyReceipt(sub, {
|
|
239
|
+
root, keys: flags.keys === true ? undefined : flags.keys, sigstore: flags.sigstore === true,
|
|
240
|
+
recompute: flags.recompute === true, base: flags.base === true ? undefined : flags.base,
|
|
241
|
+
});
|
|
242
|
+
print(r, r.message);
|
|
243
|
+
process.exitCode = r.exitCode;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'receipt': {
|
|
247
|
+
const r = await buildReceipt(root, {
|
|
248
|
+
base: flags.base === true ? undefined : flags.base,
|
|
249
|
+
role: flags.role === true ? undefined : flags.role,
|
|
250
|
+
task: flags.task === true ? undefined : flags.task,
|
|
251
|
+
vault: flags.vault === true,
|
|
252
|
+
});
|
|
253
|
+
print(r);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
88
256
|
case 'hook': {
|
|
89
|
-
|
|
257
|
+
if ((sub === 'pre-commit' || sub === 'pre-push') && !ownerGate(root)) break;
|
|
258
|
+
if (sub === 'stop') {
|
|
259
|
+
let payload = {};
|
|
260
|
+
try {
|
|
261
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
262
|
+
if (raw.trim()) payload = JSON.parse(raw);
|
|
263
|
+
} catch { break; }
|
|
264
|
+
const where = flags.root ? root : (payload.cwd || root);
|
|
265
|
+
const out = await hookStop(where, payload);
|
|
266
|
+
if (out.receipts.length === 1) print(out.receipts[0]);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
let lines;
|
|
270
|
+
if (sub === 'pre-push' && !process.stdin.isTTY) lines = fs.readFileSync(0, 'utf8').split('\n').filter((l) => l.trim());
|
|
271
|
+
const pushUrl = typeof pos[3] === 'string' ? pos[3] : undefined;
|
|
272
|
+
const r = sub === 'pre-commit' ? P.hookPreCommit(root) : sub === 'pre-push' ? P.hookPrePush(root, { lines, pushUrl }) : null;
|
|
90
273
|
if (!r) { process.exitCode = 3; break; }
|
|
91
274
|
if (!r.ok) { console.error(`proofline ${sub}: REFUSED\n${r.msg}\n(There is no bypass. Fix the cause.)`); process.exitCode = 1; }
|
|
275
|
+
else if (r.msg) console.error(r.msg);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
case 'watch': {
|
|
279
|
+
process.exitCode = G.watch(root, sub);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'connect': {
|
|
283
|
+
if (sub !== 'claude-code') { console.error(HELP); process.exitCode = 3; break; }
|
|
284
|
+
const r = connectClaudeCode();
|
|
285
|
+
if (!r.ok) { console.error(`proofline connect: ${r.reason}`); process.exitCode = 1; break; }
|
|
286
|
+
print(r, `hook installed in ${r.file}`);
|
|
92
287
|
break;
|
|
93
288
|
}
|
|
94
289
|
case 'mcp': await import('../src/mcp.mjs').then((m) => m.serve(root)); break;
|
package/docs/receipt.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Receipt
|
|
2
|
+
|
|
3
|
+
Without a contract, `receipt` infers the suite from the manifest at the base revision. A change to that command is reported and is not what runs.
|
|
4
|
+
|
|
5
|
+
| Manifest | Command |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| package.json `scripts.test` | that script, else `vitest run` or `jest` when that dev dependency is present |
|
|
8
|
+
| Cargo.toml | `cargo test` |
|
|
9
|
+
| pyproject.toml `[tool.pytest]` or pytest.ini | `pytest` |
|
|
10
|
+
| go.mod | `go test ./...` |
|
|
11
|
+
| build.gradle, `*.sln` | no default command, status blocked |
|
|
12
|
+
|
|
13
|
+
Changed tests are paths under `test/` or `tests/`, or names matching `*_test.*`, `*.test.*`, `*.spec.*` or `test_*.py`. Nothing inferable yields an L0 receipt and exit 2. A missing toolchain is blocked, not a pass.
|
|
14
|
+
|
|
15
|
+
`verify-receipt` checks the DSSE envelope offline against PEM files in `--keys` (or `$AGENTKEY_HOME/keys`), named `<keyid>.pem`. Exit 0 is verified, 1 is a bad signature, a schema problem or a `--recompute` difference, 2 is an unknown key, and 3 is usage. `--sigstore` is the only path that loads that verifier.
|
|
16
|
+
|
|
17
|
+
The MCP tools `receipt` and `verify_receipt` return that same JSON. `tools/list` shows nine tools.
|
|
18
|
+
|
|
19
|
+
`proofline hook stop` reads a Claude Code Stop payload and writes one receipt for the commits that session made. A second run on the same range writes none. `proofline connect claude-code` adds that hook after any Stop hook already in `~/.claude/settings.json`.
|
|
20
|
+
|
|
21
|
+
When `GET /v1/state/digest` answers within 2 seconds, an L1 receipt gains `memory.vault_digest` and `memory.frontier`. An L2 receipt in ci mode gains only `memory_commitment`, the sha256 of the vault id, the digest and the frontier JSON, so a replica id is not in the receipt. No answer leaves the receipt unchanged. `--vault` writes `work/receipts/<date>-<role>-<sha7>.md` with origin `proofline`.
|
|
22
|
+
|
|
23
|
+
The weakening check is a warning beside the owner signature. It names a loosened threshold, a removed criterion, or a changed command. Once `guard/owner.pub` is pinned, a missing, altered or wrongly-signed contract is refused with `contract not owner-signed`.
|
|
24
|
+
|
|
25
|
+
grep, wiring and the forbidden scan are marked `heuristic` in the receipt. They are one layer, not a boundary. An isolated run records `isolated` and `env_digest`. A local run records `isolated` false.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optitransfer/proofline",
|
|
3
|
-
"version": "0.2.0-next.
|
|
3
|
+
"version": "0.2.0-next.1",
|
|
4
4
|
"description": "Independent V&V for coding agents. The agent says done; Proofline re-runs every check against a contract, rejects claims the evidence does not support, and gates the push.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -55,5 +55,8 @@
|
|
|
55
55
|
"demo": "node examples/demo/demo.mjs",
|
|
56
56
|
"self-gate": "node bin/proofline.mjs gate PL-SELF --skip-pushed",
|
|
57
57
|
"mcp": "node bin/proofline.mjs mcp"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"hivevault": "0.3.3"
|
|
58
61
|
}
|
|
59
62
|
}
|
package/src/checks.mjs
CHANGED
|
@@ -58,8 +58,10 @@ export function diffHunks(root, base) {
|
|
|
58
58
|
return files;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function runCounted(cmd, root, v) {
|
|
62
|
-
const r =
|
|
61
|
+
function runCounted(cmd, root, v, ctx) {
|
|
62
|
+
const r = ctx?.sandbox
|
|
63
|
+
? ctx.sandbox(cmd, { cwd: root, timeoutS: v.timeout_s || 900, env: v.env, writable: v.writable })
|
|
64
|
+
: run(cmd, { cwd: root, timeoutS: v.timeout_s || 900, env: v.env });
|
|
63
65
|
const txt = r.stdout + '\n' + r.stderr;
|
|
64
66
|
const num = (k) => { const m = txt.match(new RegExp(`^[#ℹ]\\s*${k}\\s+(\\d+)`, 'm')); return m ? +m[1] : undefined; };
|
|
65
67
|
r.counts = { tests: num('tests'), pass: num('pass'), fail: num('fail'), skipped: num('skipped'), todo: num('todo'), cancelled: num('cancelled') };
|
|
@@ -103,7 +105,7 @@ const named = (e) => {
|
|
|
103
105
|
export const verifiers = {
|
|
104
106
|
command(v, ctx) {
|
|
105
107
|
const expect = v.expect_exit ?? 0;
|
|
106
|
-
let r = runCounted(v.cmd, ctx.root, v);
|
|
108
|
+
let r = runCounted(v.cmd, ctx.root, v, ctx);
|
|
107
109
|
const first = ev(r), firstExit = r.exit;
|
|
108
110
|
if (r.exit === 127) return { status: 'error', detail: `command not found: ${v.cmd}`, evidence: first };
|
|
109
111
|
const tooMany = (r2) => {
|
|
@@ -117,7 +119,7 @@ export const verifiers = {
|
|
|
117
119
|
if (r.exit === expect) return { status: 'fail', detail: [...tooMany(r), named(first)].filter(Boolean).join('; '), evidence: first };
|
|
118
120
|
// Both runs keep their failing names: on a flaky result the first run's list is the tests that flapped.
|
|
119
121
|
for (let i = 0; i < (ctx.flakyReruns ?? 1); i++) {
|
|
120
|
-
r = runCounted(v.cmd, ctx.root, v);
|
|
122
|
+
r = runCounted(v.cmd, ctx.root, v, ctx);
|
|
121
123
|
if (r.exit === expect && !tooMany(r).length) return { status: 'flaky', detail: `failed (exit ${firstExit}${named(first) ? `; ${named(first)}` : ''}) then passed on re-run ${i + 1}: flaky, not a pass. Root-cause the non-determinism.`, evidence: { first, rerun: ev(r) } };
|
|
122
124
|
}
|
|
123
125
|
const rerun = ev(r), n = named(rerun.failing.length ? rerun : first);
|
|
@@ -150,8 +152,8 @@ export const verifiers = {
|
|
|
150
152
|
}
|
|
151
153
|
if (v.setup) run(v.setup, { cwd: wt, timeoutS: 900 });
|
|
152
154
|
const cmd = sub(v.cmd, { test: v.test_file, name: v.test_name || '' });
|
|
153
|
-
const red = runCounted(cmd, wt, v);
|
|
154
|
-
const green = runCounted(cmd, ctx.root, v);
|
|
155
|
+
const red = runCounted(cmd, wt, v, ctx);
|
|
156
|
+
const green = runCounted(cmd, ctx.root, v, ctx);
|
|
155
157
|
const evd = { base: ctx.base, red: ev(red), green: ev(green) };
|
|
156
158
|
if (red.exit === 127 || green.exit === 127) return { status: 'error', detail: 'test command not found', evidence: evd };
|
|
157
159
|
if (red.exit === 0) return { status: 'fail', detail: `test PASSES on base ${ctx.base.slice(0, 8)} without the change: it does not prove the change (tests how, not what?)`, evidence: evd };
|
|
@@ -174,7 +176,10 @@ export const verifiers = {
|
|
|
174
176
|
const m = (ctx.machine || readMachine)();
|
|
175
177
|
const machine = { load1: m.load1, cpus: m.cpus, load_per_cpu: +(m.load1 / Math.max(1, m.cpus)).toFixed(2), node: nodeVersion(ctx.root, v.env) };
|
|
176
178
|
if (typeof max === 'number' && m.load1 / Math.max(1, m.cpus) > max) return { r: null, machine };
|
|
177
|
-
|
|
179
|
+
const exec = ctx.sandbox
|
|
180
|
+
? (cmd, o) => ctx.sandbox(cmd, { ...o, writable: v.writable })
|
|
181
|
+
: (cmd, o) => run(cmd, o);
|
|
182
|
+
return { r: exec(v.cmd, { cwd: ctx.root, timeoutS: v.timeout_s || 1800, env: v.env }), machine };
|
|
178
183
|
})();
|
|
179
184
|
const on = `load ${machine.load1.toFixed(2)} / ${machine.cpus} CPUs, node ${machine.node || 'not found'}`;
|
|
180
185
|
if (!r) return { status: 'blocked', detail: `machine busy: load ${machine.load1.toFixed(2)} over ${machine.cpus} CPUs = ${machine.load_per_cpu.toFixed(2)} per CPU > max_load_per_cpu ${max}; the bench was not run. Re-run when the machine is idle.`, evidence: { cmd: v.cmd, machine } };
|