From 28c1f7886a2f9bf27c8009932e96eebf40405dd3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 06:13:36 -0700 Subject: [PATCH] Initial commit --- .dagger/.gitattributes | 1 + .dagger/.gitignore | 4 + .dagger/pyproject.toml | 9 + .dagger/src/project_template_ci/__init__.py | 4 + .dagger/src/project_template_ci/main.py | 51 ++ .forgejo/workflows/build.yaml | 44 ++ .forgejo/workflows/release.yaml | 300 +++++++++ .gitea/template | 9 + .github/actionlint.yaml | 3 + .gitignore | 13 + .yamllint.yaml | 27 + AGENTS.md | 61 ++ CHANGELOG.md | 13 + CLAUDE.md | 1 + README.md | 57 ++ dagger.json | 8 + docs/changelog.d/.gitkeep | 0 docs/explanation/explanation.md | 13 + docs/how-to/agent-change-process.md | 277 +++++++++ docs/how-to/how-to.md | 15 + docs/index.md | 18 + docs/quartz.config.ts | 91 +++ docs/quartz.layout.ts | 53 ++ docs/reference/reference.md | 68 ++ docs/tutorials/ai-assistance-guide.md | 111 ++++ docs/tutorials/tutorials.md | 15 + mise-tasks/ai-docs | 21 + mise-tasks/branch-cleanup | 440 +++++++++++++ mise-tasks/changelog-check | 26 + mise-tasks/docs-check-filenames | 85 +++ mise-tasks/docs-check-frontmatter | 115 ++++ mise-tasks/docs-check-index | 117 ++++ mise-tasks/docs-check-links | 255 ++++++++ mise-tasks/docs-mikado | 655 ++++++++++++++++++++ mise-tasks/docs-preview | 83 +++ mise-tasks/mikado-branch-invariant-check | 301 +++++++++ mise-tasks/pr-comments | 118 ++++ mise-tasks/runner-logs | 283 +++++++++ mise.toml | 7 + prek.toml | 150 +++++ towncrier.toml | 40 ++ 41 files changed, 3962 insertions(+) create mode 100644 .dagger/.gitattributes create mode 100644 .dagger/.gitignore create mode 100644 .dagger/pyproject.toml create mode 100644 .dagger/src/project_template_ci/__init__.py create mode 100644 .dagger/src/project_template_ci/main.py create mode 100644 .forgejo/workflows/build.yaml create mode 100644 .forgejo/workflows/release.yaml create mode 100644 .gitea/template create mode 100644 .github/actionlint.yaml create mode 100644 .gitignore create mode 100644 .yamllint.yaml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 dagger.json create mode 100644 docs/changelog.d/.gitkeep create mode 100644 docs/explanation/explanation.md create mode 100644 docs/how-to/agent-change-process.md create mode 100644 docs/how-to/how-to.md create mode 100644 docs/index.md create mode 100644 docs/quartz.config.ts create mode 100644 docs/quartz.layout.ts create mode 100644 docs/reference/reference.md create mode 100644 docs/tutorials/ai-assistance-guide.md create mode 100644 docs/tutorials/tutorials.md create mode 100755 mise-tasks/ai-docs create mode 100755 mise-tasks/branch-cleanup create mode 100755 mise-tasks/changelog-check create mode 100755 mise-tasks/docs-check-filenames create mode 100755 mise-tasks/docs-check-frontmatter create mode 100755 mise-tasks/docs-check-index create mode 100755 mise-tasks/docs-check-links create mode 100755 mise-tasks/docs-mikado create mode 100755 mise-tasks/docs-preview create mode 100755 mise-tasks/mikado-branch-invariant-check create mode 100755 mise-tasks/pr-comments create mode 100755 mise-tasks/runner-logs create mode 100644 mise.toml create mode 100644 prek.toml create mode 100644 towncrier.toml diff --git a/.dagger/.gitattributes b/.dagger/.gitattributes new file mode 100644 index 0000000..8274184 --- /dev/null +++ b/.dagger/.gitattributes @@ -0,0 +1 @@ +/sdk/** linguist-generated diff --git a/.dagger/.gitignore b/.dagger/.gitignore new file mode 100644 index 0000000..2343dfc --- /dev/null +++ b/.dagger/.gitignore @@ -0,0 +1,4 @@ +/.venv +/**/__pycache__ +/sdk +/.env diff --git a/.dagger/pyproject.toml b/.dagger/pyproject.toml new file mode 100644 index 0000000..553c4f3 --- /dev/null +++ b/.dagger/pyproject.toml @@ -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" diff --git a/.dagger/src/project_template_ci/__init__.py b/.dagger/src/project_template_ci/__init__.py new file mode 100644 index 0000000..e9233a3 --- /dev/null +++ b/.dagger/src/project_template_ci/__init__.py @@ -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 diff --git a/.dagger/src/project_template_ci/main.py b/.dagger/src/project_template_ci/main.py new file mode 100644 index 0000000..df47c43 --- /dev/null +++ b/.dagger/src/project_template_ci/main.py @@ -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") + ) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml new file mode 100644 index 0000000..5180340 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -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 diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..f54aaeb --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -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" diff --git a/.gitea/template b/.gitea/template new file mode 100644 index 0000000..a91d6ad --- /dev/null +++ b/.gitea/template @@ -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 diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..20281ec --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - k8s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46e9957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.claude/settings.local.json + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ + +# Linter caches +.ruff_cache/ + +# OS +.DS_Store diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..e15a1db --- /dev/null +++ b/.yamllint.yaml @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..882e882 --- /dev/null +++ b/AGENTS.md @@ -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/..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/` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(): 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4771d0f --- /dev/null +++ b/CHANGELOG.md @@ -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/). + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a742a1 --- /dev/null +++ b/README.md @@ -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 + + diff --git a/dagger.json b/dagger.json new file mode 100644 index 0000000..42a5f98 --- /dev/null +++ b/dagger.json @@ -0,0 +1,8 @@ +{ + "name": "hephaestus-ci", + "engineVersion": "v0.20.6", + "sdk": { + "source": "python" + }, + "source": ".dagger" +} diff --git a/docs/changelog.d/.gitkeep b/docs/changelog.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md new file mode 100644 index 0000000..2eba5e8 --- /dev/null +++ b/docs/explanation/explanation.md @@ -0,0 +1,13 @@ +--- +title: Explanation +modified: 2026-03-03 +tags: + - explanation + - meta +--- + +# Explanation + +Background context and design decisions. + + diff --git a/docs/how-to/agent-change-process.md b/docs/how-to/agent-change-process.md new file mode 100644 index 0000000..462baae --- /dev/null +++ b/docs/how-to/agent-change-process.md @@ -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/+..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/..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/`, where `` 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(): +``` + +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/` 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(): 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(): 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(): 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/ --not main + ``` +3. **Reset the branch** to that commit: + ```bash + git reset --hard + ``` +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 ... + ``` +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(): 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 ` — 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/` 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/`, Mikado Branch Invariant enforced, `C2()` commit convention, PR early, push after every leaf-node closure +- **Changelog fragments (all levels):** Add `docs/changelog.d/..md` for any user-visible or noteworthy change, regardless of change class. C0 uses orphan fragments (`+..md`). C1/C2 use the branch name (`..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 ` | Show dependency chain for a goal card | +| `mise run docs-mikado --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 ` | Resume a specific chain with branch consistency check | +| `mise run docs-preview ` | 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 diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md new file mode 100644 index 0000000..b6caa2c --- /dev/null +++ b/docs/how-to/how-to.md @@ -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 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..132f634 --- /dev/null +++ b/docs/index.md @@ -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 diff --git a/docs/quartz.config.ts b/docs/quartz.config.ts new file mode 100644 index 0000000..67d1ae3 --- /dev/null +++ b/docs/quartz.config.ts @@ -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 diff --git a/docs/quartz.layout.ts b/docs/quartz.layout.ts new file mode 100644 index 0000000..ee317cf --- /dev/null +++ b/docs/quartz.layout.ts @@ -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: [], +} diff --git a/docs/reference/reference.md b/docs/reference/reference.md new file mode 100644 index 0000000..7045557 --- /dev/null +++ b/docs/reference/reference.md @@ -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-.tar.gz` via `dagger call build-docs --src=. --version=` +- Executes `.forgejo/scripts/release ` 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 ` | Extract and serve a released docs tarball locally | +| `mise run mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline | +| `mise run pr-comments ` | 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 diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md new file mode 100644 index 0000000..b60a6c2 --- /dev/null +++ b/docs/tutorials/ai-assistance-guide.md @@ -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 | diff --git a/docs/tutorials/tutorials.md b/docs/tutorials/tutorials.md new file mode 100644 index 0000000..74a83c8 --- /dev/null +++ b/docs/tutorials/tutorials.md @@ -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 diff --git a/mise-tasks/ai-docs b/mise-tasks/ai-docs new file mode 100755 index 0000000..d93158c --- /dev/null +++ b/mise-tasks/ai-docs @@ -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[@]}" diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup new file mode 100755 index 0000000..a538880 --- /dev/null +++ b/mise-tasks/branch-cleanup @@ -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 " help="Forgejo API token (default: read from 1Password)" +#USAGE flag "--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() diff --git a/mise-tasks/changelog-check b/mise-tasks/changelog-check new file mode 100755 index 0000000..2bc76ea --- /dev/null +++ b/mise-tasks/changelog-check @@ -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 ..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." diff --git a/mise-tasks/docs-check-filenames b/mise-tasks/docs-check-filenames new file mode 100755 index 0000000..721ee60 --- /dev/null +++ b/mise-tasks/docs-check-filenames @@ -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()) diff --git a/mise-tasks/docs-check-frontmatter b/mise-tasks/docs-check-frontmatter new file mode 100755 index 0000000..3571801 --- /dev/null +++ b/mise-tasks/docs-check-frontmatter @@ -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()) diff --git a/mise-tasks/docs-check-index b/mise-tasks/docs-check-index new file mode 100755 index 0000000..47695d6 --- /dev/null +++ b/mise-tasks/docs-check-index @@ -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()) diff --git a/mise-tasks/docs-check-links b/mise-tasks/docs-check-links new file mode 100755 index 0000000..46deab9 --- /dev/null +++ b/mise-tasks/docs-check-links @@ -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()) diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado new file mode 100755 index 0000000..d6d13bc --- /dev/null +++ b/mise-tasks/docs-mikado @@ -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 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 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) diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview new file mode 100755 index 0000000..6eb6bc6 --- /dev/null +++ b/mise-tasks/docs-preview @@ -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 "" help="Path to docs tarball (e.g. ~/Downloads/docs-v0.0.2.tar.gz)" +#USAGE flag "--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) diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check new file mode 100755 index 0000000..81e855b --- /dev/null +++ b/mise-tasks/mikado-branch-invariant-check @@ -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(): 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() diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments new file mode 100755 index 0000000..6f44e39 --- /dev/null +++ b/mise-tasks/pr-comments @@ -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 "" 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 +""" + +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 ") + 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()) diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs new file mode 100755 index 0000000..c36cca5 --- /dev/null +++ b/mise-tasks/runner-logs @@ -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 " help="Job index (0-based) to fetch logs for" +#USAGE flag "--repo " help="Forge repo (owner/name), default: detected from git remote" +#USAGE flag "--limit -n " help="Max runs to display (0 for all)" +#USAGE flag "--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 to see jobs in a run[/dim]") + console.print("[dim] mise run runner-logs -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() diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..138b804 --- /dev/null +++ b/mise.toml @@ -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" diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..caa02e1 --- /dev/null +++ b/prek.toml @@ -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 diff --git a/towncrier.toml b/towncrier.toml new file mode 100644 index 0000000..f8443cf --- /dev/null +++ b/towncrier.toml @@ -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