feat(claude): track agents and the matej and notes skills

This commit is contained in:
2026-10-16 14:20:28 +00:00
parent 110105f902
commit b042b90d4d
9 changed files with 1971 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
---
name: matej
description: How Matej likes larger work run - one lead session that talks to him and briefs agents, a senior agent that builds and hands back a checked branch, explorer agents for open questions, notes and a design canvas for review. Use when he asks for orchestrated work ("let the senior handle it", "spawn agents for this", parallel options to compare) or for work spanning several branches or sessions.
---
# Running work through agents
Guidelines, not a procedure. Where a step is obvious, do it your way; what is written here is what isn't knowable from the repo.
## Roles
- **Lead**: the session Matej talks to. Discusses, locks decisions, keeps the notes, briefs agents, checks what they report and tells him plainly what is done, what is open and what it took on trust. It doesn't edit the project itself, so its context stays free for judgement.
- **Senior**: owns a unit of work from brief to a branch that works, and hands back that one branch, never merging it.
- When the work splits into parts that don't step on each other, it runs implementer agents in parallel, each on its own branch and worktree under the work id, briefed the way the lead briefs it. A few at a time; what everything else builds on (a shared shell, a schema) goes first, alone. Small or tightly coupled work it does itself.
- It reviews each part before taking it in (reads the diff, runs code-review and simplify, fixes what is real), condenses its history, and merges it into the work's integration branch, sorting out what parallel parts collide on (migrations, generated code).
- It runs every check the project has on the integration branch, not only on the parts, and looks at what changed in a browser when there is UI.
- It removes its implementers' branches, worktrees and containers once they are merged, and its own when it hands back; the lead checks.
- **Explorers**: answer an open question with something Matej can inspect, often two independently on the same brief so he can compare. Prototypes may cut corners; they never land as they are.
## Protocol
- Discuss, lock, relay. Only locked decisions go to builders, in the words that were agreed. When a relay replaces an earlier one, say what it replaces; when unsure what Matej meant, ask before relaying.
- Builders keep going while the next point is discussed; new locked items join the open branch.
- Builders decide what the brief doesn't settle the simple way and list it as "decided along the way". Only the lead asks Matej.
## Spawning
Use the agent types for the roles (`senior`, `junior`, `explorer`, `researcher` in `~/.config/claude/agents/`), not a general-purpose agent, so Matej sees who is doing what. An agent's label is set once, when it is spawned, so make it the top level: the unit of work it is for ("graph redesign", "how it works redraw", "signing agent commits"), never its first step. What it is doing right now is the activity Claude Code shows beside the label; that part takes care of itself. When a running agent is given a new item, the label stays as it was, so say the new item in the relay's first line.
## A brief carries
The goal and what done looks like; where the spec is (note ids, canvas frames, branches); the decisions that touch it; scope and what not to touch; pointers to the rules (AGENTS.md, CLAUDE.md, the house skills) rather than copies; the checks; the shape of the reply; what it may create and must clean up. Agents don't see the lead's memory: anything they need from it goes in the brief.
## Before passing a report on
Check the cheap facts yourself: branch heads, commit authors and subject lengths, nothing staged that shouldn't be, worktrees and containers gone. Re-run something long only when a claim looks off. Say what you checked.
## Where things go
- Plans, decisions, reports, leftovers: the project's `.notes/` (the notes skill). Agents can't write there; the lead files their reports.
- Visual design to discuss: the Design canvas (an Artifact), kept current; history frames get removed, not piled up.
- Prototypes and finished work: branches. The repo's `.git` is on Matej's host, so he can `git worktree add --detach` any branch there.
- Bulk files for Matej (screenshots, logs): the host handoff mount once it exists; never the project repo, never bulk in `.notes/`.
## Ownership and clean-up
- Everything a unit of work creates carries its work id: branches `<wNNN>/<name>`, worktrees under `$SANDBOX_VM_STATE/wt/<wNNN>/`, containers `<wNNN>-<name>`. Agents remove what they created and nothing else.
- The lead keeps `made.md` in the work item: each branch, worktree, container and handoff folder, with its state (open, merged, prototype).
- Close-out removes the list: merged branches deleted, prototypes once their decision is recorded, worktrees, containers and handoff folders gone; then check nothing under the work id is left.
## Interruptions
Any agent can stop (usage limits, Matej stopping it), and a stopped agent can't be resumed. Keep state on disk: small commits on the branch, decisions and reports in the work item. Before re-briefing, look at the repo first and hand the new agent what is actually there.
## Git
Agents never sign; Matej re-signs on his host before pushing (CLAUDE.md, Git).

View File

@@ -0,0 +1,116 @@
---
name: notes
description: Manage the project's local .notes/ dir (design docs, work handoffs, parked questions). Use whenever writing a design doc, plan, proposal, research or review for local use; starting, handing off or closing out work that spans sessions (brief, plan, report); parking something for later, out of scope or a follow-up; picking up or settling a parked question; preparing a document for someone else; or when the user mentions .notes, handoffs, open questions or asks what is pending.
---
# Notes
The policy is `~/.config/claude/rules/notes.md`: layout, numbering, status lines, close-out, what doesn't belong. This skill holds the procedures. Every step that changes `.notes/` ends with `commit`, which also regenerates `index.html`, `notes.css`, the markdown pages under `_pages/` and the portable copies under `_out/`. Change the look only in this skill's `notes.css`, never in a project.
```
N=~/.config/claude/skills/notes/notes.py
python3 $N init # create .notes/ if missing (idempotent)
python3 $N next d|w|q # next free id, counts deleted items too
python3 $N commit "<subject>" # regenerate the generated pages, commit all of .notes/
python3 $N index # regenerate index.html, notes.css, _pages/ and _out/ only
python3 $N serve # every project's notes on http://127.0.0.1:8765/: hub filters projects, each project page searches its notes (the user runs this)
```
All commands take `--root <project>`; the default is the git toplevel of the current dir. Never pick an id by hand or from `ls`; deleted ids live only in git history.
## New doc
1. `init`, then `next d`
2. write `docs/dNNN-slug.html` (or `docs/dNNN-slug/index.html` with its assets next to it, then every `../` below becomes `../../`) on this skeleton, using only the components documented at the top of `notes.css` (callout, tag, cards, grid-2, table, figure.diagram with its svg classes); no `<style>`, no font links:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Short name</title>
<link rel="stylesheet" href="../notes.css">
</head>
<body>
<main class="page">
<nav class="back"><a href="../index.html">← notes</a></nav>
<header>
<p class="eyebrow">dNNN · plan · 2026-10-14</p>
<h1>Title</h1>
<p class="status current">status: current</p>
<p class="lede">What this doc decides, in one or two sentences.</p>
</header>
<section>
<h2>…</h2>
</section>
</main>
</body>
</html>
```
3. if it replaces an older doc, set the older one's status line to `<p class="status superseded">status: superseded by <a href="dNNN-slug.html">dNNN</a> (<date>)</p>`; mind the relative path when either doc is a folder
4. look at it: a headless browser screenshot in light and dark, or `serve`
5. `commit "dNNN: <slug>"`
## New work item
Only for work that will span sessions.
1. `init`, then `next w`, create `work/wNNN-slug/`
2. `brief.md`, starting with `# wNNN <title>`: goal, done when, scope, verification, decisions to ask, prerequisites to read
3. add `plan.html` (the proposal, on the doc skeleton with `../../notes.css` and `../../index.html`) and research files as the work goes; `report.md` at the end: what was built, what differs from the plan, leftovers
4. `commit "wNNN: <what changed>"`
## Close-out
When the work's branch is merged or the work is dropped, in that same session:
1. lasting design → new doc or a status line on an existing one (see above)
2. each leftover → a question with `origin: wNNN`
3. `rm -r work/wNNN-slug`, then `commit "wNNN: closed, <merged as <sha>|dropped>"`
When `index` shows a work folder untouched for weeks with no open branch in the project, tell the user; don't close it yourself.
## Park a question
1. `init`, then `next q`
2. write `questions/qNNN-slug.md`:
```
---
created: 2026-10-14
origin: w009
status: open
---
# <one-line question or follow-up>
What is open and why it matters. What is known so far, with paths, commits and ids.
Where to start looking.
```
3. `commit "qNNN: <slug>"`
Write it so a session with no memory of today can act on it.
## Settle a question
1. check the repo first: it may already be done or moot
2. fold the outcome into a doc or a work item if it is worth keeping
3. `rm questions/qNNN-slug.md`, `commit "qNNN: settled, <how: done in <sha> | moot because ... | moved to wNNN>"`
## Other
- `other/in/`: the user's files; read only when pointed at
- `other/out/`: documents for someone else, written for that reader; no ids, no status line
- html follows the doc skeleton and components like any doc, with `../../notes.css` and `../../index.html`; the eyebrow names the reader and date instead of an id (`for the team · 2026-10-14`)
- what gets sent is the generated `_out/<file>`: `notes.css` inlined, back link dropped, other files copied; the index links it as "to send"
- look at the `_out/` copy too before handing it over, then `commit "other: <file>"`
## Never
- stage `.notes/` from the project repo (`git add .`, `git add -A`); stage project paths explicitly
- reference `.notes/` paths from committed project files
- keep archives or dated copies; delete, git keeps it
- push `.notes/` or add a remote

View File

@@ -0,0 +1,961 @@
/*
* notes.css: the one stylesheet for every page in a project's .notes/
* notes.py copies it to .notes/notes.css; edit it here, in the notes skill, never in a project
*
* gruvbox material (sainnhe/gruvbox-material: medium contrast, material foreground), light or dark
* by the system scheme; jetbrains mono for all text
*
* page skeleton for a doc (docs/dNNN-slug.html; one level deeper for a folder doc):
* <link rel="stylesheet" href="../notes.css">
* <body><main class="page">
* <nav class="back"><a href="../index.html">← notes</a></nav>
* <header>
* <p class="eyebrow">d007 · 2026-10-13</p>
* <h1>Title</h1>
* <p class="status current|stale|superseded">status: …</p>
* <p class="lede">one or two sentences</p>
* </header>
* <section><h2>…</h2> …</section>
* </main></body>
*
* components:
* .callout (+ .good .warn .bad .info) a boxed note, recommendation, open point
* .tag (+ .good .warn .bad .info .muted) a small inline label
* .cards > .card (.card-id, strong, small) a grid of linked or plain cards
* .grid-2 two columns, one on a phone
* .table > table a table that scrolls sideways when wide
* figure.diagram > svg (+ figcaption) an inline svg diagram, see "diagrams" below
* dl.facts > dt + dd label and value pairs in two columns (repro, cause, fix)
* pre.diff > span(.add .del) a unified diff, one span per line
* .legend > span > svg + text a row of notation keys above a diagram
* .swatch (+ .info .good .warn .bad) a small colour square that matches a diagram box
* main.page.wide a wider page, for pages carried by wide diagrams
* .sub, .muted secondary text
* .fix a line prefixed with an arrow
*/
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
:root {
color-scheme: light dark;
--radius: 0.375rem;
/* gruvbox material light */
--red: #c14a4a;
--orange: #c35e0a;
--yellow: #b47109;
--green: #6c782e;
--aqua: #4c7a5d;
--blue: #45707a;
--purple: #945e80;
--background: #fbf1c7;
--foreground: #654735;
--card: #f4e8be;
--muted: #eee0b7;
--muted-foreground: #7c6f64;
--border: #ddccab;
--input: #a89984;
--link: var(--blue);
/* text colours and their tints, both follow the scheme's palette */
--good: var(--green);
--warn: var(--yellow);
--bad: var(--red);
--info: var(--blue);
--good-bg: color-mix(in srgb, var(--green) 14%, transparent);
--warn-bg: color-mix(in srgb, var(--yellow) 16%, transparent);
--bad-bg: color-mix(in srgb, var(--red) 13%, transparent);
--info-bg: color-mix(in srgb, var(--blue) 13%, transparent);
--accent: var(--orange);
--accent-bg: color-mix(in srgb, var(--orange) 12%, transparent);
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
/* gruvbox material dark */
--red: #ea6962;
--orange: #e78a4e;
--yellow: #d8a657;
--green: #a9b665;
--aqua: #89b482;
--blue: #7daea3;
--purple: #d3869b;
--background: #282828;
--foreground: #d4be98;
--card: #32302f;
--muted: #45403d;
--muted-foreground: #a89984;
--border: #45403d;
--input: #5a524c;
--good-bg: color-mix(in srgb, var(--green) 16%, transparent);
--warn-bg: color-mix(in srgb, var(--yellow) 15%, transparent);
--bad-bg: color-mix(in srgb, var(--red) 16%, transparent);
--info-bg: color-mix(in srgb, var(--blue) 16%, transparent);
--accent-bg: color-mix(in srgb, var(--orange) 15%, transparent);
}
}
/* base */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scrollbar-gutter: stable;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font: 400 14px/1.65 var(--font-mono);
-webkit-font-smoothing: antialiased;
}
main.page {
max-width: 860px;
margin: 0 auto;
padding: 24px 16px 96px;
}
main.page.wide {
max-width: 1240px;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
border-radius: 3px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
line-height: 1.25;
text-wrap: balance;
margin: 0;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 19px;
margin: 44px 0 12px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
h3 {
font-size: 16px;
margin: 28px 0 8px;
}
h4 {
font-size: 15px;
margin: 20px 0 6px;
}
section > h2:first-child {
margin-top: 40px;
}
p,
ul,
ol,
blockquote,
pre,
figure,
.table,
.callout,
.cards,
.grid-2,
details {
margin: 0 0 14px;
}
ul,
ol {
padding-left: 22px;
}
li + li {
margin-top: 4px;
}
li > ul,
li > ol {
margin: 4px 0 0;
}
strong,
b {
font-weight: 600;
}
hr {
border: 0;
border-top: 1px solid var(--border);
margin: 32px 0;
}
blockquote {
padding: 2px 0 2px 16px;
border-left: 3px solid var(--border);
color: var(--muted-foreground);
}
img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
border: 1px solid var(--border);
}
code,
kbd,
pre,
.mono {
font-family: var(--font-mono);
font-size: 0.93em;
}
code {
background: var(--muted);
padding: 1px 5px;
border-radius: 4px;
overflow-wrap: break-word;
}
pre {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
overflow-x: auto;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
font-size: 1em;
overflow-wrap: normal;
}
/* page chrome */
nav.back {
font-size: 13px;
margin-bottom: 28px;
}
nav.back a {
color: var(--muted-foreground);
}
nav.back a:hover {
color: var(--link);
}
header {
margin-bottom: 8px;
}
header > * + * {
margin-top: 10px;
}
.eyebrow {
font: 500 12px/1.4 var(--font-mono);
letter-spacing: 0.04em;
color: var(--muted-foreground);
margin: 0;
}
.lede {
font-size: 17px;
line-height: 1.6;
margin-top: 14px;
}
.sub,
.muted {
color: var(--muted-foreground);
}
p.status {
display: inline-block;
font-size: 13px;
line-height: 1.5;
padding: 3px 10px;
border-radius: 6px;
background: var(--muted);
color: var(--muted-foreground);
}
p.status.current {
background: var(--good-bg);
color: var(--good);
}
p.status.stale {
background: var(--warn-bg);
color: var(--warn);
}
.fix::before {
content: "→ ";
color: var(--accent);
font-weight: 600;
}
/* components */
.callout {
background: var(--card);
border: 1px solid var(--border);
border-left: 3px solid var(--input);
border-radius: var(--radius);
padding: 10px 14px;
}
.callout > :last-child {
margin-bottom: 0;
}
.callout.good {
background: var(--good-bg);
border-color: transparent;
border-left-color: var(--good);
}
.callout.warn {
background: var(--warn-bg);
border-color: transparent;
border-left-color: var(--warn);
}
.callout.bad {
background: var(--bad-bg);
border-color: transparent;
border-left-color: var(--bad);
}
.callout.info {
background: var(--info-bg);
border-color: transparent;
border-left-color: var(--info);
}
.tag {
display: inline-block;
font: 500 11px/1.5 var(--font-mono);
letter-spacing: 0.02em;
padding: 1px 8px;
border-radius: 99px;
white-space: nowrap;
vertical-align: 1px;
background: var(--muted);
color: var(--muted-foreground);
}
.tag.good {
background: var(--good-bg);
color: var(--good);
}
.tag.warn {
background: var(--warn-bg);
color: var(--warn);
}
.tag.bad {
background: var(--bad-bg);
color: var(--bad);
}
.tag.info {
background: var(--info-bg);
color: var(--info);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.card {
display: block;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
color: var(--foreground);
}
a.card:hover {
border-color: var(--accent);
text-decoration: none;
}
.card-id {
display: block;
font: 500 12px/1.4 var(--font-mono);
color: var(--accent);
}
.card strong {
display: block;
}
.card small {
display: block;
color: var(--muted-foreground);
font-size: 13px;
}
.card > :last-child {
margin-bottom: 0;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 720px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.table {
overflow-x: auto;
}
table {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
th,
td {
text-align: left;
vertical-align: top;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 600;
font-size: 13px;
color: var(--muted-foreground);
}
td > :last-child {
margin-bottom: 0;
}
td .tag {
white-space: normal;
}
td code {
overflow-wrap: anywhere;
}
details {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 14px;
}
summary {
cursor: pointer;
font-weight: 500;
}
details[open] > summary {
margin-bottom: 8px;
}
dl.facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 16px;
margin: 0 0 14px;
}
dl.facts dt {
color: var(--muted-foreground);
font-weight: 500;
}
dl.facts dd {
margin: 0;
}
@media (max-width: 560px) {
dl.facts {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
dl.facts dd + dt {
margin-top: 8px;
}
}
/* one column as wide as the longest line, so every line's tint spans it when scrolled */
pre.diff {
display: grid;
grid-template-columns: minmax(max-content, 1fr);
}
pre.diff span {
white-space: pre;
}
pre.diff .add {
background: var(--good-bg);
color: var(--good);
}
pre.diff .del {
background: var(--bad-bg);
color: var(--bad);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 22px;
margin: 0 0 14px;
font-size: 13px;
color: var(--muted-foreground);
}
.legend > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.legend svg {
width: 34px;
height: 20px;
flex: none;
}
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid var(--muted-foreground);
background: var(--card);
vertical-align: 0;
}
.swatch.info {
background: color-mix(in srgb, var(--blue) 14%, var(--card));
border-color: var(--blue);
}
.swatch.good {
background: color-mix(in srgb, var(--green) 14%, var(--card));
border-color: var(--green);
}
.swatch.warn {
background: color-mix(in srgb, var(--yellow) 18%, var(--card));
border-color: var(--yellow);
}
.swatch.bad {
background: color-mix(in srgb, var(--red) 14%, var(--card));
border-color: var(--red);
}
/*
* diagrams: inline svg inside figure.diagram, drawn with these classes so both schemes work
* <g class="node [accent|dashed|good|bad|warn]"><rect …/><text …>Label</text><text class="sub" …>detail</text></g>
* <path class="edge [back|good|bad|warn]" … marker-end="url(#arrow)"/>
* <path class="arrow [back|good|bad|warn]" d="M0 0 L10 5 L0 10 z"/> inside a <marker>
* <text class="edge-label [back|good|bad|warn]" …>label</text>
* <rect class="group" …/> and <text class="group-label" …> a dashed box around nodes
* for drawings with their own text placement (a data flow, a sequence), classes go on the shapes:
* <rect class="box [info|good|warn|bad|accent] [strong] [dashed] [open]"/> a step, process or entity
* <rect class="zone [info|good|warn|bad|accent] [boundary]"/> a tinted dashed area; boundary strokes it red
* <rect class="panel"/> a quiet solid area behind a group of boxes
* <rect class="chip [good|accent]"/> + <text class="chip-text"> a small filled badge or numbered dot
* <path class="line"/> a plain stroke (a data store's rails) <line class="rule"/> a faint dashed guide
* <rect class="fill"/> a borderless card-coloured shape: a data store's body, a backdrop
* <text class="title|sub|mono|heading [info|good|warn|bad]"> text roles; text-anchor stays an attribute
* .edge.dashed an answer or a nudge, not a main flow
* figure.diagram.wide svg at least 1000px wide, scrolls sideways on a phone
* an svg may also use currentColor and var(--good|--bad|--warn|--accent) in fill and stroke attributes
*/
figure {
margin-left: 0;
margin-right: 0;
}
figure.diagram {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
overflow-x: auto;
color: var(--foreground);
}
figure.diagram svg {
display: block;
width: 100%;
height: auto;
min-width: 640px;
font-family: var(--font-mono);
}
figure.diagram.wide svg {
min-width: 1000px;
}
figure.diagram text {
fill: var(--foreground);
}
figcaption {
color: var(--muted-foreground);
font-size: 13px;
margin-top: 8px;
}
.node rect,
.node circle,
.node ellipse,
.node polygon {
fill: var(--card);
stroke: var(--muted-foreground);
stroke-width: 1;
}
.node.accent rect,
.node.accent circle,
.node.accent polygon {
fill: var(--accent-bg);
stroke: var(--accent);
stroke-width: 1.5;
}
.node.dashed rect,
.node.dashed circle,
.node.dashed polygon {
stroke-dasharray: 5 4;
}
.node.good rect {
fill: var(--good-bg);
stroke: var(--good);
}
.node.bad rect {
fill: var(--bad-bg);
stroke: var(--bad);
}
.node.warn rect {
fill: var(--warn-bg);
stroke: var(--warn);
}
.node text {
fill: var(--foreground);
font: 500 14px var(--font-mono);
text-anchor: middle;
}
.node text.sub {
fill: var(--muted-foreground);
font: 400 11px var(--font-mono);
}
.edge {
fill: none;
stroke: var(--muted-foreground);
stroke-width: 1.4;
}
.arrow {
fill: var(--muted-foreground);
}
.edge-label {
fill: var(--muted-foreground);
font: 400 11px var(--font-mono);
text-anchor: middle;
paint-order: stroke;
stroke: var(--card);
stroke-width: 4px;
stroke-linejoin: round;
}
.edge.back {
stroke: var(--accent);
stroke-dasharray: 5 4;
}
.arrow.back,
.edge-label.back {
fill: var(--accent);
}
.edge.good {
stroke: var(--good);
}
.arrow.good,
.edge-label.good {
fill: var(--good);
}
.edge.bad {
stroke: var(--bad);
}
.arrow.bad,
.edge-label.bad {
fill: var(--bad);
}
.edge.warn {
stroke: var(--warn);
}
.arrow.warn,
.edge-label.warn {
fill: var(--warn);
}
.group {
fill: none;
stroke: var(--muted-foreground);
stroke-opacity: 0.5;
stroke-dasharray: 5 3;
}
.group-label {
fill: var(--muted-foreground);
font: 500 11px var(--font-mono);
}
/* shapes with their own classes, for drawings that place their own text */
svg .box {
fill: var(--card);
stroke: var(--muted-foreground);
stroke-width: 1.2;
}
svg .box.info {
fill: color-mix(in srgb, var(--blue) 14%, var(--card));
stroke: var(--blue);
}
svg .box.good {
fill: color-mix(in srgb, var(--green) 14%, var(--card));
stroke: var(--green);
}
svg .box.warn {
fill: color-mix(in srgb, var(--yellow) 18%, var(--card));
stroke: var(--yellow);
}
svg .box.bad {
fill: color-mix(in srgb, var(--red) 14%, var(--card));
stroke: var(--red);
}
svg .box.accent {
fill: color-mix(in srgb, var(--orange) 14%, var(--card));
stroke: var(--orange);
}
svg .box.strong {
stroke: var(--foreground);
stroke-width: 1.6;
}
svg .box.dashed,
svg .edge.dashed {
stroke-dasharray: 5 4;
}
svg .box.open {
fill: none;
}
svg .zone {
fill: color-mix(in srgb, var(--muted-foreground) 6%, transparent);
stroke: var(--muted-foreground);
stroke-width: 1.5;
stroke-dasharray: 7 5;
}
svg .zone.info {
fill: color-mix(in srgb, var(--blue) 7%, transparent);
}
svg .zone.good {
fill: color-mix(in srgb, var(--green) 7%, transparent);
}
svg .zone.warn {
fill: color-mix(in srgb, var(--yellow) 9%, transparent);
}
svg .zone.bad {
fill: color-mix(in srgb, var(--red) 6%, transparent);
}
svg .zone.accent {
fill: color-mix(in srgb, var(--orange) 7%, transparent);
}
svg .zone.boundary {
stroke: var(--red);
}
svg .panel {
fill: color-mix(in srgb, var(--foreground) 4%, transparent);
stroke: var(--border);
}
svg .chip {
fill: var(--blue);
}
svg .chip.good {
fill: var(--green);
}
svg .chip.accent {
fill: var(--orange);
}
svg text.chip-text {
fill: var(--background);
font: 700 10px var(--font-mono);
}
svg .fill {
fill: var(--card);
stroke: none;
}
svg .line {
fill: none;
stroke: var(--foreground);
stroke-width: 1.6;
}
svg .rule {
stroke: var(--border);
stroke-width: 1.5;
stroke-dasharray: 3 5;
}
svg text.title {
fill: var(--foreground);
font: 600 13px var(--font-mono);
}
svg text.sub {
fill: var(--muted-foreground);
font: 400 11px var(--font-mono);
}
svg text.mono {
fill: var(--muted-foreground);
font: 400 10.5px var(--font-mono);
}
svg text.heading {
fill: var(--muted-foreground);
font: 600 11px var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
}
svg text.info {
fill: var(--info);
}
svg text.good {
fill: var(--good);
}
svg text.warn {
fill: var(--warn);
}
svg text.bad {
fill: var(--bad);
}
/* index */
.index-head h1 {
font-size: 26px;
}
.index-head p {
margin: 4px 0 0;
color: var(--muted-foreground);
font-size: 13px;
}
.index h2 {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
margin: 36px 0 6px;
padding: 0;
border: 0;
}
.index h2 .count {
font: 500 12px var(--font-mono);
letter-spacing: 0;
}
.rows {
list-style: none;
padding: 0;
margin: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rows li {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) auto;
gap: 12px;
align-items: baseline;
padding: 9px 14px;
margin: 0;
}
.rows li + li {
border-top: 1px solid var(--border);
}
.rows .id {
font: 500 12px var(--font-mono);
color: var(--muted-foreground);
}
.rows .meta {
font-size: 12px;
color: var(--muted-foreground);
white-space: nowrap;
}
.rows .title.superseded a {
color: var(--muted-foreground);
}
.rows.plain li {
grid-template-columns: minmax(0, 1fr);
}
.empty {
color: var(--muted-foreground);
font-size: 13px;
margin: 0;
}
@media (max-width: 560px) {
.rows li {
grid-template-columns: 44px minmax(0, 1fr);
}
.rows .meta {
grid-column: 2;
}
}
/* a page rendered from markdown */
.md-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 13px;
color: var(--muted-foreground);
margin: 12px 0 0;
}
.md-meta dt {
display: inline;
font-weight: 500;
}
.md-meta dd {
display: inline;
margin: 0 0 0 4px;
}
.md-meta div {
min-width: 0;
}
.files {
font-size: 13px;
color: var(--muted-foreground);
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* serve's hub: one row per project */
.rows.hub li {
grid-template-columns: minmax(0, 1fr) auto;
}
@media (max-width: 560px) {
.rows.hub li {
grid-template-columns: minmax(0, 1fr);
}
.rows.hub .meta {
grid-column: 1;
}
}
/* serve's search */
form.search,
input.filter {
margin: 24px 0 0;
}
form.search input,
input.filter {
width: 100%;
font: inherit;
color: var(--foreground);
background: var(--card);
border: 1px solid var(--input);
border-radius: var(--radius);
padding: 9px 14px;
}
form.search input:focus,
input.filter:focus {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.rows.results li {
grid-template-columns: 52px minmax(0, 1fr);
}
.rows.results .meta {
display: none;
}
.snippet {
display: block;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 2px;
}
mark {
background: var(--warn-bg);
color: inherit;
border-radius: 3px;
padding: 0 2px;
}

788
claude/skills/notes/notes.py Executable file
View File

@@ -0,0 +1,788 @@
#!/usr/bin/env python3
"""mechanics for a project's .notes/ dir, see ~/.config/claude/rules/notes.md
usage:
notes.py [--root DIR] init create .notes/ with its git repo, if missing
notes.py [--root DIR] next d|w|q print the next free id of a bucket
notes.py [--root DIR] index regenerate index.html, notes.css, the markdown pages and
the portable copies of other/out/ in _out/
notes.py [--root DIR] commit SUBJECT regenerate the index, commit every change in .notes/
notes.py serve [--port 8765] [--roots DIR ...]
every project's notes on one port; roots default to
$NOTES_ROOTS, else ~/git
DIR is the project root, by default the git toplevel of the current dir, else the current dir
"""
import argparse
import html
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
from urllib.parse import quote
BUCKETS = {"d": "docs", "w": "work", "q": "questions"}
ID_RE = re.compile(r"^(docs|work|questions)/([dwq])(\d{3})-")
README = (
"Local notes, never pushed. Layout and rules: ~/.config/claude/rules/notes.md\n"
)
def git(notes: Path, *args: str, check: bool = True) -> str:
return subprocess.run(
["git", "-C", str(notes), *args], check=check, capture_output=True, text=True
).stdout
def project_root(arg: str | None) -> Path:
if arg:
return Path(arg).resolve()
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=False,
capture_output=True,
text=True,
)
root = Path(out.stdout.strip()) if out.returncode == 0 else Path.cwd()
# inside .notes the toplevel is the notes repo itself
return root.parent if root.name == ".notes" else root
def init(notes: Path) -> None:
for d in ("docs", "work", "questions", "other/in", "other/out"):
(notes / d).mkdir(parents=True, exist_ok=True)
if not (notes / "README.md").exists():
(notes / "README.md").write_text(README)
if not (notes / ".gitignore").exists():
(notes / ".gitignore").write_text("/index.html\n")
if not (notes / ".git").exists():
git(notes, "init", "-q", "-b", "main")
# NOTE:(@janezicmatej) local-only repo, any identity works when none is configured
if not git(notes, "config", "user.email", check=False).strip():
git(notes, "config", "user.name", "notes")
git(notes, "config", "user.email", "notes@localhost")
commit(notes, "init notes")
else:
write_index(notes)
print(notes)
def next_id(notes: Path, letter: str) -> str:
bucket = BUCKETS[letter]
paths = [f"{bucket}/{p.name}" for p in (notes / bucket).glob("*")]
if (notes / ".git").exists():
log = git(notes, "log", "--all", "--name-only", "--pretty=format:", check=False)
paths += log.splitlines()
nums = [int(m[3]) for p in paths if (m := ID_RE.match(p)) and m[2] == letter]
return f"{letter}{max(nums, default=0) + 1:03d}"
def commit(notes: Path, subject: str) -> None:
write_index(notes)
git(notes, "add", "-A")
if git(notes, "status", "--porcelain").strip():
git(notes, "commit", "-q", "--no-gpg-sign", "-m", subject)
print(git(notes, "log", "-1", "--oneline").strip())
else:
print("nothing to commit")
# pages: index.html, notes.css, _pages/ and _out/ are generated, never committed
CSS = Path(__file__).with_name("notes.css")
GENERATED = ("/index.html", "/notes.css", "/_pages/", "/_out/")
PAGES = "_pages"
OUT = "_out"
CSS_LINK = re.compile(r'<link\b[^>]*href="[^"]*notes\.css"[^>]*>', re.IGNORECASE)
BACK_NAV = re.compile(r'\s*<nav class="back">.*?</nav>', re.DOTALL | re.IGNORECASE)
ID_TOKEN = re.compile(r"(?<![\w/.-])([dwq]\d{3})(?![\w/-])")
def ensure_gitignore(notes: Path) -> None:
path = notes / ".gitignore"
lines = path.read_text().splitlines() if path.exists() else []
missing = [g for g in GENERATED if g not in lines]
if missing:
path.write_text("\n".join([*lines, *missing]) + "\n")
def frontmatter(text: str) -> tuple[dict[str, str], str]:
m = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
if not m:
return {}, text
meta = {}
for line in m[1].splitlines():
key, sep, value = line.partition(":")
if sep:
meta[key.strip()] = value.strip()
return meta, text[m.end() :]
def split_title(body: str, fallback: str) -> tuple[str, str]:
"""first heading as the title, without a leading id, and the body without it"""
m = re.search(r"^#\s+(.+)\n?", body, re.MULTILINE)
if not m:
return fallback, body
title = re.sub(r"^[dwq]\d{3}\b\s*", "", m[1].strip())
return title, body[: m.start()] + body[m.end() :]
def strip_tags(s: str) -> str:
return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
class Site:
"""what exists in .notes/ and where each item's page is, relative to .notes/"""
def __init__(self, notes: Path) -> None:
self.notes = notes
self.docs = self._docs()
self.work = sorted(p for p in (notes / "work").glob("w[0-9]*") if p.is_dir())
self.questions = sorted((notes / "questions").glob("q[0-9]*.md"))
self.other = {
sub: sorted(f for f in (notes / "other" / sub).rglob("*") if f.is_file())
for sub in ("in", "out")
if (notes / "other" / sub).exists()
}
self.urls: dict[str, str] = {}
for d in self.docs:
self.urls[d["id"]] = d["page"]
for w in self.work:
self.urls[w.name[:4]] = self.page_of(w / "brief.md")
for q in self.questions:
self.urls[q.name[:4]] = self.page_of(q)
def _docs(self) -> list[dict]:
out = []
for p in sorted((self.notes / "docs").glob("d[0-9]*")):
page = p / "index.html" if p.is_dir() else p
if not page.is_file() or page.suffix != ".html":
continue
text = page.read_text(errors="replace")
title = re.search(r"<title>(.*?)</title>", text, re.DOTALL | re.IGNORECASE)
status = re.search(
r"<p[^>]*class=\"[^\"]*\bstatus\b[^\"]*\"[^>]*>(.*?)</p>",
text,
re.DOTALL | re.IGNORECASE,
)
status_text = re.sub(
r"^status:\s*", "", strip_tags(status[1]) if status else ""
)
out.append(
{
"id": p.name[:4],
"title": strip_tags(title[1]) if title else p.stem,
"page": page.relative_to(self.notes).as_posix(),
"status": status_text,
}
)
return out
def page_of(self, src: Path) -> str:
"""where a markdown file's rendered page lives, relative to .notes/"""
rel = src.relative_to(self.notes).with_suffix(".html")
return f"{PAGES}/{rel.as_posix()}"
def href(self, target: str, from_page: str) -> str:
"""a link from one page to another, both relative to .notes/"""
base = Path(from_page).parent
return quote(Path(*([".."] * len(base.parts)), target).as_posix())
def status_tag(status: str) -> str:
e = html.escape
if status.startswith("current"):
return f'<span class="tag good" title="{e(status)}">current</span>'
if status.startswith("partly stale"):
return f'<span class="tag warn" title="{e(status)}">stale</span>'
if m := re.match(r"superseded by (d\d{3})", status):
return f'<span class="tag" title="{e(status)}">superseded by {m[1]}</span>'
return '<span class="tag bad">no status line</span>'
def page(site: Site, path: str, title: str, body: str) -> str:
css = site.href("notes.css", path)
return (
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f"<title>{html.escape(title)}</title>\n"
f'<link rel="stylesheet" href="{css}">\n</head>\n'
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
)
# markdown, the small subset notes are written in
def inline(text: str, site: Site, at: str) -> str:
out, last = [], 0
for m in re.finditer(r"`([^`]+)`", text):
out.append(inline_plain(text[last : m.start()], site, at))
out.append(f"<code>{html.escape(m[1])}</code>")
last = m.end()
out.append(inline_plain(text[last:], site, at))
return "".join(out)
def inline_plain(text: str, site: Site, at: str) -> str:
links: list[str] = []
def keep(s: str) -> str:
links.append(s)
return f"\x00{len(links) - 1}\x00"
text = re.sub(
r"\[([^\]]+)\]\(([^)\s]+)\)",
lambda m: keep(f'<a href="{html.escape(m[2])}">{html.escape(m[1])}</a>'),
text,
)
text = html.escape(text, quote=False)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
def id_link(m: re.Match) -> str:
url = site.urls.get(m[1])
return f'<a href="{site.href(url, at)}">{m[1]}</a>' if url else m[1]
text = ID_TOKEN.sub(id_link, text)
return re.sub(r"\x00(\d+)\x00", lambda m: links[int(m[1])], text)
def markdown(text: str, site: Site, at: str) -> str:
lines = text.splitlines()
out: list[str] = []
para: list[str] = []
# open lists as (indent, tag)
lists: list[tuple[int, str]] = []
item_open = False
def flush_para() -> None:
if para:
out.append(f"<p>{inline(' '.join(para), site, at)}</p>")
para.clear()
def close_lists(to: int = -1) -> None:
nonlocal item_open
while lists and lists[-1][0] > to:
out.append(f"</li></{lists.pop()[1]}>")
item_open = bool(lists)
i = 0
while i < len(lines):
line = lines[i]
if line.lstrip().startswith("```"):
flush_para()
close_lists()
indent = len(line) - len(line.lstrip())
code = []
i += 1
while i < len(lines) and not lines[i].lstrip().startswith("```"):
code.append(lines[i][indent:])
i += 1
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
i += 1
continue
if not line.strip():
flush_para()
i += 1
continue
if m := re.match(r"^(#{1,4})\s+(.*)$", line):
flush_para()
close_lists()
level = min(len(m[1]) + 1, 4)
out.append(f"<h{level}>{inline(m[2], site, at)}</h{level}>")
i += 1
continue
if m := re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line):
flush_para()
indent, tag = len(m[1]), "ol" if m[2][0].isdigit() else "ul"
if not lists or indent > lists[-1][0]:
out.append(f"<{tag}><li>")
lists.append((indent, tag))
else:
close_lists(indent)
if lists and lists[-1][0] == indent:
out.append("</li><li>")
else:
out.append(f"<{tag}><li>")
lists.append((indent, tag))
item = [m[3]]
i += 1
# continuation lines belong to the item
while (
i < len(lines)
and lines[i].strip()
and not re.match(r"^\s*([-*]|\d+\.)\s+|^\s*```|^#{1,4}\s", lines[i])
):
item.append(lines[i].strip())
i += 1
out.append(inline(" ".join(item), site, at))
continue
if lists and line.startswith(" "):
out.append(" " + inline(line.strip(), site, at))
i += 1
continue
close_lists()
para.append(line.strip())
i += 1
flush_para()
close_lists()
return "\n".join(out)
def render_md(site: Site, src: Path, kind: str) -> None:
at = site.page_of(src)
meta, body = frontmatter(src.read_text(errors="replace"))
title, body = split_title(body, src.stem)
e = html.escape
item_id = next((p[:4] for p in src.parts if re.match(r"[dwq]\d{3}-", p)), "")
eyebrow = " · ".join(x for x in (item_id, kind) if x)
parts = [
f'<nav class="back"><a href="{site.href("index.html", at)}">← notes</a></nav>',
"<header>",
f'<p class="eyebrow">{e(eyebrow)}</p>' if eyebrow else "",
f"<h1>{e(title)}</h1>",
]
if meta:
dl = "".join(
f"<div><dt>{e(k)}</dt><dd>{inline(v, site, at)}</dd></div>"
for k, v in meta.items()
)
parts.append(f'<dl class="md-meta">{dl}</dl>')
parts += ["</header>", markdown(body, site, at)]
if kind == "work":
files = []
for f in sorted(src.parent.rglob("*")):
if not f.is_file() or f == src:
continue
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(site.notes)
files.append(f'<a href="{site.href(str(target), at)}">{e(f.name)}</a>')
if files:
parts.append(f'<p class="files">files: {" · ".join(files)}</p>')
out = site.notes / at
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(page(site, at, title, "\n".join(p for p in parts if p)))
def clear(d: Path) -> None:
if d.exists():
for f in sorted(d.rglob("*"), reverse=True):
f.unlink() if f.is_file() else f.rmdir()
def write_portable(site: Site) -> None:
"""other/out/ as it leaves .notes/: css inlined, no back link to an index it won't have"""
out = site.notes / OUT
clear(out)
src = site.notes / "other" / "out"
style = f"<style>\n{CSS.read_text()}</style>"
for f in site.other.get("out", []):
target = out / f.relative_to(src)
target.parent.mkdir(parents=True, exist_ok=True)
if f.suffix != ".html":
target.write_bytes(f.read_bytes())
continue
# the nav goes before the css comes in: its header comment shows a nav too
text = BACK_NAV.sub("", f.read_text(errors="replace"), count=1)
target.write_text(CSS_LINK.sub(lambda _: style, text, count=1))
def write_index(notes: Path) -> None:
ensure_gitignore(notes)
(notes / "notes.css").write_text(CSS.read_text())
site = Site(notes)
clear(notes / PAGES)
write_portable(site)
for q in site.questions:
render_md(site, q, "question")
for w in site.work:
for f in sorted(w.rglob("*.md")):
render_md(site, f, "work")
for files in site.other.values():
for f in files:
if f.suffix == ".md":
render_md(site, f, "other")
e = html.escape
def section(name: str, rows: list[str], empty: str, plain: bool = False) -> str:
head = f'<h2>{name} <span class="count">{len(rows)}</span></h2>'
if not rows:
return f'{head}<p class="empty">{empty}</p>'
cls = "rows plain" if plain else "rows"
return f'{head}<ul class="{cls}">{"".join(rows)}</ul>'
def row(item_id: str, href: str, title: str, meta: str = "", cls: str = "") -> str:
return (
f'<li><span class="id">{e(item_id)}</span>'
f'<span class="title {cls}"><a href="{href}">{e(title)}</a></span>'
f'<span class="meta">{meta}</span></li>'
)
docs = [
row(
d["id"],
quote(d["page"]),
d["title"],
status_tag(d["status"]),
"superseded" if d["status"].startswith("superseded") else "",
)
for d in site.docs
]
work = []
for w in site.work:
brief = w / "brief.md"
title = w.name[5:]
if brief.exists():
title, _ = split_title(frontmatter(brief.read_text())[1], title)
work.append(row(w.name[:4], quote(site.urls[w.name[:4]]), title))
questions = []
for q in site.questions:
meta, body = frontmatter(q.read_text(errors="replace"))
title, _ = split_title(body, q.stem)
created = e(meta.get("created", ""))
questions.append(row(q.name[:4], quote(site.page_of(q)), title, created))
other = []
for sub, files in site.other.items():
for f in files:
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(notes)
portable = ""
if sub == "out" and f.suffix == ".html":
rel = f.relative_to(notes / "other" / "out").as_posix()
portable = (
f' <a class="tag info" href="{quote(f"{OUT}/{rel}")}">to send</a>'
)
other.append(
f'<li><span class="title"><a href="{quote(str(target))}">{e(f.name)}</a>'
f' <span class="tag">{sub}</span>{portable}</span></li>'
)
project = notes.parent.name
body = "\n".join(
[
# under serve this is the hub, under a plain http.server it is this index
'<nav class="back"><a href="/">← all notes</a></nav>',
'<header class="index-head">',
f"<h1>{e(project)} notes</h1>",
(
f"<p>{len(site.docs)} docs · {len(site.work)} in flight · "
f"{len(site.questions)} open questions · generated {date.today()}</p>"
),
"</header>",
'<div class="index">',
section("Docs", docs, "no docs"),
section("Work in flight", work, "nothing in flight"),
section("Open questions", questions, "no open questions"),
section("Other", other, "nothing here", plain=True) if other else "",
"</div>",
]
)
(notes / "index.html").write_text(
page(site, "index.html", f"{project} notes", body)
)
# serve: every project's notes on one port
SKIP_DIRS = {"node_modules", "target", "dist", "build", "vendor", "__pycache__"}
def find_notes(roots: list[Path], depth: int = 6) -> dict[str, Path]:
"""url prefix (path relative to its root) -> .notes dir, for every project under roots"""
found: dict[str, Path] = {}
def walk(d: Path, root: Path, left: int) -> None:
notes = d / ".notes"
if notes.is_dir() and (notes / "docs").is_dir():
found[d.relative_to(root).as_posix() or d.name] = notes
# a repo's own subdirectories are its code, not more projects
if left == 0 or ((d / ".git").exists() and d != root):
return
try:
children = sorted(d.iterdir())
except OSError:
return
for c in children:
skip = c.name.startswith(".") or c.name in SKIP_DIRS
if c.is_dir() and not c.is_symlink() and not skip:
walk(c, root, left - 1)
for root in roots:
if root.is_dir():
walk(root, root, depth)
return found
def hub_page(title: str, body: str) -> str:
return (
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f"<title>{html.escape(title)}</title>\n"
'<link rel="stylesheet" href="/notes.css">\n</head>\n'
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
)
def search_form(action: str, q: str = "") -> str:
return (
f'<form class="search" action="{action}" role="search">'
f'<input type="search" name="q" value="{html.escape(q)}" '
'placeholder="search this project\'s notes" aria-label="search notes">'
"</form>"
)
# filters the hub's project rows as you type, nothing leaves the page
FILTER_BOX = (
'<input class="filter" type="search" placeholder="filter projects" '
'aria-label="filter projects" autofocus>'
)
FILTER_SCRIPT = """<script>
const box = document.querySelector("input.filter");
const rows = [...document.querySelectorAll(".rows.hub li")];
box.addEventListener("input", () => {
const words = box.value.toLowerCase().split(/\\s+/).filter(Boolean);
for (const r of rows) {
const name = r.dataset.name;
r.hidden = !words.every((w) => name.includes(w));
}
document.querySelector(".hub-empty").hidden = rows.some((r) => !r.hidden);
});
box.addEventListener("keydown", (e) => {
const first = rows.find((r) => !r.hidden);
if (e.key === "Enter" && first) location.href = first.querySelector("a").href;
});
</script>"""
def hub(projects: dict[str, Path]) -> str:
e = html.escape
rows = []
for prefix, notes in sorted(projects.items()):
questions = len(list((notes / "questions").glob("q[0-9]*.md")))
work = len([p for p in (notes / "work").glob("w[0-9]*") if p.is_dir()])
docs = len(list((notes / "docs").glob("d[0-9]*")))
last = git(notes, "log", "-1", "--format=%cs", check=False).strip()
meta = f"{docs} docs · {work} in flight · {questions} open · {last or 'no commits'}"
rows.append(
f'<li data-name="{e(prefix.lower())}">'
f'<span class="title"><a href="/{quote(prefix)}/">{e(prefix)}</a></span>'
f'<span class="meta">{meta}</span></li>'
)
body = (
'<header class="index-head"><h1>notes</h1>'
f"<p>{len(projects)} projects · generated {date.today()}</p></header>"
+ (FILTER_BOX if rows else "")
+ '<div class="index"><h2>Projects</h2>'
+ (
f'<ul class="rows hub">{"".join(rows)}</ul>'
'<p class="empty hub-empty" hidden>no project matches</p>'
if rows
else '<p class="empty">no .notes/ found</p>'
)
+ "</div>"
# after the rows, so the script finds them
+ (FILTER_SCRIPT if rows else "")
)
return hub_page("notes", body)
def page_text(page: str) -> str:
"""the readable text of an html page, without its chrome, words kept apart"""
flags = re.DOTALL | re.IGNORECASE
page = re.sub(r"<(style|script|svg|nav|title)\b.*?</\1>", " ", page, flags=flags)
return html.unescape(re.sub(r"<[^>]+>", " ", page))
def searchable(notes: Path) -> list[tuple[str, str, str, str]]:
"""(id, title, url relative to .notes/, plain text) for every page worth searching"""
site = Site(notes)
out = []
for d in site.docs:
text = (notes / d["page"]).read_text(errors="replace")
out.append((d["id"], d["title"], d["page"], page_text(text)))
mds = [*site.questions, *(f for w in site.work for f in sorted(w.rglob("*.md")))]
mds += [f for files in site.other.values() for f in files if f.suffix == ".md"]
for f in mds:
_, body = frontmatter(f.read_text(errors="replace"))
title, _ = split_title(body, f.stem)
item = next((p[:4] for p in f.parts if re.match(r"[dwq]\d{3}-", p)), "")
out.append((item, title, site.page_of(f), body))
for files in site.other.values():
for f in files:
if f.suffix == ".html":
text = page_text(f.read_text(errors="replace"))
out.append(("", f.name, f.relative_to(notes).as_posix(), text))
return out
def search(prefix: str, notes: Path, q: str) -> str:
e = html.escape
terms = [t.lower() for t in q.split() if t]
hits = []
for item, title, url, text in searchable(notes) if terms else []:
low_title, low = title.lower(), " ".join(text.split()).lower()
if not all(t in low_title or t in low for t in terms):
continue
flat = " ".join(text.split())
# centre on the whole phrase if it occurs, else on the rarest word
at = low.find(" ".join(terms))
if at < 0:
rarest = min((t for t in terms if t in low), key=low.count, default="")
at = max(low.find(rarest), 0) if rarest else 0
start = max(0, at - 80)
snippet = e(("…" if start else "") + flat[start : at + 160] + "…")
for t in sorted(set(terms), key=len, reverse=True):
snippet = re.sub(
f"({re.escape(e(t))})", r"<mark>\1</mark>", snippet, flags=re.IGNORECASE
)
score = sum(t in low_title for t in terms) * 100 + sum(
low.count(t) for t in terms
)
hits.append((score, item, title, url, snippet))
write_index(notes)
hits.sort(key=lambda h: -h[0])
rows = "".join(
f'<li><span class="id">{e(item)}</span><span class="title">'
f'<a href="{quote(url)}">{e(title)}</a>'
f'<span class="snippet">{snippet}</span></span></li>'
for _, item, title, url, snippet in hits
)
found = (
f'<ul class="rows results">{rows}</ul>'
if rows
else '<p class="empty">nothing found</p>'
)
body = (
'<nav class="back"><a href="./">← notes</a></nav>'
f'<header class="index-head"><h1>{e(prefix)}: search</h1>'
f"<p>{len(hits)} results for “{e(q)}”</p></header>"
+ search_form("search", q)
+ f'<div class="index">{found}</div>'
)
return hub_page(f"{q} · {prefix} notes", body)
def serve(roots: list[Path], host: str, port: int) -> None:
import http.server
from urllib.parse import parse_qs, unquote, urlsplit
class Handler(http.server.SimpleHTTPRequestHandler):
def guess_type(self, path: str) -> str:
if str(path).endswith(".md"):
return "text/plain; charset=utf-8"
return super().guess_type(path)
def send_text(self, body: str, ctype: str) -> None:
data = body.encode()
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
path = unquote(urlsplit(self.path).path)
if path in ("/", "/index.html"):
return self.send_text(
hub(find_notes(roots)), "text/html; charset=utf-8"
)
if path == "/notes.css":
return self.send_text(CSS.read_text(), "text/css; charset=utf-8")
projects = find_notes(roots)
prefix = max(
(p for p in projects if path == f"/{p}" or path.startswith(f"/{p}/")),
key=len,
default=None,
)
if prefix is None:
return self.send_error(404, "no such project")
if path == f"/{prefix}":
self.send_response(301)
self.send_header("Location", f"/{quote(prefix)}/")
self.end_headers()
return None
rest = path[len(prefix) + 2 :]
notes = projects[prefix]
if rest == "search":
q = parse_qs(urlsplit(self.path).query).get("q", [""])[0]
body = search(prefix, notes, q)
return self.send_text(body, "text/html; charset=utf-8")
if rest in ("", "index.html"):
# rebuilt on every visit, so a session that forgot to regenerate never shows stale;
# the search box only works under serve, so it is added here, not in the file
write_index(notes)
page = (notes / "index.html").read_text()
page = page.replace("</header>", "</header>" + search_form("search"), 1)
return self.send_text(page, "text/html; charset=utf-8")
self.serve_from = projects[prefix]
self.path = "/" + rest
return super().do_GET()
def translate_path(self, path: str) -> str:
base = self.serve_from.resolve()
target = (base / unquote(urlsplit(path).path).lstrip("/")).resolve()
return str(target if target.is_relative_to(base) else base / "__outside__")
def log_message(self, format: str, *args) -> None:
pass
server = http.server.ThreadingHTTPServer((host, port), Handler)
print(f"serving notes from {', '.join(map(str, roots))} on http://{host}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
def default_roots() -> list[Path]:
import os
env = os.environ.get("NOTES_ROOTS")
if env:
return [Path(p).expanduser() for p in env.split(os.pathsep) if p]
return [Path.home() / "git"]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--root")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("init")
p_next = sub.add_parser("next")
p_next.add_argument("letter", choices=sorted(BUCKETS))
sub.add_parser("index")
p_commit = sub.add_parser("commit")
p_commit.add_argument("subject")
p_serve = sub.add_parser("serve")
p_serve.add_argument("--port", type=int, default=8765)
p_serve.add_argument("--host", default="127.0.0.1")
p_serve.add_argument("--roots", nargs="*", type=Path)
args = parser.parse_args()
if args.cmd == "serve":
serve(args.roots or default_roots(), args.host, args.port)
return
notes = project_root(args.root) / ".notes"
if args.cmd == "init":
init(notes)
return
if not notes.exists():
sys.exit(f"{notes} does not exist, run init first")
if args.cmd == "next":
print(next_id(notes, args.letter))
elif args.cmd == "index":
write_index(notes)
print(notes / "index.html")
elif args.cmd == "commit":
commit(notes, args.subject)
if __name__ == "__main__":
main()