hypgraph 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.
- hypgraph/__init__.py +28 -0
- hypgraph/__main__.py +3 -0
- hypgraph/bundle.py +216 -0
- hypgraph/checks.py +140 -0
- hypgraph/cli.py +445 -0
- hypgraph/demos/__init__.py +3 -0
- hypgraph/demos/broad_street.py +283 -0
- hypgraph/derive.py +390 -0
- hypgraph/graph.py +1002 -0
- hypgraph/mcp.py +461 -0
- hypgraph/plan.py +328 -0
- hypgraph/policy.py +51 -0
- hypgraph/schema.py +141 -0
- hypgraph/server.py +202 -0
- hypgraph/skill/SKILL.md +94 -0
- hypgraph/store.py +139 -0
- hypgraph/ui/__init__.py +7 -0
- hypgraph/ui/atlas.css +219 -0
- hypgraph/ui/atlas.js +1052 -0
- hypgraph/ui/hypgraph.svg +10 -0
- hypgraph/ui/index.html +30 -0
- hypgraph/ui/vendor/LICENSES.md +10 -0
- hypgraph/ui/vendor/cose-base.js +3214 -0
- hypgraph/ui/vendor/cytoscape-fcose.js +1549 -0
- hypgraph/ui/vendor/cytoscape.min.js +32 -0
- hypgraph/ui/vendor/layout-base.js +5230 -0
- hypgraph-0.1.0.dist-info/METADATA +140 -0
- hypgraph-0.1.0.dist-info/RECORD +31 -0
- hypgraph-0.1.0.dist-info/WHEEL +4 -0
- hypgraph-0.1.0.dist-info/entry_points.txt +2 -0
- hypgraph-0.1.0.dist-info/licenses/LICENSE +201 -0
hypgraph/cli.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hypgraph — the command line.
|
|
3
|
+
|
|
4
|
+
hypgraph demo build the Broad Street (1854) teaching investigation into ./research.hypgraph
|
|
5
|
+
hypgraph serve open the Atlas explorer on http://127.0.0.1:8765
|
|
6
|
+
hypgraph init "Plant growth" --objective "…" --reviewers ada,lin
|
|
7
|
+
hypgraph add hypothesis statement="Blue light grows cress faster." predictions='["taller after 7 days"]'
|
|
8
|
+
hypgraph add result summary="Blue trays averaged 41 mm, red 33 mm." values='{"blue_mm": {"value": 41}}'
|
|
9
|
+
hypgraph link RS1 supports H1 --why "The blue trays grew taller, as H1 predicts."
|
|
10
|
+
hypgraph show CL1 --why what it rests on (--impact: what rests on it, --history: versions)
|
|
11
|
+
hypgraph report the facts, generated from the record
|
|
12
|
+
hypgraph export -o study.json a bundle anyone can verify: hypgraph verify study.json
|
|
13
|
+
hypgraph mcp an MCP server for agents (pip install "hypgraph[mcp]")
|
|
14
|
+
hypgraph skill --install the agent skill, into .claude/skills/hypgraph/SKILL.md
|
|
15
|
+
|
|
16
|
+
The graph file is --db, else $HYPGRAPH_DB, else ./research.hypgraph. You write as --as, else $HYPGRAPH_AUTHOR, else
|
|
17
|
+
your login name. Nodes can be named by id or by code (H1, CL2, X3 …). Add --json to any command for machine output.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import getpass
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .bundle import verify_bundle
|
|
31
|
+
from .graph import Graph, GraphError
|
|
32
|
+
from .schema import TYPES, describe
|
|
33
|
+
from .store import verify_file
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _out(args, data, text: str | None = None) -> None:
|
|
37
|
+
if getattr(args, "json", False) or text is None:
|
|
38
|
+
print(json.dumps(data, indent=1, ensure_ascii=False, default=str))
|
|
39
|
+
else:
|
|
40
|
+
print(text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _graph(args) -> Graph:
|
|
44
|
+
return Graph(args.db)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _author(args) -> str:
|
|
48
|
+
who = args.author or os.getenv("HYPGRAPH_AUTHOR") or getpass.getuser()
|
|
49
|
+
return who.strip().lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _line(n: dict, width: int = 100) -> str:
|
|
53
|
+
t = " ".join(str(n.get("title") or "").split())
|
|
54
|
+
t = t if len(t) <= width else t[:width - 1] + "…"
|
|
55
|
+
return f"{n['code'] or n['id']:<6} {n['type']:<12} {t}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _inv(g: Graph, ref: str = "") -> str:
|
|
59
|
+
"""The investigation: named, else the only active one."""
|
|
60
|
+
if ref:
|
|
61
|
+
return g.resolve(ref)
|
|
62
|
+
invs = [i for i in g.investigations() if i["state"]["lifecycle"] == "active"] or g.investigations()
|
|
63
|
+
if len(invs) == 1:
|
|
64
|
+
return invs[0]["id"]
|
|
65
|
+
if not invs:
|
|
66
|
+
raise GraphError("no investigation yet — hypgraph init \"Title\" --objective \"…\"")
|
|
67
|
+
raise GraphError("which investigation? --inv " + " | ".join(f"{i['code']} ({i['title'][:40]})" for i in invs))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fields(pairs: list[str]) -> dict:
|
|
71
|
+
"""key=value pairs; a value that parses as JSON (lists, objects, numbers) is taken as JSON."""
|
|
72
|
+
out = {}
|
|
73
|
+
for p in pairs or []:
|
|
74
|
+
if "=" not in p:
|
|
75
|
+
raise GraphError(f"{p!r}: give fields as key=value")
|
|
76
|
+
k, v = p.split("=", 1)
|
|
77
|
+
try:
|
|
78
|
+
out[k.strip()] = json.loads(v) if v.strip()[:1] in "[{0123456789-tfn\"" else v
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
out[k.strip()] = v
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _links(g: Graph, specs: list[str], inv: str) -> list[dict]:
|
|
85
|
+
"""--link rel:to:NODE[:why] (from the new node) or rel:from:NODE[:why] (into it)."""
|
|
86
|
+
out = []
|
|
87
|
+
for s in specs or []:
|
|
88
|
+
parts = s.split(":", 3)
|
|
89
|
+
if len(parts) < 3 or parts[1] not in ("to", "from"):
|
|
90
|
+
raise GraphError(f"--link {s!r}: rel:to:NODE[:why] or rel:from:NODE[:why]")
|
|
91
|
+
link = {"rel": parts[0], parts[1]: g.resolve(parts[2], inv)}
|
|
92
|
+
if len(parts) > 3:
|
|
93
|
+
link["why"] = parts[3]
|
|
94
|
+
out.append(link)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _show(n: dict) -> str:
|
|
99
|
+
st = n.get("state") or {}
|
|
100
|
+
out = [f"{n['code']} ({n['id']}) — {n['type']} v{n['v']} by {n['by']} {n['at'][:10]}",
|
|
101
|
+
f"standing: {st.get('sentence', '')}"]
|
|
102
|
+
for k, v in (n.get("body") or {}).items():
|
|
103
|
+
if k != "legacy":
|
|
104
|
+
out.append(f" {k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}")
|
|
105
|
+
if n.get("withdrawn"):
|
|
106
|
+
out.append(f" WITHDRAWN by {n['withdrawn']['by']}: {n['withdrawn']['why']}")
|
|
107
|
+
for e in n.get("out", []):
|
|
108
|
+
out.append(f" → {e['rel']} {e['dst']}" + (f" ({e['why']})" if e.get("why") else ""))
|
|
109
|
+
for e in n.get("in", []):
|
|
110
|
+
out.append(f" ← {e['rel']} {e['src']}" + (f" ({e['why']})" if e.get("why") else ""))
|
|
111
|
+
return "\n".join(out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── commands ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def cmd_init(args) -> int:
|
|
117
|
+
g = _graph(args)
|
|
118
|
+
fields = {"title": args.title, "objective": args.objective, **_fields(args.fields)}
|
|
119
|
+
if args.reviewers:
|
|
120
|
+
fields["reviewers"] = [x.strip().lower() for x in args.reviewers.split(",") if x.strip()]
|
|
121
|
+
n = g.create(author=_author(args), type="investigation", fields=fields)
|
|
122
|
+
_out(args, n, f"{n['code']} ({n['id']}) in {g.path}\nNext: hypgraph add question text=\"…\" · hypgraph add "
|
|
123
|
+
"hypothesis statement=\"…\" predictions='[\"…\"]'")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_add(args) -> int:
|
|
128
|
+
g = _graph(args)
|
|
129
|
+
if args.type not in TYPES or args.type in ("review", "investigation"):
|
|
130
|
+
raise GraphError(f"type: one of {', '.join(t for t in TYPES if t not in ('review', 'investigation'))} "
|
|
131
|
+
"(investigations: hypgraph init; reviews: hypgraph review)")
|
|
132
|
+
inv = _inv(g, args.inv)
|
|
133
|
+
n = g.create(author=_author(args), type=args.type, inv=inv, fields=_fields(args.fields),
|
|
134
|
+
links=_links(g, args.link, inv))
|
|
135
|
+
notes = "".join(f"\n note: {x}" for x in n.get("notes") or [])
|
|
136
|
+
_out(args, n, f"Added {n['code']} — {n['state']['sentence']}{notes}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_link(args) -> int:
|
|
141
|
+
g = _graph(args)
|
|
142
|
+
inv = _inv(g, args.inv) if args.inv else ""
|
|
143
|
+
r = g.link(author=_author(args), src=g.resolve(args.src, inv), rel=args.rel, dst=g.resolve(args.dst, inv),
|
|
144
|
+
why=args.why or "", data=_fields(args.data))
|
|
145
|
+
_out(args, r, f"Linked {args.src} -{args.rel}-> {args.dst} (edge {r['edge']})"
|
|
146
|
+
+ (" — turned round to the direction the relation allows" if r["flipped"] else ""))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_review(args) -> int:
|
|
151
|
+
g = _graph(args)
|
|
152
|
+
n = g.review(author=_author(args), target=g.resolve(args.node, args.inv or ""), verdict=args.verdict,
|
|
153
|
+
method=args.method, notes=args.notes)
|
|
154
|
+
_out(args, n, f"{n['code']} — {n['state']['sentence']}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_revise(args) -> int:
|
|
159
|
+
g = _graph(args)
|
|
160
|
+
n = g.revise(author=_author(args), node=g.resolve(args.node, args.inv or ""), fields=_fields(args.fields),
|
|
161
|
+
reason=args.reason)
|
|
162
|
+
_out(args, n, f"{n['code']} v{n['v']} — {n['state']['sentence']}")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_withdraw(args) -> int:
|
|
167
|
+
g = _graph(args)
|
|
168
|
+
n = g.withdraw(author=_author(args), node=g.resolve(args.node, args.inv or ""), why=args.why)
|
|
169
|
+
_out(args, n, f"{n['code']} — {n['state']['sentence']}")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_ls(args) -> int:
|
|
174
|
+
g = _graph(args)
|
|
175
|
+
if args.type == "investigation" or (not args.inv and not args.type and not args.text and
|
|
176
|
+
len(g.investigations()) != 1):
|
|
177
|
+
rows = g.investigations()
|
|
178
|
+
_out(args, rows, "\n".join(f"{r['code']:<6} {r['state']['lifecycle']:<9} "
|
|
179
|
+
f"{sum(r['counts'].values()):>4} nodes {r['title']}" for r in rows)
|
|
180
|
+
or "No investigations yet — hypgraph init \"Title\" --objective \"…\" (or hypgraph demo)")
|
|
181
|
+
return 0
|
|
182
|
+
rows = g.search(text=args.text or "", type=args.type or "", inv=_inv(g, args.inv), limit=500)
|
|
183
|
+
_out(args, rows, "\n".join(f"{_line(r)}\n {r['state']['sentence']}" for r in rows) or "Nothing found.")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_show(args) -> int:
|
|
188
|
+
g = _graph(args)
|
|
189
|
+
nid = g.resolve(args.node, args.inv or "")
|
|
190
|
+
if args.why:
|
|
191
|
+
r = g.why(nid)
|
|
192
|
+
_out(args, r, f"WHY {args.node} — what it rests on:\n" + "\n".join(
|
|
193
|
+
f" {_line(n)}\n {n['state']['sentence']}" for n in r["nodes"] if n["id"] != nid))
|
|
194
|
+
elif args.impact:
|
|
195
|
+
r = g.impact(nid)
|
|
196
|
+
_out(args, r, f"IF {args.node} IS WRONG — {len(r['dependents'])} node(s) rest on it:\n" + "\n".join(
|
|
197
|
+
f" {' ' * (d['depth'] - 1)}{d['rel']} ← {_line(d['node'])}" for d in r["dependents"]))
|
|
198
|
+
else:
|
|
199
|
+
n = g.get(nid, history=args.history)
|
|
200
|
+
text = _show(n)
|
|
201
|
+
if args.history:
|
|
202
|
+
text += "\n versions:\n" + "\n".join(f" v{v['v']} {v['at'][:10]} {v['by']}: {v['reason'] or '(created)'}"
|
|
203
|
+
for v in n.get("versions", []))
|
|
204
|
+
_out(args, n, text)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_next(args) -> int:
|
|
209
|
+
g = _graph(args)
|
|
210
|
+
r = g.next_experiments(_inv(g, args.inv))
|
|
211
|
+
lines = [f"Next experiments ({r['rule']}):"]
|
|
212
|
+
lines += [f" {x['experiment']['code']:<5} score {x['score']:<6} separates {x['separates']}"
|
|
213
|
+
f"{', power check' if x['power_check'] else ''}{', DUPLICATE' if x['duplicate'] else ''} — "
|
|
214
|
+
f"{x['experiment']['title'][:80]}" for x in r["ranked"]] or [" (none planned)"]
|
|
215
|
+
if r["untested_hypotheses"]:
|
|
216
|
+
lines.append("Untested hypotheses: " + ", ".join(h["code"] for h in r["untested_hypotheses"]))
|
|
217
|
+
_out(args, r, "\n".join(lines))
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_work(args) -> int:
|
|
222
|
+
g = _graph(args)
|
|
223
|
+
w = g.work_map(_inv(g, args.inv))
|
|
224
|
+
sec = lambda name, rows: f"{name} ({len(rows)}): " + (", ".join(x["code"] for x in rows) or "—") # noqa: E731
|
|
225
|
+
_out(args, w, "\n".join([
|
|
226
|
+
"Being worked: " + (", ".join(f"{L['agent']} → {L['node']['code']}" for L in w["leases"]) or "—"),
|
|
227
|
+
sec("Review queue", w["review_queue"]), sec("Needs revalidation", w["revalidation"]),
|
|
228
|
+
sec("Contested", w["contested"]), sec("Decisions to revisit", w["decisions_to_revisit"]),
|
|
229
|
+
f"Duplicate experiment groups: {len(w['duplicates'])}"]))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_report(args) -> int:
|
|
234
|
+
g = _graph(args)
|
|
235
|
+
r = g.report(_inv(g, args.inv))
|
|
236
|
+
if args.output:
|
|
237
|
+
Path(args.output).write_text(r["markdown"] + "\n\n## Interpretation\n\n", encoding="utf-8")
|
|
238
|
+
print(f"Wrote the facts to {args.output}; write your interpretation under them.")
|
|
239
|
+
return 0
|
|
240
|
+
_out(args, r, r["markdown"])
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_verify(args) -> int:
|
|
245
|
+
target = args.target or args.db
|
|
246
|
+
if str(target).endswith(".json"):
|
|
247
|
+
res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")))
|
|
248
|
+
else:
|
|
249
|
+
res = verify_file(target)
|
|
250
|
+
ok = res.get("ok")
|
|
251
|
+
text = (f"✓ {target}: {res.get('checked')} events verified — chain intact, every row matches the event that "
|
|
252
|
+
f"wrote it. Head {str(res.get('head', ''))[:16]}…" if ok else f"✗ {target}: {res.get('problem')}")
|
|
253
|
+
if res.get("warning"):
|
|
254
|
+
text += "\n note: " + res["warning"]
|
|
255
|
+
_out(args, res, text)
|
|
256
|
+
return 0 if ok else 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_export(args) -> int:
|
|
260
|
+
g = _graph(args)
|
|
261
|
+
inv = g.resolve(args.inv) if args.inv else ""
|
|
262
|
+
b = g.export(inv)
|
|
263
|
+
data = json.dumps(b, ensure_ascii=False, separators=(",", ":") if args.compact else None,
|
|
264
|
+
indent=None if args.compact else 1)
|
|
265
|
+
if args.output:
|
|
266
|
+
Path(args.output).write_text(data, encoding="utf-8")
|
|
267
|
+
print(f"Wrote {args.output}: {len(b['events'])} events, {len(b['nodes'])} nodes"
|
|
268
|
+
+ ("" if b["complete"] else f" ({b['redacted_events']} events outside the investigation redacted)"))
|
|
269
|
+
else:
|
|
270
|
+
print(data)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_import(args) -> int:
|
|
275
|
+
b = json.loads(Path(args.bundle).read_text(encoding="utf-8"))
|
|
276
|
+
if Path(args.db).exists():
|
|
277
|
+
raise GraphError(f"{args.db} exists — import into a new file (--db new.hypgraph)")
|
|
278
|
+
g = Graph.from_bundle(b, args.db)
|
|
279
|
+
print(f"Imported into {g.path}: {g.verify()['checked']} events, verified.")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_demo(args) -> int:
|
|
284
|
+
from .demos import broad_street
|
|
285
|
+
if Path(args.db).exists():
|
|
286
|
+
raise GraphError(f"{args.db} exists — choose another file: hypgraph demo --db broad-street.hypgraph")
|
|
287
|
+
g, inv, story = broad_street.build(path=args.db)
|
|
288
|
+
if args.json:
|
|
289
|
+
_out(args, {"db": g.path, "inv": inv, "story": story})
|
|
290
|
+
return 0
|
|
291
|
+
for s in story:
|
|
292
|
+
print(f"{s['at']} {s['actor']:<10} {s['text']}")
|
|
293
|
+
if "refused" in s:
|
|
294
|
+
print(" ✗ the record refused it:\n " +
|
|
295
|
+
s["refused"].replace("\n", "\n "))
|
|
296
|
+
print(f"\nBuilt {broad_street.TITLE!r} into {g.path}.\nExplore it: hypgraph --db {args.db} serve"
|
|
297
|
+
f" · hypgraph --db {args.db} report")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def cmd_schema(args) -> int:
|
|
302
|
+
d = describe()
|
|
303
|
+
if args.json:
|
|
304
|
+
_out(args, d)
|
|
305
|
+
return 0
|
|
306
|
+
lines = [f"{d['format']} — node types:"]
|
|
307
|
+
lines += [f" {t:<13} {v['prefix']:<4} {v['about']}" for t, v in d["types"].items()]
|
|
308
|
+
lines += ["", "relations:"] + [f" {r:<14} {v['about']}" for r, v in d["relations"].items()]
|
|
309
|
+
print("\n".join(lines))
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def skill_text() -> str:
|
|
314
|
+
return (Path(__file__).parent / "skill" / "SKILL.md").read_text(encoding="utf-8")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_skill(args) -> int:
|
|
318
|
+
"""Print the agent skill, or install it where Claude Code (and other Agent Skills hosts) look for skills."""
|
|
319
|
+
if not args.install:
|
|
320
|
+
print(skill_text())
|
|
321
|
+
return 0
|
|
322
|
+
base = Path.home() if args.user else Path(args.dir or ".")
|
|
323
|
+
dest = base / ".claude" / "skills" / "hypgraph" / "SKILL.md"
|
|
324
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
dest.write_text(skill_text(), encoding="utf-8")
|
|
326
|
+
print(f"Installed the hypgraph skill at {dest}")
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cmd_serve(args) -> int:
|
|
331
|
+
from .server import serve
|
|
332
|
+
serve(args.db, host=args.host, port=args.port, writable=args.writable, open_browser=not args.no_browser)
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def cmd_mcp(args) -> int:
|
|
337
|
+
from .mcp import run
|
|
338
|
+
run(args.db, author=_author(args) if args.author else None, transport=args.transport, port=args.port)
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
343
|
+
p = argparse.ArgumentParser(prog="hypgraph", description="Don't believe the hype. Test the hyp.",
|
|
344
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n")[1])
|
|
345
|
+
p.add_argument("--db", default=os.getenv("HYPGRAPH_DB", "research.hypgraph"), help="the graph file")
|
|
346
|
+
p.add_argument("--as", dest="author", default="", help="who you are writing as")
|
|
347
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
348
|
+
p.add_argument("--version", action="version", version=f"hypgraph {__version__}")
|
|
349
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="command")
|
|
350
|
+
|
|
351
|
+
def cmd(name, fn, help, **kw):
|
|
352
|
+
s = sub.add_parser(name, help=help, **kw)
|
|
353
|
+
s.set_defaults(fn=fn)
|
|
354
|
+
s.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
|
|
355
|
+
return s
|
|
356
|
+
|
|
357
|
+
s = cmd("init", cmd_init, "start an investigation")
|
|
358
|
+
s.add_argument("title")
|
|
359
|
+
s.add_argument("--objective", required=True, help="what you want to find out, in a sentence")
|
|
360
|
+
s.add_argument("--reviewers", default="", help="comma-separated: whose verdicts count")
|
|
361
|
+
s.add_argument("fields", nargs="*", help="more fields as key=value (scope=…, success_criteria=…)")
|
|
362
|
+
s = cmd("add", cmd_add, "add a question, hypothesis, experiment, run, result, claim, source, decision …")
|
|
363
|
+
s.add_argument("type")
|
|
364
|
+
s.add_argument("fields", nargs="*", help="key=value; JSON values for lists and objects")
|
|
365
|
+
s.add_argument("--inv", default="")
|
|
366
|
+
s.add_argument("--link", action="append", help="rel:to:NODE[:why] or rel:from:NODE[:why]; repeatable")
|
|
367
|
+
s = cmd("link", cmd_link, "link two nodes")
|
|
368
|
+
s.add_argument("src"), s.add_argument("rel"), s.add_argument("dst")
|
|
369
|
+
s.add_argument("--why", default="")
|
|
370
|
+
s.add_argument("--inv", default="")
|
|
371
|
+
s.add_argument("data", nargs="*", help="key=value edge data (expected=…, differs=…, role=…)")
|
|
372
|
+
s = cmd("review", cmd_review, "review a node's current version")
|
|
373
|
+
s.add_argument("node"), s.add_argument("verdict")
|
|
374
|
+
s.add_argument("--method", required=True, help="how you checked")
|
|
375
|
+
s.add_argument("--notes", required=True, help="what you found")
|
|
376
|
+
s.add_argument("--inv", default="")
|
|
377
|
+
s = cmd("revise", cmd_revise, "add a new version of a node")
|
|
378
|
+
s.add_argument("node")
|
|
379
|
+
s.add_argument("fields", nargs="*")
|
|
380
|
+
s.add_argument("--reason", required=True)
|
|
381
|
+
s.add_argument("--inv", default="")
|
|
382
|
+
s = cmd("withdraw", cmd_withdraw, "withdraw a node (it stays on record)")
|
|
383
|
+
s.add_argument("node")
|
|
384
|
+
s.add_argument("--why", required=True)
|
|
385
|
+
s.add_argument("--inv", default="")
|
|
386
|
+
s = cmd("ls", cmd_ls, "list investigations, or the nodes of one")
|
|
387
|
+
s.add_argument("text", nargs="?", default="")
|
|
388
|
+
s.add_argument("--type", default="")
|
|
389
|
+
s.add_argument("--inv", default="")
|
|
390
|
+
s = cmd("show", cmd_show, "one node, its standing and links")
|
|
391
|
+
s.add_argument("node")
|
|
392
|
+
s.add_argument("--why", action="store_true", help="what it rests on")
|
|
393
|
+
s.add_argument("--impact", action="store_true", help="what rests on it")
|
|
394
|
+
s.add_argument("--history", action="store_true", help="its versions")
|
|
395
|
+
s.add_argument("--inv", default="")
|
|
396
|
+
for name, fn, h in (("next", cmd_next, "experiments ranked by how well they separate hypotheses"),
|
|
397
|
+
("work", cmd_work, "review queue, revalidation, contested, decisions to revisit")):
|
|
398
|
+
cmd(name, fn, h).add_argument("--inv", default="")
|
|
399
|
+
s = cmd("report", cmd_report, "the facts of an investigation, generated from the record")
|
|
400
|
+
s.add_argument("--inv", default="")
|
|
401
|
+
s.add_argument("-o", "--output", default="")
|
|
402
|
+
s = cmd("verify", cmd_verify, "check a graph file or a bundle")
|
|
403
|
+
s.add_argument("target", nargs="?", default="")
|
|
404
|
+
s = cmd("export", cmd_export, "write a verifiable bundle")
|
|
405
|
+
s.add_argument("--inv", default="", help="one investigation (events outside it are redacted)")
|
|
406
|
+
s.add_argument("-o", "--output", default="")
|
|
407
|
+
s.add_argument("--compact", action="store_true")
|
|
408
|
+
s = cmd("import", cmd_import, "rebuild a graph file from a complete bundle")
|
|
409
|
+
s.add_argument("bundle")
|
|
410
|
+
cmd("demo", cmd_demo, "build the Broad Street (1854) teaching investigation")
|
|
411
|
+
cmd("schema", cmd_schema, "the node types and relations")
|
|
412
|
+
s = cmd("skill", cmd_skill, "print the agent skill (SKILL.md), or --install it for Claude Code")
|
|
413
|
+
s.add_argument("--install", action="store_true", help="write .claude/skills/hypgraph/SKILL.md")
|
|
414
|
+
s.add_argument("--user", action="store_true", help="install under your home directory instead of this project")
|
|
415
|
+
s.add_argument("--dir", default="", help="project directory to install into (default: here)")
|
|
416
|
+
s = cmd("serve", cmd_serve, "the Atlas explorer and JSON API on localhost")
|
|
417
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
418
|
+
s.add_argument("--port", type=int, default=8765)
|
|
419
|
+
s.add_argument("--writable", action="store_true", help="also accept writes (POST /api/graph/write)")
|
|
420
|
+
s.add_argument("--no-browser", action="store_true")
|
|
421
|
+
s = cmd("mcp", cmd_mcp, "an MCP server for agents (needs hypgraph[mcp])")
|
|
422
|
+
s.add_argument("--transport", default="stdio", choices=["stdio", "http"])
|
|
423
|
+
s.add_argument("--port", type=int, default=8766)
|
|
424
|
+
return p
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main(argv: list[str] | None = None) -> int:
|
|
428
|
+
for stream in (sys.stdout, sys.stderr):
|
|
429
|
+
try:
|
|
430
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # Windows consoles default to cp1252
|
|
431
|
+
except (AttributeError, ValueError):
|
|
432
|
+
pass
|
|
433
|
+
args = build_parser().parse_args(argv)
|
|
434
|
+
try:
|
|
435
|
+
return args.fn(args)
|
|
436
|
+
except GraphError as e:
|
|
437
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
438
|
+
return 2
|
|
439
|
+
except KeyError as e:
|
|
440
|
+
print(f"not found: {e.args[0] if e.args else e}", file=sys.stderr)
|
|
441
|
+
return 3
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
raise SystemExit(main())
|