Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# the whole test suite. See CONTRIBUTING.md.
- name: Run script self-checks
run: |
for s in render notes gallery export transcript tags retag slides themes; do
for s in render notes gallery export transcript tags retag slides themes genres; do
echo "--- $s"
uv run skills/take-notes/scripts/$s.py --selftest
done
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ All notable changes to this project are documented here, in the
written by hand; the version and the tag come from `cz bump` — see
[AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md).

## [1.6.0]

### Added

- **Your own genres.** A genre is now one Markdown file — a front-matter block
naming its layout, its card label and the one-line "the source is…" the
router reads, then the sections the skill writes — and `~/take-notes/genres/`
is scanned beside the three that ship. Pick one of the three layouts, write
the sections, and it is routable the moment it has a `when:` line; leave that
out and it is used only by name with `--genre`, the safe way to try one. A
file named like a bundled genre replaces it, so a copy of `recipe.md` with
different facts on the card is your own recipe genre.
- **The router is generated, not written.** `scripts/genres.py --list` prints
Step 3's table from the contracts' front-matter, in precedence order — user
genres first, then `recipe`, `fieldguide`, `offprint` as the catch-all — and
`check-drift.py` refuses a router row written back into `SKILL.md` by hand.

### Changed

- **The offprint's contract moved to `genres/offprint.md`**, in the same shape
as the other two. `SKILL.md` is 52 lines shorter for every note, and the
three genres are now defined the same way — the precondition for letting a
user define a fourth.
- Every note template records its genre on `<html data-genre>`, the offprint's
two included, so a user genre on the offprint layout reads back as itself.
- **Recipes are a cookbook page, not a study note.** Out go *The one
takeaway*, *Concepts* and *Going deeper*; the sections are now *Description*,
*Card*, *Ingredients*, *Steps*, *Tips*, and the optional *Gallery* and *Gaps*
— what the source never measured or said, named and never filled in. The
card has a fixed core on every recipe so they compare across the archive —
*Category* (a closed list), *Yield* (*1 loaf*, *24 cookies*, not servings),
*Active time*, *Total time*, *Setup* (oven and tin together) — plus a few
fields per category, like *Proof* for bread. Difficulty and unit conversions
are no longer invented. A recipe now exports no Anki cards, having neither
a takeaway nor concepts.
- **`SKILL.md` carries only what every note needs** — 434 lines to 327 per
invocation. Settings management (`--tags`, `--theme`, `--retag`) moved to
`references/settings.md`, and everything about combining several URLs into
one note — spread over Steps 1, 3 and 5 — into `references/combining.md`;
each is read only when its case arises. The *Related* section, cross-references
to other skills that no note needed, is gone.

## [1.5.0]

### Added
Expand Down
79 changes: 54 additions & 25 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,13 @@ cd take-notes

No build, no virtualenv, no install step. `uv` provisions Python per script.

### 6. A genre is a contract plus a template

`SKILL.md` Step 3 routes a source's *content* to a genre; each genre is one body
contract and one template, and the offprint is the default. The offprint's
contract stays inline in `SKILL.md` (it is the common case and saves a Read per
note); every other genre's lives in `skills/take-notes/genres/<name>.md`.
Adding one means:

1. a row in the Step 3 table, above the offprint's catch-all;
2. `genres/<name>.md` — the sections, in `## Sections`' voice, plus the class
vocabulary the template styles (the one exception to "no classes");
3. `assets/<name>-template.html`, self-contained like the others, carrying the
landmarks in the table above, `{{PALETTE}}`, `{{FONTS}}`,
`data-genre="{{GENRE}}"`, and `{{MASTHEAD}}` where the poster or kicker goes;
4. an entry in `GENRE_TEMPLATES` in `scripts/render.py`, a label in both
languages of `UI` in `scripts/gallery.py`, and asserts in each `--selftest`.

The theme is orthogonal: a genre template consumes the palette tokens and never
hard-codes a colour.

## Verify

There is no test suite. Each script with non-trivial logic carries its own
asserts behind `--selftest`, and all of them must pass before a PR:

```sh
for s in render notes gallery export transcript tags retag slides themes; do
for s in render notes gallery export transcript tags retag slides themes genres; do
uv run skills/take-notes/scripts/$s.py --selftest
done
```
Expand All @@ -53,8 +33,9 @@ uv run scripts/check-drift.py
```

It fails if never-auto-invoke stops agreeing across its two files, if the Step 1
catch-all row moves above a more specific one, if the table and `references/`
stop naming the same guides, if a guide grows a copy of the writing standard, or
catch-all row moves above a more specific one, if a file under `references/`
is reachable from nowhere in `SKILL.md`, if an acquisition guide grows a copy of
the writing standard, or
if one of a handful of load-bearing rules disappears from `SKILL.md`. It is
development tooling and is not shipped inside the skill.

Expand Down Expand Up @@ -119,12 +100,18 @@ conversation. They must move together.

### 2. `SKILL.md` holds the only copy of the note-writing standard

The guides under `references/` cover **acquisition only**. Each one hands back
The guides Step 1 routes to cover **acquisition only**. Each one hands back
the same five fields (title, byline, span, canonical URL, body) whatever the
source, and knows nothing about how notes are written. Adding a source means
adding one guide and one routing row — never a second copy of the writing
rules, which would immediately drift from the first.

`references/` also holds two **procedures** that are not acquisition guides
and are read only when their case arises: `settings.md` (Step 0 — `--tags`,
`--theme`, `--retag`) and `combining.md` (Step 1 — several URLs into one
note). That is the skill's progressive disclosure: `SKILL.md` carries what
every note needs, and nothing a minority of invocations needs.

The routing table in `SKILL.md` Step 1 is matched **top to bottom, first match
wins**. `arxiv.org`, `docs.google.com/presentation` and `github.com` are
`http(s)` pages, so they must stay above the catch-all `web.md` row or they will
Expand All @@ -140,7 +127,7 @@ styling:
|---|---|---|
| `.poster`, `.kicker`, `.meta`, `.watch` | both note templates | the masthead parser |
| `.tags` / `.tag` / `.tag.is-primary`, immediately above the literal `<div id="index">` | both note templates | the tag parser, the gallery's chips, and `retag.py`'s insertion anchor |
| `<html data-genre="…">` | the genre templates (absent = offprint) | the genre parser, the gallery's card label, the Markdown frontmatter |
| `<html data-genre="…">` | every note template (absent = a note older than genres, an offprint) | the genre parser, the gallery's card label, the Markdown frontmatter |
| `<article id="body">` with a flat run of `<h2>` — and no `<article>` inside it | every note template, and every genre contract | the section splitter, which ends the body at the first `</article>` |
| `<li><strong>term</strong> — definition</li>` | `SKILL.md` Key points / Concepts | the Anki card builder |

Expand Down Expand Up @@ -176,6 +163,48 @@ own type). `--selftest` enforces the contrast floors, that the display weight is
one the family actually loads, and that the payload survives the round trip the
gallery sends it on.

### 6. A genre is a contract; a layout is a template

`SKILL.md` Step 3 routes a source's *content* to a genre. A genre is one file,
`skills/take-notes/genres/<name>.md`, for all three that ship — the offprint's
contract is no longer inline in `SKILL.md`. The file opens with a flat
front-matter block the scripts read (`genres.py` parses it; no YAML library):

```
---
layout: recipe # offprint | fieldguide | recipe — the template
label-en: recipe # the gallery card's label
label-es: receta
when: a dish — ingredients with quantities and a method with times
---
```

`when` is the genre's row in the router. `genres.py --list` generates the
table Step 3 reads from these lines, in precedence order — user genres first,
then `recipe`, `fieldguide`, `offprint` (`BUNDLED_ORDER`) — so the table is
never written by hand and `check-drift.py` refuses a row that is. A genre
without `when` is on request only (`--genre <name>`). Users add genres under
`~/take-notes/genres/`, and one named like a bundled genre replaces it.

Adding a **bundled genre** means:

1. `genres/<name>.md` — the front-matter above, then **only the genre's own
sections**, in the voice of the other contracts, plus the class vocabulary
the template styles (the one exception to "no classes"). The only rule
every genre shares is that the body opens with a `<p>`, which the gallery
card quotes. Add the name to `BUNDLED_ORDER` in
`scripts/genres.py`, above `offprint`;
2. if it needs a new **layout**: `assets/<name>-template.html`, self-contained
like the others, carrying the landmarks in the table above, `{{PALETTE}}`,
`{{FONTS}}`, `data-genre="{{GENRE}}"`, and `{{MASTHEAD}}` where the poster
or kicker goes; an entry in `LAYOUT_TEMPLATES` in `scripts/render.py` and in
`LAYOUTS` in `scripts/genres.py`. Most genres do not — the layouts are a
closed set on purpose, and a genre's value is its sections;
3. asserts in `genres.py --selftest` and `render.py --selftest`.

The theme is orthogonal: a genre template consumes the palette tokens and never
hard-codes a colour.

## Good first contributions

- **A source it handled badly** — open an issue with the URL and what the notes
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,19 @@ different objects rather than the same outline with different words:
The default is `offprint`, and it stays the default whenever the call is not
obvious — a wrong genre is worse than the plain one. Override it for a run with
`--genre` or in words ("write it as a recipe"); the skill says which genre it
chose whenever it is not the offprint. Each genre has its own contract under
[`skills/take-notes/genres/`](skills/take-notes/genres/) and its own template
under `assets/`; the colour [theme](#theme) applies to all of them.
chose whenever it is not the offprint. Each genre is one contract under
[`skills/take-notes/genres/`](skills/take-notes/genres/) — a front-matter block
naming its layout and the "the source is…" line the router reads, then the
sections the skill writes — rendered by one of three layouts under `assets/`;
the colour [theme](#theme) applies to all of them.

**Your own genres** go in `~/take-notes/genres/<name>.md`, in the same shape.
Pick a layout, write the sections, and it is routable the moment you give it a
`when:` line — or leave that out and use it by name with `--genre <name>`,
which is the safe way to try one. A file named like a bundled genre replaces
it: copy `recipe.md` there and change the facts on the card to have your own
recipe. `uv run skills/take-notes/scripts/genres.py --list` prints what is
installed, in the order the router reads it.

### Language

Expand Down Expand Up @@ -241,7 +251,7 @@ silently. You are never prompted to invent a tag mid-run.
The full grammar, for reference:

```sh
/take-notes <url> [more urls…] [focus] [--lang en|es] [--genre offprint|fieldguide|recipe]
/take-notes <url> [more urls…] [focus] [--lang en|es] [--genre <name>]
/take-notes --tags | --add-tag "AI" | --remove-tag "AI" | --retag
/take-notes --theme | --theme "notebook"
```
Expand Down
35 changes: 27 additions & 8 deletions scripts/check-drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
"Didactic means explaining, not compressing": "the point of the whole skill",
}

# What a copied-in section contract looks like. The writing standard has one
# copy, in SKILL.md; the guides under references/ are acquisition only.
# What a copied-in section contract looks like. The writing standard lives in
# SKILL.md and the contracts under genres/; the guides under references/ are
# acquisition only.
WRITING_STANDARD_MARKERS = ("Executive summary", "The one takeaway", "Didactic means")


Expand All @@ -54,7 +55,10 @@ def main() -> int:
)

# 2. Step 1 is matched top to bottom, first match wins, so the catch-all is last.
routed = re.findall(r"^\|.*`references/([a-z-]+\.md)`.*\|$", skill, re.M)
# Only Step 1's own table routes to an acquisition guide; references/ also
# holds procedures (settings, combining) that other steps point at in prose.
step1 = skill[skill.index("## Step 1"):skill.index("## Step 2")]
routed = re.findall(r"^\|.*`references/([a-z-]+\.md)`.*\|$", step1, re.M)
if "web.md" not in routed:
errors.append("SKILL.md Step 1 has no `references/web.md` catch-all row")
else:
Expand All @@ -65,15 +69,18 @@ def main() -> int:
"which matches http(s) first — those rows are unreachable"
)

# 3. The table and the directory name the same guides.
# 3. Every routed guide exists, and every file shipped under references/ is
# reached from SKILL.md — by a Step 1 row or by name from another step.
on_disk = {p.name for p in REFERENCES.glob("*.md")}
for missing in sorted(set(routed) - on_disk):
errors.append(f"SKILL.md Step 1 routes to references/{missing}, which is not on disk")
for orphan in sorted(on_disk - set(routed)):
errors.append(f"references/{orphan} ships to users but no Step 1 row routes to it")
if f"`references/{orphan}`" not in skill:
errors.append(f"references/{orphan} ships to users but nothing in SKILL.md reads it")

# 4. Acquisition guides know nothing about how notes are written.
for guide in sorted(REFERENCES.glob("*.md")):
# 4. Acquisition guides know nothing about how notes are written. The
# procedures (combining.md in particular) may, so only Step 1's guides.
for guide in sorted(REFERENCES / name for name in routed if (REFERENCES / name).exists()):
copied = [m for m in WRITING_STANDARD_MARKERS if m in guide.read_text(encoding="utf-8")]
if copied:
errors.append(
Expand All @@ -86,7 +93,19 @@ def main() -> int:
if phrase not in skill:
errors.append(f"SKILL.md lost the rule {phrase!r} — {why}")

# 6. The version that ships is a version someone can read the notes for.
# 6. The genre router is generated from the contracts (genres.py --list), so
# a row written back into SKILL.md by hand would be a second copy that
# drifts from the front-matter the script reads.
if "genres.py\" --list" not in skill:
errors.append("SKILL.md Step 3 no longer runs `genres.py --list` — the router table is generated, not written")
stale = re.findall(r"^\|.*`(recipe|fieldguide|offprint)`.*\|$", skill, re.M)
if stale:
errors.append(
f"SKILL.md carries a hand-written router row for {', '.join(stale)} — the table comes "
"from genres/*.md front-matter; edit the `when:` there instead"
)

# 7. The version that ships is a version someone can read the notes for.
version = re.search(r'^\s*version:\s*"([^"]+)"', skill, re.M)
if version is None:
errors.append("SKILL.md has no `version:` in its frontmatter")
Expand Down
Loading
Loading