diff --git a/.gitignore b/.gitignore index 05a0341..0fb1d17 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index df3e63e..51c718a 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -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 diff --git a/claude/rules/notes.md b/claude/rules/notes.md deleted file mode 100644 index 96423d8..0000000 --- a/claude/rules/notes.md +++ /dev/null @@ -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 - -- `-`: `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 = site.notes / OUT / f.relative_to(src) + if f.suffix != ".html": + write_whole(target, 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) + write_whole(target, CSS_LINK.sub(lambda _: style, text, count=1)) + + +def write_whole(path: Path, data: str | bytes) -> None: + """write beside path, then rename over it: readers see the old file or the new one""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{secrets.token_hex(4)}.partial") + try: + tmp.write_bytes(data) if isinstance(data, bytes) else tmp.write_text(data) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + + +def prune(site: Site) -> None: + """generated files and folders whose source is gone or changed kind, and the partial + files of crashed builds; another build may be writing alongside, so its partials stay""" + notes = site.notes + # notes.css was just written: its mtime is now by the clock that stamps the partials + now = (notes / "notes.css").stat().st_mtime + found = [ + *notes.glob(".*.partial"), + *(notes / PAGES).rglob("*"), + *(notes / OUT).rglob("*"), + ] + # deepest first, so a folder is looked at once its contents are gone + for f in sorted(found, reverse=True): + try: + if PARTIAL.match(f.name): + if now - f.lstat().st_mtime > PARTIAL_AGE: + f.unlink() + elif f.is_dir(): + if not site.source_of(f).is_dir() and not any(f.iterdir()): + f.rmdir() + elif not site.source_of(f).is_file(): + f.unlink() + except FileNotFoundError: + # another build removed or renamed it first + pass + + +def write_index(notes: Path) -> None: + ensure_gitignore(notes) + write_whole(notes / "notes.css", CSS.read_text()) + site = Site(notes) + prune(site) + + write_portable(site) + for d in site.docs: + if d["src"].suffix == ".md": + render_md(site, d["src"], "doc") + for f in site.followups: + render_md(site, f, "follow-up") + 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) -> str: + head = f'

{name} {len(rows)}

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

{empty}

' + return f'{head}
    {"".join(rows)}
' + + def row(item_id: str, href: str, title: str, meta: str, line: str = "") -> str: + return ( + f'
  • {e(item_id)}' + f'{e(title)}{line}' + f'{meta}
  • ' + ) + + # matej reads work items and his questions; docs, follow-ups and other/ are the lead's + # and the agents' side, rendered so a link from a work item still opens + work = [] + for w in site.work: + st = site.state(w) + meta = f'{tags(st["flags"])} {e(st["last"])}' + line = "" + if st["move"]: + # links in the move point from the brief, so here they keep only their text + first = re.sub(r"^[-*]\s+", "", st["move"].splitlines()[0]) + first = MD_LINK.sub(r"\1", first) + line = f'{inline(first, site, "index.html")}' + work.append( + row(w.name[:4], quote(site.urls[w.name[:4]]), st["title"], meta, line) + ) + 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)) + + 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.work)} in flight · {len(site.questions)} open questions · " + f"generated {datetime.now().astimezone().date()}

    " + ), + "
    ", + '
    ', + section("Work in flight", work, "nothing in flight"), + section("Your questions", questions, "no open questions"), + "
    ", + ] + ) + write_whole( + notes / "index.html", page(site, "index.html", f"{project} notes", body) + ) + + +# status: what is open and what waits, for a fresh lead session + + +def status(notes: Path) -> None: + site = Site(notes) + for w in site.work: + st = site.state(w) + print(f"{w.name[:4]} {st['title']} (notes touched {st['last']})") + for _, text in st["flags"]: + print(f" ! {text}") + for label in ("move", "next"): + if lines := st[label].splitlines(): + more = f" (+{len(lines) - 1} lines)" if len(lines) > 1 else "" + print(f" {label}: {lines[0][:160]}{more}") + for key, src in st["waiting"]: + print(f" decide {key} in {src.relative_to(notes)}") + for b, sha, merged in st["branches"]: + print(f" branch {b} {sha} {'in main' if merged else 'not in main'}") + states = [frontmatter(f.read_text(errors="replace"))[0] for f in site.followups] + open_fs = sum(1 for m in states if m.get("status", "open").startswith("open")) + print( + f"follow-ups {open_fs} open · questions {len(site.questions)} · docs {len(site.docs)}" + ) + # superseded is a doc's normal end; stale or missing status lines need the lead + for label, test in ( + ("partly stale", lambda st: st.startswith("partly stale")), + ("no status line", lambda st: not st), + ): + if ids := [d["id"] for d in site.docs if test(d["status"])]: + print(f" docs {label}: {', '.join(ids)}") + + +# file: an agent's final reply into its work item, verbatim from the transcript + +HARNESS_PREFIX = re.compile(r"^\[harness:[^\]]*\]\s*") + + +def final_reply(transcript: Path) -> str: + """the text of the agent's last end_turn message, as the task notification carries it + (the notification html-escapes it and may put a harness warning before it)""" + # one message can span several jsonl records sharing its id + msgs: list[tuple[str, str, list[str]]] = [] + for line in transcript.open(): + record = json.loads(line) + if record.get("type") != "assistant": + continue + m = record.get("message") or {} + texts = [ + b["text"] + for b in m.get("content") or [] + if isinstance(b, dict) and b.get("type") == "text" + ] + if msgs and msgs[-1][0] == m.get("id"): + msg_id, stop, prev = msgs.pop() + msgs.append((msg_id, m.get("stop_reason") or stop, prev + texts)) + else: + msgs.append((m.get("id"), m.get("stop_reason"), texts)) + done = [texts for _, stop, texts in msgs if stop == "end_turn" and texts] + if not done: + sys.exit(f"{transcript}: no finished reply in it") + return HARNESS_PREFIX.sub("", "\n\n".join(done[-1]).strip()) + + +def find_transcript(ref: str) -> Path: + """the notification's output-file (a symlink to the transcript), a jsonl, or an agent id""" + path = Path(ref).expanduser() + if path.exists(): + return path.resolve() + if not re.fullmatch(r"[0-9a-z]{6,40}", ref): + sys.exit(f"{ref}: neither a file nor an agent id") + bases = ( + [Path(os.environ["CLAUDE_CONFIG_DIR"])] + if "CLAUDE_CONFIG_DIR" in os.environ + else [] + ) + bases += [Path.home() / ".claude", Path.home() / ".config" / "claude"] + for base in bases: + hits = sorted((base / "projects").glob(f"*/*/subagents/agent-{ref}.jsonl")) + if hits: + return max(hits, key=lambda h: h.stat().st_mtime) + sys.exit(f"no transcript for agent {ref}") + + +def file_reply(notes: Path, work: str, part: str, ref: str, checked: str) -> None: + dirs = [d for d in (notes / "work").glob(f"{work}-*") if d.is_dir()] + if not re.fullmatch(r"w\d{3}", work) or len(dirs) != 1: + sys.exit(f"no work item {work}") + if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", part): + sys.exit(f"part {part!r} is not lowercase kebab") + out = dirs[0] / f"report-{part}.md" + if out.exists(): + sys.exit(f"{out} exists; pick another part name") + reply = final_reply(find_transcript(ref)) + day = datetime.now().astimezone().date().isoformat() + out.write_text( + f"# {work} report: {part} ({day})\n\nLead checked: {checked.strip()}\n\n" + f"## Reply as it came\n\n{reply}\n" + ) + print(out.relative_to(notes), f"{len(reply)} chars") + + +# 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{ICON}\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"))) + followups = len(list((notes / "followups").glob("f[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 · {followups} follow-ups · {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 = d["src"].read_text(errors="replace") + if d["src"].suffix == ".html": + text = page_text(text) + out.append((d["id"], d["title"], d["page"], text)) + mds = [ + *site.followups, + *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"[dwfq]\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) + + +LOOPBACK = {"127.0.0.1", "::1", "::ffff:127.0.0.1"} +LOCAL_NAMES = {"127.0.0.1", "localhost", "::1"} + + +def serve(roots: list[Path], host: str, port: int) -> None: + import http.server + import threading + from urllib.parse import parse_qs, unquote, urlsplit + + # one decision append and commit at a time + write_lock = threading.Lock() + # a scan can take a second under a big root: done at start and on each hub visit + projects = find_notes(roots) + + def rescan() -> dict[str, Path]: + nonlocal projects + projects = find_notes(roots) + return projects + + def lookup(path: str) -> tuple[str, Path] | None: + """the project a path is under and its .notes/, by the last scan; a miss rescans + once, for a project added since""" + + def longest(found: dict[str, Path]) -> tuple[str, Path] | None: + under = [p for p in found if path == f"/{p}" or path.startswith(f"/{p}/")] + prefix = max(under, key=len, default=None) + return (prefix, found[prefix]) if prefix is not None else None + + hit = longest(projects) + if hit is None or not hit[1].is_dir(): + hit = longest(rescan()) + return hit + + 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(rescan()), "text/html; charset=utf-8") + if path == "/notes.css": + return self.send_text(CSS.read_text(), "text/css; charset=utf-8") + hit = lookup(path) + if hit is None: + return self.send_error(404, "no such project") + prefix, notes = hit + 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 :] + 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 = notes + self.path = "/" + rest + return super().do_GET() + + def do_POST(self) -> None: + # /decide: one click in a decide block, appended to work/wNNN-*/decisions.md + host = urlsplit(f"//{self.headers.get('Host', '')}").hostname + if self.client_address[0] not in LOOPBACK or host not in LOCAL_NAMES: + return self.send_error(403, "local only") + # browsers send it on every post; a client without it (curl) is not a page + if self.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin": + return self.send_error(403, "cross-site") + path = unquote(urlsplit(self.path).path) + hit = lookup(path) + if hit is None or path != f"/{hit[0]}/decide": + return self.send_error(404) + prefix, notes = hit + length = self.headers.get("Content-Length", "") + n = min(int(length), 4096) if length.isdigit() else 0 + form = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode()).items()} + work, key, choice = (form.get(f, "") for f in ("work", "key", "choice")) + if not ( + re.fullmatch(r"w\d{3}", work) + and re.fullmatch(r"[a-z0-9-]{1,40}", key) + and re.fullmatch(r"[a-z0-9]{1,12}", choice) + ): + return self.send_error(400, "bad fields") + dirs = [d for d in (notes / "work").glob(f"{work}-*") if d.is_dir()] + if len(dirs) != 1: + return self.send_error(404, "no such work item") + # only an option the work item's own pages offer for that key + offered: set[str] = set() + for f in dirs[0].rglob("*.md"): + offered.update( + decide_blocks(f.read_text(errors="replace")).get(key, []) + ) + if choice not in offered: + return self.send_error(400, "not an option of that decision") + day = datetime.now().astimezone().date().isoformat() + with write_lock: + with (dirs[0] / "decisions.md").open("a") as f: + f.write(f"- {day} {key}: {choice} (matej, clicked)\n") + commit(notes, f"{work}: {key} decided, {choice}") + back = ( + urlsplit(self.headers.get("Referer", "")).path or f"/{quote(prefix)}/" + ) + self.send_response(303) + self.send_header("Location", back) + self.end_headers() + return None + + 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]: + 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)) + p_new = sub.add_parser("new") + p_new.add_argument("letter", choices=["d", "w"]) + p_new.add_argument("slug") + p_new.add_argument("--html", action="store_true", help="a doc with a diagram") + sub.add_parser("index") + p_commit = sub.add_parser("commit") + p_commit.add_argument("subject") + sub.add_parser("status") + p_file = sub.add_parser("file") + p_file.add_argument("work") + p_file.add_argument("part") + p_file.add_argument( + "transcript", help="the notification's output-file or the agent id" + ) + p_file.add_argument("--checked", required=True, help="what the lead checked first") + 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 == "new": + new(notes, args.letter, args.slug, args.html) + elif args.cmd == "index": + write_index(notes) + print(notes / "index.html") + elif args.cmd == "commit": + commit(notes, args.subject) + elif args.cmd == "status": + status(notes) + elif args.cmd == "file": + file_reply(notes, args.work, args.part, args.transcript, args.checked) + + +if __name__ == "__main__": + main() diff --git a/claude/skills/matej/SKILL.md b/claude/skills/matej/SKILL.md deleted file mode 100644 index 3ac1883..0000000 --- a/claude/skills/matej/SKILL.md +++ /dev/null @@ -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 `/`, worktrees under `$SANDBOX_VM_STATE/wt//`, containers `-`. 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). diff --git a/claude/skills/notes/SKILL.md b/claude/skills/notes/SKILL.md deleted file mode 100644 index b7b9a4b..0000000 --- a/claude/skills/notes/SKILL.md +++ /dev/null @@ -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 "" # 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 `; 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 `" - 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/zsh/aliases.zsh b/zsh/aliases.zsh index e27c73e..781c002 100644 --- a/zsh/aliases.zsh +++ b/zsh/aliases.zsh @@ -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}}'"