# Annotate Project

> Analyse a project's source code and generate a simply-annotate database file with LLM-created annotations categorised by tag: `defun` for functions and large syntactical blocks, `line` for smaller sub-blocks. File-wide narrative lives in a sibling `.org` overview file, not in an overlay. Use this skill when the user asks to annotate a project, generate code descriptions, or create a simply-annotate database for a codebase. Trigger on phrases like "annotate this project", "generate annotations", "describe this codebase", "create annotations for", or "annotate the source".

- Skill: `captainflasmr/annotate-project` (Agent Skill)
- Install (CLI): `npx skillmds@latest add captainflasmr/annotate-project`
- Raw SKILL.md: https://api.skillmd.com/api/skills/captainflasmr/annotate-project/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: captainflasmr (https://skillmd.com/u/captainflasmr)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/captainflasmr/annotate-project

---


# Annotate Project Skill

You are generating a **simply-annotate** database file that describes a
project's source code using two tag categories — `defun` (functions, classes,
types, and other major syntactical blocks) and `line` (smaller sub-blocks worth
calling out). When the user opens files in Emacs with `simply-annotate-mode`,
they see these as tagged annotations they can filter and step through.

**Important: simply-annotate's annotation model is tag-based, not
level-based.** The legacy `level` field (`file`/`defun`/`line`) no longer
exists as a first-class concept — what used to be a level is now a tag on a
thread-format annotation. File-wide overview annotations (overlays spanning
the whole buffer) are **not produced**; that narrative lives exclusively in
the sibling directory overview `.org` file created in Step 9.

---

## Step 1: Discover Source Files

If the user provides a specific directory or glob argument (e.g.
`/annotate-project src/` or `/annotate-project java/cuis-server`), scan **only
that scope**. Otherwise scan the project root.

Use the Glob tool to find files matching common source extensions:
- `**/*.el` `**/*.py` `**/*.js` `**/*.ts` `**/*.tsx` `**/*.jsx`
- `**/*.rs` `**/*.go` `**/*.java` `**/*.c` `**/*.cpp` `**/*.h`
- `**/*.rb` `**/*.lua` `**/*.sh` `**/*.clj` `**/*.hs` `**/*.proto`

### Directories to skip

Respect `.gitignore` and always skip these directories/patterns:
- `node_modules`, `vendor`, `dist`, `build`, `target`, `.git`, `__pycache__`,
  `.elc`, `.class`
- Directories named `generated`, `gensrc`, `gen`, `auto-generated`
- Third-party / vendored code: `third_party`, `thirdparty`, `extern`, `deps`

### File count guard

After discovering files, **count them** and report the total to the user
**before doing any reading or annotation**. Include:

1. The total number of source files found.
2. A breakdown by immediate subdirectory (top 10 by count).
3. Any directories that look auto-generated (containing `generated`, `gensrc`,
   `gen`, `proto/generated`, `pb.go`, `.pb.h`, etc.) — flag these and ask
   whether to include them.

If the file count exceeds **100 files**, warn the user:

> Found N source files. Annotating all of them will take a while.
> Would you like to proceed, or narrow the scope to a specific subdirectory?

Wait for confirmation before continuing. If the user narrows scope, re-discover
within that scope.

If the count is **≤ 100**, proceed automatically.

---

## Step 2: Determine the Database Path

Annotations are stored in a **project-local** database file by default. This
keeps each project's annotations self-contained, makes them shareable via
version control if desired, and avoids one giant global database growing
unbounded.

Resolve the database path in this order:

1. **Project-local (default).** Find the project root by walking up from the
   current working directory looking for one of: `.git`, `.hg`, `.svn`,
   `package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`, `pom.xml`,
   `build.gradle`, `build.gradle.kts`, `Makefile`. Use that directory. If
   nothing is found, use the current working directory itself.

   Database path: `<project-root>/.simply-annotations.el`

2. **Global fallback.** Only use `~/.emacs.d/simply-annotations.el` if the
   user explicitly asks for the global database (e.g. "use the global db",
   "save to ~/.emacs.d/...").

Use the resolved path consistently in **all** subsequent steps (backup,
generate, validate). Refer to it below as `$DB_PATH`.

---

## Step 3: Write the Elisp Helper Script

Since the annotations target Emacs, use a **native elisp batch script** to
convert line numbers to Emacs point positions. This guarantees byte offsets
match what Emacs sees — no manual conversion needed.

The script produces annotations in the current thread-alist format used by
simply-annotate:

```elisp
((start . N)
 (end . N)
 (text . ((id . "thread-...")
          (created . "...")
          (status . "open")
          (priority . "normal")
          (tags . ("defun"))
          (comments . (((id . "comment-...")
                        (parent-id . nil)
                        (author . "Claude")
                        (timestamp . "...")
                        (text . "The actual annotation text")
                        (type . "comment")))))))
```

It also accepts an **optional project root** third argument. When supplied,
the DB keys are rewritten to project-relative paths (`src/foo.ts` rather than
`/home/user/project/src/foo.ts`) — this is **required** for project-local
databases, because simply-annotate's `simply-annotate--file-key` returns a
project-relative path when looking up annotations, and a DB keyed by absolute
paths will silently find nothing.

Write the following script **verbatim** to `/tmp/gen_annotations.el` at the
start of the session. Do not modify it — it is tested and known to work.

```elisp
;;; gen_annotations.el --- Convert line-based annotation JSON to simply-annotate (thread-tag format) -*- lexical-binding: t; -*-

;; Usage: emacs --batch -l /tmp/gen_annotations.el /tmp/annotations.json $DB_PATH [PROJECT_ROOT]
;;
;; When PROJECT_ROOT is supplied, keys are rewritten to project-relative paths.
;; This is REQUIRED for project-local databases.
;;
;; Input JSON shape (per file):
;;   { "/abs/path/file.ts": [
;;       {"sl": 8, "el": 52, "text": "...", "tag": "defun"},
;;       {"sl": 32, "el": 51, "text": "...", "tag": "line"}
;;     ] }

(require 'json)
(require 'cl-lib)

(defvar gen-annotations--counter 0)

(defun gen-annotations--uid (prefix)
  "Return a stable unique id with PREFIX."
  (setq gen-annotations--counter (1+ gen-annotations--counter))
  (format "%s-%s-%06d"
          prefix
          (format-time-string "%s")
          gen-annotations--counter))

(defun gen-annotations--make-thread (text tag)
  "Wrap TEXT into a minimal thread alist tagged with TAG."
  (let ((now (format-time-string "%Y-%m-%dT%H:%M:%S%z")))
    `((id . ,(gen-annotations--uid "thread"))
      (created . ,now)
      (status . "open")
      (priority . "normal")
      (tags . (,tag))
      (comments . (((id . ,(gen-annotations--uid "comment"))
                    (parent-id . nil)
                    (author . "Claude")
                    (timestamp . ,now)
                    (text . ,text)
                    (type . "comment")))))))

(defun gen-annotations--line-to-point (buf line-number)
  "In BUF, return the point at the beginning of LINE-NUMBER (1-based)."
  (with-current-buffer buf
    (save-excursion
      (goto-char (point-min))
      (if (and (> line-number 0)
               (zerop (forward-line (1- line-number))))
          (point)
        (point-max)))))

(defun gen-annotations--end-of-line-region (buf line-number)
  "In BUF, return the point at end of LINE-NUMBER (1-based)."
  (with-current-buffer buf
    (save-excursion
      (goto-char (point-min))
      (if (and (> line-number 0)
               (zerop (forward-line (1- line-number))))
          (line-end-position)
        (point-max)))))

(defun gen-annotations--convert-file (filepath annotations)
  "Convert ANNOTATIONS for FILEPATH from line numbers to point positions.
Each input annotation has keys sl, el, text, tag."
  (if (not (file-exists-p filepath))
      (progn
        (message "WARNING: File not found, skipping: %s" filepath)
        nil)
    (let ((buf (generate-new-buffer " *gen-ann-temp*"))
          result)
      (unwind-protect
          (progn
            (with-current-buffer buf
              (insert-file-contents filepath))
            (dolist (ann annotations)
              (let* ((sl (alist-get 'sl ann))
                     (el (alist-get 'el ann))
                     (text (alist-get 'text ann))
                     (tag (alist-get 'tag ann))
                     (start (gen-annotations--line-to-point buf sl))
                     (end (if (= el -1)
                              (with-current-buffer buf (point-max))
                            (gen-annotations--end-of-line-region buf el)))
                     (thread (gen-annotations--make-thread text tag)))
                (push `((start . ,start)
                        (end . ,end)
                        (text . ,thread))
                      result))))
        (kill-buffer buf))
      ;; Sort by start position, ascending.
      (sort (nreverse result)
            (lambda (a b) (< (alist-get 'start a) (alist-get 'start b)))))))

(defun gen-annotations--load-existing (output-path)
  "Load existing database from OUTPUT-PATH if it exists."
  (when (file-exists-p output-path)
    (with-temp-buffer
      (insert-file-contents output-path)
      (let ((content (string-trim (buffer-string))))
        (unless (string-empty-p content)
          (condition-case err
              (car (read-from-string content))
            (error
             (message "WARNING: Could not parse existing database: %s"
                      (error-message-string err))
             nil)))))))

(defun gen-annotations--db-key (filepath project-root)
  "Return the DB key for FILEPATH, made project-relative when PROJECT-ROOT is set."
  (if (and project-root
           (stringp project-root)
           (> (length project-root) 0)
           (string-prefix-p (file-name-as-directory project-root) filepath))
      (substring filepath (length (file-name-as-directory project-root)))
    filepath))

(defun gen-annotations--main ()
  "Main entry point for batch annotation generation."
  (let* ((args (last command-line-args 3))
         (json-path (nth 0 args))
         (output-path (and (nth 1 args) (expand-file-name (nth 1 args))))
         (project-root (and (nth 2 args)
                            (file-name-as-directory
                             (expand-file-name (nth 2 args))))))

    (unless (and json-path output-path)
      (error "Usage: emacs --batch -l gen_annotations.el /tmp/annotations.json output.el [project-root]"))

    (let* ((json-object-type 'alist)
           (json-array-type 'list)
           (json-key-type 'symbol)
           (annotations (with-temp-buffer
                          (insert-file-contents json-path)
                          (json-read)))
           (existing-db (gen-annotations--load-existing output-path))
           (new-db existing-db)
           (file-count 0))

      (dolist (entry annotations)
        (let* ((filepath (symbol-name (car entry)))
               (db-key (gen-annotations--db-key filepath project-root))
               (file-annotations (cdr entry))
               ;; Read from absolute filepath for line-to-point conversion;
               ;; only the DB key is made relative.
               (converted (gen-annotations--convert-file filepath file-annotations)))
          (when converted
            (cl-incf file-count)
            (if (assoc db-key new-db #'string=)
                (setcdr (assoc db-key new-db #'string=) converted)
              (push (cons db-key converted) new-db)))))

      (setq new-db (sort new-db (lambda (a b) (string< (car a) (car b)))))

      (with-temp-file output-path
        (insert ";;; Simply Annotate Database\n")
        (insert ";;; This file is auto-generated. Do not edit manually.\n\n")
        (let ((print-level nil)
              (print-length nil))
          (prin1 new-db (current-buffer)))
        (insert "\n"))

      (message "Generated annotations for %d files -> %s" file-count output-path))))

(gen-annotations--main)
```

---

## Step 4: Read and Analyse Each File

For each source file, read the contents and produce annotations under two
tags: `defun` and `line`. Record annotations using **line numbers** (`sl`/`el`)
— the helper script will convert to byte offsets.

**Do not produce file-wide overlay annotations.** In the previous model there
was a `file` level covering the whole buffer (`sl: 1, el: -1`). That overlay
is no longer part of the new format — a single annotation spanning the whole
file overlaps every other annotation and confuses tag filtering. The file's
high-level narrative belongs in the sibling `.org` overview file created in
Step 9, where it can be read as prose.

### Tag: `defun`

One annotation per major syntactical block:
- **Elisp**: `defun`, `defmacro`, `defvar`, `defcustom`, `defclass`,
  `define-minor-mode`, `use-package` blocks
- **Python**: `def`, `class`, top-level decorators
- **JavaScript/TypeScript**: `function`, `class`, arrow function assignments,
  `export default`, React components
- **Rust**: `fn`, `impl`, `struct`, `enum`, `trait`, `mod`
- **Go**: `func`, `type`, `interface`
- **C/C++**: function definitions, `struct`/`class`/`enum` definitions
- **Ruby**: `def`, `class`, `module`
- **Java**: method definitions, class definitions
- **Shell**: function definitions
- **Proto**: `message`, `enum`, `service`, `rpc` definitions

The annotation `sl` and `el` should span the full block (from the first line of
the definition to the closing delimiter line). Write a description covering:
what it does, parameters/return values (briefly), key side effects, and how it
relates to the rest of the file. 1-3 sentences.

**Skip defun-tagged annotations for trivial files** (< 30 lines, or pure
boilerplate like single-exception classes, simple data holders, or marker
interfaces).

### Tag: `line`

Annotations for smaller but meaningful sub-blocks within defun-tagged regions:
- Significant `let`/`let*` binding blocks
- Important conditional branches (`if`/`cond`/`pcase`/`match`/`switch`)
- Loop bodies with non-trivial logic
- Error handling blocks (`condition-case`, `try`/`catch`/`except`)
- Key variable assignments that control flow
- Non-obvious expressions that benefit from explanation

Do NOT annotate every line — focus on blocks where a reader would benefit from
context. Aim for roughly 3-8 `line`-tagged annotations per `defun`-tagged
block, fewer for simple functions. **Skip `line` annotations entirely for
simple functions.**

---

## Step 5: Produce Annotation JSON

Collect all annotations into a single JSON object. Use **absolute paths** as
the keys (the helper script will rewrite them to project-relative paths using
the project-root argument in Step 7):

```json
{
  "/absolute/path/to/file.java": [
    {"sl": 25, "el": 80, "text": "Class description...", "tag": "defun"},
    {"sl": 42, "el": 55, "text": "Key conditional...", "tag": "line"}
  ]
}
```

Note there is **no file-wide entry** (`sl: 1, el: -1`) — that narrative goes
into the `.org` overview file in Step 9.

Write this JSON to `/tmp/annotations.json`.

---

## Step 6: Backup the Existing Database

Before generating, **always back up** the current database if it exists.
Use the `$DB_PATH` resolved in Step 2:

```bash
cp "$DB_PATH" "$DB_PATH.bak" 2>/dev/null || true
```

This ensures the user can recover if the generation or merge corrupts the file.
If something goes wrong, restore with: `cp "$DB_PATH.bak" "$DB_PATH"`.

---

## Step 7: Generate the Database

Run the elisp helper script (written in Step 3) against `$DB_PATH`. **Pass
the project root as a third argument** so keys are written as project-relative
paths — without this, simply-annotate will fail to find the annotations when
the user opens a file:

```bash
emacs --batch -l /tmp/gen_annotations.el /tmp/annotations.json "$DB_PATH" "$PROJECT_ROOT"
```

Where `$PROJECT_ROOT` is the directory resolved in Step 2 (the one containing
`.simply-annotations.el`). Omit the project-root argument **only** when using
the global database under `~/.emacs.d/`, which keys by absolute path.

The script handles everything:
- **Merging**: files already in the database are replaced; files not in the
  new JSON are preserved untouched.
- **Key rewriting**: when `PROJECT_ROOT` is supplied, absolute paths inside
  that root become project-relative (e.g. `src/index.ts`).
- **Thread format**: each annotation's `text` is a full thread alist with
  `tags`, matching the current simply-annotate on-disk format.
- **Format**: output is written with `prin1`, matching
  `simply-annotate--save-database` format — a raw alist readable by
  `read-from-string`. No `(setq ...)` wrapper.

---

## Step 8: Validate the Database

After generating, **always run the validation script** to confirm the database
is well-formed and won't break simply-annotate-mode. Write the following
**verbatim** to `/tmp/validate_annotations.el` and run it against `$DB_PATH`.

```elisp
;;; validate_annotations.el --- Validate a simply-annotate thread-tag database -*- lexical-binding: t; -*-

;; Usage: emacs --batch -l /tmp/validate_annotations.el $DB_PATH

(require 'cl-lib)

(defun validate-annotations--thread-p (obj)
  "Return non-nil if OBJ looks like a simply-annotate thread alist."
  (and (listp obj)
       (listp (car-safe obj))
       (assq 'id obj)
       (assq 'comments obj)))

(defun validate-annotations--main ()
  "Validate the simply-annotate database file."
  (let* ((db-path (expand-file-name (car (last command-line-args))))
         (errors 0)
         (warnings 0)
         (total-annotations 0)
         (tag-counts (make-hash-table :test 'equal))
         db)

    (unless (file-exists-p db-path)
      (message "FAIL: Database file does not exist: %s" db-path)
      (kill-emacs 1))

    ;; Step 1: Can we read it?
    (condition-case err
        (with-temp-buffer
          (insert-file-contents db-path)
          (setq db (car (read-from-string (string-trim (buffer-string))))))
      (error
       (message "FAIL: Cannot parse database: %s" (error-message-string err))
       (kill-emacs 1)))

    ;; Step 2: Is it a list?
    (unless (listp db)
      (message "FAIL: Database is not a list (got %s)" (type-of db))
      (kill-emacs 1))

    ;; Step 3: Validate each entry
    (dolist (entry db)
      (let ((filepath (car entry))
            (annotations (cdr entry)))

        (unless (stringp filepath)
          (message "ERROR: Entry key is not a string: %S" filepath)
          (cl-incf errors))

        (unless (listp annotations)
          (message "ERROR: Annotations for %s is not a list" filepath)
          (cl-incf errors))

        ;; File-existence check: project-relative keys won't exist as absolute
        ;; paths, so skip the check for non-absolute keys.
        (when (and (stringp filepath)
                   (file-name-absolute-p filepath)
                   (not (file-exists-p filepath)))
          (message "WARNING: File does not exist: %s" filepath)
          (cl-incf warnings))

        (dolist (ann annotations)
          (cl-incf total-annotations)
          (let ((start (alist-get 'start ann))
                (end   (alist-get 'end ann))
                (text  (alist-get 'text ann)))

            (unless (and start end text)
              (message "ERROR: %s: annotation missing required fields: %S" filepath ann)
              (cl-incf errors))

            (when (and start (not (and (integerp start) (> start 0))))
              (message "ERROR: %s: invalid start: %S" filepath start)
              (cl-incf errors))
            (when (and end (not (and (integerp end) (> end 0))))
              (message "ERROR: %s: invalid end: %S" filepath end)
              (cl-incf errors))
            (when (and start end (integerp start) (integerp end) (> start end))
              (message "ERROR: %s: start (%d) > end (%d)" filepath start end)
              (cl-incf errors))

            ;; text must be a string OR a thread alist.
            (unless (or (stringp text)
                        (validate-annotations--thread-p text))
              (message "ERROR: %s: text is neither a string nor a thread alist: %S"
                       filepath text)
              (cl-incf errors))

            ;; Accumulate tag counts for reporting.
            (when (validate-annotations--thread-p text)
              (dolist (tag (alist-get 'tags text))
                (puthash tag (1+ (gethash tag tag-counts 0)) tag-counts)))))))

    ;; Report
    (message "Validation complete: %d files, %d annotations, %d errors, %d warnings"
             (length db) total-annotations errors warnings)
    (when (> (hash-table-count tag-counts) 0)
      (message "Tag breakdown:")
      (maphash (lambda (tag count) (message "  %s: %d" tag count)) tag-counts))

    (if (> errors 0)
        (progn
          (message "FAIL: Database has %d errors. Restore backup with:" errors)
          (message "  cp %s.bak %s" db-path db-path)
          (kill-emacs 1))
      (message "OK: Database is valid.")
      (kill-emacs 0))))

(validate-annotations--main)
```

Run with:

```bash
emacs --batch -l /tmp/validate_annotations.el "$DB_PATH"
```

If validation **fails**, immediately restore the backup:

```bash
cp "$DB_PATH.bak" "$DB_PATH"
```

Then investigate the `/tmp/annotations.json` for the bad data and re-run
generation.

---

## Step 9: Generate Directory Overview Org File

For the annotated directory, create an **org file alongside it** with the same
name as the directory. For example, annotating `/project/java/client` creates
`/project/java/client.org`.

The org file should contain:

```org
#+TITLE: <directory-name> — Directory Overview
#+AUTO_TANGLE: nil

* Purpose

<2-4 sentence overview of what this directory/module does and its role in the
wider project.>

* Structure

<Brief description of how the directory is organised — subdirectories, key
groupings of files, and the relationships between them.>

** <subdirectory-or-grouping-1>

<1-2 sentences on what this group of files does.>

** <subdirectory-or-grouping-2>

...

* Key Files

| File | Description |
|------+-------------|
| file1.java | One-line summary |
| file2.java | One-line summary |
| ... | ... |

List ALL source files in the directory scope, not just the "important" ones.
Group by subdirectory if the directory has subdirectories.

* Important Notes

<Bullet points covering anything a newcomer should know: key patterns used,
auto-generated files to ignore, non-obvious conventions, gotchas, entry points,
important interfaces, etc. Skip this section if there is nothing noteworthy.>
```

### Rules for the org file

- **One org file per annotated directory scope** — if the user annotates
  `java/client`, create `java/client.org`. If they annotate `java/cuis-server`,
  create `java/cuis-server.org`.
- The file sits **alongside** (as a sibling of) the directory, not inside it.
- Keep it concise — this is a quick-reference map, not exhaustive documentation.
- The `Key Files` table and `Structure` section are the **primary home** for
  file-wide narrative (since there is no file-level overlay annotation). Each
  file's one-line summary here is what a file-level annotation used to
  provide.
- If the directory has auto-generated subdirectories (e.g. `gensrc/`), note
  them in Important Notes.

---

## Step 10: Report

After writing, report:
- Number of files annotated
- Total annotations generated (broken down by tag — `defun` and `line`)
- The database file path (clearly state whether it is project-local or global)
- Validation result (pass/fail)
- The directory overview org file path
- Remind the user to open files in Emacs and enable `simply-annotate-mode`
  (or use `simply-annotate-show-all` for the global overview)

---

## Guidelines

- **Be concise**: `defun`-tagged descriptions 1-3 sentences; `line`-tagged
  1 sentence. File-wide overview prose lives in the `.org` overview file
  (2-4 sentences per file in the Key Files table).
- **Be descriptive, not prescriptive**: Describe what the code does, not what
  it should do. This is documentation, not code review.
- **Use plain language**: Avoid jargon where possible. The goal is to help
  someone unfamiliar with the codebase orient themselves.
- **Process files in parallel**: Use the Agent tool to analyse multiple files
  simultaneously when practical — but keep batches reasonable (10-20 files per
  agent).
- **Handle large files**: For files over 1000 lines, you may need to read them
  in chunks. Still produce annotations for the full file.
- **Respect the user's scope**: If they say "just the src/ directory" or "only
  Python files", honour that constraint.
- **Don't annotate the world**: The goal is useful coverage of code the user
  cares about, not exhaustive annotation of every file in the repo.

