results-cli 0.1.0__py3-none-any.whl

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.
results/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Seal a run, record what it produced, and verify the chain."""
2
+ from __future__ import annotations
3
+ __version__ = "0.1.0"
results/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """Seal a run, record what it produced, and verify the chain.
2
+
3
+ results init start tracking results here
4
+ results seal <file>... hash inputs before a run (prereg, script, data)
5
+ results access <note> record a data-access event (what you looked at, when)
6
+ results run <file>... record outputs after a run completes
7
+ results claim <text> bind a manuscript claim to a run's output
8
+ results verify check the ledger chain and every hash it names
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import pathlib
15
+ import sys
16
+
17
+ from results import ledger
18
+
19
+
20
+ RESULTS_DIR = ".results"
21
+
22
+
23
+ def find_root(start: pathlib.Path | None = None) -> pathlib.Path | None:
24
+ here = (start or pathlib.Path.cwd()).resolve()
25
+ for d in [here, *here.parents]:
26
+ if (d / RESULTS_DIR).is_dir():
27
+ return d / RESULTS_DIR
28
+ return None
29
+
30
+
31
+ def require_root() -> pathlib.Path:
32
+ root = find_root()
33
+ if root is None:
34
+ print(f"no {RESULTS_DIR}/ here or above. `results init` makes one.")
35
+ sys.exit(2)
36
+ return root
37
+
38
+
39
+ def ledger_path(root: pathlib.Path) -> pathlib.Path:
40
+ return root / ledger.LEDGER
41
+
42
+
43
+ def cmd_init(a) -> int:
44
+ d = pathlib.Path.cwd() / RESULTS_DIR
45
+ if d.exists():
46
+ print(f"{d} already exists.")
47
+ return 1
48
+ d.mkdir()
49
+ lp = d / ledger.LEDGER
50
+ lp.touch()
51
+ ledger.append_event(lp, {"event": "init"})
52
+ print(f"created {RESULTS_DIR}/")
53
+ print(f" {ledger.LEDGER} append-only event log")
54
+ print("\nseal your inputs before running: `results seal prereg.md script.py data.csv`")
55
+ return 0
56
+
57
+
58
+ def cmd_seal(a) -> int:
59
+ root = require_root()
60
+ lp = ledger_path(root)
61
+ sealed = []
62
+ for name in a.files:
63
+ p = pathlib.Path(name).resolve()
64
+ if not p.is_file():
65
+ print(f"not a file: {name}")
66
+ return 1
67
+ digest = ledger.sha256_of_file(p)
68
+ sealed.append({"path": os.path.relpath(p), "sha256": digest})
69
+ ev = ledger.append_event(lp, {
70
+ "event": "seal",
71
+ "role": a.role,
72
+ "files": sealed,
73
+ })
74
+ print(f"sealed {len(sealed)} file(s) as {a.role}")
75
+ for s in sealed:
76
+ print(f" {s['sha256'][:16]}… {s['path']}")
77
+ return 0
78
+
79
+
80
+ def cmd_access(a) -> int:
81
+ root = require_root()
82
+ lp = ledger_path(root)
83
+ if a.level not in ACCESS_LEVELS:
84
+ print(f"level must be one of: {', '.join(ACCESS_LEVELS)}")
85
+ return 1
86
+ ev = ledger.append_event(lp, {
87
+ "event": "access",
88
+ "level": a.level,
89
+ "note": a.note,
90
+ })
91
+ print(f"recorded: {a.level} — {a.note}")
92
+ if a.level == "outcomes seen":
93
+ print("\nany analysis registered after this is retrospective, not confirmatory.")
94
+ return 0
95
+
96
+
97
+ def cmd_run(a) -> int:
98
+ root = require_root()
99
+ lp = ledger_path(root)
100
+ existing = ledger.read_ledger(lp)
101
+ existing_ids = {e["run_id"] for e in existing if e.get("event") == "run"}
102
+ if a.run_id in existing_ids:
103
+ print(f"warning: run id '{a.run_id}' already exists in the ledger.")
104
+ print("the new run will be recorded alongside the old one.")
105
+ outputs = []
106
+ for name in a.files:
107
+ p = pathlib.Path(name).resolve()
108
+ if not p.is_file():
109
+ print(f"not a file: {name}")
110
+ return 1
111
+ digest = ledger.sha256_of_file(p)
112
+ outputs.append({"path": os.path.relpath(p), "sha256": digest})
113
+ ev = ledger.append_event(lp, {
114
+ "event": "run",
115
+ "run_id": a.run_id,
116
+ "outputs": outputs,
117
+ "note": a.note or "",
118
+ })
119
+ print(f"run {a.run_id}: {len(outputs)} output(s)")
120
+ for o in outputs:
121
+ print(f" {o['sha256'][:16]}… {o['path']}")
122
+ return 0
123
+
124
+
125
+ def cmd_claim(a) -> int:
126
+ root = require_root()
127
+ lp = ledger_path(root)
128
+
129
+ events = ledger.read_ledger(lp)
130
+ run_ids = {e["run_id"] for e in events if e.get("event") == "run"}
131
+ if a.run_id not in run_ids:
132
+ print(f"no run with id '{a.run_id}' in the ledger.")
133
+ print(f"known runs: {', '.join(sorted(run_ids)) or '(none)'}")
134
+ return 1
135
+
136
+ ev = ledger.append_event(lp, {
137
+ "event": "claim",
138
+ "claim": a.text,
139
+ "run_id": a.run_id,
140
+ "confirmatory": a.confirmatory,
141
+ "location": a.location or "",
142
+ })
143
+ status = "confirmatory" if a.confirmatory else "exploratory"
144
+ print(f"claim ({status}): {a.text[:72]}")
145
+ print(f" backed by run: {a.run_id}")
146
+ if a.location:
147
+ print(f" appears in: {a.location}")
148
+ return 0
149
+
150
+
151
+ def cmd_verify(a) -> int:
152
+ root = require_root()
153
+ lp = ledger_path(root)
154
+
155
+ ok, problems = ledger.verify_chain(lp)
156
+ if not ok:
157
+ print("CHAIN BROKEN")
158
+ for p in problems:
159
+ print(f" {p}")
160
+ return 1
161
+
162
+ events = ledger.read_ledger(lp)
163
+ print(f"chain intact: {len(events)} events\n")
164
+
165
+ counts = {}
166
+ for e in events:
167
+ t = e.get("event", "?")
168
+ counts[t] = counts.get(t, 0) + 1
169
+ for t, n in sorted(counts.items()):
170
+ print(f" {t:<12}{n:>5}")
171
+
172
+ drift = 0
173
+ if a.files:
174
+ print("\nfile hashes:")
175
+ file_hashes = {}
176
+ for e in events:
177
+ for f in e.get("files", []) + e.get("outputs", []):
178
+ file_hashes[f["path"]] = f["sha256"]
179
+ for path, expected in sorted(file_hashes.items()):
180
+ p = pathlib.Path(path)
181
+ if not p.exists():
182
+ print(f" MISSING {path}")
183
+ drift += 1
184
+ else:
185
+ actual = ledger.sha256_of_file(p)
186
+ if actual == expected:
187
+ print(f" ok {path}")
188
+ else:
189
+ print(f" CHANGED {path}")
190
+ print(f" sealed {expected[:16]}…")
191
+ print(f" now {actual[:16]}…")
192
+ drift += 1
193
+ if drift:
194
+ print(f"\n{drift} file(s) changed or missing since they were recorded.")
195
+ return 1
196
+
197
+ access_events = [e for e in events if e.get("event") == "access"]
198
+ if access_events:
199
+ print("\ndata access timeline:")
200
+ for e in access_events:
201
+ print(f" {e['timestamp'][:19]} {e['level']:<20} {e.get('note', '')}")
202
+
203
+ claims = [e for e in events if e.get("event") == "claim"]
204
+ if claims:
205
+ unlinked = []
206
+ for c in claims:
207
+ run_events = [e for e in events
208
+ if e.get("event") == "run" and e.get("run_id") == c.get("run_id")]
209
+ if not run_events:
210
+ unlinked.append(c)
211
+ if unlinked:
212
+ print(f"\n{len(unlinked)} claim(s) reference missing runs:")
213
+ for c in unlinked:
214
+ print(f" {c['claim'][:60]} (run: {c.get('run_id')})")
215
+
216
+ print()
217
+ if ok and not (a.files and drift):
218
+ print("all checks passed.")
219
+ return 0
220
+
221
+
222
+ ACCESS_LEVELS = [
223
+ "nothing seen",
224
+ "metadata only",
225
+ "structure seen",
226
+ "outcomes seen",
227
+ ]
228
+
229
+
230
+ def main() -> int:
231
+ ap = argparse.ArgumentParser(prog="results", description=__doc__.split("\n")[0])
232
+ sub = ap.add_subparsers(dest="cmd")
233
+
234
+ sub.add_parser("init", help="start tracking results here")
235
+
236
+ s = sub.add_parser("seal", help="hash inputs before a run")
237
+ s.add_argument("files", nargs="+")
238
+ s.add_argument("--role", default="input",
239
+ help="what these files are: input, prereg, script, data")
240
+ s.set_defaults(fn=cmd_seal)
241
+
242
+ ac = sub.add_parser("access", help="record a data-access event")
243
+ ac.add_argument("note", help="what was accessed and why")
244
+ ac.add_argument("--level", default="metadata only",
245
+ help=f"one of: {', '.join(ACCESS_LEVELS)}")
246
+ ac.set_defaults(fn=cmd_access)
247
+
248
+ r = sub.add_parser("run", help="record outputs after a run")
249
+ r.add_argument("files", nargs="+")
250
+ r.add_argument("--run-id", required=True, help="a name for this run")
251
+ r.add_argument("--note", help="what this run computed")
252
+ r.set_defaults(fn=cmd_run)
253
+
254
+ cl = sub.add_parser("claim", help="bind a manuscript claim to a run")
255
+ cl.add_argument("text", help="the claim, as it appears in the manuscript")
256
+ cl.add_argument("--run-id", required=True, help="which run backs this claim")
257
+ cl.add_argument("--confirmatory", action="store_true",
258
+ help="mark as confirmatory (default: exploratory)")
259
+ cl.add_argument("--location", help="where in the manuscript: Table 2, Section 4.1, etc.")
260
+ cl.set_defaults(fn=cmd_claim)
261
+
262
+ v = sub.add_parser("verify", help="check the ledger and every hash it names")
263
+ v.add_argument("--files", action="store_true",
264
+ help="also check that sealed/output files still match their hashes")
265
+ v.set_defaults(fn=cmd_verify)
266
+
267
+ a = ap.parse_args()
268
+ if not a.cmd:
269
+ ap.print_help()
270
+ return 0
271
+ if a.cmd == "init":
272
+ return cmd_init(a)
273
+ return a.fn(a)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())
results/ledger.py ADDED
@@ -0,0 +1,78 @@
1
+ """Append-only JSONL ledger: every event is one line, hash-chained to the previous."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ import hashlib
6
+ import json
7
+ import pathlib
8
+
9
+
10
+ LEDGER = "ledger.jsonl"
11
+
12
+
13
+ def sha256_of_file(path: pathlib.Path) -> str:
14
+ h = hashlib.sha256()
15
+ with open(path, "rb") as f:
16
+ for chunk in iter(lambda: f.read(1 << 16), b""):
17
+ h.update(chunk)
18
+ return h.hexdigest()
19
+
20
+
21
+ def sha256_of_str(s: str) -> str:
22
+ return hashlib.sha256(s.encode()).hexdigest()
23
+
24
+
25
+ def now_iso() -> str:
26
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
27
+
28
+
29
+ def last_hash(ledger: pathlib.Path) -> str:
30
+ """The hash of the last line as stored, or a zero hash if the ledger is empty."""
31
+ if not ledger.exists() or ledger.stat().st_size == 0:
32
+ return "0" * 64
33
+ with open(ledger, "rb") as f:
34
+ last = b""
35
+ for line in f:
36
+ if line.strip():
37
+ last = line
38
+ return sha256_of_str(last.decode().strip())
39
+
40
+
41
+ def append_event(ledger: pathlib.Path, event: dict) -> dict:
42
+ """Write one event to the ledger. Returns the event with chain fields added."""
43
+ event["timestamp"] = now_iso()
44
+ event["prev_hash"] = last_hash(ledger)
45
+ line = json.dumps(event, separators=(",", ":"), sort_keys=True)
46
+ with open(ledger, "a") as f:
47
+ f.write(line + "\n")
48
+ return event
49
+
50
+
51
+ def read_ledger(ledger: pathlib.Path) -> list[dict]:
52
+ if not ledger.exists():
53
+ return []
54
+ events = []
55
+ for line in ledger.read_text().splitlines():
56
+ line = line.strip()
57
+ if line:
58
+ events.append(json.loads(line))
59
+ return events
60
+
61
+
62
+ def verify_chain(ledger: pathlib.Path) -> tuple[bool, list[str]]:
63
+ """Check that every line's prev_hash matches the hash of the previous line as stored."""
64
+ if not ledger.exists():
65
+ return True, []
66
+ lines = [ln.strip() for ln in ledger.read_text().splitlines() if ln.strip()]
67
+ if not lines:
68
+ return True, []
69
+ problems = []
70
+ prev = "0" * 64
71
+ for i, raw in enumerate(lines):
72
+ ev = json.loads(raw)
73
+ if ev.get("prev_hash") != prev:
74
+ problems.append(
75
+ f"line {i + 1}: prev_hash mismatch — expected {prev[:16]}…, "
76
+ f"got {ev.get('prev_hash', '???')[:16]}…")
77
+ prev = sha256_of_str(raw)
78
+ return len(problems) == 0, problems
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: results-cli
3
+ Version: 0.1.0
4
+ Summary: Seal a run, record what it produced, and verify the chain
5
+ Author-email: Elliot Tower <elliot@elliottower.ai>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: open science,provenance,reproducibility,results
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # results
13
+
14
+ Seal a run, record what it produced, and verify the chain.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install results-cli
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ results init
26
+ results seal prereg.md analysis.py data.csv --role input
27
+ results access "read zenodo metadata" --level "metadata only"
28
+
29
+ # run the computation, then record its outputs
30
+ results run output.json --run-id exp_001 --note "ICC analysis"
31
+ results claim "ICC = 0.42" --run-id exp_001 --confirmatory --location "Table 2"
32
+ results verify --files
33
+ ```
34
+
35
+ ```
36
+ chain intact: 5 events
37
+
38
+ access 1
39
+ claim 1
40
+ init 1
41
+ run 1
42
+ seal 1
43
+
44
+ file hashes:
45
+ ok prereg.md
46
+ ok analysis.py
47
+ ok data.csv
48
+ ok output.json
49
+
50
+ all checks passed.
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | What it does |
56
+ |---------|-------------|
57
+ | `results init` | Start tracking results here |
58
+ | `results seal <file>...` | Hash inputs before a run |
59
+ | `results access <note>` | Record a data-access event |
60
+ | `results run <file>...` | Record outputs after a run |
61
+ | `results claim <text>` | Bind a manuscript claim to a run |
62
+ | `results verify` | Check the ledger chain and every hash it names |
63
+
64
+ ## The chain
65
+
66
+ A number in a manuscript names a claim. The claim names a run. The run names its outputs. The
67
+ outputs were hashed when they were recorded. The inputs were hashed before the run started.
68
+
69
+ ```
70
+ manuscript → claim → run → output file → sha256
71
+ input files → sha256
72
+ ```
73
+
74
+ `results verify --files` walks the whole thing and tells you what moved.
75
+
76
+ ## Data-access levels
77
+
78
+ The access timeline is what makes the confirmatory/exploratory distinction verifiable.
79
+
80
+ | Level | Meaning |
81
+ |-------|---------|
82
+ | `nothing seen` | No target data touched |
83
+ | `metadata only` | Structure, region names, sample sizes — not outcomes |
84
+ | `structure seen` | Data shape and distributions, not the target variable |
85
+ | `outcomes seen` | The dependent variable was observed |
86
+
87
+ An analysis registered after `outcomes seen` is retrospective.
88
+
89
+ ## Verify output
90
+
91
+ | Result | Meaning |
92
+ |--------|---------|
93
+ | `chain intact` | Every event's prev_hash matches the line before it |
94
+ | `CHAIN BROKEN` | The ledger was edited after it was written |
95
+ | `ok` | File matches its recorded hash |
96
+ | `CHANGED` | File was modified since it was recorded |
97
+ | `MISSING` | File no longer exists |
98
+
99
+ ## The ledger
100
+
101
+ Append-only JSONL in `.results/ledger.jsonl`. Each line is hash-chained to the previous — editing
102
+ or inserting a line breaks the chain. `git diff` shows what changed; `results verify` checks
103
+ whether it should have.
104
+
105
+ ## Claude Code
106
+
107
+ `plugin/` is a Claude Code plugin that tells Claude when to reach for the CLI.
108
+
109
+ ```bash
110
+ /plugin marketplace add elliottower/results
111
+ /plugin install results@results
112
+ ```
113
+
114
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ results/__init__.py,sha256=quPt6si4TzPx3vsWIdqxTKZy9hnQYlWqEb5Q66t_3No,122
2
+ results/cli.py,sha256=H8cGnttpljtrHmti01dcnd-Ct4DzR3TwDfXr3MovVOA,8919
3
+ results/ledger.py,sha256=apzy1v6NGOp5Uy5hW51xBk_6VtIEoaJ-V-rwJU9YD2M,2366
4
+ results_cli-0.1.0.dist-info/METADATA,sha256=lJBuE-UMNqpAvIxMv1xxQjxWBUUkCMuuMW0lVPpZkBw,3135
5
+ results_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ results_cli-0.1.0.dist-info/entry_points.txt,sha256=IKJM5z5zKmfR1JLm5wze8zXDYnbOrpp1SKg8JQ3Ifsk,45
7
+ results_cli-0.1.0.dist-info/licenses/LICENSE,sha256=-C2xzeJxZAl5nBr-WFxtiYY3521mIQjxI0-_5GEpFCQ,1069
8
+ results_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ results = results.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elliot Tower
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.