Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bcc446a
fixtures for a live knoten server and a graph worth sharing
BY571 Sep 5, 2026
5df960e
a server holds three things the graph cannot: tokens, invites, its owner
BY571 Sep 5, 2026
d5e977d
create() rolls back partial repos on any failure
BY571 Sep 5, 2026
8bca118
tokens say who is connecting; invites are spent on first use, live or…
BY571 Sep 5, 2026
d633aa5
revoke needs to check the graph exists before taking the lock
BY571 Sep 5, 2026
e5d0f2c
knoten serve: an authorization layer in front of git's own HTTP backend
BY571 Sep 5, 2026
3f7bef5
serve: a crashed backend answers 500, not a silent 200
BY571 Sep 5, 2026
5db3afc
four endpoints: the owner creates, an admin invites and revokes, a co…
BY571 Sep 5, 2026
d218a76
guard json body parsing and days field conversion
BY571 Sep 5, 2026
370e942
knoten serve, and the owner secret is shown exactly once
BY571 Sep 5, 2026
d4678ba
fix serve_cmd port validation and socket cleanup
BY571 Sep 5, 2026
d0bcc6b
a credential store git asks through its own helper protocol
BY571 Sep 5, 2026
4ef011f
enforce mode 0600 on credential file even when it already exists
BY571 Sep 5, 2026
c9aded0
remote create, push, pull: one command each, git underneath
BY571 Sep 5, 2026
f07ad97
secrets handed to a human on a command line must not start with a dash
BY571 Sep 5, 2026
0e649d0
explain the push failure git actually gave, not a false match
BY571 Sep 5, 2026
453e3ee
stub the prompt, not stdin, so a missing terminal test stays quiet
BY571 Sep 5, 2026
c7a32ea
invite, join, revoke: a friend goes from nothing to pushing in two co…
BY571 Sep 5, 2026
6199ae3
join: say the invite is spent when clone fails after redemption
BY571 Sep 5, 2026
e886c59
one server, many graphs, no cross-talk, and eight joins at once lose …
BY571 Sep 5, 2026
b99f74b
say that a graph can be shared, and stop saying we would never host one
BY571 Sep 5, 2026
3ab922f
_explain matches git's own phrasing, not bare digits in the url
BY571 Sep 5, 2026
dbe51dd
/join no longer confirms which graphs exist
BY571 Sep 5, 2026
4a59920
guard against a malformed or negative content-length
BY571 Sep 5, 2026
d1d1f70
refuse chunked push bodies instead of relaying them as empty
BY571 Sep 5, 2026
c783477
three trivial hardening minors from the review
BY571 Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 35 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ knoten attach hyp-idea run.py accuracy.png # the code and the plot

knoten validate # enforce this graph's rules
knoten hook # make `git commit` refuse a broken graph
knoten hook --server g.git # ...and `git push`, for every contributor
knoten remote create g --on https://graphs.example # share it
knoten invite maria --role write # let someone in
knoten viz --open # the whole graph as one HTML file
```

Expand Down Expand Up @@ -137,35 +138,43 @@ tags: [decoding, reasoning, prompting, evaluation]

## A shared graph

A graph is a folder in git, so a whole lab can work in one, and "has this been tried?"
stops quietly meaning "have *I* tried this?". Sharing one needs no knoten at all:
One graph, several people, one set of rules enforced for all of them. A remote is a
`knoten serve` process on any machine you can reach over HTTPS: a box you own behind a
reverse proxy or a tunnel, a small VPS, or a hosted knoten.

```bash
git init --bare lab-graph.git # on any box you can all reach
git clone you@box:lab-graph.git # everyone else, human or agent
# you, once, in your graph
knoten remote create trading --on https://graphs.example
knoten invite maria --role write # prints a one-time code

# maria, anywhere
knoten join https://graphs.example/trading --invite 7f3a9c...
knoten frontier # her clone; the loop is unchanged from here
knoten push # over HTTPS, through the gate
```

What knoten adds is the gate. `knoten hook` protects the person who ran it, in the clone
they ran it in, and `git commit --no-verify` walks past it. `knoten hook --server
lab-graph.git` installs a `pre-receive` hook on the repo everyone pushes *to*: it unpacks
each pushed tree, finds every graph in it, runs `knoten validate` on each and refuses the
push if any fails. No CI, no runner, no minutes, and nobody can skip it from a laptop.

That gate matters more here than it looks. The parser refuses rather than skips, on
purpose, so one malformed node does not quietly vanish from the graph. On one machine
that is a good trade. On a shared one it means a single bad push breaks `frontier`,
`index` and `gates` for **everyone** until somebody fixes a file they did not write.

Two people working at once do not collide: an edge is declared once, on the subject, and
back-links are generated at load, so adding connections touches two different files and
git merges them. Pull before you work, or your frontier will recommend something a
collaborator killed on Tuesday.

Reading needs nothing installed. Nodes are markdown, so any forge renders them, and
`knoten viz` writes the graph as one self-contained HTML file you can hand to someone who
has never heard of knoten. For invitations, per-user permissions or required approvals,
put the repo on a forge such as [Forgejo](https://forgejo.org) and let it handle the
people; the gate above still does the part no forge can.
Every push runs `knoten validate` on the server before the ref moves, so a node that
breaks the graph's rules is refused for everyone, including whoever wrote the rules, and
including anyone who never installed `knoten hook`. That matters more here than it
looks: the parser refuses rather than skips, on purpose, so on one machine a malformed
node is your problem and on a shared one it would be everybody's.

`read` can clone and pull. `write` can push. `admin` can invite and revoke. Tokens say
who is connecting and nothing else; what a token can do is the role the admin gave it.

Reading needs nothing installed. Nodes are markdown, and `knoten viz` writes the graph as
one self-contained HTML file you can hand to someone who has never heard of knoten.

To run the server:

```bash
knoten serve --data ~/knoten-remotes # prints the owner secret once; keep it
```

It binds localhost and speaks plain HTTP. Put TLS in front before anyone outside the
machine connects. For a graph that lives in a bare repo you administer yourself, without
a server, `knoten hook --server <repo.git>` installs the same gate as a `pre-receive`
hook.

## For agents

Expand Down
10 changes: 6 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ or retracts the old one, never an edit to it.

## Before you work

If the graph is shared, `git pull` FIRST. Every read below answers from the files on
disk, so a stale clone reports work a collaborator settled days ago as still open. That
is the exact failure this graph exists to prevent, arriving through the back door.
If the graph has a remote, `knoten pull` FIRST. Every read below answers from the files
on disk, so a stale clone reports work a collaborator settled days ago as still open.
That is the exact failure this graph exists to prevent, arriving through the back door.

1. `knoten frontier` — what is worth doing next: open work, dead ends whose stated
reopen condition may now hold, and gates nothing has been through.
Expand All @@ -91,7 +91,9 @@ is the exact failure this graph exists to prevent, arriving through the back doo
graph and the one that would otherwise be lost. Use `knoten update <id> --status dead
--append <file> --field cause=<value>` instead if you opened the node earlier.
6. `knoten attach <id> <files...>` — the script that ran it and the plot that shows it.
A claim nobody can re-run is a claim nobody trusts in six months.
A claim nobody can re-run is a claim nobody trusts in six months. If the graph has a
remote, `knoten push` when the node is filed; the server runs the same rules and
refuses what this clone would have.

## When you run out of ideas

Expand Down
8 changes: 5 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ A system that stores only conclusions would have preserved ~15% of that.

## 2. Non-goals

- **We do not build a Git host.** Git already provides versioning, branching, blame,
diffs, PRs (peer review!) and hosting. Reimplementing any of it is madness.
- **We do not build a general git host.** We host knoten graphs, because the permission
model (who may write, who verified what) is part of the graph and no general host can
enforce it. Git still provides versioning, branching, blame, diffs and history; we
write none of that.
- **We do not build a UI first.** A static site generator over the graph is a
phase-3 nicety, and it can emit its own JSON when it exists.
- **We do not invent a vocabulary.** Micropublications and nanopublications already
Expand Down Expand Up @@ -377,7 +379,7 @@ body the day a graph outgrows a tag-filtered index — which the 1k–5k node ca
| **2** | **Tool-protocol server** | ✅ done — later demoted to a fallback, then removed (§8) |
| **2.5** | CLI becomes the primary agent surface: `ops.py` as the one implementation behind every read, `--json` on every read, `commit`/`update` on the CLI, `SKILL.md` | ✅ done |
| 3 | Static-site graph viewer → GitHub Pages | free hosting |
| 4 | Hosted multi-graph service | probably never needed |
| 4 | **Remote graphs**: `knoten serve`, invites, roles, the gate on push | ✅ transport done; signed identity and verification follow |

Phase 0 **validated the schema against real content** — including retractions, structural
blockers, and prose that no JSON schema could hold.
Expand Down
113 changes: 113 additions & 0 deletions src/knoten/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from .commit import commit
from .core import GraphError, ID_RE, LOCK, find_root, node_path, today
from .hook import install as install_hook, install_server
from .registry import ROLES, Registry
from .serve import make_server
from . import remote
from .validate import _csv, applies, load_config

# Keyed by the uppercase word `ops` puts in `verdict` — not by raw status, which is
Expand Down Expand Up @@ -226,6 +229,63 @@ def server_hook(repo, force) -> int:
return 0


def remote_cmd(root, args) -> int:
if args.remote_cmd == "create":
url = remote.remote_create(root, args.name, args.on, admin=args.admin,
owner_secret=args.owner_secret)
print(f" ✓ {url}")
print(" invite someone: knoten invite <name> --role write")
return 0
remote.remote_add(root, args.url)
print(" ✓ origin set. `knoten pull` and `knoten push` now use it.")
return 0


def invite_cmd(root, name, role, days) -> int:
code = remote.invite(root, name, role, days)
print(f" ✓ {name} may join as {role} for {days} day(s). Send them this code, once:")
print(f" {code}")
return 0


def revoke_cmd(root, name) -> int:
remote.revoke(root, name)
print(f" ✓ {name} can no longer connect. What they already pushed stays.")
return 0


def serve_cmd(data, bind) -> int:
"""Run on the server. Prints the owner secret the first time a data directory is
used, because that is the one moment the owner is certainly at the keyboard."""
host, _, port_str = bind.rpartition(":")
host = host or "127.0.0.1"
# Parse and validate the port before any file is written; a crash after the owner
# secret exists orphans it, shown to nobody, forever.
try:
port = int(port_str)
except ValueError:
raise GraphError(f"--bind wants HOST:PORT with a numeric port, got '{bind}'") from None
reg = Registry(Path(data))
srv = make_server(reg, host, port)
# Only now, after the server socket is open, check and display the owner secret.
first = not (reg.data / "owner").exists()
secret = reg.owner_secret()
if first:
print(f" owner secret (shown once, keep it somewhere safe): {secret}")
if host not in ("127.0.0.1", "localhost"):
print(" warning: plain HTTP on a non-local address. Put TLS in front (a reverse "
"proxy or a tunnel) before anyone outside this machine connects.",
file=sys.stderr)
print(f" serving {reg.data} on http://{host}:{srv.server_address[1]} (ctrl-c to stop)")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
return 0


def render_get(payload: dict) -> None:
print(f"{payload['id']} [{MARK.get(payload['verdict'], payload['verdict'])}] "
f"type={payload['type']}\n")
Expand Down Expand Up @@ -597,6 +657,40 @@ def _parser() -> argparse.ArgumentParser:
s.add_argument("--force", action="store_true",
help="overwrite a hook knoten did not write")

s = sub.add_parser("serve", help="host remote graphs (run this on the server)")
s.add_argument("--data", required=True, metavar="DIR", help="where graphs and tokens live")
s.add_argument("--bind", default="127.0.0.1:8899", metavar="HOST:PORT")

# git runs this; nobody types it. `!knoten credential` is set in every clone's config.
s = sub.add_parser("credential", help=argparse.SUPPRESS)
s.add_argument("action", nargs="?")

s = sub.add_parser("remote", help="connect this graph to a knoten server")
rs = s.add_subparsers(dest="remote_cmd", required=True)
c = rs.add_parser("create", help="create this graph on a server and push it")
c.add_argument("name")
c.add_argument("--on", required=True, metavar="URL", help="the server, e.g. https://graphs.example")
c.add_argument("--as", dest="admin", metavar="NAME", help="your contributor name (default: git user.name)")
c.add_argument("--owner-secret", help="the server's owner secret (asked for if not stored)")
a = rs.add_parser("add", help="point this clone at an existing remote graph")
a.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading")

sub.add_parser("push", help="push this graph to its remote, through the gate")
sub.add_parser("pull", help="fetch what collaborators pushed")

s = sub.add_parser("invite", help="admin: let someone in (prints a one-time code)")
s.add_argument("name", help="their contributor name, kebab-case")
s.add_argument("--role", default="write", choices=ROLES)
s.add_argument("--expires", type=int, default=7, metavar="DAYS")

s = sub.add_parser("join", help="redeem an invite and clone the graph")
s.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading")
s.add_argument("--invite", required=True, metavar="CODE")
s.add_argument("--dest", metavar="DIR", help="where to clone (default: the graph's name)")

s = sub.add_parser("revoke", help="admin: remove a contributor's access")
s.add_argument("name")

s = sub.add_parser("show", help="the node, its edges and its attachments")
s.add_argument("node")
s.add_argument("--json", action="store_true", help="emit the raw payload")
Expand Down Expand Up @@ -648,6 +742,20 @@ def main(argv=None) -> int:
if args.cmd == "hook" and args.server is not None:
return server_hook(args.server, args.force)

if args.cmd == "serve":
return serve_cmd(args.data, args.bind)

if args.cmd == "credential":
if args.action == "get":
sys.stdout.write(remote.credential_helper(sys.stdin.read()))
return 0 # store/erase: git manages nothing here; knoten does

if args.cmd == "join":
clone, name, role = remote.join(args.url, args.invite, args.dest)
print(f" ✓ joined as {name} ({role}), cloned to {clone}/")
print(f" cd {clone} && knoten frontier")
return 0

root = find_root()
return {
"query": lambda: query(root, args.term, args.json),
Expand All @@ -669,6 +777,11 @@ def main(argv=None) -> int:
"hook": lambda: hook(root, args.force),
"attach": lambda: attach(root, args.node, args.files),
"detach": lambda: detach(root, args.node, args.file),
"remote": lambda: remote_cmd(root, args),
"push": lambda: remote.push(root),
"pull": lambda: remote.pull(root),
"invite": lambda: invite_cmd(root, args.name, args.role, args.expires),
"revoke": lambda: revoke_cmd(root, args.name),
}[args.cmd]()
except (GraphError, OSError) as e:
# OSError: a typo'd --frontmatter/--body/--append path is ordinary user error,
Expand Down
Loading
Loading