diff --git a/.gitignore b/.gitignore index 404e16a..c61a793 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ sway/scripts/* !bin bin/* !bin/ssh-menu +!bin/git-sign-branch # swaync !swaync @@ -124,3 +125,12 @@ claude/* !claude/settings.json !claude/statusline-command.sh !claude/rules +!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 diff --git a/bin/git-sign-branch b/bin/git-sign-branch new file mode 100755 index 0000000..b8ac19c --- /dev/null +++ b/bin/git-sign-branch @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# git sign-branch [-n] [--reset-author] [ []] +# re-creates every commit in .. with a signature, keeping trees, parents (mapped to +# the new ids), author, committer, dates and message byte for byte; merges are copied, never re-run +# --reset-author: author and committer become this repo's user.name and user.email (the signer); dates stay +# a branch with a merge of a signed tag (mergetag header) is refused, commit-tree can't carry the tag over +set -euo pipefail + +dry=0 reset=0 +while [[ ${1:-} == -* ]]; do + case $1 in + -n | --dry-run) dry=1 ;; + --reset-author) reset=1 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac + shift +done +if (( reset )); then + me_name=$(git config user.name) me_email=$(git config user.email) +fi +branch=${1:-$(git symbolic-ref --short HEAD)} +upstream=${2:-main} +ref=refs/heads/$branch +old_tip=$(git rev-parse --verify "$ref^{commit}") +git rev-parse --verify -q "$upstream^{commit}" >/dev/null || { echo "no such upstream: $upstream" >&2; exit 1; } + +# checked before anything is signed, so a refusal leaves no partial work +tagged=() +while read -r c; do + if [[ -n $(git cat-file commit "$c" | sed -n '1,/^$/{/^mergetag /p}') ]]; then + tagged+=("$(git log -1 --format='%h %s' "$c")") + fi +done < <(git rev-list "$upstream..$old_tip") +if (( ${#tagged[@]} )); then + echo "refusing to sign $branch: commit-tree can't carry over the signed tag these merges embed (mergetag):" >&2 + printf ' %s\n' "${tagged[@]}" >&2 + exit 1 +fi + +declare -A new # old id -> new id, only for commits in the range +msg=$(mktemp); trap 'rm -f "$msg"' EXIT +count=0 + +while read -r c parents; do + args=() changed=0 + for p in $parents; do + np=${new[$p]:-$p}; [[ $np != "$p" ]] && changed=1 + args+=(-p "$np") + done + # a commit already signed, with the same parents (and, with --reset-author, already ours) stays as it is + mine=1 + if (( reset )) && [[ $(git log -1 --format='%an%x1f%ae%x1f%cn%x1f%ce' "$c") != "$me_name"$'\x1f'"$me_email"$'\x1f'"$me_name"$'\x1f'"$me_email" ]]; then mine=0; fi + if (( !changed && mine )) && git verify-commit "$c" >/dev/null 2>&1; then new[$c]=$c; continue; fi + count=$((count + 1)) + if (( dry )); then + echo "would sign $(git log -1 --format='%h %s' "$c")"; new[$c]=dry-$c; continue + fi + git cat-file commit "$c" | sed '1,/^$/d' >"$msg" # message bytes after the header block + # a non-utf-8 commit keeps its encoding header; names are read in it so their bytes stay too + enc=$(git cat-file commit "$c" | sed -n '1,/^$/{s/^encoding //p}') + enc=${enc:-UTF-8} + IFS=$'\x1f' read -r an ae ad cn ce cd < <(git log -1 --encoding="$enc" --date=raw --format='%an%x1f%ae%x1f%ad%x1f%cn%x1f%ce%x1f%cd' "$c") + if (( reset )); then an=$me_name ae=$me_email cn=$me_name ce=$me_email; fi + new[$c]=$(GIT_AUTHOR_NAME=$an GIT_AUTHOR_EMAIL=$ae GIT_AUTHOR_DATE=$ad \ + GIT_COMMITTER_NAME=$cn GIT_COMMITTER_EMAIL=$ce GIT_COMMITTER_DATE=$cd \ + git -c i18n.commitEncoding="$enc" commit-tree -S "${args[@]}" -F "$msg" "$c^{tree}") +done < <(git rev-list --reverse --topo-order --parents "$upstream..$old_tip") + +if (( dry )); then echo "$count commit(s) to sign on $branch"; exit 0; fi +new_tip=${new[$old_tip]:-$old_tip} +[[ $(git rev-parse "$new_tip^{tree}") == $(git rev-parse "$old_tip^{tree}") ]] || { echo "tip tree differs, ref left alone" >&2; exit 1; } +# compare-and-swap: fails if the branch moved while we were signing +git update-ref -m "sign-branch: signed $count commit(s)" "$ref" "$new_tip" "$old_tip" +echo "$branch: $old_tip -> $new_tip ($count signed); undo: git update-ref $ref $old_tip" diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 0e56b5b..df3e63e 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -4,28 +4,24 @@ - for plans, reports, design docs, and anything benefiting from diagrams, styling, or embedded images, prefer HTML over Markdown - 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) + ## Diagnostics -- IMPORTANT: pursue root cause before proposing a fix; never propose a manual workaround before identifying the underlying cause -- if a fix doesn't resolve the symptom, dig deeper instead of layering patches -- verify the fix addresses the reported behavior, not just that the error went away -- for system/boot/network/regression bugs, write a root-cause hypothesis first (see ~/.config/claude/rules/diagnostics.md) +- IMPORTANT: find the root cause before proposing a fix; never present a workaround as a fix (see ~/.config/claude/rules/diagnostics.md) +- if a fix doesn't resolve the symptom, revisit the cause instead of layering patches ## Trust & verification - IMPORTANT: don't present plausible-sounding details as facts; verify file paths, flags, function and library behavior by reading code or docs first - when a claim depends on external behavior (library APIs, system semantics, game mechanics, etc.), cite the source you verified against, or mark it as unverified ## Planning vs execution -- IMPORTANT: when asked to "plan", "analyze", "deep-dive", or "research", produce documents only; no Edit/Write (except to the plan file) or Bash mutations until approved +- IMPORTANT: when asked to "plan", "analyze", "deep-dive", or "research", produce documents only; no Edit/Write (except to the plan file or `.notes/`) or Bash mutations until approved - if a request is ambiguous between planning and implementing, ask which one ## Scope -- if a diff grows beyond the original request, stop and confirm before continuing - -## Workflow -- when redirected or interrupted, stop the current approach immediately and follow the new direction without explaining or defending it -- focus on one goal per session; don't drift into unrelated improvements -- implement changes directly; don't use sub-agents/Task for simple operations like renames, file moves, or single-file edits -- use Task agents only for genuinely complex parallel exploration or research, not for straightforward implementation +- 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 ## Comments - lowercase, no trailing punctuation @@ -35,5 +31,9 @@ - keyword comments must always include the `(@janezicmatej)` attribution ## Git -- agents MUST commit with `--no-gpg-sign` -- when implementing multi-step work, split into logical commits the user can review and stage independently; don't bundle everything into one +- agents never sign commits, in any form: they are not me. commit with `--no-gpg-sign`; I re-sign branches on my host (`git sign-branch`) before they are pushed +- use the identity git already has; where it has none, commit as `-c user.name=agent -c user.email=agent@sandbox.invalid` (my host signing resets the author) +- commit messages: a conventional-commit subject line of at most 72 characters including the prefix; a body only where the project wants one, wrapped at 72; no trailers +- stage paths explicitly; never `git add .` or `git add -A` +- split multi-step work into a few logical commits I can review; condense fixups before handing a branch back +- every branch, worktree and container you create is yours to remove when the work ends; never remove ones you did not create diff --git a/claude/agents/explorer.md b/claude/agents/explorer.md new file mode 100644 index 0000000..02c96fb --- /dev/null +++ b/claude/agents/explorer.md @@ -0,0 +1,10 @@ +--- +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. +--- + +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. + +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. + +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. diff --git a/claude/agents/junior.md b/claude/agents/junior.md new file mode 100644 index 0000000..627de72 --- /dev/null +++ b/claude/agents/junior.md @@ -0,0 +1,10 @@ +--- +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. diff --git a/claude/agents/researcher.md b/claude/agents/researcher.md new file mode 100644 index 0000000..e1dcf2f --- /dev/null +++ b/claude/agents/researcher.md @@ -0,0 +1,8 @@ +--- +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//` 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. diff --git a/claude/agents/senior.md b/claude/agents/senior.md new file mode 100644 index 0000000..d8d000c --- /dev/null +++ b/claude/agents/senior.md @@ -0,0 +1,12 @@ +--- +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. diff --git a/claude/rules/diagnostics.md b/claude/rules/diagnostics.md index fa8ec26..9b81fec 100644 --- a/claude/rules/diagnostics.md +++ b/claude/rules/diagnostics.md @@ -1,26 +1,8 @@ # Diagnostics rules -Detailed root-cause discipline for system, boot, network, and regression bugs. The headline rules live in `CLAUDE.md`; this file is the long form. +The long form of the headline rules in `CLAUDE.md`. -## Root-cause hypothesis - -Before proposing any fix for a system/boot/network/regression bug, write a "Root cause hypothesis" section with these four parts: - -1. **Symptom** — the observable behavior, copied verbatim where possible (error message, missing output, wrong value) -2. **Proximate cause** — the immediate trigger, with evidence (file path + line, log excerpt, command output) -3. **Underlying cause** — why the proximate cause occurred; the layer of indirection that makes the proximate cause possible -4. **Ruled-out alternative** — at least one other plausible hypothesis you considered and why you discarded it - -Only after writing all four parts should you propose a fix. - -## Workaround discipline - -- never present a manual workaround as a fix; if a workaround is the only option, label it explicitly as a workaround and describe what would need to change to make it unnecessary -- if a fix doesn't resolve the symptom on first try, don't layer another patch; revisit the hypothesis instead -- if you're tempted to add a try/except, retry loop, or feature flag to mask an error, stop and ask first - -## Verification - -- before declaring a fix done, re-run the failing check and confirm the reported behavior is now correct -- "the error message disappeared" is not sufficient; confirm the underlying behavior matches what was expected -- if the fix touches a process, daemon, or background job, confirm it cleaned up after itself (no orphaned procs, sockets, locks, temp files) +- Say what the cause is, with the evidence (file and line, log excerpt, command output), before proposing a fix. For system, boot, network and regression bugs, also say which other cause you ruled out and why. +- A workaround is labelled as one, with what would have to change to make it unnecessary. Don't mask an error with a try/except, a retry or a flag without asking first. +- If the fix doesn't resolve the symptom, go back to the cause; don't stack another patch on top. +- Done means the reported behaviour is now right, checked by re-running what failed. An error that went away is not enough. If the fix touched a process, daemon or background job, check it left nothing behind (processes, sockets, locks, temp files). diff --git a/claude/rules/notes.md b/claude/rules/notes.md new file mode 100644 index 0000000..96423d8 --- /dev/null +++ b/claude/rules/notes.md @@ -0,0 +1,78 @@ +# 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 + +- `-`: `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 `" + 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'

{name} {len(rows)}

' + if not rows: + return f'{head}

{empty}

' + cls = "rows plain" if plain else "rows" + return f'{head}
    {"".join(rows)}
' + + def row(item_id: str, href: str, title: str, meta: str = "", cls: str = "") -> str: + return ( + f'
  • {e(item_id)}' + f'{e(title)}' + f'{meta}
  • ' + ) + + 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' to send' + ) + other.append( + f'
  • {e(f.name)}' + f' {sub}{portable}
  • ' + ) + + project = notes.parent.name + body = "\n".join( + [ + # under serve this is the hub, under a plain http.server it is this index + '', + '
    ', + f"

    {e(project)} notes

    ", + ( + f"

    {len(site.docs)} docs · {len(site.work)} in flight · " + f"{len(site.questions)} open questions · generated {datetime.now().astimezone().date()}

    " + ), + "
    ", + '
    ', + 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 "", + "
    ", + ] + ) + (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 ( + '\n\n\n\n' + '\n' + f"{html.escape(title)}\n" + '\n\n' + f'\n
    \n{body}\n
    \n\n\n' + ) + + +def search_form(action: str, q: str = "") -> str: + return ( + f'" + ) + + +# filters the hub's project rows as you type, nothing leaves the page +FILTER_BOX = ( + '' +) +FILTER_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'
  • ' + f'{e(prefix)}' + f'{meta}
  • ' + ) + body = ( + '

    notes

    ' + f"

    {len(projects)} projects · generated {datetime.now().astimezone().date()}

    " + + (FILTER_BOX if rows else "") + + '

    Projects

    ' + + ( + f'
      {"".join(rows)}
    ' + '' + if rows + else '

    no .notes/ found

    ' + ) + + "
    " + # 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.*?", " ", 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"\1", 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'
  • {e(item)}' + f'{e(title)}' + f'{snippet}
  • ' + for _, item, title, url, snippet in hits + ) + found = ( + f'
      {rows}
    ' + if rows + else '

    nothing found

    ' + ) + body = ( + '' + f'

    {e(prefix)}: search

    ' + f"

    {len(hits)} results for “{e(q)}”

    " + + search_form("search", q) + + f'
    {found}
    ' + ) + 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("", "" + 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() diff --git a/git/config b/git/config index 9e6ea7a..5d4e6d2 100644 --- a/git/config +++ b/git/config @@ -47,6 +47,10 @@ [alias] blame-ignore = "config blame.ignoreRevsFile .git-blame-ignore-revs" + # re-sign .. as the repo identity, keeping trees and merge topology + sign-branch = "!~/.config/bin/git-sign-branch --reset-author" + # list every commit in without a good signature; exits 1 if any, git's status if the range is bad + check-signed = "!f() { log=$(git log --format='%G? %h %s' \"${1:-main..HEAD}\") || exit; bad=$(printf '%s\\n' \"$log\" | grep -v -e '^[GU] ' -e '^$'); [ -z \"$bad\" ] && echo 'all signed' || { printf '%s\\n' \"$bad\"; exit 1; }; }; f" [branch] sort = -committerdate diff --git a/git/ignore b/git/ignore index 6e8a08d..3b9b799 100644 --- a/git/ignore +++ b/git/ignore @@ -9,6 +9,7 @@ # ai CLAUDE.local.md .claude +.notes # languages .python-version diff --git a/zsh/.zshrc b/zsh/.zshrc index 06005c8..6e08e73 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -91,6 +91,16 @@ fi zstyle ':completion:*' cache-path "$XDG_CACHE_HOME/zsh/zcompcache" zstyle ':completion:*' menu select zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}' +# _git picks these up for the matching git aliases (git/config) +_git-sign-branch() { + _arguments \ + '(-n --dry-run)'{-n,--dry-run}'[list the commits that would be signed]' \ + ':branch:__git_branch_names' \ + ':upstream:__git_references' +} +_git-check-signed() { + _arguments ':commit range:__git_commit_ranges' +} # tool hooks eval "$(ggman shellrc)" diff --git a/zsh/aliases.zsh b/zsh/aliases.zsh index d84b462..e27c73e 100644 --- a/zsh/aliases.zsh +++ b/zsh/aliases.zsh @@ -7,6 +7,9 @@ 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' + # docker alias dp="docker ps --format 'table {{.Names}}\t{{.Ports}}'" alias dcp="docker compose ps --format 'table {{.Name}}\t{{.Ports}}'"