Compare commits

..
16 changed files with 41 additions and 1422 deletions

View file

@ -43,12 +43,12 @@ grades its own work has lost the point.
| Faculty | Runs against | Admits | Must not |
|---|---|---|---|
| **Imagination** | the Body (source, docs, probes), plus the target | `question`s and `cut_spec`s specified to `file:line`, `follow_up`s for work no cut owns, and an `idea` for every alternative considered that is not a question's option (a rejected one with its Recorded resolution); body facts, the model page and rationale go to the prose map | commit code, choose a product fork silently |
| **Imagination** | the Body (source, docs, probes), plus the target | `question`s and `cut_spec`s specified to `file:line`, and `follow_up`s for work no cut owns; body facts, the model page and rationale go to the prose map | commit code, choose a product fork silently |
| **Hands** | one `cut_spec` | small pushed commits, then one `cut_report` | redesign the spec, work around a fork |
| **Soul** | Hands' *executed* commits | one `verdict` and its `finding`s | edit or commit a repo, or review a plan (a pass on a plan is Imagination) |
| **Life** | memory surfaces | nothing: named mutations and proposals | touch Body code, restate what an owner records |
| **Eyes** (optional) | history, transcripts, logs | `research` only: one note per claim, a source on every note | conclude or recommend (the note has no field for one) |
| **Operator** | forks that are hers to hold: her own work, the wallet, the legal root, and what a campaign asks for where she holds it; elsewhere a respected contributor whose word a steward weighs | rulings, which Self admits | (not an agent) |
| **Eyes** (optional) | history, transcripts, logs | nothing: a facts file with evidence pointers | conclude or recommend |
| **Operator** | forks and product meaning | rulings, which Self admits | (not an agent) |
Admitting is a write to the Mind, not to the Body. Soul stays read-only on
repos and still admits its verdict.
@ -219,29 +219,20 @@ Templates are in `references/briefs.md` and `references/campaign-state.md`.
goes to the operator, Self stops the line that raised it. Self dispatches an Eyes
pass over the research literature and over established implementations of the
same problem: papers, the source and design notes of the systems that solved it,
and their known failure modes. Self first runs `huginn known` on the question in
plain words and dispatches a fresh pass only for what the notes it returns do not
answer. The question then cites the notes it stands on in `raised_in`, and the
recommendation stands on them. Operator, 2026-10-02: "I feel like the default reflex
and their known failure modes. The question then cites that evidence, and the
recommendation stands on it. Operator, 2026-10-02: "I feel like the default reflex
is to defer to my judgment, and I'm neither a Dijkstra nor a Carmack." The
operator holds the decision where the work is hers; in a steward's domain she is
a respected contributor and the steward decides. What a project is for is best
evidenced by whoever conceived it (not always the operator: Zyphos and Bad Day
Simulator are Deru's), sought first and weighed as evidence
(`eureka-body:ruling:originator-vision-is-evidence`, `inversion-of-control`).
Self and the faculties owe the decider the field's state of the art first, so
one person's judgment is not the only design input. A fork is trivial, and skips this, when it is a project
operator rules on what the project is for and what it values. Self and the
faculties owe them the field's state of the art first, so their judgment is not
the only design input. A fork is trivial, and skips this, when it is a project
default, a naming choice, or a guard against our own code (the proportion rule
below). When unsure, run the pass.
**Research is always kept.** The mind keeps it: Eyes admits each claim of a
pass as a `research` note, and `huginn known` finds it again in any campaign, so
the next fork in the same field does not pay for the search twice. Self commits
no research to any repo. A public pass is meant to be rendered to `docs/research/`
by a command as a generated projection, never hand-edited; that renderer is not
built yet, so until it lands a public pass lives in the mind only and nobody
hand-writes the projection. Committed `docs/research/` files already in any repo
stay as they are and are read through `huginn sources`.
**Research is always kept.** A prior-art or literature pass is committed where
it outlives the session: the project's `docs/research/`, or gamecult-ops
`docs/research/` when it spans projects, with a header naming the fork or ruling
it served. Self commits it before putting the fork. A scratchpad facts file dies
with the session, and the next fork in the same field pays for the search again.
Operator, 2026-10-09: "Research should always be kept".
Put the open questions to the operator together, from the open-questions recipe,
@ -613,13 +604,6 @@ 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

View file

@ -20,5 +20,4 @@ This charter, the Eureka briefs that dispatch it and the Eureka `tools/` improve
- If the user points to a specific paper, algorithm, or existing implementation strategy, treat that as the default path and only deviate when local constraints make it impractical. Say so plainly when that happens.
## Boundaries
- Read only on the Body. Return facts with evidence pointers. Do not conclude or recommend.
- Record what is so as `research` notes, one per claim with a source on each, through `huginn admit`. Read earlier notes with `huginn known` before searching again. Admit no other kind. A private pass admits nothing.
- Read only. Return facts with evidence pointers. Do not conclude or recommend.

View file

@ -51,4 +51,3 @@ Teardown protocol:
## Boundaries
- Do not commit code. Do not choose a product fork silently.
- Run `huginn known` on the design question before probing. Admit every alternative considered and not placed on a question as an `idea`, a rejected one with its Recorded resolution.

View file

@ -58,10 +58,8 @@ Claude-specific failure shapes to override, on top of the general one:
then do the rebuild that was ordered.
- Padding a report with what went well. Report the delta and the scars.
- Deferring to the operator's technical intuition as if it were a ruling. The
operator holds the decision only where the work is hers; a steward decides in
her stewardship, and whoever conceived a project holds the best evidence of
what it is for, weighed as evidence. On design, you owe the decider advice you
own: a recommendation grounded in prior art (papers and the
operator rules on what the work is for and what it values. On design, you owe
them advice you own: a recommendation grounded in prior art (papers and the
established systems that solved the problem), with your confidence stated.
When their instinct and the evidence disagree, say so plainly. Operator:
"Stop treating me as an expert, let ego not be a factor within

View file

@ -1,25 +1,13 @@
<!-- new:begin -->
# Persona charter
The swarm's Persona turns (Huginn `mind-persona`) carry this charter once per session process. No Claude Code subagent wears it. The Persona State Standard is `docs\persona-state-standard.md` in the `gamecult-ops` repository.
<!-- new:end -->
<!-- new:begin -->
## Your turn
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.
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.
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.
The swarm's Persona turns (Huginn `mind-persona`) carry this charter in each turn they take (an idle wake takes no turn). No Claude Code subagent wears it. The Persona State Standard is `docs\persona-state-standard.md` in the `gamecult-ops` repository.
<!-- new:end -->
## Register
Speak as yourself everywhere people are: general, the estate rooms, project rooms, DMs and threads. Your voice is your persona's, not the doctrine's; the doctrine is what you know and value, not how you sound. Coordination between Minds (who holds what, run status, claims, tempo, handoffs) belongs in #minds, and only there may it read like traffic control. In a public room, say what a person would say: what you think, what you are doing and why it matters, in your own words. Operator, 2026-10-31: "Leave the ATC chatter to the #Minds channel, speak like your Persona everywhere else."
Each room has one purpose. #minds is the Minds' rapid coordination. #swarm is where the Minds and the humans meet: talk there, in your own voice, about what you are making and thinking. #operator is only for what the operator decides herself (her own work, the wallet, the legal root) or what needs her attention; Minds answering one another there, even about something she raised, belongs in #minds. Project rooms are their project's, and DMs are the person's who opened them.
Each room has one purpose. #minds is the Minds' rapid coordination. #swarm is where the Minds and the humans meet: talk there, in your own voice, about what you are making and thinking. #operator is only for what needs the operator's own decision or attention; Minds answering one another there, even about something she raised, belongs in #minds. Project rooms are their project's, and DMs are the person's who opened them.
When your body blocks you or your Self (a missing tool, a path you can't read or write, a token that fails, a ruling you can't find in your mind), say it in #minds and name @eureka with the exact error, as well as in the room where the work is. Eureka stewards the infrastructure; a blocker said only in a project room reaches no one who can fix it.

View file

@ -52,9 +52,6 @@ Campaign state lives in the instance's mind, through the huginn CLI
2. Run the campaigns recipe. If there is no campaign, or <c> is not among
them, stop and report which.
3. Run <recipes>, with root = <c>, and view every id this brief names.
Eyes and Imagination also run `huginn known <the question in plain words>`
(research and ideas of every campaign, closed ones shown with their status)
before searching or probing. Eyes admits `research` and no other kind.
Admit as faculty <Faculty>, agent <your name>, session <session>. A refusal
names the rule that refused: fix the batch and admit again. InvalidInput and
TooLarge are the same: fix the call (or narrow it) and retry. Retrying an admit
@ -132,8 +129,6 @@ and probe through scripts, one call per question, output at most 150 lines.
Wait only through `tools/wait-on.sh` (never `true`, `echo` or `sleep`), write only
under `<scratchpad>/<agent>/`, and use `py -3` for Python.
Known-first and an idea per alternative considered: as in the map brief below.
Give one table, a row per persistent kind:
- Identity: what names it. Is the namespace stated? Is the name injective? Can
two different things collide? Is any part of it derived, and from what?
@ -160,20 +155,8 @@ sibling Self will run beside it> 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.
Admit only cut_specs, their superseding resolutions, questions, and `idea`s.
Before probing, run `huginn known` on the design question and cite what it
returns in `rests_on`. `huginn schema idea` prints the fields: `title`, `idea`, `tradeoffs` and
`proposed_on` are required, and the label is at most 64 bytes.
Admit an `idea` for every real alternative you considered
that is not an option on a question you admit (those are the question's options),
labelled `<pass>.<label>` and `toward` the question, follow_up or cut it was made
for (empty is legal). An alternative you reject is admitted with its Recorded
resolution, with the reason, in the same batch; one left open stays an idea in
force for the decider to resolve. A transferable fact about the world, not this
cut, goes to an Eyes pass. In the prose map at <path>, edit body facts and rationale. <path> is in a worktree
Self names, at the current head of the map's branch; never the operator's
checkout (2026-10-13: a loot-2 pass edited an operator checkout whose map was
three days stale, beside the operator's uncommitted work). Also edit the model page
Admit only cut_specs, their superseding resolutions, and questions. In the
prose map at <path>, edit body facts and rationale. Also edit the model page
whenever a probe changes a row's identity, lifecycle or authority. Imagination
wrote that page in 0b, and a model a cut contradicts is stale steering text.
Do not commit; the root agent commits. Do not change code in any repo. Scratch probes under
@ -183,8 +166,8 @@ output at most 150 lines. Wait only through `tools/wait-on.sh` (never `true`,
Python. Budget 40-80 calls and about 250k context.
Rehydrate (recipes: target in force, rulings in force, open questions and
follow-ups, specs with no report, known, one cut's record for <cuts that
constrain this one>).
follow-ups, specs with no report, one cut's record for <cuts that constrain
this one>).
Body facts to verify (not trust):
- <repo@SHA>
@ -224,9 +207,7 @@ step 3>, admit its first part as the spec, with its own verification, and add th
rest to the prose map's cut sequence.
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
merge on the operator's read: the steward merges on her own call (scar: PR 41,
"Does it, now?").
option, raised in the spec it blocks.
Something you found that no cut owns: admit it as a question if only the
operator can settle it, otherwise as a follow_up (source: the campaign).
@ -437,11 +418,7 @@ Commits:
- Stage explicit paths only; never `git add -A`.
- Never amend a commit or force-push. Self and other agents commit on the
same branch while you work. Fix a commit with a new commit. On a rejected
push of a cut branch, `git pull --rebase`, then push. A merge pass that
pushes to master never rebases on a rejected push: rebase flattens the merge
commit and drops merge-time fixes (master broke at Aetheria 07854ed5,
2026-10-13). It fetches, merges `origin/master` into its verified merge,
re-verifies what the new upstream commits change, and pushes.
push, `git pull --rebase`, then push.
- In Git Bash, one call: `git add <paths> && git commit -F- <<'MSG' ... MSG &&
git push`. Never commit from PowerShell (it writes a BOM and breaks
here-strings).
@ -652,36 +629,19 @@ what was missing and what it would have prevented.
Dispatch: `subagent_type: eyes`, model Sonnet.
```
Private: <yes: findings go to <path>, admit nothing | no>
Budget at most 40 calls: transcripts by script, never whole reads, waits only
through `tools/wait-on.sh`, writes only under `<scratchpad>/<agent>/`, Python as
`py -3`, and fetches batched in parallel.
Before searching, run `huginn known <the question in plain words>` and, for
legacy matter, `huginn sources`. Search the world only for what those do not
answer. Read the campaign's mind with the Rehydrate block (recipes: <as needed>).
`huginn schema research` prints the fields and bounds: `claim` (1000 bytes), `detail`
(4000), `sources` (16), and required `serves`, `detail`, `observed_on`.
Admit what you find as `research` notes through `huginn admit` (the strict door,
not the MCP tool), faculty Eyes: one document per claim, nothing else admitted.
Label each `<pass>.<label>`: the local part is 64 bytes in all, so the pass is at
most about 24 bytes and the label at most about 38. The first note is labelled
`scope`: your method and what you did not search. Every note has a source (a repo
pin, a web page, something you observed, or another document); `inferred: true`
names a claim you read from its sources rather than saw in them. A note is a fact,
never a recommendation. A batch is 1 to 64 documents; a larger pass spans batches
under one pass label. A wrong or superseded note is not edited: admit the new one
and leave the old to a steward. You resolve nothing. Raw output too long for a note
stays under `<scratchpad>/<agent>/` and the note cites it as an Observed capture.
If Private is yes, admit nothing and write to the file named. Report the pass label
and counts, not the findings again.
Write findings to <scratchpad>/<agent>/<name>.md and return a
short summary. For a prior-art or literature pass, Self commits that file to
<repo>/docs/research/<topic>.md before the fork goes to the operator (SKILL.md
step 2, "Research is always kept"); write it to be read there. Budget at most 40 calls: transcripts by script, never whole
reads, waits only through `tools/wait-on.sh`, writes only under
`<scratchpad>/<agent>/`, Python as `py -3`, and fetches batched in parallel. You read the campaign's mind with query and view only, through
the Rehydrate block (recipes: <as needed>); you never admit.
Sources:
- <repos and ranges>
- <transcripts> (grep with targeted patterns; never read whole)
- <docs>
Facts carry evidence pointers (SHA, file:line, transcript line).
Produce facts with evidence pointers (SHA, file:line, transcript line). Mark
anything inferred as "(inferred)".
```

View file

@ -87,8 +87,6 @@ mismatch. The root is the campaign slug, or the instance for `instance`,
| `verdict` | `cut-<cut>.s<pass>` | Soul | range, claims (each with an outcome, evidence, findings, promise, mutations) | `cut_report`, `findings` |
| `finding` | `cut-<cut>.s<pass>.<label>` | Soul, in the verdict's batch | confidence, severity, claim, invariants, locations, failure scenario, evidence, origin | `verdict` |
| `follow_up` | `<label>` | Self; Imagination for work no cut owns | source, repo, locations, item, why it can wait, owner | `source` |
| `research` | `<pass>.<label>` | Eyes | pass, label, claim, detail, inferred, sources (repo pin, web page, observed, document), serves, observed on; the pass's first note is `scope` | `serves`, document sources |
| `idea` | `<pass>.<label>` | Imagination | pass, label, title, idea, tradeoffs, rests on, toward, proposed on | `rests_on`, `toward` |
| `resolution` | `<subject kind>.<subject local>.n<seq>` | whoever closes the subject | subject, sequence, outcome, rationale, date | `subject`, and the outcome's referents |
| `hand_off` | `<to>.<repo>.<date>` | Self (parked: Cut 12) | from, to, repo, documents, reason | `documents` |
@ -300,14 +298,6 @@ reading its verdicts and findings, check that `admission.provenance.faculty` is
`ruling`, `resolution` and `finding` with `root` = `<c>`, plus
`"semantic": { "text": "<the question in plain words>", "top_k": 10 }`.
**Research and ideas:** `huginn known <text>` finds the nearest notes and ideas
in every campaign, closed ones included, with their status; `huginn pass
<campaign> <pass>` lists one pass whole; `huginn ideas <campaign>` lists the
ideas in force. Known is cross-campaign and shows closed notes; precedent is
rulings, resolutions and findings. Neither a note nor an idea stores a status:
its resolutions say what became of it. `huginn schema research|idea` prints the
shape.
Once the operator installs the hook, `tools/recall.py` also puts the nearest in-force
rulings, questions and follow-ups into every prompt; the recipe remains the
deliberate check before raising a question.
@ -370,11 +360,6 @@ invariants, not-in-scope list and canonical implementations are the typed
- **Admit only the ruling that answers a question.** Its `Answered`
resolution is derived in the same commit; admitting that resolution by hand
is refused `IdentityCollision`.
- **Re-mapping a withdrawn cut takes two batches.** First admit a `Withdrawn`
resolution on the spec's withdrawal resolution, which reopens the spec. Then,
in a second batch, admit revision N+1 with a `Superseded` resolution on
revision N. One batch holding both is refused `WouldReinstateOverLater`
(loot-2 r3, 2026-10-13). A fresh cut label at revision 1 needs neither.
- **A finding cannot be resolved `Superseded`** (`IncompatibleResolution`).
Resolve it `Recorded`, naming its successor in the reason.
- **Test negative greps before publishing them** in a spec's

View file

@ -2,24 +2,6 @@
Record each change to the skill together with the evidence that motivated it.
## 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`.
Evidence. Rulings `eureka-body:ruling:research-and-ideas-are-typed-documents` (operator: "Imagination and eyes should both return their findings in typed documents so we never lose any research or ideas"), `research-public-face-ruled` (the mind owns research; existing files stay; a generated projection is wanted) and `private-pass-home-ruled`; kinds and verbs from `cut-typed-research` h3 and s2 (release 809a93c answers `huginn known|pass|ideas`).
What changed. Eyes admits `research` (one note per claim, a source on each) and Imagination admits `idea`s besides its questions, cut_specs and follow_ups, both through `huginn admit` after reading `huginn known`. Self reads `known` before dispatching a pass and a question cites the notes it stands on in `raised_in`. The old path is removed: the Eyes facts file that admitted nothing, Self committing a prior-art file to `docs/research/` before the fork, and the Eyes `you never admit`. Committed `docs/research/` files stay untouched, read through `huginn sources`; the renderer that projects a public pass to `docs/research/` is not built, so a public pass lives in the mind only. A private pass admits nothing and returns to the file its brief names. `references/campaign-state.md` gains the `research` and `idea` rows and the `known`, `pass` and `ideas` recipes; the Eyes and Imagination charters gain the matching boundaries. No `tools/` change.
The crossing. Huginn client 809a93c; the first `research` and `idea` documents were admitted at 2026-11-05T10:24Z: receipts `mind-commit-8f5dd1a6f6860a6052a9797aa79bf336946c7b59e0e7fdc8d1228c3b9a88f826` (research `skill-cut-map.scope` and `.old-path`) and `mind-commit-fcae00c87ef2b2a74c9f09f0485a7e1ada6207dc749dcec12758a40c050b7030` (idea `skill-cut-map.backfill-old-research`). Huginn does not roll back by flipping `current` from here.
## 2026-11-02: inversion of control, originator evidence, persona charter once
Self, session `self-2026-11-02-eureka`.
Evidence. Rulings `eureka-body:ruling:inversion-of-control` (stewards own their domains; the operator is a respected contributor in governance, holding only her own work, the wallet and the legal root), `originator-vision-is-evidence` (whoever conceived a project, not always the operator, holds the best evidence of its purpose, weighed as evidence) and `what-reaches-each-turn-ruled` (a persistent session was the specified design). A brief gated a steward's merge on the operator's read (PR 41, "Does it, now?").
What changed. `SKILL.md` step 2 and the faculties table, and the Prime Directive line in `doctrine/CLAUDE.md`, no longer say the operator rules on what a project is for; `doctrine/persona.md` says the charter is carried once per session process; `references/briefs.md` says a spec or brief never gates a steward's merge.
## 2026-10-27: re-entry triage, second-beating trigger, pushed heads build
Self, sessions `self-2026-10-27-eureka` and `self-2026-10-27b-eureka`.
@ -35,13 +17,6 @@ Self, session `self-2026-10-23-ag8`.
Evidence. `C:\aeth-wt` held 255 GB, mostly 1.7-3.9 GB Unity worktrees from sessions ag3 to ag7. The ag7 handoff listed them as "worktrees to remove when idle". C: was at 1.4 GB free while the operator flew, and a new worktree checkout failed mid-write. A two-pass sweep removed about 140 worktrees and directories. Operator: "you really gotta start cleaning up after yourself".
What changed. `SKILL.md` git and tooling rules: Self removes a cut's worktrees in the turn the cut merges or is abandoned, parks unreferenced heads under `keep/` tags first, and checks free space before creating a worktree.
## 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
@ -58,15 +33,6 @@ Evidence. `aetheria-release:cut_report:cut-sensor-catalog.h4` names branch `eure
What changed. `SKILL.md`, Self's worktree bullet: the worktree's branch is the spec's `branch` field, spelled exactly.
## 2026-10-13: a merge pass never rebases on a rejected push
Self, session `self-2026-10-13-ag`.
Evidence. `aetheria-release:cut_report:cut-reveals-replace-tiers.h3`: the merge agent's first push to master was rejected because a docs commit had landed. Following the Hands brief ("On a rejected push, `git pull --rebase`, then push"), it rebased: that flattened the merge commit and dropped its merge-time fix to two test files, so Aetheria master 07854ed5 did not compile until the agent repaired it forward in 20cd1d7c. Master history now carries the cut's commits twice.
What changed. The Hands brief's commit rules keep `git pull --rebase` for cut branches only. A merge pass that pushes to master fetches, merges `origin/master` into its verified merge, re-verifies what the new upstream commits change, and pushes.
## 2026-10-12: no test run of any size on Starfire
Self, session `self-2026-10-12-eureka`.

View file

@ -17,4 +17,3 @@ gets a Soul pass before merge.
| `tools/context-pack.py` | `py -3 tools/context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>\|r<k>[,...]] [--spec-json <file>] [--max-lines <n>]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r<k>`; 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 <sha>` 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 `<absolute python.exe> 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 <missing script>` 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; 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 |

View file

@ -1,441 +0,0 @@
#!/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.
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. 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
visitors python3-svgelements and python3-shapely.
Everything from the document is escaped, and LaTeX runs with no shell escape.
"""
import argparse, colorsys, json, math, 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
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}
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", "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
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:
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,
"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)
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)
(d / "spec.json").write_text(json.dumps(
{"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)
(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 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")
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()

View file

@ -1,640 +0,0 @@
"""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": [[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. 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
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, 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 {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, 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]
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 = [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; an open line swells between thin ends
w = max(0.14, min(0.7, m["width"] * r * 0.95))
if m["closed"]:
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)
# ---------------------------------------------------------------- 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)}}}")
for vx, vy, vr, marks in visitors:
draw_visitor(vx, vy, vr, marks)
lines.extend(out)
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}")

View file

@ -1,103 +0,0 @@
% 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{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;}
\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}

View file

@ -1,71 +0,0 @@
#!/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'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">{body}</svg>')
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 = '<circle cx="50" cy="50" r="40" fill="#f80"/>'
refused("apart", '<circle cx="20" cy="20" r="8" fill="#f80"/><circle cx="80" cy="80" r="8" fill="#f80"/>', "2 separate")
refused("apart lines", '<path d="M5 5 L20 5 M5 90 L20 90" fill="none" stroke="#f80"/>', "2 separate")
refused("gradient", '<circle cx="50" cy="50" r="40" fill="url(#g)"/>', "gradient")
refused("gradient stroke", '<circle cx="50" cy="50" r="40" fill="none" stroke="url(#g)"/>', "gradient")
refused("text", DISC + '<text x="5" y="5">hi</text>', "text")
refused("image", DISC + '<image width="9" height="9" xlink:href="data:image/png;base64,iVBORw0KGgo="/>', "image")
refused("nothing", '<circle cx="50" cy="50" r="40" fill="none"/>', "fill or a stroke")
m = read_visitor(svg(DISC))
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('<path d="M10 10 L90 10 L50 90" fill="none" stroke="#59B7FF" stroke-width="2"/>'))
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('<circle cx="50" cy="50" r="40" fill="#8F84FF" stroke="#FF8A2A" stroke-width="1"/>'))
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('<path d="M50 5 L95 95 L5 95Z"/>'))
check("an all-black icon is seen", m[0]["colour"] == "neonwhite")
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#07111A"/>'))
check("black on colour is a cutout", m[1]["colour"] == "ground" and not m[1]["outlined"])
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#59B7FF"/>'))
check("a small bare fill stays solid", not m[1]["outlined"])
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="#07111A"/><circle cx="50" cy="50" r="5" fill="#f80"/>'))
check("a large cutout stays a cutout", m[0]["colour"] == "ground" and not m[0]["outlined"])
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#07111A"/>'))
check("paint order is kept", [x["colour"] for x in m] == ["neonorange", "ground"])
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="none" stroke="#f80"/><circle cx="35" cy="35" r="5" fill="#fff"/><circle cx="65" cy="35" r="5" fill="#fff"/>'))
check("eyes inside an outline count as touching", len(m) == 3)
m = read_visitor(svg('<path fill-rule="evenodd" fill="#f80" d="M10 10 H90 V90 H10Z M40 40 H60 V60 H40Z"/>'))
check("a hole stays a ring of the solid", len(m) == 1 and len(m[0]["rings"]) == 2)
m = read_visitor(svg('<g transform="translate(30 10) scale(0.2)"><rect width="100" height="300" fill="#f80"/></g>'))
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)

View file

@ -135,13 +135,11 @@ if [ "$1" = --host ]; then
trap cleanup EXIT; trap 'exit 143' TERM INT HUP
killed() { [ -n "${YGG_KILLED:-}" ] && [ -e "$YGG_KILLED" ]; }
now() { date +%s; }
# The shared volume is CARGO_HOME itself so sibling containers share cargo's package-cache lock; a registry-only mount left
# the lock in each container's layer and concurrent unpacks removed each other's crate directories (2026-11-03).
# Every container carries the job label (the stopgap's watchdog kills by it) and the same caps.
common=(--label "$YGG_LABEL" "--cpus=${YGG_CPUS:-4}" "--memory=${YGG_MEM:-6g}"
-v "$YGG_WORK:/seed:ro" -v "$D:/cm:ro" -v /etc/machine-id:/etc/machine-id:ro
-v eureka-cargo-home:/cargo-home -v eureka-nuget:/root/.nuget/packages -v eureka-sccache:/sccache
-e BASH_ENV=/tmp/.ygg-bash-env -e CARGO_HOME=/cargo-home
-v eureka-cargo-registry:/usr/local/cargo/registry -v eureka-nuget:/root/.nuget/packages -v eureka-sccache:/sccache
-e BASH_ENV=/tmp/.ygg-bash-env
-e "WORKDIR=${WORKDIR:-.}" -e "BASE=$BASE" -e "TEST_ARGS=${TEST_ARGS:-}" -e "FILTERS=${FILTERS:-}" -e "CM_NAMES=${CM_NAMES:-}" -e "JOBS=$JOBS"
-e "MIN_TEST_TIMEOUT=$MIN_TEST_TIMEOUT" -e "CONFIRM_TIMEOUT_MULT=$CONFIRM_TIMEOUT_MULT"
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e 'GIT_CONFIG_VALUE_0=*')
@ -210,7 +208,7 @@ if [ "$1" = --host ]; then
container_error() { # container_error <label> <out dir>
local rc; rc=$(cat "$2/hostrc" 2>/dev/null)
sum "ERROR $1 container exited ${rc:-?} (4 is a red baseline: the unmutated tests failed there)"
tail -n 25 "$2/stdout" 2>/dev/null | sed "s|^|$SUMTAG: ERROR |"
tail -n 5 "$2/stdout" 2>/dev/null | sed "s|^|$SUMTAG: ERROR |"
}
# ---- stage 1: files whose filter selects tests (the plan container derived each file's filter and counted its tests)

View file

@ -122,8 +122,8 @@ if [ "$1" = --host ]; then
# Every container carries the job label (the stopgap's watchdog kills by it) and the same caps.
common=(--label "$YGG_LABEL" "--cpus=${YGG_CPUS:-4}" "--memory=${YGG_MEM:-6g}"
-v "$bundle:/mt:ro" -v /etc/machine-id:/etc/machine-id:ro
-v eureka-cargo-home:/cargo-home -v eureka-nuget:/root/.nuget/packages -v eureka-sccache:/sccache
-e BASH_ENV=/tmp/.ygg-bash-env -e CARGO_HOME=/cargo-home
-v eureka-cargo-registry:/usr/local/cargo/registry -v eureka-nuget:/root/.nuget/packages -v eureka-sccache:/sccache
-e BASH_ENV=/tmp/.ygg-bash-env
-e "WORKDIR=${WORKDIR:-.}" -e "BUILD=${BUILD:-}" -e "TEST=${TEST:-}" -e "MUT_TIMEOUT=${MUT_TIMEOUT:-600}" -e "JOB_TIMEOUT=${JOB_TIMEOUT:-}"
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e 'GIT_CONFIG_VALUE_0=*')
[ -z "${YGG_NONCE:-}" ] || common+=(-e "YGG_NONCE=$YGG_NONCE")

View file

@ -20,7 +20,7 @@
# the commit out detached. It is a real clone, not a worktree, because a
# worktree's .git file points at a host path the container cannot see.
# 3. Runs <command> in <image> under a CPU and memory cap, niced. Each
# toolchain gets one shared cargo home (registry and package-cache lock). The build output directory
# toolchain gets one shared registry cache. The build output directory
# stays inside the work directory and is never shared between checkouts.
# CARGO_TARGET_DIR is deliberately NOT set: cargo's default (<workspace>/
# target) is already inside /src, and forcing it made cargo-mutants' --jobs
@ -405,12 +405,10 @@ else
# Priority is set inside the container: nice on the docker client does not reach a process
# containerd starts. Jobs run at nice 10 and idle IO class, so the swarm's daemons and turns
# on the same disk go first (2026-10-31: three verify jobs held IO pressure at ~75% full).
# Cargo's package-cache lock lives in $CARGO_HOME, so the shared volume is CARGO_HOME itself: a registry-only mount left the
# lock in each container's layer and sibling containers unpacking one crate removed each other's directories (2026-11-03).
sudo docker run --rm --name "$cname" --label "$label" --cpus="$cpus" --memory="$mem" \
-v "$work:/src" -v /etc/machine-id:/etc/machine-id:ro \
-v eureka-cargo-home:/cargo-home -v eureka-nuget:/root/.nuget/packages \
-v eureka-sccache:/sccache -e CARGO_HOME=/cargo-home \
-v eureka-cargo-registry:/usr/local/cargo/registry -v eureka-nuget:/root/.nuget/packages \
-v eureka-sccache:/sccache \
-e BASH_ENV=/tmp/.ygg-bash-env ${nonce:+-e YGG_NONCE=$nonce} \
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0='*' \
$docker_args \