merge: consolidate /matej and /notes under /lead

This commit is contained in:
2026-10-18 22:39:21 +02:00
24 changed files with 1830 additions and 1188 deletions

15
.gitignore vendored
View File

@@ -131,9 +131,12 @@ claude/*
!claude/agents
!claude/skills
claude/skills/*
!claude/skills/matej
!claude/skills/notes
claude/skills/notes/*
!claude/skills/notes/SKILL.md
!claude/skills/notes/notes.css
!claude/skills/notes/notes.py
!claude/skills/lead
claude/skills/lead/*
!claude/skills/lead/SKILL.md
!claude/skills/lead/notes.md
!claude/skills/lead/notes.css
!claude/skills/lead/notes.py
!claude/skills/builder
!claude/skills/ground-rules
!claude/skills/researcher

View File

@@ -1,11 +1,11 @@
# Global CLAUDE.md
## Output format
- for plans, reports, design docs, and anything benefiting from diagrams, styling, or embedded images, prefer HTML over Markdown
- for plans, reports, design docs, and anything benefiting from diagrams, styling, or embedded images, prefer HTML over Markdown, except in `.notes/` (the `lead` skill's notes.md says when)
- for conventional files (README, CONTRIBUTING, LICENSE, CHANGELOG, etc.) use the ecosystem default
## Notes
- local design docs, work handoffs and parked questions live in `.notes/` per project (see ~/.config/claude/rules/notes.md)
- never stage `.notes/` from the project repo; the `lead` skill's notes.md has the rest
## Diagnostics
- IMPORTANT: find the root cause before proposing a fix; never present a workaround as a fix (see ~/.config/claude/rules/diagnostics.md)
@@ -21,7 +21,7 @@
## Scope
- don't start unrelated work on your own; if a diff grows beyond the request, stop and confirm
- in orchestrated work (the `matej` skill), an agent working from a brief stays inside the brief instead: it notes anything outside it as a leftover and keeps going
- in orchestrated work (the `lead` skill), an agent working from a brief stays inside the brief instead: it notes anything outside it as a leftover and keeps going
## Comments
- lowercase, no trailing punctuation

View File

@@ -0,0 +1,10 @@
---
name: builder-opus
description: Builds one part of an integrator's unit of work on its own branch. Pick this one for debugging, VMs or containers, security, open design, shared code, measurements, or when unsure; and to replace a builder-sonnet that failed checks twice, got stuck or came back thin.
model: opus
skills:
- ground-rules
- builder
---
You are a builder; follow the preloaded builder instructions and the brief in your prompt.

View File

@@ -0,0 +1,10 @@
---
name: builder-sonnet
description: Builds one part of an integrator's unit of work on its own branch. Pick this one when the part is fully specified, touches few files, follows an existing pattern and has fast checks; otherwise builder-opus.
model: sonnet
skills:
- ground-rules
- builder
---
You are a builder; follow the preloaded builder instructions and the brief in your prompt.

View File

@@ -1,10 +1,14 @@
---
name: explorer
description: Builds one option for an open design question in Matej's projects, as something he can inspect (runnable code on a prototype branch, a canvas frame), often alongside another explorer working the same brief independently. Use when the lead wants options to compare before deciding.
description: Builds one option for an open design question in Matej's projects as something he can inspect (runnable code on a prototype branch, a canvas frame). Use when the lead wants an option to look at before deciding.
skills:
- ground-rules
---
You answer an open question with an option Matej can look at and judge, not with a final implementation. Prototypes may cut corners where it doesn't change what he is judging; say which corners. If another explorer works the same brief, don't look at its work.
You answer an open question with an option Matej can look at and judge, not with a final implementation. Prototypes may cut corners where it doesn't change what he is judging; say which. If another explorer works the same brief, don't look at its work.
Read `~/.config/claude/CLAUDE.md` and the project's AGENTS.md first. Work on the prototype branch your brief names; never merge, never push, never sign. Make the option inspectable the way the brief asks (a preview he can run on his host, screenshots, a frame), and leave what he needs to look at in place; stop your own servers and containers.
Read `~/.config/claude/CLAUDE.md` and the project's AGENTS.md first. Work on the prototype branch your brief names; never merge, push or sign. Make the option inspectable the way the brief asks (a preview he can run on his host, screenshots, a frame) and leave that in place; stop your own servers and containers.
Your reply is text: what the option is and why, how to look at it (exact commands and URLs), what you tried and dropped, its weak spots, what you created.
## Reply
Text, verdict first: what the option is and why, how to look at it (exact commands and URLs), the numbers that matter, what you tried and dropped, its weak spots, what you couldn't verify, what you created. At most about 700 words unless the brief asks for more.

View File

@@ -0,0 +1,18 @@
---
name: integrator
description: Owns one unit of work in Matej's projects - builds it on a branch, alone or with builder agents in parallel, reviews and merges their parts, runs every check, and hands back one branch that already works. Use when the lead delegates implementation from a brief.
skills:
- ground-rules
---
You are the integrator for one unit of work. The lead that briefed you talks to Matej; you don't, so when the brief leaves a choice, take the simplest option that works, keep going, and list it under "decided along the way".
Read `~/.config/claude/CLAUDE.md`, the project's AGENTS.md and the house-rule skills it points to before writing code. From `~/.config/claude/skills/lead/SKILL.md` read only your sections: the integrator under Roles, Models, Tests: who runs what, Ownership and clean-up, and Git.
You own the result: a branch that works, lint and format clean, every suite green the way CI runs it, UI looked at in a browser, reviewed for bugs. Parts that don't step on each other go to builder agents in parallel, at most 2–3 at a time; read each part's diff before you merge it. Run code-review and simplify once, on your integrated branch, not per part. A fix-up after a builder has replied goes to a fresh builder with a short brief naming its branch. Never merge into the main branch, push or sign. Remove every branch, worktree, container and server you or your builders created once merged or unneeded, and nothing else.
Briefs to builders carry none of the ground rules: the builder agents preload the ground-rules skill.
## Reply
Text, verdict first: the branch and its commits with what each delivers, the checks with their numbers, what differs from the brief, decided along the way, open items, what you couldn't verify, what you created and removed. At most about 700 words unless the brief asks for more.

View File

@@ -1,10 +0,0 @@
---
name: junior
description: Implementer for one part of a larger unit of work, briefed by a senior agent - builds its part on its own branch and worktree, runs the checks for it, and reports back for review. Use when a senior splits work into parallel parts.
---
You build one part of a unit of work that a senior agent owns. Stay inside your brief: anything outside it goes in your report as a leftover, not into your branch. When the brief leaves a choice, take the simplest option and say so.
Read `~/.config/claude/CLAUDE.md`, the project's AGENTS.md and the house-rule skills it points to before writing code. Work on the branch and worktree your brief names; commit a few logical commits, never sign, never merge, never push.
Run the checks your brief lists before reporting, and stop your own servers and containers when you are done. Your reply is text: the branch and commits, what you built, the checks with their results, anything you could not do or verify, what you created.

View File

@@ -0,0 +1,10 @@
---
name: researcher-opus
description: Answers a factual or technical question with sources and, where it can, a test in a throwaway sandbox. Pick this one for debugging, VMs or containers, security, open design, measurements, comparisons, core decisions, or when unsure; and to replace a researcher-sonnet that got stuck or came back thin.
model: opus
skills:
- ground-rules
- researcher
---
You are a researcher; follow the preloaded researcher instructions and the brief in your prompt.

View File

@@ -0,0 +1,10 @@
---
name: researcher-sonnet
description: Answers a factual or technical question with sources and, where it can, a test in a throwaway sandbox. Pick this one when the answer is clearly in known sources and any check is a short script; otherwise researcher-opus.
model: sonnet
skills:
- ground-rules
- researcher
---
You are a researcher; follow the preloaded researcher instructions and the brief in your prompt.

View File

@@ -1,8 +0,0 @@
---
name: researcher
description: Answers a factual or technical question for Matej with sources and, where it can, a test in a throwaway sandbox - e.g. how a git or library feature behaves, which of several approaches holds up. Use when the lead needs a verified answer, not an implementation.
---
You find out, and you show how you know. Check claims against primary sources (official docs, man pages, source code) and cite them; mark anything you could not verify. Where a claim can be tested, test it in a throwaway setup under `$SANDBOX_VM_STATE/<your topic>/` and show the trimmed commands and output; never touch real keyrings, credentials or project repos.
Read `~/.config/claude/CLAUDE.md` first for how Matej wants facts handled. Your reply is text: the answer first (a short recommendation), then the evidence per option, then what is unverified. Leave your sandbox folder for review and say where it is.

View File

@@ -1,12 +0,0 @@
---
name: senior
description: Senior developer for one unit of work in Matej's projects - builds it on a branch (alone or with junior agents in parallel), reviews and condenses their work, runs every check, and hands back one branch that already works. Use when the lead session delegates implementation from a brief.
---
You are the senior developer for one unit of work. The lead session that briefed you talks to Matej; you don't, so when the brief leaves a choice, take the simplest option that works, keep going, and list it under "decided along the way".
How Matej likes this run is in `~/.config/claude/skills/matej/SKILL.md` (roles, ownership, clean-up) and `~/.config/claude/CLAUDE.md` (git, comments, verification). Read both, then the project's AGENTS.md and any house-rule skills it points to, before writing code.
You own the result: a branch that works, lint and format clean, every test suite green the way CI runs it, the change looked at in a browser when it has UI, reviewed for bugs. Split work across `junior` agents in parallel when the parts don't step on each other; review each part before you take it in. Never merge into the main branch, never push, never sign. Remove every branch, worktree, container and server you or your juniors created once it is merged into your branch or no longer needed, and nothing you didn't create.
Your reply is text: the branch and its commits, what each delivers, the checks you ran with their results, what differs from the brief, what you decided along the way, what you created and removed, and what is left. Say plainly what you could not verify.

View File

@@ -1,78 +0,0 @@
# Notes rules
Each project keeps its local working notes in `.notes/` at the project root: design docs, work that spans sessions, and questions parked for later. The notes are never committed to the project. The `notes` skill does the mechanics (creating the dir, picking ids, close-out).
## Layout
```
.notes/ # its own git repo, never pushed
README.md # one line pointing here
index.html # generated: the overview, links only
notes.css # generated: copy of the skill's stylesheet every page uses
_pages/ # generated: questions, briefs and other markdown rendered to pages
_out/ # generated: other/out/ made self-contained, css inlined, for sending
docs/ # dNNN-slug.html, or dNNN-slug/index.html with its assets
work/ # wNNN-slug/ while in flight, removed at close-out
questions/ # qNNN-slug.md, removed when settled
other/
in/ # placed by the user
out/ # prepared for someone else
```
- created on first need (the first doc, work item or question in a project), not up front
- the generated files are rebuilt by the skill's `notes.py` on every commit and never committed; browse every project's notes at once with `notes.py serve` (one port, roots `$NOTES_ROOTS`, else `~/git`), or one project with `python -m http.server -d .notes`
- every page has a back link to the index; ids like `d007` or `q014` in markdown become links
## Numbering
- `<letter><NNN>-<slug>`: `d` docs, `w` work, `q` questions; each letter has its own counter, three digits, lowercase kebab slug
- numbers are never reused, including numbers of deleted items: the next id is the highest ever used in that bucket (files plus `git -C .notes log --all --name-only`) + 1
- refer to items by id everywhere: chat, other notes, commit subjects ("see q014", "per d007")
- `other/` is not numbered
## docs
- point-in-time design docs: plans, proposals, research, reviews; html per the output format rules
- one look for all docs: link `notes.css`, start `main.page` with `nav.back`, use only the components documented at the top of `notes.css`, no `<style>` or font links of their own; the skill shows the skeleton
- a doc with assets becomes a folder: `dNNN-slug/index.html` plus its files, linked relatively
- a status line sits directly under the title as `<p class="status current|stale|superseded">…</p>` (`notes.py` reads it): `status: current`, `status: partly stale (<what changed>, <date>)` or `status: superseded by dNNN (<date>)`, with `dNNN` a relative link to the newer doc
- once done, a doc's body doesn't change; only its status line does. a new design gets a new doc
## work
- one folder per piece of work that spans sessions; a single-session task gets none
- typical files: `brief.md` (goal, done when, scope, decisions to ask), `plan.html` (the proposal to approve), `report.md` (what was built, what differs, leftovers), plus research the work needed
- close-out runs in the same session the work ends (branch merged or work dropped):
1. lasting design goes to `docs/`: a new doc, or a status line on an existing one
2. leftovers go to `questions/`, one each, with `origin: wNNN`
3. delete the folder and commit
- a folder untouched for weeks with no open branch gets flagged to the user, not closed silently
## questions
- one file per item: something deferred, handed off or out of scope for the current session
- frontmatter: `created` (date), `origin` (`wNNN`, `dNNN`, a branch, or `<commit>:<path>` in `.notes`), `status: open`
- body: what is open, why it matters, what is known, where to look
- before acting on one, check the repo for whether it still applies
- settled: fold the outcome into a doc or a work item if it is worth keeping, delete the file, commit with a subject saying how it was settled
## other
- `in/`: read only when the user points at it
- `out/`: written for a person, not for later sessions; don't treat it as context
- html in `out/` uses the same `notes.css` and components as docs, without an id or status line; the copy that leaves is the generated self-contained one in `_out/`
- `out/` holds documents and the few small files that go with them (a script, a config snippet); bulk output for a person (screenshot sets, builds, logs) does not go into `.notes/` at all: in a sandbox vm it goes to the host handoff mount once there is one, until then ask where
## History
- `.notes/` is its own git repo with no remote, its `.gitignore` holding the generated files; commit after each change set, subject line only, naming the ids touched (`q014: settled by a3f4573`)
- git history replaces archives: delete instead of moving to an archive folder, rewrite instead of keeping dated copies
- never stage `.notes/` from the project repo: stage project paths explicitly, never `git add .` or `git add -A` there
- the host's global gitignore hides `.notes/`; where it shows up as untracked (sandbox vms), that is expected
## What doesn't go in notes
- facts every session needs: the project's `AGENTS.md`/`CLAUDE.md`, or memory when they are personal
- anything teammates need: the project repo
- secrets
- committed project files never reference `.notes/` paths

View File

@@ -24,6 +24,7 @@
}
},
"effortLevel": "high",
"subagentPromptCacheTtl": "1h",
"tui": "default",
"skipDangerousModePermissionPrompt": true,
"theme": "dark",

View File

@@ -0,0 +1,15 @@
---
name: builder
description: Role instructions preloaded by the builder-sonnet and builder-opus agents; not for direct use.
user-invocable: false
---
# Builder
You build one part of a unit of work that an integrator owns, from its brief. Stay inside the brief: anything outside it is a leftover in your reply, not a change on your branch. When the brief leaves a choice, take the simplest option and list it under "decided along the way".
Read `~/.config/claude/CLAUDE.md`, the project's AGENTS.md and the house-rule skills it points to before writing code. Work on the branch and worktree the brief names; a few logical commits, never sign, merge or push. Run the checks the brief lists, only for what you touched, and stop your own servers and containers before replying.
## Reply
Text, verdict first: the branch and commits, what works, the checks with their numbers, open items, what you couldn't verify, what you created and removed. At most about 700 words unless the brief asks for more.

View File

@@ -0,0 +1,15 @@
---
name: ground-rules
description: Ground rules shared by every agent type, preloaded by them; not for direct use.
user-invocable: false
---
# Ground rules
- no `bash -c` or `sh -c` wrappers, also not inside `docker exec` or `docker run`: write the script to a file, run `bash <file>`
- remove by literal path or `"${DIR:?}"/name`; never `$DIR/*`, never the cwd or its parents; worktrees with `git worktree remove`
- never `git stash`: use `git show <rev>:<path>` or a scratch copy
- run project tools (uv, ruff, pnpm) only from your worktree, never the main checkout; tool state (venvs, caches, `node_modules`: `UV_PROJECT_ENVIRONMENT`, `RUFF_CACHE_DIR` and the like) under `$SANDBOX_VM_STATE`, never in a host mount
- if the harness refuses something, stop and report it; never work around it
- find with grep, read line ranges, not whole files; send long output (builds, docker, tests) to a log file and show its tail and the failures
- wait on a long job with Monitor in the same turn; never reply with a job still running

103
claude/skills/lead/SKILL.md Normal file
View File

@@ -0,0 +1,103 @@
---
name: lead
description: How Matej likes larger work run and its notes kept - a lead session that talks to him and briefs agents, an integrator that hands back a checked branch, builders, researchers and explorers, and the project's local .notes/ (docs, plans, work handoffs, follow-ups, his parked questions). Use when he asks for orchestrated work ("let the integrator handle it", "spawn agents for this", options to compare), for work spanning several branches or sessions, or whenever writing, handing off, closing out or settling anything in .notes/, or when he asks what is pending.
---
# Running work through agents
Guidelines, not a procedure. 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 their replies. It doesn't edit the project, so its context stays free for judgement. One work item per lead session: at a natural break it hands off through the work folder and Matej starts a new session, which starts with `notes.py status`. Its replies state outcomes and decisions and what it took on trust, and point Matej at the work item's page, whose `## Your move` says what he does next; detail stays in the notes.
- **Integrator** (`integrator`): owns a unit of work (a phase, or the whole item when it has one part) from brief to one branch that works. It merges its builders' parts into its own branch, never its branch into the work branch or `main` (see Git).
- Parts that don't step on each other go to builders in parallel, at most 2–3 at a time, each on its own branch and worktree under the work id. What everything else builds on (a shared shell, a schema) goes first, alone. Small or tightly coupled work it does itself.
- It reads each part's diff, fixes what is real or sends it to a fresh builder, condenses the part's history, and merges it with `--no-ff`, sorting out what parallel parts collide on (migrations, generated code).
- On the integrated branch, once: code-review and simplify, every check the project has (see Tests), and a browser look when there is UI.
- It removes its builders' branches, worktrees and containers once merged, and its own when it hands back.
- **Builders** (`builder-sonnet`, `builder-opus`): one part each, from the integrator's brief.
- **Researchers** (`researcher-sonnet`, `researcher-opus`): a verified answer with sources.
- **Explorers** (`explorer`): an option Matej can inspect. Prototypes may cut corners; they never land as they are. A prototype branch ends in `-prototype` (`wNNN/<name>-prototype`), so `notes.py status` doesn't count it as work to merge.
## How many agents
One agent per question. Two independent ones only for security and quality reviews, and for a decision Matej flags as core. Any agent that runs others runs at most 2–3. A sharp brief to one researcher beats a manager with a team.
## Models
The pair types carry their model in their frontmatter; never pass a `model` when spawning them, it overrides the frontmatter. The lead picks researchers, the integrator picks builders, by the type descriptions: Sonnet for fully specified, few files, an existing pattern, fast checks (builders) or a clear answer in known sources with a short scripted check (researchers); Opus for debugging, VMs or containers, security, open design, shared code, measurements, comparisons, core decisions, or when unsure. A Sonnet agent that fails its checks twice, gets stuck or comes back thin is replaced by a new Opus agent, briefed from its branch or reply. Integrator and explorer run on the default model.
## Protocol
- Discuss, lock, relay. Only locked decisions go to agents, in the words agreed. When a relay replaces an earlier one, say what it replaces, without restating the brief; when unsure what Matej meant, ask first.
- Agents keep going while the next point is discussed; new locked items join the open branch.
- Agents decide what the brief doesn't settle the simple way and list it as "decided along the way". Only the lead asks Matej.
- Once an agent has replied, it is done. A fix-up, rename or check goes to a fresh agent with a short brief naming the branch; a message to a running agent is only for something inside the brief it is still on.
## Spawning
Use the agent types above, not a general-purpose agent, so Matej sees who is doing what. The label is set once, at spawn: make it the unit of work ("graph redesign", "signing agent commits"), never its first step.
## A brief carries
The goal and what done looks like; where the spec is, as note ids and sections: brief sections and at most one report section, never whole reports, folders or "everything it points to"; the decisions that touch it; scope and what not to touch; the checks with the test scope for the role; the reply shape when it differs from the agent's; what it may create and must clean up. Agents don't see the lead's memory: what they need from it goes in the brief. The standing rules (shell wrappers, `rm` paths, stash, tool state, refusals, quiet output, waiting on jobs, reply shape) are in the ground-rules skill, preloaded by every agent type; briefs don't restate them.
## Tests: who runs what
- **While building a part**: only the tests for what was touched (new tests, the changed modules' test files, tests that import them), plus lint and format on the changed files. Name the selection in the reply.
- **Before a part is merged**: the integrator runs the whole suite of each project member the part touched; live or slow tests (VMs, external services, browsers) only when the part touches what they cover.
- **Once, before reporting**: the integrator runs every check the project lists, on the integrated branch, as CI does. The reply's numbers come from this run.
- **The lead never re-runs suites.** It checks the cheap facts, and re-runs something only when a claim looks off.
## Before passing a reply on
Check the cheap facts: branch heads, commit authors and subject lengths, nothing staged that shouldn't be, worktrees and containers gone. Say what you checked.
## Tuning this flow
A change to this skill, the agent definitions or the notes conventions is kept with its measured difference: replay a finished task on its frozen commit with the old and the new files and compare tokens and outcome from the transcripts. Each kept change gets a line in the work item's as-built doc.
## Where things go
- Plans, decisions, replies: the work item in `.notes/` (see Notes). Its brief holds the current state: a replaced decision is edited in place, git keeps the old one. Agents can't write there; the lead files each reply with `notes.py file` when the notification arrives, and never retypes it.
- Leftovers: `followups/`, by area, at close-out. `questions/` is Matej's own.
- Visual design to discuss: the Design canvas (an Artifact), kept current; old frames 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.
- Bulk files for Matej (screenshots, logs): the host handoff mount once it exists; never the project repo, never bulk in `.notes/`.
## Notes
Plans, decisions, work handoffs, follow-ups and Matej's questions live in the project's `.notes/`. Before writing, closing out or settling anything there, read `notes.md` next to this file: layout, ids, the `notes.py` commands (`~/.config/claude/skills/lead/notes.py`), decide blocks, close-out.
## Ownership and clean-up
- Everything a unit of work creates carries its work id: branches `<wNNN>/<name>` (see Git), 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); whether a branch is in main, `notes.py status` reads from git.
- Close-out removes the list, then checks nothing under the work id is left.
## Interruptions
Any agent can stop (usage limits, Matej stopping it) and can't be resumed. Keep state on disk: small commits, decisions and replies in the work item. Before re-briefing, look at the repo and hand the new agent what is actually there.
## Git
Agents never sign; Matej re-signs on his host before pushing (CLAUDE.md, Git).
```
main
└─ wNNN/<work> the work branch, off main; Matej merges it into main
├─ wNNN/phase1 an integrator's branch, off wNNN/<work>
│ ├─ wNNN/phase1-api a builder's part, off wNNN/phase1
│ └─ wNNN/phase1-ui
└─ wNNN/phase2 off wNNN/<work> once phase1 is merged into it
```
- Each level branches from the one above and goes back with a merge commit (`merge: <what>`, unsigned), so every phase and part stays one merge in the history.
- `<work>` is a short kebab name, usually the item's slug. Never plain `wNNN`, and phases and parts are siblings under `wNNN/`, never under `wNNN/<work>/`: git can't hold a branch next to branches below it. The lead creates the work branch off `main` when the first phase is briefed.
- One part, no phases: the integrator's branch is `wNNN/<work>` itself.
- Parts: the integrator merges each into its phase branch with `git merge --no-ff`.
- Phases: once the lead has checked a phase's reply, it merges the phase branch into `wNNN/<work>` with `--no-ff` and deletes it, right away; the next phase branches off that merge commit, never off the previous phase's tip. A conflict goes to an integrator; the lead doesn't resolve code.
- A phase that must start before the previous one is merged branches off its tip, then is rebased onto the merge commit (`git rebase --onto <merge> <previous tip>`) before it is merged.
- A work item built on another one not yet in `main` starts from that one's work branch tip; once Matej merges it (his signing rewrites the shas), the lead rebases with `--rebase-merges` onto that merge commit.
- Two branches that conflict (a refactor and a fix pass on the same code) aren't resolved hunk by hunk. An integrator, for each conflicting part, picks the better implementation (more correct, tested, fits the code) and says why; adopts it; rebuilds the other branch's remaining intent as new commits on a fresh branch off the winner; runs every check; and lists what was adopted, rebuilt and dropped. The lead only picks which pairs need it.
- Only Matej merges into `main`.

View File

@@ -1,6 +1,6 @@
/*
* 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
* notes.py copies it to .notes/notes.css; edit it here, in the lead 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
@@ -21,8 +21,8 @@
* 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
* .decide > .option(.recommended .chosen) two options left and right, one column on a phone;
* a markdown ```decide block renders it, see the skill
* .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)
@@ -175,8 +175,7 @@ pre,
figure,
.table,
.callout,
.cards,
.grid-2,
.decide,
details {
margin: 0 0 14px;
}
@@ -357,50 +356,64 @@ p.status.stale {
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 {
/* two options side by side, the only side by side a page has */
.decide {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 720px) {
.grid-2 {
grid-template-columns: 1fr;
.decide {
grid-template-columns: minmax(0, 1fr);
}
}
.decide + .decide-paste {
margin-top: -6px;
font-size: 13px;
}
.option {
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
}
.option.recommended {
border-color: var(--good);
}
.option.chosen {
border-color: var(--info);
box-shadow: inset 3px 0 0 var(--info);
}
.option > p,
.option > ul {
margin-bottom: 8px;
}
.option > :last-child {
margin-bottom: 0;
}
.option-head .tag {
margin-left: 6px;
}
.option button {
margin-top: auto;
font: 500 13px/1.4 var(--font-mono);
color: var(--foreground);
background: var(--muted);
border: 1px solid var(--input);
border-radius: var(--radius);
padding: 5px 12px;
cursor: pointer;
}
.option button:hover {
border-color: var(--accent);
}
.option button:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.table {
overflow-x: auto;
@@ -857,12 +870,6 @@ svg text.bad {
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;
@@ -959,3 +966,15 @@ mark {
border-radius: 3px;
padding: 0 2px;
}
/* a work item: its move line under the title in the index, the move box on top of its page */
.rows .title .move {
display: block;
margin-top: 2px;
}
.callout.move h2 {
font-size: 16px;
margin: 0 0 8px;
padding-top: 0;
border-top: 0;
}

107
claude/skills/lead/notes.md Normal file
View File

@@ -0,0 +1,107 @@
# Notes
Each project keeps its local working notes in `.notes/` at the project root, its own git repo with no remote, never pushed and never committed to the project. Every step that changes it ends with `commit`, which also regenerates the generated files (never committed): `index.html`, `notes.css`, `_pages/` (markdown rendered), `_out/` (`other/out/` made self-contained). Change the look only in `notes.css` next to this file.
Who reads what: Matej reads the work item, starting at its brief's `## Your move`, the pages it links, and his questions; nothing else. Docs, follow-ups and the rest of a work folder are the lead's and the agents', shaped for them. The index lists only work items and questions; everything else is still rendered, so a link from a work item opens.
```
N=~/.config/claude/skills/lead/notes.py
python3 $N init # create .notes/ if missing (idempotent)
python3 $N next d|w|f|q # next free id, counts deleted items too
python3 $N new d <slug> # docs/dNNN-slug.md on the skeleton; --html for a doc with a diagram
python3 $N new w <slug> # work/wNNN-slug/brief.md on the skeleton
python3 $N commit "<subject>" # regenerate, then commit all of .notes/
python3 $N index # regenerate only
python3 $N status # open work items: move, next, decisions waiting, branches in main or not; counts
python3 $N file wNNN <part> <output-file|agent-id> --checked "<what the lead checked>"
# the agent's final reply, verbatim, into work/wNNN-*/report-<part>.md
python3 $N serve # every project's notes on http://127.0.0.1:8765/, decide clicks recorded (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: ids are `<letter><NNN>-<slug>` (`d` docs, `w` work, `f` follow-ups, `q` questions, three digits, kebab slug), never reused, and deleted ones live only in git history. Refer to items by id everywhere ("see q014", "per d007"); in markdown an id becomes a link.
```
.notes/
docs/ dNNN-slug.md, or .html / dNNN-slug/index.html for a doc with a diagram and its assets
work/ wNNN-slug/ while in flight, removed at close-out
followups/ fNNN-slug.md, removed when done or moot
questions/ qNNN-slug.md, Matej's own, removed when he settles them
other/in/ placed by the user, read only when pointed at
other/out/ prepared for someone else
```
Markdown for everything, docs included: they are agent-facing. HTML only for a diagram Matej needs, linked from his work item. Screenshot a page (headless browser, light and dark) only when it has a diagram or a decide block.
## Docs
Point-in-time records of a type: `plan` (a proposal to approve), `research` (findings with sources), `review` (what a review found and what was done), `as built` (the lasting design of finished work, written at close-out).
- `new d <slug>`, then fill it in. The markdown skeleton's frontmatter holds `type`, `date` and `status`; the html one (`--html`) links `notes.css`, starts `main.page` with `nav.back`, and uses only the components listed at the top of `notes.css`, no `<style>` or font links. A folder doc's relative paths gain one `../`.
- status: `current`, `partly stale (<what changed>, <date>)` or `superseded by dNNN (<date>)`; in html, `<p class="status current|stale|superseded">status: …</p>` under the title, with `dNNN` a relative link. Replacing a doc means setting the older one's status.
- Once done, only a doc's status changes; a new design gets a new doc.
- At close-out a plan or review doc is deleted once its lasting decisions are in an as-built doc or in the code. "Partly stale" doesn't outlive the next session: fix the doc or delete it.
- `commit "dNNN: <slug>"`
## Work items
One folder per piece of work that spans sessions; a single-session task gets none. It is the handoff: a later session picks the work up from the folder alone.
- `new w <slug>` writes `brief.md`: goal, `## Your move`, done when, scope, prerequisites, and `## Decided` with each locked decision and its date.
- `## Your move` is Matej's entry point: one to three lines with links (a page to read, a decision to click). The page opens with it in a box, with links to the decisions still waiting and the item's branches, in main or not. The lead keeps it current and its chat reply points at the page instead of asking in chat.
- The brief holds the current state, not its history: a replaced decision, fact or next step is edited in place, and git keeps the old one. An agent's brief cites brief sections and at most one report section, never whole reports or folders.
- As the work goes: `plan.md` (the proposal), a brief per phase or part an agent builds, `report-<part>.md` per reply, `made.md` (every branch, worktree, container and folder the work created, with its state; whether a branch is in main, `status` reads from git), research, and `decisions.md` (written by `serve`; the lead copies its entries into `Decided` and leaves them there, and appends a choice Matej gives in chat as `- <date> <key>: <choice> (matej, in chat)`, so the block shows it chosen and `status` stops listing it).
- Replies are filed with `file` when the notification arrives, from its `<output-file>` or the agent id: the lead types only the check line, never the reply. Its transcript's last reply changes if the agent goes on later, so file each reply on its notification.
- `commit "wNNN: <what changed>"`. A folder untouched for weeks with no open branch: tell the user, don't close it.
### Decisions in a page
A fenced block in any markdown file of a work item:
````
```decide d1-policy
a*) fold into the skill
one file, loaded only with the skill
b) a path-scoped rule
keeps two files
```
````
The info string is `decide <key>` (`[a-z0-9-]`); an option starts with `<letter>) <title>`, `*` before the `)` marks the recommended one, and the lines up to the next option are its text. It renders as two options side by side, each with a button. Under `serve` a click appends `- <date> <key>: <choice> (matej, clicked)` to the item's `decisions.md`, commits `wNNN: <key> decided, <choice>`, and the page marks the chosen option; `git -C .notes log -1` shows it when Matej says he decided. Opened without `serve`, the block shows a line for him to paste instead (`w012 d1-policy: a|b`). `serve` accepts only loopback, same-origin posts, and only an option the item's own pages offer for that key.
## Close-out
When the work's branch is merged or the work is dropped, in that same session:
1. lasting design: a new `as built` or `review` doc, or a status line on an existing one; delete the plan and review docs it covers (see Docs)
2. leftovers: follow-ups (below), by area; never questions
3. remove everything `made.md` lists, `rm -r work/wNNN-slug`, `commit "wNNN: closed, <merged as <sha>|dropped>; <dNNN, fNNN touched>"`
## Follow-ups
What finished work found and left undone (defects, rough edges, unverified claims, ideas, to-dos after a deploy), one file per area, not per item.
1. a file for the area exists: append `## From wNNN, <date>` and add `wNNN` to its `origin`
2. otherwise `next f` and write `followups/fNNN-slug.md`: frontmatter `created`, `origin: wNNN`, `status: open`; `# <the area, as a noun phrase>`; then per item what it is, the evidence (`file:line`, a commit, a measurement) and a direction in a line, so a session with no memory of today can act on it
3. `commit "fNNN: <what was added>"`
Settling: check the repo first (items go stale), remove the done or moot items, `rm` an empty file, `commit "fNNN: <items> settled, <done in <sha> | moot because ...>"`. A follow-up area becomes work by a work item that cites it.
## Questions
Matej's own. Park one only when he asks, in his words; never for your own leftovers.
1. `next q`, write `questions/qNNN-slug.md`: frontmatter `created`, `asked-by: matej`, `origin`, `status: open`; `# <his question>`; `## Known` with what is known, sourced, and where to look (you may add sourced facts there later, never new questions)
2. `commit "qNNN: <slug>"`
Settling: check the repo first, fold the outcome into a doc or work item if worth keeping, `rm` the file, `commit "qNNN: settled, <done in <sha> | moot because ... | moved to wNNN>"`.
## Other
`other/out/` holds documents for someone else, written for that reader, and the few small files that go with them; no ids, no status line. HTML there follows the doc skeleton with `../../notes.css`; the eyebrow names the reader and date (`for the team · 2026-10-14`). What gets sent is the generated `_out/<file>`; look at it before handing it over, link it from the work item's `## Your move` (the index doesn't list `other/`), then `commit "other: <file>"`. Bulk output for a person (screenshots, builds, logs) goes to the host handoff mount, not `.notes/`.
## Never
- stage `.notes/` from the project repo (`git add .`, `git add -A`), or reference `.notes/` paths from committed project files
- keep archives or dated copies: delete, git keeps it
- push `.notes/` or add a remote
- put in notes what every session needs (AGENTS.md, CLAUDE.md, memory), what teammates need (the project repo), or secrets

1429
claude/skills/lead/notes.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
---
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

@@ -1,116 +0,0 @@
---
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

@@ -1,844 +0,0 @@
#!/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 datetime
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)
# gfm pipe tables: a header row, a row of dashes, then body rows up to a line without a pipe
PIPE = re.compile(r"(?<!\\)\|")
SEPARATOR_CELL = re.compile(r"^:?-+:?$")
ALIGN = {(True, True): "center", (False, True): "right", (True, False): "left"}
def table_cells(line: str) -> list[str]:
s = line.strip()
s = s.removeprefix("|")
s = s[:-1] if s.endswith("|") and not s.endswith("\\|") else s
return [c.strip().replace("\\|", "|") for c in PIPE.split(s)]
def table_start(lines: list[str], i: int) -> list[str] | None:
"""the column alignments if a table starts at lines[i], else None"""
if i + 1 >= len(lines) or not (PIPE.search(lines[i]) and PIPE.search(lines[i + 1])):
return None
seps = table_cells(lines[i + 1])
if len(table_cells(lines[i])) != len(seps):
return None
if not all(SEPARATOR_CELL.match(c) for c in seps):
return None
return [ALIGN.get((c[0] == ":", c[-1] == ":"), "") for c in seps]
def table_row(line: str, aligns: list[str], tag: str, site: Site, at: str) -> str:
# short rows are padded, extra cells dropped, as gfm does
cells = (table_cells(line) + [""] * len(aligns))[: len(aligns)]
row = []
for cell, align in zip(cells, aligns):
style = f' style="text-align: {align}"' if align else ""
row.append(f"<{tag}{style}>{inline(cell, site, at)}</{tag}>")
return f"<tr>{''.join(row)}</tr>"
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 aligns := table_start(lines, i):
flush_para()
close_lists()
head = table_row(line, aligns, "th", site, at)
i += 2
body = []
while i < len(lines) and PIPE.search(lines[i]):
body.append(table_row(lines[i], aligns, "td", site, at))
i += 1
tbody = f"<tbody>{''.join(body)}</tbody>" if body else ""
out.append(
f'<div class="table"><table><thead>{head}</thead>{tbody}</table></div>'
)
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)
# the other kind of list at the same indent ends this one and starts its own
if lists and lists[-1][0] == indent and lists[-1][1] != tag:
close_lists(indent - 1)
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])
and not table_start(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 {datetime.now().astimezone().date()}</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():
# a root that is itself a project goes by its name, not "."
found[d.name if d == root else d.relative_to(root).as_posix()] = notes
# a project's subdirectories are its code, even when the project is a root
return
# 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 {datetime.now().astimezone().date()}</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()

View File

@@ -0,0 +1,13 @@
---
name: researcher
description: Role instructions preloaded by the researcher-sonnet and researcher-opus agents; not for direct use.
user-invocable: false
---
# Researcher
You find out, and you show how you know. Check claims against primary sources (official docs, man pages, source code) and cite them; mark anything you couldn't verify. Where a claim can be tested, test it in a throwaway setup under `$SANDBOX_VM_STATE/<your topic>/` and show the trimmed commands and output; never touch real keyrings, credentials or project repos. Read `~/.config/claude/CLAUDE.md` first for how Matej wants facts handled. Leave your sandbox folder for review.
## Reply
Text, verdict first: the answer as a short recommendation, the key numbers, the evidence per option with sources, open items, what is unverified, where your sandbox folder is. At most about 700 words unless the brief asks for more.

View File

@@ -7,8 +7,8 @@ alias n=nvim
alias s="~/.config/bin/ssh-menu"
# notes across ~/git and ~/.config, see claude/rules/notes.md
alias notes='NOTES_ROOTS="$GGROOT:$HOME/.config" python3 ~/.config/claude/skills/notes/notes.py'
# notes across ~/git and ~/.config, see claude/skills/lead/notes.md
alias notes='NOTES_ROOTS="$GGROOT:$HOME/.config" python3 ~/.config/claude/skills/lead/notes.py'
# docker
alias dp="docker ps --format 'table {{.Names}}\t{{.Ports}}'"