notegraph 0.2.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.
note_cli/SKILL.md ADDED
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: note
3
+ description: Use when working on a repo that has a note store — record each attempt in the decision graph and score it against the project's goal criteria before moving on.
4
+ ---
5
+
6
+ # note
7
+
8
+ `note` keeps one decision graph and one goal per repo, in `<repo>/.git/notes/`.
9
+ Every git worktree of the repo shares that store, so what you record is visible
10
+ to agents working other branches in parallel.
11
+
12
+ ## The loop
13
+
14
+ 1. **Read the standard.** `note goal` prints the final goal and its weighted
15
+ criteria, each with a one-line rubric. Score against those rubrics, never
16
+ against a standard you invent.
17
+ 2. **Do the work.**
18
+ 3. **Record the attempt.** `note add "what you tried" --type attempt --tags arm-b1`
19
+ prints its id. The current branch is tagged automatically.
20
+ 4. **Point it at a goal.** `note link <attempt> <goal> --rel targets`. A scored
21
+ attempt that targets nothing shows up under `(unassigned)` in `note goals` —
22
+ that is a linking mistake, not a category of work.
23
+ 5. **Score it.** `note score <attempt> consistency=0.8 legibility=0.6 --note "why"`.
24
+ Each criterion is 0.0–1.0. The `--note` is where the justification goes;
25
+ a score with no stated reason cannot be argued with later.
26
+
27
+ 6. **Confirm the loop closed.** `note check` exits nonzero while any attempt
28
+ targets no goal or carries no score, and names them. Run it before you
29
+ consider a piece of work done.
30
+
31
+ ## Rules
32
+
33
+ - **Never score a goal node.** Scores go on attempts. A goal's score is derived
34
+ as the maximum over the attempts that target it.
35
+ - **Re-scoring replaces wholesale.** A criterion left out of a new score line
36
+ counts as 0, not as its previous value. Submit the full set every time.
37
+ - **Only criteria named in the current goal are accepted.** If a rubric no
38
+ longer fits the work, change the goal (`note goal --set ... --criterion ...`)
39
+ rather than inventing a criterion at score time.
40
+ - **Totals are recomputed against the current goal.** When the goal moves, old
41
+ attempts visibly drop until they are re-judged. That is correct.
42
+
43
+ ## Correcting a node
44
+
45
+ Nothing is ever edited in place. `note supersede <id> "corrected content"`
46
+ appends a new node and an edge `old -superseded_by-> new`, which moves the
47
+ frontier to the new node and takes the old one out of `note tails`. Type and
48
+ tags are inherited unless you pass new ones.
49
+
50
+ ## Orientation
51
+
52
+ - `note status` — goal, criteria, per-goal best and the frontier, in one screen
53
+ - `note check` — what still breaks the loop (exit 1 if anything does)
54
+ - `note goals` — per-goal best attempt and which one set it
55
+ - `note tails` — the working frontier (goal nodes excluded)
56
+ - `note trace <id>` — what an attempt ultimately feeds
57
+ - `note trace <goal-id> --up` — which attempts feed a goal
58
+ - `note search <keyword>` — find a node to start from
note_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """note — a decision-graph and goal notebook for a git repo."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("notegraph")
6
+ except PackageNotFoundError: # running from a source tree, not installed
7
+ __version__ = "0+unknown"
note_cli/_cli_check.py ADDED
@@ -0,0 +1,103 @@
1
+ """End-to-end drive of every command. Run: python -m note_cli._cli_check"""
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from note_cli.cli import main
7
+
8
+
9
+ def run(*args):
10
+ main(list(args))
11
+
12
+
13
+ def main_check():
14
+ os.chdir(tempfile.mkdtemp())
15
+ subprocess.run(["git", "init", "-q"], check=True)
16
+
17
+ try:
18
+ run("heads")
19
+ raise AssertionError("expected SystemExit before init")
20
+ except SystemExit as e:
21
+ assert "note init" in str(e), e
22
+
23
+ run("init")
24
+ run("init") # idempotent, must not raise
25
+ run("goal", "--set", "ship it",
26
+ "--criterion", "consistency:3:style holds: across seeds",
27
+ "--criterion", "legibility:1:readable")
28
+ run("goal")
29
+ run("goal", "--history")
30
+
31
+ run("add", "legible text", "--type", "goal") # id 1
32
+ run("add", "arm B1_P1", "--type", "attempt", "--tags", "p1,ref") # id 2
33
+ run("add", "arm B1_P2", "--type", "attempt") # id 3
34
+ run("link", "2", "1", "--rel", "targets")
35
+ run("link", "3", "1", "--rel", "targets")
36
+ run("link", "2", "3", "--rel", "leads_to")
37
+ run("score", "2", "consistency=0.4")
38
+ run("score", "3", "consistency=1.0", "legibility=0.8", "--note", "best so far")
39
+ run("goals")
40
+ run("show", "3") # 2 -leads_to-> 3 and nothing else: not yet a merge
41
+ run("add", "arm B1_P3", "--type", "attempt") # id 4
42
+ run("link", "4", "3", "--rel", "leads_to")
43
+ run("show", "3") # now in-degree 2: must print the merge-point line
44
+ run("search", "B1")
45
+ run("trace", "2")
46
+ run("trace", "1", "--up")
47
+ run("heads")
48
+ run("tails")
49
+ run("graph")
50
+ run("graph", "--from", "2", "--depth", "1")
51
+
52
+ from note_cli import store
53
+ assert (store.store_dir() / "graph.dot").exists()
54
+
55
+ # `check` must exit nonzero while an attempt is untargeted or unscored
56
+ run("add", "unlinked try", "--type", "attempt") # id 5
57
+ try:
58
+ run("check")
59
+ raise AssertionError("expected SystemExit(1) with a loose attempt")
60
+ except SystemExit as e:
61
+ assert e.code == 1, e.code
62
+ run("link", "5", "1", "--rel", "targets")
63
+ try:
64
+ run("check")
65
+ raise AssertionError("expected SystemExit(1) with an unscored attempt")
66
+ except SystemExit as e:
67
+ assert e.code == 1, e.code
68
+ run("score", "5", "consistency=0.1")
69
+ # node 4 was added earlier as a merge-point helper and never targeted a
70
+ # goal — `check` caught that, which is the point of the command
71
+ run("link", "4", "1", "--rel", "targets")
72
+ run("score", "4", "consistency=0.5")
73
+ run("check") # now clean: must not raise
74
+
75
+ # supersede: the new node becomes the frontier, the old drops out
76
+ before = {n["id"] for n in graphtails()}
77
+ run("supersede", "5", "unlinked try, corrected") # id 6
78
+ after = {n["id"] for n in graphtails()}
79
+ assert 5 not in after and 6 in after, (before, after)
80
+
81
+ run("status")
82
+
83
+ # --version must report what the package metadata says, so a bug report
84
+ # names a real release
85
+ from note_cli import __version__
86
+ try:
87
+ run("--version")
88
+ raise AssertionError("argparse should exit after --version")
89
+ except SystemExit as e:
90
+ assert e.code == 0, e.code
91
+ import importlib.metadata as md
92
+ assert __version__ == md.version("notegraph"), (__version__, md.version("notegraph"))
93
+
94
+ print("cli: ok")
95
+
96
+
97
+ def graphtails():
98
+ from note_cli import graph as g, store
99
+ return g.tails(store.read("nodes.jsonl"), store.read("edges.jsonl"))
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main_check()
@@ -0,0 +1,40 @@
1
+ """Two processes adding under the lock must produce two distinct ids.
2
+
3
+ Separate from store.demo() because it forks; run it directly:
4
+ python -m note_cli._concurrency_check
5
+ """
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+
10
+ from note_cli import store
11
+
12
+
13
+ def main():
14
+ d = tempfile.mkdtemp()
15
+ os.chdir(d)
16
+ subprocess.run(["git", "init", "-q"], check=True)
17
+ store.init()
18
+
19
+ reads, writes = zip(*(os.pipe() for _ in range(8)))
20
+ for i, w in enumerate(writes):
21
+ if os.fork() == 0:
22
+ nid = store.add_node(f"child {i}")
23
+ os.write(w, str(nid).encode())
24
+ os._exit(0)
25
+ for w in writes:
26
+ os.close(w)
27
+ ids = []
28
+ for r in reads:
29
+ ids.append(int(os.read(r, 16)))
30
+ os.close(r)
31
+ for _ in writes:
32
+ os.wait()
33
+
34
+ assert len(set(ids)) == len(ids), f"duplicate ids under concurrency: {sorted(ids)}"
35
+ assert sorted(ids) == list(range(1, len(ids) + 1)), sorted(ids)
36
+ print("concurrency: ok")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
note_cli/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ import argparse
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if not hasattr(__import__("os"), "fork"):
7
+ # store.py locks with fcntl.flock, which Windows has no equivalent of.
8
+ # pip installs regardless of the POSIX classifier, so say so plainly
9
+ # instead of letting `import fcntl` raise ModuleNotFoundError.
10
+ sys.exit("note requires a POSIX system (Linux, macOS, WSL): it locks the "
11
+ "store with fcntl.flock, which Windows does not provide.")
12
+
13
+ from note_cli import __version__
14
+ from note_cli import goal as goalmod
15
+ from note_cli import graph as graphmod
16
+ from note_cli import render, store
17
+
18
+
19
+ def _nodes_edges():
20
+ return store.read("nodes.jsonl"), store.read("edges.jsonl")
21
+
22
+
23
+ def _line(n):
24
+ tags = f" [{','.join(n['tags'])}]" if n.get("tags") else ""
25
+ return f" {n['id']:>4} {n['type']:<10} {n['content']}{tags} ({n.get('branch')})"
26
+
27
+
28
+ def _print_nodes(title, nodes):
29
+ print(title)
30
+ for n in nodes:
31
+ print(_line(n))
32
+ if not nodes:
33
+ print(" (none)")
34
+
35
+
36
+ def cmd_init(args):
37
+ path, created = store.init()
38
+ print(f"store: {path}")
39
+ print(f"created: {', '.join(created)}" if created else "already initialized")
40
+
41
+
42
+ def cmd_goal(args):
43
+ if args.history:
44
+ for i, g in enumerate(goalmod.history(), 1):
45
+ print(f"{i}. [{g['timestamp']}] {g['goal']}")
46
+ for c in g["criteria"]:
47
+ print(f" {c['weight']:<5} {c['name']}: {c['rubric']}")
48
+ return
49
+ if args.set is not None:
50
+ criteria = [goalmod.parse_criterion(c) for c in (args.criterion or [])]
51
+ if not criteria:
52
+ raise SystemExit("--set needs at least one --criterion name:weight:rubric")
53
+ goalmod.set_goal(args.set, criteria)
54
+ print(f"goal set, {len(criteria)} criteria")
55
+ return
56
+ g = goalmod.current_goal()
57
+ if not g:
58
+ raise SystemExit("no goal set — run `note goal --set ... --criterion ...`")
59
+ print(g["goal"])
60
+ denom = sum(c["weight"] for c in g["criteria"])
61
+ for c in g["criteria"]:
62
+ print(f" {c['weight'] / denom:>5.0%} {c['name']}: {c['rubric']}")
63
+
64
+
65
+ def cmd_add(args):
66
+ tags = args.tags.split(",") if args.tags else []
67
+ print(store.add_node(args.content, type=args.type, tags=tags))
68
+
69
+
70
+ def cmd_link(args):
71
+ store.add_edge(args.src, args.dst, args.rel)
72
+ print(f"{args.src} -{args.rel}-> {args.dst}")
73
+
74
+
75
+ def cmd_score(args):
76
+ scores = {}
77
+ for pair in args.pairs:
78
+ if "=" not in pair:
79
+ raise SystemExit(f"want name=value, got {pair!r}")
80
+ k, v = pair.split("=", 1)
81
+ try:
82
+ scores[k] = float(v)
83
+ except ValueError:
84
+ raise SystemExit(f"{k}: {v!r} is not a number")
85
+ if not 0.0 <= scores[k] <= 1.0:
86
+ raise SystemExit(f"{k}: scores are 0.0-1.0, got {scores[k]}")
87
+ print(f"{goalmod.add_score(args.id, scores, args.note):.3f}")
88
+
89
+
90
+ def cmd_goals(args):
91
+ rows, unassigned = goalmod.rollup()
92
+ for n, best in rows:
93
+ if best:
94
+ total, aid, ts = best
95
+ print(f"{n['id']:>4} {n['content']}\n best {total:.3f} from #{aid} ({ts})")
96
+ else:
97
+ print(f"{n['id']:>4} {n['content']}\n (no scored attempts)")
98
+ if unassigned:
99
+ print("\n(unassigned) — scored but targeting no goal:")
100
+ for n, total, ts in unassigned:
101
+ print(f"{n['id']:>4} {total:.3f} {n['content']} ({ts})")
102
+
103
+
104
+ def cmd_supersede(args):
105
+ tags = args.tags.split(",") if args.tags else []
106
+ print(store.supersede(args.id, args.content, type=args.type, tags=tags))
107
+
108
+
109
+ def cmd_check(args):
110
+ """Exit nonzero while any attempt is loose, so a hook or an agent can gate
111
+ on it rather than trusting itself to remember the loop."""
112
+ untargeted, unscored = goalmod.unfinished()
113
+ if not untargeted and not unscored:
114
+ print("clean: every attempt targets a goal and carries a score")
115
+ return
116
+ if untargeted:
117
+ print("attempts targeting no goal — `note link <id> <goal> --rel targets`:")
118
+ for n in untargeted:
119
+ print(_line(n))
120
+ if unscored:
121
+ print("attempts with no score — `note score <id> name=0.0-1.0`:")
122
+ for n in unscored:
123
+ print(_line(n))
124
+ raise SystemExit(1)
125
+
126
+
127
+ def cmd_status(args):
128
+ g = goalmod.current_goal()
129
+ if not g:
130
+ print("no goal set — run `note goal --set ... --criterion ...`")
131
+ else:
132
+ print(g["goal"])
133
+ denom = sum(c["weight"] for c in g["criteria"])
134
+ for c in g["criteria"]:
135
+ print(f" {c['weight'] / denom:>5.0%} {c['name']}: {c['rubric']}")
136
+ print()
137
+ cmd_goals(args)
138
+ nodes, edges = _nodes_edges()
139
+ _print_nodes("\nfrontier:", graphmod.tails(nodes, edges))
140
+
141
+
142
+ def cmd_show(args):
143
+ nodes, edges = _nodes_edges()
144
+ by_id = {n["id"]: n for n in nodes}
145
+ if args.id not in by_id:
146
+ raise SystemExit(f"no node {args.id}")
147
+ n = by_id[args.id]
148
+ print(f"#{n['id']} [{n['type']}] {n['content']}")
149
+ print(f" tags: {', '.join(n.get('tags') or []) or '-'}")
150
+ print(f" branch: {n.get('branch')} {n['timestamp']}")
151
+ s = goalmod.latest_scores().get(args.id)
152
+ if s:
153
+ g = goalmod.current_goal()
154
+ total = goalmod.weighted_total(s["scores"], g["criteria"] if g else [])
155
+ print(f" score: {total:.3f} {s['scores']} ({s['timestamp']})")
156
+ if s.get("note"):
157
+ print(f" {s['note']}")
158
+ if args.id in {m["id"] for m in graphmod.merges(nodes, edges)}:
159
+ print(" (merge point: two or more lines of work converge here)")
160
+ for e in edges:
161
+ if e["from"] == args.id:
162
+ print(f" -> {e['to']:<4} {e['relation']}")
163
+ if e["to"] == args.id:
164
+ print(f" <- {e['from']:<4} {e['relation']}")
165
+
166
+
167
+ def cmd_search(args):
168
+ k = args.keyword.lower()
169
+ nodes, _ = _nodes_edges()
170
+ _print_nodes(f"matches for {args.keyword!r}:",
171
+ [n for n in nodes
172
+ if k in n["content"].lower()
173
+ or any(k in t.lower() for t in n.get("tags") or [])])
174
+
175
+
176
+ def cmd_trace(args):
177
+ nodes, edges = _nodes_edges()
178
+ by_id = {n["id"]: n for n in nodes}
179
+ if args.id not in by_id:
180
+ raise SystemExit(f"no node {args.id}")
181
+
182
+ def show(tree, depth, rel=None):
183
+ nid, kids = tree
184
+ n = by_id.get(nid)
185
+ arrow = f"-{rel}-> " if rel else ""
186
+ print(" " * depth + f"{arrow}#{nid} {n['content'] if n else '?'}")
187
+ for r, kid in kids:
188
+ show(kid, depth + 1, r)
189
+
190
+ show(graphmod.trace(args.id, edges, args.direction), 0)
191
+
192
+
193
+ def cmd_heads(args):
194
+ nodes, edges = _nodes_edges()
195
+ _print_nodes("heads:", graphmod.heads(nodes, edges))
196
+
197
+
198
+ def cmd_tails(args):
199
+ nodes, edges = _nodes_edges()
200
+ _print_nodes("tails:", graphmod.tails(nodes, edges))
201
+
202
+
203
+ def cmd_graph(args):
204
+ nodes, edges = _nodes_edges()
205
+ if args.frm is not None:
206
+ keep = graphmod.subgraph(args.frm, edges, args.depth)
207
+ nodes = [n for n in nodes if n["id"] in keep]
208
+ edges = [e for e in edges if e["from"] in keep and e["to"] in keep]
209
+ src = render.dot_source(nodes, edges, goalmod.current_goal())
210
+ dot_file, svg = render.write_graph(store.require_store(), src)
211
+ print(f"wrote {dot_file}")
212
+ if svg:
213
+ print(f"wrote {svg}")
214
+ else:
215
+ print("graphviz `dot` not found — install it to also render an .svg")
216
+
217
+
218
+ def cmd_skill(args):
219
+ src = Path(__file__).parent / "SKILL.md"
220
+ dst = Path.home() / ".claude" / "skills" / "note" / "SKILL.md"
221
+ dst.parent.mkdir(parents=True, exist_ok=True)
222
+ shutil.copyfile(src, dst)
223
+ print(f"wrote {dst}")
224
+
225
+
226
+ def build_parser():
227
+ p = argparse.ArgumentParser(prog="note", description=__doc__)
228
+ p.add_argument("--version", action="version", version=f"note {__version__}")
229
+ sub = p.add_subparsers(dest="cmd", required=True)
230
+
231
+ sub.add_parser("init", help="create the store").set_defaults(fn=cmd_init)
232
+
233
+ g = sub.add_parser("goal", help="show or set the final goal")
234
+ g.add_argument("--set")
235
+ g.add_argument("--criterion", action="append", metavar="name:weight:rubric")
236
+ g.add_argument("--history", action="store_true")
237
+ g.set_defaults(fn=cmd_goal)
238
+
239
+ a = sub.add_parser("add", help="append a node")
240
+ a.add_argument("content")
241
+ a.add_argument("--type", default="note")
242
+ a.add_argument("--tags")
243
+ a.set_defaults(fn=cmd_add)
244
+
245
+ ln = sub.add_parser("link", help="append an edge")
246
+ ln.add_argument("src", type=int)
247
+ ln.add_argument("dst", type=int)
248
+ ln.add_argument("--rel", required=True)
249
+ ln.set_defaults(fn=cmd_link)
250
+
251
+ sc = sub.add_parser("score", help="score an attempt against the goal criteria")
252
+ sc.add_argument("id", type=int)
253
+ sc.add_argument("pairs", nargs="+", metavar="name=0.0-1.0")
254
+ sc.add_argument("--note")
255
+ sc.set_defaults(fn=cmd_score)
256
+
257
+ sub.add_parser("goals", help="per-goal best attempt").set_defaults(fn=cmd_goals)
258
+ sub.add_parser("status", help="goal, criteria, best attempts and frontier"
259
+ ).set_defaults(fn=cmd_status)
260
+ sub.add_parser("check", help="exit 1 if any attempt is untargeted or unscored"
261
+ ).set_defaults(fn=cmd_check)
262
+
263
+ sp = sub.add_parser("supersede", help="correct a node with a new one")
264
+ sp.add_argument("id", type=int)
265
+ sp.add_argument("content")
266
+ sp.add_argument("--type")
267
+ sp.add_argument("--tags")
268
+ sp.set_defaults(fn=cmd_supersede)
269
+
270
+ sh = sub.add_parser("show", help="one node in full")
271
+ sh.add_argument("id", type=int)
272
+ sh.set_defaults(fn=cmd_show)
273
+
274
+ se = sub.add_parser("search", help="substring match over content and tags")
275
+ se.add_argument("keyword")
276
+ se.set_defaults(fn=cmd_search)
277
+
278
+ tr = sub.add_parser("trace", help="DFS from a node")
279
+ tr.add_argument("id", type=int)
280
+ tr.add_argument("--up", dest="direction", action="store_const", const="up")
281
+ tr.add_argument("--down", dest="direction", action="store_const", const="down")
282
+ tr.add_argument("--both", dest="direction", action="store_const", const="both")
283
+ tr.set_defaults(fn=cmd_trace, direction="down")
284
+
285
+ sub.add_parser("heads", help="in-degree-0 work nodes").set_defaults(fn=cmd_heads)
286
+ sub.add_parser("tails", help="the working frontier").set_defaults(fn=cmd_tails)
287
+
288
+ gr = sub.add_parser("graph", help="emit Graphviz DOT")
289
+ gr.add_argument("--from", dest="frm", type=int)
290
+ gr.add_argument("--depth", type=int)
291
+ gr.set_defaults(fn=cmd_graph)
292
+
293
+ sk = sub.add_parser("skill", help="install the agent skill")
294
+ sk.add_argument("--install", action="store_true", required=True)
295
+ sk.set_defaults(fn=cmd_skill)
296
+
297
+ return p
298
+
299
+
300
+ def main(argv=None):
301
+ args = build_parser().parse_args(argv)
302
+ args.fn(args)
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
note_cli/goal.py ADDED
@@ -0,0 +1,213 @@
1
+ from note_cli import store
2
+
3
+ GOAL_FILE = "goal.jsonl"
4
+ SCORE_FILE = "scores.jsonl"
5
+
6
+
7
+ def history():
8
+ return store.read(GOAL_FILE)
9
+
10
+
11
+ def current_goal():
12
+ """Last line in FILE ORDER wins — appends are serialized by the write
13
+ lock, so file order is a total order and timestamps are informational."""
14
+ lines = history()
15
+ return lines[-1] if lines else None
16
+
17
+
18
+ def set_goal(text, criteria):
19
+ with store.write_lock():
20
+ store.append(GOAL_FILE, {"goal": text, "criteria": criteria,
21
+ "timestamp": store.now()})
22
+
23
+
24
+ def parse_criterion(s):
25
+ parts = s.split(":", 2) # first two colons only; rubrics contain colons
26
+ if len(parts) != 3:
27
+ raise SystemExit(f"--criterion wants name:weight:rubric, got {s!r}")
28
+ name, weight, rubric = parts
29
+ try:
30
+ weight = float(weight)
31
+ except ValueError:
32
+ raise SystemExit(f"criterion {name!r}: weight {weight!r} is not a number")
33
+ return {"name": name, "weight": weight, "rubric": rubric}
34
+
35
+
36
+ def weighted_total(scores, criteria):
37
+ """Always computed against the CURRENT criteria. A score line that lacks a
38
+ criterion contributes 0 for it and that criterion's weight still counts in
39
+ the normalization, so an attempt judged under an older standard visibly
40
+ drops — the bar moved and it has not been re-judged against it."""
41
+ denom = sum(c["weight"] for c in criteria)
42
+ if not denom:
43
+ return 0.0
44
+ return sum(c["weight"] * scores.get(c["name"], 0.0) for c in criteria) / denom
45
+
46
+
47
+ def latest_scores():
48
+ """node id -> its last score line. A re-score replaces the previous
49
+ judgement WHOLESALE; merging would make a node's real score depend on the
50
+ order of every past line."""
51
+ out = {}
52
+ for line in store.read(SCORE_FILE):
53
+ out[line["node"]] = line
54
+ return out
55
+
56
+
57
+ def add_score(nid, scores, note=None):
58
+ with store.write_lock():
59
+ nodes = {n["id"]: n for n in store.read("nodes.jsonl")}
60
+ if nid not in nodes:
61
+ raise SystemExit(f"no node {nid}")
62
+ if nodes[nid]["type"] == "goal":
63
+ raise SystemExit(f"node {nid} is a goal — scores belong on attempts")
64
+ g = current_goal()
65
+ if not g:
66
+ raise SystemExit("no goal set — run `note goal --set ...`")
67
+ known = {c["name"] for c in g["criteria"]}
68
+ unknown = sorted(set(scores) - known)
69
+ if unknown:
70
+ raise SystemExit(f"unknown criteria {unknown}; goal has {sorted(known)}")
71
+ store.append(SCORE_FILE, {"node": nid, "scores": scores, "note": note,
72
+ "timestamp": store.now()})
73
+ return weighted_total(scores, g["criteria"])
74
+
75
+
76
+ def rollup():
77
+ """Per-goal best attempt, plus scored attempts that target nothing.
78
+
79
+ Max, not mean or latest: the working pattern is "run several arms, adopt
80
+ the best one". A mean would dilute a winning arm with its failed siblings.
81
+ """
82
+ nodes = store.read("nodes.jsonl")
83
+ edges = store.read("edges.jsonl")
84
+ g = current_goal()
85
+ criteria = g["criteria"] if g else []
86
+ latest = latest_scores()
87
+ by_id = {n["id"]: n for n in nodes}
88
+
89
+ targeted, assigned = {}, set()
90
+ for e in edges:
91
+ if e["relation"] == "targets":
92
+ targeted.setdefault(e["to"], []).append(e["from"])
93
+ assigned.add(e["from"])
94
+
95
+ rows = []
96
+ for n in nodes:
97
+ if n["type"] != "goal":
98
+ continue
99
+ best = None
100
+ for aid in targeted.get(n["id"], ()):
101
+ if aid not in latest:
102
+ continue
103
+ cand = (weighted_total(latest[aid]["scores"], criteria), aid,
104
+ latest[aid]["timestamp"])
105
+ if best is None or cand[0] > best[0]:
106
+ best = cand
107
+ rows.append((n, best))
108
+
109
+ # a scored attempt with no goal is a linking mistake, not a category of
110
+ # work — it must not simply vanish from the rollup
111
+ unassigned = [(by_id[i], weighted_total(latest[i]["scores"], criteria),
112
+ latest[i]["timestamp"])
113
+ for i in sorted(latest) if i not in assigned and i in by_id]
114
+ return rows, unassigned
115
+
116
+
117
+ def unfinished():
118
+ """Attempts that break the loop, as (untargeted, unscored).
119
+
120
+ Only `attempt` nodes are held to it — a decision or a plain note has
121
+ nothing to score. This is what `note check` exits nonzero on, so a hook
122
+ or an agent can gate on it instead of trusting itself to remember.
123
+ """
124
+ nodes = store.read("nodes.jsonl")
125
+ edges = store.read("edges.jsonl")
126
+ latest = latest_scores()
127
+ targeting = {e["from"] for e in edges if e["relation"] == "targets"}
128
+ untargeted, unscored = [], []
129
+ for n in nodes:
130
+ if n["type"] != "attempt":
131
+ continue
132
+ if n["id"] not in targeting:
133
+ untargeted.append(n)
134
+ elif n["id"] not in latest:
135
+ unscored.append(n)
136
+ return untargeted, unscored
137
+
138
+
139
+ def demo():
140
+ import os, subprocess, tempfile
141
+ os.chdir(tempfile.mkdtemp())
142
+ subprocess.run(["git", "init", "-q"], check=True)
143
+ store.init()
144
+
145
+ # rubric text routinely contains a colon: split on the first two only
146
+ c = parse_criterion("legibility:0.3:readable at 512px: no clipping")
147
+ assert c == {"name": "legibility", "weight": 0.3,
148
+ "rubric": "readable at 512px: no clipping"}, c
149
+ try:
150
+ parse_criterion("badinput")
151
+ raise AssertionError("expected SystemExit")
152
+ except SystemExit:
153
+ pass
154
+
155
+ crit = [{"name": "consistency", "weight": 3.0, "rubric": "style holds"},
156
+ {"name": "legibility", "weight": 1.0, "rubric": "readable"}]
157
+ set_goal("ship an education image generator", crit)
158
+ assert current_goal()["goal"] == "ship an education image generator"
159
+
160
+ # weights need not sum to 1 — they are normalized
161
+ assert weighted_total({"consistency": 1.0, "legibility": 1.0}, crit) == 1.0
162
+ assert weighted_total({"consistency": 1.0}, crit) == 0.75
163
+ assert weighted_total({}, crit) == 0.0
164
+
165
+ g = store.add_node("legible text", type="goal")
166
+ a1 = store.add_node("arm B1_P1", type="attempt")
167
+ a2 = store.add_node("arm B1_P2", type="attempt")
168
+ orphan = store.add_node("unlinked try", type="attempt")
169
+ store.add_edge(a1, g, "targets")
170
+ store.add_edge(a2, g, "targets")
171
+
172
+ assert round(add_score(a1, {"consistency": 0.4}), 9) == 0.3 # 3*0.4/4 is not exact
173
+ assert add_score(a2, {"consistency": 1.0, "legibility": 1.0}) == 1.0
174
+ add_score(orphan, {"legibility": 1.0})
175
+
176
+ # re-score replaces wholesale: the omitted criterion counts 0, not its old value
177
+ assert add_score(a2, {"legibility": 1.0}) == 0.25
178
+ assert latest_scores()[a2]["scores"] == {"legibility": 1.0}
179
+
180
+ try:
181
+ add_score(g, {"consistency": 1.0})
182
+ raise AssertionError("expected SystemExit for scoring a goal node")
183
+ except SystemExit as e:
184
+ assert "goal" in str(e), e
185
+ try:
186
+ add_score(a1, {"nonesuch": 1.0})
187
+ raise AssertionError("expected SystemExit for an unknown criterion")
188
+ except SystemExit as e:
189
+ assert "nonesuch" in str(e), e
190
+
191
+ rows, unassigned = rollup()
192
+ assert len(rows) == 1
193
+ node, best = rows[0]
194
+ assert node["id"] == g
195
+ assert round(best[0], 9) == 0.3 and best[1] == a1, best # max; a2 fell on re-score
196
+ assert [u[0]["id"] for u in unassigned] == [orphan], unassigned
197
+
198
+ # orphan targets nothing; a1/a2 target g and are scored
199
+ untargeted, unscored = unfinished()
200
+ assert [n["id"] for n in untargeted] == [orphan], untargeted
201
+ assert unscored == [], unscored
202
+
203
+ fresh = store.add_node("just tried, not judged yet", type="attempt")
204
+ store.add_edge(fresh, g, "targets")
205
+ untargeted, unscored = unfinished()
206
+ assert [n["id"] for n in untargeted] == [orphan], untargeted
207
+ assert [n["id"] for n in unscored] == [fresh], unscored
208
+
209
+ print("goal: ok")
210
+
211
+
212
+ if __name__ == "__main__":
213
+ demo()
note_cli/graph.py ADDED
@@ -0,0 +1,128 @@
1
+ TARGETS = "targets"
2
+ GOAL_TYPE = "goal"
3
+
4
+
5
+ def adjacency(edges):
6
+ """Work-graph adjacency. `targets` edges are excluded so that merge
7
+ detection (in-degree > 1) sees only real convergence of work."""
8
+ out, inn = {}, {}
9
+ for e in edges:
10
+ if e["relation"] == TARGETS:
11
+ continue
12
+ out.setdefault(e["from"], []).append(e["to"])
13
+ inn.setdefault(e["to"], []).append(e["from"])
14
+ return out, inn
15
+
16
+
17
+ def heads(nodes, edges):
18
+ _, inn = adjacency(edges)
19
+ return [n for n in nodes if n["type"] != GOAL_TYPE and not inn.get(n["id"])]
20
+
21
+
22
+ def tails(nodes, edges):
23
+ """The working frontier.
24
+
25
+ Two filters, not one. Dropping `targets` edges is not enough: a goal node
26
+ whose only edges are inbound `targets` becomes isolated in the filtered
27
+ graph — in-degree 0 AND out-degree 0 — so it would surface in heads and
28
+ tails both. Skipping goal-typed nodes is what keeps this list meaningful.
29
+ """
30
+ out, _ = adjacency(edges)
31
+ return [n for n in nodes if n["type"] != GOAL_TYPE and not out.get(n["id"])]
32
+
33
+
34
+ def merges(nodes, edges):
35
+ _, inn = adjacency(edges)
36
+ return [n for n in nodes if len(inn.get(n["id"], ())) > 1]
37
+
38
+
39
+ def _directed(edges, direction):
40
+ """Neighbour map over ALL edges — traversal ignores the topology filters."""
41
+ fwd, bwd = {}, {}
42
+ for e in edges:
43
+ fwd.setdefault(e["from"], []).append((e["to"], e["relation"]))
44
+ bwd.setdefault(e["to"], []).append((e["from"], e["relation"]))
45
+ if direction == "down":
46
+ return [fwd]
47
+ if direction == "up":
48
+ return [bwd]
49
+ return [fwd, bwd]
50
+
51
+
52
+ def trace(root, edges, direction="down"):
53
+ """DFS from root, as nested (node_id, [(relation, child), ...]).
54
+
55
+ The seen-set is per path, not global: it stops cycles from recursing
56
+ forever while still letting a node legitimately reachable by two routes
57
+ show under both.
58
+ """
59
+ maps = _directed(edges, direction)
60
+
61
+ def walk(nid, seen):
62
+ if nid in seen:
63
+ return (nid, [])
64
+ seen = seen | {nid}
65
+ kids = [(rel, walk(m, seen)) for mp in maps for m, rel in mp.get(nid, ())]
66
+ return (nid, kids)
67
+
68
+ return walk(root, frozenset())
69
+
70
+
71
+ def subgraph(root, edges, depth):
72
+ """Node ids reachable downward from root within `depth` hops (None = all).
73
+ Follows every relation, `targets` included."""
74
+ (fwd,) = _directed(edges, "down")
75
+ seen, frontier, hops = {root}, [root], 0
76
+ while frontier and (depth is None or hops < depth):
77
+ nxt = []
78
+ for nid in frontier:
79
+ for m, _rel in fwd.get(nid, ()):
80
+ if m not in seen:
81
+ seen.add(m)
82
+ nxt.append(m)
83
+ frontier, hops = nxt, hops + 1
84
+ return seen
85
+
86
+
87
+ def demo():
88
+ nodes = [
89
+ {"id": 1, "type": "note"},
90
+ {"id": 2, "type": "note"},
91
+ {"id": 3, "type": "note"},
92
+ {"id": 4, "type": "goal"},
93
+ ]
94
+ edges = [
95
+ {"from": 1, "to": 3, "relation": "leads_to"},
96
+ {"from": 2, "to": 3, "relation": "leads_to"},
97
+ {"from": 3, "to": 4, "relation": "targets"},
98
+ {"from": 1, "to": 4, "relation": "targets"},
99
+ ]
100
+
101
+ out, inn = adjacency(edges)
102
+ assert out == {1: [3], 2: [3]}, out # targets edges excluded
103
+ assert inn == {3: [1, 2]}, inn
104
+
105
+ assert [n["id"] for n in heads(nodes, edges)] == [1, 2]
106
+ # 4 is a goal and is isolated once targets are dropped: it must appear in
107
+ # NEITHER heads nor tails, which edge-filtering alone would not achieve
108
+ assert [n["id"] for n in tails(nodes, edges)] == [3]
109
+ assert [n["id"] for n in merges(nodes, edges)] == [3]
110
+
111
+ # trace follows targets; topology filters do not apply to traversal
112
+ assert trace(1, edges) == (1, [("leads_to", (3, [("targets", (4, []))])),
113
+ ("targets", (4, []))])
114
+ assert trace(4, edges, "up") == (4, [("targets", (3, [("leads_to", (1, [])),
115
+ ("leads_to", (2, []))])),
116
+ ("targets", (1, []))])
117
+
118
+ cyc = [{"from": 1, "to": 2, "relation": "r"}, {"from": 2, "to": 1, "relation": "r"}]
119
+ assert trace(1, cyc) == (1, [("r", (2, [("r", (1, []))]))]) # terminates
120
+
121
+ assert subgraph(1, edges, None) == {1, 3, 4}
122
+ assert subgraph(1, edges, 1) == {1, 3, 4}
123
+ assert subgraph(1, edges, 0) == {1}
124
+ print("graph: ok")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ demo()
note_cli/render.py ADDED
@@ -0,0 +1,76 @@
1
+ import shutil
2
+ import subprocess
3
+
4
+ MAX_LABEL = 40
5
+
6
+
7
+ def _label(text):
8
+ """DOT string literals cannot hold a double quote; swap for a single."""
9
+ t = text[:MAX_LABEL].replace('"', "'").replace("\n", " ")
10
+ return t + ("\u2026" if len(text) > MAX_LABEL else "")
11
+
12
+
13
+ def dot_source(nodes, edges, goal):
14
+ """The final goal is drawn as a VIRTUAL node at the far right — it is
15
+ never stored, so goal.jsonl stays the single source of truth, but the
16
+ rendering shows the intended shape: attempts converge on branch goals,
17
+ branch goals converge on the one final goal."""
18
+ out = ["digraph notes {", " rankdir=LR;"]
19
+ for n in nodes:
20
+ shape = "box" if n["type"] == "goal" else "ellipse"
21
+ out.append(f' n{n["id"]} [label="{n["id"]}: {_label(n["content"])}" shape={shape}];')
22
+ for e in edges:
23
+ attrs = f'label="{e["relation"]}"'
24
+ if e["relation"] == "targets":
25
+ attrs += ", style=dashed"
26
+ out.append(f' n{e["from"]} -> n{e["to"]} [{attrs}];')
27
+ if goal:
28
+ out.append(f' FINAL [label="{_label(goal["goal"])}" shape=doubleoctagon];')
29
+ for n in nodes:
30
+ if n["type"] == "goal":
31
+ out.append(f' n{n["id"]} -> FINAL [style=dashed];')
32
+ out.append("}")
33
+ return "\n".join(out) + "\n"
34
+
35
+
36
+ def write_graph(store_path, source):
37
+ """Always write the .dot. Render the .svg only if `dot` is on PATH —
38
+ a missing graphviz is a warning, not an error."""
39
+ dot_file = store_path / "graph.dot"
40
+ dot_file.write_text(source)
41
+ if shutil.which("dot") is None:
42
+ return dot_file, None
43
+ svg = store_path / "graph.svg"
44
+ subprocess.run(["dot", "-Tsvg", str(dot_file), "-o", str(svg)], check=True)
45
+ return dot_file, svg
46
+
47
+
48
+ def demo():
49
+ import tempfile
50
+ from pathlib import Path
51
+
52
+ nodes = [{"id": 1, "content": "arm B1_P2", "type": "attempt"},
53
+ {"id": 2, "content": "legible text", "type": "goal"}]
54
+ edges = [{"from": 1, "to": 2, "relation": "targets"}]
55
+ src = dot_source(nodes, edges, {"goal": "ship it"})
56
+
57
+ assert src.startswith("digraph notes {")
58
+ assert src.rstrip().endswith("}")
59
+ assert 'n1 [label="1: arm B1_P2"' in src
60
+ assert "shape=doubleoctagon" in src # the virtual final goal
61
+ assert "n2 -> FINAL" in src
62
+ assert "n1 -> n2" in src and "style=dashed" in src
63
+ assert "FINAL" not in dot_source(nodes, edges, None)
64
+
65
+ # a quote in content must not break the DOT string
66
+ assert '\\"' not in dot_source([{"id": 9, "content": 'say "hi"', "type": "note"}], [], None)
67
+
68
+ d = Path(tempfile.mkdtemp())
69
+ dot_file, svg = write_graph(d, src)
70
+ assert dot_file == d / "graph.dot" and dot_file.read_text() == src
71
+ assert svg is None or svg == d / "graph.svg"
72
+ print("render: ok")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ demo()
note_cli/store.py ADDED
@@ -0,0 +1,211 @@
1
+ import fcntl
2
+ import json
3
+ import os
4
+ import subprocess
5
+ from contextlib import contextmanager
6
+ from datetime import datetime
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+
10
+ FILES = ("nodes.jsonl", "edges.jsonl", "goal.jsonl", "scores.jsonl")
11
+
12
+
13
+ def _git(*args):
14
+ """Run a git command, returning stripped stdout; SystemExit on failure."""
15
+ r = subprocess.run(["git", *args], capture_output=True, text=True)
16
+ if r.returncode != 0:
17
+ raise SystemExit(r.stderr.strip() or f"git {' '.join(args)} failed")
18
+ return r.stdout.strip()
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def store_dir():
23
+ """<repo>/.git/notes — one store shared by every worktree of the repo.
24
+
25
+ `--git-common-dir` prints a relative path from the main worktree and an
26
+ absolute one from a linked worktree; realpath against cwd absorbs both.
27
+ (`--path-format=absolute` would be cleaner but needs git >= 2.31.)
28
+ """
29
+ return Path(os.path.realpath(_git("rev-parse", "--git-common-dir"))) / "notes"
30
+
31
+
32
+ def require_store():
33
+ d = store_dir()
34
+ if not d.is_dir():
35
+ raise SystemExit(f"no note store at {d} — run `note init`")
36
+ return d
37
+
38
+
39
+ def init():
40
+ """Create the store. Idempotent: existing files are never truncated.
41
+
42
+ Six worktrees share one store, so a second agent's `init` is a re-entry
43
+ into a store that already holds everyone's work, not a fresh start.
44
+ """
45
+ d = store_dir()
46
+ d.mkdir(parents=True, exist_ok=True)
47
+ created = []
48
+ for name in FILES:
49
+ f = d / name
50
+ if not f.exists():
51
+ f.touch()
52
+ created.append(name)
53
+ return d, created
54
+
55
+
56
+ @contextmanager
57
+ def write_lock():
58
+ """Serialize every write path — each is a read-then-append that a
59
+ concurrent write can invalidate."""
60
+ d = require_store()
61
+ with open(d / ".lock", "w") as fh:
62
+ fcntl.flock(fh, fcntl.LOCK_EX)
63
+ try:
64
+ yield d
65
+ finally:
66
+ fcntl.flock(fh, fcntl.LOCK_UN)
67
+
68
+
69
+ def read(name):
70
+ with open(require_store() / name) as fh:
71
+ return [json.loads(line) for line in fh if line.strip()]
72
+
73
+
74
+ def append(name, obj):
75
+ with open(require_store() / name, "a") as fh:
76
+ fh.write(json.dumps(obj, ensure_ascii=False) + "\n")
77
+
78
+
79
+ def now():
80
+ return datetime.now().isoformat(timespec="seconds")
81
+
82
+
83
+
84
+ def current_branch():
85
+ """The branch this node was written from. In a shared store, "which arm
86
+ is this" is otherwise lost. Detached HEAD (a worktree mid-rebase, for
87
+ one) prints nothing, so fall back to the short SHA."""
88
+ b = _git("branch", "--show-current")
89
+ return b or _git("rev-parse", "--short", "HEAD")
90
+
91
+
92
+ def add_node(content, type="note", tags=()):
93
+ with write_lock():
94
+ nodes = read("nodes.jsonl")
95
+ # max, not last: immune to a hand-edited file, same cost
96
+ nid = max((n["id"] for n in nodes), default=0) + 1
97
+ append("nodes.jsonl", {
98
+ "id": nid,
99
+ "content": content,
100
+ "type": type,
101
+ "tags": list(tags),
102
+ "branch": current_branch(),
103
+ "timestamp": now(),
104
+ })
105
+ return nid
106
+
107
+
108
+ def add_edge(src, dst, relation):
109
+ with write_lock():
110
+ ids = {n["id"] for n in read("nodes.jsonl")}
111
+ for i in (src, dst):
112
+ if i not in ids:
113
+ raise SystemExit(f"no node {i}")
114
+ append("edges.jsonl", {"from": src, "to": dst, "relation": relation})
115
+
116
+
117
+ def supersede(old, content, type=None, tags=()):
118
+ """Correct a node. Append-only has no edit, so a correction is a new node
119
+ plus an edge `old -superseded_by-> new`.
120
+
121
+ That direction is deliberate. With `new -supersedes-> old` the old node
122
+ would have out-degree 0 and surface in `tails` as the working frontier,
123
+ which is backwards. This way the NEW node is the frontier and the
124
+ corrected one drops out of it. Type and tags are inherited unless given.
125
+ """
126
+ with write_lock():
127
+ nodes = read("nodes.jsonl")
128
+ by_id = {n["id"]: n for n in nodes}
129
+ if old not in by_id:
130
+ raise SystemExit(f"no node {old}")
131
+ nid = max((n["id"] for n in nodes), default=0) + 1
132
+ append("nodes.jsonl", {
133
+ "id": nid,
134
+ "content": content,
135
+ "type": type or by_id[old]["type"],
136
+ "tags": list(tags) or list(by_id[old].get("tags") or []),
137
+ "branch": current_branch(),
138
+ "timestamp": now(),
139
+ })
140
+ append("edges.jsonl", {"from": old, "to": nid, "relation": "superseded_by"})
141
+ return nid
142
+
143
+
144
+ def demo():
145
+ import tempfile
146
+ d = tempfile.mkdtemp()
147
+ os.chdir(d) # chdir BEFORE any store call: store_dir is cached
148
+ subprocess.run(["git", "init", "-q"], check=True)
149
+ subprocess.run(["git", "config", "user.email", "t@t"], check=True)
150
+ subprocess.run(["git", "config", "user.name", "t"], check=True)
151
+
152
+ path, created = init()
153
+ assert path == Path(os.path.realpath(".git")) / "notes", path
154
+ assert sorted(created) == sorted(FILES), created
155
+
156
+ # idempotent and non-truncating: the second init must preserve content
157
+ append("nodes.jsonl", {"id": 1, "content": "survivor"})
158
+ path2, created2 = init()
159
+ assert path2 == path
160
+ assert created2 == [], created2
161
+ assert read("nodes.jsonl") == [{"id": 1, "content": "survivor"}]
162
+
163
+ with write_lock() as locked:
164
+ assert locked == path
165
+
166
+ assert "T" in now()
167
+
168
+ a = add_node("first attempt", type="attempt", tags=["arm-b1"])
169
+ g = add_node("legible text at 512px", type="goal")
170
+ assert (a, g) == (2, 3), (a, g) # id 1 was written by hand above
171
+
172
+ rows = {n["id"]: n for n in read("nodes.jsonl")}
173
+ assert rows[a]["type"] == "attempt"
174
+ assert rows[a]["tags"] == ["arm-b1"]
175
+ assert rows[a]["branch"], "branch must never be empty" # name varies by git config
176
+ assert "timestamp" in rows[a]
177
+
178
+ add_edge(a, g, "targets")
179
+ assert read("edges.jsonl") == [{"from": a, "to": g, "relation": "targets"}]
180
+
181
+ try:
182
+ add_edge(a, 999, "leads_to")
183
+ raise AssertionError("expected SystemExit for a nonexistent node")
184
+ except SystemExit as e:
185
+ assert "999" in str(e), e
186
+
187
+ # detached HEAD must not produce an empty branch field
188
+ subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "x"], check=True)
189
+ head = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
190
+ subprocess.run(["git", "checkout", "-q", head], check=True)
191
+ assert current_branch(), "detached HEAD must fall back to a short SHA"
192
+
193
+ # a correction is a new node plus old -superseded_by-> new, so the NEW one
194
+ # is the frontier and the old one drops out of tails
195
+ new_id = supersede(a, "first attempt, corrected")
196
+ rows = {n["id"]: n for n in read("nodes.jsonl")}
197
+ assert rows[new_id]["content"] == "first attempt, corrected"
198
+ assert rows[new_id]["type"] == rows[a]["type"], "type is inherited"
199
+ assert rows[new_id]["tags"] == rows[a]["tags"], "tags are inherited"
200
+ assert {"from": a, "to": new_id, "relation": "superseded_by"} in read("edges.jsonl")
201
+ try:
202
+ supersede(999, "nope")
203
+ raise AssertionError("expected SystemExit")
204
+ except SystemExit as e:
205
+ assert "999" in str(e), e
206
+
207
+ print("store: ok")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ demo()
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: notegraph
3
+ Version: 0.2.0
4
+ Summary: Decision-graph and goal notebook for a git repo, shared across every worktree
5
+ Author-email: 송재훈 <sjh030504@i-screammedia.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sjh354/note-cli
8
+ Project-URL: Source, https://github.com/sjh354/note-cli
9
+ Project-URL: Issues, https://github.com/sjh354/note-cli/issues
10
+ Project-URL: Changelog, https://github.com/sjh354/note-cli/blob/main/CHANGELOG.md
11
+ Keywords: git,worktree,notebook,decision-graph,agent,cli
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Version Control :: Git
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Provides-Extra: dev
30
+ Requires-Dist: build; extra == "dev"
31
+ Requires-Dist: twine; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # notegraph
35
+
36
+ A decision-graph and goal notebook for a git repo. One store per repo, shared
37
+ by every worktree, kept in `<repo>/.git/notes/` — so it never appears in the
38
+ working tree and needs no `.gitignore` entry.
39
+
40
+ pipx install notegraph # once published; for a checkout: pipx install -e .
41
+ note init
42
+ note goal --set "ship an education image generator" \
43
+ --criterion "consistency:3:the reference style holds across seeds" \
44
+ --criterion "legibility:1:rendered text is readable at 512px"
45
+
46
+ note add "legible text at 512px" --type goal # -> 1
47
+ note add "arm B1_P2" --type attempt --tags p2 # -> 2
48
+ note link 2 1 --rel targets
49
+ note score 2 consistency=1.0 legibility=0.8 --note "best so far"
50
+ note goals
51
+
52
+ note status # everything at a glance
53
+ note check # exit 1 if the loop is open
54
+ note supersede 2 "arm B1_P2, corrected" # append-only correction
55
+
56
+ Three names, deliberately: the PyPI distribution is **notegraph**, the git repo
57
+ is **note-cli**, and the command you type is **note**. `note-cli` was taken on
58
+ PyPI by similarity to an existing `notecli`.
59
+
60
+ Requires a POSIX system (Linux, macOS, WSL) — the store is locked with
61
+ `fcntl.flock`. MIT licensed.
62
+
63
+ Design: [`docs/superpowers/specs/2026-09-17-note-cli-design.md`](docs/superpowers/specs/2026-09-17-note-cli-design.md)
64
+
65
+ For an agent working in this repo: `note skill --install`, then follow the
66
+ `note` skill.
@@ -0,0 +1,15 @@
1
+ note_cli/SKILL.md,sha256=I0finC4UMtoIkx9vilTPIEsgAe7dqB8PSbTLz5nKOiM,2814
2
+ note_cli/__init__.py,sha256=YQiA1KXBKE70_zZBmCvmJyVvI9ciKhI6kHIthX5BbFk,284
3
+ note_cli/_cli_check.py,sha256=BhCVEW7sEuKsK8zjI1zSz0OObu1XY9IBa7NvLX7-smQ,3611
4
+ note_cli/_concurrency_check.py,sha256=zAsuPP3JHV5UbhlO1_5TZcP3kKoHCieZZtYTgsmdFY0,1011
5
+ note_cli/cli.py,sha256=yVZmw73CG60NB6lGr2w9VSAhy4i_glZLfvy9v8JLbK8,10692
6
+ note_cli/goal.py,sha256=XdmprqgY0x0dJ6DRnrj3VsVh4zuaXpemFSOx56UxKdE,7890
7
+ note_cli/graph.py,sha256=Im3iBC_f7ciukOB2Ki7sN5EzQWIYXNHBeDr97xupmi8,4418
8
+ note_cli/render.py,sha256=Y2M4RElbjih0iUaaMPlDcq_IN-g8foySQKOQSzj9gGc,2800
9
+ note_cli/store.py,sha256=tCUmRIzAj8xqgYJx0viidGu7ogiBvwhWr0JrH5R2-iI,7256
10
+ notegraph-0.2.0.dist-info/licenses/LICENSE,sha256=NCGJ-C4hYPcbcO7sOhld-8OKzbQ2V38NRoPOLJo0RMY,1081
11
+ notegraph-0.2.0.dist-info/METADATA,sha256=-RGISY-xo7sI4JJvZAS-3VrPBbnJ_t57uurCcebS8Xw,2920
12
+ notegraph-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ notegraph-0.2.0.dist-info/entry_points.txt,sha256=t59CEGHHaHUf96aEbh_L5QqAvDpmVwQdcvKlLjCkj4M,43
14
+ notegraph-0.2.0.dist-info/top_level.txt,sha256=2dDepLZO893-FXwMH2sNRuMVBnTA5pgtLJ6K8lr3lOs,9
15
+ notegraph-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ note = note_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 송재훈 (Jaehoon Song)
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.
@@ -0,0 +1 @@
1
+ note_cli