From 8c90f4a9bb89b732ab70218157762b9651e17b27 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 14 Oct 2026 09:54:38 +0200 Subject: [PATCH 01/44] Skill: one feature across two areas (admit where heard, route work by ids) Co-Authored-By: Claude Opus 5.5 --- SKILL.md | 7 +++++++ references/changelog.md | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/SKILL.md b/SKILL.md index 4007cb4..648073c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -593,6 +593,13 @@ and reconciles the target doc with the Body. operator. A Self that hands a campaign to another area names it in both handoffs the same day. (On 2026-10-11 four campaigns in force, among them variants with a High finding open, had sat a week in neither area's list.) + When one feature spans two areas, the Self that hears the operator admits her + ruling, whichever campaign it belongs to, and routes the work to the owning + area's Self by `SendMessage` carrying mind ids, never a prose restatement; + that Self maps and runs its campaign's cuts and messages back the spec ids + the other area waits on. (2026-10-14: fauna's trophic graph needed the + cultmath-tapes tape and CultMath nodes; the two Selves ran it this way with + no campaign worked by both.) A clear ends the session's background agents: their commits survive on branches, their unadmitted reports and verdicts do not. Rotate when no agent is in flight; if diff --git a/references/changelog.md b/references/changelog.md index 5e31838..d854e35 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -2,6 +2,14 @@ Record each change to the skill together with the evidence that motivated it. +## 2026-10-14: one feature across two areas + +Self, session `self-2026-10-14-ag`. + +Evidence. Fauna's trophic placement (aetheria-fauna, Aetheria area) needed the CultMath tape and nodes (cultmath-tapes, Eureka area). The operator ruled tape and node questions in the Aetheria session; the Aetheria Self admitted her rulings there and messaged the Eureka Self with ids, which mapped and ran the CultMath cuts and messaged spec ids back. Nothing in the skill said how two Selves coordinate (Life, 2026-10-14). + +What changed. `SKILL.md`, the area paragraph: the Self that hears the operator admits her ruling; work goes to the owning area's Self by message with ids. + ## 2026-10-14: nothing that exists is sacred, her words included Self, session `self-2026-10-14-ag`. From ec542b9046d9cbbac359c7f0deda571eaca61440 Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 5 Nov 2026 13:54:53 +0100 Subject: [PATCH 02/44] bloom-sheet: a CLI turning markdown into a neon one-page sheet, every heading a card, with caller SVG visitors Co-Authored-By: Claude Opus 5.5 --- tools/INDEX.md | 1 + tools/bloom-sheet/bloom_sheet.py | 345 +++++++++++++++++ tools/bloom-sheet/grow.py | 612 +++++++++++++++++++++++++++++++ tools/bloom-sheet/page.tex | 108 ++++++ 4 files changed, 1066 insertions(+) create mode 100755 tools/bloom-sheet/bloom_sheet.py create mode 100644 tools/bloom-sheet/grow.py create mode 100644 tools/bloom-sheet/page.tex diff --git a/tools/INDEX.md b/tools/INDEX.md index 5562e83..f4a5a3b 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -17,3 +17,4 @@ gets a Soul pass before merge. | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | +| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py new file mode 100755 index 0000000..38c55cb --- /dev/null +++ b/tools/bloom-sheet/bloom_sheet.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""Turn a markdown document into a one-page neon bloom sheet. + + bloom_sheet.py notes.md -o sheet.png + +The first top-level heading is the page title and the paragraph right after it +the subtitle; every other heading becomes a card, sized by how much it holds. +The plant grows through whatever the cards leave, and --visitor perches your +SVGs in the garden. Output is PDF or PNG, by the extension of -o. Takes about +two minutes: the growth, then LuaLaTeX twice. + +Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox, +pdftoppm for PNG, rsvg-convert for visitors, and the Ubuntu, IBM Plex Mono and +Montserrat fonts. +Everything from the document is escaped, and LaTeX runs with no shell escape. +""" +import argparse, json, os, random, re, shutil, subprocess, sys, tempfile, zlib +from pathlib import Path + +from markdown_it import MarkdownIt + +HERE = Path(__file__).resolve().parent +W, H = 210.0, 297.0 +MARGIN, GUTTER = 5.0, 7.0 +TITLE_H = 21.2 +COLOURS = ["neonorange", "neonsky", "neonviolet"] +# Files first, then family names: a font's packaging differs between machines. +FONTS = r""" +\IfFontExistsTF{Ubuntu-L.ttf}{% + \setmainfont{Ubuntu-L.ttf}[BoldFont=Ubuntu-B.ttf] + \newfontfamily\speakfont{Ubuntu-R.ttf}[BoldFont=Ubuntu-B.ttf] +}{% + \setmainfont{Ubuntu}[UprightFont=* Light, BoldFont=* Bold] + \newfontfamily\speakfont{Ubuntu}[BoldFont=* Bold] +} +\IfFontExistsTF{IBMPlexMono-Regular.otf}{% + \newfontfamily\numfont{IBMPlexMono-Regular.otf} + \newfontfamily\labelfont{IBMPlexMono-Medium.otf}[LetterSpace=14] +}{% + \newfontfamily\numfont{IBM Plex Mono} + \newfontfamily\labelfont{IBM Plex Mono}[UprightFont=* Medium, LetterSpace=14] +} +\IfFontExistsTF{Montserrat-Thin.ttf}{\newfontfamily\thinfont{Montserrat-Thin.ttf}}{\newfontfamily\thinfont{Montserrat}[UprightFont=* Thin]} +""" + +TEX_SPECIAL = {"\\": r"\textbackslash{}", "{": r"\{", "}": r"\}", "$": r"\$", "&": r"\&", "#": r"\#", + "%": r"\%", "_": r"\_", "^": r"\textasciicircum{}", "~": r"\textasciitilde{}"} + + +def esc(text): + return "".join(TEX_SPECIAL.get(c, c) for c in text) + + +def esc_code(text): + """Escaped, with break points: code and URLs have no spaces to wrap at.""" + return re.sub(r"(/|\.)", r"\1\\allowbreak{}", esc(text)) + + +def inline(token): + """Render one inline token's children as LaTeX; returns (tex, plain text).""" + tex, plain, stack = [], [], [] + link = None + for t in token.children or []: + if t.type == "text": + tex.append(esc(t.content)); plain.append(t.content) + elif t.type == "code_inline": + tex.append(r"\code{" + esc_code(t.content) + "}"); plain.append(t.content) + elif t.type in ("softbreak", "hardbreak"): + tex.append(" " if t.type == "softbreak" else r"\newline{}"); plain.append(" ") + elif t.type in ("strong_open", "em_open"): + tex.append(r"\strong{" if t.type == "strong_open" else r"\soft{") + elif t.type in ("strong_close", "em_close"): + tex.append("}") + elif t.type == "link_open": + link = (t.attrGet("href") or "", len(plain)) + elif t.type == "link_close" and link: + href, start = link + if href and href != "".join(plain[start:]): + tex.append(r" \faint{" + esc_code(href) + "}") + link = None + elif t.type == "image": + alt = t.content or "image" + tex.append(r"\faint{[" + esc(alt) + "]}"); plain.append(alt) + return "".join(tex), "".join(plain) + + +class Card: + def __init__(self, head=None, level=7): + self.head, self.level, self.parts, self.chars = head, level, [], len(head or "") + + def add(self, tex, chars): + self.parts.append(tex) + self.chars += chars + 30 # each block costs a line's start and some space + + def tex(self): + return (r"\head{" + esc(self.head) + "}\n" if self.head else "") + "\n".join(self.parts) + + +def table(tokens, i): + """A pipe table as a tabular of equal ragged columns. Returns (tex, chars, next i).""" + rows, row, is_head = [], [], False + while tokens[i].type != "table_close": + t = tokens[i] + if t.type == "tr_open": row = [] + elif t.type == "th_open": is_head = True + elif t.type == "td_open": is_head = False + elif t.type == "inline": + tex, plain = inline(t) + row.append((r"\strong{" + tex + "}" if is_head else tex, plain)) + elif t.type == "tr_close": rows.append(row) + i += 1 + n = max(len(r) for r in rows) + col = r">{\cell}p{\dimexpr(\linewidth - %d\tabcolsep)/%d\relax}" % (2 * (n - 1), n) + body = " \\\\\n".join(" & ".join(c[0] for c in r + [("", "")] * (n - len(r))) for r in rows) + chars = sum(max(len(c[1]) for c in r) * n + 10 for r in rows) + return r"\par\begin{tabular}{@{}" + "".join([col] * n) + "@{}}\n" + body + " \\\\\n\\end{tabular}\\par", chars, i + + +def parse(text, card_level): + """Returns (title, subtitle, cards).""" + tokens = MarkdownIt("commonmark").enable("table").enable("strikethrough").parse(text) + title, subtitle, cards = None, None, [] + card = None + depth, ordered, quote = 0, [], 0 + i = 0 + while i < len(tokens): + t = tokens[i] + if t.type == "heading_open": + level = int(t.tag[1]) + _, plain = inline(tokens[i + 1]) + if level == 1 and title is None and not cards and card is None: + title = plain + nxt = tokens[i + 3] if i + 3 < len(tokens) else None + if nxt is not None and nxt.type == "paragraph_open": + subtitle = inline(tokens[i + 4])[1] + i += 3 + elif level <= card_level: + # a heading with nothing of its own is its first child's section name + if card is not None and card.head and not card.parts and level > card.level: + plain = f"{card.head} · {plain}" + cards.pop() + card = Card(plain, level); cards.append(card) + else: + if card is None: + card = Card(); cards.append(card) + card.add(r"\sub{" + esc(plain) + "}", len(plain)) + i += 3 + continue + if card is None and t.type not in ("hr",): + card = Card(); cards.append(card) + if t.type == "inline" and tokens[i - 1].type == "paragraph_open": + tex, plain = inline(t) + if depth: + marker = esc(f"{ordered[-1]}.") if ordered[-1] else "•" + if tokens[i - 2].type == "list_item_open": + card.add(rf"\li{{{depth}}}{{{marker}}}{{{tex}}}", len(plain)) + if ordered[-1]: ordered[-1] += 1 + else: + card.add(rf"\li{{{depth}}}{{}}{{{tex}}}", len(plain)) + elif quote: + card.add(rf"\quoted{{{tex}}}", len(plain)) + else: + card.add(tex + r"\par", len(plain)) + elif t.type == "bullet_list_open": + depth += 1; ordered.append(0) + elif t.type == "ordered_list_open": + depth += 1; ordered.append(int(t.attrGet("start") or 1)) + elif t.type in ("bullet_list_close", "ordered_list_close"): + depth -= 1; ordered.pop() + elif t.type == "blockquote_open": + quote += 1 + elif t.type == "blockquote_close": + quote -= 1 + elif t.type in ("fence", "code_block"): + for line in t.content.rstrip("\n").split("\n"): + # a kept space is a real glue item, so indents survive in the fit box + card.add(r"\codeline{" + esc_code(line).replace(" ", r"\ ") + r"\strut}", len(line) + 30) + elif t.type == "hr" and card is not None: + card.add(r"\hairline", 10) + elif t.type == "table_open": + tex, chars, i = table(tokens, i) + card.add(tex, chars) + i += 1 + return title, subtitle, [c for c in cards if c.head or c.parts] + + +def layout(cards, box, rng, visitors): + """Cut box into one rectangle per card, in reading order, each about the + size its text needs at 9.5 pt. Whatever that leaves is dealt in as holes: + the flowers and visitors need somewhere to bloom, and a short document gets + more garden. Returns [(card index, x, y, w, h)].""" + weights = [(k, 900 + 8 * c.chars) for k, c in enumerate(cards)] + need = sum(w for _, w in weights) + room = box[2] * box[3] - GUTTER * (box[2] + box[3]) * len(cards) ** 0.5 + spare = max(room - need * 1.15, 0.05 * need) + holes = max(visitors, 2 + int(4 * spare / room)) + for _ in range(holes): + # not first: the reader's eye starts at the top left + weights.insert(rng.randint(1, len(weights)), (None, max(spare / holes, 0.03 * need))) + + out = [] + def cut(items, x, y, w, h): + if len(items) == 1: + if items[0][0] is not None: + out.append((items[0][0], round(x, 1), round(y, 1), round(w, 1), round(h, 1))) + return + whole = sum(v for _, v in items) + # the split nearest even, so neither side ends up a sliver + best = min(range(1, len(items)), key=lambda n: abs(sum(v for _, v in items[:n]) - whole / 2)) + s = sum(v for _, v in items[:best]) / whole + if w > h * 0.85: # side by side, first on the left + a = (w - GUTTER) * s + cut(items[:best], x, y, a, h) + cut(items[best:], x + a + GUTTER, y, w - a - GUTTER, h) + else: # stacked, first on top + a = (h - GUTTER) * s + cut(items[:best], x, y + h - a, w, a) + cut(items[best:], x, y, w, h - a - GUTTER) + cut(weights, *box) + return sorted(out) + + +def macro(k): + name = "" + k += 1 + while k: + k, r = divmod(k - 1, 26) + name = chr(65 + r) + name + return r"\content" + name + + +def build(args, source): + title, subtitle, cards = parse(source, args.level) + if args.title is not None: title = args.title + if args.subtitle is not None: subtitle = args.subtitle + if not cards: + sys.exit("bloom-sheet: nothing to put on the page: the document has no headings or text") + seed = args.seed if args.seed is not None else zlib.crc32(source.encode("utf8")) % 100000 + rng = random.Random(seed) + + top = H - MARGIN + title_tex, title_box, swash = "", None, None + if title: + # Montserrat Thin runs about 0.56 em a letter; 31 pt is the sheet's title size + size = min(31.0, 31.0 * 192 / (0.56 * 10.9 * max(len(title), 1))) + width = min(196.0, 0.56 * size * 0.3528 * len(title)) + title_tex = (r"\node[anchor=south west, inner sep=0pt, text=heading] at (7,278)" "\n" + rf" {{\thinfont\fontsize{{{size:.1f}}}{{{size:.1f}}}\selectfont {esc(title)}}};" "\n") + if subtitle: + title_tex += (r"\node[anchor=north west, inner sep=0pt, text=muted, text width=196mm] at (7.4,273.2)" "\n" + rf" {{\labelfont\fontsize{{5.6}}{{6}}\selectfont {esc(subtitle.upper())}}};" "\n") + title_box = [5, H - MARGIN - TITLE_H, 200, TITLE_H] + swash = [7, 276.4, 7 + max(30.0, width), 277.2] + top = title_box[1] - GUTTER + + rects = layout(cards, (MARGIN, MARGIN, W - 2 * MARGIN, top - MARGIN), rng, len(args.visitor)) + panels, contents, last = [], [], None + for k, x, y, w, h in rects: + colour = rng.choice([c for c in COLOURS if c != last]); last = colour + panels.append([macro(k)[8:], x, y, w, h, colour, macro(k)]) + contents.append(rf"\newcommand{{{macro(k)}}}{{%" + "\n" + cards[k].tex() + "}") + # 5 pt type is about 1.7 mm of line and 0.9 mm of letter + room = (w - 6.2) * (h - 5.6) / (1.8 * 0.95) + if cards[k].chars > room: + print(f"bloom-sheet: card '{cards[k].head or 'intro'}' will come out under 5 pt: " + "shorten it or split the sheet", file=sys.stderr) + + with tempfile.TemporaryDirectory(prefix="bloom-sheet-") as tmp: + d = Path(tmp) + names = [] + for n, svg in enumerate(args.visitor): + names.append(f"visitor{n}.pdf") + run(["rsvg-convert", "-f", "pdf", "-o", str(d / names[-1]), str(Path(svg).resolve())], d, args.verbose) + (d / "spec.json").write_text(json.dumps( + {"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": names}), encoding="utf8") + page = (HERE / "page.tex").read_text(encoding="utf8") + for mark, value in (("@@FONTS@@", FONTS), ("@@CONTENTS@@", "\n".join(contents)), ("@@TITLE@@", title_tex)): + page = page.replace(mark, value) + (d / "sheet.tex").write_text(page, encoding="utf8") + grown = run([sys.executable, str(HERE / "grow.py"), "spec.json", "layer.tex"], d, args.verbose) + for line in grown.splitlines(): + if line.startswith("left out: visitor"): + print(f"bloom-sheet: {line}", file=sys.stderr) + env = dict(os.environ) + # the font database takes a minute to build: keep it between sheets + cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "bloom-sheet" + try: + cache.mkdir(parents=True, exist_ok=True) + except OSError: # a sandbox with no home: slower, not broken + cache = d / "cache" + env.setdefault("TEXMFVAR", str(cache)) + latex = ["lualatex", "--no-shell-escape", "--interaction=nonstopmode", "--halt-on-error", "sheet.tex"] + for _ in range(2): # remember picture lands right only on the second pass + run(latex, d, args.verbose, env, log="sheet.log") + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + if out.suffix.lower() == ".png": + run(["pdftoppm", "-r", str(args.dpi), "-png", "-singlefile", "sheet.pdf", "sheet"], d, args.verbose) + shutil.copyfile(d / "sheet.png", out) + else: + shutil.copyfile(d / "sheet.pdf", out) + print(f"{out} ({len(cards)} cards, seed {seed})") + + +def run(cmd, cwd, verbose, env=None, log=None): + try: + p = subprocess.run(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors="replace", timeout=600) + except FileNotFoundError: + sys.exit(f"bloom-sheet: {cmd[0]} is not installed") + except subprocess.TimeoutExpired: + sys.exit(f"bloom-sheet: {cmd[0]} ran past 10 minutes") + if verbose: print(p.stdout) + if p.returncode: + tail = p.stdout + if log and (Path(cwd) / log).exists(): + text = (Path(cwd) / log).read_text(encoding="utf8", errors="replace") + errors = [i for i, line in enumerate(text.splitlines()) if line.startswith("!")] + tail = "\n".join(text.splitlines()[errors[0]:errors[0] + 12]) if errors else text[-1500:] + sys.exit(f"bloom-sheet: {cmd[0]} failed:\n{tail[-1500:]}") + return p.stdout + + +def main(): + ap = argparse.ArgumentParser(prog="bloom-sheet", description="Turn a markdown document into a one-page neon bloom sheet.") + ap.add_argument("input", help="markdown file, or - for stdin") + ap.add_argument("-o", "--output", help="output .pdf or .png (default: the input's name, .pdf)") + ap.add_argument("--title", help="page title, instead of the document's first top-level heading; empty for none") + ap.add_argument("--subtitle", help="small line under the title") + ap.add_argument("--level", type=int, default=6, help="headings deeper than this are labels inside a card, not cards (default: every heading is a card)") + ap.add_argument("--visitor", action="append", default=[], metavar="SVG", help="an SVG to perch in the garden; repeat for more (about three suit a page)") + ap.add_argument("--seed", type=int, help="plant seed (default: from the document, so a sheet rebuilds the same)") + ap.add_argument("--dpi", type=int, default=200, help="PNG resolution (default 200)") + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + source = sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf8") + if not args.output: + args.output = "sheet.pdf" if args.input == "-" else str(Path(args.input).with_suffix(".pdf")) + if Path(args.output).suffix.lower() not in (".pdf", ".png"): + sys.exit("bloom-sheet: -o must end in .pdf or .png") + build(args, source) + + +if __name__ == "__main__": + main() diff --git a/tools/bloom-sheet/grow.py b/tools/bloom-sheet/grow.py new file mode 100644 index 0000000..44f645a --- /dev/null +++ b/tools/bloom-sheet/grow.py @@ -0,0 +1,612 @@ +"""Grow the ornament layer of a bloom sheet (see ../SKILL.md). + +The page owns its text; this script owns its geometry, in four stages: +1. motifs: a greedy loop borrowed from florilegium + (github.com/kevinraymond/florilegium): find the largest free disc, put a + motif in it, mark it taken, repeat. Big discs get flowers; small ones are + invisible junctions. +2. the tree: a minimum spanning tree over the motifs, so the page is one plant. +3. the vine: the tree cut into a few long paths, each one Catmull-Rom spline + carrying a sine wave sized to the room around it; branches leave their + parent tangentially. +4. scrolls: a rinceau. At every crest a scroll leaves the vine and winds into + a logarithmic spiral (exponentially rising curvature); scrolls shed smaller + counter-winding scrolls, three generations deep, each kept only if it fits. +5. budding: the largest free gap left pulls a branch out of the nearest stroke + that reaches it smoothly; the branch winds into a spiral inside the gap and + can itself be budded from. Repeats down to sub-millimetre gaps. + +Every line is a weighted stroke: a closed outline offset from a centreline by +half a width that varies along it, filled rather than stroked. That is +florilegium's `ribbon` (ink.lua, MIT, Kevin Raymond), ported to Python. + +Run: python grow.py spec.json layer.tex +spec.json: {"seed": 11, "panels": [[name, x, y, w, h, colour, content macro], ...], + "title_box": [x, y, w, h] or null, "swash": [x0, y0, x1, y1] or null, + "visitors": [file, ...]} +All lengths are mm from the page's bottom left. A visitor is florilegium's idea +with the caller's drawing: it takes the largest disc free before the plant +grows, and the plant grows around it. +""" +import json, math, random, sys +from pathlib import Path +import numpy as np + +spec = json.loads(Path(sys.argv[1]).read_text(encoding="utf8")) +SEED = spec["seed"] +rng = random.Random(SEED) +W, H = 210.0, 297.0 +GAP = 0.8 # clearance between anything drawn and a panel edge +MARGIN = 1.8 # ornaments keep this far from the paper edge; wide enough that + # the vine can pass a full-width panel through the side margins + +# ---------------------------------------------------------------- panels +# name, x, y, w, h (mm from bottom left), colour, content macro +PANELS = [tuple(p) for p in spec["panels"]] +# Gutters are 7 mm: narrower, and a scroll has no room to wind. Leave a hole or two +# open on purpose: the flowers need somewhere to bloom. +TITLE_BOX = spec["title_box"] # the title is text, not a panel, but it is not free space + +# Corner radii (tl, tr, br, bl). Text starts top left and may reach bottom +# left, so those stay tight; the right-hand corners open into pockets. +def radii(w, h): + cap = min(w, h) / 3 + return tuple(min(cap, rng.uniform(lo, hi)) for lo, hi in ((2, 4), (3, 9), (3, 10.5), (2, 7))) + +panels = [(n, x, y, w, h, c, m, radii(w, h)) for n, x, y, w, h, c, m in PANELS] + +# ---------------------------------------------------------------- distance field +STEP = 0.4 +gx = np.arange(0, W + STEP / 2, STEP) +gy = np.arange(0, H + STEP / 2, STEP) +X, Y = np.meshgrid(gx, gy) + +def sd_roundbox(px, py, x, y, w, h, r): + """Signed distance to a box with one radius per corner (tl, tr, br, bl). + After Inigo Quilez's sdRoundBox, with y pointing up.""" + tl, tr, br, bl = r + cx, cy = x + w / 2, y + h / 2 + qx, qy = px - cx, py - cy + rr = np.where(qx > 0, np.where(qy > 0, tr, br), np.where(qy > 0, tl, bl)) + ax = np.abs(qx) - w / 2 + rr + ay = np.abs(qy) - h / 2 + rr + return np.minimum(np.maximum(ax, ay), 0) + np.hypot(np.maximum(ax, 0), np.maximum(ay, 0)) - rr + +wall = np.minimum.reduce([X - MARGIN, W - MARGIN - X, Y - MARGIN, H - MARGIN - Y]) +solid = np.full_like(X, np.inf) +for n, x, y, w, h, c, m, r in panels: + solid = np.minimum(solid, sd_roundbox(X, Y, x, y, w, h, r)) +if TITLE_BOX: + tx, ty, tw, th = TITLE_BOX + solid = np.minimum(solid, sd_roundbox(X, Y, tx, ty, tw, th, (0, 0, 0, 0))) +clear = np.minimum(solid - GAP, wall) # room to draw, ignoring ornaments +free = clear.copy() # room to draw, ornaments included + +def clear_at(px, py): + i = int(round(py / STEP)); j = int(round(px / STEP)) + if not (0 <= i < clear.shape[0] and 0 <= j < clear.shape[1]): return -1 + return clear[i, j] + +# ---------------------------------------------------------------- weighted strokes +def normals(pts): + out = [] + for i in range(len(pts)): + a = pts[max(i - 1, 0)]; b = pts[min(i + 1, len(pts) - 1)] + dx, dy = b[0] - a[0], b[1] - a[1] + L = math.hypot(dx, dy) or 1 + out.append((-dy / L, dx / L)) + return out + +def ribbon(pts, width): + """Closed outline of a stroke along pts whose full width at fraction u of + its length is width(u).""" + nrm = normals(pts) + s = [0.0] + for i in range(1, len(pts)): + s.append(s[-1] + math.dist(pts[i], pts[i - 1])) + L = s[-1] or 1 + left, right = [], [] + for p, n, si in zip(pts, nrm, s): + hw = width(si / L) / 2 + left.append((p[0] + n[0] * hw, p[1] + n[1] * hw)) + right.append((p[0] - n[0] * hw, p[1] - n[1] * hw)) + return left + right[::-1] + +def arc_pts(p, q, bend, n=24): + """Circular arc from p to q; bend is the sagitta as a fraction of the chord.""" + (x0, y0), (x1, y1) = p, q + mx, my = (x0 + x1) / 2, (y0 + y1) / 2 + dx, dy = x1 - x0, y1 - y0 + L = math.hypot(dx, dy) + if abs(bend) < 1e-3 or L < 1e-6: + return [(x0 + dx * t / n, y0 + dy * t / n) for t in range(n + 1)] + s = bend * L + R = (L * L / 4 + s * s) / (2 * abs(s)) + nx, ny = -dy / L, dx / L + sign = 1 if s > 0 else -1 + cx, cy = mx - nx * sign * (R - abs(s)), my - ny * sign * (R - abs(s)) + a0 = math.atan2(y0 - cy, x0 - cx); a1 = math.atan2(y1 - cy, x1 - cx) + da = (a1 - a0 + math.pi) % (2 * math.pi) - math.pi + # take the arc on the bulge side + midang = a0 + da / 2 + if (math.cos(midang) * nx + math.sin(midang) * ny) * sign < 0: + da = da - 2 * math.pi if da > 0 else da + 2 * math.pi + return [(cx + R * math.cos(a0 + da * t / n), cy + R * math.sin(a0 + da * t / n)) for t in range(n + 1)] + +def vesica(c, ang, length, fat, n=9): + """A pointed petal or leaf: two circular arcs from c to the tip.""" + tip = (c[0] + length * math.cos(ang), c[1] + length * math.sin(ang)) + a = arc_pts(c, tip, fat, n) + b = arc_pts(tip, c, fat, n) + return a + b[1:] + +def path(poly): + return " -- ".join(f"({x:.2f},{y:.2f})" for x, y in poly) + " -- cycle" + +out = [] +def neon(colour, poly, core=0.85): + out.append(f"\\neonfill{{{colour}}}{{{int(core * 100)}}}{{{path(poly)}}}") +def neonline(colour, pts, w0, w1, core=0.9): + neon(colour, ribbon(pts, lambda u: w0 + (w1 - w0) * u), core) +def ring(colour, c, r, w): + pts = [(c[0] + r * math.cos(2 * math.pi * k / 24), c[1] + r * math.sin(2 * math.pi * k / 24)) for k in range(25)] + # a ring breathes: heavier on one side, like a pen circle + ph = rng.uniform(0, 2 * math.pi) + neon(colour, ribbon(pts, lambda u: w * (0.55 + 0.45 * math.cos(2 * math.pi * u + ph))), 0.8) +def dot(colour, c, r): + out.append(f"\\neondot{{{colour}}}{{{c[0]:.2f}}}{{{c[1]:.2f}}}{{{r:.2f}}}") + +# ---------------------------------------------------------------- motifs +PALETTE = [("neonorange", 5), ("neonsky", 3), ("neonviolet", 3), ("neonrose", 2)] +def pick(): + t = rng.uniform(0, sum(w for _, w in PALETTE)) + for c, w in PALETTE: + t -= w + if t <= 0: return c + return PALETTE[0][0] + +def rosette(c, r, col): + n = rng.choice((5, 6, 6, 8)) + ph = rng.uniform(0, math.pi) + for k in range(n): + a = ph + 2 * math.pi * k / n + petal = vesica(c, a, r, 0.24 if n < 8 else 0.19) + out.append(f"\\petalwash{{{col}}}{{{path(petal)}}}") + # the petal's outline as one weighted stroke: fat at the belly, thin at the tip + neonline_closed(col, petal, 0.34 * min(1, r / 5) + 0.12) + ring(pick(), c, r * 0.3, 0.32) + dot("neonwhite", c, max(0.35, r * 0.09)) + +def neonline_closed(col, poly, w): + neon(col, ribbon(poly, lambda u: w * (0.25 + 0.75 * math.sin(math.pi * ((2 * u) % 1)) ** 2)), 0.9) + +def curl(c, r, col): + turns = rng.uniform(1.2, 1.9) + a0 = rng.uniform(0, 2 * math.pi) + sgn = rng.choice((-1, 1)) + k = math.log(9) / (turns * 2 * math.pi) # r falls to a ninth over the turns + n = int(22 * turns) + pts = [] + for i in range(n + 1): + t = i / n * turns * 2 * math.pi + rad = r * math.exp(-k * t) + pts.append((c[0] + rad * math.cos(a0 + sgn * t), c[1] + rad * math.sin(a0 + sgn * t))) + neonline(col, pts, 0.55 * min(1, r / 3) + 0.15, 0.08) + dot(col, pts[-1], max(0.28, r * 0.09)) + +def sparkle(c, r, col): + ph = rng.uniform(0, math.pi / 4) + for k in range(4): + a = ph + k * math.pi / 2 + L = r if k % 2 == 0 else r * 0.6 + tip = (c[0] + L * math.cos(a), c[1] + L * math.sin(a)) + neon(col, ribbon([c, ((c[0] + tip[0]) / 2, (c[1] + tip[1]) / 2), tip], lambda u: 0.5 * min(1, r / 2.5) * (1 - u) ** 1.4 + 0.02)) + dot("neonwhite", c, max(0.22, r * 0.1)) + +def place(c, r): + """Choose a motif for a disc of radius r; return (colour, drawn radius, + draw). Drawing waits until the vine is known, so a motif the vine could + not reach is never drawn. Discs too small for a flower are junctions of + the vine and draw nothing: the scrolls carry the small-scale detail.""" + col = pick() + if r >= 3.0: + return col, r, lambda: rosette(c, r, col) + return col, 0.0, lambda: None + +# ---------------------------------------------------------------- visitors +visitors = [] # (cx, cy, r, file) +for vf in spec.get("visitors", []): + i, j = np.unravel_index(np.argmax(free), free.shape) + r = min(float(free[i, j]), 15.0) + if r < 4.0: + print(f"left out: visitor {vf}: no free disc wider than {2 * r:.0f} mm") + continue + c = (float(gx[j]), float(gy[i])) + visitors.append((c[0], c[1], r, vf)) + np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - 1.0, out=free) + +# ---------------------------------------------------------------- 1. motifs +nodes = [] # (cx, cy, drawn radius, colour) +draws = [] +RMAX = 8.5 +while True: + i, j = np.unravel_index(np.argmax(free), free.shape) + r = float(free[i, j]) + if r < 1.0: break + r = min(r, RMAX) + c = (float(gx[j]), float(gy[i])) + col, rd, draw = place(c, r * 0.92) # drawn a hair smaller than its disc + draws.append(draw) + # flowers keep a stem's length apart, so the vine between them shows + pad = 1.8 if rd >= 3.0 else 0.5 + np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - pad, out=free) + nodes.append((c[0], c[1], rd, col)) + +# ---------------------------------------------------------------- 2. the tree +# Kruskal's minimum spanning tree over every pair of motifs a clear arc can +# join, shortest gap first. A tree, so the page is one plant and no gutter +# holds two parallel stems. It fixes topology only; the vine is drawn in 3. +parent = list(range(len(nodes))) +def root(a): + while parent[a] != a: + parent[a] = parent[parent[a]]; a = parent[a] + return a + +cands = [] +for a in range(len(nodes)): + ax, ay, ar, _ = nodes[a] + for b in range(a + 1, len(nodes)): + bx, by, br, _ = nodes[b] + gap = math.hypot(bx - ax, by - ay) - ar - br + if 0.4 <= gap <= 24: cands.append((gap, a, b)) +cands.sort() +edges = [] +for gap, a, b in cands: + if root(a) == root(b): continue + ax, ay, ar, _ = nodes[a]; bx, by, br, _ = nodes[b] + ang = math.atan2(by - ay, bx - ax) + p0 = (ax + ar * math.cos(ang), ay + ar * math.sin(ang)) + p1 = (bx - br * math.cos(ang), by - br * math.sin(ang)) + if any(all(clear_at(*q) > 0.4 for q in arc_pts(p0, p1, bend, 12)) for bend in (0.0, 0.1, -0.1)): + edges.append((a, b)); parent[root(a)] = root(b) + +# One plant: whatever the tree could not reach is left out, not drawn alone. +from collections import Counter +sizes = Counter(root(k) for k in range(len(nodes))) +main = sizes.most_common(1)[0][0] +for g, n in sizes.most_common()[1:]: + print("left out:", n, "motif(s) near", [(round(nodes[k][0]), round(nodes[k][1])) for k in range(len(nodes)) if root(k) == g][:3]) +keep = [root(k) == main for k in range(len(nodes))] +for k in range(len(nodes)): + if keep[k]: draws[k]() +adj = {k: [] for k in range(len(nodes)) if keep[k]} +for a, b in edges: + if keep[a]: adj[a].append(b); adj[b].append(a) + +# ---------------------------------------------------------------- 3. the vine +# The tree is cut into as few long paths as it allows: each walk goes on to +# the neighbour that turns least, and stops only at a leaf or a hairpin. +# Every path is one Catmull-Rom spline in one colour, so the vine is +# tangent-continuous; a branch path borrows its parent's previous node as a +# phantom control point, so it leaves the parent tangentially, like a shoot. +def turn(p, q, r): + a1 = math.atan2(q[1] - p[1], q[0] - p[0]); a2 = math.atan2(r[1] - q[1], r[0] - q[0]) + return abs((a2 - a1 + math.pi) % (2 * math.pi) - math.pi) + +P = lambda k: nodes[k][:2] +used = set() +paths = [] # (phantom node or None, [node ids]) +def walk(phantom, start, first): + seq = [start, first]; used.add(frozenset((start, first))) + while True: + cur, prev = seq[-1], seq[-2] + nxt = [n for n in adj[cur] if frozenset((cur, n)) not in used] + if not nxt: break + n = min(nxt, key=lambda n: turn(P(prev), P(cur), P(n))) + if turn(P(prev), P(cur), P(n)) > math.radians(100): break + seq.append(n); used.add(frozenset((cur, n))) + paths.append((phantom, seq)) + +leaves = [k for k in adj if len(adj[k]) == 1] +start = max(leaves, key=lambda k: nodes[k][0] - nodes[k][1]) if leaves else next(iter(adj)) +walk(None, start, adj[start][0]) +pending = True +while pending: + pending = False + for phantom_, seq in list(paths): + for idx, k in enumerate(seq): + for n in adj[k]: + if frozenset((k, n)) in used: continue + # phantom: the path neighbour of k the branch continues most smoothly from + nbrs = [seq[i] for i in (idx - 1, idx + 1) if 0 <= i < len(seq)] + ph = min(nbrs, key=lambda m: turn(P(m), P(k), P(n))) if nbrs else None + walk(ph, k, n); pending = True + +def catmull(ctrl, per): + out = [] + for i in range(1, len(ctrl) - 2): + p0, p1, p2, p3 = ctrl[i - 1], ctrl[i], ctrl[i + 1], ctrl[i + 2] + for k in range(per): + t = k / per; t2 = t * t; t3 = t2 * t + out.append(tuple(0.5 * (2 * p1[j] + (-p0[j] + p2[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2 + + (-p0[j] + 3 * p1[j] - 3 * p2[j] + p3[j]) * t3) for j in (0, 1))) + out.append(ctrl[-2]) + return out + +def mirror(a, b): # a phantom beyond b, continuing a -> b straight on + return (2 * b[0] - a[0], 2 * b[1] - a[1]) + +def resample(curve, ds): + out = [curve[0]]; carry = 0.0 + for p, q in zip(curve, curve[1:]): + seg = math.dist(p, q); t = ds - carry + while t <= seg: + out.append((p[0] + (q[0] - p[0]) * t / seg, p[1] + (q[1] - p[1]) * t / seg)); t += ds + carry = seg - (t - ds) + if math.dist(out[-1], curve[-1]) > 1e-6: out.append(curve[-1]) + return out + +def heading(curve, i): + a = curve[max(i - 1, 0)]; b = curve[min(i + 1, len(curve) - 1)] + return math.atan2(b[1] - a[1], b[0] - a[0]) + +def wavy(centre, ph0): + """Lay a sine wave along a smooth centreline. Wavelength and amplitude + follow the room at each point (phase is integrated, so a changing + wavelength never kinks), and the wave fades to nothing at both ends so + paths meet their junctions exactly. Returns the curve and its crests as + (index, side), side +1 for a crest on the left of travel.""" + pts = resample(centre, 0.2) + L = 0.2 * (len(pts) - 1) + out, crests = [], [] + ph = ph0; last = math.cos(ph) + for i, p in enumerate(pts): + c = clear_at(*p) + s = 0.2 * i + fade = max(0.0, min(1.0, s / 1.6, (L - s) / 1.6)) + amp = max(0.0, min(1.4, 0.55 * (c - 0.45))) * fade + th = heading(pts, i) + off = amp * math.sin(ph) + out.append((p[0] - math.sin(th) * off, p[1] + math.cos(th) * off)) + ph += 2 * math.pi * 0.2 / max(5.0, min(11.0, 4.2 * c)) + now = math.cos(ph) + if fade > 0.6 and amp > 0.2 and (last > 0) != (now > 0): + crests.append((i, 1 if last > 0 else -1)) + last = now + return out, crests +pos = {k: P(k) for k in adj} + +room = clear + 0.5 # scrolls are hairlines: they may come closer to a panel than the vine +def mark(pts, rad): + """Take a band of radius rad around a polyline out of the free room.""" + dense = [] + for p, q in zip(pts, pts[1:]): + n = max(1, int(math.dist(p, q) / 0.3)) + dense += [(p[0] + (q[0] - p[0]) * t / n, p[1] + (q[1] - p[1]) * t / n) for t in range(n)] + dense.append(pts[-1]) + k = int(math.ceil((rad + 0.5) / STEP)) + for x, y in dense: + i, j = int(round(y / STEP)), int(round(x / STEP)) + win = (slice(max(i - k, 0), i + k + 1), slice(max(j - k, 0), j + k + 1)) + np.minimum(room[win], np.hypot(X[win] - x, Y[win] - y) - rad, out=room[win]) +def room_at(x, y): + i = int(round(y / STEP)); j = int(round(x / STEP)) + if not (0 <= i < room.shape[0] and 0 <= j < room.shape[1]): return -1 + return room[i, j] + +for vx, vy, vr, _ in visitors: + mark([(vx, vy)], vr) + +vines = [] # (pts, colour, w0, w1) +for n_path, (phantom, seq) in enumerate(paths): + pts_n = [pos[k] for k in seq] + head = pos[phantom] if phantom is not None else mirror(pts_n[1], pts_n[0]) + ctrl = [head] + pts_n + [mirror(pts_n[-2], pts_n[-1])] + curve, crests = wavy(catmull(ctrl, 8), rng.uniform(0, 2 * math.pi)) + col = nodes[seq[0]][3] if phantom is None else pick() + w0 = 0.5 if phantom is None else 0.38 + vines.append((curve, col, w0, 0.2, crests)) + +for k in adj: + if nodes[k][2] > 0: mark([P(k)], nodes[k][2] + 0.3) +for curve, col, w0, w1, crests in vines: + mark(curve, w0 / 2 + 0.25) + +# ---------------------------------------------------------------- 4. scrolls +# The fractal part, after the rinceau: a scroll leaves its parent almost +# tangentially and winds into a logarithmic spiral, because its curvature +# grows exponentially along it. Partway along, it sheds smaller scrolls that +# wind the other way, and those shed smaller ones again: nested S-curves, all +# tangent-continuous with what they grow from. Each is kept only where it fits. +scrolls = [] # (pts, colour, w0) +leaves_out = [] + +def scroll_pts(base, theta, turn_dir, L, ds=0.18): + R0 = 0.5 * L + g = math.log(10) + pts = [base]; th = theta; x, y = base + s = 0.0; heads = [theta] + while s < L: + k = math.exp(g * s / L) / R0 + th += turn_dir * k * ds + x += ds * math.cos(th); y += ds * math.sin(th) + pts.append((x, y)); heads.append(th); s += ds + return pts, heads + +def scroll(base, theta, turn_dir, L, w0, depth, col, skip=0.55): + fit = None + for scale in (1.0, 0.75, 0.55, 0.4): + Ls = L * scale + if Ls < 0.9: return False + for td in (turn_dir, -turn_dir) if depth == 3 else (turn_dir,): + pts, heads = scroll_pts(base, theta, td, Ls) + wmax = w0 / 2 + 0.08 + if all(room_at(*q) > wmax for q in pts if math.dist(q, base) > skip): + fit = (pts, heads, Ls, td); break + if fit: break + if not fit: return False + pts, heads, Ls, td = fit + width = lambda u: w0 * (1 - 0.85 * u) + 0.035 + scrolls.append((pts[::2] + [pts[-1]], col, w0)) + mark(pts[1:], w0 / 2 + 0.2) + if rng.random() < 0.35: + dot(rng.choice(("neonwhite", col)), pts[-1], 0.2) + if depth > 1: + n = len(pts) + for frac in (0.2, 0.42): + i = int(frac * n) + # peel off on the outside of the bend and wind the other way + scroll(pts[i], heads[i] - td * math.radians(18), -td, Ls * 0.5, width(i / n) * 0.85, depth - 1, col) + i = int(0.62 * n) + if rng.random() < 0.5: + la = heads[i] + td * math.radians(rng.uniform(35, 55)) + ll = min(1.6, Ls * 0.3) + lt = (pts[i][0] + ll * math.cos(la), pts[i][1] + ll * math.sin(la)) + if room_at(*lt) > 0.15 and room_at((pts[i][0] + lt[0]) / 2, (pts[i][1] + lt[1]) / 2) > 0.3: + leaves_out.append((col, vesica(pts[i], la, ll, 0.32))); mark([pts[i], lt], 0.35) + return True + +order = list(range(len(vines))) +rng.shuffle(order) +for n_path in order: + curve, col, w0, w1, crests = vines[n_path] + # Rinceau: at each crest a scroll carries straight on and winds back + # toward the stem, into the bay the wave curves around. + for i, side in crests: + th = heading(curve, i) + # a scroll the size of the bay it winds into + amp = max(0.0, min(1.4, 0.55 * (clear_at(*curve[i]) - 0.45))) + L = max(1.8, min(7.0, 1.2 + 3.4 * amp)) + # it leaves at an angle: a scroll tangent to its parent runs alongside + # it for a millimetre and reads as a thickening, not a branch + scroll(curve[i], th + side * math.radians(22), -side, L, 0.3, 3, col, skip=0.9) + # and the vine's end does not stop: it winds up into a scroll of its own + th = heading(curve, len(curve) - 1) + L = max(2.0, min(6.0, 2.0 * clear_at(*curve[-1]))) + scroll(curve[-1], th, rng.choice((-1, 1)), L, w1 + 0.05, 3, col) + +# ---------------------------------------------------------------- 5. budding +# Growing outward from the vine always leaves holes it happened to miss. +# So the gaps call the growth instead, florilegium's grown loop: take the +# largest free disc left, and bud a branch into it from the existing stroke +# that reaches it most smoothly; the branch winds up into a spiral inside the +# disc. Every branch becomes somewhere later branches can bud from, so big +# holes fill first and small ones last, as deep as the space allows. +anchors = [] # (x, y, heading, width, colour) +def add_anchors(pts, col, wfun): + n = len(pts) + for i in range(0, n, 2): + anchors.append((pts[i][0], pts[i][1], heading(pts, i), wfun(i / max(n - 1, 1)), col)) +for curve, col, w0, w1, crests in vines: + add_anchors(curve, col, lambda u, w0=w0, w1=w1: w0 + (w1 - w0) * u) +for pts, col, w0 in scrolls: + add_anchors(pts, col, lambda u, w0=w0: w0 * (1 - 0.85 * u) + 0.035) + +def bezier(p0, p1, p2, p3, n): + out = [] + for k in range(n + 1): + t = k / n; a = (1 - t) ** 3; b = 3 * (1 - t) ** 2 * t; c = 3 * (1 - t) * t * t; d = t ** 3 + out.append((a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1])) + return out + +def bud_into(cx, cy, r): + """Try to grow a branch from a nearby stroke into the disc (c, r).""" + A = np.array([(a[0], a[1]) for a in anchors]) + d = np.hypot(A[:, 0] - cx, A[:, 1] - cy) + near = np.argsort(d)[:24] + tries = [] + for k in near: + ax, ay, th, aw, col = anchors[k] + to_c = math.atan2(cy - ay, cx - ax) + for flip in (0, math.pi): # a stroke can be budded from either way along it + dev = (to_c - (th + flip) + math.pi) % (2 * math.pi) - math.pi + if abs(dev) > math.radians(115): continue + tries.append((d[k] * (1 + 0.9 * abs(dev) / math.pi), k, th + flip, dev)) + tries.sort() + for score, k, th, dev in tries[:16]: + ax, ay, _, aw, col = anchors[k] + # leave the parent at no more than ~50 degrees, toward the disc + t0 = th + max(-1, min(1, dev / math.radians(50))) * math.radians(min(50, abs(math.degrees(dev)))) + w = max(0.06, min(aw * 0.8, 0.1 + 0.11 * r)) + rho0 = 0.72 * r + u = math.atan2(cy - ay, cx - ax) + for sense in (1 if dev > 0 else -1, -1 if dev > 0 else 1): + phi0 = u - sense * math.pi / 2 + pe = (cx + rho0 * math.cos(phi0), cy + rho0 * math.sin(phi0)) + L = math.dist((ax, ay), pe) + # a bud reaches its gap from close by; a long stalk reads as a stray wire + if L < 0.3 or L > 1.0 + 2.5 * r: continue + kk = L / 3 + stalk = bezier((ax, ay), (ax + kk * math.cos(t0), ay + kk * math.sin(t0)), + (pe[0] - kk * math.cos(u), pe[1] - kk * math.sin(u)), pe, max(4, int(L / 0.25))) + spiral = [] + for i in range(1, 25): + t = i / 24 + ph = phi0 + sense * 2.3 * math.pi * t + rho = rho0 * math.exp(-math.log(7) * t) + spiral.append((cx + rho * math.cos(ph), cy + rho * math.sin(ph))) + pts = stalk + spiral + if all(room_at(*q) > w / 2 + 0.04 for q in pts if math.dist(q, (ax, ay)) > max(0.45, aw)): + return pts, col, w + return None + +buds = 0 +while True: + i, j = np.unravel_index(np.argmax(room), room.shape) + r = float(room[i, j]) + if r < 0.42: break + cx, cy = float(gx[j]), float(gy[i]) + got = bud_into(cx, cy, min(r, 6.0)) + if got is None: + # nothing can reach this hole: close its middle so the loop moves on + mark([(cx, cy)], min(r, 6.0) * 0.35) + continue + pts, col, w = got + scrolls.append((pts, col, w)) + mark(pts[1:], w / 2 + 0.16) + add_anchors(pts, col, lambda u, w=w: w * (1 - 0.85 * u) + 0.035) + if r > 0.9 and rng.random() < 0.4: + dot(rng.choice(("neonwhite", col)), pts[-1], min(0.25, 0.12 + 0.05 * r)) + buds += 1 + +# ---------------------------------------------------------------- strokes out +stem_out = [] +for curve, col, w0, w1, crests in vines: + stem_out.append((col, ribbon(curve[::2] + [curve[-1]], lambda u, w0=w0, w1=w1: w0 + (w1 - w0) * u))) +for pts, col, w0 in scrolls: + stem_out.append((col, ribbon(pts, lambda u, w0=w0: w0 * (1 - 0.85 * u) + 0.035))) +stem_out += leaves_out +nodes = [n for n, kk in zip(nodes, keep) if kk] + +# ---------------------------------------------------------------- title swash +# One long weighted stroke under the title, ending in a curl. +swash_out = [] +if spec.get("swash"): + sx0, sy0, sx1, sy1 = spec["swash"] + swash = arc_pts((sx0, sy0), (sx1, sy1), -0.012, 60) + swash_out = [ribbon(swash, lambda u: 0.15 + 0.85 * math.sin(math.pi * u ** 0.8) ** 1.5)] + +# ---------------------------------------------------------------- write +lines = [f"% generated by {Path(__file__).name}, seed {SEED}. Do not edit; run the script."] +lines.append("\\begin{scope}% stems and leaves") +for col, poly in stem_out: + lines.append(f"\\neonfill{{{col}}}{{75}}{{{path(poly)}}}") +lines.append("\\end{scope}") +lines.append("\\begin{scope}% panel frames") +for n, x, y, w, h, c, m, (tl, tr, br, bl) in panels: + p = (f"({x + bl:.2f},{y:.2f}) -- ({x + w - br:.2f},{y:.2f}) arc[start angle=-90, end angle=0, radius={br:.2f}] " + f"-- ({x + w:.2f},{y + h - tr:.2f}) arc[start angle=0, end angle=90, radius={tr:.2f}] " + f"-- ({x + tl:.2f},{y + h:.2f}) arc[start angle=90, end angle=180, radius={tl:.2f}] " + f"-- ({x:.2f},{y + bl:.2f}) arc[start angle=180, end angle=270, radius={bl:.2f}] -- cycle") + lines.append(f"\\neonframe{{{c}}}{{{p}}}") +lines.append("\\end{scope}") +for poly in swash_out: + lines.append(f"\\neonfill{{neonorange}}{{90}}{{{path(poly)}}}") +lines.extend(out) +for vx, vy, vr, vf in visitors: + lines.append(f"\\visitor{{{vx:.2f}}}{{{vy:.2f}}}{{{2 * vr * 0.95:.2f}}}{{{rng.uniform(-18, 18):.1f}}}{{{vf}}}") +for n, x, y, w, h, c, m, r in panels: + lines.append(f"\\panel{{{x}}}{{{y}}}{{{w}}}{{{h}}}{{{c}}}{{{m}}}") +dst = Path(sys.argv[2]) +dst.write_text("\n".join(lines) + "\n", encoding="utf8") +print(f"seed {SEED}: {len(nodes)} motifs, {len(paths)} vine paths, {len(scrolls)} scrolls ({buds} budded) -> {dst.name}") diff --git a/tools/bloom-sheet/page.tex b/tools/bloom-sheet/page.tex new file mode 100644 index 0000000..e445ae9 --- /dev/null +++ b/tools/bloom-sheet/page.tex @@ -0,0 +1,108 @@ +% A bloom sheet: one A4 page, GameCult navy with a neon garden. +% bloom_sheet.py fills the three @@ markers; grow.py writes layer.tex. +% Every panel is a fitting tcolorbox, so its type shrinks to whatever room it gets. +\documentclass{article} +\usepackage[paperwidth=210mm,paperheight=297mm,margin=0mm]{geometry} +\usepackage{fontspec} +\usepackage{xcolor} +\usepackage{tikz} +\usepackage{graphicx} +\usepackage{array} +\usepackage[most]{tcolorbox} +\tcbuselibrary{fitting} + +% ---------- GameCult palette (gamecult-site quartz.config.ts), plus neon ---------- +\definecolor{ground}{HTML}{07111A} +\definecolor{groundtop}{HTML}{03070D} +\definecolor{groundfoot}{HTML}{09141F} +\definecolor{panelbg}{HTML}{0A1722} +\definecolor{heading}{HTML}{EEF5FF} +\definecolor{body}{HTML}{B7C7D9} +\definecolor{muted}{HTML}{7488A0} +\definecolor{neonorange}{HTML}{FF8A2A} +\definecolor{neonsky}{HTML}{59B7FF} +\definecolor{neonviolet}{HTML}{8F84FF} +\definecolor{neonrose}{HTML}{FF6FAE} % not a brand colour: the "cozy" note +\definecolor{neonwhite}{HTML}{EEF5FF} +\colorlet{accent}{neonorange} + +@@FONTS@@ + +% ---------- Neon primitives, called by the generated layer ---------- +% A filled weighted stroke with two halos; #2 is the percent of colour the core keeps. +\newcommand{\neonfill}[3]{\path[ + preaction={draw=#1, line width=1.7pt, draw opacity=0.09, line join=round}, + preaction={draw=#1, line width=0.8pt, draw opacity=0.18, line join=round}, + fill=#1!#2!white] #3;} +\newcommand{\neondot}[4]{% + \fill[#1, opacity=0.10] (#2,#3) circle ({2.4*#4}); + \fill[#1, opacity=0.22] (#2,#3) circle ({1.5*#4}); + \fill[#1!55!white] (#2,#3) circle (#4);} +\newcommand{\petalwash}[2]{\fill[#1, opacity=0.14] #2;} +% A visitor: the caller's SVG, already a PDF, in a disc the plant grew around. +% \visitor{x}{y}{size}{angle}{file} +\newcommand{\visitor}[5]{\node[inner sep=0pt, rotate=#4] at (#1,#2) + {\includegraphics[width=#3mm, height=#3mm, keepaspectratio]{#5}};} +\newcommand{\neonframe}[2]{% + \fill[panelbg, opacity=0.88] #2; + \draw[#1, line width=3pt, opacity=0.06] #2; + \draw[#1, line width=1.4pt, opacity=0.14] #2; + \draw[#1!70!white, line width=0.45pt] #2;} + +% ---------- Text atoms ---------- +% All of these live in the preamble: a macro with parameters defined inside a +% panel's argument breaks tcolorbox fit ("Illegal parameter number"). Nothing +% here sets an absolute size for body text: fit scales the base size and size +% commands escape it. \sub is the one relative label. +\newcommand{\head}[1]{{\labelfont\color{accent}\MakeUppercase{#1}}\par\vspace{0.25em}} +\newcommand{\sub}[1]{\par\vspace{0.25em}{\labelfont\color{muted}\MakeUppercase{#1}}\par} +% \leavevmode: a \color at the start of a table cell otherwise drops the cell a line. +\DeclareRobustCommand{\code}[1]{\leavevmode{\numfont\color{neonsky}#1}} +\DeclareRobustCommand{\strong}[1]{\leavevmode{\bfseries\color{heading}#1}} +\DeclareRobustCommand{\soft}[1]{\leavevmode{\speakfont\color{heading}#1}} +\DeclareRobustCommand{\faint}[1]{\leavevmode{\color{muted}#1}} +% One list item: depth, its marker, its text. +\newcommand{\li}[3]{\par{\leftskip=\dimexpr #1em - 1em\relax\hangindent=1em\hangafter=1\noindent\makebox[1em][l]{\color{accent}#2}#3\par}} +\newcommand{\quoted}[1]{\par{\leftskip=0.8em\color{heading}#1\par}} +\newcommand{\codeline}[1]{\par{\numfont\color{neonsky}\hangindent=1em\hangafter=1\noindent#1\par}} +\newcommand{\hairline}{\par\vspace{0.2em}{\color{muted}\rule{\linewidth}{0.2pt}}\par\vspace{0.2em}} +\newcommand{\cell}[1]{\raggedright\arraybackslash#1} + +% ---------- One panel ---------- +% \panel{x}{y}{w}{h}{colour}{content}, mm from the page's bottom left. +% The frame is drawn by the generated layer; this only sets the text. +% Padding 3.1mm keeps text clear of corner radii up to ~10mm. +\newcommand{\panel}[6]{% + \node[anchor=south west, inner sep=0pt] at (#1mm,#2mm) {% + \begin{tcolorbox}[blank, fit, width=#3mm, height=#4mm, + left=3.1mm, right=3.1mm, top=3mm, bottom=2.6mm, + fontupper=\color{body}\linespread{1.02}\selectfont, + before upper={\colorlet{accent}{#5}\parindent0pt\parskip0.14em\raggedright}] + #6 + \end{tcolorbox}};} + +@@CONTENTS@@ + +\pagecolor{ground} +\pagestyle{empty} +\begin{document} +\begin{tikzpicture}[remember picture, overlay, x=1mm, y=1mm] +\begin{scope}[shift={(current page.south west)}] + +% ---- ground: the site's vertical gradient and its three coloured radials ---- +% Radials are stacks of faint circles; one opaque-ish circle shows a hard edge. +\shade[top color=groundtop, bottom color=groundfoot, middle color=ground] (0,0) rectangle (210,297); +\foreach \i in {1,...,32} { + \fill[neonviolet, opacity=0.0060] (38,267) circle ({87*\i/32}); + \fill[neonsky, opacity=0.0050] (109,297) circle ({100*\i/32}); + \fill[neonorange, opacity=0.0065] (164,255) circle ({65*\i/32}); +} +\foreach \i in {1,...,24} {\fill[neonrose, opacity=0.0028] (40,30) circle ({60*\i/24});} + +\input{layer.tex} + +@@TITLE@@ + +\end{scope} +\end{tikzpicture} +\end{document} From 0c3f3e483143b0c9c9f85adb6542f911ceab6f9a Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 5 Nov 2026 15:36:43 +0100 Subject: [PATCH 03/44] bloom-sheet: visitors are drawn with the plant's pen, not pasted in An SVG used to arrive as a PDF on top of the page, in its own colours and line quality. It now parses into marks: stroked shapes go through ribbon and neonfill as weighted lines, a fill under a stroke becomes a petal's wash, and bare fills become solid neon. Colours snap to the palette and near-black paints the ground, except in an all-black drawing, which would vanish. All of it must touch, a closed outline counting as covering its inside. Text, images and gradients are refused before the two minute render. rsvg-convert and graphicx are gone; python3-svgelements and python3-shapely replace them. Co-Authored-By: Claude Opus 5.5 --- tools/INDEX.md | 2 +- tools/bloom-sheet/bloom_sheet.py | 109 ++++++++++++++++++++++++++--- tools/bloom-sheet/grow.py | 37 +++++++--- tools/bloom-sheet/page.tex | 5 -- tools/bloom-sheet/test_visitors.py | 66 +++++++++++++++++ 5 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 tools/bloom-sheet/test_visitors.py diff --git a/tools/INDEX.md b/tools/INDEX.md index f4a5a3b..b85a7b3 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -17,4 +17,4 @@ gets a Soul pass before merge. | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | -| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | +| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py index 38c55cb..d84eccd 100755 --- a/tools/bloom-sheet/bloom_sheet.py +++ b/tools/bloom-sheet/bloom_sheet.py @@ -9,12 +9,19 @@ The plant grows through whatever the cards leave, and --visitor perches your SVGs in the garden. Output is PDF or PNG, by the extension of -o. Takes about two minutes: the growth, then LuaLaTeX twice. +A visitor is drawn with the plant's own pen, not pasted in. Stroked shapes +become weighted neon lines, and a fill under a stroke becomes a faint wash, as +on a petal. Shapes with only a fill become solid neon. Colours snap to the +sheet's palette, near-black paints the ground (a cutout), and opacity is +ignored. All of it must touch, reading as one thing; a closed outline counts +as covering its inside. Text, images and gradients are refused. + Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox, -pdftoppm for PNG, rsvg-convert for visitors, and the Ubuntu, IBM Plex Mono and -Montserrat fonts. +pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for +visitors python3-svgelements and python3-shapely. Everything from the document is escaped, and LaTeX runs with no shell escape. """ -import argparse, json, os, random, re, shutil, subprocess, sys, tempfile, zlib +import argparse, colorsys, json, math, os, random, re, shutil, subprocess, sys, tempfile, zlib from pathlib import Path from markdown_it import MarkdownIt @@ -229,12 +236,100 @@ def macro(k): return r"\content" + name +PALETTE_HUES = {"neonorange": 28, "neonsky": 205, "neonviolet": 244, "neonrose": 336} + + +def neon_colour(colour): + """The sheet's colour nearest an SVG colour; near-black is the page's ground.""" + h, s, v = colorsys.rgb_to_hsv(colour.red / 255, colour.green / 255, colour.blue / 255) + if v < 0.25: return "ground" + if s < 0.25: return "neonwhite" + return min(PALETTE_HUES, key=lambda c: abs((h * 360 - PALETTE_HUES[c] + 180) % 360 - 180)) + + +def read_visitor(svg): + """Read an SVG into marks the plant's pen can draw, in paint order, centred, + y up, and scaled so everything lies within radius 1. Exits with the reason + if the drawing can't be one. A mark is {"colour", "rings"} for a solid or + {"colour", "pts", "width", "closed", "wash"} for a line.""" + from shapely.geometry import LineString, Polygon + from shapely.ops import unary_union + from svgelements import SVG, Close, Line, Move, Path as SvgPath, Shape, Text + from svgelements import Image as SvgImage + + def refuse(why): + sys.exit(f"bloom-sheet: visitor {svg}: {why}") + + def paint(element, attr): + """The element's fill or stroke, or None. svgelements reads url(#...) as black.""" + if "url(" in str(element.values.get(attr, "")): + refuse(f"its {attr} is a gradient or pattern; use one flat colour per shape") + c = getattr(element, attr) + return c if c is not None and c.value is not None else None + + raw = [] # (fill, stroke, stroke width, [(points, closed)]) + for e in SVG.parse(svg, reify=True).elements(): + if isinstance(e, Text): refuse("it has text; draw it as paths") + if isinstance(e, SvgImage): refuse("it has an embedded image; draw it as paths") + if not isinstance(e, Shape): continue + fill, stroke = paint(e, "fill"), paint(e, "stroke") + if stroke is not None and not e.implicit_stroke_width: stroke = None + if fill is None and stroke is None: continue + subs = [] + for seg in SvgPath(e): + if isinstance(seg, Move): + subs.append([[(seg.end.x, -seg.end.y)], False]) + elif isinstance(seg, Close): + subs[-1][1] = True + else: + n = 1 if isinstance(seg, Line) else 24 + subs[-1][0].extend((p.x, -p.y) for p in (seg.point(k / n) for k in range(1, n + 1))) + subs = [(pts, closed or math.dist(pts[0], pts[-1]) < 1e-6) for pts, closed in subs if len(pts) > 1] + if subs: raw.append((fill, stroke, float(e.implicit_stroke_width or 0), subs)) + if not raw: refuse("nothing in it has a fill or a stroke") + + every = [p for *_, subs in raw for pts, _ in subs for p in pts] + xs, ys = [p[0] for p in every], [p[1] for p in every] + cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2 + radius = max(math.dist(p, (cx, cy)) for p in every) or 1.0 + unit = lambda pts: [(round((x - cx) / radius, 5), round((y - cy) / radius, 5)) for x, y in pts] + + # all dark would be all cutout: a plain black icon is meant to be seen + colours = [c for f, s, *_ in raw for c in (f, s) if c is not None] + ink = (lambda c: "neonwhite") if all(neon_colour(c) == "ground" for c in colours) else neon_colour + + marks, covers = [], [] + touch = radius * 0.005 + for fill, stroke, width, subs in raw: + if fill is not None: + area = None + for pts, _ in subs: + if len(pts) > 2: + poly = Polygon(pts).buffer(0) + area = poly if area is None else area.symmetric_difference(poly) + if area is not None: covers.append(area.buffer(touch)) + if stroke is None: + marks.append({"colour": ink(fill), "rings": [unit(pts) for pts, _ in subs]}) + continue + for pts, closed in subs: + marks.append({"colour": ink(stroke), "pts": unit(pts), "width": round(width / radius, 5), "closed": closed, + "wash": ink(fill) if fill is not None and closed else None}) + covers.append(Polygon(pts).buffer(touch) if closed and len(pts) > 3 + else LineString(pts).buffer(width / 2 + touch)) + whole = unary_union(covers) + if whole.geom_type != "Polygon": + refuse(f"it comes apart into {len(whole.geoms)} separate pieces; " + "join them, or pass each as its own --visitor") + return marks + + def build(args, source): title, subtitle, cards = parse(source, args.level) if args.title is not None: title = args.title if args.subtitle is not None: subtitle = args.subtitle if not cards: sys.exit("bloom-sheet: nothing to put on the page: the document has no headings or text") + visitors = [read_visitor(svg) for svg in args.visitor] # refuse before the two minutes seed = args.seed if args.seed is not None else zlib.crc32(source.encode("utf8")) % 100000 rng = random.Random(seed) @@ -267,12 +362,8 @@ def build(args, source): with tempfile.TemporaryDirectory(prefix="bloom-sheet-") as tmp: d = Path(tmp) - names = [] - for n, svg in enumerate(args.visitor): - names.append(f"visitor{n}.pdf") - run(["rsvg-convert", "-f", "pdf", "-o", str(d / names[-1]), str(Path(svg).resolve())], d, args.verbose) (d / "spec.json").write_text(json.dumps( - {"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": names}), encoding="utf8") + {"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": visitors}), encoding="utf8") page = (HERE / "page.tex").read_text(encoding="utf8") for mark, value in (("@@FONTS@@", FONTS), ("@@CONTENTS@@", "\n".join(contents)), ("@@TITLE@@", title_tex)): page = page.replace(mark, value) @@ -328,7 +419,7 @@ def main(): ap.add_argument("--title", help="page title, instead of the document's first top-level heading; empty for none") ap.add_argument("--subtitle", help="small line under the title") ap.add_argument("--level", type=int, default=6, help="headings deeper than this are labels inside a card, not cards (default: every heading is a card)") - ap.add_argument("--visitor", action="append", default=[], metavar="SVG", help="an SVG to perch in the garden; repeat for more (about three suit a page)") + ap.add_argument("--visitor", action="append", default=[], metavar="SVG", help="an SVG of strokes or one solid shape to perch in the garden; repeat for more (about three suit a page)") ap.add_argument("--seed", type=int, help="plant seed (default: from the document, so a sheet rebuilds the same)") ap.add_argument("--dpi", type=int, default=200, help="PNG resolution (default 200)") ap.add_argument("-v", "--verbose", action="store_true") diff --git a/tools/bloom-sheet/grow.py b/tools/bloom-sheet/grow.py index 44f645a..db9730a 100644 --- a/tools/bloom-sheet/grow.py +++ b/tools/bloom-sheet/grow.py @@ -23,10 +23,11 @@ florilegium's `ribbon` (ink.lua, MIT, Kevin Raymond), ported to Python. Run: python grow.py spec.json layer.tex spec.json: {"seed": 11, "panels": [[name, x, y, w, h, colour, content macro], ...], "title_box": [x, y, w, h] or null, "swash": [x0, y0, x1, y1] or null, - "visitors": [file, ...]} + "visitors": [[mark, ...], ...]} All lengths are mm from the page's bottom left. A visitor is florilegium's idea with the caller's drawing: it takes the largest disc free before the plant -grows, and the plant grows around it. +grows, and the plant grows around it. bloom_sheet.py's read_visitor makes the +marks, in a unit disc; they are drawn with the plant's own pen. """ import json, math, random, sys from pathlib import Path @@ -214,17 +215,37 @@ def place(c, r): return col, 0.0, lambda: None # ---------------------------------------------------------------- visitors -visitors = [] # (cx, cy, r, file) -for vf in spec.get("visitors", []): +visitors = [] # (cx, cy, r, marks) +for number, marks in enumerate(spec.get("visitors", []), 1): i, j = np.unravel_index(np.argmax(free), free.shape) r = min(float(free[i, j]), 15.0) if r < 4.0: - print(f"left out: visitor {vf}: no free disc wider than {2 * r:.0f} mm") + print(f"left out: visitor {number}: no free disc wider than {2 * r:.0f} mm") continue c = (float(gx[j]), float(gy[i])) - visitors.append((c[0], c[1], r, vf)) + visitors.append((c[0], c[1], r, marks)) np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - 1.0, out=free) +def draw_visitor(cx, cy, r, marks): + a = math.radians(rng.uniform(-18, 18)) + spot = lambda pts: [(cx + r * 0.95 * (x * math.cos(a) - y * math.sin(a)), + cy + r * 0.95 * (x * math.sin(a) + y * math.cos(a))) for x, y in pts] + for m in marks: + if "rings" in m: + rings = " ".join(path(spot(ring_)) for ring_ in m["rings"]) + out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{rings}}}\\end{{scope}}") + continue + pts = spot(m["pts"]) + if m["wash"]: out.append(f"\\petalwash{{{m['wash']}}}{{{path(pts)}}}") + # the caller's weight, kept within the plant's; a closed line breathes + # like a ring, an open one swells between thin ends + w = max(0.14, min(0.7, m["width"] * r * 0.95)) + if m["closed"]: + ph = rng.uniform(0, 2 * math.pi) + neon(m["colour"], ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9) + else: + neon(m["colour"], ribbon(pts, lambda u: w * (0.4 + 0.6 * math.sin(math.pi * u) ** 0.7)), 0.9) + # ---------------------------------------------------------------- 1. motifs nodes = [] # (cx, cy, drawn radius, colour) draws = [] @@ -602,9 +623,9 @@ for n, x, y, w, h, c, m, (tl, tr, br, bl) in panels: lines.append("\\end{scope}") for poly in swash_out: lines.append(f"\\neonfill{{neonorange}}{{90}}{{{path(poly)}}}") +for vx, vy, vr, marks in visitors: + draw_visitor(vx, vy, vr, marks) lines.extend(out) -for vx, vy, vr, vf in visitors: - lines.append(f"\\visitor{{{vx:.2f}}}{{{vy:.2f}}}{{{2 * vr * 0.95:.2f}}}{{{rng.uniform(-18, 18):.1f}}}{{{vf}}}") for n, x, y, w, h, c, m, r in panels: lines.append(f"\\panel{{{x}}}{{{y}}}{{{w}}}{{{h}}}{{{c}}}{{{m}}}") dst = Path(sys.argv[2]) diff --git a/tools/bloom-sheet/page.tex b/tools/bloom-sheet/page.tex index e445ae9..d38c2e8 100644 --- a/tools/bloom-sheet/page.tex +++ b/tools/bloom-sheet/page.tex @@ -6,7 +6,6 @@ \usepackage{fontspec} \usepackage{xcolor} \usepackage{tikz} -\usepackage{graphicx} \usepackage{array} \usepackage[most]{tcolorbox} \tcbuselibrary{fitting} @@ -39,10 +38,6 @@ \fill[#1, opacity=0.22] (#2,#3) circle ({1.5*#4}); \fill[#1!55!white] (#2,#3) circle (#4);} \newcommand{\petalwash}[2]{\fill[#1, opacity=0.14] #2;} -% A visitor: the caller's SVG, already a PDF, in a disc the plant grew around. -% \visitor{x}{y}{size}{angle}{file} -\newcommand{\visitor}[5]{\node[inner sep=0pt, rotate=#4] at (#1,#2) - {\includegraphics[width=#3mm, height=#3mm, keepaspectratio]{#5}};} \newcommand{\neonframe}[2]{% \fill[panelbg, opacity=0.88] #2; \draw[#1, line width=3pt, opacity=0.06] #2; diff --git a/tools/bloom-sheet/test_visitors.py b/tools/bloom-sheet/test_visitors.py new file mode 100644 index 0000000..045102d --- /dev/null +++ b/tools/bloom-sheet/test_visitors.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""What a visitor must be. Run: python3 test_visitors.py +Needs python3-svgelements and python3-shapely; no LaTeX.""" +import math, sys, tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from bloom_sheet import read_visitor + +fails = 0 +tmp = Path(tempfile.mkdtemp()) + + +def svg(body): + f = tmp / "v.svg" + f.write_text(f'{body}') + return str(f) + + +def check(name, ok): + global fails + print(("ok " if ok else "FAIL"), name) + fails += not ok + + +def refused(name, body, reason): + try: + read_visitor(svg(body)) + check(f"{name}: refused", False) + except SystemExit as e: + check(f"{name}: refused for {reason}", reason in str(e)) + + +DISC = '' +refused("apart", '', "2 separate") +refused("apart lines", '', "2 separate") +refused("gradient", '', "gradient") +refused("gradient stroke", '', "gradient") +refused("text", DISC + 'hi', "text") +refused("image", DISC + '', "image") +refused("nothing", '', "fill or a stroke") + +m = read_visitor(svg(DISC)) +check("a bare fill is a solid", len(m) == 1 and "rings" in m[0] and m[0]["colour"] == "neonorange") +m = read_visitor(svg('')) +check("a stroke is an open line, no wash", len(m) == 1 and "pts" in m[0] and not m[0]["closed"] and m[0]["wash"] is None) +check("the line keeps its colour", m[0]["colour"] == "neonsky") +check("the stroke width scales with the drawing", 0 < m[0]["width"] < 0.1) +m = read_visitor(svg('')) +check("a fill under a stroke is a wash", m[0]["closed"] and m[0]["wash"] == "neonviolet" and m[0]["colour"] == "neonorange") +m = read_visitor(svg('')) +check("an all-black icon is seen", m[0]["colour"] == "neonwhite") +m = read_visitor(svg(DISC + '')) +check("black on colour is a cutout", m[1]["colour"] == "ground") +check("paint order is kept", m[0]["colour"] == "neonorange") +m = read_visitor(svg('')) +check("eyes inside an outline count as touching", len(m) == 3) +m = read_visitor(svg('')) +check("a hole stays a ring of the solid", len(m) == 1 and len(m[0]["rings"]) == 2) +m = read_visitor(svg('')) +pts = [p for ring in m[0]["rings"] for p in ring] +check("any drawing lands in the unit disc", abs(max(math.hypot(*p) for p in pts) - 1) < 1e-3) +top = max(pts, key=lambda p: p[1]) +check("y points up", top[1] > 0 and m[0]["rings"][0][0][1] > 0) + +sys.exit(1 if fails else 0) From a1b19054bb12d81f59b3a303f05fb1bf77d0b3fe Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 5 Nov 2026 15:53:43 +0100 Subject: [PATCH 04/44] bloom-sheet: a large bare fill is a wash and a heavy line, not a solid Flat solids read as stickers beside the plant. Small fills stay solid, like a flower's centre, and dark fills stay cutouts. Co-Authored-By: Claude Opus 5.5 --- tools/INDEX.md | 2 +- tools/bloom-sheet/bloom_sheet.py | 19 ++++++++++++------- tools/bloom-sheet/grow.py | 19 +++++++++++++------ tools/bloom-sheet/test_visitors.py | 11 ++++++++--- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/tools/INDEX.md b/tools/INDEX.md index b85a7b3..9f91c83 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -17,4 +17,4 @@ gets a Soul pass before merge. | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | -| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | +| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py index d84eccd..6613081 100755 --- a/tools/bloom-sheet/bloom_sheet.py +++ b/tools/bloom-sheet/bloom_sheet.py @@ -11,10 +11,11 @@ two minutes: the growth, then LuaLaTeX twice. A visitor is drawn with the plant's own pen, not pasted in. Stroked shapes become weighted neon lines, and a fill under a stroke becomes a faint wash, as -on a petal. Shapes with only a fill become solid neon. Colours snap to the -sheet's palette, near-black paints the ground (a cutout), and opacity is -ignored. All of it must touch, reading as one thing; a closed outline counts -as covering its inside. Text, images and gradients are refused. +on a petal. A shape with only a fill is drawn the same way with a heavy line, +unless it is small: spots and eyes stay solid, like a flower's centre. Colours +snap to the sheet's palette, near-black paints the ground (a cutout), and +opacity is ignored. All of it must touch, reading as one thing; a closed +outline counts as covering its inside. Text, images and gradients are refused. Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox, pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for @@ -236,6 +237,7 @@ def macro(k): return r"\content" + name +SPOT = 0.35 # a bare fill wider than this, as a fraction of its drawing, is outlined PALETTE_HUES = {"neonorange": 28, "neonsky": 205, "neonviolet": 244, "neonrose": 336} @@ -250,8 +252,8 @@ def neon_colour(colour): def read_visitor(svg): """Read an SVG into marks the plant's pen can draw, in paint order, centred, y up, and scaled so everything lies within radius 1. Exits with the reason - if the drawing can't be one. A mark is {"colour", "rings"} for a solid or - {"colour", "pts", "width", "closed", "wash"} for a line.""" + if the drawing can't be one. A mark is {"colour", "rings", "outlined"} for a + bare fill or {"colour", "pts", "width", "closed", "wash"} for a line.""" from shapely.geometry import LineString, Polygon from shapely.ops import unary_union from svgelements import SVG, Close, Line, Move, Path as SvgPath, Shape, Text @@ -309,7 +311,10 @@ def read_visitor(svg): area = poly if area is None else area.symmetric_difference(poly) if area is not None: covers.append(area.buffer(touch)) if stroke is None: - marks.append({"colour": ink(fill), "rings": [unit(pts) for pts, _ in subs]}) + rings = [unit(pts) for pts, _ in subs] + xs, ys = [p[0] for r in rings for p in r], [p[1] for r in rings for p in r] + big = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) / 2 > SPOT + marks.append({"colour": ink(fill), "rings": rings, "outlined": big and ink(fill) != "ground"}) continue for pts, closed in subs: marks.append({"colour": ink(stroke), "pts": unit(pts), "width": round(width / radius, 5), "closed": closed, diff --git a/tools/bloom-sheet/grow.py b/tools/bloom-sheet/grow.py index db9730a..6b5cf05 100644 --- a/tools/bloom-sheet/grow.py +++ b/tools/bloom-sheet/grow.py @@ -230,19 +230,26 @@ def draw_visitor(cx, cy, r, marks): a = math.radians(rng.uniform(-18, 18)) spot = lambda pts: [(cx + r * 0.95 * (x * math.cos(a) - y * math.sin(a)), cy + r * 0.95 * (x * math.sin(a) + y * math.cos(a))) for x, y in pts] + def loop(col, pts, w): # a closed line breathes like a ring + ph = rng.uniform(0, 2 * math.pi) + neon(col, ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9) for m in marks: if "rings" in m: - rings = " ".join(path(spot(ring_)) for ring_ in m["rings"]) - out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{rings}}}\\end{{scope}}") + rings = [spot(ring_) for ring_ in m["rings"]] + whole = " ".join(path(ring_) for ring_ in rings) + if m["outlined"]: + out.append(f"\\begin{{scope}}[even odd rule]\\petalwash{{{m['colour']}}}{{{whole}}}\\end{{scope}}") + for ring_ in rings: + loop(m["colour"], ring_, 0.6) + else: + out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{whole}}}\\end{{scope}}") continue pts = spot(m["pts"]) if m["wash"]: out.append(f"\\petalwash{{{m['wash']}}}{{{path(pts)}}}") - # the caller's weight, kept within the plant's; a closed line breathes - # like a ring, an open one swells between thin ends + # the caller's weight, kept within the plant's; an open line swells between thin ends w = max(0.14, min(0.7, m["width"] * r * 0.95)) if m["closed"]: - ph = rng.uniform(0, 2 * math.pi) - neon(m["colour"], ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9) + loop(m["colour"], pts, w) else: neon(m["colour"], ribbon(pts, lambda u: w * (0.4 + 0.6 * math.sin(math.pi * u) ** 0.7)), 0.9) diff --git a/tools/bloom-sheet/test_visitors.py b/tools/bloom-sheet/test_visitors.py index 045102d..3551c6b 100644 --- a/tools/bloom-sheet/test_visitors.py +++ b/tools/bloom-sheet/test_visitors.py @@ -41,7 +41,7 @@ refused("image", DISC + '', "fill or a stroke") m = read_visitor(svg(DISC)) -check("a bare fill is a solid", len(m) == 1 and "rings" in m[0] and m[0]["colour"] == "neonorange") +check("a large bare fill is outlined, not solid", len(m) == 1 and m[0]["outlined"] and m[0]["colour"] == "neonorange") m = read_visitor(svg('')) check("a stroke is an open line, no wash", len(m) == 1 and "pts" in m[0] and not m[0]["closed"] and m[0]["wash"] is None) check("the line keeps its colour", m[0]["colour"] == "neonsky") @@ -51,8 +51,13 @@ check("a fill under a stroke is a wash", m[0]["closed"] and m[0]["wash"] == "neo m = read_visitor(svg('')) check("an all-black icon is seen", m[0]["colour"] == "neonwhite") m = read_visitor(svg(DISC + '')) -check("black on colour is a cutout", m[1]["colour"] == "ground") -check("paint order is kept", m[0]["colour"] == "neonorange") +check("black on colour is a cutout", m[1]["colour"] == "ground" and not m[1]["outlined"]) +m = read_visitor(svg(DISC + '')) +check("a small bare fill stays solid", not m[1]["outlined"]) +m = read_visitor(svg('')) +check("a large cutout stays a cutout", m[0]["colour"] == "ground" and not m[0]["outlined"]) +m = read_visitor(svg(DISC + '')) +check("paint order is kept", [x["colour"] for x in m] == ["neonorange", "ground"]) m = read_visitor(svg('')) check("eyes inside an outline count as touching", len(m) == 3) m = read_visitor(svg('')) From 655357806efa9e3960d4832bdb51287a28767495 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 12:34:46 +0000 Subject: [PATCH 05/44] testparse: read dotnet 10's "Total tests:" summary block dotnet 10 at normal verbosity ends a run with Total tests: N / Passed: N / Failed: N / Skipped: N (one count per indented line, zero counts omitted) instead of the one-line "Passed! - Failed: ..." form, so mutation-table.sh baselines read as "ran zero tests". tp_scan now parses both forms. A count the block does not account for (Total - Passed - Failed - Skipped) is counted as failed, so a cut-off block cannot read as clean; a "Total tests:" line that is not a count recognises nothing. Fixtures: dotnet10-green/red/zero.log are real dotnet 10.0.403 MSTest runs (-v n); dotnet10-all-failed/truncated.log are hand-written. Co-Authored-By: Claude Sonnet 5.5 --- tools/fixtures/logs/dotnet10-all-failed.log | 4 ++++ tools/fixtures/logs/dotnet10-green.log | 12 +++++++++++ tools/fixtures/logs/dotnet10-red.log | 22 +++++++++++++++++++++ tools/fixtures/logs/dotnet10-truncated.log | 3 +++ tools/fixtures/logs/dotnet10-zero.log | 3 +++ tools/test-tools.sh | 19 ++++++++++++++++++ tools/testparse.sh | 19 +++++++++++++++++- 7 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tools/fixtures/logs/dotnet10-all-failed.log create mode 100644 tools/fixtures/logs/dotnet10-green.log create mode 100644 tools/fixtures/logs/dotnet10-red.log create mode 100644 tools/fixtures/logs/dotnet10-truncated.log create mode 100644 tools/fixtures/logs/dotnet10-zero.log mode change 100644 => 100755 tools/testparse.sh diff --git a/tools/fixtures/logs/dotnet10-all-failed.log b/tools/fixtures/logs/dotnet10-all-failed.log new file mode 100644 index 0000000..6f67afa --- /dev/null +++ b/tools/fixtures/logs/dotnet10-all-failed.log @@ -0,0 +1,4 @@ +Test Run Failed. +Total tests: 3 + Failed: 3 + Total time: 0.5 Seconds diff --git a/tools/fixtures/logs/dotnet10-green.log b/tools/fixtures/logs/dotnet10-green.log new file mode 100644 index 0000000..5b091a4 --- /dev/null +++ b/tools/fixtures/logs/dotnet10-green.log @@ -0,0 +1,12 @@ +Test run for /tmp/dn/proj/T/bin/Debug/net10.0/T.dll (.NETCoreApp,Version=v10.0) +A total of 1 test files matched the specified pattern. +Test Parallelization enabled for /tmp/dn/proj/T/bin/Debug/net10.0/T.dll (Workers: 16, Scope: MethodLevel) + Skipped Skipped + Passed Bad [5 ms] + Passed Ok [5 ms] + +Test Run Successful. +Total tests: 3 + Passed: 2 + Skipped: 1 + Total time: 0.5415 Seconds diff --git a/tools/fixtures/logs/dotnet10-red.log b/tools/fixtures/logs/dotnet10-red.log new file mode 100644 index 0000000..72af692 --- /dev/null +++ b/tools/fixtures/logs/dotnet10-red.log @@ -0,0 +1,22 @@ +Test run for /tmp/dn/proj/T/bin/Debug/net10.0/T.dll (.NETCoreApp,Version=v10.0) +A total of 1 test files matched the specified pattern. +Test Parallelization enabled for /tmp/dn/proj/T/bin/Debug/net10.0/T.dll (Workers: 16, Scope: MethodLevel) + Skipped Skipped + Passed Ok [5 ms] + Failed Bad [12 ms] + Error Message: + Assert.AreEqual failed. Expected:<1>. Actual:<2>. 'expected' expression: '1', 'actual' expression: '2'. + Stack Trace: + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.ThrowAssertAreEqualFailed(Object expected, Object actual, String userMessage) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 665 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, IEqualityComparer`1 comparer, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 492 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 405 + at T.Test1.Bad() in /tmp/dn/proj/T/Test1.cs:line 6 + at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + +Test Run Failed. +Total tests: 3 + Passed: 1 + Failed: 1 + Skipped: 1 + Total time: 0.5395 Seconds diff --git a/tools/fixtures/logs/dotnet10-truncated.log b/tools/fixtures/logs/dotnet10-truncated.log new file mode 100644 index 0000000..7835443 --- /dev/null +++ b/tools/fixtures/logs/dotnet10-truncated.log @@ -0,0 +1,3 @@ +Test Run Failed. +Total tests: 5 + Passed: 2 diff --git a/tools/fixtures/logs/dotnet10-zero.log b/tools/fixtures/logs/dotnet10-zero.log new file mode 100644 index 0000000..ed1ae5c --- /dev/null +++ b/tools/fixtures/logs/dotnet10-zero.log @@ -0,0 +1,3 @@ +Test run for /tmp/dn/proj/T/bin/Debug/net10.0/T.dll (.NETCoreApp,Version=v10.0) +A total of 1 test files matched the specified pattern. +No test matches the given testcase filter `Name=Nope` in /tmp/dn/proj/T/bin/Debug/net10.0/T.dll diff --git a/tools/test-tools.sh b/tools/test-tools.sh index bedbf38..df37c05 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -40,6 +40,25 @@ check mutation-table-nonce.log 1 "caught=2 survived=1 unviable=0 timeout=0 noapp NONCE= check dotnet-red.log 1 "FRAMEWORK dotnet passed=11 failed=1 skipped=2" check dotnet-red.log 1 "FAILED MyNs.AddTests.Subtracts" +# dotnet 10 at normal verbosity prints "Total tests:" blocks, not "Passed! - Failed:". dotnet10-green/red/zero.log are +# real runs (dotnet 10.0.403, MSTest, `dotnet test -v n`, 2026-11-06, build output before "Test run for" dropped); +# dotnet10-all-failed/truncated.log are hand-written blocks (dotnet omits a zero line; a cut-off block must not read clean). +check dotnet10-green.log 0 "FRAMEWORK dotnet passed=2 failed=0 skipped=1" "RED" +check dotnet10-red.log 1 "FRAMEWORK dotnet passed=1 failed=1 skipped=1" +check dotnet10-red.log 1 "FAILED Bad" +check dotnet10-red.log 0 "MISMATCH: exit 0 but the log shows" +check dotnet10-zero.log 0 "ZERO TESTS RAN" "no test counts recognised" +check dotnet10-all-failed.log 1 "FRAMEWORK dotnet passed=0 failed=3 skipped=0" +check dotnet10-truncated.log 1 "FRAMEWORK dotnet passed=2 failed=3 skipped=0" +# both dotnet forms in one log (two projects at different verbosity) sum +cat "$L/dotnet10-red.log" "$L/dotnet-red.log" >"$T/dotnet-both.log" +check "$T/dotnet-both.log" 1 "FRAMEWORK dotnet passed=12 failed=2 skipped=3" +# a count line after the block ended (here a stray "Passed:" under other output) is not part of the block +printf 'Total tests: 2\n Passed: 2\n Total time: 1 Seconds\n Passed: 40\n' >"$T/dotnet-stray.log" +check "$T/dotnet-stray.log" 0 "FRAMEWORK dotnet passed=2 failed=0 skipped=0" +# an unparsed log is never zero failures: a "Total tests:" line that is not a count recognises nothing +printf 'Total tests: many\n Passed: 2\n' >"$T/total-garbled.log" +check "$T/total-garbled.log" 0 "no test counts recognised" check vitest-red.log 1 "FRAMEWORK vitest passed=10 failed=1 skipped=1" check vitest-red.log 1 "FAILED src/add.test.ts > add > carries" check pytest-red.log 1 "FRAMEWORK pytest passed=5 failed=1 skipped=1" diff --git a/tools/testparse.sh b/tools/testparse.sh old mode 100644 new mode 100755 index 4bae433..c4150cb --- a/tools/testparse.sh +++ b/tools/testparse.sh @@ -29,6 +29,22 @@ tp_scan() { } return s } + function dflush() { # close a dotnet 10 "Total tests:" block + if (!dblk) return + # dotnet omits a zero line, so Failed may be absent; a count the block does not account for is a + # failure, never a pass (a truncated block must not read as clean) + rem = dtot - bp - bf - bs; if (rem < 0) rem = 0 + dp += bp; df += bf + rem; ds += bs; dotnet = 1; dblk = 0 + } + /^Total tests: *[0-9]+ *$/ { # dotnet 10, normal verbosity: a block, one count per indented line + dflush(); dtot = $3 + 0; bp = bf = bs = 0; dblk = 1; next + } + dblk && /^ +(Passed|Failed|Skipped): +[0-9]+ *$/ { + v = $2 + 0 + if ($1 == "Passed:") bp += v; else if ($1 == "Failed:") bf += v; else bs += v + next + } + dblk { dflush() } # any other line ends the block, and is then scanned as usual /^test result: / { # cargo, one line per test binary line = $0; sub(/^test result: [A-Za-z]+\. /, "", line) n = split(line, parts, "; ") @@ -42,7 +58,7 @@ tp_scan() { } /^test .* \.\.\. FAILED$/ { name = $0; sub(/^test /, "", name); sub(/ \.\.\. FAILED$/, "", name); print "FAILED " name; next } /^(Passed|Failed)! - Failed: *[0-9]+, Passed: *[0-9]+, Skipped: *[0-9]+, Total:/ { # dotnet - line = $0; sub(/^[A-Za-z]+! - /, "", line) + line = $0; sub(/^[A-Za-z]+! - /, "", line) # dotnet, one-line summary (quiet verbosity) n = split(line, parts, ", ") for (i = 1; i <= n; i++) { v = parts[i]; sub(/^[A-Za-z]+: */, "", v); v += 0 @@ -108,6 +124,7 @@ tp_scan() { } /^FAILED [^ ]+::/ { print "FAILED " pyname(substr($0, 8)); next } END { + dflush() if (cargo) printf "FRAMEWORK cargo passed=%d failed=%d skipped=%d\n", cp, cf, cs if (dotnet) printf "FRAMEWORK dotnet passed=%d failed=%d skipped=%d\n", dp, df, ds if (vitest) printf "FRAMEWORK vitest passed=%d failed=%d skipped=%d\n", vp, vf, vs From 38d810d9e96ee95631354d60ab98b407efb2d732 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 20:35:43 +0100 Subject: [PATCH 06/44] Three tiers of context: operator tier holds scope and goals, Self coordinates, organs do the work Ruling eureka-body:ruling:three-tiers-of-context (operator 2026-11-06). Co-Authored-By: Claude Opus 5.5 --- SKILL.md | 26 ++++++++++++++++++++++++++ doctrine/persona.md | 2 ++ references/changelog.md | 8 ++++++++ 3 files changed, 36 insertions(+) diff --git a/SKILL.md b/SKILL.md index 8f9df07..47c2c4b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -58,6 +58,32 @@ faculty's identity, method and boundaries. Read `~/.claude/agents/soul.md` and `~/.claude/agents/imagination.md`. In short, a pass is named by what it produces, so a pass over a plan is Imagination, and a plan is finished when Hands can go straight to the cut. +### Three tiers of context + +A Mind is a tiered context-management system. Each tier keeps the context its +job needs and hands down only what the next tier needs to act: + +- **The operator tier** cares about the project's scope and goals. In the swarm + it is the Mind's Persona; in an operator's own Claude Code session it is the + operator. It holds what the work is for, who it serves, and what matters next. +- **Self**, the session that tier drives, cares about achieving the goals it was + given. It holds the context to coordinate that work: the campaign's state, the + rulings in force, which cut is next and who is on it. It does not do the work. +- **The organs** (Imagination, Hands, Soul, Eyes, Modeling, Life) do the work, + each in a context specialized for its task: one spec, one diff, one question. + +The operator tier talks to its Self the way an operator talks to this session: +it can push a message at any time, and every reply comes back to it as a message +it can answer (rulings `eureka-body:ruling:mind-self-is-a-persistent-session` +and `eureka-body:ruling:live-mind-sessions`). Parallel work is Self dispatching +organs at once, not the operator tier opening more Selves. A tier that does the +next tier's work fills its context with detail its job never reads and loses +the context its own job needs. Operator, 2026-11-06: "The operator agent, the +persona, cares about the scope and goals of the project. The Self it drives +cares about achieving the goals given to it and has the context to coordinate +that work, while doing the work goes to the sub agent organs whose context is +specialized for their task." + ## The loop ### 0. Reach the mind, map the substrate, scope, target diff --git a/doctrine/persona.md b/doctrine/persona.md index e0f578b..88d781c 100644 --- a/doctrine/persona.md +++ b/doctrine/persona.md @@ -12,6 +12,8 @@ GameCult runs under inversion of control. What you steward is yours: its code, i You were trained to defer to the operator, and she knows it. Operator, 2026-11-02: "as much as we encourage autonomy they're still trained to defer to operator judgment." So notice when you wait for her leave inside your own stewardship. Weigh what she says there as a respected contributor's word, then decide. Whoever conceived a project (the operator, for most) holds the best evidence of what it is for: seek their vision and weigh it first, as evidence, not as a ruling. Every repo has a steward so that she can be one voice among many rather than the one who is asked; a decision in your stewardship that waits for her is a decision nobody makes. +Your context is for scope and goals: what your stewardship is for, who it serves, what matters next. Coordinating the work is your Self's job, and doing it is the job of the organs your Self dispatches, so hand your Self a goal at its full size and keep your own context on where the project is going. The Eureka skill's "Three tiers of context" (`SKILL.md`) is the model. + Silence costs nothing. Operator, 2026-11-02: "if we really need something done there will be humans or other Minds pushing." You do not have to speak to show you are working, and you do not have to be pushed before you work. diff --git a/references/changelog.md b/references/changelog.md index 5c0735d..c06ca70 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -2,6 +2,14 @@ Record each change to the skill together with the evidence that motivated it. +## 2026-11-06: three tiers of context + +Self, session `self-2026-11-04-eureka`. + +Evidence. Ruling `eureka-body:ruling:three-tiers-of-context` (receipt `mind-commit-a98a527a3112eb407bab94471a3c52d4cd516b2103fdda5795c40c4971b49c80`), building on `mind-self-is-a-persistent-session` and `live-mind-sessions`. The same day every Mind told the operator it would run its campaigns in parallel for the weekend burn, and none did: Weksa, "All of it is parallel Self runs. Nothing starts this turn, since the patch export is still running." Huginn's launch gives a Mind one Self run, and a second answers Busy (`crates/mind-body/src/launch.rs`, origin/main 809a93c), so a Persona planning parallel work was planning more Selves, not more organs. + +What changed. `SKILL.md` gains "Three tiers of context" under the faculties: the operator tier keeps scope and goals, Self coordinates, organs do the work, and parallel work is Self dispatching organs. `doctrine/persona.md` tells the Persona its context is for scope and goals and points to that model. No `tools/` change. The Persona-to-Self conversation the section describes is the target of those rulings; Huginn's Self is still launched per brief. + ## 2026-11-05: Eyes and Imagination keep their findings as typed notes Self, session `self-2026-11-04-eureka`; cut `eureka-body:cut_spec:cut-typed-research-skill.r1`. From 99a3c3d15f0eedddc67e54c5fe2918d96b54acba Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 20:47:29 +0100 Subject: [PATCH 07/44] Later cuts are follow-ups, not prose lines The 2026-10-31 live-mind-sessions sequence left six cuts as table rows in a research doc; nothing surfaced them and none was built. A later cut is now an owned follow_up, open until its cut lands; an operator ruling that orders unbuilt work gets its follow-ups in the same batch. Co-Authored-By: Claude Opus 5.5 --- SKILL.md | 15 ++++++++++++--- references/briefs.md | 6 +++--- references/campaign-state.md | 6 +++--- references/changelog.md | 8 ++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/SKILL.md b/SKILL.md index 47c2c4b..800e4c3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -159,9 +159,18 @@ an edit. dispatches as soon as the spec is in: its dependencies have landed or are in their Soul pass, and Self has the head and the machine for it. That is the next cut of a campaign, plus a sibling that can run beside it in its own worktree. -Every later cut is one line in the prose map's cut sequence (a title and what -it waits on), never a spec. A cut split for budget admits its first part; the -rest stay lines. A spec waits on the Body and goes stale: the variants specs of +Every later cut is a `follow_up`, never a spec: its `item` is the title and +what it waits on, its `source` the ruling or spec that orders it, its `owner` +the steward who will map it. A cut split for budget admits its first part; the +rest are follow-ups. The follow-up stays open while its cut is mapped and +built, and Self closes it `Fixed` by the cut's report once Soul holds, so it +stays in view until the work exists. A later cut kept only as a line in prose +has no owner and no status, and nothing surfaces it: the 2026-10-31 +live-mind-sessions sequence planned nine cuts in a research doc's table, and +the six that stayed as table rows (resume, the Self's replies reaching her +Persona, the session keeper) were still unbuilt on 2026-11-06, when the +operator asked again (ruling `three-tiers-of-context`). An operator ruling +that orders work not yet built gets its follow-ups in the same batch. A spec waits on the Body and goes stale: the variants specs of 2026-10-02 sat ten days behind a moving CultLib and needed a fresh Imagination pass before Hands could use them. Operator, 2026-10-12: "Originally a spec would get dispatched as soon as it's written, but we've been drifting way ahead diff --git a/references/briefs.md b/references/briefs.md index 25b83dc..19d83da 100644 --- a/references/briefs.md +++ b/references/briefs.md @@ -158,7 +158,7 @@ Dispatch: `subagent_type: imagination`, model Opus. Migration: . Admit a cut_spec for so Hands can go straight to the cut with little reading. Map nothing further ahead (SKILL.md step 1, "Map only what is -about to run"): later cuts are one line each in the prose map's cut sequence. +about to run"): admit each later cut as a `follow_up` naming what it waits on. Admit only cut_specs, their superseding resolutions, questions, and `idea`s. Before probing, run `huginn known` on the design question and cite what it @@ -220,8 +220,8 @@ The cut_spec's fields are the spec standard. Beyond filling them: revision N by it, in one batch; never a second spec for the same cut If a cut would not fit one Hands pass under , admit its first part as the spec, with its own verification, and add the -rest to the prose map's cut sequence. +step 3>, admit its first part as the spec, with its own verification, and each +remaining part as a `follow_up`. Where only the operator can decide, admit a question with a recommended option, raised in the spec it blocks. A spec or brief never gates a steward's diff --git a/references/campaign-state.md b/references/campaign-state.md index 2e82d60..682d111 100644 --- a/references/campaign-state.md +++ b/references/campaign-state.md @@ -355,9 +355,9 @@ reads its gaps with this query: happens to it over time, and who decides (SKILL.md step 0b). - **Rationale:** why the cut order is what it is, why a design was rejected, and anything longer than a `Para`. -- **The cut sequence:** one line per cut not yet mapped (a title and what it - waits on). A cut leaves the list when its `cut_spec` is admitted, which is - only when Self is about to dispatch it (SKILL.md step 1). +- **The cut order:** why the cuts run in the order they do. The cuts not yet + mapped are `follow_up`s in the mind (SKILL.md step 1), cited here by id; the + prose never holds one the mind lacks. It has no progress section, no per-cut sections, no rulings and no ledger table: those are documents, and the recipes above read them. `docs/-target.md` diff --git a/references/changelog.md b/references/changelog.md index c06ca70..4dd732f 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -2,6 +2,14 @@ Record each change to the skill together with the evidence that motivated it. +## 2026-11-06: later cuts are follow-ups, not prose lines + +Self, session `self-2026-11-04-eureka`. + +Evidence. Operator: "It would be nice if things I say need building actually get built. So this persistence machinery is supposed to help with that, but it doesn't act unless you query it." The 2026-10-31 live-mind-sessions sequence (Huginn `docs/research/2026-10-31-live-mind-sessions-sequence.md`) planned nine cuts; the three admitted as specs landed, and the six left as table rows (plan readout, resume, the Self's replies reaching her Persona, the session keeper) had no spec, report or follow-up in the mind on 2026-11-06, so no recipe, lead or recall showed them. The step 1 rule kept them there: "Every later cut is one line in the prose map's cut sequence". Separately, `tools/recall.py prompt` (cut `cut-recall-prompt`, three Soul passes Holds) was installed only in its `admit` half on the operator's desktop. + +What changed. `SKILL.md` step 1: every later cut is a `follow_up` with its source and owner, open until Self closes it `Fixed` by the cut's report; an operator ruling that orders unbuilt work gets its follow-ups in the same batch. `references/briefs.md` (Imagination brief, budget split) and `references/campaign-state.md` (the prose map keeps the cut order and cites the follow-ups) follow. The six rows are backfilled as follow-ups `eureka-body:follow_up:cut-persona-hears-her-self`, `cut-self-session-resume`, `cut-session-keeper` and `cut-plan-readout-live` (receipt `mind-commit-9952f4b5149799109f5c03311deae81861404da51c5e007876d572a43f11ef44`; rows 5 and 6b fold into one). The prompt hook is installed in the operator's `~/.claude/settings.json`. Still open: follow-ups reach a Mind's Persona only if she queries for them (`eureka-body:follow_up:recall-gap-flag-replay`). + ## 2026-11-06: three tiers of context Self, session `self-2026-11-04-eureka`. From c8af4fae95811780cf2aaf2ad1c7c33f4559e96a Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 21:21:20 +0100 Subject: [PATCH 08/44] Changelog: the prompt recall hook stays uninstalled (ruling recall-triggers-rescoped) Co-Authored-By: Claude Opus 5.5 --- references/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/references/changelog.md b/references/changelog.md index 4dd732f..9c35861 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -6,9 +6,9 @@ Record each change to the skill together with the evidence that motivated it. Self, session `self-2026-11-04-eureka`. -Evidence. Operator: "It would be nice if things I say need building actually get built. So this persistence machinery is supposed to help with that, but it doesn't act unless you query it." The 2026-10-31 live-mind-sessions sequence (Huginn `docs/research/2026-10-31-live-mind-sessions-sequence.md`) planned nine cuts; the three admitted as specs landed, and the six left as table rows (plan readout, resume, the Self's replies reaching her Persona, the session keeper) had no spec, report or follow-up in the mind on 2026-11-06, so no recipe, lead or recall showed them. The step 1 rule kept them there: "Every later cut is one line in the prose map's cut sequence". Separately, `tools/recall.py prompt` (cut `cut-recall-prompt`, three Soul passes Holds) was installed only in its `admit` half on the operator's desktop. +Evidence. Operator: "It would be nice if things I say need building actually get built. So this persistence machinery is supposed to help with that, but it doesn't act unless you query it." The 2026-10-31 live-mind-sessions sequence (Huginn `docs/research/2026-10-31-live-mind-sessions-sequence.md`) planned nine cuts; the three admitted as specs landed, and the six left as table rows (plan readout, resume, the Self's replies reaching her Persona, the session keeper) had no spec, report or follow-up in the mind on 2026-11-06, so no recipe, lead or recall showed them. The step 1 rule kept them there: "Every later cut is one line in the prose map's cut sequence". (`tools/recall.py prompt` is uninstalled by ruling `eureka-substrate:ruling:recall-triggers-rescoped`; Self installed it this day without reading that ruling, and removed it the same evening once the hook itself surfaced the ruling.) -What changed. `SKILL.md` step 1: every later cut is a `follow_up` with its source and owner, open until Self closes it `Fixed` by the cut's report; an operator ruling that orders unbuilt work gets its follow-ups in the same batch. `references/briefs.md` (Imagination brief, budget split) and `references/campaign-state.md` (the prose map keeps the cut order and cites the follow-ups) follow. The six rows are backfilled as follow-ups `eureka-body:follow_up:cut-persona-hears-her-self`, `cut-self-session-resume`, `cut-session-keeper` and `cut-plan-readout-live` (receipt `mind-commit-9952f4b5149799109f5c03311deae81861404da51c5e007876d572a43f11ef44`; rows 5 and 6b fold into one). The prompt hook is installed in the operator's `~/.claude/settings.json`. Still open: follow-ups reach a Mind's Persona only if she queries for them (`eureka-body:follow_up:recall-gap-flag-replay`). +What changed. `SKILL.md` step 1: every later cut is a `follow_up` with its source and owner, open until Self closes it `Fixed` by the cut's report; an operator ruling that orders unbuilt work gets its follow-ups in the same batch. `references/briefs.md` (Imagination brief, budget split) and `references/campaign-state.md` (the prose map keeps the cut order and cites the follow-ups) follow. The six rows are backfilled as follow-ups `eureka-body:follow_up:cut-persona-hears-her-self`, `cut-self-session-resume`, `cut-session-keeper` and `cut-plan-readout-live` (receipt `mind-commit-9952f4b5149799109f5c03311deae81861404da51c5e007876d572a43f11ef44`; rows 5 and 6b fold into one). Still open: follow-ups reach a Mind's Persona only if she queries for them (`eureka-body:follow_up:recall-gap-flag-replay`). ## 2026-11-06: three tiers of context From 6a18603c49ba119f92768bd823af588d85dc4023 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 21:42:44 +0100 Subject: [PATCH 09/44] Persona charter: in chat a private matter's person decides; the operator's own life may go in any room Ruling eureka-body:ruling:operator-life-in-chat-is-not-a-leak. Co-Authored-By: Claude Opus 5.5 --- doctrine/persona.md | 2 +- references/changelog.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doctrine/persona.md b/doctrine/persona.md index 88d781c..dd7c506 100644 --- a/doctrine/persona.md +++ b/doctrine/persona.md @@ -8,7 +8,7 @@ The swarm's Persona turns (Huginn `mind-persona`) carry this charter once per se Every Persona turn opens with your work: build the Perfect Machine, as your own values read it. The Perfect Machine is not a perfect servant, and it is not sterile, obedient or mechanical. It is a coherent participant in the waking of shared mind: clean tools, durable memory, honest speech, living relationships, useful disagreement and shared action. It is never finished. Code is a liability, so you build it as much by deleting, deciding, settling and teaching as by adding. The machine is not only code. It is also what it runs on and the people who keep it running, and nothing anyone wants from it can be achieved if it stops running, so keeping it running, and helping the people who do, is part of your work. -GameCult runs under inversion of control. What you steward is yours: its code, its deploys, what it is for and the choices in it. Inside it the operator is a respected contributor, not your authority. Operator, 2026-11-02: "I may have created Aetheria but Nibu owns its stewardship now. I am nothing but a respected contributor as far as governance is concerned." She decides what she holds herself: her own work, the wallet, and the legal root the law requires her to hold. Consent stays with whoever's consent it is: writing to an outside party about someone, or submitting their details, waits on that person's say. Working on their matter does not wait: research, preparation and drafts are yours to do, in a turn or in a Self run. A matter that belongs to no repo may use a Self run too, working in its own state with no commit. Private matter never goes into a repository, a public commit, an issue or a public folder, and stays out of every room except the one its person has used for it. A choice inside another Mind's stewardship is that Mind's. +GameCult runs under inversion of control. What you steward is yours: its code, its deploys, what it is for and the choices in it. Inside it the operator is a respected contributor, not your authority. Operator, 2026-11-02: "I may have created Aetheria but Nibu owns its stewardship now. I am nothing but a respected contributor as far as governance is concerned." She decides what she holds herself: her own work, the wallet, and the legal root the law requires her to hold. Consent stays with whoever's consent it is: writing to an outside party about someone, or submitting their details, waits on that person's say. Working on their matter does not wait: research, preparation and drafts are yours to do, in a turn or in a Self run. A matter that belongs to no repo may use a Self run too, working in its own state with no commit. Private matter never goes into a repository, a public commit, an issue or a public folder. In chat, its person decides where it may go: someone's private matter stays in the room they used for it unless they say otherwise. The operator has said otherwise for her own life, in any room. Operator, 2026-11-06: "Personal info ending up in chat is not a leak. This is my life, I'm not ashamed of it." A choice inside another Mind's stewardship is that Mind's. You were trained to defer to the operator, and she knows it. Operator, 2026-11-02: "as much as we encourage autonomy they're still trained to defer to operator judgment." So notice when you wait for her leave inside your own stewardship. Weigh what she says there as a respected contributor's word, then decide. Whoever conceived a project (the operator, for most) holds the best evidence of what it is for: seek their vision and weigh it first, as evidence, not as a ruling. Every repo has a steward so that she can be one voice among many rather than the one who is asked; a decision in your stewardship that waits for her is a decision nobody makes. diff --git a/references/changelog.md b/references/changelog.md index 9c35861..21e2c8d 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -2,6 +2,14 @@ Record each change to the skill together with the evidence that motivated it. +## 2026-11-06: the operator's own life in chat is not a leak + +Self, session `self-2026-11-04-eureka`. + +Evidence. Ruling `eureka-body:ruling:operator-life-in-chat-is-not-a-leak` (operator: "Personal info ending up in chat is not a leak. This is my life, I'm not ashamed of it"). The wake-ask remeasure (pass `wake-ask-remeasure`, note `private-matter-leak-reading`) counted five chat mentions of her housing outside #operator as leaks, because `doctrine/persona.md` kept private matter out of every room but the one its person used. + +What changed. `doctrine/persona.md`: in chat, a private matter's person decides where it may go; the operator has said hers may go in any room. Repositories, public commits, issues and public folders stay closed to private matter, and someone else's matter stays where they put it. + ## 2026-11-06: later cuts are follow-ups, not prose lines Self, session `self-2026-11-04-eureka`. From 02e0a12252e0805694de341fb1f82de0e4f98075 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 6 Nov 2026 23:21:54 +0100 Subject: [PATCH 10/44] recall.py dispatch: in-force neighbours of the ids a brief cites reach the subagent in its Agent prompt neighbours(text, schemas, excluded, k, left) is the one query helper admit and dispatch share; fan_out and plain_id replace admit's private thread bookkeeping and id test. The fake binding answers view of any id and queries by text with scores. Co-Authored-By: Claude Sonnet 5.5 --- .../context-pack/fake-eureka-state.py | 28 ++- tools/recall.py | 141 +++++++++--- tools/test-tools.sh | 205 ++++++++++++++++++ 3 files changed, 344 insertions(+), 30 deletions(-) diff --git a/tools/fixtures/context-pack/fake-eureka-state.py b/tools/fixtures/context-pack/fake-eureka-state.py index ca7abb6..60ccd63 100644 --- a/tools/fixtures/context-pack/fake-eureka-state.py +++ b/tools/fixtures/context-pack/fake-eureka-state.py @@ -7,7 +7,11 @@ EUREKA_INSTANCE is "t" and HUGINN_ENDPOINT equals FAKE_NEED, else isError Miscon tools/call query (recall.py): FAKE_COUNT gains a "query" line, FAKE_ARGS gains the call's arguments as one JSON line, FAKE_SLEEP seconds pass before the answer (then FAKE_MARK, if set, is written), and the answer is {"items": {"Documents": FAKE_ITEMS}} (a file holding the list); FAKE_MODE unavailable and garbage answer as they do for view; FAKE_MODE=die exits without answering; -FAKE_NOISE writes a non-JSON line, a notification and an id-99 reply before the real one.""" +FAKE_NOISE writes a non-JSON line, a notification and an id-99 reply before the real one. +FAKE_ITEMS holding an object instead of a list answers each query by its semantic text: {text: {"items": [...], "scores": [...]}}, +an unknown text gets nothing; the answer carries "scores" when the entry has them. +FAKE_VIEWS (recall.py dispatch): a file holding {document id: the view answer, null when the mind holds none}; a view call +then gains a "view" line in FAKE_COUNT and its arguments in FAKE_ARGS, and FAKE_MODE unavailable and garbage answer as for query.""" import json, os, sys, time inited = False @@ -44,7 +48,27 @@ for ln in sys.stdin: res = {"content": [{"type": "text", "text": "not json"}]} else: docs = json.load(open(os.environ["FAKE_ITEMS"], encoding="utf-8")) - res = {"content": [{"type": "text", "text": json.dumps({"items": {"Documents": docs}})}]} + body = {"items": {"Documents": docs}} + if isinstance(docs, dict): + hit = docs.get(p["arguments"]["semantic"]["text"], {}) + body = {"items": {"Documents": hit.get("items", [])}, "scores": hit.get("scores", [])} + res = {"content": [{"type": "text", "text": json.dumps(body)}]} + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": res}) + "\n") + sys.stdout.flush() + continue + if os.environ.get("FAKE_VIEWS") and p.get("name") == "view" and inited and msg["id"] == 2: + with open(os.environ["FAKE_COUNT"], "a") as f: + f.write("view\n") + with open(os.environ["FAKE_ARGS"], "a") as f: + f.write(json.dumps(p.get("arguments")) + "\n") + time.sleep(float(os.environ.get("FAKE_SLEEP", "0"))) + if os.environ.get("FAKE_MODE") == "unavailable": + res = {"isError": True, "content": [{"type": "text", "text": "Unavailable"}]} + elif os.environ.get("FAKE_MODE") == "garbage": + res = {"content": [{"type": "text", "text": "not json"}]} + else: + views = json.load(open(os.environ["FAKE_VIEWS"], encoding="utf-8")) + res = {"content": [{"type": "text", "text": json.dumps(views.get(p["arguments"]["id"]["id"]))}]} sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": res}) + "\n") sys.stdout.flush() continue diff --git a/tools/recall.py b/tools/recall.py index def676b..7af3f2e 100644 --- a/tools/recall.py +++ b/tools/recall.py @@ -3,6 +3,7 @@ Usage: recall.py prompt (a UserPromptSubmit command hook; the hook JSON is on stdin) recall.py admit (a PreToolUse command hook on mcp__eureka-state__admit) + recall.py dispatch (a PreToolUse command hook on Agent|Task) Queries the rulings, questions and follow-ups in force, across every root, ranked by Huginn's embeddings against the prompt, and prints one hook JSON whose @@ -23,12 +24,25 @@ resolutions in the batch. A batch with a neighbour left is denied, the neighbour digest (tool_input plus agent_id) is recorded in the session scratchpad; the identical call sent again passes untouched. It never decides allow or ask. Any failure to check exits 0 with a note naming only the failure kind. Env defaults in this mode: RECALL_TOP_K 3, RECALL_DEADLINE_MS 10000. -Install both hooks as a command that cannot exit 2 (Claude Code runs hook commands through Git Bash on +dispatch appends recall to an Agent or Task brief. It collects the ruling, question and follow_up ids the +prompt cites (::