results-cli 0.5.2__tar.gz → 0.5.3__tar.gz

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.
Files changed (31) hide show
  1. {results_cli-0.5.2 → results_cli-0.5.3}/PKG-INFO +73 -3
  2. {results_cli-0.5.2 → results_cli-0.5.3}/README.md +72 -2
  3. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/.claude-plugin/plugin.json +1 -1
  4. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/skills/results/SKILL.md +24 -0
  5. {results_cli-0.5.2 → results_cli-0.5.3}/pyproject.toml +1 -1
  6. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/audit.py +86 -0
  7. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/cli.py +50 -1
  8. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/record.py +165 -4
  9. results_cli-0.5.3/tests/test_annotate.py +244 -0
  10. results_cli-0.5.3/tests/test_review.py +152 -0
  11. {results_cli-0.5.2 → results_cli-0.5.3}/.gitignore +0 -0
  12. {results_cli-0.5.2 → results_cli-0.5.3}/LICENSE +0 -0
  13. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/README.md +0 -0
  14. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/commands/results-check.md +0 -0
  15. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/hooks/hooks.json +0 -0
  16. {results_cli-0.5.2 → results_cli-0.5.3}/plugin/hooks/unbound_numbers.py +0 -0
  17. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/__init__.py +0 -0
  18. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/ledger.py +0 -0
  19. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/manuscript.py +0 -0
  20. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/paths.py +0 -0
  21. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/stamps.py +0 -0
  22. {results_cli-0.5.2 → results_cli-0.5.3}/src/results/timeline.py +0 -0
  23. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_cli.py +0 -0
  24. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_cli_help.py +0 -0
  25. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_concurrency.py +0 -0
  26. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_coverage_and_hook.py +0 -0
  27. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_ledger.py +0 -0
  28. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_manuscript.py +0 -0
  29. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_tamper.py +0 -0
  30. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_timeline.py +0 -0
  31. {results_cli-0.5.2 → results_cli-0.5.3}/tests/test_timestamp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: results-cli
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Seal a run, record what it produced, and verify the chain
5
5
  Project-URL: Changelog, https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md
6
6
  Project-URL: Homepage, https://github.com/elliottower/reproducible-science
@@ -24,11 +24,11 @@ Description-Content-Type: text/markdown
24
24
  [![pypi](https://img.shields.io/pypi/v/results-cli)](https://pypi.org/project/results-cli/)
25
25
  [![python](https://img.shields.io/pypi/pyversions/results-cli)](https://pypi.org/project/results-cli/)
26
26
  [![license](https://img.shields.io/pypi/l/results-cli)](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
27
- [![docs](https://img.shields.io/badge/docs-live-blue)](https://elliottower.github.io/reproducible-science/)
27
+ [![docs](https://img.shields.io/badge/docs-live-blue)](https://reproducible.science/)
28
28
 
29
29
  Seal inputs, record outputs, and bind a paper's claims to the runs behind them.
30
30
 
31
- Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://elliottower.github.io/reproducible-science/tools/results/).
31
+ Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://reproducible.science/tools/results/).
32
32
 
33
33
  ## Install
34
34
 
@@ -76,6 +76,8 @@ all checks passed.
76
76
  | `results access <note>` | Record a data-access event |
77
77
  | `results run <file>...` | Record outputs after a run |
78
78
  | `results claim <text>` | Bind a manuscript claim to a run |
79
+ | `results review <file>...` | Record that someone examined these files, and what they found |
80
+ | `results annotate <file>...` | Link these files to something outside the ledger; linked, not checked |
79
81
  | `results verify` | Check the ledger chain and every hash it names |
80
82
  | `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
81
83
 
@@ -91,6 +93,74 @@ manuscript → claim → run → output file → sha256
91
93
 
92
94
  `results verify --files` walks the whole thing and tells you what moved.
93
95
 
96
+ ## Reviews
97
+
98
+ Some checks are done by reading: a person, or a model, compares a table with the file behind it.
99
+ `results review` records that the reading happened, and of which bytes.
100
+
101
+ ```bash
102
+ results review results/table3.csv paper/table3.pdf \
103
+ --scope "every cell of Table 3 against the regenerated file" \
104
+ --procedure "read the rendered page beside the csv" \
105
+ --verdict partial --reviewer "A. Reader" --model claude-opus-5-5 \
106
+ --note "30 cells agree at the certified revision; 4 differ"
107
+ ```
108
+
109
+ Each file is recorded by its sha256, so the review is of those bytes and no others.
110
+ `results verify --files` lists every review and marks a subject `STALE` once the file has
111
+ changed, without failing: a reviewed file that then changes is the ordinary result of acting on
112
+ a review. `--scope`, `--procedure`, `--verdict` and `--reviewer` are required, and the verdict
113
+ is one of `pass`, `fail`, `partial` and `uncertain`.
114
+
115
+ The record is self-reported and unsigned. The ledger shows that the review was stated and when;
116
+ nothing checks who stated it. `--model` is the identifier the caller gives. Inside Claude Code
117
+ the command also records the harness, its version and the session identifier from the
118
+ environment, which does not say which model ran, so a model reviewer passes `--model`.
119
+
120
+ ## Annotations
121
+
122
+ Some of what a paper rests on lives outside the repository: a Weights & Biases artifact, a
123
+ deposited dataset, an OSF node, a row in another group's table. `results annotate` records that
124
+ a file here corresponds to that outside thing.
125
+
126
+ ```bash
127
+ results annotate checkpoints/model.pt \
128
+ --kind wandb:artifact --ref "lab/study/model:v3" \
129
+ --digest md5:0123456789abcdef0123456789abcdef \
130
+ --by "A. Reader" --note "the checkpoint behind Table 2"
131
+
132
+ results annotate results/table2.csv \
133
+ --kind doi:dataset --ref 10.5281/zenodo.1234567 --location "Table 2"
134
+ ```
135
+
136
+ An annotation is a statement of a link and not a check: the tool fetches nothing and compares
137
+ nothing, and the event carries the status `linked, not checked`. Each file is recorded by its
138
+ sha256, so the link is stated of those bytes.
139
+
140
+ `--kind` and `--ref` are required. `--kind` is `namespace:type` in lowercase, such as
141
+ `wandb:artifact`, `doi:dataset`, `osf:node`, `url:page`, `zenodo:record` or `hf:dataset`; any
142
+ kind of that shape is accepted, and a malformed kind writes nothing. `--ref` is the artifact
143
+ path, DOI, URL or identifier. `--digest` records the digest the outside system reports, as
144
+ `algorithm:hex`. `--by` names who states the link, `--note` says anything else, and `--location`
145
+ names where in the manuscript the linked number or sentence appears. No session information is
146
+ recorded.
147
+
148
+ ```text
149
+ annotations (linked, not checked):
150
+ 2026-10-11T16:54:21 wandb:artifact lab/study/model:v3 [A. Reader]
151
+ digest as stated: md5:0123456789abcdef0123456789abcdef
152
+ STALE checkpoints/model.pt
153
+ annotated 1b465fa6b6bcbc06…, now 7e4fa2eb8c7ac089…
154
+ 2026-10-11T16:54:21 doi:dataset 10.5281/zenodo.1234567
155
+ appears in: Table 2
156
+ current results/table2.csv
157
+ 1 annotated file(s) are no longer the bytes the link was stated of.
158
+ ```
159
+
160
+ `results verify` lists every annotation. With `--files` each file is `current`, `STALE` once it
161
+ has changed, or `MISSING`; without `--files` it is `not checked`. None of the three changes the
162
+ exit code.
163
+
94
164
  ## Data-access levels
95
165
 
96
166
  The access timeline is what makes the confirmatory/exploratory distinction verifiable.
@@ -3,11 +3,11 @@
3
3
  [![pypi](https://img.shields.io/pypi/v/results-cli)](https://pypi.org/project/results-cli/)
4
4
  [![python](https://img.shields.io/pypi/pyversions/results-cli)](https://pypi.org/project/results-cli/)
5
5
  [![license](https://img.shields.io/pypi/l/results-cli)](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
6
- [![docs](https://img.shields.io/badge/docs-live-blue)](https://elliottower.github.io/reproducible-science/)
6
+ [![docs](https://img.shields.io/badge/docs-live-blue)](https://reproducible.science/)
7
7
 
8
8
  Seal inputs, record outputs, and bind a paper's claims to the runs behind them.
9
9
 
10
- Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://elliottower.github.io/reproducible-science/tools/results/).
10
+ Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://reproducible.science/tools/results/).
11
11
 
12
12
  ## Install
13
13
 
@@ -55,6 +55,8 @@ all checks passed.
55
55
  | `results access <note>` | Record a data-access event |
56
56
  | `results run <file>...` | Record outputs after a run |
57
57
  | `results claim <text>` | Bind a manuscript claim to a run |
58
+ | `results review <file>...` | Record that someone examined these files, and what they found |
59
+ | `results annotate <file>...` | Link these files to something outside the ledger; linked, not checked |
58
60
  | `results verify` | Check the ledger chain and every hash it names |
59
61
  | `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
60
62
 
@@ -70,6 +72,74 @@ manuscript → claim → run → output file → sha256
70
72
 
71
73
  `results verify --files` walks the whole thing and tells you what moved.
72
74
 
75
+ ## Reviews
76
+
77
+ Some checks are done by reading: a person, or a model, compares a table with the file behind it.
78
+ `results review` records that the reading happened, and of which bytes.
79
+
80
+ ```bash
81
+ results review results/table3.csv paper/table3.pdf \
82
+ --scope "every cell of Table 3 against the regenerated file" \
83
+ --procedure "read the rendered page beside the csv" \
84
+ --verdict partial --reviewer "A. Reader" --model claude-opus-5-5 \
85
+ --note "30 cells agree at the certified revision; 4 differ"
86
+ ```
87
+
88
+ Each file is recorded by its sha256, so the review is of those bytes and no others.
89
+ `results verify --files` lists every review and marks a subject `STALE` once the file has
90
+ changed, without failing: a reviewed file that then changes is the ordinary result of acting on
91
+ a review. `--scope`, `--procedure`, `--verdict` and `--reviewer` are required, and the verdict
92
+ is one of `pass`, `fail`, `partial` and `uncertain`.
93
+
94
+ The record is self-reported and unsigned. The ledger shows that the review was stated and when;
95
+ nothing checks who stated it. `--model` is the identifier the caller gives. Inside Claude Code
96
+ the command also records the harness, its version and the session identifier from the
97
+ environment, which does not say which model ran, so a model reviewer passes `--model`.
98
+
99
+ ## Annotations
100
+
101
+ Some of what a paper rests on lives outside the repository: a Weights & Biases artifact, a
102
+ deposited dataset, an OSF node, a row in another group's table. `results annotate` records that
103
+ a file here corresponds to that outside thing.
104
+
105
+ ```bash
106
+ results annotate checkpoints/model.pt \
107
+ --kind wandb:artifact --ref "lab/study/model:v3" \
108
+ --digest md5:0123456789abcdef0123456789abcdef \
109
+ --by "A. Reader" --note "the checkpoint behind Table 2"
110
+
111
+ results annotate results/table2.csv \
112
+ --kind doi:dataset --ref 10.5281/zenodo.1234567 --location "Table 2"
113
+ ```
114
+
115
+ An annotation is a statement of a link and not a check: the tool fetches nothing and compares
116
+ nothing, and the event carries the status `linked, not checked`. Each file is recorded by its
117
+ sha256, so the link is stated of those bytes.
118
+
119
+ `--kind` and `--ref` are required. `--kind` is `namespace:type` in lowercase, such as
120
+ `wandb:artifact`, `doi:dataset`, `osf:node`, `url:page`, `zenodo:record` or `hf:dataset`; any
121
+ kind of that shape is accepted, and a malformed kind writes nothing. `--ref` is the artifact
122
+ path, DOI, URL or identifier. `--digest` records the digest the outside system reports, as
123
+ `algorithm:hex`. `--by` names who states the link, `--note` says anything else, and `--location`
124
+ names where in the manuscript the linked number or sentence appears. No session information is
125
+ recorded.
126
+
127
+ ```text
128
+ annotations (linked, not checked):
129
+ 2026-10-11T16:54:21 wandb:artifact lab/study/model:v3 [A. Reader]
130
+ digest as stated: md5:0123456789abcdef0123456789abcdef
131
+ STALE checkpoints/model.pt
132
+ annotated 1b465fa6b6bcbc06…, now 7e4fa2eb8c7ac089…
133
+ 2026-10-11T16:54:21 doi:dataset 10.5281/zenodo.1234567
134
+ appears in: Table 2
135
+ current results/table2.csv
136
+ 1 annotated file(s) are no longer the bytes the link was stated of.
137
+ ```
138
+
139
+ `results verify` lists every annotation. With `--files` each file is `current`, `STALE` once it
140
+ has changed, or `MISSING`; without `--files` it is `not checked`. None of the three changes the
141
+ exit code.
142
+
73
143
  ## Data-access levels
74
144
 
75
145
  The access timeline is what makes the confirmatory/exploratory distinction verifiable.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "results",
3
3
  "description": "Seal a run's inputs, record what it produced, bind every number in a manuscript to the run behind it as it is written, and verify the chain",
4
- "version": "0.5.2",
4
+ "version": "0.5.3",
5
5
  "author": {
6
6
  "name": "Elliot Tower",
7
7
  "email": "elliot@elliottower.ai"
@@ -25,6 +25,8 @@ results seal <file>... [--role input] # hash inputs before a run
25
25
  results access <note> [--level ...] # record a data-access event
26
26
  results run <file>... --run-id <id> # record outputs after a run
27
27
  results claim <text> --run-id <id> # bind a manuscript claim to a run
28
+ results review <file>... --scope ... --procedure ... --verdict ... --reviewer ... # record a reading
29
+ results annotate <file>... --kind <namespace:type> --ref <reference> # link to an outside artifact; not checked
28
30
  results verify [--files] # check the chain and every hash it names
29
31
  results coverage <manuscript> # which of a paper's numbers are bound
30
32
  ```
@@ -104,6 +106,28 @@ With `--files`, every sealed input and recorded output is re-hashed against its
104
106
  **`CHAIN BROKEN` means the ledger was tampered with.** It does not mean the results are wrong —
105
107
  it means the evidence that they are right was damaged. Restore from git history.
106
108
 
109
+ ## Reviews
110
+
111
+ When you check something by reading it (a table against its file, a mismatch list against the
112
+ paper), record it: `results review <file>... --scope "<what was checked>" --procedure "<how>"
113
+ --verdict pass|fail|partial|uncertain --reviewer "<who answers for it>"`. Pass `--model` with
114
+ your own model identifier when you did the reading; the harness, its version and the session
115
+ are read from the environment. Each file is recorded by digest, and `results verify --files`
116
+ marks the review `STALE` once a file changes. The record is self-reported and unsigned, so
117
+ describe it as a recorded review and never as verified.
118
+
119
+ ## Annotations
120
+
121
+ When a file corresponds to something outside the repository (a W&B artifact, a dataset DOI, an
122
+ OSF node, a URL), record the link: `results annotate <file>... --kind <namespace:type> --ref
123
+ "<artifact path, DOI, URL or id>"`, with `--kind` such as `wandb:artifact`, `doi:dataset`,
124
+ `osf:node` or `url:page`. Optional: `--digest <algorithm:hex>` for the digest the outside system
125
+ reports, `--by`, `--note`, and `--location "<where in the manuscript>"`. An annotation is a
126
+ statement of a link and not a check: the tool fetches nothing, and the event's status is
127
+ `linked, not checked`. `results verify --files` marks the file `STALE` once it changes and never
128
+ fails on it. Describe an annotated file as linked and never as verified, and do not use an
129
+ annotation in place of `results run` for an output the project produced.
130
+
107
131
  ## Claims
108
132
 
109
133
  A claim names a run. A run names its outputs. The outputs were hashed when they were recorded.
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "results-cli"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "Seal a run, record what it produced, and verify the chain"
9
9
  readme = "README.md"
10
10
  keywords = [ "open science", "provenance", "reproducibility", "results" ]
@@ -19,6 +19,7 @@ from provenance_core import anchor, sha256_of_tree, try_run
19
19
 
20
20
  from results import ledger, manuscript, stamps
21
21
  from results.paths import ledger_path, require_root
22
+ from results.record import LINKED
22
23
  from results.timeline import first_outcomes_seen, first_run_timestamp, precedes
23
24
 
24
25
 
@@ -44,6 +45,75 @@ def in_history(ledger: pathlib.Path) -> str | None:
44
45
  return None
45
46
 
46
47
 
48
+ def reviews(events: list[dict], base: pathlib.Path, check_files: bool) -> list[str]:
49
+ """One block per review, and with `check_files` whether each subject is still those bytes.
50
+
51
+ A review is of a digest. Where the file has changed since, the review says nothing about
52
+ what is there now, and printing the verdict without that would carry it forward. Reported
53
+ and never counted as drift: a reviewed file that then changes is the ordinary result of
54
+ acting on a review.
55
+ """
56
+ lines: list[str] = []
57
+ for e in events:
58
+ if e.get("event") != "review":
59
+ continue
60
+ ran_in = e.get("session") or {}
61
+ harness = " ".join(v for k in ("harness", "harness_version") if (v := ran_in.get(k)))
62
+ named = ", ".join(v for v in (ran_in.get("model"), harness) if v)
63
+ lines.append(
64
+ f" {e['timestamp'][:19]} {e.get('verdict', '?'):<10} {e.get('reviewer', '?')}"
65
+ + (f" [{named}]" if named else "")
66
+ )
67
+ lines.append(f" scope: {e.get('scope', '')}")
68
+ lines += subjects(e, base, check_files, "reviewed")
69
+ return lines
70
+
71
+
72
+ def annotation_lines(events: list[dict], base: pathlib.Path, check_files: bool) -> list[str]:
73
+ """One block per annotation: what it links to, and with `check_files` whether each subject
74
+ is still the bytes the link was stated of.
75
+
76
+ An annotation is a statement that these bytes correspond to something outside the ledger.
77
+ Nothing here reads the target, so the most this can say is whether the local side of the
78
+ link still exists as recorded. Reported and never counted as drift, as a review is.
79
+ """
80
+ lines: list[str] = []
81
+ for e in events:
82
+ if e.get("event") != "annotation":
83
+ continue
84
+ target = e.get("target") or {}
85
+ lines.append(
86
+ f" {e['timestamp'][:19]} {target.get('kind', '?')} {target.get('ref', '?')}"
87
+ + (f" [{by}]" if (by := e.get("by")) else "")
88
+ )
89
+ if stated := target.get("digest"):
90
+ lines.append(f" digest as stated: {stated}")
91
+ if location := e.get("location"):
92
+ lines.append(f" appears in: {location}")
93
+ lines += subjects(e, base, check_files, "annotated")
94
+ return lines
95
+
96
+
97
+ def subjects(event: dict, base: pathlib.Path, check_files: bool, was: str) -> list[str]:
98
+ """Each subject of a review or an annotation, and whether it is still the recorded bytes."""
99
+ lines: list[str] = []
100
+ for subject in event.get("subjects", []):
101
+ path, recorded = subject["path"], subject["sha256"]
102
+ p = base / path
103
+ if not check_files:
104
+ lines.append(f" not checked {path}")
105
+ elif not p.exists():
106
+ lines.append(f" MISSING {path}")
107
+ else:
108
+ now = sha256_of_tree(p)[0] if p.is_dir() else ledger.sha256_of_file(p)
109
+ if now == recorded:
110
+ lines.append(f" current {path}")
111
+ else:
112
+ lines.append(f" STALE {path}")
113
+ lines.append(f" {was} {recorded[:16]}…, now {now[:16]}…")
114
+ return lines
115
+
116
+
47
117
  def coverage(manuscript_path: str, limit: int, strict: bool) -> int:
48
118
  root = require_root()
49
119
  events = ledger.read_ledger(ledger_path(root))
@@ -292,6 +362,22 @@ def verify(check_files: bool) -> int:
292
362
  for e in access_events:
293
363
  print(f" {e['timestamp'][:19]} {e['level']:<20} {e.get('note', '')}")
294
364
 
365
+ if review_lines := reviews(events, root.parent, check_files):
366
+ print("\nreviews (self-reported, unsigned):")
367
+ for line in review_lines:
368
+ print(line)
369
+ stale = sum(1 for line in review_lines if line.lstrip().startswith(("STALE", "MISSING")))
370
+ if stale:
371
+ print(f" {stale} reviewed file(s) are no longer the bytes that were reviewed.")
372
+
373
+ if linked := annotation_lines(events, root.parent, check_files):
374
+ print(f"\nannotations ({LINKED}):")
375
+ for line in linked:
376
+ print(line)
377
+ stale = sum(1 for line in linked if line.lstrip().startswith(("STALE", "MISSING")))
378
+ if stale:
379
+ print(f" {stale} annotated file(s) are no longer the bytes the link was stated of.")
380
+
295
381
  claims = [e for e in events if e.get("event") == "claim"]
296
382
  unlinked = []
297
383
  contested = []
@@ -5,6 +5,8 @@ results seal <file>... hash inputs before a run (prereg, script, data)
5
5
  results access <note> record a data-access event (what you looked at, when)
6
6
  results run <file>... record outputs after a run completes
7
7
  results claim <text> bind a manuscript claim to a run's output
8
+ results review <file>... record that someone examined these files, and what they found
9
+ results annotate <file>... link these files to something outside the ledger; linked, not checked
8
10
  results coverage <paper> how many of a manuscript's numbers are bound to a run
9
11
  results verify check the ledger chain and every hash it names
10
12
  results reanchor record the ledger's current length as authoritative
@@ -31,7 +33,7 @@ from results.paths import find_root as find_root
31
33
  from results.paths import ledger_path as ledger_path
32
34
  from results.paths import record_path as record_path
33
35
  from results.paths import require_root as require_root
34
- from results.record import ACCESS_LEVELS
36
+ from results.record import ACCESS_LEVELS, VERDICTS
35
37
  from results.timeline import first_outcomes_seen as first_outcomes_seen
36
38
  from results.timeline import first_run_timestamp as first_run_timestamp
37
39
  from results.timeline import freeze_timestamp as freeze_timestamp
@@ -65,6 +67,14 @@ def cmd_claim(a) -> int:
65
67
  )
66
68
 
67
69
 
70
+ def cmd_review(a) -> int:
71
+ return record.review(a.files, a.scope, a.procedure, a.verdict, a.reviewer, a.model, a.note)
72
+
73
+
74
+ def cmd_annotate(a) -> int:
75
+ return record.annotate(a.files, a.kind, a.ref, a.digest, a.by, a.note, a.location)
76
+
77
+
68
78
  def cmd_coverage(a) -> int:
69
79
  return audit.coverage(a.manuscript, a.limit, a.strict)
70
80
 
@@ -147,6 +157,45 @@ def _main(argv: list[str] | None = None) -> int:
147
157
  cl.add_argument("--location", help="where in the manuscript: Table 2, Section 4.1, etc.")
148
158
  cl.set_defaults(fn=cmd_claim)
149
159
 
160
+ rv = sub.add_parser(
161
+ "review", help="record that someone examined these files, and what they found"
162
+ )
163
+ rv.add_argument("files", nargs="+", help="what was examined; each is recorded by digest")
164
+ rv.add_argument("--scope", required=True, help="what was checked about them")
165
+ rv.add_argument("--procedure", required=True, help="how it was checked")
166
+ rv.add_argument("--verdict", required=True, help=f"one of: {', '.join(VERDICTS)}")
167
+ rv.add_argument("--reviewer", required=True, help="who answers for the review")
168
+ rv.add_argument(
169
+ "--model",
170
+ help="the model that did the reading, where one did: its identifier, as reported",
171
+ )
172
+ rv.add_argument("--note", help="anything the verdict needs said beside it")
173
+ rv.set_defaults(fn=cmd_review)
174
+
175
+ an = sub.add_parser(
176
+ "annotate", help="link these files to something outside the ledger; linked, not checked"
177
+ )
178
+ an.add_argument(
179
+ "files", nargs="+", help="what the link is stated of; each is recorded by digest"
180
+ )
181
+ an.add_argument(
182
+ "--kind",
183
+ required=True,
184
+ help="what the outside thing is, as namespace:type: wandb:artifact, doi:dataset, "
185
+ "osf:node, url:page",
186
+ )
187
+ an.add_argument(
188
+ "--ref", required=True, help="where it is: an artifact path, a DOI, a URL, an id"
189
+ )
190
+ an.add_argument(
191
+ "--digest",
192
+ help="the digest the outside system states, as algorithm:hex; recorded, never compared",
193
+ )
194
+ an.add_argument("--by", help="who states the link")
195
+ an.add_argument("--note", help="anything the link needs said beside it")
196
+ an.add_argument("--location", help="where in the manuscript: Table 2, Section 4.1, etc.")
197
+ an.set_defaults(fn=cmd_annotate)
198
+
150
199
  v = sub.add_parser("verify", help="check the ledger and every hash it names")
151
200
  v.add_argument(
152
201
  "--files",
@@ -1,16 +1,20 @@
1
1
  """The commands that put an event in the ledger.
2
2
 
3
- `init`, `seal`, `access`, `run` and `claim` share a shape: find the governing `.results/`,
4
- build one event, append it, and report what was written. What separates them is the refusal
5
- each makes first -- a path that is not a file, an access level outside the four, a run id
6
- already in use, a confirmatory claim resting on a run recorded after the outcomes were seen.
3
+ `init`, `seal`, `access`, `run`, `claim`, `review` and `annotate` share a shape: find the
4
+ governing `.results/`, build one event, append it, and report what was written. What separates
5
+ them is the refusal each makes first -- a path that is not a file, an access level outside the
6
+ four, a run id already in use, a confirmatory claim resting on a run recorded after the outcomes
7
+ were seen, a review with a verdict outside the four, an annotation whose kind is not
8
+ `namespace:type`.
7
9
 
8
10
  Each returns the process's exit code. `cli.py` unpacks the argparse namespace and calls in.
9
11
  """
10
12
 
11
13
  from __future__ import annotations
12
14
 
15
+ import os
13
16
  import pathlib
17
+ import re
14
18
 
15
19
  from provenance_core import sha256_of_tree
16
20
 
@@ -24,6 +28,22 @@ ACCESS_LEVELS = [
24
28
  "outcomes seen",
25
29
  ]
26
30
 
31
+ VERDICTS = ["pass", "fail", "partial", "uncertain"]
32
+
33
+ #: What an annotation's target is, as `namespace:type`: `wandb:artifact`, `doi:dataset`,
34
+ #: `osf:node`, `url:page`. Open-ended, because the outside systems a project links to are not
35
+ #: this tool's to enumerate; only the shape is fixed, so that a reader can group by namespace.
36
+ KIND = re.compile(r"[a-z0-9._-]+:[a-z0-9._-]+")
37
+
38
+ #: A digest as the outside system states it: `algorithm:hex`.
39
+ DIGEST = re.compile(r"([a-z0-9][a-z0-9_-]*):([0-9a-fA-F]+)")
40
+
41
+ #: Hex digits in the digests whose length is fixed. Any other algorithm is taken at any length.
42
+ DIGEST_LENGTHS = {"md5": 32, "sha1": 40, "sha256": 64, "sha512": 128}
43
+
44
+ #: Written into every annotation and printed wherever one is shown.
45
+ LINKED = "linked, not checked"
46
+
27
47
 
28
48
  #: Written into `.results/` so the ledger is tracked without anyone editing the project's own
29
49
  #: ignore file. The ledger and its anchor are the record a claim appeals to, and one that exists
@@ -250,3 +270,144 @@ def claim(
250
270
  elif retrospective:
251
271
  print(f" recorded after outcomes were seen at {retrospective[:19]}; verify will report it")
252
272
  return 0
273
+
274
+
275
+ def session(model: str | None) -> dict[str, str]:
276
+ """Where the command ran and which model the caller names, for a review's record.
277
+
278
+ Read off the environment and the `--model` flag, and checked against nothing. Claude Code
279
+ exports its own version and session but not the model, so the model is whatever the caller
280
+ passes. A person who runs the command from inside a session is recorded the same way as a
281
+ model that does, which is why the reviewer is a separate, required field.
282
+ """
283
+ found: dict[str, str] = {}
284
+ if model:
285
+ found["model"] = model
286
+ if os.environ.get("CLAUDECODE") == "1":
287
+ found["harness"] = "Claude Code"
288
+ version = pathlib.PurePath(os.environ.get("CLAUDE_CODE_EXECPATH", "")).name
289
+ if re.fullmatch(r"\d+(\.\d+)+", version):
290
+ found["harness_version"] = version
291
+ if session_id := os.environ.get("CLAUDE_CODE_SESSION_ID"):
292
+ found["session_id"] = session_id
293
+ return found
294
+
295
+
296
+ def digests_of(files: list[str], root: pathlib.Path) -> list[dict[str, str]] | None:
297
+ """Each named file or directory by its digest, or None once one of them is neither.
298
+
299
+ What a review and an annotation both record under `subjects`. Named `subjects`, never
300
+ `files`: `verify --files` reads `files` as seals, and a subject that later changes would
301
+ then report the path as sealed twice.
302
+ """
303
+ subjects = []
304
+ for name in files:
305
+ p = pathlib.Path(name).resolve()
306
+ if p.is_dir():
307
+ digest = sha256_of_tree(p)[0]
308
+ subjects.append({"path": record_path(p, root), "sha256": digest, "kind": "tree"})
309
+ continue
310
+ if not p.is_file():
311
+ print(f"not a file: {name}")
312
+ return None
313
+ digest = ledger.sha256_of_file(p)
314
+ subjects.append({"path": record_path(p, root), "sha256": digest, "kind": "file"})
315
+ return subjects
316
+
317
+
318
+ def review(
319
+ files: list[str],
320
+ scope: str,
321
+ procedure: str,
322
+ verdict: str,
323
+ reviewer: str,
324
+ model: str | None,
325
+ note: str | None,
326
+ ) -> int:
327
+ root = require_root()
328
+ lp = ledger_path(root)
329
+ if verdict not in VERDICTS:
330
+ print(f"verdict must be one of: {', '.join(VERDICTS)}")
331
+ return 1
332
+ subjects = digests_of(files, root)
333
+ if subjects is None:
334
+ return 1
335
+ ran_in = session(model)
336
+ ledger.append_event(
337
+ lp,
338
+ {
339
+ "event": "review",
340
+ "subjects": subjects,
341
+ "scope": scope,
342
+ "procedure": procedure,
343
+ "verdict": verdict,
344
+ "reviewer": reviewer,
345
+ "session": ran_in,
346
+ "note": note or "",
347
+ },
348
+ )
349
+ print(f"review ({verdict}) by {reviewer}: {scope[:72]}")
350
+ for subject in subjects:
351
+ print(f" {subject['sha256'][:16]}… {subject['path']}")
352
+ if ran_in:
353
+ print(f" session: {', '.join(f'{k} {v}' for k, v in ran_in.items())}")
354
+ if "harness" in ran_in and "model" not in ran_in:
355
+ print(" ran inside Claude Code with no --model; pass it if a model did the review.")
356
+ print("self-reported and unsigned: the ledger records that this was said, and of which bytes.")
357
+ return 0
358
+
359
+
360
+ def annotate(
361
+ files: list[str],
362
+ kind: str,
363
+ ref: str,
364
+ digest: str | None,
365
+ by: str | None,
366
+ note: str | None,
367
+ location: str | None,
368
+ ) -> int:
369
+ root = require_root()
370
+ lp = ledger_path(root)
371
+ if not KIND.fullmatch(kind):
372
+ print(f"kind must be namespace:type, such as wandb:artifact or doi:dataset; got '{kind}'")
373
+ print("lowercase letters, digits, '-', '_' and '.' on each side of one colon.")
374
+ return 1
375
+ if not ref.strip():
376
+ print("ref must name the outside thing: an artifact path, a DOI, a URL, an id.")
377
+ return 1
378
+ if digest is not None:
379
+ stated = DIGEST.fullmatch(digest)
380
+ length = DIGEST_LENGTHS.get(stated[1]) if stated else None
381
+ if not stated or (length is not None and len(stated[2]) != length):
382
+ print(f"digest must be algorithm:hex, such as sha256:<64 hex digits>; got '{digest}'")
383
+ return 1
384
+ subjects = digests_of(files, root)
385
+ if subjects is None:
386
+ return 1
387
+ # No session is read here. A review says who or what did the reading, so it records where
388
+ # the command ran; an annotation makes no such statement, and `--by` is whatever was typed.
389
+ target = {"kind": kind, "ref": ref}
390
+ if digest:
391
+ target["digest"] = digest
392
+ event: dict[str, object] = {
393
+ "event": "annotation",
394
+ "subjects": subjects,
395
+ "target": target,
396
+ "status": LINKED,
397
+ "note": note or "",
398
+ }
399
+ if by:
400
+ event["by"] = by
401
+ if location:
402
+ event["location"] = location
403
+ ledger.append_event(lp, event)
404
+ print(f"annotation: {kind} {ref[:72]}" + (f" by {by}" if by else ""))
405
+ for subject in subjects:
406
+ print(f" {subject['sha256'][:16]}… {subject['path']}")
407
+ if digest:
408
+ print(f" digest as stated: {digest}")
409
+ if location:
410
+ print(f" appears in: {location}")
411
+ print(f"{LINKED}: the ledger records that this link was stated, and of which bytes.")
412
+ print("Nothing was fetched or compared.")
413
+ return 0
@@ -0,0 +1,244 @@
1
+ """`results annotate`: a stated link from named bytes to something outside the ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ import pytest
10
+ from results import ledger
11
+
12
+ LINK = ("--kind", "wandb:artifact", "--ref", "lab/study/model:v3")
13
+ SHA256 = "sha256:" + "0123456789abcdef" * 4
14
+
15
+
16
+ def run_cli(*args, cwd, env=None):
17
+ base = {k: v for k, v in os.environ.items() if not k.startswith(("CLAUDE", "ANTHROPIC"))}
18
+ return subprocess.run(
19
+ [sys.executable, "-m", "results.cli", *args],
20
+ cwd=cwd,
21
+ capture_output=True,
22
+ text=True,
23
+ env={**base, **(env or {})},
24
+ )
25
+
26
+
27
+ def project(tmp_path):
28
+ run_cli("init", cwd=tmp_path)
29
+ (tmp_path / "model.pt").write_text("weights\n")
30
+ return tmp_path
31
+
32
+
33
+ def ledger_bytes(tmp_path):
34
+ return (tmp_path / ".results" / "ledger.jsonl").read_bytes()
35
+
36
+
37
+ def last_event(tmp_path):
38
+ return ledger.read_ledger(tmp_path / ".results" / "ledger.jsonl")[-1]
39
+
40
+
41
+ def test_an_annotation_records_the_digest_of_the_subject_and_the_target(tmp_path):
42
+ project(tmp_path)
43
+ r = run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
44
+ assert r.returncode == 0, r.stdout + r.stderr
45
+ event = last_event(tmp_path)
46
+ assert {k: v for k, v in event.items() if k not in ("seq", "timestamp", "prev_hash")} == {
47
+ "event": "annotation",
48
+ "subjects": [
49
+ {
50
+ "path": "model.pt",
51
+ "sha256": ledger.sha256_of_file(tmp_path / "model.pt"),
52
+ "kind": "file",
53
+ }
54
+ ],
55
+ "target": {"kind": "wandb:artifact", "ref": "lab/study/model:v3"},
56
+ "status": "linked, not checked",
57
+ "note": "",
58
+ }
59
+ assert "linked, not checked: the ledger records that this link was stated" in r.stdout
60
+ assert "Nothing was fetched or compared." in r.stdout
61
+
62
+
63
+ def test_by_note_and_location_are_recorded_as_typed(tmp_path):
64
+ project(tmp_path)
65
+ run_cli(
66
+ "annotate",
67
+ "model.pt",
68
+ "--kind",
69
+ "doi:dataset",
70
+ "--ref",
71
+ "10.5281/zenodo.1234567",
72
+ "--by",
73
+ "A. Reader",
74
+ "--note",
75
+ "the checkpoint behind Table 2",
76
+ "--location",
77
+ "Table 2",
78
+ cwd=tmp_path,
79
+ )
80
+ event = last_event(tmp_path)
81
+ assert event["target"] == {"kind": "doi:dataset", "ref": "10.5281/zenodo.1234567"}
82
+ assert event["by"] == "A. Reader"
83
+ assert event["note"] == "the checkpoint behind Table 2"
84
+ assert event["location"] == "Table 2"
85
+
86
+
87
+ @pytest.mark.parametrize(
88
+ "kind", ["wandb", "wandb:", ":artifact", "WandB:artifact", "wandb:art ifact", "a:b:c", "a/b:c"]
89
+ )
90
+ def test_a_malformed_kind_writes_nothing(tmp_path, kind):
91
+ project(tmp_path)
92
+ before = ledger_bytes(tmp_path)
93
+ r = run_cli("annotate", "model.pt", "--kind", kind, "--ref", "x", cwd=tmp_path)
94
+ assert r.returncode == 1
95
+ assert "namespace:type" in r.stdout
96
+ assert ledger_bytes(tmp_path) == before
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ "kind", ["osf:node", "url:page", "zenodo:record", "hf:dataset", "x-1.y:z_2"]
101
+ )
102
+ def test_any_well_formed_kind_is_accepted(tmp_path, kind):
103
+ project(tmp_path)
104
+ r = run_cli("annotate", "model.pt", "--kind", kind, "--ref", "x", cwd=tmp_path)
105
+ assert r.returncode == 0, r.stdout + r.stderr
106
+ assert last_event(tmp_path)["target"]["kind"] == kind
107
+
108
+
109
+ def test_kind_and_ref_are_required(tmp_path):
110
+ project(tmp_path)
111
+ before = ledger_bytes(tmp_path)
112
+ for dropped in ("--kind", "--ref"):
113
+ at = LINK.index(dropped)
114
+ args = LINK[:at] + LINK[at + 2 :]
115
+ r = run_cli("annotate", "model.pt", *args, cwd=tmp_path)
116
+ assert r.returncode == 2, dropped
117
+ assert dropped in r.stderr
118
+ blank = run_cli("annotate", "model.pt", "--kind", "url:page", "--ref", " ", cwd=tmp_path)
119
+ assert blank.returncode == 1
120
+ assert ledger_bytes(tmp_path) == before
121
+
122
+
123
+ def test_a_path_that_is_not_a_file_writes_nothing(tmp_path):
124
+ project(tmp_path)
125
+ before = ledger_bytes(tmp_path)
126
+ r = run_cli("annotate", "absent.pt", *LINK, cwd=tmp_path)
127
+ assert r.returncode == 1
128
+ assert ledger_bytes(tmp_path) == before
129
+
130
+
131
+ def test_no_session_is_recorded_even_inside_claude_code(tmp_path):
132
+ project(tmp_path)
133
+ env = {
134
+ "CLAUDECODE": "1",
135
+ "CLAUDE_CODE_EXECPATH": "/somewhere/claude/versions/2.1.295",
136
+ "CLAUDE_CODE_SESSION_ID": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
137
+ }
138
+ r = run_cli("annotate", "model.pt", *LINK, "--by", "A. Reader", cwd=tmp_path, env=env)
139
+ assert r.returncode == 0, r.stdout + r.stderr
140
+ event = last_event(tmp_path)
141
+ assert not {"session", "model", "harness", "harness_version", "session_id"} & set(event)
142
+ line = ledger_bytes(tmp_path).splitlines()[-1].decode()
143
+ for leaked in ("Claude", "2.1.295", "0f0f0f0f", "session", "harness", 'model"'):
144
+ assert leaked not in line, leaked
145
+
146
+
147
+ def test_a_well_formed_digest_is_stored_as_stated(tmp_path):
148
+ project(tmp_path)
149
+ for stated in (SHA256, "md5:" + "ab" * 16, "blake3:" + "CD" * 20):
150
+ r = run_cli("annotate", "model.pt", *LINK, "--digest", stated, cwd=tmp_path)
151
+ assert r.returncode == 0, r.stdout + r.stderr
152
+ assert last_event(tmp_path)["target"]["digest"] == stated
153
+ assert last_event(tmp_path)["subjects"][0]["sha256"] != stated.split(":")[1]
154
+
155
+
156
+ @pytest.mark.parametrize(
157
+ "digest",
158
+ ["0123456789abcdef" * 4, "sha256:" + "0" * 63, "sha256:" + "g" * 64, "sha256:", "md5:abc"],
159
+ )
160
+ def test_a_malformed_digest_writes_nothing(tmp_path, digest):
161
+ project(tmp_path)
162
+ before = ledger_bytes(tmp_path)
163
+ r = run_cli("annotate", "model.pt", *LINK, "--digest", digest, cwd=tmp_path)
164
+ assert r.returncode == 1
165
+ assert "algorithm:hex" in r.stdout
166
+ assert ledger_bytes(tmp_path) == before
167
+
168
+
169
+ def test_verify_reports_an_annotated_file_that_changed_as_stale_and_does_not_fail(tmp_path):
170
+ project(tmp_path)
171
+ run_cli("annotate", "model.pt", *LINK, "--by", "A. Reader", cwd=tmp_path)
172
+ recorded = ledger.sha256_of_file(tmp_path / "model.pt")
173
+
174
+ current = run_cli("verify", "--files", cwd=tmp_path)
175
+ assert current.returncode == 0, current.stdout
176
+ assert "annotations (linked, not checked):" in current.stdout
177
+ assert "wandb:artifact lab/study/model:v3 [A. Reader]" in current.stdout
178
+ assert "current model.pt" in current.stdout
179
+ assert "STALE" not in current.stdout
180
+
181
+ (tmp_path / "model.pt").write_text("other weights\n")
182
+ now = ledger.sha256_of_file(tmp_path / "model.pt")
183
+ stale = run_cli("verify", "--files", cwd=tmp_path)
184
+ assert stale.returncode == 0, stale.stdout
185
+ assert "STALE model.pt" in stale.stdout
186
+ assert f"annotated {recorded[:16]}…, now {now[:16]}…" in stale.stdout
187
+ assert "1 annotated file(s) are no longer the bytes the link was stated of" in stale.stdout
188
+
189
+ (tmp_path / "model.pt").unlink()
190
+ missing = run_cli("verify", "--files", cwd=tmp_path)
191
+ assert missing.returncode == 0, missing.stdout
192
+ assert "MISSING model.pt" in missing.stdout
193
+
194
+
195
+ def test_an_annotation_is_not_a_seal(tmp_path):
196
+ project(tmp_path)
197
+ run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
198
+ (tmp_path / "model.pt").write_text("other weights\n")
199
+ run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
200
+ r = run_cli("verify", "--files", cwd=tmp_path)
201
+ assert r.returncode == 0, r.stdout
202
+ assert "RESEALED" not in r.stdout
203
+ assert "CHANGED" not in r.stdout
204
+ assert " ok " not in r.stdout
205
+
206
+
207
+ def test_without_files_verify_lists_the_annotation_and_says_the_subject_was_not_checked(tmp_path):
208
+ project(tmp_path)
209
+ run_cli(
210
+ "annotate", "model.pt", *LINK, "--digest", SHA256, "--location", "Table 2", cwd=tmp_path
211
+ )
212
+ r = run_cli("verify", cwd=tmp_path)
213
+ assert r.returncode == 0, r.stdout
214
+ assert "annotations (linked, not checked):" in r.stdout
215
+ assert "wandb:artifact lab/study/model:v3" in r.stdout
216
+ assert f"digest as stated: {SHA256}" in r.stdout
217
+ assert "appears in: Table 2" in r.stdout
218
+ assert "not checked model.pt" in r.stdout
219
+
220
+
221
+ def test_the_chain_stays_intact_with_annotations_among_the_other_events(tmp_path):
222
+ project(tmp_path)
223
+ lp = tmp_path / ".results" / "ledger.jsonl"
224
+ run_cli("seal", "model.pt", cwd=tmp_path)
225
+ run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
226
+ run_cli("run", "model.pt", "--run-id", "r1", cwd=tmp_path)
227
+ run_cli("annotate", "model.pt", "--kind", "osf:node", "--ref", "abc12", cwd=tmp_path)
228
+ run_cli("claim", "accuracy 0.91", "--run-id", "r1", cwd=tmp_path)
229
+ assert ledger.verify(lp) == (ledger.ChainStatus.INTACT, [])
230
+ assert [e["event"] for e in ledger.read_ledger(lp)] == [
231
+ "init",
232
+ "seal",
233
+ "annotation",
234
+ "run",
235
+ "annotation",
236
+ "claim",
237
+ ]
238
+ r = run_cli("verify", "--files", cwd=tmp_path)
239
+ assert r.returncode == 0, r.stdout
240
+ assert "all checks passed." in r.stdout
241
+
242
+ tampered = lp.read_text().replace("lab/study/model:v3", "lab/study/model:v4")
243
+ lp.write_text(tampered)
244
+ assert ledger.verify(lp)[0] is ledger.ChainStatus.EDITED
@@ -0,0 +1,152 @@
1
+ """`results review`: a record that someone examined named bytes, and what `verify` says of it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from results import ledger
10
+
11
+ REVIEW = (
12
+ "--scope",
13
+ "each mismatch against the paper's table",
14
+ "--procedure",
15
+ "read the rendered page beside the file",
16
+ "--reviewer",
17
+ "A. Reader",
18
+ )
19
+
20
+
21
+ def run_cli(*args, cwd, env=None):
22
+ base = {k: v for k, v in os.environ.items() if not k.startswith(("CLAUDE", "ANTHROPIC"))}
23
+ return subprocess.run(
24
+ [sys.executable, "-m", "results.cli", *args],
25
+ cwd=cwd,
26
+ capture_output=True,
27
+ text=True,
28
+ env={**base, **(env or {})},
29
+ )
30
+
31
+
32
+ def project(tmp_path):
33
+ run_cli("init", cwd=tmp_path)
34
+ (tmp_path / "table.csv").write_text("a,b\n1,2\n")
35
+ return tmp_path
36
+
37
+
38
+ def last_event(tmp_path):
39
+ return ledger.read_ledger(tmp_path / ".results" / "ledger.jsonl")[-1]
40
+
41
+
42
+ def test_a_review_records_the_digest_of_what_was_examined(tmp_path):
43
+ project(tmp_path)
44
+ r = run_cli("review", "table.csv", *REVIEW, "--verdict", "partial", cwd=tmp_path)
45
+ assert r.returncode == 0, r.stdout + r.stderr
46
+ event = last_event(tmp_path)
47
+ assert event["event"] == "review"
48
+ assert event["verdict"] == "partial"
49
+ assert event["reviewer"] == "A. Reader"
50
+ assert event["subjects"] == [
51
+ {
52
+ "path": "table.csv",
53
+ "sha256": ledger.sha256_of_file(tmp_path / "table.csv"),
54
+ "kind": "file",
55
+ }
56
+ ]
57
+ assert "self-reported and unsigned" in r.stdout
58
+
59
+
60
+ def test_a_verdict_outside_the_four_writes_nothing(tmp_path):
61
+ project(tmp_path)
62
+ before = (tmp_path / ".results" / "ledger.jsonl").read_bytes()
63
+ r = run_cli("review", "table.csv", *REVIEW, "--verdict", "verified", cwd=tmp_path)
64
+ assert r.returncode == 1
65
+ assert (tmp_path / ".results" / "ledger.jsonl").read_bytes() == before
66
+
67
+
68
+ def test_scope_procedure_and_reviewer_are_required(tmp_path):
69
+ project(tmp_path)
70
+ for dropped in ("--scope", "--procedure", "--reviewer"):
71
+ at = REVIEW.index(dropped)
72
+ args = REVIEW[:at] + REVIEW[at + 2 :]
73
+ r = run_cli("review", "table.csv", *args, "--verdict", "pass", cwd=tmp_path)
74
+ assert r.returncode == 2, dropped
75
+ assert dropped in r.stderr
76
+
77
+
78
+ def test_the_session_is_recorded_as_the_environment_and_the_flag_give_it(tmp_path):
79
+ project(tmp_path)
80
+ env = {
81
+ "CLAUDECODE": "1",
82
+ "CLAUDE_CODE_EXECPATH": "/somewhere/claude/versions/2.1.295",
83
+ "CLAUDE_CODE_SESSION_ID": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
84
+ }
85
+ r = run_cli(
86
+ "review",
87
+ "table.csv",
88
+ *REVIEW,
89
+ "--verdict",
90
+ "pass",
91
+ "--model",
92
+ "claude-opus-5-5",
93
+ cwd=tmp_path,
94
+ env=env,
95
+ )
96
+ assert r.returncode == 0, r.stdout + r.stderr
97
+ assert last_event(tmp_path)["session"] == {
98
+ "model": "claude-opus-5-5",
99
+ "harness": "Claude Code",
100
+ "harness_version": "2.1.295",
101
+ "session_id": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
102
+ }
103
+
104
+
105
+ def test_outside_a_session_and_with_no_model_the_session_is_empty(tmp_path):
106
+ project(tmp_path)
107
+ run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
108
+ assert last_event(tmp_path)["session"] == {}
109
+
110
+
111
+ def test_a_session_with_no_model_named_says_so(tmp_path):
112
+ project(tmp_path)
113
+ r = run_cli(
114
+ "review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path, env={"CLAUDECODE": "1"}
115
+ )
116
+ assert "no --model" in r.stdout
117
+ assert "model" not in last_event(tmp_path)["session"]
118
+
119
+
120
+ def test_verify_reports_a_reviewed_file_that_changed_as_stale_and_does_not_fail(tmp_path):
121
+ project(tmp_path)
122
+ run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
123
+
124
+ current = run_cli("verify", "--files", cwd=tmp_path)
125
+ assert current.returncode == 0, current.stdout
126
+ assert "current table.csv" in current.stdout
127
+ assert "STALE" not in current.stdout
128
+
129
+ (tmp_path / "table.csv").write_text("a,b\n1,3\n")
130
+ stale = run_cli("verify", "--files", cwd=tmp_path)
131
+ assert stale.returncode == 0, stale.stdout
132
+ assert "STALE table.csv" in stale.stdout
133
+ assert "1 reviewed file(s) are no longer the bytes that were reviewed" in stale.stdout
134
+
135
+
136
+ def test_a_review_is_not_a_seal(tmp_path):
137
+ project(tmp_path)
138
+ run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
139
+ (tmp_path / "table.csv").write_text("a,b\n1,3\n")
140
+ run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
141
+ r = run_cli("verify", "--files", cwd=tmp_path)
142
+ assert "RESEALED" not in r.stdout
143
+ assert "CHANGED" not in r.stdout
144
+
145
+
146
+ def test_without_files_verify_lists_the_review_and_says_the_subject_was_not_checked(tmp_path):
147
+ project(tmp_path)
148
+ run_cli("review", "table.csv", *REVIEW, "--verdict", "fail", cwd=tmp_path)
149
+ r = run_cli("verify", cwd=tmp_path)
150
+ assert "reviews (self-reported, unsigned):" in r.stdout
151
+ assert "fail" in r.stdout and "A. Reader" in r.stdout
152
+ assert "not checked table.csv" in r.stdout
File without changes
File without changes