Initial commit

This commit is contained in:
Erich Blume 2026-05-31 06:13:36 -07:00
commit 28c1f7886a
41 changed files with 3962 additions and 0 deletions

1
.dagger/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
/sdk/** linguist-generated

4
.dagger/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/.venv
/**/__pycache__
/sdk
/.env

9
.dagger/pyproject.toml Normal file
View file

@ -0,0 +1,9 @@
[project]
name = "hephaestus-ci"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["dagger-io"]
[build-system]
requires = ["uv_build>=0.8.4,<0.9.0"]
build-backend = "uv_build"

View file

@ -0,0 +1,4 @@
"""Hephaestus CI — Dagger build functions."""
# TODO: Rename class to match your project (also rename the src/ directory)
from .main import ProjectTemplateCi as ProjectTemplateCi

View file

@ -0,0 +1,51 @@
import dagger
from dagger import dag, function, object_type
# TODO: Rename class to match your project (also rename the src/ directory)
@object_type
class ProjectTemplateCi:
@function
async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File:
"""Build Quartz docs site. Returns docs tarball."""
return await (
dag.container()
.from_("node:24-slim")
.with_exec(["apt-get", "update", "-qq"])
.with_exec(["apt-get", "install", "-y", "-qq", "git"])
.with_directory("/workspace", src)
.with_workdir("/workspace")
.with_exec(
[
"git",
"clone",
"--depth=1",
"https://github.com/jackyzha0/quartz.git",
"/tmp/quartz",
]
)
.with_exec(
[
"sh",
"-c",
"cp -r /tmp/quartz/quartz /tmp/quartz/package*.json "
"/tmp/quartz/tsconfig.json .",
]
)
.with_exec(["npm", "ci"])
.with_exec(["cp", "docs/quartz.config.ts", "."])
.with_exec(["cp", "docs/quartz.layout.ts", "."])
.with_exec(["cp", "CHANGELOG.md", "docs/"])
.with_exec(["npx", "quartz", "build", "-d", "docs"])
.with_exec(
[
"tar",
"-czf",
f"/docs-{version}.tar.gz",
"-C",
"public",
".",
]
)
.file(f"/docs-{version}.tar.gz")
)

View file

@ -0,0 +1,44 @@
# Build Workflow
#
# Generic CI validation for template-based repositories.
# By default this runs the repo's prek checks, which already cover:
# - workflow linting
# - docs integrity checks
# - formatting/linting hooks configured by the project
#
# Projects can extend this with an optional executable hook at:
# .forgejo/scripts/build
#
# The optional hook is the place for project-specific validation such as:
# - unit/integration tests
# - package builds
# - schema validation
# - language-specific linters
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: k8s
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run repository checks
run: |
prek run --all-files
- name: Run project-specific build hook
run: |
if [ -x .forgejo/scripts/build ]; then
echo "Running project-specific build hook..."
./.forgejo/scripts/build
else
echo "No .forgejo/scripts/build hook found; template validation complete."
fi

View file

@ -0,0 +1,300 @@
# Release Workflow
#
# Creates a versioned Forgejo release for template-based repositories.
# By default this includes:
# - Documentation site bundle (Quartz static build via Dagger)
# - Changelog section built from towncrier fragments, when present
#
# Projects can optionally attach additional release artifacts by providing
# an executable hook at `.forgejo/scripts/release`. That hook should place
# any extra files under `release-assets/` for upload to the release.
#
# Usage:
# 1. Go to Actions > Release > Run workflow
# 2. Select version bump type (patch/minor/major) or choose specific version
# 3. The workflow creates a release with the docs bundle and optional extras
name: Release
on:
workflow_dispatch:
inputs:
version_type:
description: "Version bump type"
required: true
default: "BUMP_PATCH"
type: choice
options:
- BUMP_PATCH
- BUMP_MINOR
- BUMP_MAJOR
- SPECIFIC_VERSION
specific_version:
description: "Specific version (only used when version_type is SPECIFIC_VERSION, e.g., v1.2.0)"
required: false
default: ""
type: string
jobs:
release:
runs-on: k8s
steps:
- name: Resolve version
id: version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION_TYPE="${{ inputs.version_type }}"
SPECIFIC_VERSION="${{ inputs.specific_version }}"
FORGE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
echo "Fetching latest release..."
# Private repos return 404 to unauthenticated callers, so the auth
# header is required even though "latest release" reads like public
# info. Without it the curl 404s, falls back to v0.0.0, and a
# BUMP_PATCH on top of v1.x.y silently produces v0.0.1.
LATEST_STATUS=$(curl -s -o /tmp/latest.json -w "%{http_code}" \
-H "Authorization: token $GITHUB_TOKEN" \
"${FORGE_URL}/releases/latest")
if [ "$LATEST_STATUS" = "200" ]; then
LATEST=$(jq -r '.tag_name' < /tmp/latest.json)
echo "Latest release: $LATEST"
elif [ "$LATEST_STATUS" = "404" ]; then
LATEST="v0.0.0"
echo "No previous releases found, using base version: $LATEST"
else
echo "Error: unexpected HTTP $LATEST_STATUS fetching latest release"
cat /tmp/latest.json
exit 1
fi
CURRENT="${LATEST#v}"
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
case "$VERSION_TYPE" in
BUMP_MAJOR)
VERSION="v$((MAJOR + 1)).0.0"
echo "Bumping major: $LATEST -> $VERSION"
;;
BUMP_MINOR)
VERSION="v${MAJOR}.$((MINOR + 1)).0"
echo "Bumping minor: $LATEST -> $VERSION"
;;
BUMP_PATCH)
VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "Bumping patch: $LATEST -> $VERSION"
;;
SPECIFIC_VERSION)
if [ -z "$SPECIFIC_VERSION" ]; then
echo "Error: specific_version is required when version_type is SPECIFIC_VERSION"
exit 1
fi
if [[ ! "$SPECIFIC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format vX.Y.Z (e.g., v1.0.0)"
exit 1
fi
VERSION="$SPECIFIC_VERSION"
echo "Using specific version: $VERSION"
;;
*)
echo "Error: Unknown version_type: $VERSION_TYPE"
exit 1
;;
esac
# Same auth requirement: on a private repo, an unauthenticated
# curl always 404s here, which would silently disable the
# "release already exists" guard.
EXISTS_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $GITHUB_TOKEN" \
"${FORGE_URL}/releases/tags/$VERSION")
if [ "$EXISTS_STATUS" = "200" ]; then
echo "Error: Release $VERSION already exists"
exit 1
elif [ "$EXISTS_STATUS" != "404" ]; then
echo "Error: unexpected HTTP $EXISTS_STATUS checking for existing release"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building release: $VERSION"
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build changelog
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
FRAGMENTS=$(find docs/changelog.d -name "*.md" -not -name ".gitkeep" 2>/dev/null | wc -l)
if [ "$FRAGMENTS" -gt 0 ]; then
echo "Found $FRAGMENTS changelog fragments, building changelog..."
uvx towncrier build --version "$VERSION" --yes
echo "changelog_updated=true" >> "$GITHUB_OUTPUT"
RELEASE_NOTES=$(awk -v ver="$VERSION" '
/^## \[/ {
if (found) exit
if (index($0, "[" ver "]")) found=1
}
found {print}
' CHANGELOG.md | tail -n +2)
echo "$RELEASE_NOTES" > /tmp/release_notes.md
echo "Release notes extracted for $VERSION"
else
echo "No changelog fragments found, skipping towncrier"
echo "changelog_updated=false" >> "$GITHUB_OUTPUT"
echo "" > /tmp/release_notes.md
fi
- name: Build docs
run: |
VERSION="${{ steps.version.outputs.version }}"
TARBALL="docs-${VERSION}.tar.gz"
echo "Building docs via Dagger..."
dagger call build-docs --src=. --version="$VERSION" \
export --path="./$TARBALL"
echo "Build complete!"
ls -lh "$TARBALL"
- name: Build project-specific release artifacts
run: |
rm -rf release-assets
mkdir -p release-assets
if [ -x .forgejo/scripts/release ]; then
echo "Running project-specific release hook..."
./.forgejo/scripts/release "${{ steps.version.outputs.version }}"
else
echo "No .forgejo/scripts/release hook found; docs-only release."
fi
echo "Release asset inventory:"
find release-assets -maxdepth 1 -type f -print | sort || true
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TARBALL="docs-${VERSION}.tar.gz"
CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}"
FORGE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
echo "Creating release $VERSION..."
{
echo "Release $VERSION"
echo ""
if [ "$CHANGELOG_UPDATED" = "true" ] && [ -s /tmp/release_notes.md ]; then
echo "## What's Changed"
echo ""
cat /tmp/release_notes.md
echo ""
fi
echo "## Assets"
echo ""
echo "- \`$TARBALL\` contains the static docs site."
EXTRA_ASSET_COUNT=$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')
if [ "$EXTRA_ASSET_COUNT" -gt 0 ]; then
echo "- Additional project-specific artifacts are attached to this release."
fi
} > /tmp/release_body.txt
RELEASE_DATA=$(jq -n \
--arg tag "$VERSION" \
--arg name "Release $VERSION" \
--rawfile body /tmp/release_body.txt \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
RELEASE_RESPONSE=$(curl -s \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: token $GITHUB_TOKEN" \
-d "$RELEASE_DATA" \
"${FORGE_URL}/releases")
echo "API Response: $RELEASE_RESPONSE"
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "Error: Failed to create release"
exit 1
fi
echo "Created release ID: $RELEASE_ID"
echo "Uploading $TARBALL..."
curl -s \
-X POST \
-H "Content-Type: application/gzip" \
-H "Authorization: token $GITHUB_TOKEN" \
--data-binary "@$TARBALL" \
"${FORGE_URL}/releases/$RELEASE_ID/assets?name=$TARBALL"
for artifact in release-assets/*; do
if [ ! -f "$artifact" ]; then
continue
fi
FILENAME=$(basename "$artifact")
echo "Uploading $FILENAME..."
curl -s \
-X POST \
-H "Content-Type: application/octet-stream" \
-H "Authorization: token $GITHUB_TOKEN" \
--data-binary "@$artifact" \
"${FORGE_URL}/releases/$RELEASE_ID/assets?name=$FILENAME"
done
echo ""
echo "Release created successfully!"
- name: Commit changelog changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}"
if [ "$CHANGELOG_UPDATED" != "true" ]; then
echo "No changelog changes to commit"
exit 0
fi
git config user.name "Forgejo Actions"
git config user.email "actions@forge.eblu.me"
git add CHANGELOG.md docs/changelog.d/
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Update changelog for $VERSION [skip ci]"
git push origin HEAD:main
echo "Changelog changes committed and pushed"
fi
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
EXTRA_ASSET_COUNT=$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')
echo "================================================"
echo "Release: $VERSION"
echo "================================================"
echo "Docs bundle: docs-${VERSION}.tar.gz"
echo "Extra project assets: $EXTRA_ASSET_COUNT"

9
.gitea/template Normal file
View file

@ -0,0 +1,9 @@
AGENTS.md
docs/index.md
dagger.json
.dagger/pyproject.toml
.dagger/src/**/*.py
docs/quartz.config.ts
docs/quartz.layout.ts
mise-tasks/pr-comments
mise-tasks/docs-mikado

3
.github/actionlint.yaml vendored Normal file
View file

@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- k8s

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.claude/settings.local.json
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
# Linter caches
.ruff_cache/
# OS
.DS_Store

27
.yamllint.yaml Normal file
View file

@ -0,0 +1,27 @@
---
extends: default
rules:
line-length:
max: 120
level: warning
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
comments:
min-spaces-from-content: 1
braces:
min-spaces-inside: 0
max-spaces-inside: 1
brackets:
min-spaces-inside: 0
max-spaces-inside: 0
indentation:
spaces: 2
indent-sequences: consistent
comments-indentation: false
octal-values:
forbid-implicit-octal: false
forbid-explicit-octal: true
ignore:
- .venv/

61
AGENTS.md Normal file
View file

@ -0,0 +1,61 @@
# AGENTS.md
Guidance for Claude Code working in this repository. See also [[ai-assistance-guide]].
## Overview
**hephaestus** — Personal context management system: wiki-style knowledge base and task management.
## First-Time Setup
This repository is a **Forgejo template**. Most customization points are auto-resolved by Forgejo's template variable expansion when a new repo is created. The remaining manual steps are:
1. **Set `baseUrl`** in `docs/quartz.config.ts` — this is the hosted docs domain, not the repo URL. localhost if not hosted.
2. **Rename `.dagger/src/project_template_ci/`** directory to match your project, and update the class names inside
3. **Fill in project structure** in the `AGENTS.md` Project Structure section
4. **Fill in license info** in `README.md`
5. **When all TODOs are resolved:** delete this "First-Time Setup" section entirely from `AGENTS.md` — it is only needed once
## Rules
1. **Always run `mise run ai-docs` at session start**
This will refresh your context with important information you will be assumed to know and follow.
**Read the full output** — never truncate, pipe to `head`/`tail`, or skip sections.
2. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements
3. **Generated repos use feature branches + PRs for C1/C2** — checkout main, pull, create branch, open PR via `tea pr create`. This template source repo usually stays C0/direct-to-main so it remains clean and templatable.
4. **Use changelog fragments in generated repos, not as template residue**`docs/changelog.d/<name>.<type>.md`
Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc`
- **Generated repos:** add fragments for noteworthy changes
- **This template repo:** keep `docs/changelog.d/` empty except for `.gitkeep`
5. **Never commit secrets**
## Change Classification
Before starting work, classify the change:
| Class | Name | When to use | Key trait |
|-------|------|-------------|-----------|
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
**C0** — commit directly to main. No branch or PR needed. Fix forward if problems arise.
**C1** — in generated repos, use a feature branch with an early PR. In this template source repo, prefer direct cleanups unless the user explicitly wants branch-based review. Search related docs first, write documentation changes before code. Upgrade to C2 if complexity spirals.
**C2** — branch `mikado/<chain-stem>` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(<chain>): plan/impl/close/finalize` convention. Reset the branch when new prerequisites are discovered. Resume with `mise run docs-mikado --resume`.
See [[agent-change-process]] for the full methodology.
## Project Structure
```
./docs/ # Diataxis docs, Quartz config, and release content
./docs/changelog.d/ # keep only .gitkeep in the template; generated repos add towncrier fragments here
./.dagger/ # Dagger module; rename src/project_template_ci/ when forking
./.forgejo/workflows/ # generic build and release workflows for generated repos
./.forgejo/scripts/ # optional per-project build/release hooks consumed by the workflows
./mise-tasks/ # repo automation via `mise run`
```
Other code paths will be listed via ai-docs. When you encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards.

13
CHANGELOG.md Normal file
View file

@ -0,0 +1,13 @@
---
title: changelog
tags:
- meta
---
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
<!-- towncrier release notes start -->

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# project-template
A personal project template with opinionated infrastructure for documentation, CI, and AI-assisted development.
## What's Included
- **Documentation** — [Diataxis](https://diataxis.fr/)-structured docs built with [Quartz](https://quartz.jzhao.xyz/)
- **Changelog** — [Towncrier](https://towncrier.readthedocs.io/) fragment-based changelog
- **CI/CD** — [Dagger](https://dagger.io/) pipelines + Forgejo `build` and `release` workflows
- **Pre-commit hooks** — [prek](https://github.com/dustinblackman/prek) with linting, formatting, secret detection
- **AI assistance**`AGENTS.md` + structured docs for Claude Code (C0/C1/C2 change process, Mikado method)
- **Task runner** — [mise](https://mise.jdx.dev/) tasks for docs validation, Mikado chain management, release preview, and runner inspection
## Forking This Template
This is a **Forgejo template repository**. When you create a new repo from this template, Forgejo automatically expands variables like `${REPO_NAME}` and `${REPO_OWNER}` in key files — handling most customization automatically.
After creating your repo, the remaining manual steps are:
1. Set `baseUrl` in `docs/quartz.config.ts` to your docs site domain
2. Rename `.dagger/src/project_template_ci/` directory and update class names to match your project
3. Review and tailor the project structure section in `AGENTS.md`
4. Add license information to `README.md`
5. Remove the "First-Time Setup" section from `AGENTS.md` and this section from `README.md`
If you use Claude Code, it will prompt you to resolve remaining TODOs at the start of your first session.
## Getting Started
```bash
# Install git hooks
prek install && prek install --hook-type commit-msg
# Run all pre-commit checks
prek run --all-files
# List available tasks
mise tasks
# Build docs (requires Dagger)
dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz
```
## Project Structure
```
./docs/ # documentation (Diataxis, Quartz)
./docs/changelog.d/ # leave only .gitkeep in the template; generated repos add towncrier fragments here
./.dagger/ # Dagger module backing docs builds and releases
./.forgejo/workflows/ # generic build/release workflows for generated repos
./.forgejo/scripts/ # optional per-project hooks consumed by those workflows
./mise-tasks/ # scripts via `mise run`
```
## License
<!-- TODO: Add license information -->

8
dagger.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "hephaestus-ci",
"engineVersion": "v0.20.6",
"sdk": {
"source": "python"
},
"source": ".dagger"
}

View file

View file

@ -0,0 +1,13 @@
---
title: Explanation
modified: 2026-03-03
tags:
- explanation
- meta
---
# Explanation
Background context and design decisions.
<!-- TODO: Add explanation entries as the project grows -->

View file

@ -0,0 +1,277 @@
---
title: Agent Change Process
modified: 2026-04-19
tags:
- how-to
- ai
---
# Agent Change Process
How to classify and execute changes, especially when working with AI agents that may lose context across sessions.
## Change Classification
Before starting work, classify the change:
| Class | Name | When to use | Key trait |
|-------|------|-------------|-----------|
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
When in doubt, start at C1. Upgrade to C2 if complexity spirals or the user requests it.
## C0 — Quick Fix
A change where the risk is low enough that problems can be quickly fixed forward.
1. Run `mise run ai-docs` to load context
2. Implement the change directly on main
3. Add a changelog fragment if the change is user-visible or noteworthy (`docs/changelog.d/+<descriptive-slug>.<type>.md`)
4. Commit and push
No feature branch or PR required. If something goes wrong, fix forward with another commit.
Examples: fix a typo, bump a version, add a simple config value, update a doc.
## C1 — Human Review
A change with enough complexity or risk that a human should review it, but not so much that a formal multi-phase approach is needed.
### Process
1. Run `mise run ai-docs` to load context
2. **Search related docs** — read existing documentation and reference cards related to the change area
3. **Create a feature branch** and open a PR early (draft is fine)
4. **Documentation first** — commit doc changes reflecting the desired end state before writing code. This helps the reviewer understand intent and catches design issues early
5. **Implement** — commit code changes, pushing as you go. The PR gets updated along the way and the user can review and comment at any point
6. **Add changelog fragment**`docs/changelog.d/<branch>.<type>.md` for any user-visible or noteworthy changes
7. After user review and successful testing, the user merges the PR
### Upgrading to C2
Upgrade to C2 if any of these happen during a C1 change:
- You discover the change requires multiple prerequisite changes that must be sequenced
- The change is spiraling in complexity beyond a single session
- The user requests it
- During planning you realize this is a multi-phase project
## C2 — Mikado Chain
A complex, multi-session change managed through the [Mikado method](https://mikadomethod.info/) with a strict branch discipline called the **Mikado Branch Invariant**.
### Planning and research
Before writing any code, invest in understanding the problem:
1. Run `mise run ai-docs` to load context
2. Search related docs, reference cards, and existing how-to guides for the change area
3. Think through the dependency graph — what prerequisites exist? What could go wrong?
4. Create Mikado cards for everything you can anticipate (you'll discover more later — that's the point of the method)
This planning phase can span multiple sessions. Cards introduced during planning are merged to main and become the foundation for work cycles later.
### The Mikado Branch Invariant
The invariant governs how commits are ordered on a C2 feature branch. The branch must always have this structure:
```
main ← [plan commits] ← [impl, close] ← [impl, close] ← ... ← [finalize]
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Planning layer Repeating work cycles
(cards only) (impl then close, one leaf at a time)
```
**Rules:**
1. The first N commits on the branch (after diverging from main) must ALL be commits that **only introduce or modify Mikado cards** — no code changes
2. After the card layer, work proceeds in **cycles**: each cycle is one or more code commits followed by one or more commits closing leaf nodes
3. A cycle should target a single leaf node (though closing multiple in one cycle is acceptable if the code supports it)
4. Cycles repeat until the chain is complete
**The one rule:** No Mikado card may be introduced after any code or card-closing commit. New cards require a branch reset (see below).
**The length-zero case:** It is valid for the "planning layer" to have zero commits on the branch — this happens when all Mikado cards were introduced in earlier sessions and are already in main's history. The invariant is satisfied.
**Exception — finalize:** The terminal commit of a completed chain rewrites Mikado cards to historical documentation. This is a card modification after code commits, and is the only permitted violation of the one rule (see "Completing a chain" below).
### Conventions
#### Branch naming
C2 branches must be named `mikado/<chain-stem>`, where `<chain-stem>` is the filename stem of the goal card. Example: goal card `deploy-service.md` → branch `mikado/deploy-service`.
#### Goal card `branch:` frontmatter
The goal card of a C2 chain must include a `branch:` field once work begins:
```yaml
---
title: Deploy Service
status: active
branch: mikado/deploy-service
requires:
- configure-database
- setup-auth
tags:
- how-to
---
```
A goal card with `status: active` but no `branch:` field indicates a chain that has been planned but not yet started — the planning-phase cards exist but no implementation branch has been created.
#### Commit message convention
All commits on a `mikado/*` branch must use this format:
```
C2(<chain-stem>): <verb> <short description>
```
Verbs and their meanings:
| Verb | Phase | What it means |
|------|-------|---------------|
| `plan` | Planning layer | Introduces or modifies a Mikado card (no code changes) |
| `impl` | Work cycle | Code progress toward closing a leaf node (no card changes) |
| `close` | Work cycle | Closes a leaf node by removing `status: active` |
| `finalize` | Terminal | Rewrites cards to historical docs, adds changelog |
Examples:
```
C2(deploy-service): plan add database and auth prerequisite cards
C2(deploy-service): impl configure secrets for service
C2(deploy-service): close configure-database
C2(deploy-service): finalize rewrite cards as historical documentation
```
The `mikado-branch-invariant-check` commit-msg hook validates this convention and the invariant ordering.
### Process
1. **Goal card:** Create a how-to doc in `docs/how-to/` describing the desired end state
- Add `status: active` and `branch: mikado/<chain-stem>` to frontmatter
- Create prerequisite cards discovered during planning, each with `status: active`
- Commit all cards together (or in a sequence of card-only commits) using `C2(<chain>): plan ...` messages
2. **Open a PR** after the first card commits so the user can review the Mikado graph
3. **Work leaf nodes** — pick a leaf (a card with `status: active` and no unmet `requires`):
- Commit code changes (`C2(<chain>): impl ...`) that progress toward closing it
- **Verify the card's own deliverables** before closing. "Works" means the card's stated outputs are correct — not that downstream consumers have integrated them
- Commit the card closure (`C2(<chain>): close ...`) — remove `status: active`
- Push to origin — this is the save point
4. **End the cycle** — after pushing a closed leaf node, prompt the user to review the PR and suggest ending the session. Each closed leaf is a natural stopping point; the chain is designed to be resumed later
5. **Repeat** until the chain is complete
6. **New agent sessions** pick up state by running `mise run docs-mikado --resume`
### Discovering new prerequisites or errors
When you discover a new prerequisite **or encounter an error** during code work, do not fix forward. The Mikado method's power comes from rigorous resets that keep the plan honest. You must restore the Mikado Branch Invariant:
1. **Stash or note any in-progress work** you want to preserve
2. **Identify the reset point** — the last `plan` or `close` commit before your current `impl` commits:
```bash
git log --oneline mikado/<chain-stem> --not main
```
3. **Reset the branch** to that commit:
```bash
git reset --hard <reset-point-sha>
```
4. **Update the plan** — add a `plan` commit that captures what you learned:
- If you discovered a new prerequisite: add a new card and update `requires`
- If you hit an error: update the relevant card with what you learned, or introduce a new prerequisite card that addresses the root cause
5. **Replay valid work** by cherry-picking commits that still apply:
```bash
git cherry-pick <sha1> <sha2> ...
```
6. **Resume the Mikado process** from the new state of the card stack
**When to reset vs. fix forward:** If an `impl` commit introduces a bug that's a simple typo or one-liner, another `impl` commit is fine. But if the error reveals a gap in understanding, a missing prerequisite, or requires rethinking the approach — reset. The threshold is: "does this error teach us something that should be in the plan?" If yes, reset.
**Saving work across resets:** It is acceptable to cherry-pick code commits from before the reset back onto the branch after adding the new card. Use `git stash` for uncommitted work. This is a pragmatic exception — use it only when you are confident the saved work is still valid given the new prerequisite. When in doubt, redo the work from scratch.
### Completing a chain
When the final leaf node is closed and no `status: active` cards remain:
1. **Rewrite all Mikado cards** to reflect their nature as historical documentation:
- Remove transient technical details that won't matter in the future
- Frame the content as "what to do if someone wanted to repeat this process"
- Add appropriate context about what was learned
- Remove `branch:` from the goal card frontmatter
2. **Add changelog information** in `docs/changelog.d/`
3. Commit as `C2(<chain>): finalize ...` — this is the one permitted exception to the invariant's "no card changes after code" rule
4. The user reviews and merges the PR
### Cold-start: resuming a chain in a new session
When starting a new session to continue C2 work:
1. Run `mise run ai-docs` to load context
2. Run `mise run docs-mikado --resume` — this will:
- Detect the current branch and match it to an active chain
- Show the chain state, ready leaf nodes, and current position in the invariant
- Show the PR number and URL if an open PR exists for the branch
- Warn about any stashed work in `git stash list`
- If on main, list active chains and suggest which to resume
3. Check PR comments with `mise run pr-comments <pr_number>` — use the PR number from the `--resume` output above
4. Pick the next ready leaf node and continue with a work cycle
## Card Conventions
### Frontmatter
```yaml
---
title: Deploy Service
status: active # omit when complete
branch: mikado/deploy-service # goal cards only; omit when complete
requires: # explicit dependencies
- configure-database
- setup-auth
tags:
- how-to
---
```
- `status: active` marks in-progress work; remove when done (this is the ONLY way a card is marked complete)
- `branch` is set on goal cards only, linking the card to its `mikado/<chain-stem>` branch. Remove `branch` when the chain is finalized.
- `requires` lists card stems (filenames without `.md`) that must be completed first. **Keep `requires` permanently** even after prerequisites are done — it documents the dependency graph history
- `required-by` is NOT stored — it's computed by `docs-mikado`
### Writing Cards
- **Mikado cards are not plans.** Plans are designed upfront; Mikado cards are discovered through failed attempts.
- Cards live in a topic subdirectory under `docs/how-to/`
- Keep cards brief (<30 seconds to read)
- Link to other cards rather than inlining their content
- Document what was learned from failures, not just what to do
### Git Discipline
- **C0:** Commit directly to main
- **C1:** Single feature branch, PR early, push often
- **C2:** Branch named `mikado/<chain-stem>`, Mikado Branch Invariant enforced, `C2()` commit convention, PR early, push after every leaf-node closure
- **Changelog fragments (all levels):** Add `docs/changelog.d/<name>.<type>.md` for any user-visible or noteworthy change, regardless of change class. C0 uses orphan fragments (`+<descriptive-slug>.<type>.md`). C1/C2 use the branch name (`<branch>.<type>.md`). C0 includes the fragment in the same commit. C1 includes it during the branch work. C2 includes it in the `finalize` commit.
- GitOps requires pushing to test — if a pushed commit breaks, revert it promptly
## Tools
| Command | Purpose |
|---------|---------|
| `mise run changelog-check` | Validate changelog fragment layout |
| `mise run docs-check-frontmatter` | Check required doc frontmatter fields |
| `mise run docs-mikado` | List all active Mikado chains with branch status |
| `mise run docs-mikado <card>` | Show dependency chain for a goal card |
| `mise run docs-mikado <card> --all` | Include completed cards in full |
| `mise run docs-mikado --resume` | Resume a chain: detect branch, show state and next steps |
| `mise run docs-mikado --resume <chain>` | Resume a specific chain with branch consistency check |
| `mise run docs-preview <tarball>` | Preview a released docs bundle locally |
| `mise run runner-logs [run_number]` | List Forgejo Actions runs or fetch logs for a specific job |
The `mikado-branch-invariant-check` commit-msg hook runs automatically on `mikado/*` branches, validating commit message conventions and invariant ordering. Requires `prek install --hook-type commit-msg`.
## Related
- [[ai-assistance-guide]] — General AI agent conventions

15
docs/how-to/how-to.md Normal file
View file

@ -0,0 +1,15 @@
---
title: How-To Guides
modified: 2026-03-03
tags:
- how-to
- meta
---
# How-To Guides
Task-oriented guides for common operations.
## Knowledge Base
- [[agent-change-process]] — C0/C1/C2 change classification and Mikado method

18
docs/index.md Normal file
View file

@ -0,0 +1,18 @@
---
title: Project Documentation
modified: 2026-03-03
tags:
- meta
---
# Project Documentation
Welcome to the **hephaestus** documentation.
## Navigation
- [[tutorials]] — Getting started and learning guides
- [[reference]] — Technical reference material
- [[how-to]] — Task-oriented guides
- [[explanation]] — Background and design decisions
- [[CHANGELOG]] — Release history

91
docs/quartz.config.ts Normal file
View file

@ -0,0 +1,91 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
/**
* Quartz configuration for project documentation
* See https://quartz.jzhao.xyz/configuration
*/
const config: QuartzConfig = {
configuration: {
pageTitle: "Hephaestus Docs",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
analytics: null,
locale: "en-US",
baseUrl: "CHANGEME.example.com", // TODO: Update to your docs site URL
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "modified",
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
},
},
},
plugins: {
transformers: [
Plugin.FrontMatter(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "git", "filesystem"],
}),
Plugin.SyntaxHighlighting({
theme: {
light: "github-light",
dark: "github-dark",
},
keepBackground: false,
}),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
],
filters: [Plugin.RemoveDrafts()],
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources(),
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),
Plugin.ContentIndex({
enableSiteMap: true,
enableRSS: true,
}),
Plugin.Assets(),
Plugin.Static(),
Plugin.NotFoundPage(),
],
},
}
export default config

53
docs/quartz.layout.ts Normal file
View file

@ -0,0 +1,53 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
/**
* Quartz layout configuration for project documentation
* See https://quartz.jzhao.xyz/layout
*/
// Components shared across all pages
export const sharedPageComponents: SharedLayout = {
head: Component.Head(),
header: [],
afterBody: [],
footer: Component.Footer({
links: {
"Repository": "/eblume/hephaestus",
},
}),
}
// Components for pages that list posts (folder pages, tag pages)
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
Component.Breadcrumbs(),
Component.ArticleTitle(),
Component.ContentMeta(),
Component.TagList(),
],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
],
right: [
Component.Graph(),
Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(),
],
}
export const defaultListPageLayout: PageLayout = {
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
],
right: [],
}

View file

@ -0,0 +1,68 @@
---
title: Reference
modified: 2026-04-19
tags:
- reference
- meta
---
# Reference
Technical reference material for the repository tooling that ships with this project.
## Template Surface Area
| Path | Purpose |
|------|---------|
| `.dagger/src/project_template_ci/` | Dagger module that builds the Quartz docs tarball used by releases |
| `.forgejo/workflows/build.yaml` | Generic CI validation workflow |
| `.forgejo/workflows/release.yaml` | Manual release workflow that versions, builds docs, and publishes release assets |
| `.forgejo/scripts/` | Optional project-specific hooks consumed by the workflows |
| `mise-tasks/` | Helper tasks for docs validation, Mikado chains, PR review, and runner inspection |
## Forgejo Workflows
### `build.yaml`
- Triggers on pushes to `main` and pull requests targeting `main`
- Runs `prek run --all-files`
- Executes `.forgejo/scripts/build` if that hook exists and is executable
- Otherwise exits after generic template validation
### `release.yaml`
- Triggered manually via `workflow_dispatch`
- Accepts `BUMP_PATCH`, `BUMP_MINOR`, `BUMP_MAJOR`, or `SPECIFIC_VERSION`
- Resolves the next version from the latest Forgejo release tag
- Builds `CHANGELOG.md` with towncrier when fragment files exist
- Builds `docs-<version>.tar.gz` via `dagger call build-docs --src=. --version=<version>`
- Executes `.forgejo/scripts/release <version>` if present to stage extra files under `release-assets/`
- Creates the Forgejo release and uploads the docs tarball plus any extra assets
- Commits generated changelog updates back to `main` when fragments were consumed
## Mise Tasks
| Task | Purpose |
|------|---------|
| `mise run ai-docs` | Print the key docs files AI agents are expected to read first |
| `mise run changelog-check` | Validate changelog fragments are flat files under `docs/changelog.d/` |
| `mise run docs-check-filenames` | Detect duplicate doc filenames |
| `mise run docs-check-frontmatter` | Validate required frontmatter fields |
| `mise run docs-check-index` | Ensure each doc is linked from its category index |
| `mise run docs-check-links` | Validate wiki-links against existing doc filenames |
| `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work |
| `mise run docs-preview <tarball>` | Extract and serve a released docs tarball locally |
| `mise run mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline |
| `mise run pr-comments <pr_number>` | List unresolved PR comments |
| `mise run runner-logs [run_number]` | List Forgejo Actions runs or fetch logs for a job |
## Changelog Fragments
- Store towncrier fragments under `docs/changelog.d/`
- Use one flat `.md` file per change
- The directory may contain only `.gitkeep` until the first real fragment is added
## TODO After Templating
- TODO: Set `baseUrl` in `docs/quartz.config.ts` to the hosted docs domain, or `localhost` if the docs are only previewed locally
- TODO: Rename `.dagger/src/project_template_ci/` and the exported Dagger class to match the generated project

View file

@ -0,0 +1,111 @@
---
title: AI Assistance Guide
modified: 2026-04-19
tags:
- tutorials
- ai
---
# AI Assistance Guide
> **Audiences:** AI, Owner
This guide provides context for AI agents (like Claude Code) assisting with this project.
## Critical Rules
These are non-negotiable for AI agents working in this repo:
1. **Run `mise run ai-docs` at session start** — Review project documentation
2. **Never commit secrets** — The repo may be public
3. **Wait for user review before deploying** — Create PRs, don't auto-deploy
4. **Never merge PRs without explicit request** — The user merges after review
Full rules are in the repo's `AGENTS.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology — C0 (direct to main), C1 (feature branch + PR), C2 (Mikado Branch Invariant).
## Workflow Conventions
### Branching
Branching depends on change classification (see [[agent-change-process]]):
- **C0 (Quick Fix):** Commit directly to main — no branch or PR needed
- **C1/C2:** Feature branch required:
```bash
git checkout main && git pull
git checkout -b feature/descriptive-name
# ... make changes ...
git commit -m "Description"
```
### Pull Requests
Use the forge's `tea` CLI:
```bash
tea pr create --title "Title" --description "$(cat <<'EOF'
## Summary
- Change 1
- Change 2
## Testing
- [ ] Test step
EOF
)"
```
### Changelog Fragments
Add a fragment for user-visible changes:
```bash
# C1/C2: use branch name
echo "Description" > docs/changelog.d/branch-name.feature.md
# C0: use orphan prefix (no branch to name after)
echo "Description" > docs/changelog.d/+descriptive-slug.feature.md
```
Types (file suffix): `.feature`, `.bugfix`, `.infra`, `.doc`, `.ai`, `.misc`
### Wiki-Link Formatting
Use simple wiki-links without alternate text or extra spaces:
- Prefer `[[borgmatic]]` over `[[borgmatic|Borgmatic]]`
- Only use alternate text when grammatically warranted
- No spaces around the pipe: `[[path|Text]]` not `[[ path|Text ]]`
When editing documentation, rewrite links to follow this convention as you encounter them.
## Mise Tasks
Run `mise tasks` to list all available tasks.
| Task | When to Use |
|------|-------------|
| `ai-docs` | At session start — review project documentation |
| `changelog-check` | Validate that changelog fragments are flat files in `docs/changelog.d/` |
| `docs-check-frontmatter` | Check required frontmatter fields across docs |
| `docs-preview` | Preview a released docs tarball locally |
| `docs-mikado` | View active Mikado dependency chains for C2 changes |
| `docs-mikado --resume` | Resume a C2 chain: detect branch, show state and next steps |
| `pr-comments` | Check unresolved PR comments during review |
| `runner-logs` | Inspect recent Forgejo Actions runs or fetch logs for a job |
| `docs-check-links` | Validate wiki-links in documentation (includes orphan detection) |
| `docs-check-index` | Check every doc is referenced in its category index |
| `docs-check-filenames` | Check for duplicate doc filenames |
## Repository Tooling
This project ships two Forgejo workflows:
- `build.yaml` runs on pushes and pull requests targeting `main`, executes `prek run --all-files`, and then runs an optional project hook at `.forgejo/scripts/build` when present.
- `release.yaml` is a manual workflow that computes the next version, optionally builds `CHANGELOG.md` from fragments, packages Quartz docs via Dagger, runs an optional `.forgejo/scripts/release` hook for extra assets, creates the Forgejo release, and pushes changelog updates back to `main` when fragments were consumed.
- TODO: Rename `.dagger/src/project_template_ci/` and the exported Dagger class during first-time setup.
## Common Pitfalls
| Pitfall | Correct Approach |
|---------|------------------|
| Deploying without review | Create PR first, wait for user approval |
| Re-explaining reference material | Link to reference cards instead |
| Committing to main without classifying | Classify as C0/C1/C2 first — only C0 goes to main |

View file

@ -0,0 +1,15 @@
---
title: Tutorials
modified: 2026-03-03
tags:
- tutorials
- meta
---
# Tutorials
Learning-oriented guides for getting started.
## Getting Started
- [[ai-assistance-guide]] — Guide for AI agents working in this repo

21
mise-tasks/ai-docs Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
#MISE description="Prime AI context with key project documentation"
set -euo pipefail
DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs"
# Key files for AI context priming, in order of importance
FILES=(
"$DOCS_DIR/tutorials/ai-assistance-guide.md"
"$DOCS_DIR/how-to/agent-change-process.md"
"$DOCS_DIR/index.md"
"$DOCS_DIR/reference/reference.md"
"$DOCS_DIR/how-to/how-to.md"
"$DOCS_DIR/explanation/explanation.md"
"$DOCS_DIR/tutorials/tutorials.md"
)
# Concatenate files with headers showing paths
# Defaults are tuned for AI consumption (plain text, file headers only)
bat --style=header --color=never --decorations=always "$@" "${FILES[@]}"

440
mise-tasks/branch-cleanup Executable file
View file

@ -0,0 +1,440 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"]
# ///
#MISE description="Delete branches that have been merged into main (local and remote)"
#MISE alias="bc"
#USAGE flag "--dry-run" help="Show what would be deleted without deleting"
#USAGE flag "--yes" help="Skip confirmation prompt"
#USAGE flag "--local-only" help="Only clean up local branches"
#USAGE flag "--remote-only" help="Only clean up remote branches"
#USAGE flag "--token <token>" help="Forgejo API token (default: read from 1Password)"
#USAGE flag "--cutoff <cutoff>" default="30" help="Only delete branches whose HEAD commit is older than N days (default 30)"
"""Clean up merged branches locally and on the Forgejo remote.
Detects merged branches via two methods:
1. git branch --merged (catches fast-forward merges)
2. Forgejo API (catches squash-merged PRs)
Remote branches are deleted via the Forgejo API. The token is resolved:
1. --token flag (explicit)
2. FORGEJO_TOKEN environment variable (for CI)
3. 1Password: op read (for local use, prompts biometric)
Local branches are deleted via git branch -D.
Warns about stale local branches that couldn't be confirmed as merged.
Usage:
mise run branch-cleanup # interactive cleanup (30-day cutoff)
mise run branch-cleanup --cutoff 7 # only branches older than 7 days
mise run branch-cleanup --cutoff 0 # all merged branches regardless of age
mise run branch-cleanup --dry-run # preview only
"""
import os
import subprocess
from datetime import datetime, timezone
from typing import Annotated
import httpx
import typer
from rich.console import Console
from rich.table import Table
PROTECTED_BRANCHES = {"main", "master"}
PROTECTED_PREFIXES = ("preserve/",)
FORGE_API = "https://forge.eblu.me/api/v1"
REPO_OWNER = "eblume"
REPO_NAME = "blumeops"
OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token"
def is_protected(name: str) -> bool:
"""Check if a branch is protected by name or prefix."""
return name in PROTECTED_BRANCHES or name.startswith(PROTECTED_PREFIXES)
def run_git(*args: str) -> str:
"""Run a git command and return stdout."""
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def resolve_token(explicit_token: str | None, console: Console) -> str:
"""Resolve Forgejo API token: explicit flag > FORGEJO_TOKEN env > 1Password."""
if explicit_token:
return explicit_token
env_token = os.environ.get("FORGEJO_TOKEN", "").strip()
if env_token:
return env_token
console.print("[dim]Reading Forgejo API token from 1Password...[/dim]")
try:
result = subprocess.run(
["op", "read", OP_TOKEN_REF],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError) as e:
console.print(f"[red]Failed to read token from 1Password:[/red] {e}")
console.print("[dim]Pass --token explicitly or ensure op CLI is available[/dim]")
raise typer.Exit(1)
def branch_head_age_days(ref: str) -> int | None:
"""Get the age in days of the HEAD commit on a branch ref."""
try:
date_str = run_git("log", "-1", "--format=%aI", ref)
if not date_str:
return None
commit_date = datetime.fromisoformat(date_str)
return (datetime.now(timezone.utc) - commit_date).days
except subprocess.CalledProcessError:
return None
def api_branch_age_days(commit_date_str: str) -> int | None:
"""Compute age in days from an ISO date string."""
try:
commit_date = datetime.fromisoformat(commit_date_str)
return (datetime.now(timezone.utc) - commit_date).days
except (ValueError, TypeError):
return None
def get_git_merged_local_branches() -> set[str]:
"""Get local branches that are fully merged into main (fast-forward)."""
try:
output = run_git("branch", "--merged", "main")
except subprocess.CalledProcessError:
return set()
branches = set()
for line in output.splitlines():
name = line.strip().lstrip("* ")
if name and not is_protected(name):
branches.add(name)
return branches
def get_git_merged_remote_branches() -> set[str]:
"""Get remote branches that are fully merged into origin/main (fast-forward)."""
try:
output = run_git("branch", "-r", "--merged", "origin/main")
except subprocess.CalledProcessError:
return set()
branches = set()
for line in output.splitlines():
name = line.strip()
if " -> " in name:
continue
if not name.startswith("origin/"):
continue
short = name.removeprefix("origin/")
if short not in PROTECTED_BRANCHES:
branches.add(short)
return branches
def get_all_local_branches() -> set[str]:
"""Get all local branch names."""
output = run_git("branch")
branches = set()
for line in output.splitlines():
name = line.strip().lstrip("* ")
if name and not is_protected(name):
branches.add(name)
return branches
def get_api_branches(client: httpx.Client) -> dict[str, str]:
"""Get all remote branches via API. Returns {name: commit_date_iso}."""
branches: dict[str, str] = {}
page = 1
limit = 50
while True:
resp = client.get(
f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches",
params={"limit": limit, "page": page},
)
resp.raise_for_status()
data = resp.json()
if not data:
break
for branch in data:
name = branch["name"]
if not is_protected(name):
date = branch.get("commit", {}).get("timestamp", "")
branches[name] = date
page += 1
return branches
def get_merged_pr_branches(client: httpx.Client, console: Console) -> set[str]:
"""Query Forgejo API for branch names from merged PRs."""
merged_branches: set[str] = set()
page = 1
limit = 50
try:
while True:
resp = client.get(
f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/pulls",
params={"state": "closed", "limit": limit, "page": page},
)
resp.raise_for_status()
prs = resp.json()
if not prs:
break
for pr in prs:
if pr.get("merged"):
head = pr.get("head", {})
ref = head.get("ref", "")
# Forgejo rewrites ref to refs/pull/N/head once the
# source branch is deleted; the original name is in label
if ref.startswith("refs/pull/"):
ref = head.get("label", "")
if ref and ref not in PROTECTED_BRANCHES:
merged_branches.add(ref)
page += 1
except httpx.HTTPError as e:
console.print(f"[yellow]Warning:[/yellow] Failed to query merged PRs: {e}")
return merged_branches
def delete_local_branch(branch: str) -> tuple[bool, str]:
"""Delete a local branch. Returns (success, message)."""
try:
# Use -D since squash-merged branches aren't git-ancestors of main
run_git("branch", "-D", branch)
return True, "deleted"
except subprocess.CalledProcessError as e:
return False, e.stderr.strip()
def delete_remote_branch_api(
client: httpx.Client, branch: str,
) -> tuple[bool, str]:
"""Delete a remote branch via Forgejo API. Returns (success, message)."""
resp = client.delete(
f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches/{branch}",
)
if resp.status_code == 204:
return True, "deleted"
return False, f"HTTP {resp.status_code}: {resp.text[:120]}"
app = typer.Typer(add_completion=False)
@app.command()
def main(
cutoff: Annotated[
int,
typer.Option(help="Only delete branches whose HEAD commit is older than N days"),
] = 30,
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="Show what would be deleted without deleting"),
] = False,
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
local_only: Annotated[
bool,
typer.Option("--local-only", help="Only clean up local branches"),
] = False,
remote_only: Annotated[
bool,
typer.Option("--remote-only", help="Only clean up remote branches"),
] = False,
token: Annotated[
str | None,
typer.Option("--token", help="Forgejo API token (default: read from 1Password)"),
] = None,
) -> None:
"""Delete branches that have been merged into main."""
console = Console()
do_local = not remote_only
do_remote = not local_only
# Resolve token (needed for API branch listing and deletion)
api_token = resolve_token(token, console) if do_remote else None
# Fetch latest remote state for local branch operations
if do_local:
console.print("[dim]Fetching remote branches...[/dim]")
try:
run_git("fetch", "--prune", "origin")
except subprocess.CalledProcessError as e:
console.print(f"[yellow]Warning:[/yellow] git fetch failed: {e.stderr}")
# Gather merge info
console.print("[dim]Checking Forgejo for squash-merged PRs...[/dim]")
with httpx.Client(
timeout=15,
headers={"Authorization": f"token {api_token}"} if api_token else {},
) as client:
api_merged = get_merged_pr_branches(client, console)
git_merged_local = get_git_merged_local_branches() if do_local else set()
git_merged_remote = get_git_merged_remote_branches() if do_local else set()
# Union of all confirmed-merged branch names
all_confirmed_merged = api_merged | git_merged_local | git_merged_remote
# Remote branches and ages via API
remote_branch_ages: dict[str, int | None] = {}
if do_remote:
console.print("[dim]Listing remote branches via API...[/dim]")
api_branches = get_api_branches(client)
for name, date_str in api_branches.items():
remote_branch_ages[name] = api_branch_age_days(date_str)
all_remote = set(api_branches.keys())
else:
all_remote = set()
remote_to_delete = sorted(all_remote & all_confirmed_merged)
# Local branches
all_local = get_all_local_branches() if do_local else set()
local_to_delete = sorted(all_local & all_confirmed_merged)
# Compute ages for all candidates
all_candidates = sorted(set(local_to_delete) | set(remote_to_delete))
branch_ages: dict[str, int | None] = {}
for name in all_candidates:
age = remote_branch_ages.get(name)
if age is None and name in all_local:
age = branch_head_age_days(name)
branch_ages[name] = age
# Apply cutoff filter
local_to_delete = [
b for b in local_to_delete
if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator]
]
remote_to_delete = [
b for b in remote_to_delete
if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator]
]
filtered_candidates = sorted(set(local_to_delete) | set(remote_to_delete))
skipped_count = len(all_candidates) - len(filtered_candidates)
if filtered_candidates:
table = Table(title=f"Merged branches to delete (older than {cutoff} days)")
table.add_column("Branch")
table.add_column("Age (days)", justify="right")
table.add_column("Method")
table.add_column("Local")
table.add_column("Remote")
for branch in filtered_candidates:
has_local = branch in local_to_delete
has_remote = branch in remote_to_delete
age = branch_ages.get(branch)
age_str = str(age) if age is not None else "?"
methods = []
if branch in git_merged_local or branch in git_merged_remote:
methods.append("git")
if branch in api_merged:
methods.append("api")
method_str = "+".join(methods)
table.add_row(
branch,
age_str,
method_str,
"[yellow]delete[/yellow]" if has_local else "[dim]-[/dim]",
"[yellow]delete[/yellow]" if has_remote else "[dim]-[/dim]",
)
console.print(table)
console.print(
f"\n[bold]{len(local_to_delete)}[/bold] local, "
f"[bold]{len(remote_to_delete)}[/bold] remote branches to delete"
)
if skipped_count:
console.print(f"[dim]({skipped_count} merged branches skipped — "
f"newer than {cutoff} days)[/dim]")
else:
console.print(f"[green]No merged branches older than "
f"{cutoff} days to clean up.[/green]")
if skipped_count:
console.print(f"[dim]({skipped_count} merged branches skipped — "
f"newer than {cutoff} days)[/dim]")
# Warn about stale unmerged local branches
if do_local:
unmerged_local = all_local - all_confirmed_merged
stale_unmerged = []
for name in sorted(unmerged_local):
age = branch_head_age_days(name)
if age is not None and age >= cutoff:
stale_unmerged.append((name, age))
if stale_unmerged:
console.print()
warn_table = Table(
title=f"[yellow]Warning:[/yellow] Stale unmerged local branches "
f"(older than {cutoff} days)",
title_style="",
)
warn_table.add_column("Branch")
warn_table.add_column("Age (days)", justify="right")
for name, age in stale_unmerged:
warn_table.add_row(f"[yellow]{name}[/yellow]", str(age))
console.print(warn_table)
console.print(
f"[dim]These {len(stale_unmerged)} branches have no merged PR on "
f"Forgejo and are not git-ancestors of main.\n"
f"They may contain work-in-progress — inspect manually "
f"before deleting.[/dim]"
)
if not filtered_candidates:
raise typer.Exit(0)
if dry_run:
console.print("\n[dim]Dry run — no branches were deleted.[/dim]")
raise typer.Exit(0)
# Confirm
if not yes and not typer.confirm("\nProceed with deletion?"):
console.print("[dim]Aborted.[/dim]")
raise typer.Exit(0)
# Delete remote branches via API
if remote_to_delete:
console.print("\n[bold]Deleting remote branches...[/bold]")
for branch in remote_to_delete:
ok, msg = delete_remote_branch_api(client, branch)
if ok:
console.print(f" [green]✓[/green] origin/{branch}")
else:
console.print(f" [red]✗[/red] origin/{branch}: {msg}")
# Delete local branches (outside httpx client context)
if local_to_delete:
console.print("\n[bold]Deleting local branches...[/bold]")
for branch in local_to_delete:
ok, msg = delete_local_branch(branch)
if ok:
console.print(f" [green]✓[/green] {branch}")
else:
console.print(f" [red]✗[/red] {branch}: {msg}")
console.print("\n[green]Done.[/green]")
if __name__ == "__main__":
app()

26
mise-tasks/changelog-check Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
#MISE description="Validate changelog fragments are flat files in docs/changelog.d/"
# Ensures no fragments end up in subdirectories (e.g. from branch names like feature/foo).
# Towncrier expects flat <name>.<type>.md files, not nested paths.
set -euo pipefail
CHANGELOG_DIR="$(git rev-parse --show-toplevel)/docs/changelog.d"
errors=0
while IFS= read -r -d '' entry; do
rel="${entry#"$CHANGELOG_DIR/"}"
if [[ "$rel" == */* ]]; then
echo "ERROR: changelog fragment in subdirectory: docs/changelog.d/$rel"
echo " Move to: docs/changelog.d/$(echo "$rel" | tr '/' '-')"
errors=$((errors + 1))
fi
done < <(find "$CHANGELOG_DIR" -name '*.md' -print0)
if [ "$errors" -gt 0 ]; then
echo ""
echo "$errors fragment(s) in subdirectories. Towncrier requires flat files in docs/changelog.d/."
exit 1
fi
echo "All changelog fragments are correctly placed."

85
mise-tasks/docs-check-filenames Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Detect duplicate filenames in documentation"
"""Detect duplicate filenames in documentation.
This script scans all markdown files in the docs/ directory (excluding
changelog.d/) and reports any duplicate filenames that could
cause wiki-link resolution issues.
With Quartz, wiki-links like [[filename]] resolve by filename,
so filenames must be unique across the documentation.
Usage: mise run docs-check-filenames
"""
import sys
from collections import defaultdict
from pathlib import Path
from rich.console import Console
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
def main() -> int:
console = Console()
# Collect all filenames and their paths
# Key: filename (without .md), Value: list of file paths
filenames: dict[str, list[str]] = defaultdict(list)
# Scan all markdown files (excluding changelog.d/)
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
rel_path = str(md_file.relative_to(DOCS_DIR))
filename = md_file.stem # filename without .md
filenames[filename].append(rel_path)
# Find duplicates
duplicates = {name: paths for name, paths in filenames.items() if len(paths) > 1}
# Print results
console.print("[bold]Doc Filename Inventory[/bold]")
console.print()
console.print("With Quartz, wiki-links like [[filename]] resolve by filename,")
console.print("so filenames must be unique across the documentation.")
console.print()
# Duplicates table (if any)
if duplicates:
console.print("[bold red]Duplicate Filenames Found[/bold red]")
dup_table = Table(show_header=True, header_style="bold")
dup_table.add_column("Filename")
dup_table.add_column("Paths")
for name in sorted(duplicates.keys()):
paths = duplicates[name]
dup_table.add_row(name, "\n".join(paths))
console.print(dup_table)
console.print()
# Summary
console.print(f"Total files: {sum(len(p) for p in filenames.values())}")
console.print(f"Unique filenames: {len(filenames)}")
console.print(f"Duplicate filenames: {len(duplicates)}")
if duplicates:
console.print()
console.print("[bold red]Action required:[/bold red] Rename files to ensure unique wiki-link resolution.")
return 1
console.print()
console.print("[bold green]All filenames are unique![/bold green]")
return 0
if __name__ == "__main__":
sys.exit(main())

115
mise-tasks/docs-check-frontmatter Executable file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Check that all docs have required frontmatter fields"
"""Validate that all documentation files have required YAML frontmatter.
Required fields: title, tags, modified
Scans all markdown files in docs/ (excluding changelog.d/) and checks
that each file has YAML frontmatter containing the required fields.
Usage: mise run docs-check-frontmatter
"""
import re
import sys
from pathlib import Path
from rich.console import Console
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
HOWTO_DIR = DOCS_DIR / "how-to"
REQUIRED_FIELDS = {"title", "tags", "modified"}
# These fields are only permitted in docs/how-to/
HOWTO_ONLY_FIELDS = {"status", "requires"}
# Match YAML frontmatter block
FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
# Match top-level YAML keys (not indented)
KEY_PATTERN = re.compile(r"^([a-zA-Z][a-zA-Z0-9_-]*):", re.MULTILINE)
def extract_frontmatter_keys(file_path: Path) -> set[str] | None:
"""Extract top-level keys from YAML frontmatter. Returns None if no frontmatter."""
content = file_path.read_text()
match = FRONTMATTER_PATTERN.match(content)
if not match:
return None
frontmatter = match.group(1)
return set(KEY_PATTERN.findall(frontmatter))
def main() -> int:
console = Console()
console.print("[bold]Frontmatter Validation[/bold]")
console.print(f"Required fields: {', '.join(sorted(REQUIRED_FIELDS))}")
console.print()
missing_issues: list[tuple[str, set[str]]] = []
misplaced_issues: list[tuple[str, set[str]]] = []
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
rel_path = str(md_file.relative_to(DOCS_DIR))
keys = extract_frontmatter_keys(md_file)
if keys is None:
missing_issues.append((rel_path, REQUIRED_FIELDS))
continue
missing = REQUIRED_FIELDS - keys
if missing:
missing_issues.append((rel_path, missing))
# Check that status/requires only appear in how-to docs
is_howto = HOWTO_DIR in md_file.parents or md_file.parent == HOWTO_DIR
if not is_howto:
misplaced = keys & HOWTO_ONLY_FIELDS
if misplaced:
misplaced_issues.append((rel_path, misplaced))
has_issues = bool(missing_issues or misplaced_issues)
if missing_issues:
console.print("[bold red]Missing Required Frontmatter[/bold red]")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Missing Fields")
for rel_path, missing in missing_issues:
table.add_row(rel_path, ", ".join(sorted(missing)))
console.print(table)
console.print()
if misplaced_issues:
console.print("[bold red]Misplaced Frontmatter Fields[/bold red]")
console.print(f"[dim]These fields are only allowed in {HOWTO_DIR.relative_to(DOCS_DIR)}/[/dim]")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Disallowed Fields")
for rel_path, misplaced in misplaced_issues:
table.add_row(rel_path, ", ".join(sorted(misplaced)))
console.print(table)
console.print()
if has_issues:
return 1
console.print("[bold green]All docs have required frontmatter![/bold green]")
return 0
if __name__ == "__main__":
sys.exit(main())

117
mise-tasks/docs-check-index Executable file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Check that every doc is referenced in its category index"
"""Check that every doc in a Diataxis category is referenced in its index.
Each Diataxis category (tutorials, reference, how-to, explanation) has an
index file that should wiki-link to every doc in that category directory.
A doc is considered referenced if its filename stem appears as a wiki-link
target (e.g., alloy.md is matched by [[alloy]]) in the category index.
Index files are excluded from the self-check.
Usage: mise run docs-check-index
"""
import re
import sys
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
# Category directories and their index files
CATEGORIES = {
"tutorials": "tutorials/tutorials.md",
"reference": "reference/reference.md",
"how-to": "how-to/how-to.md",
"explanation": "explanation/explanation.md",
}
# Regex to match wiki-links: [[Target]] or [[Target|Display]]
WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]")
# Regex to match inline code (backticks)
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
def extract_link_targets(file_path: Path) -> set[str]:
"""Extract all wiki-link targets from a file (ignoring inline code)."""
content = file_path.read_text()
targets: set[str] = set()
for line in content.splitlines():
line_without_code = INLINE_CODE_PATTERN.sub("", line)
for match in WIKILINK_PATTERN.finditer(line_without_code):
targets.add(match.group(1).strip())
return targets
def main() -> int:
console = Console()
console.print("[bold]Category Index Validation[/bold]")
console.print()
has_errors = False
missing: list[tuple[str, str, str]] = [] # (category, stem, file)
for category, index_rel in CATEGORIES.items():
index_path = DOCS_DIR / index_rel
if not index_path.exists():
console.print(f"[yellow]Warning: index file not found: {index_rel}[/yellow]")
continue
category_dir = DOCS_DIR / category
if not category_dir.is_dir():
continue
# Get all wiki-link targets from the index
index_targets = extract_link_targets(index_path)
index_stem = index_path.stem
# Check each doc in the category directory
for md_file in sorted(category_dir.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
stem = md_file.stem
# Skip the index file itself
if stem == index_stem:
continue
if stem not in index_targets:
rel_path = str(md_file.relative_to(DOCS_DIR))
missing.append((category, stem, rel_path))
if missing:
has_errors = True
console.print("[bold red]Docs Missing From Category Index[/bold red]")
console.print("These docs are not wiki-linked from their category index file.")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("Category")
table.add_column("File")
table.add_column("Add To")
for category, stem, rel_path in missing:
table.add_row(category, rel_path, CATEGORIES[category])
console.print(table)
console.print()
if has_errors:
return 1
console.print(f"Checked {len(CATEGORIES)} category indexes.")
console.print("[bold green]All docs are referenced in their category index![/bold green]")
return 0
if __name__ == "__main__":
sys.exit(main())

255
mise-tasks/docs-check-links Executable file
View file

@ -0,0 +1,255 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Validate all wiki-links point to existing doc filenames"
"""Validate that all wiki-links in documentation point to existing files.
This script scans all markdown files in the docs/ directory (excluding
changelog.d/), extracts wiki-links, and verifies each link target
exists as a unique filename in the documentation.
Wiki-link formats supported:
- [[filename]] - links to filename.md (must be unique across all docs)
- [[target|Display Text]] - filename with display text
Path-based links (containing '/') are NOT supported to ensure all
filenames are unique and links work correctly in obsidian.nvim.
Usage: mise run docs-check-links
"""
import re
import sys
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
# Regex to match wiki-links: [[Target]] or [[Target|Display]]
# Captures: group(1) = target (may have spaces), group(2) = full "|Display" part if present
WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]")
# Regex to match inline code (backticks)
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
def extract_wikilinks(file_path: Path) -> list[tuple[str, int, bool]]:
"""Extract all wiki-link targets from a markdown file with line numbers.
Returns list of (target, line_num, has_spaces) tuples.
has_spaces is True if the target or pipe separator had surrounding spaces.
Ignores wiki-links inside inline code (backticks) as these are examples.
"""
content = file_path.read_text()
links = []
for line_num, line in enumerate(content.splitlines(), start=1):
# Remove inline code before searching for wiki-links
line_without_code = INLINE_CODE_PATTERN.sub("", line)
for match in WIKILINK_PATTERN.finditer(line_without_code):
raw_target = match.group(1)
target = raw_target.strip()
pipe_part = match.group(2) # "|Display" or None
# Check for spaces: in target, or around the pipe
has_spaces = raw_target != target
if pipe_part and (raw_target.endswith(" ") or pipe_part.startswith("| ")):
has_spaces = True
links.append((target, line_num, has_spaces))
return links
def main() -> int:
console = Console()
# Collect all valid targets (both filenames and paths)
valid_targets: set[str] = set()
# Track which filenames are ambiguous (appear multiple times)
filename_counts: dict[str, list[str]] = {}
# Scan all markdown files (excluding changelog.d/)
for md_file in DOCS_DIR.rglob("*.md"):
if "changelog.d" in md_file.parts:
continue
# Track filename occurrences
filename = md_file.stem
rel_path_str = str(md_file.relative_to(DOCS_DIR).with_suffix(""))
if filename not in filename_counts:
filename_counts[filename] = []
filename_counts[filename].append(rel_path_str)
# Add relative path without extension (e.g., "reference/services/alloy")
valid_targets.add(rel_path_str)
# Only add filenames that are unique (not ambiguous)
ambiguous_filenames: set[str] = set()
for filename, paths in filename_counts.items():
if len(paths) == 1:
valid_targets.add(filename)
else:
ambiguous_filenames.add(filename)
# Special case: files at repo root that are copied into docs during build
# These are valid link targets even though they don't exist in docs/
REPO_ROOT = DOCS_DIR.parent
BUILD_TIME_DOCS = ["CHANGELOG.md"]
for filename in BUILD_TIME_DOCS:
if (REPO_ROOT / filename).exists():
valid_targets.add(Path(filename).stem)
# Collect all broken, ambiguous, path-based, and spaced links
broken_links: list[tuple[str, int, str]] = []
ambiguous_links: list[tuple[str, int, str, list[str]]] = []
path_links: list[tuple[str, int, str]] = []
spaced_links: list[tuple[str, int, str]] = []
# Track which doc stems are linked-to from other docs (for orphan detection)
all_doc_stems: set[str] = set(filename_counts.keys())
linked_stems: set[str] = set()
# Scan all markdown files for wiki-links (excluding changelog.d/)
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
rel_path = str(md_file.relative_to(DOCS_DIR))
source_stem = md_file.stem
links = extract_wikilinks(md_file)
for target, line_num, has_spaces in links:
if has_spaces:
# Links with spaces in target or around pipe are not allowed
spaced_links.append((rel_path, line_num, target))
continue
# Handle anchor links: [[#Heading]] or [[file#Heading]]
# Strip the #fragment for validation; pure anchors (#Heading) skip file check
file_target = target
if "#" in target:
file_target = target.split("#", 1)[0]
if not file_target:
# Pure in-page anchor like [[#Break-glass shutoff]] — always valid
continue
if "/" in file_target:
# Path-based links are not allowed - use simple filenames only
path_links.append((rel_path, line_num, target))
elif file_target in ambiguous_filenames:
# Link uses an ambiguous filename - needs to be renamed
ambiguous_links.append((rel_path, line_num, target, filename_counts[file_target]))
elif file_target not in valid_targets:
broken_links.append((rel_path, line_num, target))
elif file_target != source_stem:
# Valid link to a different doc — record it for orphan detection
linked_stems.add(file_target)
# Print results
console.print("[bold]Wiki-Link Validation[/bold]")
console.print()
console.print(f"Found {len(valid_targets)} valid link targets in documentation.")
console.print()
has_errors = False
if spaced_links:
has_errors = True
console.print("[bold red]Wiki-Links With Spaces Found[/bold red]")
console.print("Wiki-links must not have spaces in the target or around the pipe.")
console.print("Use [[target|Display Text]] not [[target | Display Text]].")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Line", justify="right")
table.add_column("Target")
for file_path, line_num, target in spaced_links:
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
console.print(table)
console.print()
if path_links:
has_errors = True
console.print("[bold red]Path-Based Wiki-Links Found[/bold red]")
console.print("Wiki-links must use simple filenames only (no '/' paths).")
console.print("Rename files to be unique, then use [[filename]] format.")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Line", justify="right")
table.add_column("Target")
for file_path, line_num, target in path_links:
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
console.print(table)
console.print()
if ambiguous_links:
has_errors = True
console.print("[bold red]Ambiguous Wiki-Links Found[/bold red]")
console.print("These links use filenames that exist in multiple locations.")
console.print("Rename files to be unique across all documentation.")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Line", justify="right")
table.add_column("Target")
table.add_column("Possible Paths")
for file_path, line_num, target, paths in ambiguous_links:
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"), "\n".join(paths))
console.print(table)
console.print()
if broken_links:
has_errors = True
console.print("[bold red]Broken Wiki-Links Found[/bold red]")
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Line", justify="right")
table.add_column("Target")
for file_path, line_num, target in broken_links:
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
console.print(table)
console.print()
console.print("Each wiki-link target must match a filename or path in docs/.")
console.print()
# Orphan detection: docs not linked from any other doc
ORPHAN_EXCEPTIONS = {"index"}
orphan_stems = sorted(all_doc_stems - linked_stems - ORPHAN_EXCEPTIONS)
if orphan_stems:
has_errors = True
console.print("[bold red]Orphan Documents Found[/bold red]")
console.print("These docs are not linked from any other document.")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Stem")
for stem in orphan_stems:
paths = filename_counts[stem]
for path in paths:
table.add_row(f"{path}.md", stem)
console.print(table)
console.print()
if has_errors:
return 1
console.print("[bold green]All wiki-links are valid![/bold green]")
return 0
if __name__ == "__main__":
sys.exit(main())

655
mise-tasks/docs-mikado Executable file
View file

@ -0,0 +1,655 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28.0", "pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"]
# ///
#MISE description="View active Mikado dependency chains for C2 changes"
#USAGE arg "[card]" help="Card stem to show chain for"
#USAGE flag "--all" help="Show all cards in full, including complete ones"
#USAGE flag "--resume" help="Resume a chain: detect branch, show state and next steps"
"""View active Mikado dependency chains for C2 changes.
Scans all markdown files in docs/ for YAML frontmatter with ``status: active``
and ``requires`` fields, then builds and displays the Mikado dependency graph.
Usage:
mise run docs-mikado # list all active chains
mise run docs-mikado deploy-service # show chain for a card
mise run docs-mikado deploy-service --all # include complete cards in full
mise run docs-mikado --resume # resume: detect branch, show state
mise run docs-mikado --resume deploy-service # resume specific chain
"""
import re
import subprocess
import sys
from pathlib import Path
from typing import Annotated
import typer
import yaml
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
REPO_DIR = Path(__file__).parent.parent
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
FORGE_API = "https://forge.eblu.me/api/v1"
FORGE_REPO = "eblume/hephaestus"
def extract_frontmatter(file_path: Path) -> dict | None:
"""Extract YAML frontmatter from a markdown file."""
content = file_path.read_text()
if not content.startswith("---"):
return None
end_idx = content.find("---", 3)
if end_idx == -1:
return None
frontmatter_text = content[3:end_idx].strip()
try:
return yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return None
def build_graph() -> dict[str, dict]:
"""Build the dependency graph from all docs."""
cards: dict[str, dict] = {}
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
frontmatter = extract_frontmatter(md_file)
if frontmatter is None:
continue
stem = md_file.stem
cards[stem] = {
"path": md_file,
"title": frontmatter.get("title", stem),
"status": frontmatter.get("status"),
"branch": frontmatter.get("branch"),
"requires": frontmatter.get("requires", []) or [],
"required_by": [],
}
# Compute inverse relationships
for stem, card in cards.items():
for req in card["requires"]:
if req in cards:
cards[req]["required_by"].append(stem)
return cards
def detect_cycles(cards: dict[str, dict]) -> list[list[str]]:
"""Detect circular dependencies in the graph. Returns list of cycles."""
cycles: list[list[str]] = []
visited: set[str] = set()
on_stack: set[str] = set()
def dfs(stem: str, path: list[str]) -> None:
if stem in on_stack:
cycle_start = path.index(stem)
cycles.append(path[cycle_start:] + [stem])
return
if stem in visited or stem not in cards:
return
visited.add(stem)
on_stack.add(stem)
path.append(stem)
for req in cards[stem]["requires"]:
dfs(req, path)
path.pop()
on_stack.discard(stem)
for stem in cards:
dfs(stem, [])
return cycles
def is_active(card: dict) -> bool:
"""Check if a card has status: active."""
return card.get("status") == "active"
def find_root_goals(cards: dict[str, dict]) -> list[str]:
"""Find active cards that aren't required by another active card."""
roots = []
for stem, card in cards.items():
if not is_active(card):
continue
# A root goal is not required by any other active card
has_active_parent = any(
is_active(cards[rb]) for rb in card["required_by"] if rb in cards
)
if not has_active_parent:
roots.append(stem)
return sorted(roots)
def find_ready_leaves(cards: dict[str, dict], root: str) -> list[str]:
"""Find active leaf nodes ready for work (no unmet active requires)."""
leaves = []
visited: set[str] = set()
def walk(stem: str) -> None:
if stem in visited or stem not in cards:
return
visited.add(stem)
card = cards[stem]
if not is_active(card):
return
# Check if all requires are met (not active)
unmet = [
r for r in card["requires"] if r in cards and is_active(cards[r])
]
if not unmet:
leaves.append(stem)
for req in card["requires"]:
walk(req)
walk(root)
return sorted(leaves)
def get_current_branch() -> str | None:
"""Get the current git branch name."""
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode == 0:
branch = result.stdout.strip()
return branch if branch != "HEAD" else None
return None
except FileNotFoundError:
return None
def get_branch_commits(branch: str) -> list[dict]:
"""Get commits on a branch since it diverged from main."""
try:
result = subprocess.run(
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
sha, _, subject = line.partition(" ")
match = C2_COMMIT_RE.match(subject)
commits.append(
{
"sha": sha,
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
)
# git log returns newest first; reverse for chronological
commits.reverse()
return commits
except FileNotFoundError:
return []
def classify_branch_position(commits: list[dict]) -> str:
"""Determine where in the invariant the branch currently is."""
if not commits:
return "empty"
verbs = [c["verb"] for c in commits if c["conventional"]]
if not verbs:
return "unconventional"
last_verb = verbs[-1]
has_impl = "impl" in verbs
has_close = "close" in verbs
if last_verb == "finalize":
return "finalized"
if not has_impl and not has_close:
return "planning"
if last_verb == "close":
return "between-cycles"
if last_verb == "impl":
return "mid-cycle"
if last_verb == "plan" and has_impl:
# plan after impl — invariant violation
return "invariant-violation"
return "unknown"
def find_pr_for_branch(branch: str) -> dict | None:
"""Find an open PR for the given branch via the Forgejo API."""
import httpx
try:
resp = httpx.get(
f"{FORGE_API}/repos/{FORGE_REPO}/pulls",
params={"state": "open", "limit": 50},
timeout=10,
)
if resp.status_code != 200:
return None
for pr in resp.json():
if pr.get("head", {}).get("ref") == branch:
return {
"number": pr["number"],
"title": pr["title"],
"url": pr.get("html_url", ""),
}
except (httpx.HTTPError, KeyError):
pass
return None
def get_stash_list() -> list[str]:
"""Get the list of git stash entries."""
try:
result = subprocess.run(
["git", "stash", "list"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")
except FileNotFoundError:
pass
return []
def show_resume(
cards: dict[str, dict],
console: Console,
chain_name: str | None,
) -> None:
"""Show resume information for continuing C2 work."""
current_branch = get_current_branch()
roots = find_root_goals(cards)
# Find chain-to-branch mapping from goal cards
chain_branches: dict[str, str | None] = {}
for stem in roots:
card = cards[stem]
chain_branches[stem] = card.get("branch")
if chain_name:
# Explicit chain requested — validate
if chain_name not in cards:
console.print(f"[red]Chain not found: {chain_name}[/red]")
raise typer.Exit(code=1)
if not is_active(cards[chain_name]):
console.print(
f"[yellow]{chain_name} is not active (no status: active)[/yellow]"
)
raise typer.Exit(code=1)
expected_branch = cards[chain_name].get("branch")
if expected_branch and current_branch != expected_branch:
console.print(
f"[red]Branch mismatch:[/red] chain {chain_name} expects "
f"branch [bold]{expected_branch}[/bold] but you are on "
f"[bold]{current_branch}[/bold]"
)
console.print(
f"\n[dim]Run: git checkout {expected_branch}[/dim]"
)
raise typer.Exit(code=1)
_show_chain_resume(cards, console, chain_name, current_branch)
return
# No explicit chain — try to detect from branch
if current_branch and current_branch.startswith("mikado/"):
chain_stem = current_branch.removeprefix("mikado/")
# Try to match to a goal card
matched = None
for stem in roots:
if stem == chain_stem:
matched = stem
break
if cards[stem].get("branch") == current_branch:
matched = stem
break
if matched:
_show_chain_resume(cards, console, matched, current_branch)
return
else:
console.print(
f"[yellow]On branch {current_branch} but no matching "
f"active chain found for stem '{chain_stem}'[/yellow]"
)
# On main or unrecognized branch — list options
console.print()
if not roots:
console.print("[dim]No active Mikado chains to resume.[/dim]")
raise typer.Exit()
console.print("[bold]Active Mikado chains:[/bold]")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("Chain")
table.add_column("Title")
table.add_column("Branch")
table.add_column("Status")
table.add_column("Ready Leaves")
for stem in roots:
card = cards[stem]
branch = card.get("branch")
if branch:
branch_display = f"[green]{branch}[/green]"
status = "in progress"
else:
branch_display = "[dim]not started[/dim]"
status = "planned"
leaves = find_ready_leaves(cards, stem)
leaves_display = ", ".join(leaves) if leaves else "[dim]none[/dim]"
table.add_row(stem, card["title"], branch_display, status, leaves_display)
console.print(table)
console.print()
console.print(
"[dim]Run: mise run docs-mikado --resume <chain> to resume a specific chain[/dim]"
)
def _show_chain_resume(
cards: dict[str, dict],
console: Console,
chain: str,
current_branch: str | None,
) -> None:
"""Show detailed resume info for a specific chain."""
card = cards[chain]
branch = card.get("branch") or current_branch
console.print()
# Look up PR for this branch
pr_info = find_pr_for_branch(branch) if branch else None
panel_lines = [
f"[bold]{chain}[/bold] — {card['title']}",
f"Branch: [green]{branch or 'not set'}[/green]",
]
if pr_info:
panel_lines.append(
f"PR: [cyan]#{pr_info['number']}[/cyan] — {pr_info['title']}"
)
if pr_info["url"]:
panel_lines.append(f" {pr_info['url']}")
console.print(Panel("\n".join(panel_lines), title="Resuming Mikado Chain"))
if pr_info:
console.print(
f"[dim]Check PR comments: mise run pr-comments {pr_info['number']}[/dim]"
)
# Show branch position if we can read commits
if branch and current_branch == branch:
commits = get_branch_commits(branch)
position = classify_branch_position(commits)
position_labels = {
"empty": "[dim]No commits on branch yet[/dim]",
"planning": "[cyan]Planning phase[/cyan] — still adding cards",
"mid-cycle": "[yellow]Mid-cycle[/yellow] — impl in progress, leaf not yet closed",
"between-cycles": "[green]Between cycles[/green] — ready for next leaf",
"finalized": "[green]Finalized[/green] — chain complete, awaiting merge",
"unconventional": "[yellow]Unconventional commits[/yellow] — commits don't follow C2() convention",
"invariant-violation": "[red]Invariant violation[/red] — plan commit found after impl",
"unknown": "[dim]Unknown position[/dim]",
}
console.print(f"\nBranch position: {position_labels.get(position, position)}")
if commits:
console.print("\n[bold]Recent commits:[/bold]")
# Show last 10 commits
for c in commits[-10:]:
if c["conventional"]:
verb_colors = {
"plan": "cyan",
"impl": "yellow",
"close": "green",
"finalize": "magenta",
}
color = verb_colors.get(c["verb"], "white")
console.print(
f" {c['sha'][:8]} [{color}]{c['verb']}[/{color}] {c['description']}"
)
else:
console.print(
f" {c['sha'][:8]} [dim]{c['subject']}[/dim]"
)
# Show ready leaves
leaves = find_ready_leaves(cards, chain)
if leaves:
console.print("\n[bold]Ready leaf nodes (no unmet dependencies):[/bold]")
for leaf in leaves:
leaf_card = cards[leaf]
console.print(f" [green]→[/green] {leaf} — {leaf_card['title']}")
else:
console.print("\n[dim]No ready leaf nodes — all leaves have unmet dependencies[/dim]")
# Check for stashed work
stashes = get_stash_list()
if stashes:
console.print(
f"\n[yellow]Note: {len(stashes)} stash entr{'y' if len(stashes) == 1 else 'ies'} found:[/yellow]"
)
for entry in stashes[:5]:
console.print(f" [dim]{entry}[/dim]")
if len(stashes) > 5:
console.print(f" [dim]... and {len(stashes) - 5} more[/dim]")
console.print(
"[dim]Review with: git stash list / git stash show[/dim]"
)
console.print()
def walk_chain(
cards: dict[str, dict],
stem: str,
console: Console,
show_all: bool,
visited: set[str] | None = None,
depth: int = 0,
) -> None:
"""Walk the dependency tree depth-first from a card."""
if visited is None:
visited = set()
if stem in visited:
card = cards.get(stem)
if card:
status = "[active]" if is_active(card) else "[complete]"
console.print(
f"{' ' * depth}[dim]↑ {stem} — {card['title']} {status}"
f" (shared dep, shown above)[/dim]"
)
else:
console.print(f"{' ' * depth}[red](missing: {stem})[/red]")
return
visited.add(stem)
card = cards.get(stem)
if card is None:
console.print(f"{' ' * depth}[red](missing: {stem})[/red]")
return
active = is_active(card)
if active or show_all:
# Print full card content
console.print()
separator = "=" * 72
console.print(f"[bold cyan]{separator}[/bold cyan]")
status_tag = "[active]" if active else "[complete]"
console.print(
f"[bold]{status_tag} {stem}[/bold] — {card['title']}"
)
console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]")
if card.get("branch"):
console.print(f"[dim]branch: {card['branch']}[/dim]")
if card["requires"]:
console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]")
if card["required_by"]:
console.print(
f"[dim]required-by: {', '.join(card['required_by'])}[/dim]"
)
console.print(f"[bold cyan]{separator}[/bold cyan]")
console.print()
content = card["path"].read_text()
console.print(content)
else:
# One-line summary for complete cards
console.print(
f"{' ' * depth}[green][complete][/green] {stem} — {card['title']}"
)
# Recurse into dependencies
for req in card["requires"]:
walk_chain(cards, req, console, show_all, visited, depth + 1)
def main(
card: Annotated[
str | None, typer.Argument(help="Card stem to show chain for")
] = None,
all: Annotated[
bool,
typer.Option("--all", help="Show all cards in full, including complete ones"),
] = False,
resume: Annotated[
bool,
typer.Option("--resume", help="Resume a chain: detect branch, show state"),
] = False,
) -> None:
console = Console()
cards = build_graph()
# Check for circular dependencies
cycles = detect_cycles(cards)
if cycles:
console.print("[bold red]Circular dependencies detected![/bold red]")
for cycle in cycles:
console.print(f" [red]{' → '.join(cycle)}[/red]")
console.print()
if resume:
show_resume(cards, console, chain_name=card)
return
if card is None:
# List all active Mikado chains
roots = find_root_goals(cards)
if not roots:
console.print("[dim]No active Mikado chains found.[/dim]")
console.print(
"[dim]Cards need status: active in frontmatter to appear here.[/dim]"
)
raise typer.Exit()
table = Table(
title="Active Mikado Chains", show_header=True, header_style="bold"
)
table.add_column("Goal Card")
table.add_column("Title")
table.add_column("Branch")
table.add_column("Active Deps", justify="right")
table.add_column("Total Deps", justify="right")
for stem in roots:
card_data = cards[stem]
# Count dependencies
def count_deps(s: str, seen: set[str] | None = None) -> tuple[int, int]:
if seen is None:
seen = set()
if s in seen:
return 0, 0
seen.add(s)
active_count = 0
total_count = 0
c = cards.get(s)
if c is None:
return 0, 0
for req in c["requires"]:
total_count += 1
req_card = cards.get(req)
if req_card and is_active(req_card):
active_count += 1
sub_active, sub_total = count_deps(req, seen)
active_count += sub_active
total_count += sub_total
return active_count, total_count
active_deps, total_deps = count_deps(stem)
branch = card_data.get("branch")
if branch:
branch_display = branch
else:
branch_display = "[dim]not started[/dim]"
table.add_row(
f"[bold]{stem}[/bold]",
card_data["title"],
branch_display,
str(active_deps),
str(total_deps),
)
console.print()
console.print(table)
console.print()
console.print(
"[dim]Run: mise run docs-mikado <card> to see full chain[/dim]"
)
console.print(
"[dim]Run: mise run docs-mikado --resume to resume work on a chain[/dim]"
)
else:
if card not in cards:
console.print(f"[red]Card not found: {card}[/red]")
console.print("[dim]Available cards with status: active:[/dim]")
for stem, data in sorted(cards.items()):
if is_active(data):
console.print(f" {stem} — {data['title']}")
raise typer.Exit(code=1)
walk_chain(cards, card, console, show_all=all)
if __name__ == "__main__":
typer.run(main)

83
mise-tasks/docs-preview Executable file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0", "typer>=0.24.0"]
# ///
#MISE description="Serve a docs release tarball locally in a browser"
#USAGE arg "<tarball>" help="Path to docs tarball (e.g. ~/Downloads/docs-v0.0.2.tar.gz)"
#USAGE flag "--port <port>" default="8484" help="Port for preview server (default 8484)"
"""Extract a docs release tarball and serve it locally.
Downloads the docs tarball from a Forgejo release page manually, then
point this task at it to preview the site in your browser.
Usage:
mise run docs-preview ~/Downloads/docs-v0.0.2.tar.gz
mise run docs-preview ~/Downloads/docs-v0.0.2.tar.gz --port 9090
"""
import http.server
import tarfile
import tempfile
import threading
import webbrowser
from pathlib import Path
from typing import Annotated
import typer
from rich.console import Console
console = Console()
def main(
tarball: Annotated[Path, typer.Argument(help="Path to docs tarball")],
port: Annotated[int, typer.Option(help="Port for preview server")] = 8484,
) -> None:
tarball = tarball.expanduser().resolve()
if not tarball.exists():
console.print(f"[bold red]File not found:[/bold red] {tarball}")
raise typer.Exit(code=1)
docroot = Path(tempfile.mkdtemp(prefix="docs-preview-"))
console.print(f"[dim]Extracting {tarball.name} to {docroot}...[/dim]")
with tarfile.open(tarball, "r:gz") as tf:
tf.extractall(docroot, filter="data")
url = f"http://localhost:{port}"
console.print(f"\n[bold green]Serving docs at {url}[/bold green]")
console.print(f"[yellow]Press Ctrl+C to stop.[/yellow]\n")
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
class QuartzHandler(http.server.SimpleHTTPRequestHandler):
"""Handler that resolves clean URLs to index.html files."""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(docroot), **kwargs)
def do_GET(self):
# Quartz outputs foo.html, not foo/index.html, so clean URLs
# like /tutorials/tutorials need to resolve to tutorials.html
path = self.path.split("?")[0].split("#")[0]
file = docroot / path.lstrip("/")
if not file.suffix and not file.is_file():
html_candidate = file.with_suffix(".html")
if html_candidate.is_file():
self.path = path + ".html"
elif (file / "index.html").is_file():
self.path = path.rstrip("/") + "/index.html"
super().do_GET()
handler = QuartzHandler
server = http.server.HTTPServer(("localhost", port), handler)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
console.print("\n[dim]Stopped.[/dim]")
if __name__ == "__main__":
typer.run(main)

View file

@ -0,0 +1,301 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Validate Mikado Branch Invariant on mikado/* branches"
"""Validate the Mikado Branch Invariant for C2 change branches.
Runs as a commit-msg hook on mikado/* branches. Receives the commit message
file as its first argument, classifies the incoming commit, appends it to the
existing branch history, and validates the full sequence.
Checks:
1. All commits follow the C2(<chain>): <verb> <description> convention
2. The invariant ordering is maintained: plan commits come before impl/close
3. No plan commits appear after any impl or close commits
4. Close commits don't appear before impl commits in the same cycle
5. The chain stem in commit messages matches the branch name
6. impl commits don't modify Mikado card files (docs with mikado frontmatter)
Can also be run standalone (no arguments) to validate existing branch history.
Exit code 0 if valid (or not on a mikado/* branch), 1 if violations found.
"""
import re
import subprocess
import sys
from pathlib import Path
from rich.console import Console
REPO_DIR = Path(__file__).parent.parent
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
def get_current_branch() -> str | None:
"""Get the current git branch name."""
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode == 0:
branch = result.stdout.strip()
return branch if branch != "HEAD" else None
return None
def get_branch_commits(branch: str) -> list[dict]:
"""Get commits on a branch since it diverged from main."""
result = subprocess.run(
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
sha, _, subject = line.partition(" ")
match = C2_COMMIT_RE.match(subject)
commits.append(
{
"sha": sha,
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
)
# git log returns newest first; reverse for chronological
commits.reverse()
return commits
def parse_commit_message(msg_path: str) -> str:
"""Read and return the first line of a commit message file."""
with open(msg_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
return line
return ""
def make_pending_commit(subject: str) -> dict:
"""Create a pseudo-commit dict for the incoming (not yet created) commit."""
match = C2_COMMIT_RE.match(subject)
return {
"sha": "(pending)",
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
def is_mikado_card(content: str) -> bool:
"""Check if file content has Mikado card frontmatter.
A file is a Mikado card if its YAML frontmatter contains any of:
- requires: (dependency list, kept permanently on cards)
- status: (e.g. 'active' for in-progress cards)
- branch: mikado/... (goal cards linking to their branch)
"""
match = FRONTMATTER_RE.match(content)
if not match:
return False
for line in match.group(1).split("\n"):
stripped = line.strip()
if stripped.startswith("requires:"):
return True
if stripped.startswith("status:"):
return True
if stripped.startswith("branch:") and "mikado/" in stripped:
return True
return False
def get_file_at_ref(ref: str, path: str) -> str | None:
"""Get file content at a git ref. Use ':path' for staged, 'sha:path' for commits."""
result = subprocess.run(
["git", "show", f"{ref}:{path}"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
return result.stdout if result.returncode == 0 else None
def get_staged_md_files() -> list[str]:
"""Get markdown files staged for commit (added, copied, modified, renamed)."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")]
def get_commit_md_files(sha: str) -> list[str]:
"""Get markdown files changed in a specific commit."""
result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")]
def check_impl_files_staged() -> list[str]:
"""Check that staged files for a pending impl commit don't include Mikado cards."""
errors = []
for path in get_staged_md_files():
# Check staged version; fall back to HEAD for deletions
content = get_file_at_ref("", path)
if content is None:
content = get_file_at_ref("HEAD", path)
if content and is_mikado_card(content):
errors.append(
f"(pending): impl commit modifies Mikado card: {path} — "
f"use 'plan' for card changes or 'close' to close leaf nodes"
)
return errors
def check_impl_files_historical(sha: str) -> list[str]:
"""Check that a historical impl commit didn't modify Mikado cards."""
errors = []
for path in get_commit_md_files(sha):
# Try file at this commit; fall back to parent for deletions
content = get_file_at_ref(sha, path)
if content is None:
content = get_file_at_ref(f"{sha}~1", path)
if content and is_mikado_card(content):
errors.append(
f"{sha[:8]}: impl commit modifies Mikado card: {path} — "
f"use 'plan' for card changes or 'close' to close leaf nodes"
)
return errors
def check_invariant(commits: list[dict], chain_stem: str) -> list[str]:
"""Check the Mikado Branch Invariant. Returns list of violation messages."""
errors: list[str] = []
if not commits:
return errors
seen_impl = False
seen_close = False
for i, commit in enumerate(commits):
sha_short = commit["sha"][:8]
if not commit["conventional"]:
errors.append(
f"{sha_short}: commit doesn't follow C2() convention: "
f"{commit['subject']}"
)
continue
if commit["chain"] != chain_stem:
errors.append(
f"{sha_short}: chain mismatch — expected C2({chain_stem}) "
f"but found C2({commit['chain']})"
)
verb = commit["verb"]
if verb == "plan":
if seen_impl or seen_close:
errors.append(
f"{sha_short}: plan commit after impl/close — "
f"violates the Mikado Branch Invariant. "
f"New prerequisites require a branch reset."
)
elif verb == "impl":
seen_impl = True
elif verb == "close":
seen_close = True
if not seen_impl:
errors.append(
f"{sha_short}: close commit without preceding impl — "
f"leaf nodes should be closed after implementation work"
)
elif verb == "finalize":
# finalize is the permitted exception — must be last
if i != len(commits) - 1:
errors.append(
f"{sha_short}: finalize must be the last commit on the branch"
)
return errors
def main() -> None:
console = Console(stderr=True)
branch = get_current_branch()
if not branch or not branch.startswith("mikado/"):
# Not on a mikado branch — nothing to check
sys.exit(0)
chain_stem = branch.removeprefix("mikado/")
commits = get_branch_commits(branch)
# If called with a commit message file (commit-msg hook), include the
# pending commit in the validation
if len(sys.argv) > 1:
subject = parse_commit_message(sys.argv[1])
if subject:
commits.append(make_pending_commit(subject))
if not commits:
# No commits on branch yet — valid (length-zero case)
sys.exit(0)
errors = check_invariant(commits, chain_stem)
# Check file discipline: impl commits must not touch Mikado cards
for commit in commits:
if commit["verb"] != "impl":
continue
if commit["sha"] == "(pending)":
# Pending commit — check staged files
errors.extend(check_impl_files_staged())
else:
# Historical commit — check files in that commit
errors.extend(check_impl_files_historical(commit["sha"]))
if errors:
console.print("[bold red]Mikado Branch Invariant violations:[/bold red]")
for error in errors:
console.print(f" [red]✗[/red] {error}")
console.print()
console.print(
"[dim]See: docs/how-to/agent-change-process.md "
"§ The Mikado Branch Invariant[/dim]"
)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

118
mise-tasks/pr-comments Executable file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28.0", "rich>=13.0.0"]
# ///
#MISE description="List unresolved comments on a PR"
#USAGE arg "<pr_number>" help="Pull request number"
"""Fetch and display unresolved review comments on a pull request.
This script queries the Forge (Gitea) API to find all review comments that
have not been resolved on a given PR. A comment is considered unresolved
if its 'resolver' field is null.
Usage: mise run pr-comments <pr_number>
"""
import sys
import httpx
from rich.console import Console
from rich.text import Text
FORGE_API_BASE = "https://forge.eblu.me/api/v1"
REPO_OWNER = "eblume"
REPO_NAME = "hephaestus"
def get_reviews(client: httpx.Client, pr_number: int) -> list[dict]:
"""Get all reviews for a pull request."""
response = client.get(
f"{FORGE_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}/reviews"
)
response.raise_for_status()
return response.json()
def get_review_comments(client: httpx.Client, pr_number: int, review_id: int) -> list[dict]:
"""Get all comments for a specific review."""
response = client.get(
f"{FORGE_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}/reviews/{review_id}/comments"
)
response.raise_for_status()
return response.json()
def main() -> int:
console = Console()
if len(sys.argv) < 2:
console.print("[red]Error:[/red] Please provide a PR number")
console.print("Usage: mise run pr-comments <pr_number>")
return 1
try:
pr_number = int(sys.argv[1])
except ValueError:
console.print(f"[red]Error:[/red] '{sys.argv[1]}' is not a valid PR number")
return 1
unresolved_comments: list[tuple[dict, dict]] = [] # (review, comment) pairs
with httpx.Client() as client:
# Get all reviews
try:
reviews = get_reviews(client, pr_number)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
console.print(f"[red]Error:[/red] PR #{pr_number} not found")
else:
console.print(f"[red]Error:[/red] API request failed: {e}")
return 1
# For each review, get comments and filter to unresolved
for review in reviews:
try:
comments = get_review_comments(client, pr_number, review["id"])
except httpx.HTTPStatusError:
continue # Skip reviews we can't fetch comments for
for comment in comments:
if comment.get("resolver") is None:
unresolved_comments.append((review, comment))
if not unresolved_comments:
console.print(f"[green]No unresolved comments on PR #{pr_number}[/green]")
return 0
# Display unresolved comments
console.print(f"[bold]Unresolved Comments on PR #{pr_number}[/bold] ({len(unresolved_comments)} comments)")
console.print("=" * 60)
console.print()
for review, comment in unresolved_comments:
# Header with file path and reviewer
header = Text()
path = comment.get("path", "unknown file")
reviewer = review.get("user", {}).get("login", "unknown")
header.append(f"{path}", style="bold cyan")
header.append(f" (by {reviewer})")
console.print(header)
# Comment body
body = comment.get("body", "").strip()
for line in body.split("\n"):
console.print(f" {line}")
# Link to comment
html_url = comment.get("html_url", "")
if html_url:
console.print(f" [dim]{html_url}[/dim]")
console.print()
return 0
if __name__ == "__main__":
sys.exit(main())

283
mise-tasks/runner-logs Executable file
View file

@ -0,0 +1,283 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"]
# ///
#MISE description="List recent Forgejo Actions runs or fetch logs for a specific job"
#USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)"
#USAGE flag "--job -j <job>" help="Job index (0-based) to fetch logs for"
#USAGE flag "--repo <repo>" help="Forge repo (owner/name), default: detected from git remote"
#USAGE flag "--limit -n <limit>" help="Max runs to display (0 for all)"
#USAGE flag "--token <token>" help="Forgejo API token (default: read from 1Password)"
"""List recent Forgejo Actions runs and fetch job logs.
Usage:
mise run runner-logs # list recent runs (default 15)
mise run runner-logs -n 0 # list ALL runs
mise run runner-logs --repo eblume/hermes # list runs for a different repo
mise run runner-logs 474 # show jobs in run 474
mise run runner-logs 474 -j 1 # fetch logs for job 1 of run 474
"""
import os
import re
import subprocess
import sys
from typing import Annotated
import httpx
import typer
from rich.console import Console
from rich.table import Table
FORGE_URL = "https://forge.ops.eblu.me"
FORGE_API = f"{FORGE_URL}/api/v1"
OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token"
app = typer.Typer(add_completion=False)
def resolve_token(explicit_token: str | None, console: Console) -> str:
"""Resolve Forgejo API token: explicit flag > FORGEJO_TOKEN env > 1Password."""
if explicit_token:
return explicit_token
env_token = os.environ.get("FORGEJO_TOKEN", "").strip()
if env_token:
return env_token
console.print("[dim]Reading Forgejo API token from 1Password...[/dim]")
result = subprocess.run(
["op", "read", OP_TOKEN_REF],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def detect_repo_from_git() -> str | None:
"""Sniff owner/repo from the git remote 'origin' URL."""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return None
url = result.stdout.strip()
# Match SSH (git@host:owner/repo.git) or HTTPS (https://host/owner/repo.git)
m = re.search(r"[/:]([^/]+/[^/]+?)(?:\.git)?$", url)
if not m:
return None
candidate = m.group(1)
# Only use it if the remote points at our forge
if "forge.ops.eblu.me" in url or "forge.eblu.me" in url:
return candidate
return None
def auth_headers(token: str) -> dict[str, str]:
return {"Authorization": f"token {token}"}
def fetch_tasks(repo: str, token: str) -> list[dict]:
"""Fetch all tasks from the Forgejo API, paginating if needed."""
tasks: list[dict] = []
page = 1
while True:
resp = httpx.get(
f"{FORGE_API}/repos/{repo}/actions/tasks",
params={"page": page, "limit": 50},
headers=auth_headers(token),
timeout=15,
)
resp.raise_for_status()
batch = resp.json().get("workflow_runs", [])
if not batch:
break
tasks.extend(batch)
page += 1
return tasks
def list_runs(repo: str, limit: int, token: str, console: Console) -> None:
"""List recent workflow runs, grouped by run number."""
tasks = fetch_tasks(repo, token)
# Group tasks by run_number
runs: dict[int, list[dict]] = {}
for t in tasks:
rn = t["run_number"]
runs.setdefault(rn, []).append(t)
table = Table(title=f"Recent runs — {repo}")
table.add_column("Run #", style="cyan", no_wrap=True)
table.add_column("Status")
table.add_column("Jobs")
table.add_column("Title")
table.add_column("Event")
shown = 0
for rn in sorted(runs, reverse=True):
if limit > 0 and shown >= limit:
break
jobs = sorted(runs[rn], key=lambda x: x["id"])
# Aggregate status: worst status wins
statuses = [j.get("status", "") for j in jobs]
if "failure" in statuses:
status, style = "failure", "red"
elif "running" in statuses or "waiting" in statuses:
status, style = "running", "yellow"
elif all(s == "success" for s in statuses):
status, style = "success", "green"
else:
status, style = statuses[0], "yellow"
job_names = ", ".join(j.get("name", "?")[:30] for j in jobs)
title = (jobs[0].get("display_title") or "")[:40]
event = jobs[0].get("event", "")
table.add_row(
str(rn),
f"[{style}]{status}[/{style}]",
job_names,
title,
event,
)
shown += 1
console.print(table)
console.print("\n[dim]Use: mise run runner-logs <run#> to see jobs in a run[/dim]")
console.print("[dim] mise run runner-logs <run#> -j N to fetch logs for job N[/dim]")
def show_jobs(run_number: int, repo: str, token: str, console: Console) -> None:
"""Show the jobs within a specific run."""
tasks = fetch_tasks(repo, token)
jobs = sorted(
[t for t in tasks if t["run_number"] == run_number],
key=lambda x: x["id"],
)
if not jobs:
typer.echo(f"Error: No jobs found for run #{run_number}", err=True)
raise typer.Exit(1)
table = Table(title=f"Jobs in run #{run_number} — {repo}")
table.add_column("Job #", style="cyan", no_wrap=True)
table.add_column("Status")
table.add_column("Name")
table.add_column("Created")
for i, job in enumerate(jobs):
status = job.get("status", "")
style = "green" if status == "success" else "red" if status == "failure" else "yellow"
table.add_row(
str(i),
f"[{style}]{status}[/{style}]",
job.get("name", ""),
job.get("created_at", ""),
)
console.print(table)
console.print(f"\n[dim]Use: mise run runner-logs {run_number} -j N to fetch logs for job N[/dim]")
def fetch_log(run_number: int, job_index: int, repo: str, token: str) -> None:
"""Fetch logs for a specific job via SSH to indri.
Forgejo stores action logs as zstd-compressed files on disk at
~/forgejo/data/actions_log/{owner}/{repo}/{hex_prefix}/{task_id}.log.zst
regardless of which runner executed the job. The web log endpoint doesn't
support API-token auth for private repos, so we read the files directly.
"""
tasks = fetch_tasks(repo, token)
jobs = sorted(
[t for t in tasks if t["run_number"] == run_number],
key=lambda x: x["id"],
)
if not jobs:
typer.echo(f"Error: No jobs found for run #{run_number}", err=True)
raise typer.Exit(1)
if job_index < 0 or job_index >= len(jobs):
typer.echo(
f"Error: job index {job_index} out of range (run #{run_number} has {len(jobs)} jobs)",
err=True,
)
raise typer.Exit(1)
task_id = jobs[job_index]["id"]
hex_prefix = f"{task_id & 0xff:02x}"
log_path = f"~/forgejo/data/actions_log/{repo}/{hex_prefix}/{task_id}.log.zst"
result = subprocess.run(
["ssh", "indri", f"zstdcat {log_path}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
typer.echo(
f"Error: could not read log for run #{run_number} job {job_index} (task {task_id})",
err=True,
)
typer.echo(f"Path: indri:{log_path}", err=True)
if result.stderr.strip():
typer.echo(result.stderr.strip(), err=True)
raise typer.Exit(1)
sys.stdout.write(result.stdout)
@app.command()
def main(
run_number: Annotated[
int | None,
typer.Argument(help="Run number to show jobs for (omit to list recent runs)"),
] = None,
job: Annotated[
int | None,
typer.Option("--job", "-j", help="Job index (0-based) to fetch logs for"),
] = None,
repo: Annotated[
str | None,
typer.Option("--repo", help="Forge repo (owner/name), default: detected from git remote"),
] = None,
limit: Annotated[
int,
typer.Option("--limit", "-n", help="Max runs to display (0 for all)"),
] = 15,
token: Annotated[
str | None,
typer.Option("--token", help="Forgejo API token (default: read from 1Password)"),
] = None,
) -> None:
"""List recent Forgejo Actions runs or fetch logs for a specific job."""
console = Console()
if repo is None:
repo = detect_repo_from_git()
if repo is None:
typer.echo(
"Error: could not detect repo from git remote; use --repo owner/name",
err=True,
)
raise typer.Exit(1)
console.print(f"[dim]Detected repo: {repo}[/dim]")
resolved_token = resolve_token(token, console)
if run_number is None:
if job is not None:
typer.echo("Error: --job requires a run number", err=True)
raise typer.Exit(1)
list_runs(repo, limit, resolved_token, console)
elif job is None:
show_jobs(run_number, repo, resolved_token, console)
else:
fetch_log(run_number, job, repo, resolved_token)
if __name__ == "__main__":
app()

7
mise.toml Normal file
View file

@ -0,0 +1,7 @@
[tools]
prek = "latest"
dagger = "0.20.6"
# uv runs the mise-tasks/* scripts (PEP 723 inline deps via `uv run --script`)
uv = "latest"
# bat renders the `ai-docs` session-priming output
bat = "latest"

150
prek.toml Normal file
View file

@ -0,0 +1,150 @@
# prek.toml - Git hooks configuration
# Run: prek run --all-files
# Install: prek install && prek install --hook-type commit-msg
# Built-in hooks (fast, Rust-native — no external dependencies)
[[repos]]
repo = "builtin"
hooks = [
{ id = "trailing-whitespace" },
{ id = "end-of-file-fixer" },
{ id = "check-added-large-files", args = [
"--maxkb=1000",
] },
{ id = "check-merge-conflict" },
{ id = "check-json" },
{ id = "check-toml" },
{ id = "check-case-conflict" },
{ id = "detect-private-key" },
{ id = "check-executables-have-shebangs" },
]
# check-yaml with --unsafe (builtin fast path doesn't support --unsafe yet)
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v6.0.0"
hooks = [{ id = "check-yaml", args = ["--unsafe"] }]
# Secret detection
[[repos]]
repo = "https://github.com/trufflesecurity/trufflehog"
rev = "v3.93.4"
hooks = [
{ id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [
"pre-commit",
"pre-push",
] },
]
# YAML linting
[[repos]]
repo = "https://github.com/adrienverge/yamllint"
rev = "v1.38.0"
hooks = [{ id = "yamllint", args = ["-c", ".yamllint.yaml"] }]
# Python - ruff for linting and formatting
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.15.2"
hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }]
# Shell scripts - shellcheck and shfmt
[[repos]]
repo = "https://github.com/shellcheck-py/shellcheck-py"
rev = "v0.11.0.1"
hooks = [{ id = "shellcheck", args = ["--severity=warning"] }]
[[repos]]
repo = "https://github.com/scop/pre-commit-shfmt"
rev = "v3.12.0-2"
hooks = [{ id = "shfmt", args = ["-i", "2", "-ci", "-bn"] }]
# TOML - taplo
# Only the formatter is enabled. taplo-lint is intentionally omitted: it fetches
# the remote SchemaStore catalog on every run, which requires network access
# (breaking sandboxed CI) and is broken in the pinned taplo CLI ("data did not
# match any variant of untagged enum SchemaCatalog"). TOML syntax is already
# validated by the check-toml hook above, and taplo upstream is dormant.
[[repos]]
repo = "https://github.com/ComPWA/taplo-pre-commit"
rev = "v0.9.3"
hooks = [{ id = "taplo-format" }]
# JSON formatting (prettier for consistent style)
[[repos]]
repo = "https://github.com/rbubley/mirrors-prettier"
rev = "v3.8.1"
hooks = [{ id = "prettier", types_or = ["json"], args = ["--tab-width", "2"] }]
# GitHub/Forgejo Actions workflow linting
[[repos]]
repo = "https://github.com/rhysd/actionlint"
rev = "v1.7.11"
hooks = [
{ id = "actionlint-system", args = [
"-config-file",
".github/actionlint.yaml",
], files = '\.forgejo/workflows/' },
]
# Custom local hooks
# Changelog fragment validation (no subdirectories)
[[repos]]
repo = "local"
[[repos.hooks]]
id = "changelog-check"
name = "changelog-check"
entry = "mise run changelog-check"
language = "system"
files = '^docs/changelog\.d/'
pass_filenames = false
# Mikado Branch Invariant (C2 changes)
[[repos]]
repo = "local"
[[repos.hooks]]
id = "mikado-branch-invariant-check"
name = "mikado-branch-invariant-check"
entry = "mise run mikado-branch-invariant-check"
language = "system"
always_run = true
stages = ["commit-msg"]
# Documentation validation
[[repos]]
repo = "local"
[[repos.hooks]]
id = "docs-check-filenames"
name = "docs-check-filenames"
entry = "mise run docs-check-filenames"
language = "system"
files = '^docs/.*\.md$'
pass_filenames = false
[[repos.hooks]]
id = "docs-check-links"
name = "docs-check-links"
entry = "mise run docs-check-links"
language = "system"
files = '^docs/.*\.md$'
pass_filenames = false
[[repos.hooks]]
id = "docs-check-index"
name = "docs-check-index"
entry = "mise run docs-check-index"
language = "system"
files = '^docs/.*\.md$'
pass_filenames = false
[[repos.hooks]]
id = "docs-check-frontmatter"
name = "docs-check-frontmatter"
entry = "mise run docs-check-frontmatter"
language = "system"
files = '^docs/.*\.md$'
pass_filenames = false

40
towncrier.toml Normal file
View file

@ -0,0 +1,40 @@
# Towncrier configuration
# https://towncrier.readthedocs.io/
[tool.towncrier]
directory = "docs/changelog.d"
filename = "CHANGELOG.md"
package = ""
title_format = "## [{version}] - {project_date}"
issue_format = ""
underlines = ["", "", ""]
[[tool.towncrier.type]]
directory = "feature"
name = "Features"
showcontent = true
[[tool.towncrier.type]]
directory = "bugfix"
name = "Bug Fixes"
showcontent = true
[[tool.towncrier.type]]
directory = "infra"
name = "Infrastructure"
showcontent = true
[[tool.towncrier.type]]
directory = "doc"
name = "Documentation"
showcontent = true
[[tool.towncrier.type]]
directory = "ai"
name = "AI Assistance"
showcontent = true
[[tool.towncrier.type]]
directory = "misc"
name = "Miscellaneous"
showcontent = true