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.
- notegraph-0.2.0/LICENSE +21 -0
- notegraph-0.2.0/PKG-INFO +66 -0
- notegraph-0.2.0/README.md +33 -0
- notegraph-0.2.0/note_cli/SKILL.md +58 -0
- notegraph-0.2.0/note_cli/__init__.py +7 -0
- notegraph-0.2.0/note_cli/_cli_check.py +103 -0
- notegraph-0.2.0/note_cli/_concurrency_check.py +40 -0
- notegraph-0.2.0/note_cli/cli.py +306 -0
- notegraph-0.2.0/note_cli/goal.py +213 -0
- notegraph-0.2.0/note_cli/graph.py +128 -0
- notegraph-0.2.0/note_cli/render.py +76 -0
- notegraph-0.2.0/note_cli/store.py +211 -0
- notegraph-0.2.0/notegraph.egg-info/PKG-INFO +66 -0
- notegraph-0.2.0/notegraph.egg-info/SOURCES.txt +18 -0
- notegraph-0.2.0/notegraph.egg-info/dependency_links.txt +1 -0
- notegraph-0.2.0/notegraph.egg-info/entry_points.txt +2 -0
- notegraph-0.2.0/notegraph.egg-info/requires.txt +4 -0
- notegraph-0.2.0/notegraph.egg-info/top_level.txt +1 -0
- notegraph-0.2.0/pyproject.toml +52 -0
- notegraph-0.2.0/setup.cfg +4 -0
notegraph-0.2.0/LICENSE
ADDED
|
@@ -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.
|
notegraph-0.2.0/PKG-INFO
ADDED
|
@@ -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()
|