From c991adf34e4afdc94ff51c19a745c554c43b3e06 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 19:10:12 -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 | 277 +++++++++ .gitea/template | 9 + .github/actionlint.yaml | 3 + .gitignore | 13 + .yamllint.yaml | 27 + AGENTS.md | 61 ++ CHANGELOG.md | 13 + 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/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 | 3 + prek.toml | 148 +++++ towncrier.toml | 40 ++ 39 files changed, 3492 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 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/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..25a3bef --- /dev/null +++ b/.dagger/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "datasette-satisfactory-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..802a64e --- /dev/null +++ b/.dagger/src/project_template_ci/__init__.py @@ -0,0 +1,4 @@ +"""Datasette-Satisfactory 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..30bc50c --- /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:22-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..f91cc0c --- /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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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..f3df47b --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,277 @@ +# 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 + run: | + VERSION_TYPE="${{ inputs.version_type }}" + SPECIFIC_VERSION="${{ inputs.specific_version }}" + + FORGE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + echo "Fetching latest release..." + LATEST=$(curl -s "${FORGE_URL}/releases/latest" | jq -r '.tag_name // empty' || true) + + if [ -z "$LATEST" ]; then + LATEST="v0.0.0" + echo "No previous releases found, using base version: $LATEST" + else + echo "Latest release: $LATEST" + 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 + + if curl -sf "${FORGE_URL}/releases/tags/$VERSION" > /dev/null 2>&1; then + echo "Error: Release $VERSION already exists" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building release: $VERSION" + + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + 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..7dd645b --- /dev/null +++ b/.gitea/template @@ -0,0 +1,9 @@ +CLAUDE.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..afba364 --- /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 + +**${REPO_NAME}** — ${REPO_DESCRIPTION} + +## 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/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..9d7439d --- /dev/null +++ b/dagger.json @@ -0,0 +1,8 @@ +{ + "name": "datasette-satisfactory-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..a369705 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,18 @@ +--- +title: Project Documentation +modified: 2026-03-03 +tags: + - meta +--- + +# Project Documentation + +Welcome to the **datasette-satisfactory** 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..4075aec --- /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: "Datasette-Satisfactory 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..5c5835f --- /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/datasette-satisfactory", + }, + }), +} + +// 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/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..061d4c4 --- /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/datasette-satisfactory" + + +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..906d96c --- /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 = "datasette-satisfactory" + + +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..7bf0672 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +prek = "latest" +dagger = "0.20.6" diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..41532f3 --- /dev/null +++ b/prek.toml @@ -0,0 +1,148 @@ +# 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 +[[repos]] +repo = "https://github.com/ComPWA/taplo-pre-commit" +rev = "v0.9.3" +hooks = [ + { id = "taplo-format" }, + { id = "taplo-lint", exclude = '\.dagger/pyproject\.toml$' }, +] + +# 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