diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69bac6b..42d0e0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c4dd9..9f0bc7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d6a6da..f8b805c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/.md`. -Adding one means: - -1. a row in the Step 3 table, above the offprint's catch-all; -2. `genres/.md` — the sections, in `## Sections`' voice, plus the class - vocabulary the template styles (the one exception to "no classes"); -3. `assets/-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 ``` @@ -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. @@ -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 @@ -140,7 +127,7 @@ styling: |---|---|---| | `.poster`, `.kicker`, `.meta`, `.watch` | both note templates | the masthead parser | | `.tags` / `.tag` / `.tag.is-primary`, immediately above the literal `
` | both note templates | the tag parser, the gallery's chips, and `retag.py`'s insertion anchor | -| `` | the genre templates (absent = offprint) | the genre parser, the gallery's card label, the Markdown frontmatter | +| `` | every note template (absent = a note older than genres, an offprint) | the genre parser, the gallery's card label, the Markdown frontmatter | | `
` with a flat run of `

` — and no `
` inside it | every note template, and every genre contract | the section splitter, which ends the body at the first `
` | | `
  • term — definition
  • ` | `SKILL.md` Key points / Concepts | the Anki card builder | @@ -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/.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 `). Users add genres under +`~/take-notes/genres/`, and one named like a bundled genre replaces it. + +Adding a **bundled genre** means: + +1. `genres/.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 `

    `, 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/-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 diff --git a/README.md b/README.md index cf3ae1f..f68d12e 100644 --- a/README.md +++ b/README.md @@ -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/.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 `, +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 @@ -241,7 +251,7 @@ silently. You are never prompted to invent a tag mid-run. The full grammar, for reference: ```sh -/take-notes [more urls…] [focus] [--lang en|es] [--genre offprint|fieldguide|recipe] +/take-notes [more urls…] [focus] [--lang en|es] [--genre ] /take-notes --tags | --add-tag "AI" | --remove-tag "AI" | --retag /take-notes --theme | --theme "notebook" ``` diff --git a/scripts/check-drift.py b/scripts/check-drift.py index 30ba70a..f98a9a9 100755 --- a/scripts/check-drift.py +++ b/scripts/check-drift.py @@ -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") @@ -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: @@ -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( @@ -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") diff --git a/skills/take-notes/SKILL.md b/skills/take-notes/SKILL.md index baa04da..775daa4 100644 --- a/skills/take-notes/SKILL.md +++ b/skills/take-notes/SKILL.md @@ -11,7 +11,7 @@ metadata: # `allowed-tools` stays comma-separated: the spec asks for spaces but marks the # field experimental ("support may vary"), and commas are what Claude Code # parses today. Do not "correct" either without testing in Claude Code first. -argument-hint: " [more-urls…] [focus] [--lang en|es] [--genre offprint|fieldguide|recipe] | --tags | --add-tag X | --remove-tag X | --retag | --theme [name]" +argument-hint: " [more-urls…] [focus] [--lang en|es] [--genre ] | --tags | --add-tag X | --remove-tag X | --retag | --theme [name]" allowed-tools: Bash, Read, WebFetch, AskUserQuestion disable-model-invocation: true --- @@ -26,7 +26,7 @@ into a browsable local archive instead of scrolling away in the terminal. Invocation: `/take-notes [more urls…] [focus]`. If no URL is given, ask for one. Several URLs are **one note about one subject from several sources** — a talk and the deck it was given from, a paper and the repo that implements it — -not one note each; Step 1 says how they combine. +not one note each; `references/combining.md` says how. The optional focus does two things: it narrows what Step 1 asks the source for — on a long or multi-topic source, that's the difference between fetching the whole thing and fetching only the part that matters — and it narrows what the @@ -53,68 +53,11 @@ fi ## Step 0 — settings management short-circuits everything else -These invocations manage the tag vocabulary or the theme instead of writing a -note. If the invocation is one of them, run the matching command, report the -result, and **stop** — no source, no note, nothing else in this file applies: - -| Invocation | Command | -|---|---| -| `/take-notes --tags` | `uv run "${SKILL_DIR}/scripts/tags.py"` | -| `/take-notes --add-tag "AI"` | `uv run "${SKILL_DIR}/scripts/tags.py" --add "AI"` | -| `/take-notes --remove-tag "AI"` | `uv run "${SKILL_DIR}/scripts/tags.py" --remove "AI"` | -| `/take-notes --theme` | `uv run "${SKILL_DIR}/scripts/themes.py"` | -| `/take-notes --theme "notebook"` | `uv run "${SKILL_DIR}/scripts/themes.py" --set "notebook"` | -| `/take-notes --retag` | re-files existing notes — the multi-step pass below | - -Both editing forms are repeatable — pass `--add` or `--remove` once per tag. -The script prints the resulting vocabulary; report that, and nothing more. It -rewrites only the `tags` key, so `language` survives untouched. - -`Unknown` cannot be removed: it is the fallback the note writer needs when a -source fits nothing. The script says so and leaves it in place. - -### `--theme` — the colour the archive is painted in - -`auto` (the default, following the system) or one of the named themes the -script lists. Each is a palette *and* a type stack. The script writes `theme` to the config and leaves every -other key alone; the value is baked into the gallery and into notes written from -then on. Ask for it in words too — "ponlo en petrol" is this command. - -Say so when reporting: **notes already on disk keep the colours they were -written with.** The reader can still switch theme from the gallery's own menu, -which applies to that browser and rides along to any note opened from a card. - -### `--retag` — re-file the notes already on disk - -Filing a note under a new tag used to mean re-running `/take-notes` on its -source: a refetch and a full rewrite, to change one word in the rail. This pass -edits the rendered notes instead. Run it after adding tags to a vocabulary that -was empty or thinner when those notes were written. - -1. **Read the vocabulary** — `uv run "${SKILL_DIR}/scripts/tags.py"`. If the - only entry is `Unknown`, say so and **stop**: there is nothing to file notes - under yet, and the user needs `--add-tag` first. -2. **List what is on disk** — `uv run "${SKILL_DIR}/scripts/retag.py" --list`. - One JSON object per note: path, title, byline, kind, date, current `tags`, - an `excerpt`, and `needs_tag`. -3. **Choose from the vocabulary and nothing else.** For every note with - `"needs_tag": true`, pick the entry that fits from the list read in step 1; - add further tags after the primary when they genuinely apply. The list is - closed — the script rejects anything not on it rather than inventing a tag - that would exist on one note and in no chip. Nothing fits, leave it on - `Unknown`; a wrong file is worse than an unfiled note. -4. **Write each one** — - `uv run "${SKILL_DIR}/scripts/retag.py" --set "" --tag "" [--tag ""]` -5. **Rebuild the gallery** so the chips match the notes — - `uv run "${SKILL_DIR}/scripts/gallery.py"` -6. Report one line per note re-filed, plus how many were left on `Unknown`. - -`"needs_tag": false` means the note already carries a deliberate tag. Leave -those alone unless the user asked for every note; overwriting a filing someone -chose is not an update. - -The pass rewrites only the rail's tag row. No source is fetched and no prose is -regenerated, so it costs the listing and the model's choices, nothing more. +`--tags`, `--add-tag`, `--remove-tag`, `--theme` and `--retag` manage the tag +vocabulary, the colour palette or the notes already on disk instead of writing +a note. If the invocation is one of them — as a flag or in words ("ponlo en +petrol", "re-file my notes") — Read `references/settings.md`, do what it says, +and **stop**: no source, no note, nothing else in this file applies. ## Step 1 — route to the right acquisition guide @@ -159,28 +102,10 @@ notes from a title, a description, or a paywall stub. ### More than one source -Route **each** URL through its own row above and collect the same field set for -each. Fetching is the only part that repeats: from Step 2 on there is one -language, one tag, one body, one note. - -The **first URL is the primary source**. Everything the note's chrome shows -comes from it — title, byline, span, canonical URL — and it picks the masthead: -a video first gets the poster (and, in the offprint, timestamps), a deck, paper -or article first gets the byline kicker. The note's *shape* — its genre — is -Step 3's call, made from the whole set. The rest are -**companions**: they contribute body, and Step 5 links them in the rail. Order -is the user's control over that, so take it literally rather than promoting the -richest source. - -A **companion** that yields no body is not fatal: name it, say the notes are -poorer for it, and write from what did arrive. A **primary** that yields no body -stops the run — the note would be filed under a source it was not written from. - -A focus narrows every source at once, which is where it earns the most: two -full-length sources is the largest input this skill ever takes. - -Do not put note-writing guidance in the reference files, and do not put -acquisition detail here. Two copies of the writing standard will drift. +Route **each** URL through its own row above and collect the same field set +for each. Then Read `references/combining.md` before going on: it says which +source is primary, what a companion that yields nothing means, how the set is +read as one note, and the `--source` flags Step 5 takes. ## Step 2 — settle the language and the tag @@ -264,14 +189,18 @@ defines jargon (*Concepts*), or leaves something unresolved (*Going deeper*). A note's **genre** is the shape of its content — which sections it has and which template renders them. It is a property of the source, not a taste, so -you pick it after reading, from this table, **top to bottom, first row that -fits**: +you pick it after reading. Print the router: -| The source is… | Genre | Contract | -|---|---|---| -| a dish — ingredients with quantities and a method with times | `recipe` | Read `genres/recipe.md` | -| several things of one kind described on shared axes — tools, models, products, options, the papers in a survey; four or more of them | `fieldguide` | Read `genres/fieldguide.md` | -| anything else — a talk, an article, a paper, a docs page, a repo, a lesson | `offprint` | `## Sections` below | +```bash +uv run "${SKILL_DIR}/scripts/genres.py" --list --lang +``` + +It is a table of every installed genre — the bundled `recipe`, `fieldguide` +and `offprint`, plus any the user added under `~/take-notes/genres/` — with a +one-line "the source is…" per row and the path of its contract. Read it **top +to bottom, first row that fits**; the offprint is the last row and the +catch-all. A genre listed as *on request only* is never picked here — it is +used when asked for by name. Resolution order, stop at the first that applies: `--genre ` in the invocation, or a request in words ("write it as a recipe", "hazla como guía") @@ -279,31 +208,23 @@ invocation, or a request in words ("write it as a recipe", "hazla como guía") than the default, the same rule as for tags. With several sources the genre comes from the set as a whole, not from the first URL. -For a genre other than the offprint, Read its contract file now; it replaces -`## Sections` below and nothing else — `## Rules` still applies in full. Say -which genre you chose in the same short line as the language and the tag, only -when it is not the offprint: +Now Read the contract at the path the table gives. It holds the sections for +Step 4 and nothing else — `## Rules` still applies in full. Say which genre +you chose in the same short line as the language and the tag, only when it is +not the offprint: > Writing in Spanish per your config, filed under **Cooking**, as a **recipe**. -With several sources, read them **against each other** before writing — that -comparison is the whole reason they were combined: - -- **Overlap** — write it once, from whichever source explains it better. A deck - bullet and the sentence spoken over it are one point, not two. -- **Gaps** — a figure that is on a slide and in no transcript, a number said out - loud that is on no slide. These are what the second source bought. -- **Contradictions** — say so and attribute both. A talk that updates its own - deck is worth a line in *Going deeper*. - -Never organise the notes by source. One set of sections, ordered by what has to -be understood first; a reader should not be able to tell where the seam was. +With several sources, `references/combining.md` § Read applies before you write. ## Step 4 — write the notes as HTML -Use the sections of the genre you picked in Step 3 — the offprint's are under -`## Sections` below; the other genres' live in `genres/`. Write **body HTML -only** — no ``, ``, +Use the sections of the contract you read in Step 3, in its order. Whatever +the genre, the body **opens with a section whose first element is a `

    `**: +the gallery card quotes that paragraph. The title and metadata line are **not** +in the body — they come from the renderer flags. + +Write **body HTML only** — no ``, ``, ``, no `

    `, and no metadata line: the renderer supplies the document shell and the masthead from the fields you collected in Step 1. @@ -327,23 +248,12 @@ HTML Pass one `--tag` per tag chosen in Step 2, **primary first** — `--tag AI --tag Engineering`. With no `--tag` at all the note is filed under `Unknown`. -`--genre` is Step 3's choice — `fieldguide` or `recipe`; leave it off for an -offprint. `--video-id` still decides the masthead in every genre: a poster with -it, a byline kicker without. - -The masthead flags describe the **primary** source. When the run combined -several, add one `--source "