Merge branch 'main' into docgen-graphgen

This commit is contained in:
2026-09-13 21:41:29 -03:00
24 changed files with 2570 additions and 196 deletions

View File

@@ -1,7 +1,7 @@
"""Render per-room manifests for deploy into the shared `spr` kind cluster.
The `spr` cluster itself is created via `ctrl/kind-up.sh` at the repo root
(one cluster, all rooms). Each room becomes a namespace inside it.
The `spr` cluster itself is created by `make cluster up` at the repo root,
which hands spr's shape to rig to build (one cluster, all rooms). Each room becomes a namespace inside it.
Called from build.py when a room opts in to k8s output. Emits:

View File

@@ -395,21 +395,21 @@ resources:
# ─── Lifecycle scripts ──────────────────────────────────────────────
# These target the shared `spr` kind cluster (created via repo-root
# ctrl/kind-up.sh). Each room owns a namespace inside that cluster.
# These target the shared `spr` kind cluster (created by `make cluster up`
# at the repo root, which builds it with rig). Each room owns a namespace inside that cluster.
def k8s_up_sh(*, room: str, cluster: str, nodeport: int) -> str:
return f"""\
#!/bin/bash
# Apply the "{room}" room into the shared `{cluster}` kind cluster.
# (Run repo-root ctrl/kind-up.sh first if the cluster doesn't exist.)
# (Run 'make cluster up' at the repo root first if the cluster doesn't exist.)
set -e
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
K8S_DIR="$SCRIPT_DIR/k8s"
if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then
echo "Kind cluster '{cluster}' not found — run ctrl/kind-up.sh from the repo root first."
echo "Kind cluster '{cluster}' not found — run 'make cluster up' from the repo root first."
exit 1
fi
@@ -432,7 +432,7 @@ def k8s_down_sh(*, room: str, cluster: str) -> str:
return f"""\
#!/bin/bash
# Remove the "{room}" namespace from the shared `{cluster}` cluster.
# Leaves the cluster itself running (use repo-root ctrl/kind-down.sh to drop everything).
# Leaves the cluster itself running (use 'make cluster down' at the repo root to drop everything).
set -e
CTX="kind-{cluster}"

View File

@@ -0,0 +1,4 @@
# Local job: which repos this machine distills is not a fact about the tool, and
# the list names whatever those repos are. Copy distill-example.json to
# distill.json and edit that; it stays here.
distill.json

View File

@@ -0,0 +1,26 @@
{
"_comment": "Template for distill.json, the job distill.sh reads when no repo is named on the command line. Copy this to distill.json (gitignored) and edit that. Everything that shapes the run is at the top; 'repos' is just a list of paths. Preview any change with: ./distill.sh list",
"_command": "tree = a directory per repo. digest = one .md per repo, flattened into a single readable file. both = each repo as a directory AND a .md. list = write nothing, just report what would be kept.",
"command": "list",
"out": "distilled",
"_branch_mode": "full = each branch is a complete, standalone copy. diff = only the files that differ from diff_base (it gets a _PARTIAL.md saying so).",
"branch_mode": "full",
"diff_base": "main",
"_filters": "Applied to every repo. exclude/include are path globs; a pattern with no / also matches basenames at any depth. 'all' keeps the noise (lockfiles, images, minified, maps). max_bytes skips anything larger and names it in MANIFEST.md.",
"exclude": [],
"include": [],
"all": false,
"max_bytes": null,
"skip_unchanged": true,
"prune": true,
"_repos": "'path' is absolute, or a name resolved under --root. 'branches' is optional — leave it out for the working tree as it stands, uncommitted changes included. A path may appear more than once.",
"repos": [
{ "path": "/path/to/some-repo" },
{ "path": "/path/to/another-repo", "branches": ["origin/main", "origin/feature/example"] }
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,75 @@
# explode — one file back into the tree it describes
The other half of `distill.sh`. Together they are a round trip:
```
distill.sh digest -> one file -> paste into a chat -> the reply -> explode.sh
```
`distill` flattens repos into a single readable file so they fit somewhere that
only takes text. `explode` takes the answer and writes it back onto disk. Neither
is much use without the other.
```bash
./explode.sh --list reply.md # what is in there; writes nothing
./explode.sh -o ./restored reply.md # write the tree
./explode.sh -o ./restored --force x.md # overwrite what is already there
./explode.sh --contract > contract.txt # the format to hand to the model
./explode.sh --selftest # check this copy against known input
```
## Ask for `@@`, and attach it
`--contract` prints the output format to give whatever writes the reply. **Attach
that file; do not paste it into the message.** A chat box renders markdown before
the model sees it, and `===` alone under a line of text is setext syntax for a
heading — so a pasted spec gets rendered as a title and the model is told nothing.
`---` is worse, `##` is a heading, backticks open a fence. `@@` means nothing in
markdown, which is exactly why it is the marker to ask for.
Keeping the wording in `--contract` rather than in a note somewhere means what you
ask for cannot drift from what the parser accepts.
## Layouts
Four shapes are recognised, picked automatically; `--format` overrides the guess.
| shape | when |
| --- | --- |
| `@@ FILE: path``@@ END` | **ask for this** — explicit, and invisible to markdown |
| `=== FILE: path``=== END` | the same thing, still read; do not ask for it |
| `=== path` marker | a marker line, then the file until the next one |
| `## path` + fenced block | `distill.sh`'s own digest |
Explicit open and close is worth insisting on: a writer emitting plain three-backtick
fences silently truncates any file that itself contains a fence — every README with a
shell example — because the nested fence looks exactly like the closing one.
## One reply, several projects
A thread usually touches more than one repo, and produces one file regardless. The
contract asks for paths that begin with the project name, so point `-o` at the
directory those projects sit in and each file lands in its own worktree:
```bash
./ctrl/explode.sh --list bundle.txt # what is in there, write nothing
./ctrl/explode.sh -o ./restored bundle.txt # write the tree
./ctrl/explode.sh -o ./restored --force x.md # overwrite what is already there
./explode.sh -o ~/wdir ~/Downloads/reply.md
```
No mapping table to maintain, and a new project needs no change here.
## Refusals
Paths come out of a text file, so they are untrusted. Anything absolute or reaching
upward with `..` is refused and **nothing** is written — the check runs over the whole
input before the first file is created. An unclosed block is refused too, rather than
writing the file short. Existing files are never overwritten without `--force`.
A digest whose files `distill` **clipped** to fit a token budget is refused for the
same reason: the document holds only their head and tail, and a truncated file that
reads complete is the failure the whole format exists to prevent. The tree copy beside
the digest has them whole — take them from there.
Two limits worth knowing: a file whose last line had no trailing newline comes back
with one, and in the bare `=== path` layout a line starting with `=== ` inside a file's
own content cannot be told from a real marker. The digest and `@@` layouts have no such
ambiguity.

View File

@@ -1,18 +1,22 @@
#!/usr/bin/env bash
# Explode one file back into the tree of files it describes.
#
# The inverse of ctrl/distill.sh's digest: something hands you a single text
# The inverse of distill.sh's digest: something hands you a single text
# file with many files inside it, each introduced by its path, and you want the
# directory back.
#
# Three layouts are understood, picked automatically. Prefer the first if you
# control what writes the file:
#
# === FILE: pkg/models/domain.py explicit open and close. Nothing has to be
# @@ FILE: pkg/models/domain.py explicit open and close. Nothing has to be
# <the file> counted or inferred, and a block that is
# === END never closed is an error rather than a
# @@ END never closed is an error rather than a
# file quietly missing its tail.
#
# === FILE: pkg/models/domain.py the same thing with '===' instead of '@@'.
# <the file> Still read, but do not ask for it: see the
# === END note on markdown below.
#
# === ./pkg/models/domain.py a marker line, then the file, until the
# <the file> next marker or the end
#
@@ -30,12 +34,15 @@
# -n same as --list
# --force overwrite files that already exist
# --format F fenced | marker | digest | auto (default: auto)
# --contract print the output format to hand to whatever generates the file
# --selftest check this copy of the script against known input and exit
#
# Examples:
# explode.sh --list bundle.txt
# explode.sh -o ./restored bundle.txt
# explode.sh -o ./restored --force repo.md
# explode.sh --contract > /tmp/contract.txt # attach this, do not paste it
# explode.sh -o ~/wdir ~/Downloads/reply.md # one reply, every project
#
# Why the explicit form is worth asking for: a writer that emits plain three-
# backtick fences truncates any file that itself contains a fence — every README
@@ -43,6 +50,24 @@
# exactly like the closing one. distill.sh avoids that by making its fences
# longer than anything inside the file, but nothing else will bother.
#
# Ask for '@@', not '==='. A chat box renders markdown before the model sees the
# message, and '===' alone on a line directly under text is setext syntax for a
# level-one heading — so the format spec you paste gets swallowed and rendered as
# a title, and the model is told nothing. '---' is worse (heading AND horizontal
# rule), '##' is a heading, backticks open a fence. '@@' has no meaning in
# markdown at all, which is the whole reason to use it. Both are parsed here, so
# nothing already written stops working.
#
# The other half of that: put the spec in an attached file rather than the
# message body. Attachments are not rendered. '--contract' prints the exact text
# to attach, so the wording cannot drift from the parser that reads the reply
# back.
#
# One reply, several projects. A thread produces one file however many repos it
# touched, so --contract asks for paths that start with the project name. Point
# -o at the directory those projects are siblings in and each file lands in its
# own worktree — no table to maintain, and a new worktree needs no change here.
#
# Two limits worth knowing. A file whose last line has no trailing newline comes
# back with one: the digest has to put a newline before the closing fence, so the
# distinction is not in the input to recover. And in marker layout a line
@@ -66,6 +91,7 @@ FORCE=""
FORMAT="auto"
SRC=""
SELFTEST=""
CONTRACT=""
while [ $# -gt 0 ]; do
case "$1" in
@@ -73,6 +99,7 @@ while [ $# -gt 0 ]; do
--list|-n) LIST=1 ;;
--force) FORCE=1 ;;
--format) shift; FORMAT="${1:-}" ;;
--contract) CONTRACT=1 ;;
--selftest) SELFTEST=1 ;;
-h|--help) usage; exit 0 ;;
-*) die "unknown option: $1" ;;
@@ -81,6 +108,43 @@ while [ $# -gt 0 ]; do
shift
done
# ── the contract ───────────────────────────────────────────────────────────
# One copy of the wording, printed rather than remembered, so what you ask for
# and what this parses cannot drift apart. Attach it; do not paste it into the
# message body, where markdown gets a say first.
contract() {
cat <<'CONTRACT'
OUTPUT FORMAT
Return every file you changed or created in full, one after another, using
exactly this shape and nothing else:
@@ FILE: <project>/relative/path/to/file.py
<the complete contents of the file>
@@ END
Rules:
- One @@ FILE: line per file, and a matching @@ END line after its last line.
- Start every path with the project it belongs to, spelled exactly as the
heading of the document it came from, then the path relative to that
project's root. One reply covers every project we touched; the prefix is
the only thing that says which file goes where, so it is never optional
and never abbreviated.
- No leading ./ or /.
- Between @@ FILE: and @@ END, emit the file verbatim. Do not wrap it in
markdown fences, do not add line numbers, do not elide anything as
"unchanged" or "...". A partial file is worse than no file.
- Anything you want to say to me goes outside the blocks, before the first
@@ FILE: or after the last @@ END. Text between blocks is ignored.
- Return whole files only. No diffs, no patches, no hunks.
- If a file's own content happens to contain a line starting with @@, say so
in your prose so I know to check that block by hand.
CONTRACT
}
if [ -n "$CONTRACT" ]; then contract; exit 0; fi
# ── self-test ──────────────────────────────────────────────────────────────
# So a copy of this script on another machine can be checked without any real
# input, and without asking whether it is the version that knows a given format.
@@ -137,6 +201,21 @@ FIXTURE
check "wrong parser: refused" "1" "$([ -e "$t/d" ] && echo 0 || echo 1)"
# The other two layouts still work.
# The @@ markers, which are the ones to ask a chat model for.
printf '@@ FILE: pkg/a.py\nx = 1\n@@ END\n@@ FILE: b.md\n# t\n\n```sh\nls\n```\n@@ END\n' > "$t/g.txt"
"$0" -o "$t/g" "$t/g.txt" >/dev/null 2>&1 || true
check "at-markers: file count" "2" "$(find "$t/g" -type f 2>/dev/null | wc -l)"
check "at-markers: fenced body" "2" "$(grep -c '```' "$t/g/b.md" 2>/dev/null || echo 0)"
printf '@@ FILE: a.py\nx = 1\n' > "$t/h.txt"
"$0" -o "$t/h" "$t/h.txt" >/dev/null 2>&1 || true
check "at-markers: unterminated" "1" "$([ -e "$t/h" ] && echo 0 || echo 1)"
# One reply, several projects: the prefix is just the first directory.
printf '@@ FILE: projA/a.py\nx = 1\n@@ END\n@@ FILE: projB/deep/b.py\ny = 2\n@@ END\n' > "$t/i.txt"
"$0" -o "$t/i" "$t/i.txt" >/dev/null 2>&1 || true
check "multi-project: first" "x = 1" "$(cat "$t/i/projA/a.py" 2>/dev/null)"
check "multi-project: nested" "y = 2" "$(cat "$t/i/projB/deep/b.py" 2>/dev/null)"
printf '=== ./x/y.py\nz = 1\n' > "$t/e.txt"
"$0" -o "$t/e" "$t/e.txt" >/dev/null 2>&1 || true
check "marker layout" "z = 1" "$(cat "$t/e/x/y.py" 2>/dev/null)"
@@ -145,6 +224,19 @@ FIXTURE
"$0" -o "$t/f" "$t/f.txt" >/dev/null 2>&1 || true
check "digest layout" "z = 1" "$(cat "$t/f/x/y.py" 2>/dev/null)"
# What distill actually writes: a metadata line between the heading and the
# fence, and prose sections whose heading is followed by no fence at all.
printf '# d\n\n## Tree\n\nx/\n y.py\n\n## x/y.py\n\n_1 lines · 6 bytes_\n\n```python\nz = 1\n```\n' > "$t/j.txt"
"$0" -o "$t/j" "$t/j.txt" >/dev/null 2>&1 || true
check "digest: metadata line" "z = 1" "$(cat "$t/j/x/y.py" 2>/dev/null)"
check "digest: prose skipped" "1" "$(find "$t/j" -type f 2>/dev/null | wc -l)"
# A clipped file is head and tail only. Writing it would truncate the real
# one, so the whole run is refused.
printf '# d\n\n## x/y.py\n\n_900 lines · 60000 bytes · CLIPPED — head and tail only_\n\n```python\nz = 1\n```\n' > "$t/k.txt"
"$0" -o "$t/k" "$t/k.txt" >/dev/null 2>&1 || true
check "digest: clipped refused" "1" "$([ -e "$t/k" ] && echo 0 || echo 1)"
echo
if [ "$rc" -eq 0 ]; then echo "all checks passed — this copy is current"
else echo "SOME CHECKS FAILED — this copy is out of date or broken" >&2
@@ -163,9 +255,9 @@ case "$FORMAT" in fenced|marker|digest|auto) ;; *) die "--format must be fenced,
# markdown will contain plenty of '=== ' inside its own fenced content, and a
# marker file can quote a '## ' heading just as easily.
if [ "$FORMAT" = auto ]; then
n_fenced=$(grep -cE '^=== +FILE: +[^ ]' "$SRC" || true)
n_fenced=$(grep -cE '^(===|@@) +FILE: +[^ ]' "$SRC" || true)
n_marker=$(grep -cE '^=== +\.?/?[^ ]' "$SRC" || true)
n_marker=$((n_marker - n_fenced - $(grep -cE '^=== +END[ \t]*$' "$SRC" || true)))
n_marker=$((n_marker - n_fenced - $(grep -cE '^(===|@@) +END[ \t]*$' "$SRC" || true)))
[ "$n_marker" -lt 0 ] && n_marker=0
n_digest=$(grep -cE '^## +[^ ]' "$SRC" || true)
if [ "$n_fenced" -gt 0 ]; then
@@ -220,7 +312,7 @@ parse() {
# Explicit open/close. The whole point is that nothing is inferred:
# content is content until the END line, whatever it looks like.
fmt == "fenced" && path == "" && /^=== +FILE: +/ {
fmt == "fenced" && path == "" && /^(===|@@) +FILE: +/ {
p = substr($0, index($0, "FILE:") + 5)
sub(/^[ \t]+/, "", p)
p = clean(p)
@@ -228,7 +320,7 @@ parse() {
open_file(p)
next
}
fmt == "fenced" && path != "" && /^=== +END[ \t]*$/ { flush(); next }
fmt == "fenced" && path != "" && /^(===|@@) +END[ \t]*$/ { flush(); next }
fmt == "fenced" && path == "" { next } # anything between blocks is prose
fmt == "marker" && /^=== +/ {
@@ -256,6 +348,22 @@ parse() {
}
fmt == "digest" && expect == 1 {
if ($0 ~ /^[ \t]*$/) next # blank line between the two
# distill.sh puts an italic "N lines, B bytes" line under each heading,
# so the fence is no longer the next thing after it. Step over that
# line rather than reading it as prose — without this, every file in
# a current digest is skipped and the whole document looks empty.
if ($0 ~ /^_.*_[ \t]*$/) {
# Unless it says the file was clipped. A clipped body is head
# and tail with a marker in between; writing it out would
# replace a real file with a truncated one that reads complete,
# which is the exact failure the unterminated check exists for.
if ($0 ~ /CLIPPED/) {
print "CLIPPED\t" pending
bad = 1; expect = 0; pending = ""
next
}
next
}
if ($0 ~ /^`{3,}/) { # a fence: this is a file
match($0, /^`+/)
fence = substr($0, 1, RLENGTH)
@@ -302,7 +410,7 @@ fi
# looks complete is the failure this format exists to prevent.
wrongfmt="$(printf '%s\n' "$scan" | grep '^WRONGFMT' || true)"
if [ -n "$wrongfmt" ]; then
echo "$SELF: this file uses '=== FILE: path' / '=== END', but it was read as" >&2
echo "$SELF: this file uses 'FILE: path' / 'END' markers, but it was read as" >&2
echo "the plain marker format, which would create a directory called 'FILE: .'" >&2
echo "and files called 'END'. Re-run with --format fenced, or update this script." >&2
exit 1
@@ -310,13 +418,25 @@ fi
unterminated="$(printf '%s\n' "$scan" | grep '^UNTERMINATED' || true)"
if [ -n "$unterminated" ]; then
echo "$SELF: refusing — this block was never closed with '=== END':" >&2
echo "$SELF: refusing — this block was never closed with '@@ END' or '=== END':" >&2
printf '%s\n' "$unterminated" | sed 's/^UNTERMINATED\t/ /' >&2
echo "the file it describes would be silently truncated" >&2
exit 1
fi
listing="$(printf '%s\n' "$scan" | grep -vE '^(UNSAFE|UNTERMINATED|WRONGFMT)' || true)"
# distill clips the largest files to fit a budget, and says so. The tree copy
# beside the digest has them whole, so the fix is to take them from there — not
# to write out the head and tail under the real name.
clipped="$(printf '%s\n' "$scan" | grep '^CLIPPED' || true)"
if [ -n "$clipped" ]; then
echo "$SELF: refusing — distill clipped these, so the digest has only their" >&2
echo "head and tail:" >&2
printf '%s\n' "$clipped" | sed 's/^CLIPPED\t/ /' >&2
echo "take them from the tree copy instead; writing these would truncate them" >&2
exit 1
fi
listing="$(printf '%s\n' "$scan" | grep -vE '^(UNSAFE|UNTERMINATED|WRONGFMT|CLIPPED)' || true)"
[ -n "$listing" ] || die "no files found in $SRC (format: $FORMAT)"
count=$(printf '%s\n' "$listing" | grep -c . )