diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..18454b2
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# The artwork under docs/art/ is generated, and a gate compares the committed
+# bytes to a fresh render. Normalizing line endings per platform would make that
+# comparison depend on who checked the repository out.
+docs/art/*.svg text eol=lf
+docs/art/*.json text eol=lf
diff --git a/README.md b/README.md
index c0329f2..cdf35ab 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
# plexus
@@ -29,6 +29,8 @@ consumable mid-task, not just from a human's terminal.
How it compares to MCP / LangGraph / Dagster / CrewAI: see [COMPARISON.md](COMPARISON.md).
plexus is the discovery layer that sits *above* an executor, not another executor.
+
+
## The problem
You wire up five tools. Each one produces artifacts and accepts inputs, but
@@ -90,6 +92,12 @@ tampered plan is caught, and a tool whose manifest changed since the plan makes
drift **visible** instead of letting it silently shift under you. Exit non-zero on drift,
so it works as a CI check over your toolchain's wiring.
+Two things make that check worth running. `verify` rebuilds the receipt from the
+plan it just re-derived, not from the one saved in the file, so editing the saved
+body cannot make it agree with itself. And the receipt carries a method version
+that has to match before anything else is compared, so a plan written by an older
+plexus is reported as failing rather than silently re-interpreted under new rules.
+
## How a tool plugs in
A manifest is plain JSON. A tool ships one and it joins the mesh. Drop
@@ -171,6 +179,15 @@ plexus is also honest about what does **not** connect:
- `discover().collisions`: organ ids declared by more than one manifest, named
rather than silently resolved last-writer-wins.
+
+
+An unmet input is a capability something consumes that nothing in the set emits,
+and an unconsumed output is the mirror of it. Both fall out of the same
+comparison, so neither is a special case someone remembered to write. The set is
+also exactly what you handed it: plexus reads the manifests present and reasons
+about nothing else, which is why an unmet input means only that no manifest here
+produces it, not that no such tool exists.
+
## Receipt
`plexus discover` stamps a `receipt` on its output: the plexus version, a UTC
diff --git a/docs/art/honesty-lane.svg b/docs/art/honesty-lane.svg
new file mode 100644
index 0000000..1b8cef0
--- /dev/null
+++ b/docs/art/honesty-lane.svg
@@ -0,0 +1,19 @@
+
diff --git a/docs/art/plexus-header.svg b/docs/art/plexus-header.svg
new file mode 100644
index 0000000..1edfaec
--- /dev/null
+++ b/docs/art/plexus-header.svg
@@ -0,0 +1 @@
+
diff --git a/docs/art/plexus.art.json b/docs/art/plexus.art.json
new file mode 100644
index 0000000..a3ebe5a
--- /dev/null
+++ b/docs/art/plexus.art.json
@@ -0,0 +1,141 @@
+{
+ "header": {
+ "name": "plexus",
+ "role": "toolchain wiring discovery",
+ "tagline": "Point it at your tools and it computes how they plug together.",
+ "words": [
+ "emit",
+ "consume",
+ "wire",
+ "plan",
+ "declare"
+ ]
+ },
+ "flows": [
+ {
+ "file": "wiring-lane.svg",
+ "kicker": "one tool set, one runnable order",
+ "title": "How a pile of tools becomes a plan you can check later",
+ "footnote": "Verify re-derives the plan from the manifests. The saved body is never trusted.",
+ "alt": "Eight stages from manifest to verify: manifest, discover, capability, wiring, goal, plan, receipt, verify. Each tool ships a small manifest naming what it emits and what it consumes. Discovery reads every manifest and hashes its canonical content. A capability is a typed name, and an edge exists wherever one tool emits a name another consumes. Naming a goal produces a dependency order with any feedback loops reported rather than forced flat. The receipt binds the plan to the content hash of every manifest behind it plus a hash over the plan body. Verify re-derives the plan from the live mesh and compares receipts, so a tampered body and a manifest that changed both fail. Two outcomes: still holds, and drifted.",
+ "stages": [
+ {
+ "title": "Manifest",
+ "note": "Each tool ships one: what it emits, what it needs."
+ },
+ {
+ "title": "Discover",
+ "note": "Every manifest read, and its content hashed."
+ },
+ {
+ "title": "Capability",
+ "note": "A typed name, on both sides of an edge."
+ },
+ {
+ "title": "Wiring",
+ "note": "An edge wherever a name matches across tools."
+ },
+ {
+ "title": "Goal",
+ "note": "The tool you want fed, named on the command line."
+ },
+ {
+ "title": "Plan",
+ "note": "An order over the graph, with cycles reported."
+ },
+ {
+ "title": "Receipt",
+ "note": "The plan hash, and every manifest hash behind it."
+ },
+ {
+ "title": "Verify",
+ "note": "Re-derive from the mesh, then compare receipts."
+ }
+ ],
+ "returns": [
+ {
+ "from": 7,
+ "to": 5,
+ "label": "VERIFY NEVER TRUSTS THE SAVED PLAN BODY"
+ }
+ ],
+ "outcomes": [
+ {
+ "label": "STILL HOLDS",
+ "note": "the same manifests re-derive the same plan",
+ "tone": "verified"
+ },
+ {
+ "label": "DRIFTED",
+ "note": "a manifest moved under the saved plan",
+ "tone": "drift"
+ }
+ ]
+ },
+ {
+ "file": "honesty-lane.svg",
+ "kicker": "one mesh, and what it will not pretend",
+ "title": "How the parts that do not connect get named instead of hidden",
+ "footnote": "Every edge is declared by its producer. Nothing here imports or runs the source it cites.",
+ "alt": "Eight stages of the honesty surface: tool set, emits, consumes, match, leftover, loop, collision, report. Only the manifests present are considered. A capability name appearing on both an emit and a consume side becomes an edge. Every name that matched on only one side is a leftover: an unmet input that nothing in the set produces, or an output nobody downstream takes. A cycle is surfaced as a feedback loop rather than forced into a false linear order, and an organ id claimed by two manifests is named as a collision rather than resolved last writer wins. Three outcomes: wired, colliding, and unmet.",
+ "stages": [
+ {
+ "title": "Tool set",
+ "note": "The manifests present, and nothing else."
+ },
+ {
+ "title": "Emits",
+ "note": "What each one declares that it produces."
+ },
+ {
+ "title": "Consumes",
+ "note": "What each one declares that it needs."
+ },
+ {
+ "title": "Match",
+ "note": "A name on both sides becomes one edge."
+ },
+ {
+ "title": "Leftover",
+ "note": "Every name that matched on one side only."
+ },
+ {
+ "title": "Loop",
+ "note": "An edge set that returns where it started."
+ },
+ {
+ "title": "Collision",
+ "note": "One organ id claimed by two manifests."
+ },
+ {
+ "title": "Report",
+ "note": "Named in the output, never quietly resolved."
+ }
+ ],
+ "returns": [
+ {
+ "from": 7,
+ "to": 4,
+ "label": "EACH LEFTOVER IS NAMED, ONE BY ONE"
+ }
+ ],
+ "outcomes": [
+ {
+ "label": "WIRED",
+ "note": "every input has a producer in the set",
+ "tone": "verified"
+ },
+ {
+ "label": "COLLIDING",
+ "note": "two manifests claim the same organ id",
+ "tone": "drift"
+ },
+ {
+ "label": "UNMET",
+ "note": "an input nothing in the set emits",
+ "tone": "none"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/art/wiring-lane.svg b/docs/art/wiring-lane.svg
new file mode 100644
index 0000000..e7432c2
--- /dev/null
+++ b/docs/art/wiring-lane.svg
@@ -0,0 +1,19 @@
+
diff --git a/tests/test_repo_art.py b/tests/test_repo_art.py
new file mode 100644
index 0000000..2b1e63f
--- /dev/null
+++ b/tests/test_repo_art.py
@@ -0,0 +1,71 @@
+"""The README's diagrams are generated from a spec, so they can go stale the way any
+other derived file goes stale: somebody edits a stage name, nobody re-renders, and the
+picture describes a version of plexus that no longer exists. The gate re-renders from
+the spec and compares bytes. This runs the gate under pytest and asserts on its receipt,
+so a drifted drawing fails the suite instead of quietly shipping."""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+_REPO = Path(__file__).resolve().parents[1]
+_GATE = _REPO / "tools" / "check_repo_art.py"
+
+GATES = (
+ "spec.present",
+ "art.matches_spec",
+ "art.render_is_deterministic",
+ "art.identity_per_repository",
+ "art.seed_is_recorded",
+ "art.no_local_paths_or_em_dashes",
+ "art.spec_words_reach_the_drawing",
+ "art.note_survives_the_wrapper",
+ "art.return_edge_stays_on_its_row",
+ "art.every_illustration_is_shown",
+ "art.tagline_stays_inside_its_rule",
+ "art.outcome_fits_its_box",
+)
+
+DRAWINGS = (
+ "docs/art/plexus-header.svg",
+ "docs/art/wiring-lane.svg",
+ "docs/art/honesty-lane.svg",
+)
+
+
+def _receipt() -> dict:
+ out = subprocess.run([sys.executable, str(_GATE), "--json"],
+ cwd=_REPO, capture_output=True, text=True)
+ assert out.returncode == 0, out.stdout + out.stderr
+ return json.loads(out.stdout)
+
+
+def test_every_gate_passes_and_the_receipt_names_what_it_ran():
+ receipt = _receipt()
+ assert receipt["schema"] == "plexus.repo-art/v1"
+ assert [c["name"] for c in receipt["checks"]] == list(GATES)
+ assert all(c["passed"] for c in receipt["checks"]), \
+ [c for c in receipt["checks"] if not c["passed"]]
+
+
+def test_both_diagrams_and_the_mark_are_accounted_for():
+ receipt = _receipt()
+ assert receipt["specs"] == ["docs/art/plexus.art.json"]
+ drawn = {out["file"]: out for out in receipt["outputs"]}
+ assert set(drawn) == set(DRAWINGS)
+ for path, out in drawn.items():
+ assert len(out["sha256"]) == 64, path
+ assert out["bytes"] > 0, path
+
+
+def test_a_gate_that_cannot_fail_is_not_a_gate(tmp_path, monkeypatch):
+ """Point the outcome-box check at a note too wide for its box and it has to
+ complain. Without this, a green suite proves only that the gate ran."""
+ sys.path.insert(0, str(_REPO / "tools"))
+ import check_repo_art as gate
+ spec = json.loads((_REPO / "docs" / "art" / "plexus.art.json").read_text("utf-8"))
+ spec["flows"][0]["outcomes"][0]["note"] = "x" * 80
+ (tmp_path / "plexus.art.json").write_text(json.dumps(spec), encoding="utf-8")
+ monkeypatch.setattr(gate, "ART", tmp_path)
+ assert len(gate.check_outcome_fits_its_box([])) == 1
diff --git a/tools/check_repo_art.py b/tools/check_repo_art.py
new file mode 100644
index 0000000..3d70deb
--- /dev/null
+++ b/tools/check_repo_art.py
@@ -0,0 +1,264 @@
+"""Check that the front-page artwork still tells the truth, and say so in a receipt.
+
+ python tools/check_repo_art.py # a readable summary
+ python tools/check_repo_art.py --json # the same run as a receipt
+
+A picture in a README is never diffed, so it drifts from the text silently:
+somebody edits a stage name, nobody re-renders, and the diagram now describes a
+version of the tool that no longer exists. Here the picture is a pure function
+of a spec that IS diffable, so this re-renders it and compares bytes.
+
+The sibling repositories run these same gates from their own test runners. Here
+the gates run as a script and emit a receipt, and tests/test_repo_art.py asserts
+on that receipt under pytest, so `python -m pytest -q` covers the front page.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import render_repo_art as RENDER # noqa: E402
+import repo_art as ART_LIB # noqa: E402
+import repo_flow as FLOW # noqa: E402
+
+ROOT = Path(__file__).resolve().parents[1]
+ART = ROOT / "docs" / "art"
+SCHEMA = "plexus.repo-art/v1"
+
+# The widest tagline that has been looked at on a rendered page. It counts
+# characters rather than measuring glyphs, so it cannot tell "mmmm" from
+# "iiii": a guardrail, not a typographic fact.
+TAGLINE_BUDGET = 70
+
+# Where an illustration lives. .github/assets/ and docs/brand/ are deliberately
+# outside this set: they hold the social-preview source and the flagship heroes,
+# which other gates already cover.
+SHOWN_DIRS = ("docs/art",)
+
+EM_DASH = "—"
+
+
+def _rel(path: Path) -> str:
+ return str(path.relative_to(ROOT)).replace("\\", "/")
+
+
+def _specs() -> list[Path]:
+ return sorted(ART.glob("*.art.json"))
+
+
+def _loaded() -> list[dict]:
+ return [json.loads(p.read_text(encoding="utf-8")) for p in _specs()]
+
+
+def check_spec_present(specs: list[Path]) -> list[str]:
+ return [] if specs else ["docs/art holds no *.art.json spec"]
+
+
+def check_artwork_matches_spec(specs: list[Path]) -> list[str]:
+ bad = []
+ for spec_path in specs:
+ for path, text in RENDER.rendered(spec_path).items():
+ if not path.exists():
+ bad.append(f"{_rel(path)} was never rendered")
+ elif path.read_text(encoding="utf-8") != text + "\n":
+ bad.append(f"{_rel(path)} is stale; run python tools/render_repo_art.py")
+ return bad
+
+
+def check_render_is_deterministic(specs: list[Path]) -> list[str]:
+ """The corona is random draws. Seeded ones, or this fails."""
+ return [f"{p.name} renders differently every time"
+ for p in specs if RENDER.rendered(p) != RENDER.rendered(p)]
+
+
+def check_identity_per_repository(_unused: list[Path]) -> list[str]:
+ """The identity claim, checked rather than asserted in a doc."""
+ names = ["gather", "flywheel", "crucible", "index", "forum", "telos",
+ "learn", "emet", "relay", "mneme", "plexus"]
+ marks = {n: ART_LIB.header_svg(
+ {"name": n, "role": "x", "tagline": "y", "words": ["z"]}) for n in names}
+ bad = []
+ if len({ART_LIB.seed_for(n) for n in names}) != len(names):
+ bad.append("two repositories share a seed")
+ bodies = {n: re.sub(r"[A-Z]{3,}", "", svg) for n, svg in marks.items()}
+ if len(set(bodies.values())) != len(names):
+ bad.append("two repositories drew alike")
+ return bad
+
+
+def check_seed_is_recorded(_unused: list[Path]) -> list[str]:
+ """A generated mark carries the seed that made it."""
+ svg = ART_LIB.header_svg(
+ {"name": "plexus", "role": "x", "tagline": "y", "words": []})
+ stamp = f"SEED {ART_LIB.seed_for('plexus') % 100000:05d}"
+ return [] if stamp in svg else ["the mark does not record its own seed"]
+
+
+def check_no_local_paths_or_em_dashes(_unused: list[Path]) -> list[str]:
+ bad = []
+ for path in sorted(ART.glob("*.svg")):
+ text = path.read_text(encoding="utf-8")
+ if EM_DASH in text:
+ bad.append(f"{path.name} carries an em-dash")
+ if re.search(r"[A-Z]:[\\/]", text):
+ bad.append(f"{path.name} names a local path")
+ return bad
+
+
+def check_spec_words_reach_the_drawing(specs: list[Path]) -> list[str]:
+ """Guards against a diagram that renders but silently drops content."""
+ bad = []
+ for spec_path in specs:
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ drawn = "".join(RENDER.rendered(spec_path).values())
+ if spec["header"]["tagline"] not in drawn:
+ bad.append(f"{spec_path.name}: the tagline never reaches the drawing")
+ for flow in spec.get("flows", []):
+ for stage in flow["stages"]:
+ if stage["title"] not in drawn:
+ bad.append(f'{spec_path.name}: {stage["title"]} is missing')
+ return bad
+
+
+def check_note_survives_the_wrapper(_unused: list[Path]) -> list[str]:
+ """Card notes wrap to three lines and the wrapper drops the rest, so an
+ edited sentence can lose its ending in the drawing while reading fine
+ in the spec."""
+ bad = []
+ for spec in _loaded():
+ for flow in spec.get("flows", []):
+ for stage in flow["stages"]:
+ drawn = " ".join(FLOW._wrap(stage["note"]))
+ if drawn != " ".join(stage["note"].split()):
+ bad.append(f'{stage["title"]}: the drawing cuts off at "{drawn}"')
+ return bad
+
+
+def check_return_edge_stays_on_its_row(_unused: list[Path]) -> list[str]:
+ """A backward edge is routed under the row it leaves, so a cross-row one
+ would be drawn straight through whatever cards sit in between."""
+ bad = []
+ for spec in _loaded():
+ for flow in spec.get("flows", []):
+ for edge in flow.get("returns", []):
+ if edge["from"] // FLOW.PER_ROW != edge["to"] // FLOW.PER_ROW:
+ bad.append(
+ f'return {edge["from"]}->{edge["to"]} crosses a row break')
+ return bad
+
+
+def check_every_illustration_is_shown(_unused: list[Path]) -> list[str]:
+ """No orphans. An image nobody links to is an image nobody sees."""
+ haystack = (ROOT / "README.md").read_text(encoding="utf-8")
+ haystack += "".join(p.read_text(encoding="utf-8", errors="ignore")
+ for p in ROOT.glob("docs/**/*.md"))
+ images = sorted(p for d in SHOWN_DIRS for p in (ROOT / d).glob("*")
+ if p.suffix.lower() in {".svg", ".png"})
+ return [f"committed but never shown: {_rel(p)}"
+ for p in images if _rel(p) not in haystack]
+
+
+def check_tagline_stays_inside_its_rule(_unused: list[Path]) -> list[str]:
+ """The tagline is one unwrapped line under a rule that ends at x=700. Past
+ that it runs on toward the aperture and nothing about the render fails."""
+ bad = []
+ for spec in _loaded():
+ tagline = spec["header"]["tagline"]
+ if len(tagline) > TAGLINE_BUDGET:
+ bad.append(f"{len(tagline)} characters runs past the rule: {tagline!r}")
+ return bad
+
+
+def _outcome_budgets(count: int) -> tuple[int, int]:
+ """Label and note budgets for one box in a band of `count` boxes."""
+ span = (FLOW.W - FLOW.PAD * 2 - FLOW.GAP * (count - 1)) / count
+ usable = span - 14 - 10
+ return int(usable / 7.0), int(usable / 5.4)
+
+
+def check_outcome_fits_its_box(_unused: list[Path]) -> list[str]:
+ """An outcome box is one unwrapped label over one unwrapped note, and
+ neither is clipped, so an over-long note runs into the next box."""
+ bad = []
+ for spec in _loaded():
+ for flow in spec.get("flows", []):
+ label_budget, note_budget = _outcome_budgets(len(flow["outcomes"]))
+ for item in flow["outcomes"]:
+ if len(item["label"]) > label_budget:
+ bad.append(f'{item["label"]!r} is wider than its box')
+ if len(item["note"]) > note_budget:
+ bad.append(f'the note under {item["label"]} is wider than '
+ f'its box: {item["note"]!r}')
+ return bad
+
+
+CHECKS = [
+ ("spec.present", check_spec_present),
+ ("art.matches_spec", check_artwork_matches_spec),
+ ("art.render_is_deterministic", check_render_is_deterministic),
+ ("art.identity_per_repository", check_identity_per_repository),
+ ("art.seed_is_recorded", check_seed_is_recorded),
+ ("art.no_local_paths_or_em_dashes", check_no_local_paths_or_em_dashes),
+ ("art.spec_words_reach_the_drawing", check_spec_words_reach_the_drawing),
+ ("art.note_survives_the_wrapper", check_note_survives_the_wrapper),
+ ("art.return_edge_stays_on_its_row", check_return_edge_stays_on_its_row),
+ ("art.every_illustration_is_shown", check_every_illustration_is_shown),
+ ("art.tagline_stays_inside_its_rule", check_tagline_stays_inside_its_rule),
+ ("art.outcome_fits_its_box", check_outcome_fits_its_box),
+]
+
+
+def _outputs(specs: list[Path]) -> list[dict]:
+ seen = []
+ for spec_path in specs:
+ for path in RENDER.rendered(spec_path):
+ body = path.read_bytes() if path.exists() else b""
+ seen.append({
+ "file": _rel(path),
+ "spec": _rel(spec_path),
+ "bytes": len(body),
+ "sha256": hashlib.sha256(body).hexdigest(),
+ })
+ return sorted(seen, key=lambda item: item["file"])
+
+
+def receipt() -> dict:
+ specs = _specs()
+ results = [{"name": name, "passed": not failures, "failures": failures}
+ for name, failures in ((n, f(specs)) for n, f in CHECKS)]
+ return {
+ "schema": SCHEMA,
+ "mode": "check",
+ "specs": [_rel(p) for p in specs],
+ "outputs": _outputs(specs),
+ "checks": results,
+ "passed": all(item["passed"] for item in results),
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--json", action="store_true",
+ help="emit the run as a receipt instead of a summary")
+ args = parser.parse_args(argv)
+ report = receipt()
+ if args.json:
+ print(json.dumps(report, indent=2))
+ return 0 if report["passed"] else 1
+ for item in report["checks"]:
+ print(f"{'ok ' if item['passed'] else 'FAIL'} {item['name']}")
+ for failure in item["failures"]:
+ print(f" {failure}")
+ print(f"{len(report['outputs'])} files from "
+ f"{len(report['specs'])} spec files")
+ return 0 if report["passed"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/render_repo_art.py b/tools/render_repo_art.py
new file mode 100644
index 0000000..03533a2
--- /dev/null
+++ b/tools/render_repo_art.py
@@ -0,0 +1,73 @@
+"""Render a repository's front-page artwork from its spec.
+
+ python tools/render_repo_art.py # write the SVGs
+ python tools/render_repo_art.py --check # fail if any is stale
+
+The check mode is the point. Committed artwork drifts from the words it
+illustrates the moment someone edits one and not the other, and nobody
+notices, because a picture in a README is never diffed. Here the picture is
+a pure function of a spec that IS diffable, so a test can re-render and
+compare bytes.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from repo_art import header_svg # noqa: E402
+from repo_flow import flow_svg # noqa: E402
+
+ART = Path(__file__).resolve().parents[1] / "docs" / "art"
+
+
+def rendered(spec_path: Path) -> dict[Path, str]:
+ """Every file one spec produces, as path to text."""
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ stem = spec_path.name.removesuffix(".art.json")
+ out = {spec_path.parent / f"{stem}-header.svg": header_svg(spec["header"])}
+ for flow in spec.get("flows", []):
+ out[spec_path.parent / flow["file"]] = flow_svg(flow)
+ return out
+
+
+def specs() -> list[Path]:
+ return sorted(ART.glob("*.art.json"))
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true",
+ help="report stale artwork instead of rewriting it")
+ args = parser.parse_args(argv)
+
+ stale: list[str] = []
+ for spec_path in specs():
+ for path, text in rendered(spec_path).items():
+ body = text + "\n"
+ if args.check:
+ current = path.read_text(encoding="utf-8") if path.exists() else ""
+ if current != body:
+ stale.append(str(path.relative_to(ART.parents[1])))
+ continue
+ # newline="" so a Windows run writes the same bytes a Linux
+ # run does. The whole point of this file is that committed
+ # artwork and a fresh render are comparable.
+ path.write_text(body, encoding="utf-8", newline="")
+ print(f"wrote {path.relative_to(ART.parents[1])} ({len(body)} bytes)")
+
+ if stale:
+ print("stale artwork, re-run tools/render_repo_art.py:", file=sys.stderr)
+ for name in stale:
+ print(f" {name}", file=sys.stderr)
+ return 1
+ if args.check:
+ print(f"artwork matches its spec ({len(specs())} spec files)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/repo_art.py b/tools/repo_art.py
new file mode 100644
index 0000000..eba6814
--- /dev/null
+++ b/tools/repo_art.py
@@ -0,0 +1,276 @@
+"""repo_art.py -- deterministic SVG artwork for a repository's front page.
+
+Two renderers, one design language, no binary blobs.
+
+`header_svg` draws the identity card: a seeded aperture on pure black, with the
+repository's name, what it does, and the words it works in. The aperture is the
+one form the whole visual corpus returns to, a luminous core with fine radial
+line-work resolving around it. Every repository gets the SAME form and a
+DIFFERENT drawing of it, because the corona, the halftone screen and the hue
+are all derived from a hash of the repository's own name. That is where the
+sense of identity comes from: sibling projects, not a template applied twice.
+
+`flow_svg` draws a workflow: cards on a calm ground, hairline connectors, and
+color used only to say what a path means. It reads the same in a light or a
+dark reader because it defines both and lets the reader's own setting pick.
+
+The split between the two is deliberate and is the figure-ground rule made
+concrete. Generative energy belongs where the art is the subject, so it is
+contained inside the header. Where words have to be read it recedes to a
+hairline, so the diagrams carry no texture at all.
+
+Both renderers are pure functions of their spec: same spec, same bytes. That is
+what lets a test re-render the committed art and fail on drift, rather than
+trusting that whoever last touched the file also regenerated it.
+"""
+from __future__ import annotations
+
+import colorsys
+import hashlib
+import random
+
+# Pure black, because the corpus grounds on pure black rather than a soft
+# near-black. The two inks are the warm bone the rest of the ecosystem uses.
+VOID = "#07080A"
+BONE = "#F2F4F1"
+SOFT = "#8E9AA0"
+
+# The hues a corona is allowed to take, drawn from the electric-neon-on-black
+# and luminous-warm-core poles of the inspiration corpus. Angles in degrees.
+HUES = (188, 168, 96, 44, 22, 286, 322)
+
+GROTESK = "Hanken Grotesk, Segoe UI, ui-sans-serif, system-ui, sans-serif"
+MONO = "Conso, ui-monospace, Cascadia Mono, Consolas, monospace"
+
+
+def _num(value: float) -> str:
+ """Two decimal places, no trailing noise. Byte-stability starts here."""
+ return f"{value:.2f}".rstrip("0").rstrip(".") or "0"
+
+
+def _esc(text: str) -> str:
+ return (str(text).replace("&", "&").replace("<", "<")
+ .replace(">", ">").replace('"', """))
+
+
+def seed_for(name: str) -> int:
+ """A stable seed from the repository's own name.
+
+ sha256 rather than hash(): CPython randomises str hashing per process, so
+ the built-in would give a different drawing on every run.
+ """
+ return int(hashlib.sha256(name.encode("utf-8")).hexdigest()[:8], 16)
+
+
+def _hue_pair(rng: random.Random) -> tuple[str, str]:
+ """The corona hue and its cooler companion, as hex."""
+ # Anchor on one of the corpus hues, then drift up to 14 degrees off it.
+ # Seven fixed hues collide across a dozen repositories; the drift keeps
+ # two siblings that land on the same anchor from reading as one project.
+ hue = (HUES[rng.randrange(len(HUES))] + rng.uniform(-14, 14)) % 360
+ warm = colorsys.hls_to_rgb(hue / 360.0, 0.62, 0.92)
+ cool = colorsys.hls_to_rgb(((hue + 34) % 360) / 360.0, 0.44, 0.70)
+ return ("#%02X%02X%02X" % tuple(round(c * 255) for c in warm),
+ "#%02X%02X%02X" % tuple(round(c * 255) for c in cool))
+
+
+def _spokes(cx: float, cy: float, rng: random.Random, count: int = 300) -> str:
+ """The corona, as a field of short radial dashes rather than long spokes.
+
+ Continuous spokes read as a star. Broken ones read as line-work: the eye
+ resolves a texture instead of counting rays, and the corona can be dense
+ without becoming loud. Each angle walks outward emitting a dash, then a
+ gap, until it runs out of reach, and the reach is set by a two-wave field
+ so the ring thickens and thins instead of sitting perfectly even.
+
+ Bucketed into five opacity groups, brightest nearest the aperture, so the
+ whole corona is five path elements rather than a thousand.
+ """
+ import math
+
+ buckets: list[list[str]] = [[] for _ in range(5)]
+ for i in range(count):
+ angle = (i / count) * math.tau + rng.uniform(-0.008, 0.008)
+ field = (math.sin(angle * 3 + rng.random() * 0.3) * 0.22
+ + math.sin(angle * 7) * 0.13 + 0.68)
+ cos_a, sin_a = math.cos(angle), math.sin(angle)
+ limit = 92 + field * 52
+ radius = 76 + rng.uniform(0, 9)
+ while radius < limit:
+ far = min(limit, radius + rng.uniform(3.0, 15.0))
+ level = min(4, max(0, int((1.0 - (radius - 76) / 78.0) * 4.6)))
+ buckets[level].append(
+ f"M{_num(cx + cos_a * radius)} {_num(cy + sin_a * radius)}"
+ f"L{_num(cx + cos_a * far)} {_num(cy + sin_a * far)}")
+ radius = far + rng.uniform(2.5, 13.0)
+ out = []
+ for level, segs in enumerate(buckets):
+ if not segs:
+ continue
+ out.append(f'')
+ return "".join(out)
+
+
+def _rings(cx: float, cy: float, rng: random.Random) -> str:
+ """Concentric broken arcs. This is the mesh half of the corona.
+
+ Each ring is one circle with a seeded dash pattern, so a whole band of
+ fine arc-work costs one element and varies per repository.
+ """
+ out = []
+ for ring in range(14):
+ radius = 80 + ring * 4.6
+ fall = 1.0 - (ring / 14.0) ** 1.5
+ dash = rng.uniform(2.2, 13.0)
+ gap = dash * rng.uniform(0.7, 3.4)
+ out.append(f'')
+ return "".join(out)
+
+
+def _blades(cx: float, cy: float, rng: random.Random, accent: str) -> str:
+ """A dot screen in a narrow band around the core, the way a halftone
+ plate carries the shoulder of a highlight."""
+ import math
+
+ dots = []
+ for ring in range(1, 7):
+ radius = 41 + ring * 5.1
+ n = max(10, int(radius * 0.85))
+ for k in range(n):
+ angle = (k / n) * math.tau + ring * 0.27
+ fall = max(0.0, 1.0 - (ring / 7.0) ** 1.2)
+ if rng.random() > fall * 0.86 + 0.1:
+ continue
+ dots.append(f'')
+ return f'{"".join(dots)}'
+
+
+def _core(cx: float, cy: float, cool: str) -> str:
+ """The aperture itself, and the one spectral flare.
+
+ The middle is a void with an incandescent rim, not a bright ball. That
+ is the form the whole visual corpus keeps returning to, and it is also
+ the more honest picture: what the tool gives you is an opening onto
+ something, with the light at its edge.
+
+ The flare is an anamorphic streak, white where the light is hottest and
+ splitting to red at one end and blue at the other, which is what a lens
+ does at the edge of a bright source. It is the single hot mark the art
+ is allowed and the only place the full spectrum appears.
+ """
+ ticks = "".join(
+ f''
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
+ return (
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ # The reticle: a measured circle and four ticks, so the aperture
+ # reads as something being observed rather than admired.
+ f''
+ f''
+ f"{ticks}")
+
+
+def _defs(accent: str, cool: str, seed: int) -> str:
+ """Gradients, the scanline screen, the grain, and the wash that keeps
+ text legible.
+
+ feTurbulence carries an explicit seed for the same reason the corona
+ does: an unseeded filter is a different image on every render, and a
+ test that compares bytes would never pass twice.
+ """
+ return (
+ ""
+ f''
+ ''
+ ''
+ f''
+ f''
+ f''
+ f''
+ f''
+ ''
+ ''
+ f''
+ f''
+ # The refraction: red at one end, white where it is hottest, blue at
+ # the other. One mark, and the only full spectrum in the kit.
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ f''
+ f''
+ f''
+ ''
+ f''
+ ''
+ ''
+ ''
+ "")
+
+
+def header_svg(spec: dict) -> str:
+ """The identity card. 1280x340, the proportion a README header wants."""
+ name = spec["name"]
+ seed = seed_for(name)
+ rng = random.Random(seed)
+ accent, cool = _hue_pair(rng)
+ cx, cy = 1012.0, 168.0
+ words = " / ".join(w.upper() for w in spec.get("words", []))
+ meta = f'{spec.get("publisher", "ZENTROPY LABS")} / {spec["role"].upper()}'
+ return (
+ f'")
diff --git a/tools/repo_flow.py b/tools/repo_flow.py
new file mode 100644
index 0000000..f858fde
--- /dev/null
+++ b/tools/repo_flow.py
@@ -0,0 +1,169 @@
+"""repo_flow.py -- a workflow diagram rendered from a spec, not hand-placed.
+
+The picture a reader actually needs is what happens to one piece of work as it
+moves through the tool: what it passes through, what can send it back, and what
+it ends up as. This draws that from a list of stages, so the diagram is data in
+the repository and stays correctable by editing a sentence rather than by
+nudging coordinates in a drawing program.
+
+Color says one thing here and nothing else. The forward path is the verified
+green, the edge that sends work back is the drift iris, and everything that is
+merely structure is a hairline. Both a light and a dark palette are defined and
+the reader's own setting picks between them, so the diagram is legible in a
+README either way without shipping two files.
+
+Cards carry a 3px corner. A full round would read as a capsule, which is the
+default shape of every generated interface and says nothing about what the
+thing is; a small radius reads as drawn.
+"""
+from __future__ import annotations
+
+from repo_art import GROTESK, MONO, _esc, _num
+
+W = 960
+PAD = 44
+GAP = 26
+CARD_H = 96
+PER_ROW = 4
+ROW_GAP = 96
+
+# The two palettes, matching the tokens the repository's existing schematics
+# already use so the whole set reads as one hand.
+STYLE = """
+ :root{ --void:#f4f3ef; --bone:#0b0c0e; --muted:#43474e;
+ --hairline:rgba(11,12,14,.16); --card:rgba(255,255,255,.66);
+ --verified:#1f7a52; --drift:#3a2bd6; }
+ @media (prefers-color-scheme: dark){
+ :root{ --void:#0b0e0f; --bone:#eef1ee; --muted:#9aa39c;
+ --hairline:rgba(238,241,238,.18); --card:rgba(255,255,255,.05);
+ --verified:#5fae93; --drift:#a99cf5; } }
+ .bg{ fill:var(--void); }
+ .card{ fill:var(--card); stroke:var(--hairline); stroke-width:1.4; }
+ .n{ fill:var(--bone); font-size:15px; font-weight:650; }
+ .s{ fill:var(--muted); font-size:11.5px; }
+ .k{ fill:var(--muted); font-size:11px; letter-spacing:.16em; }
+ .h{ fill:var(--bone); font-size:21px; font-weight:700; }
+ .fwd{ stroke:var(--verified); stroke-width:2; fill:none; }
+ .back{ stroke:var(--drift); stroke-width:1.8; fill:none; stroke-dasharray:5 4; }
+ .thin{ stroke:var(--hairline); stroke-width:1.4; fill:none; }
+ .step{ fill:var(--muted); font-size:11px; font-weight:700; letter-spacing:.1em; }
+"""
+
+
+def _wrap(text: str, width: int = 30) -> list[str]:
+ lines: list[str] = []
+ line = ""
+ for word in text.split():
+ candidate = f"{line} {word}".strip()
+ if len(candidate) > width and line:
+ lines.append(line)
+ line = word
+ else:
+ line = candidate
+ if line:
+ lines.append(line)
+ return lines[:3]
+
+
+def _card_box(index: int) -> tuple[float, float, float]:
+ """Left edge, top edge and width for the card at `index`."""
+ width = (W - PAD * 2 - GAP * (PER_ROW - 1)) / PER_ROW
+ row, col = divmod(index, PER_ROW)
+ return (PAD + col * (width + GAP), 110 + row * (CARD_H + ROW_GAP), width)
+
+
+def _card(index: int, stage: dict) -> str:
+ x, y, w = _card_box(index)
+ notes = "".join(
+ f''
+ f"{_esc(line)}"
+ for i, line in enumerate(_wrap(stage.get("note", ""))))
+ return (f''
+ f''
+ f"{index + 1:02d}"
+ f''
+ f'{_esc(stage["title"])}{notes}')
+
+
+def _forward(index: int) -> str:
+ """The edge from card `index` to card `index + 1`."""
+ x0, y0, w = _card_box(index)
+ x1, y1, _ = _card_box(index + 1)
+ mid0, mid1 = y0 + CARD_H / 2, y1 + CARD_H / 2
+ if y0 == y1:
+ return (f'')
+ # The wrap between rows, routed through the gutter so it never crosses a
+ # card: out to the right margin, back across the empty band, then in.
+ gut = _num(y0 + CARD_H + ROW_GAP - 26)
+ return (f'')
+
+
+def _return(edge: dict) -> str:
+ """A dashed edge that sends work back, dipping below the row it leaves."""
+ x0, y0, w0 = _card_box(edge["from"])
+ x1, y1, w1 = _card_box(edge["to"])
+ dip = y0 + CARD_H + 22
+ label_x = (x0 + w0 / 2 + x1 + w1 / 2) / 2
+ return (f''
+ f''
+ f'{_esc(edge["label"])}')
+
+
+def _outcomes(items: list[dict], top: float, source: int) -> str:
+ x0, y0, w0 = _card_box(source)
+ span = (W - PAD * 2 - GAP * (len(items) - 1)) / len(items)
+ trunk = x0 + w0 / 2
+ tone = {"verified": "var(--verified)", "drift": "var(--drift)",
+ "none": "var(--muted)"}
+ out = [f'']
+ for i, item in enumerate(items):
+ x = PAD + i * (span + GAP)
+ out.append(
+ f''
+ f''
+ f'{_esc(item["label"])}'
+ f'{_esc(item["note"])}')
+ return "".join(out)
+
+
+def flow_svg(spec: dict) -> str:
+ """Stages, the edges between them, and what the work ends up as."""
+ stages = spec["stages"]
+ rows = (len(stages) + PER_ROW - 1) // PER_ROW
+ body = 110 + rows * CARD_H + (rows - 1) * ROW_GAP
+ top = body + 74
+ height = top + 46 + 46
+ cards = "".join(_card(i, s) for i, s in enumerate(stages))
+ edges = "".join(_forward(i) for i in range(len(stages) - 1))
+ backs = "".join(_return(e) for e in spec.get("returns", []))
+ ends = _outcomes(spec["outcomes"], top, len(stages) - 1)
+ return (
+ f'")