notegraph 0.2.0__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.
@@ -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,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,33 @@
1
+ # notegraph
2
+
3
+ A decision-graph and goal notebook for a git repo. One store per repo, shared
4
+ by every worktree, kept in `<repo>/.git/notes/` — so it never appears in the
5
+ working tree and needs no `.gitignore` entry.
6
+
7
+ pipx install notegraph # once published; for a checkout: pipx install -e .
8
+ note init
9
+ note goal --set "ship an education image generator" \
10
+ --criterion "consistency:3:the reference style holds across seeds" \
11
+ --criterion "legibility:1:rendered text is readable at 512px"
12
+
13
+ note add "legible text at 512px" --type goal # -> 1
14
+ note add "arm B1_P2" --type attempt --tags p2 # -> 2
15
+ note link 2 1 --rel targets
16
+ note score 2 consistency=1.0 legibility=0.8 --note "best so far"
17
+ note goals
18
+
19
+ note status # everything at a glance
20
+ note check # exit 1 if the loop is open
21
+ note supersede 2 "arm B1_P2, corrected" # append-only correction
22
+
23
+ Three names, deliberately: the PyPI distribution is **notegraph**, the git repo
24
+ is **note-cli**, and the command you type is **note**. `note-cli` was taken on
25
+ PyPI by similarity to an existing `notecli`.
26
+
27
+ Requires a POSIX system (Linux, macOS, WSL) — the store is locked with
28
+ `fcntl.flock`. MIT licensed.
29
+
30
+ Design: [`docs/superpowers/specs/2026-09-17-note-cli-design.md`](docs/superpowers/specs/2026-09-17-note-cli-design.md)
31
+
32
+ For an agent working in this repo: `note skill --install`, then follow the
33
+ `note` skill.
@@ -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
@@ -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"
@@ -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()
@@ -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()