commit 0eba7743a1e8dcd660ee075c141111eb5c3ba701 Author: Erich Blume Date: Tue Mar 3 19:05:51 2026 -0800 Initial scaffold (pre-hook install) diff --git a/.dagger/pyproject.toml b/.dagger/pyproject.toml new file mode 100644 index 0000000..1003178 --- /dev/null +++ b/.dagger/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "project-template-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..ff55008 --- /dev/null +++ b/.dagger/src/project_template_ci/__init__.py @@ -0,0 +1,3 @@ +"""Project Template CI — Dagger build functions.""" + +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..9934417 --- /dev/null +++ b/.dagger/src/project_template_ci/main.py @@ -0,0 +1,50 @@ +import dagger +from dagger import dag, function, object_type + + +@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..f117197 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -0,0 +1,242 @@ +# Release Workflow +# +# Creates a versioned release with build artifacts. +# Currently includes: +# - Documentation site (Quartz static build) +# - Changelog (built from towncrier fragments) +# +# Usage: +# 1. Go to Actions > Build > Run workflow +# 2. Select version bump type (patch/minor/major) or choose specific version +# 3. The workflow creates a release with attached artifacts + +name: Build + +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: + build: + runs-on: k8s + steps: + - name: Resolve version + id: version + run: | + VERSION_TYPE="${{ inputs.version_type }}" + SPECIFIC_VERSION="${{ inputs.specific_version }}" + + # Fetch latest release + # TODO: Update FORGE_URL to match your Forgejo instance and repo + FORGE_URL="https://forge.example.com/api/v1/repos/owner/repo" + 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 + + # Parse current version components (strip 'v' prefix) + 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 + + # Check if this version already exists + 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" + + # Extract the changelog section for this release + 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: 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 }}" + + # TODO: Update FORGE_URL to match your Forgejo instance and repo + FORGE_URL="https://forge.example.com/api/v1/repos/owner/repo" + + 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 "## Documentation" + echo "" + echo "Download \`$TARBALL\` for the documentation site build." + } > /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" + + # Upload the asset + 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" + + 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.example.com" + + 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 }}" + echo "================================================" + echo "Release: $VERSION" + echo "================================================" 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..f6fe9e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.claude/settings.local.json + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ + +# 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4771d0f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +--- +title: changelog +tags: + - meta +--- + +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..490c8fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +Guidance for Claude Code working in this repository. See also [[ai-assistance-guide]]. + +## Overview + + +**PROJECT_NAME** — description goes here. + +**CRITICAL: Public repo — never commit secrets!** + +**Shell:** The user's shell is **fish**. Use `$status` not `$?` for exit codes. Use fish syntax in interactive examples. + +## 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. **Feature branches + PRs for C1/C2** — checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main. +4. **Add changelog fragments (all change levels)** — `docs/changelog.d/..md` + Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc` + - **C1/C2:** Use branch name: `..md` + - **C0:** Use orphan prefix: `+..md` +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** — feature branch with early PR. 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/ # documentation (Diataxis, Quartz) +./docs/changelog.d/ # towncrier fragments +./.dagger/ # dagger pipelines +./.forgejo/ # forgejo-runner actions and workflows +./mise-tasks/ # scripts 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/dagger.json b/dagger.json new file mode 100644 index 0000000..673dea1 --- /dev/null +++ b/dagger.json @@ -0,0 +1,8 @@ +{ + "name": "project-template-ci", + "engineVersion": "v0.19.11", + "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..7a90f8f --- /dev/null +++ b/docs/how-to/agent-change-process.md @@ -0,0 +1,273 @@ +--- +title: Agent Change Process +modified: 2026-03-03 +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 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 | + +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..e536718 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,20 @@ +--- +title: Project Documentation +modified: 2026-03-03 +tags: + - meta +--- + +# Project Documentation + + + +Welcome to the **PROJECT_NAME** 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..db52879 --- /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: "Project Docs", + pageTitleSuffix: "", + enableSPA: true, + enablePopovers: true, + analytics: null, + locale: "en-US", + baseUrl: "CHANGEME.example.com", + 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..888f89e --- /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": "https://CHANGEME.example.com/owner/repo", + }, + }), +} + +// 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..0d0e5b1 --- /dev/null +++ b/docs/reference/reference.md @@ -0,0 +1,13 @@ +--- +title: Reference +modified: 2026-03-03 +tags: + - reference + - meta +--- + +# Reference + +Technical reference material. + + diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md new file mode 100644 index 0000000..074c6dd --- /dev/null +++ b/docs/tutorials/ai-assistance-guide.md @@ -0,0 +1,98 @@ +--- +title: AI Assistance Guide +modified: 2026-03-03 +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 `CLAUDE.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 | +| `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 | +| `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 | + +## 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..1061948 --- /dev/null +++ b/mise-tasks/docs-mikado @@ -0,0 +1,656 @@ +#!/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+(.+)$") + +# TODO: Update to match your Forgejo instance and repo +FORGE_API = "https://forge.example.com/api/v1" +FORGE_REPO = "owner/repo" + + +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/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..0e80338 --- /dev/null +++ b/mise-tasks/pr-comments @@ -0,0 +1,119 @@ +#!/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 + +# TODO: Update to match your Forgejo instance and repo +FORGE_API_BASE = "https://forge.example.com/api/v1" +REPO_OWNER = "owner" +REPO_NAME = "repo" + + +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.toml b/mise.toml new file mode 100644 index 0000000..516afb6 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +prek = "latest" +dagger = "latest" diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..5311d68 --- /dev/null +++ b/prek.toml @@ -0,0 +1,145 @@ +# 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" }] + +# 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