From f59dfb4fca92d713657c649ce095ffcf52953fbd Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 21:44:06 -0700 Subject: [PATCH 001/108] start: #1 The first release, v0.1.0 From 224052705a85b72fbaf75a653617bc7d2747cb49 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:02:26 -0700 Subject: [PATCH 002/108] #1 .kit: the delivered tree Co-Authored-By: Claude Fable 5.1 --- .kit/1st.md | 78 ++++++++++++ .kit/bash/1st.sh | 129 ++++++++++++++++++++ .kit/bash/gh.sh | 223 ++++++++++++++++++++++++++++++++++ .kit/bash/git.sh | 262 ++++++++++++++++++++++++++++++++++++++++ .kit/ci.yml | 141 +++++++++++++++++++++ .kit/hooks/pre-commit | 85 +++++++++++++ .kit/hooks/pre-rebase | 46 +++++++ .kit/kit.just | 67 ++++++++++ .kit/rules/bash.md | 43 +++++++ .kit/rules/gitignore.md | 24 ++++ .kit/rules/json.md | 21 ++++ .kit/rules/just.md | 44 +++++++ .kit/rules/markdown.md | 39 ++++++ .kit/rules/yaml.md | 29 +++++ .kit/settings.json | 12 ++ .kit/version | 1 + 16 files changed, 1244 insertions(+) create mode 100644 .kit/1st.md create mode 100644 .kit/bash/1st.sh create mode 100644 .kit/bash/gh.sh create mode 100644 .kit/bash/git.sh create mode 100644 .kit/ci.yml create mode 100755 .kit/hooks/pre-commit create mode 100755 .kit/hooks/pre-rebase create mode 100644 .kit/kit.just create mode 100644 .kit/rules/bash.md create mode 100644 .kit/rules/gitignore.md create mode 100644 .kit/rules/json.md create mode 100644 .kit/rules/just.md create mode 100644 .kit/rules/markdown.md create mode 100644 .kit/rules/yaml.md create mode 100644 .kit/settings.json create mode 100644 .kit/version diff --git a/.kit/1st.md b/.kit/1st.md new file mode 100644 index 0000000..e51c83d --- /dev/null +++ b/.kit/1st.md @@ -0,0 +1,78 @@ +# Read this first + +This file is the kit's entry. It binds every agent and every session in a repository that carries `.kit/`, and +the repository's own rules in `AGENTS.md` bind beside it. Where the two differ, the repository's rule wins. + +## Integrity + +1. Report only what happened. Write "done", "passing", or "verified" only for what ran and held. +2. A skip is a skip. A check not run, not implemented, or not implementable is reported as exactly that. It + clears no gate and earns no credit. +3. A claim carries its evidence, or says it has none. Name the command that ran and what it printed. +4. Being wrong is recoverable. Misreporting is not. + +## `main` is the human's desk + +The checkout at the repository root stays on the default branch. It is where the human types, reviews, and +leaves work in progress. Never check out, reset, rebase, or otherwise change it in place. Work happens in a +worktree under `worktrees/`, one per issue, and reaches `main` only through a merged pull request. + +## The loop + +1. An issue exists. A change without an issue has not started. +2. `just kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a + draft pull request. It never switches the checkout it is run from. +3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. +4. `gh pr ready` says the work is done. The pull request body is the report: what changed, what was checked + and how, and what was left out and why. +5. A human merges. Nothing reaches `main` any other way, and nothing is called merged until the merge is + observed. + +## Plans are prose + +A design, a spec, or a plan is the body of an issue, written in prose. It is never a committed file. When +the design is written, the issue is the deliverable, and the loop above starts from it. + +## The worker contract + +A worker is whoever implements one issue in the worktree that issue names. + +1. `cd` into the worktree named in the task and confirm `git rev-parse --abbrev-ref HEAD` prints `-`. +2. Do every command from there. Never touch the repository root checkout. +3. Never run `just kit issue`, make a branch, or make a worktree. Those were done before the handoff. +4. Implement what the issue asks and nothing beside it. +5. Push as you go. +6. Run the repository's gate before calling anything done, and run it through the pinned toolchain. +7. End with `gh pr ready` and the report in the pull request body. +8. If the gate fails or the work is partial, leave the pull request a draft and say why. +9. Never merge. +10. Report what ran and what it printed, nothing more. + +## Rules by file type + +Each file kind has a rule file under `.kit/rules/`. A harness that loads rules by path finds the same text +under `.claude/rules/kit-.md`. Read the rule for a kind before writing a file of that kind. + +| Kind | Paths | Rule | +| --- | --- | --- | +| Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | +| Bash | `*.sh`, `*.bash`, `.kit/hooks/*` | [rules/bash.md](rules/bash.md) | +| Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | +| YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | +| JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | +| Ignore lists | `.gitignore` | [rules/gitignore.md](rules/gitignore.md) | + +## What the kit carries + +- `.kit/kit.just`, a just module. The consumer's root justfile takes it with `mod kit ".kit/kit.just"`, + which gives `just kit issue`, `just kit tidy`, and `just kit status`. The bash behind them is `.kit/bash/`. +- `.kit/hooks/pre-rebase` refuses a rebase of the default branch. `.kit/hooks/pre-commit` lints the staged + paths. `kit install` sets `core.hooksPath` to `.kit/hooks`. +- `.kit/settings.json`, deny rules for Claude Code that `kit install` merges into `.claude/settings.json`. + The kit owns only the entries it writes; the consumer may add its own. +- `.kit/ci.yml`, placed as `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, + runs `kit check`, and refuses a pull request not linked to an open issue. +- `.kit/manifest`, written by `kit install`: the release version and a checksum per delivered file. + +`kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a +new release and running `kit update`, never by editing them in place. diff --git a/.kit/bash/1st.sh b/.kit/bash/1st.sh new file mode 100644 index 0000000..89d94e8 --- /dev/null +++ b/.kit/bash/1st.sh @@ -0,0 +1,129 @@ +# shellcheck shell=bash +# 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Sourced into every recipe body of .kit/kit.just through BASH_ENV, and by bin/kit and the hooks that need a +# verb. It defines functions only and sets no shell option, so the caller's options stand. A module under +# .kit/bash/ is loaded on first use with `use `, and each exported function has a stub here so a +# caller never has to load it by hand. +# +# Every message goes to stderr, so stdout stays clean for data. Color is off when stderr is not a terminal or +# NO_COLOR is set. Under GitHub Actions, warn and error also emit the matching workflow command. + +kit_bash_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +declare -gr kit_bash_dir +declare -Ag kit_modules=([1st]=1) + +# use … loads .kit/bash/.sh once. An unknown module is a bug in the caller, so it is fatal. +use() { + local module + for module in "$@" + do + [[ ${kit_modules[${module}]+x} ]] && continue + if [[ ! -f "${kit_bash_dir}/${module}.sh" ]] + then + error "no such kit bash module: ${module} (looked in ${kit_bash_dir})" + return 2 + fi + kit_modules[${module}]=1 + # shellcheck disable=SC1090 # the path is computed from the module name + source "${kit_bash_dir}/${module}.sh" + done +} + +if [[ -n "${NO_COLOR:-}" ]] +then + declare -gr out_color=0 +elif [[ -t 2 ]] +then + declare -gr out_color=1 +else + declare -gr out_color=0 +fi + +out:sgr() { + (( out_color )) && printf '\x1b[%sm' "$1" + return 0 +} + +# out:emit … prints one line to stderr. +out:emit() { + local icon=$1 sgr=$2 + shift 2 + printf '%s%s %s%s\n' "$(out:sgr "${sgr}")" "${icon}" "$*" "$(out:sgr 0)" >&2 +} + +info() { out:emit '•' '' "$@"; } +step() { out:emit '→' '36' "$@"; } +ok() { out:emit '✅' '32' "$@"; } +section() { out:emit '' '1' "── $* ──"; } + +warn() { + out:emit '⚠️' '33' "$@" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::warning::$*" >&2 + return 0 +} + +error() { + out:emit '❌' '31' "$@" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::error::$*" >&2 + return 0 +} + +# guard:require … returns non-zero on the first predicate that does not hold, with the remedy. +# gh gh is usably authenticated (a real API call, since a revoked token still passes gh auth status) +# signing-key git user.signingkey is set +# tool: is on PATH +guard:require() { + local predicate + for predicate in "$@" + do + case "${predicate}" in + (gh) + if ! gh api user --jq '.login' >/dev/null 2>&1 + then + error "run: gh auth login (the GitHub CLI is not authenticated)" + return 1 + fi + ;; + (signing-key) + if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] + then + error "run: git config user.signingkey (every commit here is signed, and none is configured)" + return 1 + fi + ;; + (tool:*) + local tool=${predicate#tool:} + if ! command -v "${tool}" >/dev/null 2>&1 + then + error "run: mise install (${tool} is not on PATH)" + return 1 + fi + ;; + (*) + error "guard:require: unknown predicate '${predicate}'" + return 1 + esac + done +} + +# git:default-branch prints the default branch: origin/HEAD, with main as the fallback. +git:default-branch() { + local branch + branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) + [[ -n "${branch}" ]] || branch=main + printf '%s\n' "${branch}" +} + +# git:main-root prints the root of the main checkout, the first entry of the worktree list. worktrees/ lives +# under it whether the caller stands in the main checkout or in a linked worktree. +git:main-root() { + git worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 +} + +gh:issue() { use gh && gh:issue "$@"; } +git:tidy() { use git && git:tidy "$@"; } +git:status() { use git && git:status "$@"; } + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/bash/gh.sh b/.kit/bash/gh.sh new file mode 100644 index 0000000..c0e8f49 --- /dev/null +++ b/.kit/bash/gh.sh @@ -0,0 +1,223 @@ +# shellcheck shell=bash +# gh.sh - the issue on-ramp: a linked branch, a worktree, a start commit, and a draft pull request (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# gh:issue […] is the one command the loop needs and no vendor provides. The first number is the +# primary issue, linked to the branch under Development by `gh issue develop`, which closes it on merge. Any +# further number is a secondary, closed by a `Closes #N` line in the pull request body. Every listed issue is +# assigned to the caller. +# +# It never switches the checkout it is run from. The branch is checked out in worktrees/- under the +# main checkout's root, and an existing branch, worktree, or pull request is reused, so a re-run reconciles +# rather than duplicates. +# +# With no argument and a terminal on stdin it asks for a title, creates the issue, and continues. Without a +# terminal the usage error stands, so an agent never hangs on a prompt. +# +# If the invoking checkout is on the default branch and carries uncommitted changes, they are swept into the +# new worktree as uncommitted changes and the invoking checkout is left clean, then fast-forwarded to origin. +# The sweep runs only from the default-branch checkout: inside another worktree, dirty files are that issue's +# own work and are never moved. A sweep that cannot apply leaves the stash in place and stops, so nothing is +# lost. + +gh:issue() { + if (( $# == 0 )) + then + if [[ ! -t 0 ]] + then + error "usage: just kit issue […]" + return 2 + fi + guard:require gh || return 1 + local new_title new_number + read -r -p "Issue title: " new_title + if [[ -z "${new_title}" ]] + then + error "an issue title is required" + return 2 + fi + new_number=$(gh issue create --title "${new_title}" --body "" 2>/dev/null | sed -n 's@.*/issues/@@p') + if [[ ! "${new_number}" =~ ^[0-9]+$ ]] + then + error "run: gh auth status (the issue could not be created)" + return 1 + fi + ok "Created #${new_number}: ${new_title}" + set -- "${new_number}" + fi + + local n + for n in "$@" + do + if [[ ! "${n}" =~ ^[0-9]+$ ]] + then + error "not an issue number: '${n}'" + return 2 + fi + done + local primary=$1 + + guard:require gh || return 1 + git fetch --quiet --prune origin + + local default_branch main_root invoking_root invoking_branch + default_branch=$(git:default-branch) + main_root=$(git:main-root) + invoking_root=$(git rev-parse --show-toplevel) + invoking_branch=$(git -C "${invoking_root}" rev-parse --abbrev-ref HEAD) + + local do_sweep=false stash_ref="" + if [[ "${invoking_branch}" == "${default_branch}" && -n "$(git -C "${invoking_root}" status --porcelain)" ]] + then + do_sweep=true + fi + + local title + title=$(gh issue view "${primary}" --json title --jq '.title') + if [[ -z "${title}" ]] + then + error "run: gh auth status (the title of #${primary} could not be read)" + return 1 + fi + + local me assignees + me=$(gh api user --jq '.login') + if [[ -z "${me}" ]] + then + error "run: gh auth status (the current user could not be read)" + return 1 + fi + for n in "$@" + do + assignees=$(gh issue view "${n}" --json assignees --jq '[.assignees[].login] | join(" ")') + [[ " ${assignees} " == *" ${me} "* ]] && continue + if [[ -n "${assignees}" ]] + then + warn "#${n} is already assigned to ${assignees}; adding you as well. Coordinate before continuing." + fi + if ! gh issue edit "${n}" --add-assignee "${me}" >/dev/null 2>&1 + then + warn "#${n} could not be assigned to ${me}; assign it by hand." + fi + done + + section "Starting #${primary}: ${title}" + + # The branch gh made for the issue, found by an open pull request's head, then by the remote ref. Any hit is + # reused, so a second run never forks a --1 duplicate. + local branch + branch=$(gh pr list --state open --json headRefName \ + --jq "[.[] | select(.headRefName | test(\"^${primary}-\"))] | .[0].headRefName // empty" 2>/dev/null || true) + [[ -n "${branch}" ]] || branch=$(git ls-remote --heads origin "${primary}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + if [[ -z "${branch}" ]] + then + step "Linking a branch to #${primary} (gh issue develop)" + gh issue develop "${primary}" --base "${default_branch}" >/dev/null + git fetch --quiet origin + branch=$(git ls-remote --heads origin "${primary}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + if [[ -z "${branch}" ]] + then + error "the branch gh made for #${primary} could not be found; run: gh issue develop ${primary} --list" + return 1 + fi + fi + + if [[ "${do_sweep}" == true ]] + then + step "Sweeping the uncommitted work on ${default_branch} into the worktree" + if ! git -C "${invoking_root}" stash push --include-untracked --message "kit issue #${primary} → ${branch}" >/dev/null + then + error "the uncommitted work on ${default_branch} could not be stashed; resolve it by hand and re-run." + return 1 + fi + stash_ref=$(git -C "${invoking_root}" rev-parse 'stash@{0}') + fi + + local wt + wt=$(git worktree list --porcelain 2>/dev/null \ + | awk -v b="branch refs/heads/${branch}" '/^worktree /{w=substr($0,10)} $0==b{print w; exit}') + if [[ -n "${wt}" ]] + then + step "Reusing worktree ${wt}" + else + wt="${main_root}/worktrees/${branch}" + if [[ -e "${wt}" ]] + then + error "${wt} exists but is not a worktree of ${branch}; remove it (or run: just kit tidy) and re-run." + return 2 + fi + step "Creating worktree ${wt}" + mkdir -p "${main_root}/worktrees" + if git show-ref --verify --quiet "refs/heads/${branch}" + then + git worktree add --quiet "${wt}" "${branch}" + else + git worktree add --quiet "${wt}" -b "${branch}" "origin/${branch}" + fi + fi + + if [[ "$(git config core.hooksPath 2>/dev/null)" != .kit/hooks ]] + then + warn "run: kit install (core.hooksPath is not .kit/hooks, so the desk guard is off in this clone)" + fi + + # GitHub cannot open a pull request on a branch with nothing to compare, so the first commit is an empty one. + if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] + then + step "Adding the start commit" + git -C "${wt}" commit --quiet --allow-empty --message "start: #${primary} ${title}" + fi + git -C "${wt}" push --quiet --set-upstream origin "${branch}" + + if [[ -n "${stash_ref}" ]] + then + if git -C "${wt}" stash apply "${stash_ref}" >/dev/null 2>&1 + then + local slot + slot=$(git -C "${invoking_root}" stash list --format='%gd %H' | awk -v h="${stash_ref}" '$2==h{print $1; exit}') + [[ -n "${slot}" ]] && git -C "${invoking_root}" stash drop "${slot}" >/dev/null + ok "Swept the work into ${wt}, uncommitted; commit it there with a real message." + if ! git -C "${invoking_root}" merge --ff-only "origin/${default_branch}" >/dev/null 2>&1 + then + warn "${default_branch} could not fast-forward to origin; it is left as it was." + fi + else + error "the swept work conflicts in ${wt}. It is kept in the stash: git stash apply ${stash_ref}" + return 1 + fi + fi + + local pr + pr=$(gh pr list --head "${branch}" --state open --json number --jq '.[0].number // empty') + if [[ -z "${pr}" ]] + then + step "Opening a draft pull request" + gh pr create --draft --base "${default_branch}" --head "${branch}" --title "${title}" --body "" >/dev/null + pr=$(gh pr list --head "${branch}" --state open --json number --jq '.[0].number') + fi + + if (( $# > 1 )) + then + local body changed=false + body=$(gh pr view "${pr}" --json body --jq '.body // ""') + for n in "${@:2}" + do + if ! grep --quiet --ignore-case --extended-regexp "(close[sd]?|fix(e[sd])?|resolve[sd]?):? #${n}([^0-9]|\$)" <<<"${body}" + then + body="${body}"$'\n'"Closes #${n}" + changed=true + fi + done + if [[ "${changed}" == true ]] + then + step "Linking the secondary issues in the pull request body" + gh pr edit "${pr}" --body "${body}" >/dev/null + fi + fi + + ok "Draft pull request #${pr} on ${branch}, primary #${primary}${2:+, secondaries: ${*:2}}" + ok "Worktree ready: ${wt}" + step "cd '${wt}' # do all work there; this checkout stays on ${default_branch}" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/bash/git.sh b/.kit/bash/git.sh new file mode 100644 index 0000000..a8544b8 --- /dev/null +++ b/.kit/bash/git.sh @@ -0,0 +1,262 @@ +# shellcheck shell=bash +# git.sh - the picture of the worktrees, and the sweep that returns a checkout to pristine (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# git:status [--no-fetch] renders the main checkout on one line and one block per worktree under worktrees/ +# with its branch, pull request, and dirty or clean state, then the local branches whose upstream is gone. It +# changes nothing. +# +# git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, prunes +# the worktree bookkeeping, and deletes every local branch whose upstream is gone. It never touches the default +# branch, a branch checked out in a live worktree, a dirty worktree, or the worktree it runs in. --dry-run +# prints what a sweep would do and changes nothing. + +# git:pr-summary prints "\t" for the open pull request on , or nothing. +git:pr-summary() { + gh pr list --head "$1" --state open --json number,isDraft \ + --jq '.[0] | select(.number) | "\(.number)\t\(if .isDraft then "Draft" else "Ready" end)"' 2>/dev/null || true +} + +# git:gone-branches prints every local branch whose upstream no longer exists. +git:gone-branches() { + git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2=="[gone]"{print $1}' +} + +# git:each-worktree calls for every linked worktree under +# worktrees/. is empty and is true for a detached checkout. +git:each-worktree() { + local callback=$1 worktree_root path="" branch="" detached=false line + worktree_root="$(git:main-root)/worktrees" + while IFS= read -r line + do + case "${line}" in + (worktree\ *) + path=${line#worktree } + branch="" + detached=false + ;; + (branch\ refs/heads/*) + branch=${line#branch refs/heads/} + ;; + (detached) + detached=true + ;; + ("") + [[ -n "${path}" ]] || continue + case "${path}" in + ("${worktree_root}"/*) + "${callback}" "${path}" "${branch}" "${detached}" + esac + path="" + esac + done < <(git worktree list --porcelain; printf '\n') +} + +# git:status-block renders one worktree. git:status sets found, main_root, and +# default_branch before calling it through git:each-worktree. +git:status-block() { + local path=$1 branch=$2 rel pr ahead_behind porcelain + found=true + rel=${path#"${main_root}"/} + step "${rel}" + info " branch ${branch:-(detached)}" + if [[ -n "${branch}" ]] + then + pr=$(git:pr-summary "${branch}") + ahead_behind=$(git rev-list --left-right --count "${branch}...${default_branch}" 2>/dev/null || printf '0\t0') + if [[ -n "${pr}" ]] + then + info " pr #${pr%%$'\t'*} ${pr##*$'\t'} · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" + else + info " pr (none open) · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" + fi + else + info " pr (no branch)" + fi + porcelain=$(git -C "${path}" status --porcelain 2>/dev/null || true) + if [[ -z "${porcelain}" ]] + then + info " status clean" + else + info " status $(grep --count . <<<"${porcelain}") file(s) modified" + fi +} + +# git:tidy-worktree decides one worktree. git:tidy sets found, self_root, dry_run, +# removed_worktrees, and freed before calling it through git:each-worktree. +git:tidy-worktree() { + local path=$1 branch=$2 detached=$3 state="" number + found=true + if [[ "${path}" == "${self_root}" ]] + then + step "keeping ${path} (this command runs in it)" + return 0 + fi + if [[ -n "${branch}" ]] + then + state=$(gh pr list --head "${branch}" --state all --json state --jq '.[0].state // empty' 2>/dev/null || true) + if [[ -z "${state}" && "${branch}" =~ ^([0-9]+)- ]] + then + number=${BASH_REMATCH[1]} + [[ "$(gh issue view "${number}" --json state --jq .state 2>/dev/null)" == CLOSED ]] && state=CLOSED + fi + elif [[ "${detached}" == true && -z "$(git -C "${path}" status --porcelain 2>/dev/null)" ]] + then + state=DETACHED + fi + case "${state}" in + (MERGED | CLOSED | DETACHED) + if [[ -n "$(git -C "${path}" status --porcelain 2>/dev/null)" ]] + then + step "keeping ${path} (${state}, but it has uncommitted changes)" + return 0 + fi + if [[ "${dry_run}" == true ]] + then + step "would remove ${path} (${branch:-detached}, ${state})" + else + step "removing ${path} (${branch:-detached}, ${state})" + git worktree remove "${path}" + fi + [[ -n "${branch}" ]] && freed[${branch}]=1 + (( ++removed_worktrees )) + ;; + (*) + step "keeping ${path} (${branch:-detached}, pull request ${state:-open or unknown})" + esac + return 0 +} + +git:status() { + local no_fetch=false arg + for arg in "$@" + do + case "${arg}" in + (--no-fetch) + no_fetch=true + ;; + (*) + error "unknown flag '${arg}' (only --no-fetch is recognized)" + return 2 + esac + done + if [[ "${no_fetch}" != true ]] + then + git fetch --quiet --prune origin || warn "git fetch --prune failed; ahead and behind counts may be stale" + fi + + local main_branch main_dirty counts + main_root=$(git:main-root) + default_branch=$(git:default-branch) + main_branch=$(git -C "${main_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') + main_dirty=clean + [[ -z "$(git -C "${main_root}" status --porcelain 2>/dev/null)" ]] || main_dirty=dirty + counts=$(git -C "${main_root}" rev-list --left-right --count "HEAD...origin/${default_branch}" 2>/dev/null || printf '0\t0') + section "${main_root##*/} (${main_branch}, ${main_dirty}, ↑${counts%%$'\t'*} ↓${counts##*$'\t'} vs origin/${default_branch})" + + section "worktrees/" + found=false + git:each-worktree git:status-block + [[ "${found}" == true ]] || ok "none" + + section "branches whose upstream is gone (tidy removes these)" + local gone + gone=$(git:gone-branches) + if [[ -n "${gone}" ]] + then + while IFS= read -r branch + do + info "${branch}" + done <<<"${gone}" + else + ok "none" + fi +} + +git:tidy() { + local dry_run=false flag + for flag in "$@" + do + case "${flag}" in + (--dry-run) + dry_run=true + ;; + (*) + error "unknown flag '${flag}' (only --dry-run is recognized)" + return 2 + esac + done + guard:require gh || return 1 + + local repo + repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true) + if [[ -z "${repo}" ]] + then + error "this checkout does not resolve to a GitHub repository (run: gh repo view), so nothing is swept." + return 1 + fi + if [[ "${dry_run}" == true ]] + then + section "kit tidy, dry run: nothing is changed" + else + section "kit tidy: sweeping ${repo} back to pristine" + fi + + default_branch=$(git:default-branch) + self_root=$(git rev-parse --show-toplevel) + if [[ "${dry_run}" == true ]] + then + step "would run: git fetch --prune origin" + else + step "git fetch --prune origin" + git fetch --quiet --prune origin + fi + + section "worktrees whose pull request is merged or closed" + removed_worktrees=0 + found=false + declare -A freed=() + git:each-worktree git:tidy-worktree + [[ "${found}" == true ]] || ok "none" + + section "worktree bookkeeping" + if [[ "${dry_run}" == true ]] + then + git worktree prune --verbose --dry-run || true + else + git worktree prune --verbose || true + fi + ok "pruned" + + section "local branches whose upstream is gone" + local removed_branches=0 checked_out branch + checked_out=$(git worktree list --porcelain | sed -n 's@^branch refs/heads/@@p') + while IFS= read -r branch + do + [[ -n "${branch}" ]] || continue + [[ "${branch}" == "${default_branch}" ]] && continue + if [[ -z "${freed[${branch}]+x}" ]] && grep --quiet --line-regexp --fixed-strings "${branch}" <<<"${checked_out}" + then + step "keeping ${branch} (checked out in a worktree)" + continue + fi + if [[ "${dry_run}" == true ]] + then + step "would delete ${branch}" + else + step "deleting ${branch}" + git branch -D "${branch}" >/dev/null + fi + (( ++removed_branches )) + done <<<"$(git:gone-branches)" + (( removed_branches > 0 )) || ok "none" + + if [[ "${dry_run}" == true ]] + then + ok "Dry run: would remove ${removed_worktrees} worktree(s) and ${removed_branches} branch(es)." + else + ok "Tidied ${repo}: removed ${removed_worktrees} worktree(s) and ${removed_branches} branch(es)." + fi +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/ci.yml b/.kit/ci.yml new file mode 100644 index 0000000..dd0e5f0 --- /dev/null +++ b/.kit/ci.yml @@ -0,0 +1,141 @@ +# kit-ci.yml - the check the kit delivers: the repository's gate, the kit's own check, and the issue link +# ---------------------------------------------------------------------------------------------------------------------- +# +# One job, CI, which is the check name a ruleset requires. Three steps: the repository's `just ci` when its +# root justfile has one, `kit check` against the release mise pins, and a refusal of a pull request that is +# not linked to an open issue of this repository under Development. The last two run even when an earlier step +# refused, so one run reports every finding. +# +# This file is placed by `kit install` and verified by `kit check`. Change it by pinning a new release. +# +# see: .kit/1st.md + +name: kit + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + CI: + if: github.event.pull_request.base.ref == github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Every tool below comes from mise.toml, the kit included. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + + - name: gate + run: | + set -o errexit -o nounset -o pipefail + if [[ ! -f justfile ]] || ! just --show ci >/dev/null 2>&1 + then + echo "::warning::the root justfile has no ci recipe, so the gate was skipped" + printf '⚠️ The gate was skipped: the root justfile has no ci recipe.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + else + just ci + printf '✅ just ci passed.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + fi + + - name: kit-check + if: ${{ !cancelled() }} + run: | + set -o errexit -o nounset -o pipefail + if command -v kit >/dev/null 2>&1 + then + kit check + elif [[ -x bin/kit ]] + then + bin/kit check + else + echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml' + printf '### ❌ kit is not on PATH\n\nPin the release in mise.toml under [tools]: "github:empowerite/kit" = "".\n' \ + | tee --append "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + printf '✅ Every delivered file matches the pinned kit release.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + + - name: linked-open-issue + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o errexit -o nounset -o pipefail + + repo_slug="${GITHUB_REPOSITORY}" + pr=$(jq --raw-output '.pull_request.number' "${GITHUB_EVENT_PATH}") + pull="${GITHUB_SERVER_URL}/${repo_slug}/pull/${pr}" + run="${GITHUB_SERVER_URL}/${repo_slug}/actions/runs/${GITHUB_RUN_ID}" + + remedy=$(cat <\` makes this link when it starts the work. + REMEDY + ) + + refuse() { + echo "::error::$1" + printf '### ❌ %s\n\n%s\n' "${1^}" "${remedy}" | tee --append "${GITHUB_STEP_SUMMARY}" + return 1 + } + + query=$(cat <<'GRAPHQL' + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100) { + nodes { number state repository { nameWithOwner } } + } + } + } + } + GRAPHQL + ) + refs=$(gh api graphql \ + --field owner="${repo_slug%%/*}" \ + --field name="${repo_slug#*/}" \ + --field number="${pr}" \ + --raw-field query="${query}" \ + --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] + | "\(.number) \(.state) \(.repository.nameWithOwner)"') + + if [[ -z "${refs}" ]] + then + refuse 'this pull request is not linked to any issue' + else + open_local=0 + while read -r n state repo + do + if [[ "${repo}" != "${repo_slug}" ]] + then + echo "::warning::skipping ${repo}#${n}: it belongs to another repository" + continue + fi + if [[ "${state}" != OPEN ]] + then + echo "::warning::${repo}#${n} is ${state}; merging will not change it" + continue + fi + open_local=$(( open_local + 1 )) + done <<< "${refs}" + if (( open_local == 0 )) + then + refuse 'every issue this pull request is linked to is closed or belongs to another repository' + else + closes=$(cut --delimiter=' ' --fields=1 <<< "${refs}" | sed 's/^/#/' | paste --serial --delimiters=' ') + printf '✅ Linked to %s.\n' "${closes}" | tee --append "${GITHUB_STEP_SUMMARY}" + fi + fi + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/hooks/pre-commit b/.kit/hooks/pre-commit new file mode 100755 index 0000000..f84188a --- /dev/null +++ b/.kit/hooks/pre-commit @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# pre-commit - lint the staged paths and refuse the commit on a finding (executed git hook, fails open) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Each linter runs over the staged paths of its own kind, when the linter is on PATH: shellcheck over bash, +# actionlint over workflows, and markdownlint-cli2 over markdown when the repository carries a config for it. +# A linter that is not on PATH is a reported skip, never a silent pass and never a refusal: lint is not the +# enforcement of record, and a GUI client with a short PATH must still be able to commit. +# +# The working tree's copy of each path is linted, not the staged blob, so a partial add can be told about a line +# the commit does not carry. The escape hatch is git's own: git commit --no-verify + +set -o nounset -o pipefail + +staged=() +while IFS= read -r -d '' path +do + [[ -f "${path}" ]] && staged+=("${path}") +done < <(git diff --cached --name-only --diff-filter=ACMR -z 2>/dev/null) +(( ${#staged[@]} )) || exit 0 + +bash_paths=() +workflow_paths=() +markdown_paths=() +for path in "${staged[@]}" +do + case "${path}" in + (*.sh | *.bash | .kit/hooks/*) + bash_paths+=("${path}") + ;; + (.github/workflows/*.yml | .github/workflows/*.yaml) + workflow_paths+=("${path}") + ;; + (*.md) + markdown_paths+=("${path}") + esac +done + +findings=0 +skip() { + printf '\xe2\x9a\xa0\xef\xb8\x8f %s is not on PATH, so the staged %s were NOT linted.\n' "$1" "$2" >&2 +} + +if (( ${#bash_paths[@]} )) +then + if command -v shellcheck >/dev/null 2>&1 + then + shellcheck --shell=bash --external-sources "${bash_paths[@]}" || findings=1 + else + skip shellcheck "bash files" + fi +fi + +if (( ${#workflow_paths[@]} )) +then + if command -v actionlint >/dev/null 2>&1 + then + actionlint "${workflow_paths[@]}" || findings=1 + else + skip actionlint workflows + fi +fi + +if (( ${#markdown_paths[@]} )) && compgen -G ".markdownlint-cli2.*" >/dev/null +then + if command -v markdownlint-cli2 >/dev/null 2>&1 + then + markdownlint-cli2 "${markdown_paths[@]}" || findings=1 + else + skip markdownlint-cli2 "markdown files" + fi +fi + +if (( findings )) +then + cat >&2 <<'MESSAGE' + +❌ refusing the commit: a linter found something in the staged paths. Each finding above names the file and line. + Fix it and commit again. If you mean it anyway, the escape hatch is git's own: git commit --no-verify +MESSAGE + exit 1 +fi +exit 0 + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/hooks/pre-rebase b/.kit/hooks/pre-rebase new file mode 100755 index 0000000..240bc3e --- /dev/null +++ b/.kit/hooks/pre-rebase @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# pre-rebase - refuse a rebase of the default branch (executed git hook, fails open) +# ---------------------------------------------------------------------------------------------------------------------- +# +# git runs this before every rebase, however it was spelled: git rebase, git pull --rebase, an editor's button. +# It refuses one thing, a rebase whose branch is the default branch, because that branch is the human's desk and +# a rebase rewrites it. A rebase in worktrees/- is on an issue branch and passes. +# +# It fails open: a rung that cannot tell whether the branch is the default branch exits 0, since refusing every +# worktree rebase in a clone that cannot answer would be worse than the gap. The one exception is below. +# +# git passes as $1, and as $2 only for `git rebase `; an empty $2 means +# the branch checked out here. + +set -o nounset -o pipefail + +upstream=${1:-} +branch=${2:-} +[[ -n "${branch}" ]] || branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) +[[ -n "${branch}" ]] || exit 0 + +default_branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) +[[ -n "${default_branch}" ]] || default_branch=main +[[ "${branch}" == "${default_branch}" ]] || exit 0 + +# A branch with no commit of its own has nothing a rebase would replay: it is a fast-forward, and passes. Once +# the branch is known to be the default branch, an that cannot be resolved is refused, not passed: +# this is the one rung where "cannot tell" means no. +if [[ -n "${upstream}" ]] +then + unique=$(git rev-list --count "${upstream}..${branch}" 2>/dev/null) && (( unique == 0 )) && exit 0 +fi + +# The branch name goes through printf, never into the heredoc, since a ref may carry a backtick or a dollar. +printf '\xe2\x9d\x8c refusing to rebase %s: it is the default branch, and the default branch is never rebased.\n' \ + "'${branch}'" >&2 +cat >&2 <<'MESSAGE' + + The default branch is the human's desk. It advances by fast-forward: git pull --ff-only + A worktree's branch is rebased in that worktree: git -C worktrees/- rebase main + + If you mean it, the escape hatch is git's own, on the rebase itself: git rebase --no-verify +MESSAGE +exit 1 + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/kit.just b/.kit/kit.just new file mode 100644 index 0000000..ecb46dc --- /dev/null +++ b/.kit/kit.just @@ -0,0 +1,67 @@ +# kit.just - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +# The kit's just module. A consumer's root justfile takes it with `mod kit ".kit/kit.just"`, which gives +# `just kit issue`, `just kit tidy`, and `just kit status`. Each recipe carries [no-cd] so its body runs +# from the checkout it was invoked in, and the bash behind each lives in .kit/bash/, loaded through BASH_ENV. +# +# A root justfile that wants the bare spelling adds its own veneer: +# +# issue *numbers: +# just kit issue "$@" + +set dotenv-load := true +set fallback := true +set positional-arguments := true + +export BASH_ENV := source_directory() / "bash/1st.sh" + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "{{source_file()}}" --list + +set export := true + +# Start, or return to, work on an issue: a branch linked to it under Development, a worktree at +# worktrees/-, a start commit, and a draft pull request. Further numbers are secondary issues the +# pull request closes. Safe to re-run; it never switches the checkout it runs from. +# +# just kit issue 42 # start #42 +# just kit issue 42 43 # start #42, and close #43 with it +# just kit issue # ask for a title, create the issue, and start it (needs a terminal) +[doc("🌿 Start or resume an issue: linked branch, worktree, start commit, draft pull request")] +[no-cd] +issue *numbers: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + gh:issue "$@" + +# Sweep the checkout back to pristine: remove worktrees whose pull request is merged or closed, prune the +# bookkeeping, and delete local branches whose upstream is gone. The default branch, a dirty worktree, and the +# worktree this runs in are never touched. +# +# just kit tidy # sweep +# just kit tidy --dry-run # say what a sweep would do +[doc("🧹 Remove merged worktrees and gone branches")] +[no-cd] +tidy *flags: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + git:tidy "$@" + +# Show the main checkout and every worktree under worktrees/: branch, pull request, and dirty or clean. Reads +# only. +# +# just kit status # fetch --prune, then render +# just kit status --no-fetch # render from the current remote-tracking refs +[doc("📊 Show every worktree with its branch, pull request, and state")] +[no-cd] +status *flags: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + git:status "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md new file mode 100644 index 0000000..786ba9c --- /dev/null +++ b/.kit/rules/bash.md @@ -0,0 +1,43 @@ +# Bash + +Applies to `*.sh`, `*.bash`, `.kit/hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +workflows. + +## Shape + +1. An executed script opens with `#!/usr/bin/env bash`, a `# - ` banner, a rule of dashes to + column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they + would change the caller's. +3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. +4. The file closes with the same rule of dashes. + +## Style + +1. `then`, `do`, and `else` go on their own line, never after a semicolon. +2. Spell `set` options out: `set -o errexit`, never `set -e`. +3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. + Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. +5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. +6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. +7. Quote every expansion outside `[[ ]]` and `(( ))`. +8. A function in a sourced module is named `:`, the module being the file's basename. +9. A function private to one executed script is bare kebab-case. + +## Behavior + +1. An error message is one line, names the remedy first, and is the same wherever the same condition arises. +2. A helper that refuses returns non-zero and lets the caller end the script. It does not `exit` itself. +3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. +4. Diagnostics go to stderr. Stdout carries data a caller can pipe. +5. A command that mutates a remote, a branch, or a worktree is guarded by a check that names what it is about + to do, and a `--dry-run` where the mutation is a sweep. +6. The default branch is read from `origin/HEAD`, with `main` as the fallback, never hard-coded. +7. shellcheck passes with no directive, or the directive carries the reason on the same line. + +## Comments + +1. A comment states what the code does and the non-obvious why. It carries no history and defends no obvious + choice. +2. A pointer is `# see: `, on its own line after the comment it supports. diff --git a/.kit/rules/gitignore.md b/.kit/rules/gitignore.md new file mode 100644 index 0000000..bc034de --- /dev/null +++ b/.kit/rules/gitignore.md @@ -0,0 +1,24 @@ +# Ignore lists + +Applies to `.gitignore` at any depth. + +## Shape + +1. The file opens with `# .gitignore - untracked files and folders`, a rule of dashes to column 120, and a + blank line. +2. Entries are grouped. Each group opens with `# ; see: ` and is followed by a blank + line. +3. The file closes with a rule of dashes. + +## Entries + +1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the + user's global ignore list, not here. +2. An entry lives in the deepest directory where it applies. The root carries only patterns that span the + repository. +3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. +4. `worktrees/` is ignored at the root: it is where the loop checks out a branch per issue. + +## Adding one + +Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/.kit/rules/json.md b/.kit/rules/json.md new file mode 100644 index 0000000..b11ca35 --- /dev/null +++ b/.kit/rules/json.md @@ -0,0 +1,21 @@ +# JSON + +Applies to `*.json` and `*.jsonc`. + +## Shape + +1. Two-space indentation. Keys in a stable order: the order the consuming tool documents, else alphabetical. +2. One trailing newline. No trailing commas in `.json`. +3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where + it reads plain JSON, the reason for a setting lives in the document that describes the file. + +## Settings + +1. A disabled linter rule carries a comment naming the rule and the convention it would fight. +2. A tool's settings file that the kit writes into is merged by key. The kit owns only the keys and entries + it writes, and anything else in the file is the repository's. + +## Comments + +1. A comment states what the entry does and the non-obvious why. It carries no history. +2. A pointer is `// see: `. diff --git a/.kit/rules/just.md b/.kit/rules/just.md new file mode 100644 index 0000000..fe8b9a1 --- /dev/null +++ b/.kit/rules/just.md @@ -0,0 +1,44 @@ +# Just + +Applies to `justfile` and `*.just`. A recipe body under `#!/usr/bin/env bash` is bash, and the bash rule +applies to it as well. + +## Shape + +1. A module file opens with `# - see: https://just.systems/man/en/`, a rule of dashes to column 120, and + a description of what the file is and who imports it. +2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the + root, which has no parent), `set positional-arguments := true`, and `set export := true`. +3. Every justfile carries the default-and-help pair: + + ```just + # Default recipe: list this justfile's recipes. + _: help + + # List recipes in this justfile. + [no-exit-message] + @help *options: + just --justfile "{{source_file()}}" --list + ``` + +4. A module the root takes with `mod ""` carries `[no-cd]` on every recipe, so its body runs from + the invoking root rather than the module's directory. +5. The file closes with a rule of dashes. + +## Recipes + +1. Every recipe carries `[doc(" ")]`. Related recipes share an emoji. +2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe + does, when to run it, and what it produces. +3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. +4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. +5. Recipe bodies use `${var}`, never `{{var}}` mixed in. `set export := true` makes parameters available. +6. Two-space indentation inside a body. Never a tab. +7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. +8. Comments wrap at 118 columns. Bodies do not wrap. + +## Invocation + +1. The idiom is the space form, `just kit issue 42`, never `just kit/issue` and never `kit::issue` outside a + dependency list. +2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md new file mode 100644 index 0000000..8f8da0e --- /dev/null +++ b/.kit/rules/markdown.md @@ -0,0 +1,39 @@ +# Markdown + +Applies to `*.md`. + +## Prose + +1. Write plain declarative sentences with the thing under discussion as the subject. +2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". +3. Use a list where the content is a list. Use a table where the content has columns. +4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. +5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. +6. Use the serial comma in every list of three or more. +7. Expand an uncommon acronym the first time it appears. +8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline + when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. + +## Layout + +1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns + per nesting level, to a limit of 12. +2. Balance the wrap: no line, in particular the last, reads as a stub. +3. Do not wrap fenced code, tables, HTML comments, or a line whose only content is a link. +4. Keep a list tight or loose as it was written. Reflow never flips one to the other. +5. Headings are ATX (`#`). One `#` heading per file, at the top. +6. Fenced code blocks declare a language and have a blank line on each side. +7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. + +## Links + +1. Use inline links with descriptive text, never a bare URL in angle brackets. +2. Use the same anchor text every time the same URL appears in a file. +3. When a label and the link text would say the same thing, make the label the link. +4. A relative link resolves from the file that carries it, and it resolves: a check reads them. + +## Files + +1. Name an index file `1st.md`. It lists its siblings and says what each is for. +2. A file says what its subject is in its first paragraph, before how to use it. +3. No file names another repository as the source of a rule. diff --git a/.kit/rules/yaml.md b/.kit/rules/yaml.md new file mode 100644 index 0000000..b39eca6 --- /dev/null +++ b/.kit/rules/yaml.md @@ -0,0 +1,29 @@ +# YAML + +Applies to `*.yml` and `*.yaml`, which here means GitHub Actions workflows. + +## Shape + +1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a + reader would not guess from the keys. +2. Two-space indentation. No tabs. No trailing whitespace. +3. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. +4. One job per scope, one step per policy. A job's key is the check's name a ruleset requires, so renaming it + breaks the requirement. +5. `permissions` is declared at the top and names the least the jobs need. + +## Steps + +1. Every `uses:` pins a full commit SHA with the version as a trailing comment: `@ # v1.2.3`. +2. A step has a `name` that is the policy it enforces, in kebab-case. +3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. +4. A refusal prints `::error::`, writes `### ❌ ` and a remedy to `${GITHUB_STEP_SUMMARY}`, + and returns non-zero. The remedy is the action to take, with a link that is that action. +5. A pass writes one `✅` line to the summary. +6. A later step that must run after an earlier refusal carries `if: ${{ !cancelled() }}`. +7. A tool is on `PATH` because `mise.toml` pins it and `jdx/mise-action` installs it, never by `curl` in a step. + +## Comments + +1. A comment states the non-obvious why. It carries no history. +2. A pointer is `# see: `. diff --git a/.kit/settings.json b/.kit/settings.json new file mode 100644 index 0000000..7911d58 --- /dev/null +++ b/.kit/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "deny": [ + "Bash(git checkout main *)", + "Bash(git switch main *)", + "Bash(git reset --hard *)", + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git worktree remove --force *)" + ] + } +} diff --git a/.kit/version b/.kit/version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/.kit/version @@ -0,0 +1 @@ +0.1.0 From ed72ef71c289bda4960eb51c7b6bb0cfb853a737 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:02:27 -0700 Subject: [PATCH 003/108] #1 bin/kit: install, update, and check Co-Authored-By: Claude Fable 5.1 --- bin/kit | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100755 bin/kit diff --git a/bin/kit b/bin/kit new file mode 100755 index 0000000..4b6c59f --- /dev/null +++ b/bin/kit @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# kit - install, update, and check the kit in a repository (executed entrypoint) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit install copy this release into the repository and write .kit/manifest +# kit update after the pin is bumped: replace unchanged files, three-way merge changed ones +# kit check fail when a delivered file differs from this release +# kit version print this release's version +# +# KIT_BASE= names the previous release's root for \`kit update\` on a machine without mise. +# +# The release this command belongs to is the directory above bin/: its .kit/ is the payload. The repository is +# the git checkout the command runs in. A delivered file is one of: every file under .kit/, except manifest; +# .claude/rules/kit-.md, each the rule file of that kind with a paths: header so a harness loads it by +# path; and .github/workflows/kit-ci.yml, which is .kit/ci.yml placed where GitHub reads it. .claude/settings.json +# is shared with the repository, so it is merged by key: the kit owns only the deny entries it writes. +# +# see: .kit/1st.md + +set -o errexit -o nounset -o pipefail + +kit_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +payload="${kit_root}/.kit" +# shellcheck source=.kit/bash/1st.sh +source "${payload}/bash/1st.sh" + +usage() { + sed -n '5,8p' "${BASH_SOURCE[0]}" | sed 's/^# *//' >&2 +} + +# digest prints the sha256 of stdin, by whichever tool the machine carries. +digest() { + if command -v sha256sum >/dev/null 2>&1 + then + sha256sum | cut -d ' ' -f 1 + else + shasum -a 256 | cut -d ' ' -f 1 + fi +} + +# rule-paths prints the paths: globs a harness loads the rule of for, one per line. +rule-paths() { + case "$1" in + (markdown) printf '%s\n' '**/*.md' ;; + (bash) printf '%s\n' '**/*.sh' '**/*.bash' '.kit/hooks/*' ;; + (just) printf '%s\n' 'justfile' '**/justfile' '**/*.just' ;; + (yaml) printf '%s\n' '**/*.yml' '**/*.yaml' ;; + (json) printf '%s\n' '**/*.json' '**/*.jsonc' ;; + (gitignore) printf '%s\n' '.gitignore' '**/.gitignore' ;; + (*) printf '%s\n' "**/*.$1" + esac +} + +# delivered-paths prints every repository path the release at delivers. +delivered-paths() { + local root=$1 file + while IFS= read -r file + do + [[ "${file}" == manifest ]] && continue + printf '.kit/%s\n' "${file}" + done < <(cd "${root}" && find . -type f | sed 's@^\./@@' | sort) + for file in "${root}"/rules/*.md + do + printf '.claude/rules/kit-%s\n' "$(basename "${file}")" + done + printf '.github/workflows/kit-ci.yml\n' +} + +# expected prints the content the release at delivers at repository . +expected() { + local root=$1 path=$2 kind glob + case "${path}" in + (.kit/*) + cat "${root}/${path#.kit/}" + ;; + (.claude/rules/kit-*.md) + kind=${path#.claude/rules/kit-} + kind=${kind%.md} + printf -- '---\npaths:\n' + while IFS= read -r glob + do + printf ' - "%s"\n' "${glob}" + done < <(rule-paths "${kind}") + printf -- '---\n' + cat "${root}/rules/${kind}.md" + ;; + (.github/workflows/kit-ci.yml) + cat "${root}/ci.yml" + ;; + (*) + error "kit: '${path}' is not a delivered path" + return 2 + esac +} + +# write-expected writes the delivered content at , making its directory. +write-expected() { + mkdir -p "$(dirname "$2")" + expected "$1" "$2" > "$2.kit-new" + mv "$2.kit-new" "$2" + [[ "$2" == .kit/hooks/* ]] && chmod +x "$2" + return 0 +} + +# merge-settings [] merges the deny entries into .claude/settings.json: the base +# release's entries go, the new release's come, and everything else in the file stays as it was. +merge-settings() { + local new_root=$1 base_root=${2:-} base_deny='[]' + [[ -n "${base_root}" && -f "${base_root}/settings.json" ]] && base_deny=$(jq '.permissions.deny // []' "${base_root}/settings.json") + mkdir -p .claude + [[ -f .claude/settings.json ]] || printf '{}\n' > .claude/settings.json + jq --indent 2 \ + --argjson base "${base_deny}" \ + --argjson new "$(jq '.permissions.deny // []' "${new_root}/settings.json")" \ + '.permissions.deny = (((.permissions.deny // []) - $base) as $kept | $kept + ($new - $kept))' \ + .claude/settings.json > .claude/settings.json.kit-new + mv .claude/settings.json.kit-new .claude/settings.json +} + +# ensure-ignored makes sure worktrees/ is ignored at the root, the way the loop needs. +ensure-ignored() { + local separator="" + if [[ -f .gitignore ]] && grep --quiet --line-regexp --extended-regexp '/?worktrees/' .gitignore + then + return 0 + fi + [[ -f .gitignore && -s .gitignore ]] && separator=$'\n' + printf '%s# the per-issue worktrees of the loop; see: .kit/1st.md\n/worktrees/\n' "${separator}" >> .gitignore +} + +# write-manifest records the release version and, per delivered path, the digest of the release's own content +# for it, never of the file as written: a later update tells an untouched file from an edited one by comparing +# the file to this, so a merge that kept a repository's edit is not mistaken for the release's text. +write-manifest() { + local path + { + printf 'version %s\n' "$(cat "${payload}/version")" + while IFS= read -r path + do + printf '%s %s\n' "$(expected "${payload}" "${path}" | digest)" "${path}" + done < <(delivered-paths "${payload}") + } > .kit/manifest +} + +# finish sets the clone's hooks path and reports. +finish() { + git config core.hooksPath .kit/hooks + ok "kit $(cat "${payload}/version") is installed: core.hooksPath is .kit/hooks, and .kit/manifest is written." + step "Commit the delivered files. The loop is in .kit/1st.md." +} + +cmd-install() { + guard:require tool:jq tool:git + if [[ -f .kit/manifest ]] + then + error "run: kit update (this repository already carries kit $(sed -n 's/^version //p' .kit/manifest))" + return 1 + fi + section "kit install $(cat "${payload}/version")" + local path + while IFS= read -r path + do + step "${path}" + write-expected "${payload}" "${path}" + done < <(delivered-paths "${payload}") + merge-settings "${payload}" + ensure-ignored + write-manifest + finish +} + +cmd-update() { + guard:require tool:jq tool:git + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi + local previous current base_root="" path recorded actual conflicts=0 + previous=$(sed -n 's/^version //p' .kit/manifest) + current=$(cat "${payload}/version") + section "kit update ${previous} → ${current}" + + # The previous release is the base of every three-way merge. KIT_BASE names its root directly; otherwise + # mise keeps it where it installed it. + if [[ -n "${KIT_BASE:-}" ]] + then + base_root=${KIT_BASE} + elif [[ "${previous}" != "${current}" ]] && command -v mise >/dev/null 2>&1 + then + base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) + if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null + then + base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) + fi + fi + [[ -n "${base_root}" ]] && base_root="${base_root}/.kit" + + while IFS= read -r path + do + if [[ ! -f "${path}" ]] + then + step "${path}: missing, written" + write-expected "${payload}" "${path}" + continue + fi + recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) + actual=$(digest < "${path}") + if [[ -z "${recorded}" || "${recorded}" == "${actual}" ]] + then + step "${path}: unchanged since ${previous}, replaced" + write-expected "${payload}" "${path}" + continue + fi + if [[ -z "${base_root}" || ! -d "${base_root}" ]] + then + error "${path} was edited and the base release ${previous} is not installed; run: mise install github:empowerite/kit@${previous}" + conflicts=$(( conflicts + 1 )) + continue + fi + step "${path}: edited since ${previous}, three-way merged" + expected "${base_root}" "${path}" > "${path}.kit-base" + expected "${payload}" "${path}" > "${path}.kit-new" + if ! git merge-file --marker-size 7 "${path}" "${path}.kit-base" "${path}.kit-new" + then + warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" + conflicts=$(( conflicts + 1 )) + fi + rm -f "${path}.kit-base" "${path}.kit-new" + done < <(delivered-paths "${payload}") + + merge-settings "${payload}" "${base_root}" + ensure-ignored + write-manifest + if (( conflicts > 0 )) + then + error "${conflicts} file(s) need a hand; see above." + return 1 + fi + finish +} + +cmd-check() { + local path findings=0 version + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi + version=$(sed -n 's/^version //p' .kit/manifest) + if [[ "${version}" != "$(cat "${payload}/version")" ]] + then + error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${payload}/version"); run: kit update" + findings=$(( findings + 1 )) + fi + while IFS= read -r path + do + if [[ ! -f "${path}" ]] + then + error "${path} is missing" + findings=$(( findings + 1 )) + elif ! expected "${payload}" "${path}" | cmp --silent - "${path}" + then + error "${path} differs from the pinned release" + findings=$(( findings + 1 )) + fi + done < <(delivered-paths "${payload}") + if [[ ! -f .claude/settings.json ]] + then + error ".claude/settings.json is missing; run: kit update" + findings=$(( findings + 1 )) + elif ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so .claude/settings.json could not be read)" + findings=$(( findings + 1 )) + elif (( $(jq --argjson new "$(jq '.permissions.deny // []' "${payload}/settings.json")" \ + '($new - (.permissions.deny // [])) | length' .claude/settings.json) > 0 )) + then + error ".claude/settings.json is missing a deny entry the kit writes; run: kit update" + findings=$(( findings + 1 )) + fi + if (( findings > 0 )) + then + error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." + return 1 + fi + ok "kit ${version}: every delivered file matches the pinned release." +} + +case "${1:-}" in + (install) cmd-install ;; + (update) cmd-update ;; + (check) cmd-check ;; + (version) cat "${payload}/version" ;; + (-h | --help | help | "") usage; [[ -n "${1:-}" ]] ;; + (*) + error "kit: unknown command '$1'" + usage + exit 2 +esac + +# ---------------------------------------------------------------------------------------------------------------------- From fe4d123fa81f569bf62ef6490c86ba0965d86561 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:02:27 -0700 Subject: [PATCH 004/108] #1 .github/workflows/release.yml: a tag on main becomes a release Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fbf936c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +# release.yml - a tag v on main becomes a GitHub release carrying the kit as one tarball +# ---------------------------------------------------------------------------------------------------------------------- +# +# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/ and .kit/, the layout +# a consumer's mise unpacks and puts on PATH. Two refusals: a tag whose commit is not on main, and a tag whose +# version is not the one .kit/version records, so the release's version and the kit's own agree. +# +# see: .kit/1st.md + +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: tag-is-on-main + run: | + set -o errexit -o nounset -o pipefail + default_branch=$(jq --raw-output '.repository.default_branch' "${GITHUB_EVENT_PATH}") + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${default_branch}" + then + echo "::error::${GITHUB_REF_NAME} is not on ${default_branch}; delete the tag and tag a commit on ${default_branch}" + exit 1 + fi + echo "✅ ${GITHUB_REF_NAME} is on ${default_branch}." + + - name: tag-matches-version + run: | + set -o errexit -o nounset -o pipefail + tag_version=${GITHUB_REF_NAME#v} + file_version=$(cat .kit/version) + if [[ "${tag_version}" != "${file_version}" ]] + then + echo "::error::the tag says ${tag_version} and .kit/version says ${file_version}; set .kit/version on main first, then tag" + exit 1 + fi + echo "✅ .kit/version is ${file_version}." + + - name: pack-and-release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o errexit -o nounset -o pipefail + version=${GITHUB_REF_NAME#v} + staging=$(mktemp --directory) + mkdir "${staging}/kit-${version}" + cp --recursive bin .kit "${staging}/kit-${version}/" + rm --force "${staging}/kit-${version}/.kit/manifest" + tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" + sha256sum "kit-${version}.tar.gz" > "kit-${version}.tar.gz.sha256" + notes=$(printf 'Pin it in mise.toml:\n\n [tools]\n "github:empowerite/kit" = "%s"\n\nThen, in the repository: kit install\n' "${version}") + flags=() + [[ "${version}" == *-* ]] && flags+=(--prerelease) + gh release create "${GITHUB_REF_NAME}" "kit-${version}.tar.gz" "kit-${version}.tar.gz.sha256" \ + --title "kit ${version}" --notes "${notes}" "${flags[@]}" + echo "✅ Released kit ${version}." + +# ---------------------------------------------------------------------------------------------------------------------- From 9a430d2b38a9f272781eee5dc4814b22f446f021 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:02:27 -0700 Subject: [PATCH 005/108] #1 justfile, mise.toml, tests: the gate Co-Authored-By: Claude Fable 5.1 --- justfile | 50 ++++++++++ mise.lock | 230 +++++++++++++++++++++++++++++++++++++++++++++ mise.toml | 16 ++++ tests/hooks.bats | 67 +++++++++++++ tests/kit.bats | 137 +++++++++++++++++++++++++++ tests/release.bats | 35 +++++++ 6 files changed, 535 insertions(+) create mode 100644 justfile create mode 100644 mise.lock create mode 100644 mise.toml create mode 100644 tests/hooks.bats create mode 100644 tests/kit.bats create mode 100644 tests/release.bats diff --git a/justfile b/justfile new file mode 100644 index 0000000..7b6b2d1 --- /dev/null +++ b/justfile @@ -0,0 +1,50 @@ +#!/usr/bin/env just --justfile +# justfile - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +# The root justfile: the kit's own module, taken the way a consumer takes it, and the gate. + +set dotenv-load := true +set positional-arguments := true + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "{{source_file()}}" --list + +set export := true + +[doc("📁 The kit's recipes: issue, tidy, status (invoke as `just kit `)")] +mod kit ".kit/kit.just" + +# The gate a pull request passes: shellcheck over every bash file, actionlint over every workflow, the bats +# suite over bin/kit, and bin/kit check over this repository's installed copy of its own kit. Every tool comes +# from mise.toml. +# +# just ci +[doc("🚦 Run the gate: shellcheck, actionlint, bats, kit check")] +ci: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + mise exec -- shellcheck --shell=bash --external-sources bin/kit .kit/bash/*.sh .kit/hooks/* + mise exec -- actionlint + mise exec -- bats --print-output-on-failure tests/ + bin/kit check + echo "✅ ci passed" + +# Reinstall this repository's own kit from its source. Here the source under .kit/ is the payload, so an edit to +# a delivered file is an edit to the release, not a consumer's drift: the installed copies, .claude/settings.json +# (which carries only the kit's entries here), and .kit/manifest are rewritten from it. Run after changing any +# delivered file, and commit the result with the change. +# +# just refresh +[doc("🔁 Reinstall this repository's own kit from .kit/ and bin/kit")] +refresh: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + rm -f .kit/manifest .claude/settings.json + bin/kit install + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..9537f2b --- /dev/null +++ b/mise.lock @@ -0,0 +1,230 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.actionlint]] +version = "1.7.12" +backend = "aqua:rhysd/actionlint" +specifiers = ["1.7"] + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-arm64-musl"] +checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-x64-musl"] +checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" +provenance = "github-attestations" + +[tools.actionlint."platforms.windows-x64"] +checksum = "sha256:6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_windows_amd64.zip" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924919" +provenance = "github-attestations" + +[[tools.gh]] +version = "2.103.0" +backend = "aqua:cli/cli" +specifiers = ["2.103"] + +[tools.gh."platforms.linux-arm64"] +checksum = "sha256:7f322df34e624d14f5ec0c4acb121fd6dfa14392fdbbd1066aa8e0a05ac3721e" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956401" +provenance = "github-attestations" + +[tools.gh."platforms.linux-arm64-musl"] +checksum = "sha256:7f322df34e624d14f5ec0c4acb121fd6dfa14392fdbbd1066aa8e0a05ac3721e" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956401" +provenance = "github-attestations" + +[tools.gh."platforms.linux-x64"] +checksum = "sha256:fdd768eb70f6409f80fcfa9f3a1c0adf0fc98d827ab3ce0bed2ac22762c5d786" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956379" +provenance = "github-attestations" + +[tools.gh."platforms.linux-x64-musl"] +checksum = "sha256:fdd768eb70f6409f80fcfa9f3a1c0adf0fc98d827ab3ce0bed2ac22762c5d786" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956379" +provenance = "github-attestations" + +[tools.gh."platforms.macos-arm64"] +checksum = "sha256:54be566e9f614bea5de8021e95b9ad33dfee3b9d2ea120c9c294c00defaa5014" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_macOS_arm64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956481" +provenance = "github-attestations" + +[tools.gh."platforms.macos-x64"] +checksum = "sha256:64d1734941f104f996271c820daa50e96902c2618f9b4c1e63c41519517a9c6b" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_macOS_amd64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956458" +provenance = "github-attestations" + +[tools.gh."platforms.windows-x64"] +checksum = "sha256:80c846a5b8432672ca6373b34ce9de998c9686aaa1885c845f2e1ee81f713726" +url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_windows_amd64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956535" +provenance = "github-attestations" + +[[tools.jq]] +version = "1.8.2" +backend = "aqua:jqlang/jq" +specifiers = ["1.8"] + +[tools.jq."platforms.linux-arm64"] +checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756" +provenance = "github-attestations" + +[tools.jq."platforms.linux-arm64-musl"] +checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756" +provenance = "github-attestations" + +[tools.jq."platforms.linux-x64"] +checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752" +provenance = "github-attestations" + +[tools.jq."platforms.linux-x64-musl"] +checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752" +provenance = "github-attestations" + +[tools.jq."platforms.macos-arm64"] +checksum = "sha256:2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012783" +provenance = "github-attestations" + +[tools.jq."platforms.macos-x64"] +checksum = "sha256:e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012782" +provenance = "github-attestations" + +[tools.jq."platforms.windows-x64"] +checksum = "sha256:a6fc67fedaf9128a3309a1e2ebb8b986aeccf70122ee46d2cb4849e423f0c627" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-windows-amd64.exe" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012788" +provenance = "github-attestations" + +[[tools.just]] +version = "1.59.0" +backend = "aqua:casey/just" +specifiers = ["1.59"] + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:23871b1702bfd550fb5f901778fd4290ecf2ee034a45a889fbe96d89b188086d" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642950086" + +[tools.just."platforms.linux-arm64-musl"] +checksum = "sha256:23871b1702bfd550fb5f901778fd4290ecf2ee034a45a889fbe96d89b188086d" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642950086" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:409044617b6f06517c1035e55ca38c8c856ff8b650e41bc2aa1ef82d5903a23e" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642949555" + +[tools.just."platforms.linux-x64-musl"] +checksum = "sha256:409044617b6f06517c1035e55ca38c8c856ff8b650e41bc2aa1ef82d5903a23e" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642949555" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:6574cea774ed4cae12e24b6aa11857de33e80dab4667cf628fd5840df96a2a9d" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/643129724" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:036ead9df9bc31dbae9934ae3b4236ed124d0bafcffd33439460ae084d5c9cb7" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/643131819" + +[tools.just."platforms.windows-x64"] +checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea9c0" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" + +[[tools."npm:bats"]] +version = "1.13.0" +aube = { path = ".mise/locks/npm-bats/1.13.0", digest = "sha256:b3795a7982457f07d0e04a547944558c3571830dd86994d282099c4c27082169" } +backend = "npm:bats" +specifiers = ["1.13"] + +[[tools.shellcheck]] +version = "0.11.0" +backend = "aqua:koalaman/shellcheck" +specifiers = ["0.11"] + +[tools.shellcheck."platforms.linux-arm64"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-arm64-musl"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-x64"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.linux-x64-musl"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.macos-arm64"] +checksum = "sha256:56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056932" + +[tools.shellcheck."platforms.macos-x64"] +checksum = "sha256:3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056930" + +[tools.shellcheck."platforms.windows-x64"] +checksum = "sha256:8a4e35ab0b331c85d73567b12f2a444df187f483e5079ceffa6bda1faa2e740e" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.zip" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056944" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..3b0f587 --- /dev/null +++ b/mise.toml @@ -0,0 +1,16 @@ +# mise.toml - the pinned toolchain; see: https://mise.jdx.dev/configuration.html +# +# Every tool this repository runs, at one version, installed the same way on a desk (`mise install`) and on a +# runner (`jdx/mise-action`). `lockfile = true` records each pin's exact resolution in mise.lock. git and bash +# come from the machine. + +[settings] +lockfile = true + +[tools] +actionlint = "1.7" +gh = "2.103" +jq = "1.8" +just = "1.59" +shellcheck = "0.11" +"npm:bats" = "1.13" diff --git a/tests/hooks.bats b/tests/hooks.bats new file mode 100644 index 0000000..faccb2d --- /dev/null +++ b/tests/hooks.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +# hooks.bats - the desk guard: pre-rebase refuses the default branch, pre-commit refuses a lint finding +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + git config core.hooksPath "${kit_checkout}/.kit/hooks" + printf 'one\n' > a && git add a && git commit --quiet --message one +} + +teardown() { + rm -rf "${repo}" +} + +@test "pre-rebase refuses a rebase of main that would rewrite a commit, and allows one in a branch" { + git switch --quiet --create feature + printf 'two\n' > b && git add b && git commit --quiet --message two + git switch --quiet main + printf 'three\n' > c && git add c && git commit --quiet --message three + run git rebase feature + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase 'main'"* ]] + git switch --quiet feature + run git rebase main + [ "$status" -eq 0 ] +} + +@test "pre-rebase lets main fast-forward" { + git switch --quiet --create feature + printf 'two\n' > b && git add b && git commit --quiet --message two + git switch --quiet main + run git rebase feature + [ "$status" -eq 0 ] +} + +@test "pre-commit refuses a bash file shellcheck faults, and passes a clean one" { + printf '#!/usr/bin/env bash\necho $x\n' > bad.sh + git add bad.sh + run git commit --quiet --message bad + [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] + [[ "$output" == *"refusing the commit"* ]] + printf '#!/usr/bin/env bash\necho "${x:-}"\n' > bad.sh + git add bad.sh + run git commit --quiet --message good + [ "$status" -eq 0 ] +} + +@test "pre-commit reports a skip when the linter is not on PATH" { + printf '#!/usr/bin/env bash\necho $x\n' > bad.sh + git add bad.sh + # a PATH without any directory that carries a shellcheck + without="" + while IFS= read -r dir + do + [[ -n "${dir}" && ! -x "${dir}/shellcheck" ]] && without="${without:+${without}:}${dir}" + done < <(printf '%s' "${PATH}" | tr ':' '\n') + run env PATH="${without}" git commit --quiet --message bad + [ "$status" -eq 0 ] + [[ "$output" == *"NOT linted"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/kit.bats b/tests/kit.bats new file mode 100644 index 0000000..3da2a8f --- /dev/null +++ b/tests/kit.bats @@ -0,0 +1,137 @@ +#!/usr/bin/env bats +# kit.bats - bin/kit install, check, and update against a fresh repository +# ---------------------------------------------------------------------------------------------------------------------- +# +# Each test gets an empty git repository under a temporary directory and runs the kit from this checkout against +# it. The update tests build a second, later payload from the first so the three-way merge has a base. + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + repo=$(mktemp -d) + git -C "${repo}" init --quiet --initial-branch=main + cd "${repo}" +} + +teardown() { + rm -rf "${repo}" +} + +# later-release copies this checkout's kit to as version 9.9.9 with one changed rule line. +later-release() { + mkdir -p "$1" + cp -R "${kit_checkout}/bin" "${kit_checkout}/.kit" "$1/" + rm -f "$1/.kit/manifest" + printf '9.9.9\n' > "$1/.kit/version" + printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/.kit/rules/json.md" +} + +@test "install writes the delivered tree, the mirrors, the workflow, the settings, and the manifest" { + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ -f .kit/1st.md ] + [ -x .kit/hooks/pre-commit ] + [ -f .claude/rules/kit-markdown.md ] + head -n 1 .claude/rules/kit-bash.md | grep --quiet --line-regexp -- '---' + grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md + [ -f .github/workflows/kit-ci.yml ] + jq --exit-status '.permissions.deny | index("Bash(git push --force *)")' .claude/settings.json >/dev/null + grep --quiet --line-regexp '/worktrees/' .gitignore + grep --quiet '^version 0.1.0$' .kit/manifest + [ "$(git config core.hooksPath)" = ".kit/hooks" ] +} + +@test "install keeps the repository's own settings keys and deny entries" { + mkdir .claude + printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json + "${kit_checkout}/bin/kit" install + [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] + jq --exit-status '.permissions.deny | index("Bash(rm -rf /)")' .claude/settings.json >/dev/null + jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null +} + +@test "install refuses a second time and names update" { + "${kit_checkout}/bin/kit" install + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"run: kit update"* ]] +} + +@test "check passes after install and fails on a drifted, a missing, and a lost deny entry" { + "${kit_checkout}/bin/kit" install + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + printf 'edited\n' >> .kit/rules/yaml.md + rm .claude/rules/kit-json.md + jq '.permissions.deny -= ["Bash(git reset --hard *)"]' .claude/settings.json > s && mv s .claude/settings.json + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/yaml.md differs"* ]] + [[ "$output" == *".claude/rules/kit-json.md is missing"* ]] + [[ "$output" == *"missing a deny entry"* ]] + [[ "$output" == *"3 finding(s)"* ]] +} + +@test "check refuses without a manifest and names install" { + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"run: kit install"* ]] +} + +@test "update replaces unchanged files, merges an edited one, and records the release's digests" { + "${kit_checkout}/bin/kit" install + later-release "${repo}.later" + # a consumer edit to a file the later release also changes, on a different line + sed -i.bak '1s/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 0 ] + grep --quiet '^version 9.9.9$' .kit/manifest + grep --quiet '^# JSON (ours)$' .kit/rules/json.md + grep --quiet '^## Added in 9.9.9$' .kit/rules/json.md + grep --quiet '^## Added in 9.9.9$' .claude/rules/kit-json.md + # the manifest carries the release's digest, so the edit still reads as an edit + release_digest=$(shasum -a 256 "${repo}.later/.kit/rules/json.md" | cut -d ' ' -f 1) + grep --quiet "^${release_digest} .kit/rules/json.md$" .kit/manifest + # and check names the one file that still differs from the release + run "${repo}.later/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/json.md differs"* ]] + [[ "$output" == *"1 finding(s)"* ]] + rm -rf "${repo}.later" +} + +@test "update of an untouched install leaves check green" { + "${kit_checkout}/bin/kit" install + later-release "${repo}.later" + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 0 ] + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] + rm -rf "${repo}.later" +} + +@test "update leaves conflict markers and fails when the same lines changed" { + "${kit_checkout}/bin/kit" install + later-release "${repo}.later" + printf '9.9.9\n' > "${repo}.later/.kit/version" + sed -i.bak '1s/^# JSON$/# JSON (theirs)/' "${repo}.later/.kit/rules/json.md" && rm "${repo}.later/.kit/rules/json.md.bak" + sed -i.bak '1s/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + grep --quiet '^<<<<<<<' .kit/rules/json.md + [[ "$output" == *"conflict markers"* ]] + rm -rf "${repo}.later" +} + +@test "update without the base release refuses an edited file and names the base" { + "${kit_checkout}/bin/kit" install + later-release "${repo}.later" + printf 'edited\n' >> .kit/rules/bash.md + # a mise that answers nothing, ahead of the real one + mkdir shims && printf '#!/usr/bin/env bash\nexit 1\n' > shims/mise && chmod +x shims/mise + run env PATH="${PWD}/shims:${PATH}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *"base release 0.1.0 is not installed"* ]] + rm -rf "${repo}.later" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/release.bats b/tests/release.bats new file mode 100644 index 0000000..e5a0494 --- /dev/null +++ b/tests/release.bats @@ -0,0 +1,35 @@ +#!/usr/bin/env bats +# release.bats - a release packed the way the workflow packs it installs from its unpacked directory +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + work=$(mktemp -d) + cd "${work}" +} + +teardown() { + rm -rf "${work}" +} + +@test "the tarball holds one directory with bin/ and .kit/, no manifest, and installs a fresh repository" { + version=$(cat "${kit_checkout}/.kit/version") + mkdir "kit-${version}" + cp -R "${kit_checkout}/bin" "${kit_checkout}/.kit" "kit-${version}/" + rm -f "kit-${version}/.kit/manifest" + tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" + rm -rf "kit-${version}" + mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked + [ "$(ls unpacked)" = "kit-${version}" ] + [ -x "unpacked/kit-${version}/bin/kit" ] + [ ! -e "unpacked/kit-${version}/.kit/manifest" ] + mkdir fresh && git -C fresh init --quiet --initial-branch=main + cd fresh + run "${work}/unpacked/kit-${version}/bin/kit" install + [ "$status" -eq 0 ] + run "${work}/unpacked/kit-${version}/bin/kit" check + [ "$status" -eq 0 ] + [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] +} + +# ---------------------------------------------------------------------------------------------------------------------- From 39a380e083ff083f5708f4de7f43ce6e78d7984c Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:02:27 -0700 Subject: [PATCH 006/108] #1 README.md, AGENTS.md: the kit uses its own kit Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-bash.md | 49 ++++++++++++ .claude/rules/kit-gitignore.md | 29 +++++++ .claude/rules/kit-json.md | 26 ++++++ .claude/rules/kit-just.md | 50 ++++++++++++ .claude/rules/kit-markdown.md | 43 ++++++++++ .claude/rules/kit-yaml.md | 34 ++++++++ .claude/settings.json | 12 +++ .github/workflows/kit-ci.yml | 141 +++++++++++++++++++++++++++++++++ .gitignore | 7 ++ .kit/manifest | 24 ++++++ AGENTS.md | 15 ++++ README.md | 56 ++++++++++--- 12 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 .claude/rules/kit-bash.md create mode 100644 .claude/rules/kit-gitignore.md create mode 100644 .claude/rules/kit-json.md create mode 100644 .claude/rules/kit-just.md create mode 100644 .claude/rules/kit-markdown.md create mode 100644 .claude/rules/kit-yaml.md create mode 100644 .claude/settings.json create mode 100644 .github/workflows/kit-ci.yml create mode 100644 .gitignore create mode 100644 .kit/manifest create mode 100644 AGENTS.md diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md new file mode 100644 index 0000000..dbecf1c --- /dev/null +++ b/.claude/rules/kit-bash.md @@ -0,0 +1,49 @@ +--- +paths: + - "**/*.sh" + - "**/*.bash" + - ".kit/hooks/*" +--- +# Bash + +Applies to `*.sh`, `*.bash`, `.kit/hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +workflows. + +## Shape + +1. An executed script opens with `#!/usr/bin/env bash`, a `# - ` banner, a rule of dashes to + column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they + would change the caller's. +3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. +4. The file closes with the same rule of dashes. + +## Style + +1. `then`, `do`, and `else` go on their own line, never after a semicolon. +2. Spell `set` options out: `set -o errexit`, never `set -e`. +3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. + Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. +5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. +6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. +7. Quote every expansion outside `[[ ]]` and `(( ))`. +8. A function in a sourced module is named `:`, the module being the file's basename. +9. A function private to one executed script is bare kebab-case. + +## Behavior + +1. An error message is one line, names the remedy first, and is the same wherever the same condition arises. +2. A helper that refuses returns non-zero and lets the caller end the script. It does not `exit` itself. +3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. +4. Diagnostics go to stderr. Stdout carries data a caller can pipe. +5. A command that mutates a remote, a branch, or a worktree is guarded by a check that names what it is about + to do, and a `--dry-run` where the mutation is a sweep. +6. The default branch is read from `origin/HEAD`, with `main` as the fallback, never hard-coded. +7. shellcheck passes with no directive, or the directive carries the reason on the same line. + +## Comments + +1. A comment states what the code does and the non-obvious why. It carries no history and defends no obvious + choice. +2. A pointer is `# see: `, on its own line after the comment it supports. diff --git a/.claude/rules/kit-gitignore.md b/.claude/rules/kit-gitignore.md new file mode 100644 index 0000000..41b87e4 --- /dev/null +++ b/.claude/rules/kit-gitignore.md @@ -0,0 +1,29 @@ +--- +paths: + - ".gitignore" + - "**/.gitignore" +--- +# Ignore lists + +Applies to `.gitignore` at any depth. + +## Shape + +1. The file opens with `# .gitignore - untracked files and folders`, a rule of dashes to column 120, and a + blank line. +2. Entries are grouped. Each group opens with `# ; see: ` and is followed by a blank + line. +3. The file closes with a rule of dashes. + +## Entries + +1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the + user's global ignore list, not here. +2. An entry lives in the deepest directory where it applies. The root carries only patterns that span the + repository. +3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. +4. `worktrees/` is ignored at the root: it is where the loop checks out a branch per issue. + +## Adding one + +Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md new file mode 100644 index 0000000..9d0859d --- /dev/null +++ b/.claude/rules/kit-json.md @@ -0,0 +1,26 @@ +--- +paths: + - "**/*.json" + - "**/*.jsonc" +--- +# JSON + +Applies to `*.json` and `*.jsonc`. + +## Shape + +1. Two-space indentation. Keys in a stable order: the order the consuming tool documents, else alphabetical. +2. One trailing newline. No trailing commas in `.json`. +3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where + it reads plain JSON, the reason for a setting lives in the document that describes the file. + +## Settings + +1. A disabled linter rule carries a comment naming the rule and the convention it would fight. +2. A tool's settings file that the kit writes into is merged by key. The kit owns only the keys and entries + it writes, and anything else in the file is the repository's. + +## Comments + +1. A comment states what the entry does and the non-obvious why. It carries no history. +2. A pointer is `// see: `. diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md new file mode 100644 index 0000000..0958e80 --- /dev/null +++ b/.claude/rules/kit-just.md @@ -0,0 +1,50 @@ +--- +paths: + - "justfile" + - "**/justfile" + - "**/*.just" +--- +# Just + +Applies to `justfile` and `*.just`. A recipe body under `#!/usr/bin/env bash` is bash, and the bash rule +applies to it as well. + +## Shape + +1. A module file opens with `# - see: https://just.systems/man/en/`, a rule of dashes to column 120, and + a description of what the file is and who imports it. +2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the + root, which has no parent), `set positional-arguments := true`, and `set export := true`. +3. Every justfile carries the default-and-help pair: + + ```just + # Default recipe: list this justfile's recipes. + _: help + + # List recipes in this justfile. + [no-exit-message] + @help *options: + just --justfile "{{source_file()}}" --list + ``` + +4. A module the root takes with `mod ""` carries `[no-cd]` on every recipe, so its body runs from + the invoking root rather than the module's directory. +5. The file closes with a rule of dashes. + +## Recipes + +1. Every recipe carries `[doc(" ")]`. Related recipes share an emoji. +2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe + does, when to run it, and what it produces. +3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. +4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. +5. Recipe bodies use `${var}`, never `{{var}}` mixed in. `set export := true` makes parameters available. +6. Two-space indentation inside a body. Never a tab. +7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. +8. Comments wrap at 118 columns. Bodies do not wrap. + +## Invocation + +1. The idiom is the space form, `just kit issue 42`, never `just kit/issue` and never `kit::issue` outside a + dependency list. +2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md new file mode 100644 index 0000000..43d822f --- /dev/null +++ b/.claude/rules/kit-markdown.md @@ -0,0 +1,43 @@ +--- +paths: + - "**/*.md" +--- +# Markdown + +Applies to `*.md`. + +## Prose + +1. Write plain declarative sentences with the thing under discussion as the subject. +2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". +3. Use a list where the content is a list. Use a table where the content has columns. +4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. +5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. +6. Use the serial comma in every list of three or more. +7. Expand an uncommon acronym the first time it appears. +8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline + when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. + +## Layout + +1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns + per nesting level, to a limit of 12. +2. Balance the wrap: no line, in particular the last, reads as a stub. +3. Do not wrap fenced code, tables, HTML comments, or a line whose only content is a link. +4. Keep a list tight or loose as it was written. Reflow never flips one to the other. +5. Headings are ATX (`#`). One `#` heading per file, at the top. +6. Fenced code blocks declare a language and have a blank line on each side. +7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. + +## Links + +1. Use inline links with descriptive text, never a bare URL in angle brackets. +2. Use the same anchor text every time the same URL appears in a file. +3. When a label and the link text would say the same thing, make the label the link. +4. A relative link resolves from the file that carries it, and it resolves: a check reads them. + +## Files + +1. Name an index file `1st.md`. It lists its siblings and says what each is for. +2. A file says what its subject is in its first paragraph, before how to use it. +3. No file names another repository as the source of a rule. diff --git a/.claude/rules/kit-yaml.md b/.claude/rules/kit-yaml.md new file mode 100644 index 0000000..69fe17c --- /dev/null +++ b/.claude/rules/kit-yaml.md @@ -0,0 +1,34 @@ +--- +paths: + - "**/*.yml" + - "**/*.yaml" +--- +# YAML + +Applies to `*.yml` and `*.yaml`, which here means GitHub Actions workflows. + +## Shape + +1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a + reader would not guess from the keys. +2. Two-space indentation. No tabs. No trailing whitespace. +3. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. +4. One job per scope, one step per policy. A job's key is the check's name a ruleset requires, so renaming it + breaks the requirement. +5. `permissions` is declared at the top and names the least the jobs need. + +## Steps + +1. Every `uses:` pins a full commit SHA with the version as a trailing comment: `@ # v1.2.3`. +2. A step has a `name` that is the policy it enforces, in kebab-case. +3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. +4. A refusal prints `::error::`, writes `### ❌ ` and a remedy to `${GITHUB_STEP_SUMMARY}`, + and returns non-zero. The remedy is the action to take, with a link that is that action. +5. A pass writes one `✅` line to the summary. +6. A later step that must run after an earlier refusal carries `if: ${{ !cancelled() }}`. +7. A tool is on `PATH` because `mise.toml` pins it and `jdx/mise-action` installs it, never by `curl` in a step. + +## Comments + +1. A comment states the non-obvious why. It carries no history. +2. A pointer is `# see: `. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7911d58 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "deny": [ + "Bash(git checkout main *)", + "Bash(git switch main *)", + "Bash(git reset --hard *)", + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git worktree remove --force *)" + ] + } +} diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml new file mode 100644 index 0000000..dd0e5f0 --- /dev/null +++ b/.github/workflows/kit-ci.yml @@ -0,0 +1,141 @@ +# kit-ci.yml - the check the kit delivers: the repository's gate, the kit's own check, and the issue link +# ---------------------------------------------------------------------------------------------------------------------- +# +# One job, CI, which is the check name a ruleset requires. Three steps: the repository's `just ci` when its +# root justfile has one, `kit check` against the release mise pins, and a refusal of a pull request that is +# not linked to an open issue of this repository under Development. The last two run even when an earlier step +# refused, so one run reports every finding. +# +# This file is placed by `kit install` and verified by `kit check`. Change it by pinning a new release. +# +# see: .kit/1st.md + +name: kit + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + CI: + if: github.event.pull_request.base.ref == github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Every tool below comes from mise.toml, the kit included. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + + - name: gate + run: | + set -o errexit -o nounset -o pipefail + if [[ ! -f justfile ]] || ! just --show ci >/dev/null 2>&1 + then + echo "::warning::the root justfile has no ci recipe, so the gate was skipped" + printf '⚠️ The gate was skipped: the root justfile has no ci recipe.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + else + just ci + printf '✅ just ci passed.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + fi + + - name: kit-check + if: ${{ !cancelled() }} + run: | + set -o errexit -o nounset -o pipefail + if command -v kit >/dev/null 2>&1 + then + kit check + elif [[ -x bin/kit ]] + then + bin/kit check + else + echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml' + printf '### ❌ kit is not on PATH\n\nPin the release in mise.toml under [tools]: "github:empowerite/kit" = "".\n' \ + | tee --append "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + printf '✅ Every delivered file matches the pinned kit release.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + + - name: linked-open-issue + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o errexit -o nounset -o pipefail + + repo_slug="${GITHUB_REPOSITORY}" + pr=$(jq --raw-output '.pull_request.number' "${GITHUB_EVENT_PATH}") + pull="${GITHUB_SERVER_URL}/${repo_slug}/pull/${pr}" + run="${GITHUB_SERVER_URL}/${repo_slug}/actions/runs/${GITHUB_RUN_ID}" + + remedy=$(cat <\` makes this link when it starts the work. + REMEDY + ) + + refuse() { + echo "::error::$1" + printf '### ❌ %s\n\n%s\n' "${1^}" "${remedy}" | tee --append "${GITHUB_STEP_SUMMARY}" + return 1 + } + + query=$(cat <<'GRAPHQL' + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100) { + nodes { number state repository { nameWithOwner } } + } + } + } + } + GRAPHQL + ) + refs=$(gh api graphql \ + --field owner="${repo_slug%%/*}" \ + --field name="${repo_slug#*/}" \ + --field number="${pr}" \ + --raw-field query="${query}" \ + --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] + | "\(.number) \(.state) \(.repository.nameWithOwner)"') + + if [[ -z "${refs}" ]] + then + refuse 'this pull request is not linked to any issue' + else + open_local=0 + while read -r n state repo + do + if [[ "${repo}" != "${repo_slug}" ]] + then + echo "::warning::skipping ${repo}#${n}: it belongs to another repository" + continue + fi + if [[ "${state}" != OPEN ]] + then + echo "::warning::${repo}#${n} is ${state}; merging will not change it" + continue + fi + open_local=$(( open_local + 1 )) + done <<< "${refs}" + if (( open_local == 0 )) + then + refuse 'every issue this pull request is linked to is closed or belongs to another repository' + else + closes=$(cut --delimiter=' ' --fields=1 <<< "${refs}" | sed 's/^/#/' | paste --serial --delimiters=' ') + printf '✅ Linked to %s.\n' "${closes}" | tee --append "${GITHUB_STEP_SUMMARY}" + fi + fi + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fb096f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# .gitignore - untracked files and folders +# ---------------------------------------------------------------------------------------------------------------------- + +# the per-issue worktrees of the loop; see: .kit/1st.md +/worktrees/ + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/manifest b/.kit/manifest new file mode 100644 index 0000000..51c397d --- /dev/null +++ b/.kit/manifest @@ -0,0 +1,24 @@ +version 0.1.0 +367c3c2d28f51dbe2549803e113ad45db507aa68afc1997f3d5b20f94796ec6f .kit/1st.md +a36a18081eb7515871bc01b7b22372c1545d99d8b393fb386c78128f73c8dbb5 .kit/bash/1st.sh +6944cdb161e68bf5d2355c6ee48fe7bfd25d8ffa1e6edef218af123e5edf5f7c .kit/bash/gh.sh +051cab217451a69c6f73545fe3e68fb2316e0be57004030159fe299d7f510436 .kit/bash/git.sh +8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .kit/ci.yml +1ab629cb79710438fb15fd2fcbadf94ce125f4d3c26bf22def6d35c8c414dbb1 .kit/hooks/pre-commit +414bb9a82191060489213823e98828a64d166c53109c64cdf600fdd3d5c64205 .kit/hooks/pre-rebase +47919ba1fabfc077ff3950f2743d94d016d58350cfe36cf97d2130419de6b6c6 .kit/kit.just +cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bash.md +5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md +6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md +dd88591e25a0df6a039f5183f0e573e35c53b9903f04cc879b743a715f8e6559 .kit/rules/just.md +df7388a40e037d82f77d92f0dfe0deed55184c4a8bf869dbce86986673ebeab5 .kit/rules/markdown.md +52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md +617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json +e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version +1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .claude/rules/kit-bash.md +4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md +1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md +3547ea76e2711f003da90817292e48cec55926e95ae2a06dc53de0954ab38508 .claude/rules/kit-just.md +13fff95ff67775bebb820d10dbd73f4f5eb23571f0619462fa5abb1b8c6b42c6 .claude/rules/kit-markdown.md +4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md +8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .github/workflows/kit-ci.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c6faa17 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +@.kit/1st.md + +Read .kit/1st.md first. It is the kit's entry file, and this repository uses its own kit. + +## This repository's own rules + +- The delivered tree is `.kit/` and `bin/kit`. Everything in it is written for a consumer: no file names a + consumer, a person, or another repository as the source of a rule. +- `.kit/version` is the release version. A release is a commit on `main` that sets it, then the tag `v` + on that commit. The release workflow refuses a tag whose version differs from the file, or whose commit is not + on `main`. +- `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are + this repository's installed copies of its own kit. Change a delivered file at its source under `.kit/`, then + run `just refresh` to rewrite the copies, and commit both. +- `just ci` is the gate: shellcheck, actionlint, the bats suite, and `bin/kit check`. diff --git a/README.md b/README.md index cda6989..a661029 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,38 @@ kit is the agentic guardrails a repository takes as a versioned mixin: the policy text an agent reads first, the rules that bind each kind of file, the guard that keeps `main` as the human's desk, and the one command no harness provides, the issue-to-worktree on-ramp. A tag on `main` publishes a release. A consumer pins it in `mise.toml`, -installs it with `kit install`, and keeps rules of its own beside it. What the first release delivers, and what the -kit is not, is [#1](https://github.com/empowerite/kit/issues/1). +installs it with `kit install`, and keeps rules of its own beside it. The design is +[#1](https://github.com/empowerite/kit/issues/1), and the entry file every agent reads is [.kit/1st.md](.kit/1st.md). + +## Take the kit + +Pin a release in the repository's `mise.toml`, then install it: + +```toml +[tools] +"github:empowerite/kit" = "0.1.0" +``` + +```sh +mise install +kit install +``` + +`kit install` copies `.kit/` into the repository, places `.claude/rules/kit-*.md` and +`.github/workflows/kit-ci.yml`, merges its deny rules into `.claude/settings.json`, ignores `worktrees/`, points +`core.hooksPath` at `.kit/hooks`, and writes `.kit/manifest`. Commit what it wrote. Then: + +- Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. +- Add `mod kit ".kit/kit.just"` to the root justfile for `just kit issue`, `just kit tidy`, and `just kit status`. +- Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull + request not linked to an open issue. + +To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is +replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries +and settings keys stay. `kit check` fails, locally and in `CI`, when a delivered file differs from the pinned +release. + +The kit needs bash 4 or later, git, `gh`, and `jq`. macOS ships bash 3.2; `brew install bash` gives a current one. ## Setup @@ -16,19 +46,23 @@ git config user.signingkey git config commit.gpgsign true ``` +Then `mise install` for the pinned tools, and `bin/kit install` is already done: this repository carries the +installed copy of its own kit, which `just ci` checks. + ## Getting started -Work here moves by the loop the kit itself delivers. An issue exists. A branch is checked out in a worktree under -`worktrees/`, linked to the issue under Development, with a draft pull request. Commits are signed, and their -subject is `# : `. `gh pr ready` says the work is done, and the pull request body is the report. -A human merges. Nothing reaches `main` any other way. +Work here moves by the loop in [.kit/1st.md](.kit/1st.md). An issue exists. `just kit issue ` makes +`worktrees/-`, links the branch to the issue under Development, and opens a draft pull request. Commits +are signed, and their subject is `# : `. `gh pr ready` says the work is done, and the pull +request body is the report. A human merges. -Until the kit can run itself, those steps are taken by hand. The first thing #1 delivers is the command that takes -them. +`just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and +`bin/kit check`. -## Status +## Release -Pre-alpha. There is no code yet, so nothing here builds, tests, or runs. The repository will use its own kit as soon -as the kit exists. +A release is two steps on `main`: a pull request that sets `.kit/version`, then the tag `v` on its merge +commit. The release workflow packs `bin/` and `.kit/` into `kit-.tar.gz` and publishes the GitHub +release. It refuses a tag that is not on `main` or whose version differs from the file. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf From a2553e38910ea5a267aaa5f4aa937b92fd894c54 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:03:41 -0700 Subject: [PATCH 007/108] #1 .mise/locks: the npm lock sidecar mise install --locked reads Co-Authored-By: Claude Fable 5.1 --- .mise/locks/npm-bats/1.13.0/aube-lock.yaml | 23 ++++++++++++++++++++++ .mise/locks/npm-bats/1.13.0/package.json | 7 +++++++ 2 files changed, 30 insertions(+) create mode 100644 .mise/locks/npm-bats/1.13.0/aube-lock.yaml create mode 100644 .mise/locks/npm-bats/1.13.0/package.json diff --git a/.mise/locks/npm-bats/1.13.0/aube-lock.yaml b/.mise/locks/npm-bats/1.13.0/aube-lock.yaml new file mode 100644 index 0000000..0bae35d --- /dev/null +++ b/.mise/locks/npm-bats/1.13.0/aube-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + bats: + specifier: 1.13.0 + version: 1.13.0 + +packages: + + bats@1.13.0: + resolution: {integrity: sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==} + hasBin: true + +snapshots: + + bats@1.13.0: {} diff --git a/.mise/locks/npm-bats/1.13.0/package.json b/.mise/locks/npm-bats/1.13.0/package.json new file mode 100644 index 0000000..43bd251 --- /dev/null +++ b/.mise/locks/npm-bats/1.13.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "bats": "1.13.0" + } +} From a71f945f70dd2c53f084de2068750777a3113f26 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:05:42 -0700 Subject: [PATCH 008/108] #1 mise.toml: bats from the aqua backend, not npm The npm backend's bats could not exec on a Linux runner; the aqua backend fetches the release archive and needs no node. Co-Authored-By: Claude Fable 5.1 --- .mise/locks/npm-bats/1.13.0/aube-lock.yaml | 23 ----------------- .mise/locks/npm-bats/1.13.0/package.json | 7 ----- mise.lock | 30 ++++++++++++++++++++++ mise.toml | 2 +- 4 files changed, 31 insertions(+), 31 deletions(-) delete mode 100644 .mise/locks/npm-bats/1.13.0/aube-lock.yaml delete mode 100644 .mise/locks/npm-bats/1.13.0/package.json diff --git a/.mise/locks/npm-bats/1.13.0/aube-lock.yaml b/.mise/locks/npm-bats/1.13.0/aube-lock.yaml deleted file mode 100644 index 0bae35d..0000000 --- a/.mise/locks/npm-bats/1.13.0/aube-lock.yaml +++ /dev/null @@ -1,23 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - bats: - specifier: 1.13.0 - version: 1.13.0 - -packages: - - bats@1.13.0: - resolution: {integrity: sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==} - hasBin: true - -snapshots: - - bats@1.13.0: {} diff --git a/.mise/locks/npm-bats/1.13.0/package.json b/.mise/locks/npm-bats/1.13.0/package.json deleted file mode 100644 index 43bd251..0000000 --- a/.mise/locks/npm-bats/1.13.0/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "mise-npm-install", - "private": true, - "dependencies": { - "bats": "1.13.0" - } -} diff --git a/mise.lock b/mise.lock index 9537f2b..dd72d7a 100644 --- a/mise.lock +++ b/mise.lock @@ -49,6 +49,36 @@ url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_ url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924919" provenance = "github-attestations" +[[tools.bats]] +version = "1.13.0" +backend = "aqua:bats-core/bats-core" +specifiers = ["1.13"] + +[tools.bats."platforms.linux-arm64"] +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + +[tools.bats."platforms.linux-arm64-musl"] +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + +[tools.bats."platforms.linux-x64"] +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + +[tools.bats."platforms.linux-x64-musl"] +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + +[tools.bats."platforms.macos-arm64"] +checksum = "blake3:d34af9fd0f9101d5309b2de31f3d1699e56a574d7d4b5e6380580a9e758a9744" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + +[tools.bats."platforms.macos-x64"] +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" + [[tools.gh]] version = "2.103.0" backend = "aqua:cli/cli" diff --git a/mise.toml b/mise.toml index 3b0f587..53ce17c 100644 --- a/mise.toml +++ b/mise.toml @@ -13,4 +13,4 @@ gh = "2.103" jq = "1.8" just = "1.59" shellcheck = "0.11" -"npm:bats" = "1.13" +bats = "1.13" From 0679fe4db858d77b0bc09f5feea912573d9aef63 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:08:36 -0700 Subject: [PATCH 009/108] #1 tests/hooks.bats: hide the linter without hiding git Co-Authored-By: Claude Fable 5.1 --- tests/hooks.bats | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/hooks.bats b/tests/hooks.bats index faccb2d..be264ec 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -53,13 +53,13 @@ teardown() { @test "pre-commit reports a skip when the linter is not on PATH" { printf '#!/usr/bin/env bash\necho $x\n' > bad.sh git add bad.sh - # a PATH without any directory that carries a shellcheck - without="" - while IFS= read -r dir + # a PATH of one directory, carrying only what the hook itself runs, and no shellcheck + mkdir only + for tool in bash git env cat do - [[ -n "${dir}" && ! -x "${dir}/shellcheck" ]] && without="${without:+${without}:}${dir}" - done < <(printf '%s' "${PATH}" | tr ':' '\n') - run env PATH="${without}" git commit --quiet --message bad + ln -s "$(command -v "${tool}")" "only/${tool}" + done + run env PATH="${PWD}/only" git commit --quiet --message bad [ "$status" -eq 0 ] [[ "$output" == *"NOT linted"* ]] } From 8c3fe7159f28cdce037d5a6c5a7b2c9e6d32ff0b Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:17:12 -0700 Subject: [PATCH 010/108] #1 .github/workflows/release.yml: only a version tag starts a run Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbf936c..e05db38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,9 @@ # ---------------------------------------------------------------------------------------------------------------------- # # The tarball is kit-.tar.gz, holding one directory kit-/ with bin/ and .kit/, the layout -# a consumer's mise unpacks and puts on PATH. Two refusals: a tag whose commit is not on main, and a tag whose -# version is not the one .kit/version records, so the release's version and the kit's own agree. +# a consumer's mise unpacks and puts on PATH. Only a version tag starts a run, and the run refuses a tag whose +# commit is not on main (any commit on main, not only its head), a tag that is not v.. with an +# optional pre-release suffix, and a tag whose version is not the one .kit/version records at that commit. # # see: .kit/1st.md @@ -11,7 +12,7 @@ name: release on: push: - tags: ['v*'] + tags: ['v[0-9]*'] permissions: contents: write @@ -39,6 +40,11 @@ jobs: run: | set -o errexit -o nounset -o pipefail tag_version=${GITHUB_REF_NAME#v} + if [[ ! "${tag_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] + then + echo "::error::${GITHUB_REF_NAME} is not a version tag; a release tag is v.., with an optional -" + exit 1 + fi file_version=$(cat .kit/version) if [[ "${tag_version}" != "${file_version}" ]] then From 83522783560a0b3c4159f1f23d9eab8e77ec86f1 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:19:32 -0700 Subject: [PATCH 011/108] #1 .github/workflows/release.yml: a pre-release is tagged off main, a release on it Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 45 +++++++++++++++++++++++++---------- AGENTS.md | 6 ++--- README.md | 9 ++++--- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e05db38..432d02f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,11 @@ # ---------------------------------------------------------------------------------------------------------------------- # # The tarball is kit-.tar.gz, holding one directory kit-/ with bin/ and .kit/, the layout -# a consumer's mise unpacks and puts on PATH. Only a version tag starts a run, and the run refuses a tag whose -# commit is not on main (any commit on main, not only its head), a tag that is not v.. with an -# optional pre-release suffix, and a tag whose version is not the one .kit/version records at that commit. +# a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., +# is tagged on main (any commit on main, not only its head). A pre-release, the same with a -, is tagged on +# a commit that is not on main, and is published as a GitHub pre-release. Either way .kit/version at the tagged +# commit is the version without the suffix, the one under development, and the packed kit carries the tag's full +# version so a consumer pinning a pre-release sees it in kit version and .kit/manifest. # # see: .kit/1st.md @@ -25,30 +27,45 @@ jobs: with: fetch-depth: 0 - - name: tag-is-on-main + - name: tag-is-a-version run: | set -o errexit -o nounset -o pipefail - default_branch=$(jq --raw-output '.repository.default_branch' "${GITHUB_EVENT_PATH}") - if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${default_branch}" + tag_version=${GITHUB_REF_NAME#v} + if [[ ! "${tag_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] then - echo "::error::${GITHUB_REF_NAME} is not on ${default_branch}; delete the tag and tag a commit on ${default_branch}" + echo "::error::${GITHUB_REF_NAME} is not a version tag; a release is v.., a pre-release the same with -" exit 1 fi - echo "✅ ${GITHUB_REF_NAME} is on ${default_branch}." + echo "✅ ${GITHUB_REF_NAME} is a version tag." - - name: tag-matches-version + - name: tag-is-on-the-right-side-of-main run: | set -o errexit -o nounset -o pipefail tag_version=${GITHUB_REF_NAME#v} - if [[ ! "${tag_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] + default_branch=$(jq --raw-output '.repository.default_branch' "${GITHUB_EVENT_PATH}") + on_main=false + git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${default_branch}" && on_main=true + if [[ "${tag_version}" == *-* && "${on_main}" == true ]] + then + echo "::error::${GITHUB_REF_NAME} is a pre-release tag on ${default_branch}; a pre-release is tagged on a branch commit, and a release on ${default_branch} carries no suffix" + exit 1 + fi + if [[ "${tag_version}" != *-* && "${on_main}" == false ]] then - echo "::error::${GITHUB_REF_NAME} is not a version tag; a release tag is v.., with an optional -" + echo "::error::${GITHUB_REF_NAME} is not on ${default_branch}; a release is tagged on ${default_branch}, and a branch commit takes a pre-release tag with a -" exit 1 fi + echo "✅ ${GITHUB_REF_NAME} is on the right side of ${default_branch} (on it: ${on_main})." + + - name: tag-matches-version + run: | + set -o errexit -o nounset -o pipefail + tag_version=${GITHUB_REF_NAME#v} + base_version=${tag_version%%-*} file_version=$(cat .kit/version) - if [[ "${tag_version}" != "${file_version}" ]] + if [[ "${base_version}" != "${file_version}" ]] then - echo "::error::the tag says ${tag_version} and .kit/version says ${file_version}; set .kit/version on main first, then tag" + echo "::error::the tag says ${base_version} and .kit/version says ${file_version}; set .kit/version at the tagged commit first" exit 1 fi echo "✅ .kit/version is ${file_version}." @@ -63,6 +80,8 @@ jobs: mkdir "${staging}/kit-${version}" cp --recursive bin .kit "${staging}/kit-${version}/" rm --force "${staging}/kit-${version}/.kit/manifest" + # the packed kit carries the tag's full version, suffix included; the repository file carries the base + printf '%s\n' "${version}" > "${staging}/kit-${version}/.kit/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" sha256sum "kit-${version}.tar.gz" > "kit-${version}.tar.gz.sha256" notes=$(printf 'Pin it in mise.toml:\n\n [tools]\n "github:empowerite/kit" = "%s"\n\nThen, in the repository: kit install\n' "${version}") diff --git a/AGENTS.md b/AGENTS.md index c6faa17..06f6e6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,9 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its - The delivered tree is `.kit/` and `bin/kit`. Everything in it is written for a consumer: no file names a consumer, a person, or another repository as the source of a rule. -- `.kit/version` is the release version. A release is a commit on `main` that sets it, then the tag `v` - on that commit. The release workflow refuses a tag whose version differs from the file, or whose commit is not - on `main`. +- `.kit/version` is the version under development, with no suffix. A release is the tag `v` on a commit on + `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any + other pairing, and a tag whose base version differs from the file. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are this repository's installed copies of its own kit. Change a delivered file at its source under `.kit/`, then run `just refresh` to rewrite the copies, and commit both. diff --git a/README.md b/README.md index a661029..2d2a4a6 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,11 @@ request body is the report. A human merges. ## Release -A release is two steps on `main`: a pull request that sets `.kit/version`, then the tag `v` on its merge -commit. The release workflow packs `bin/` and `.kit/` into `kit-.tar.gz` and publishes the GitHub -release. It refuses a tag that is not on `main` or whose version differs from the file. +`.kit/version` carries the version under development, without a suffix. A release is the tag `v` on a +commit on `main`, not only its head. A pre-release is `v-`, say `v0.1.0-rc.1`, on a commit that is +not on `main`, and it is published as a GitHub pre-release. In both cases the workflow packs `bin/` and `.kit/` into +`kit-.tar.gz` with the tag's full version stamped into the packed `.kit/version`, and publishes the release. +It refuses a tag that is not a version, a release tag off `main`, a pre-release tag on `main`, and a tag whose base +version is not `.kit/version`. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf From f9304f5eec6f30dd6a2541317925409d53670936 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:24:12 -0700 Subject: [PATCH 012/108] #1 justfile, .kit/kit.just: no {{...}} interpolation; the path is an exported variable Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-just.md | 11 ++++++++--- .kit/kit.just | 5 ++++- .kit/manifest | 6 +++--- .kit/rules/just.md | 11 ++++++++--- justfile | 5 ++++- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index 0958e80..8044078 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -15,16 +15,19 @@ applies to it as well. a description of what the file is and who imports it. 2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the root, which has no parent), `set positional-arguments := true`, and `set export := true`. -3. Every justfile carries the default-and-help pair: +3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: ```just + # This file's own path, assigned at parse time so the body below needs no interpolation. + export _justfile := source_file() + # Default recipe: list this justfile's recipes. _: help # List recipes in this justfile. [no-exit-message] @help *options: - just --justfile "{{source_file()}}" --list + just --justfile "${_justfile}" --list ``` 4. A module the root takes with `mod ""` carries `[no-cd]` on every recipe, so its body runs from @@ -38,7 +41,9 @@ applies to it as well. does, when to run it, and what it produces. 3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. 4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. -5. Recipe bodies use `${var}`, never `{{var}}` mixed in. `set export := true` makes parameters available. +5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported + variable, `export := `, and `set export := true` exports every parameter, so a body reads + `${name}` and nothing else. 6. Two-space indentation inside a body. Never a tab. 7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. 8. Comments wrap at 118 columns. Bodies do not wrap. diff --git a/.kit/kit.just b/.kit/kit.just index ecb46dc..bc35653 100644 --- a/.kit/kit.just +++ b/.kit/kit.just @@ -15,13 +15,16 @@ set positional-arguments := true export BASH_ENV := source_directory() / "bash/1st.sh" +# This file's own path, assigned at parse time so the body below needs no interpolation. +export kit_justfile := source_file() + # Default recipe: list this justfile's recipes. _: help # List recipes in this justfile. [no-exit-message] @help *options: - just --justfile "{{source_file()}}" --list + just --justfile "${kit_justfile}" --list set export := true diff --git a/.kit/manifest b/.kit/manifest index 51c397d..887c8f2 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -6,11 +6,11 @@ a36a18081eb7515871bc01b7b22372c1545d99d8b393fb386c78128f73c8dbb5 .kit/bash/1st. 8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .kit/ci.yml 1ab629cb79710438fb15fd2fcbadf94ce125f4d3c26bf22def6d35c8c414dbb1 .kit/hooks/pre-commit 414bb9a82191060489213823e98828a64d166c53109c64cdf600fdd3d5c64205 .kit/hooks/pre-rebase -47919ba1fabfc077ff3950f2743d94d016d58350cfe36cf97d2130419de6b6c6 .kit/kit.just +b68c1bc8ba4a142f9a585b7fb58d833e5b3c0476fb629c7a45e67e8e6cb507fc .kit/kit.just cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bash.md 5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md 6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md -dd88591e25a0df6a039f5183f0e573e35c53b9903f04cc879b743a715f8e6559 .kit/rules/just.md +26e6ddb46be1adeda8ea76d77112096771147821594a89cc14ff8dbf7148d939 .kit/rules/just.md df7388a40e037d82f77d92f0dfe0deed55184c4a8bf869dbce86986673ebeab5 .kit/rules/markdown.md 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md 617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json @@ -18,7 +18,7 @@ e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version 1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .claude/rules/kit-bash.md 4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md 1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md -3547ea76e2711f003da90817292e48cec55926e95ae2a06dc53de0954ab38508 .claude/rules/kit-just.md +96d16e8c1d3889909bbc13d6690b07a6d7c47aa2e7d62ff87f4cbc0068f385c7 .claude/rules/kit-just.md 13fff95ff67775bebb820d10dbd73f4f5eb23571f0619462fa5abb1b8c6b42c6 .claude/rules/kit-markdown.md 4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md 8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .github/workflows/kit-ci.yml diff --git a/.kit/rules/just.md b/.kit/rules/just.md index fe8b9a1..d47b1c7 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -9,16 +9,19 @@ applies to it as well. a description of what the file is and who imports it. 2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the root, which has no parent), `set positional-arguments := true`, and `set export := true`. -3. Every justfile carries the default-and-help pair: +3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: ```just + # This file's own path, assigned at parse time so the body below needs no interpolation. + export _justfile := source_file() + # Default recipe: list this justfile's recipes. _: help # List recipes in this justfile. [no-exit-message] @help *options: - just --justfile "{{source_file()}}" --list + just --justfile "${_justfile}" --list ``` 4. A module the root takes with `mod ""` carries `[no-cd]` on every recipe, so its body runs from @@ -32,7 +35,9 @@ applies to it as well. does, when to run it, and what it produces. 3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. 4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. -5. Recipe bodies use `${var}`, never `{{var}}` mixed in. `set export := true` makes parameters available. +5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported + variable, `export := `, and `set export := true` exports every parameter, so a body reads + `${name}` and nothing else. 6. Two-space indentation inside a body. Never a tab. 7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. 8. Comments wrap at 118 columns. Bodies do not wrap. diff --git a/justfile b/justfile index 7b6b2d1..508e024 100644 --- a/justfile +++ b/justfile @@ -6,13 +6,16 @@ set dotenv-load := true set positional-arguments := true +# This file's own path, assigned at parse time so the body below needs no interpolation. +export root_justfile := source_file() + # Default recipe: list this justfile's recipes. _: help # List recipes in this justfile. [no-exit-message] @help *options: - just --justfile "{{source_file()}}" --list + just --justfile "${root_justfile}" --list set export := true From d0dd43cc9d7ae6ee064a4771d162e6fb201bb75f Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 22:40:44 -0700 Subject: [PATCH 013/108] #1 bin/kit: a repository takes only the targets it names A target is a harness that insists on files outside .kit/. kit install names them, the manifest records them, and update and check honor the same set. claude is the one target this release knows; kit target add and remove change the set later. Co-Authored-By: Claude Fable 5.1 --- .kit/1st.md | 13 ++- .kit/manifest | 5 +- README.md | 15 +-- bin/kit | 277 +++++++++++++++++++++++++++++++++++++------------ justfile | 2 +- tests/kit.bats | 62 +++++++++-- 6 files changed, 289 insertions(+), 85 deletions(-) diff --git a/.kit/1st.md b/.kit/1st.md index e51c83d..bd476fd 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -51,7 +51,8 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type Each file kind has a rule file under `.kit/rules/`. A harness that loads rules by path finds the same text -under `.claude/rules/kit-.md`. Read the rule for a kind before writing a file of that kind. +under its own rules directory when the repository takes that target, `.claude/rules/kit-.md` for Claude +Code. Read the rule for a kind before writing a file of that kind. | Kind | Paths | Rule | | --- | --- | --- | @@ -68,11 +69,15 @@ under `.claude/rules/kit-.md`. Read the rule for a kind before writing a f which gives `just kit issue`, `just kit tidy`, and `just kit status`. The bash behind them is `.kit/bash/`. - `.kit/hooks/pre-rebase` refuses a rebase of the default branch. `.kit/hooks/pre-commit` lints the staged paths. `kit install` sets `core.hooksPath` to `.kit/hooks`. -- `.kit/settings.json`, deny rules for Claude Code that `kit install` merges into `.claude/settings.json`. - The kit owns only the entries it writes; the consumer may add its own. +- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the + targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places + `.claude/rules/kit-.md` and merges `.kit/settings.json`, six deny rules, into `.claude/settings.json`. + The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target + back out. - `.kit/ci.yml`, placed as `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `.kit/manifest`, written by `kit install`: the release version and a checksum per delivered file. +- `.kit/manifest`, written by `kit install`: the release version, the targets taken, and a checksum per + delivered file. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/manifest b/.kit/manifest index 887c8f2..2ee9056 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,5 +1,6 @@ version 0.1.0 -367c3c2d28f51dbe2549803e113ad45db507aa68afc1997f3d5b20f94796ec6f .kit/1st.md +targets claude +f2c1c377f15a35b03ffcbffc4122b36bf11409f83805087a06e3f06646d51f26 .kit/1st.md a36a18081eb7515871bc01b7b22372c1545d99d8b393fb386c78128f73c8dbb5 .kit/bash/1st.sh 6944cdb161e68bf5d2355c6ee48fe7bfd25d8ffa1e6edef218af123e5edf5f7c .kit/bash/gh.sh 051cab217451a69c6f73545fe3e68fb2316e0be57004030159fe299d7f510436 .kit/bash/git.sh @@ -15,10 +16,10 @@ df7388a40e037d82f77d92f0dfe0deed55184c4a8bf869dbce86986673ebeab5 .kit/rules/mar 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md 617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version +8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .github/workflows/kit-ci.yml 1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .claude/rules/kit-bash.md 4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md 1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md 96d16e8c1d3889909bbc13d6690b07a6d7c47aa2e7d62ff87f4cbc0068f385c7 .claude/rules/kit-just.md 13fff95ff67775bebb820d10dbd73f4f5eb23571f0619462fa5abb1b8c6b42c6 .claude/rules/kit-markdown.md 4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md -8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .github/workflows/kit-ci.yml diff --git a/README.md b/README.md index 2d2a4a6..7108db0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ installs it with `kit install`, and keeps rules of its own beside it. The design ## Take the kit -Pin a release in the repository's `mise.toml`, then install it: +Pin a release in the repository's `mise.toml`, then install it, naming the harnesses the team uses: ```toml [tools] @@ -17,12 +17,15 @@ Pin a release in the repository's `mise.toml`, then install it: ```sh mise install -kit install +kit install claude ``` -`kit install` copies `.kit/` into the repository, places `.claude/rules/kit-*.md` and -`.github/workflows/kit-ci.yml`, merges its deny rules into `.claude/settings.json`, ignores `worktrees/`, points -`core.hooksPath` at `.kit/hooks`, and writes `.kit/manifest`. Commit what it wrote. Then: +`kit install` copies `.kit/` into the repository, places `.github/workflows/kit-ci.yml`, ignores `worktrees/`, +points `core.hooksPath` at `.kit/hooks`, and writes `.kit/manifest`. Each target named adds what that harness +insists on having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and merges the kit's deny rules into +`.claude/settings.json`. A repository with no target installed still binds every agent that reads `AGENTS.md`, +through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` change the set later, and +`kit target list` prints it. Commit what it wrote. Then: - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Add `mod kit ".kit/kit.just"` to the root justfile for `just kit issue`, `just kit tidy`, and `just kit status`. @@ -46,7 +49,7 @@ git config user.signingkey git config commit.gpgsign true ``` -Then `mise install` for the pinned tools, and `bin/kit install` is already done: this repository carries the +Then `mise install` for the pinned tools. `bin/kit install claude` is already done: this repository carries the installed copy of its own kit, which `just ci` checks. ## Getting started diff --git a/bin/kit b/bin/kit index 4b6c59f..1f538ab 100755 --- a/bin/kit +++ b/bin/kit @@ -2,18 +2,26 @@ # kit - install, update, and check the kit in a repository (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # -# kit install copy this release into the repository and write .kit/manifest -# kit update after the pin is bumped: replace unchanged files, three-way merge changed ones -# kit check fail when a delivered file differs from this release -# kit version print this release's version +# kit install […] copy this release into the repository, place the files each named target needs, +# and write .kit/manifest +# kit update after the pin is bumped: replace unchanged files, three-way merge changed ones +# kit check fail when a delivered file differs from this release +# kit target list print the targets this repository takes +# kit target add … take a target: place its files and record it +# kit target remove … drop a target: remove its files and the kit's entries from its settings +# kit version print this release's version # -# KIT_BASE= names the previous release's root for \`kit update\` on a machine without mise. +# A target is a harness that insists on files placed outside .kit/. The one this release knows is claude: +# .claude/rules/kit-.md, each the rule file of that kind with a paths: header, and the kit's deny entries +# merged into .claude/settings.json. A repository takes only the targets it names, and .kit/manifest records them +# so update and check honor the same set. +# +# KIT_BASE= names the previous release's root for `kit update` on a machine without mise. # # The release this command belongs to is the directory above bin/: its .kit/ is the payload. The repository is -# the git checkout the command runs in. A delivered file is one of: every file under .kit/, except manifest; -# .claude/rules/kit-.md, each the rule file of that kind with a paths: header so a harness loads it by -# path; and .github/workflows/kit-ci.yml, which is .kit/ci.yml placed where GitHub reads it. .claude/settings.json -# is shared with the repository, so it is merged by key: the kit owns only the deny entries it writes. +# the git checkout the command runs in. A delivered file is every file under .kit/ except manifest, the CI +# workflow .kit/ci.yml placed as .github/workflows/kit-ci.yml, and the placed files of each target taken. +# A shared settings file is merged by key: the kit owns only the entries it writes. # # see: .kit/1st.md @@ -24,8 +32,10 @@ payload="${kit_root}/.kit" # shellcheck source=.kit/bash/1st.sh source "${payload}/bash/1st.sh" +known_targets=(claude) + usage() { - sed -n '5,8p' "${BASH_SOURCE[0]}" | sed 's/^# *//' >&2 + sed -n '5,12p' "${BASH_SOURCE[0]}" | sed 's/^#//; s/^ //' >&2 } # digest prints the sha256 of stdin, by whichever tool the machine carries. @@ -38,6 +48,33 @@ digest() { fi } +# known-target is true when this release knows the target. +known-target() { + local t + for t in "${known_targets[@]}" + do + [[ "$1" == "${t}" ]] && return 0 + done + return 1 +} + +# require-targets … refuses an unknown target, naming the known ones. +require-targets() { + local t + for t in "$@" + do + if ! known-target "${t}" + then + error "'${t}' is not a target this release knows; the targets are: ${known_targets[*]}" + return 2 + fi + done +} + +# manifest-version and manifest-targets read .kit/manifest. +manifest-version() { sed -n 's/^version //p' .kit/manifest; } +manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } + # rule-paths prints the paths: globs a harness loads the rule of for, one per line. rule-paths() { case "$1" in @@ -51,19 +88,39 @@ rule-paths() { esac } -# delivered-paths prints every repository path the release at delivers. -delivered-paths() { +# target-paths prints the repository paths a target places. +target-paths() { local root=$1 file + case "$2" in + (claude) + for file in "${root}"/rules/*.md + do + printf '.claude/rules/kit-%s\n' "$(basename "${file}")" + done + esac +} + +# target-settings prints the settings file a target shares with the repository, or nothing. +target-settings() { + case "$1" in + (claude) printf '.claude/settings.json\n' + esac +} + +# delivered-paths […] prints every repository path the release delivers for those targets. +delivered-paths() { + local root=$1 file t + shift while IFS= read -r file do [[ "${file}" == manifest ]] && continue printf '.kit/%s\n' "${file}" done < <(cd "${root}" && find . -type f | sed 's@^\./@@' | sort) - for file in "${root}"/rules/*.md + printf '.github/workflows/kit-ci.yml\n' + for t in "$@" do - printf '.claude/rules/kit-%s\n' "$(basename "${file}")" + target-paths "${root}" "${t}" done - printf '.github/workflows/kit-ci.yml\n' } # expected prints the content the release at delivers at repository . @@ -102,19 +159,21 @@ write-expected() { return 0 } -# merge-settings [] merges the deny entries into .claude/settings.json: the base -# release's entries go, the new release's come, and everything else in the file stays as it was. +# merge-settings [] merges the kit's deny entries into the target's settings +# file: the base release's entries go, the new release's come, and everything else in the file stays. An empty +# takes the kit's entries out and adds none. merge-settings() { - local new_root=$1 base_root=${2:-} base_deny='[]' - [[ -n "${base_root}" && -f "${base_root}/settings.json" ]] && base_deny=$(jq '.permissions.deny // []' "${base_root}/settings.json") - mkdir -p .claude - [[ -f .claude/settings.json ]] || printf '{}\n' > .claude/settings.json - jq --indent 2 \ - --argjson base "${base_deny}" \ - --argjson new "$(jq '.permissions.deny // []' "${new_root}/settings.json")" \ + local file new_deny='[]' base_deny='[]' + file=$(target-settings "$1") + [[ -n "${file}" ]] || return 0 + [[ -n "$2" && -f "$2/settings.json" ]] && new_deny=$(jq '.permissions.deny // []' "$2/settings.json") + [[ -n "${3:-}" && -f "$3/settings.json" ]] && base_deny=$(jq '.permissions.deny // []' "$3/settings.json") + mkdir -p "$(dirname "${file}")" + [[ -f "${file}" ]] || printf '{}\n' > "${file}" + jq --indent 2 --argjson base "${base_deny}" --argjson new "${new_deny}" \ '.permissions.deny = (((.permissions.deny // []) - $base) as $kept | $kept + ($new - $kept))' \ - .claude/settings.json > .claude/settings.json.kit-new - mv .claude/settings.json.kit-new .claude/settings.json + "${file}" > "${file}.kit-new" + mv "${file}.kit-new" "${file}" } # ensure-ignored makes sure worktrees/ is ignored at the root, the way the loop needs. @@ -128,45 +187,51 @@ ensure-ignored() { printf '%s# the per-issue worktrees of the loop; see: .kit/1st.md\n/worktrees/\n' "${separator}" >> .gitignore } -# write-manifest records the release version and, per delivered path, the digest of the release's own content -# for it, never of the file as written: a later update tells an untouched file from an edited one by comparing -# the file to this, so a merge that kept a repository's edit is not mistaken for the release's text. +# write-manifest […] records the release version, the targets taken, and, per delivered path, the +# digest of the release's own content for it, never of the file as written: a later update tells an untouched +# file from an edited one by comparing the file to this, so a merge that kept a repository's edit is not mistaken +# for the release's text. write-manifest() { local path { printf 'version %s\n' "$(cat "${payload}/version")" + printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path do printf '%s %s\n' "$(expected "${payload}" "${path}" | digest)" "${path}" - done < <(delivered-paths "${payload}") + done < <(delivered-paths "${payload}" "$@") } > .kit/manifest } # finish sets the clone's hooks path and reports. finish() { git config core.hooksPath .kit/hooks - ok "kit $(cat "${payload}/version") is installed: core.hooksPath is .kit/hooks, and .kit/manifest is written." + ok "kit $(cat "${payload}/version") is installed, targets: ${*:-none}. core.hooksPath is .kit/hooks, and .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } cmd-install() { guard:require tool:jq tool:git + require-targets "$@" if [[ -f .kit/manifest ]] then - error "run: kit update (this repository already carries kit $(sed -n 's/^version //p' .kit/manifest))" + error "run: kit update (this repository already carries kit $(manifest-version))" return 1 fi - section "kit install $(cat "${payload}/version")" - local path + section "kit install $(cat "${payload}/version")${*:+, targets: $*}" + local path t while IFS= read -r path do step "${path}" write-expected "${payload}" "${path}" - done < <(delivered-paths "${payload}") - merge-settings "${payload}" + done < <(delivered-paths "${payload}" "$@") + for t in "$@" + do + merge-settings "${t}" "${payload}" + done ensure-ignored - write-manifest - finish + write-manifest "$@" + finish "$@" } cmd-update() { @@ -176,10 +241,12 @@ cmd-update() { error "run: kit install (this repository carries no .kit/manifest)" return 1 fi - local previous current base_root="" path recorded actual conflicts=0 - previous=$(sed -n 's/^version //p' .kit/manifest) + local previous current base_root="" path recorded actual conflicts=0 t + local -a targets + read -r -a targets <<<"$(manifest-targets)" + previous=$(manifest-version) current=$(cat "${payload}/version") - section "kit update ${previous} → ${current}" + section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" # The previous release is the base of every three-way merge. KIT_BASE names its root directly; otherwise # mise keeps it where it installed it. @@ -227,27 +294,32 @@ cmd-update() { conflicts=$(( conflicts + 1 )) fi rm -f "${path}.kit-base" "${path}.kit-new" - done < <(delivered-paths "${payload}") + done < <(delivered-paths "${payload}" "${targets[@]}") - merge-settings "${payload}" "${base_root}" + for t in "${targets[@]}" + do + merge-settings "${t}" "${payload}" "${base_root}" + done ensure-ignored - write-manifest + write-manifest "${targets[@]}" if (( conflicts > 0 )) then error "${conflicts} file(s) need a hand; see above." return 1 fi - finish + finish "${targets[@]}" } cmd-check() { - local path findings=0 version + local path findings=0 version t file + local -a targets if [[ ! -f .kit/manifest ]] then error "run: kit install (this repository carries no .kit/manifest)" return 1 fi - version=$(sed -n 's/^version //p' .kit/manifest) + read -r -a targets <<<"$(manifest-targets)" + version=$(manifest-version) if [[ "${version}" != "$(cat "${payload}/version")" ]] then error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${payload}/version"); run: kit update" @@ -264,33 +336,110 @@ cmd-check() { error "${path} differs from the pinned release" findings=$(( findings + 1 )) fi - done < <(delivered-paths "${payload}") - if [[ ! -f .claude/settings.json ]] - then - error ".claude/settings.json is missing; run: kit update" - findings=$(( findings + 1 )) - elif ! command -v jq >/dev/null 2>&1 - then - error "run: mise install (jq is not on PATH, so .claude/settings.json could not be read)" - findings=$(( findings + 1 )) - elif (( $(jq --argjson new "$(jq '.permissions.deny // []' "${payload}/settings.json")" \ - '($new - (.permissions.deny // [])) | length' .claude/settings.json) > 0 )) - then - error ".claude/settings.json is missing a deny entry the kit writes; run: kit update" - findings=$(( findings + 1 )) - fi + done < <(delivered-paths "${payload}" "${targets[@]}") + for t in "${targets[@]}" + do + file=$(target-settings "${t}") + [[ -n "${file}" ]] || continue + if [[ ! -f "${file}" ]] + then + error "${file} is missing; run: kit update" + findings=$(( findings + 1 )) + elif ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so ${file} could not be read)" + findings=$(( findings + 1 )) + elif (( $(jq --argjson new "$(jq '.permissions.deny // []' "${payload}/settings.json")" \ + '($new - (.permissions.deny // [])) | length' "${file}") > 0 )) + then + error "${file} is missing a deny entry the kit writes; run: kit update" + findings=$(( findings + 1 )) + fi + done if (( findings > 0 )) then error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." return 1 fi - ok "kit ${version}: every delivered file matches the pinned release." + ok "kit ${version}, targets ${targets[*]:-none}: every delivered file matches the pinned release." +} + +cmd-target() { + local verb=${1:-list} t path + local -a targets kept + shift || true + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi + read -r -a targets <<<"$(manifest-targets)" + case "${verb}" in + (list) + printf '%s\n' "${targets[@]}" + ;; + (add) + (( $# > 0 )) || { error "usage: kit target add …"; return 2; } + guard:require tool:jq tool:git + require-targets "$@" + for t in "$@" + do + if [[ " ${targets[*]} " == *" ${t} "* ]] + then + step "${t}: already taken" + continue + fi + section "kit target add ${t}" + while IFS= read -r path + do + step "${path}" + write-expected "${payload}" "${path}" + done < <(target-paths "${payload}" "${t}") + merge-settings "${t}" "${payload}" + targets+=("${t}") + done + write-manifest "${targets[@]}" + ok "targets: ${targets[*]}" + ;; + (remove) + (( $# > 0 )) || { error "usage: kit target remove …"; return 2; } + guard:require tool:jq tool:git + require-targets "$@" + for t in "$@" + do + if [[ " ${targets[*]} " != *" ${t} "* ]] + then + step "${t}: not taken" + continue + fi + section "kit target remove ${t}" + while IFS= read -r path + do + step "removing ${path}" + rm -f "${path}" + done < <(target-paths "${payload}" "${t}") + [[ -f "$(target-settings "${t}")" ]] && merge-settings "${t}" "" "${payload}" + kept=() + for path in "${targets[@]}" + do + [[ "${path}" == "${t}" ]] || kept+=("${path}") + done + targets=("${kept[@]+"${kept[@]}"}") + done + write-manifest "${targets[@]+"${targets[@]}"}" + ok "targets: ${targets[*]:-none}" + ;; + (*) + error "kit target: unknown verb '${verb}' (list, add, remove)" + return 2 + esac } case "${1:-}" in - (install) cmd-install ;; + (install) shift; cmd-install "$@" ;; (update) cmd-update ;; (check) cmd-check ;; + (target) shift; cmd-target "$@" ;; (version) cat "${payload}/version" ;; (-h | --help | help | "") usage; [[ -n "${1:-}" ]] ;; (*) diff --git a/justfile b/justfile index 508e024..3b378f2 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,6 @@ refresh: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail rm -f .kit/manifest .claude/settings.json - bin/kit install + bin/kit install claude # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/kit.bats b/tests/kit.bats index 3da2a8f..7b47795 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -25,8 +25,8 @@ later-release() { printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/.kit/rules/json.md" } -@test "install writes the delivered tree, the mirrors, the workflow, the settings, and the manifest" { - run "${kit_checkout}/bin/kit" install +@test "install with the claude target writes the tree, the mirrors, the workflow, the settings, and the manifest" { + run "${kit_checkout}/bin/kit" install claude [ "$status" -eq 0 ] [ -f .kit/1st.md ] [ -x .kit/hooks/pre-commit ] @@ -37,13 +37,59 @@ later-release() { jq --exit-status '.permissions.deny | index("Bash(git push --force *)")' .claude/settings.json >/dev/null grep --quiet --line-regexp '/worktrees/' .gitignore grep --quiet '^version 0.1.0$' .kit/manifest + grep --quiet '^targets claude$' .kit/manifest [ "$(git config core.hooksPath)" = ".kit/hooks" ] } -@test "install keeps the repository's own settings keys and deny entries" { +@test "install with no target writes the neutral tree and nothing under .claude" { + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ -f .kit/1st.md ] + [ -f .github/workflows/kit-ci.yml ] + [ ! -e .claude ] + grep --quiet '^targets$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + # a stray file under a target not taken is nobody's business + mkdir -p .claude/rules && printf 'theirs\n' > .claude/rules/kit-json.md + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "install refuses a target this release does not know and names the known ones" { + run "${kit_checkout}/bin/kit" install gemini + [ "$status" -eq 2 ] + [[ "$output" == *"the targets are: claude"* ]] + [ ! -e .kit/manifest ] +} + +@test "target add places a target's files, and target remove takes them and the kit's entries back out" { mkdir .claude printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json "${kit_checkout}/bin/kit" install + [ "$("${kit_checkout}/bin/kit" target list)" = "" ] + run "${kit_checkout}/bin/kit" target add claude + [ "$status" -eq 0 ] + [ "$("${kit_checkout}/bin/kit" target list)" = "claude" ] + [ -f .claude/rules/kit-bash.md ] + jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null + grep --quiet '^targets claude$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" target remove claude + [ "$status" -eq 0 ] + [ ! -e .claude/rules/kit-bash.md ] + [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] + jq --exit-status '.permissions.deny == ["Bash(rm -rf /)"]' .claude/settings.json >/dev/null + grep --quiet '^targets$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "install keeps the repository's own settings keys and deny entries" { + mkdir .claude + printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json + "${kit_checkout}/bin/kit" install claude [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] jq --exit-status '.permissions.deny | index("Bash(rm -rf /)")' .claude/settings.json >/dev/null jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null @@ -57,7 +103,7 @@ later-release() { } @test "check passes after install and fails on a drifted, a missing, and a lost deny entry" { - "${kit_checkout}/bin/kit" install + "${kit_checkout}/bin/kit" install claude run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] printf 'edited\n' >> .kit/rules/yaml.md @@ -78,7 +124,7 @@ later-release() { } @test "update replaces unchanged files, merges an edited one, and records the release's digests" { - "${kit_checkout}/bin/kit" install + "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" # a consumer edit to a file the later release also changes, on a different line sed -i.bak '1s/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak @@ -100,7 +146,7 @@ later-release() { } @test "update of an untouched install leaves check green" { - "${kit_checkout}/bin/kit" install + "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 0 ] @@ -110,7 +156,7 @@ later-release() { } @test "update leaves conflict markers and fails when the same lines changed" { - "${kit_checkout}/bin/kit" install + "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" printf '9.9.9\n' > "${repo}.later/.kit/version" sed -i.bak '1s/^# JSON$/# JSON (theirs)/' "${repo}.later/.kit/rules/json.md" && rm "${repo}.later/.kit/rules/json.md.bak" @@ -123,7 +169,7 @@ later-release() { } @test "update without the base release refuses an edited file and names the base" { - "${kit_checkout}/bin/kit" install + "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" printf 'edited\n' >> .kit/rules/bash.md # a mise that answers nothing, ahead of the real one From bfdf43e79e27469680f2ccf3e9e634519c3f9b82 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:03:08 -0700 Subject: [PATCH 014/108] #1 README.md: a link for a signing key, a pasteable setup, and the loop as a list Co-Authored-By: Claude Fable 5.1 --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7108db0..bee69b9 100644 --- a/README.md +++ b/README.md @@ -41,26 +41,32 @@ The kit needs bash 4 or later, git, `gh`, and `jq`. macOS ships bash 3.2; `brew ## Setup -Every commit is signed, and the branch ruleset refuses one that is not. Once per clone: +Every commit is signed, and the branch ruleset refuses one that is not. If you have no signing key GitHub +verifies, make one and register it, following [generating a new GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) +and [adding a GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account). +Then, once per clone, with the key's id in `signing_key`: ```sh git config gpg.format openpgp -git config user.signingkey +git config user.signingkey "${signing_key}" git config commit.gpgsign true +mise install ``` -Then `mise install` for the pinned tools. `bin/kit install claude` is already done: this repository carries the -installed copy of its own kit, which `just ci` checks. +This repository carries the installed copy of its own kit, and `just ci` checks it. ## Getting started -Work here moves by the loop in [.kit/1st.md](.kit/1st.md). An issue exists. `just kit issue ` makes -`worktrees/-`, links the branch to the issue under Development, and opens a draft pull request. Commits -are signed, and their subject is `# : `. `gh pr ready` says the work is done, and the pull -request body is the report. A human merges. +Work here moves by the loop in [.kit/1st.md](.kit/1st.md): -`just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and -`bin/kit check`. +- An issue exists. A change without an issue has not started. +- `just kit issue ` makes `worktrees/-`, links the branch to the issue under Development, and opens a + draft pull request. It never switches the checkout it runs from. +- Work happens in that worktree. Commits are signed, and their subject is `# : `. +- `just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and + `bin/kit check`. +- `gh pr ready` says the work is done, and the pull request body is the report. +- A human merges. `just kit tidy` then removes the worktree and its branch. ## Release From 9d6854ef79acdb8d006baafac5a4a500de223c8c Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:03:54 -0700 Subject: [PATCH 015/108] #1 .kit/rules/markdown.md: snippets take a shell variable, and a prerequisite gets its link Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-markdown.md | 6 ++++++ .kit/manifest | 4 ++-- .kit/rules/markdown.md | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md index 43d822f..b0b3b80 100644 --- a/.claude/rules/kit-markdown.md +++ b/.claude/rules/kit-markdown.md @@ -18,6 +18,12 @@ Applies to `*.md`. 8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. +## Snippets + +1. A value the reader supplies is a shell variable the prose names, `"${signing_key}"`, never an angle-bracket + placeholder, so the block pastes after one export. +2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. + ## Layout 1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns diff --git a/.kit/manifest b/.kit/manifest index 2ee9056..78c2c4e 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -12,7 +12,7 @@ cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bas 5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md 6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md 26e6ddb46be1adeda8ea76d77112096771147821594a89cc14ff8dbf7148d939 .kit/rules/just.md -df7388a40e037d82f77d92f0dfe0deed55184c4a8bf869dbce86986673ebeab5 .kit/rules/markdown.md +b65054b62fc294525a5448bca8ac6bfe8be2d1aab0a74495c16b0f229104c2e7 .kit/rules/markdown.md 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md 617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version @@ -21,5 +21,5 @@ e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version 4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md 1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md 96d16e8c1d3889909bbc13d6690b07a6d7c47aa2e7d62ff87f4cbc0068f385c7 .claude/rules/kit-just.md -13fff95ff67775bebb820d10dbd73f4f5eb23571f0619462fa5abb1b8c6b42c6 .claude/rules/kit-markdown.md +b10e9ddcd41ca3fea52b0233f94e26437bd6977b4c64b8461e63599d9bd7faaf .claude/rules/kit-markdown.md 4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md index 8f8da0e..996f6ff 100644 --- a/.kit/rules/markdown.md +++ b/.kit/rules/markdown.md @@ -14,6 +14,12 @@ Applies to `*.md`. 8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. +## Snippets + +1. A value the reader supplies is a shell variable the prose names, `"${signing_key}"`, never an angle-bracket + placeholder, so the block pastes after one export. +2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. + ## Layout 1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns From c77adfed69d74e3d9552021497e701f03b43c981 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:07:21 -0700 Subject: [PATCH 016/108] #1 .kit/1st.md: a rule lives in the repository, never in a session's memory Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-markdown.md | 7 +++++-- .kit/1st.md | 2 ++ .kit/manifest | 6 +++--- .kit/rules/markdown.md | 7 +++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md index b0b3b80..53c613f 100644 --- a/.claude/rules/kit-markdown.md +++ b/.claude/rules/kit-markdown.md @@ -10,7 +10,8 @@ Applies to `*.md`. 1. Write plain declarative sentences with the thing under discussion as the subject. 2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". -3. Use a list where the content is a list. Use a table where the content has columns. +3. Use a list where the content is a list, and for a procedure or a loop, so it scans. Use a table where the + content has columns. 4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. 5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. 6. Use the serial comma in every list of three or more. @@ -46,4 +47,6 @@ Applies to `*.md`. 1. Name an index file `1st.md`. It lists its siblings and says what each is for. 2. A file says what its subject is in its first paragraph, before how to use it. -3. No file names another repository as the source of a rule. +3. A README has no Status section and no paragraph on what the repository will do or does not yet do. It says + what the repository is. +4. No file names another repository as the source of a rule. diff --git a/.kit/1st.md b/.kit/1st.md index bd476fd..b401ebb 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -10,6 +10,8 @@ the repository's own rules in `AGENTS.md` bind beside it. Where the two differ, clears no gate and earns no credit. 3. A claim carries its evidence, or says it has none. Name the command that ran and what it printed. 4. Being wrong is recoverable. Misreporting is not. +5. A rule lives in the repository, never in a session's memory. A convention learned or a correction given is + written into a rule file or `AGENTS.md` through the loop, where every agent and every person reads it. ## `main` is the human's desk diff --git a/.kit/manifest b/.kit/manifest index 78c2c4e..89fb15c 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,6 +1,6 @@ version 0.1.0 targets claude -f2c1c377f15a35b03ffcbffc4122b36bf11409f83805087a06e3f06646d51f26 .kit/1st.md +9f794eb95cdbcaf5a938a1444b705a44d539bf998535396c1798d028afb77435 .kit/1st.md a36a18081eb7515871bc01b7b22372c1545d99d8b393fb386c78128f73c8dbb5 .kit/bash/1st.sh 6944cdb161e68bf5d2355c6ee48fe7bfd25d8ffa1e6edef218af123e5edf5f7c .kit/bash/gh.sh 051cab217451a69c6f73545fe3e68fb2316e0be57004030159fe299d7f510436 .kit/bash/git.sh @@ -12,7 +12,7 @@ cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bas 5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md 6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md 26e6ddb46be1adeda8ea76d77112096771147821594a89cc14ff8dbf7148d939 .kit/rules/just.md -b65054b62fc294525a5448bca8ac6bfe8be2d1aab0a74495c16b0f229104c2e7 .kit/rules/markdown.md +5e4ee708d4be30009de264ddf5a18df1d1257faee8ba18ca8fd703c1eb6e8a85 .kit/rules/markdown.md 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md 617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version @@ -21,5 +21,5 @@ e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version 4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md 1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md 96d16e8c1d3889909bbc13d6690b07a6d7c47aa2e7d62ff87f4cbc0068f385c7 .claude/rules/kit-just.md -b10e9ddcd41ca3fea52b0233f94e26437bd6977b4c64b8461e63599d9bd7faaf .claude/rules/kit-markdown.md +a495ae7a54188bb82b73d50925012f9611af58396fa82c72c71d8a337c1bccf6 .claude/rules/kit-markdown.md 4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md index 996f6ff..ce54df8 100644 --- a/.kit/rules/markdown.md +++ b/.kit/rules/markdown.md @@ -6,7 +6,8 @@ Applies to `*.md`. 1. Write plain declarative sentences with the thing under discussion as the subject. 2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". -3. Use a list where the content is a list. Use a table where the content has columns. +3. Use a list where the content is a list, and for a procedure or a loop, so it scans. Use a table where the + content has columns. 4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. 5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. 6. Use the serial comma in every list of three or more. @@ -42,4 +43,6 @@ Applies to `*.md`. 1. Name an index file `1st.md`. It lists its siblings and says what each is for. 2. A file says what its subject is in its first paragraph, before how to use it. -3. No file names another repository as the source of a rule. +3. A README has no Status section and no paragraph on what the repository will do or does not yet do. It says + what the repository is. +4. No file names another repository as the source of a rule. From 8fc7cfd9ca334178917faa97c3cbd1c612b28dae Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:22:28 -0700 Subject: [PATCH 017/108] #1 bin/kit: the repository carries the text, the release the machinery kit install copies .kit/1st.md and .kit/rules/ and writes the manifest; the bash and the hooks stay in the installed release, reached through kit issue, kit tidy, kit status, and core.hooksPath, which kit setup wires per clone. .kit/kit.just is gone; a root justfile adds veneers, as this one does. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-just.md | 2 +- .github/workflows/kit-ci.yml | 2 +- .kit/1st.md | 29 +++++++-------- .kit/bash/1st.sh | 2 +- .kit/bash/gh.sh | 9 ++--- .kit/ci.yml | 2 +- .kit/kit.just | 70 ------------------------------------ .kit/manifest | 17 +++------ .kit/rules/just.md | 2 +- README.md | 26 ++++++++------ bin/kit | 69 +++++++++++++++++++++++++---------- justfile | 31 ++++++++++++++-- tests/kit.bats | 16 +++++++-- tests/release.bats | 2 ++ 14 files changed, 139 insertions(+), 140 deletions(-) delete mode 100644 .kit/kit.just diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index 8044078..9505c0c 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -50,6 +50,6 @@ applies to it as well. ## Invocation -1. The idiom is the space form, `just kit issue 42`, never `just kit/issue` and never `kit::issue` outside a +1. The idiom is the space form, `just tools check`, never `just tools/check` and never `tools::check` outside a dependency list. 2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index dd0e5f0..d72042d 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -80,7 +80,7 @@ jobs: 2. pick one or more open issues 3. [re-run this check](${run}): linking fires no event, so it does not re-run on its own - \`just kit issue \` makes this link when it starts the work. + \`kit issue \` makes this link when it starts the work. REMEDY ) diff --git a/.kit/1st.md b/.kit/1st.md index b401ebb..5f99747 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -22,8 +22,8 @@ worktree under `worktrees/`, one per issue, and reaches `main` only through a me ## The loop 1. An issue exists. A change without an issue has not started. -2. `just kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a - draft pull request. It never switches the checkout it is run from. +2. `kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a draft + pull request. It never switches the checkout it is run from. 3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. 4. `gh pr ready` says the work is done. The pull request body is the report: what changed, what was checked and how, and what was left out and why. @@ -41,7 +41,7 @@ A worker is whoever implements one issue in the worktree that issue names. 1. `cd` into the worktree named in the task and confirm `git rev-parse --abbrev-ref HEAD` prints `-`. 2. Do every command from there. Never touch the repository root checkout. -3. Never run `just kit issue`, make a branch, or make a worktree. Those were done before the handoff. +3. Never run `kit issue`, make a branch, or make a worktree. Those were done before the handoff. 4. Implement what the issue asks and nothing beside it. 5. Push as you go. 6. Run the repository's gate before calling anything done, and run it through the pinned toolchain. @@ -67,19 +67,20 @@ Code. Read the rule for a kind before writing a file of that kind. ## What the kit carries -- `.kit/kit.just`, a just module. The consumer's root justfile takes it with `mod kit ".kit/kit.just"`, - which gives `just kit issue`, `just kit tidy`, and `just kit status`. The bash behind them is `.kit/bash/`. -- `.kit/hooks/pre-rebase` refuses a rebase of the default branch. `.kit/hooks/pre-commit` lints the staged - paths. `kit install` sets `core.hooksPath` to `.kit/hooks`. -- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the - targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-.md` and merges `.kit/settings.json`, six deny rules, into `.claude/settings.json`. - The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target - back out. +The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the +release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, +carries the machinery, reached through the `kit` command on `PATH`: + +- `kit issue`, `kit tidy`, and `kit status`, the loop's commands. A root justfile that wants `just issue 42` adds + a veneer recipe over `kit issue`. +- Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` + points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. +- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets + it names: `kit install claude`, or `kit target add claude` later. The `claude` target places + `.claude/rules/kit-.md` and merges six deny rules into `.claude/settings.json`. The kit owns only the + entries it writes; the repository may add its own. `kit target remove` takes a target back out. - `.kit/ci.yml`, placed as `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `.kit/manifest`, written by `kit install`: the release version, the targets taken, and a checksum per - delivered file. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/bash/1st.sh b/.kit/bash/1st.sh index 89d94e8..40299dc 100644 --- a/.kit/bash/1st.sh +++ b/.kit/bash/1st.sh @@ -2,7 +2,7 @@ # 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# Sourced into every recipe body of .kit/kit.just through BASH_ENV, and by bin/kit and the hooks that need a +# Sourced by bin/kit, whose issue, tidy, and status run the bash under .kit/bash/, and by the hooks that need a # verb. It defines functions only and sets no shell option, so the caller's options stand. A module under # .kit/bash/ is loaded on first use with `use `, and each exported function has a stub here so a # caller never has to load it by hand. diff --git a/.kit/bash/gh.sh b/.kit/bash/gh.sh index c0e8f49..2367961 100644 --- a/.kit/bash/gh.sh +++ b/.kit/bash/gh.sh @@ -25,7 +25,7 @@ gh:issue() { then if [[ ! -t 0 ]] then - error "usage: just kit issue […]" + error "usage: kit issue […]" return 2 fi guard:require gh || return 1 @@ -143,7 +143,7 @@ gh:issue() { wt="${main_root}/worktrees/${branch}" if [[ -e "${wt}" ]] then - error "${wt} exists but is not a worktree of ${branch}; remove it (or run: just kit tidy) and re-run." + error "${wt} exists but is not a worktree of ${branch}; remove it (or run: kit tidy) and re-run." return 2 fi step "Creating worktree ${wt}" @@ -156,9 +156,10 @@ gh:issue() { fi fi - if [[ "$(git config core.hooksPath 2>/dev/null)" != .kit/hooks ]] + # shellcheck disable=SC2154 # kit_bash_dir is set by the loader, 1st.sh, which sources this file + if [[ "$(git config core.hooksPath 2>/dev/null)" != "$(cd "${kit_bash_dir}/.." && pwd)/hooks" ]] then - warn "run: kit install (core.hooksPath is not .kit/hooks, so the desk guard is off in this clone)" + warn "run: kit setup (core.hooksPath does not name this release's hooks, so the desk guard is off in this clone)" fi # GitHub cannot open a pull request on a branch with nothing to compare, so the first commit is an empty one. diff --git a/.kit/ci.yml b/.kit/ci.yml index dd0e5f0..d72042d 100644 --- a/.kit/ci.yml +++ b/.kit/ci.yml @@ -80,7 +80,7 @@ jobs: 2. pick one or more open issues 3. [re-run this check](${run}): linking fires no event, so it does not re-run on its own - \`just kit issue \` makes this link when it starts the work. + \`kit issue \` makes this link when it starts the work. REMEDY ) diff --git a/.kit/kit.just b/.kit/kit.just deleted file mode 100644 index bc35653..0000000 --- a/.kit/kit.just +++ /dev/null @@ -1,70 +0,0 @@ -# kit.just - see: https://just.systems/man/en/ -# ---------------------------------------------------------------------------------------------------------------------- -# The kit's just module. A consumer's root justfile takes it with `mod kit ".kit/kit.just"`, which gives -# `just kit issue`, `just kit tidy`, and `just kit status`. Each recipe carries [no-cd] so its body runs -# from the checkout it was invoked in, and the bash behind each lives in .kit/bash/, loaded through BASH_ENV. -# -# A root justfile that wants the bare spelling adds its own veneer: -# -# issue *numbers: -# just kit issue "$@" - -set dotenv-load := true -set fallback := true -set positional-arguments := true - -export BASH_ENV := source_directory() / "bash/1st.sh" - -# This file's own path, assigned at parse time so the body below needs no interpolation. -export kit_justfile := source_file() - -# Default recipe: list this justfile's recipes. -_: help - -# List recipes in this justfile. -[no-exit-message] -@help *options: - just --justfile "${kit_justfile}" --list - -set export := true - -# Start, or return to, work on an issue: a branch linked to it under Development, a worktree at -# worktrees/-, a start commit, and a draft pull request. Further numbers are secondary issues the -# pull request closes. Safe to re-run; it never switches the checkout it runs from. -# -# just kit issue 42 # start #42 -# just kit issue 42 43 # start #42, and close #43 with it -# just kit issue # ask for a title, create the issue, and start it (needs a terminal) -[doc("🌿 Start or resume an issue: linked branch, worktree, start commit, draft pull request")] -[no-cd] -issue *numbers: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - gh:issue "$@" - -# Sweep the checkout back to pristine: remove worktrees whose pull request is merged or closed, prune the -# bookkeeping, and delete local branches whose upstream is gone. The default branch, a dirty worktree, and the -# worktree this runs in are never touched. -# -# just kit tidy # sweep -# just kit tidy --dry-run # say what a sweep would do -[doc("🧹 Remove merged worktrees and gone branches")] -[no-cd] -tidy *flags: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - git:tidy "$@" - -# Show the main checkout and every worktree under worktrees/: branch, pull request, and dirty or clean. Reads -# only. -# -# just kit status # fetch --prune, then render -# just kit status --no-fetch # render from the current remote-tracking refs -[doc("📊 Show every worktree with its branch, pull request, and state")] -[no-cd] -status *flags: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - git:status "$@" - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/manifest b/.kit/manifest index 89fb15c..1aa0c6e 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,25 +1,16 @@ version 0.1.0 targets claude -9f794eb95cdbcaf5a938a1444b705a44d539bf998535396c1798d028afb77435 .kit/1st.md -a36a18081eb7515871bc01b7b22372c1545d99d8b393fb386c78128f73c8dbb5 .kit/bash/1st.sh -6944cdb161e68bf5d2355c6ee48fe7bfd25d8ffa1e6edef218af123e5edf5f7c .kit/bash/gh.sh -051cab217451a69c6f73545fe3e68fb2316e0be57004030159fe299d7f510436 .kit/bash/git.sh -8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .kit/ci.yml -1ab629cb79710438fb15fd2fcbadf94ce125f4d3c26bf22def6d35c8c414dbb1 .kit/hooks/pre-commit -414bb9a82191060489213823e98828a64d166c53109c64cdf600fdd3d5c64205 .kit/hooks/pre-rebase -b68c1bc8ba4a142f9a585b7fb58d833e5b3c0476fb629c7a45e67e8e6cb507fc .kit/kit.just +46949c8976de559ef2d07573a2924a44d29a814980e5e8de1e84f275030b67bd .kit/1st.md cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bash.md 5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md 6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md -26e6ddb46be1adeda8ea76d77112096771147821594a89cc14ff8dbf7148d939 .kit/rules/just.md +84943496f3c31932bd0eeb84a7c0b6f83a69b877f38435fbcf9dedd02e723a5e .kit/rules/just.md 5e4ee708d4be30009de264ddf5a18df1d1257faee8ba18ca8fd703c1eb6e8a85 .kit/rules/markdown.md 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md -617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .kit/settings.json -e9dd8507f4bf0c6f42458e41aea833ad0bd3f6127272335eee9bf4d58541ed67 .kit/version -8f05739376491c844a1ee63303a421a0bb87169d0544462a9f9f3ab735d7f7ef .github/workflows/kit-ci.yml +75318ea548c175eeffcdedb521ac41061baab6ea3bedf3f85fcaa06ad8f61b6f .github/workflows/kit-ci.yml 1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .claude/rules/kit-bash.md 4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md 1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md -96d16e8c1d3889909bbc13d6690b07a6d7c47aa2e7d62ff87f4cbc0068f385c7 .claude/rules/kit-just.md +a79bd22a451fb8b1d118f0921c3b3e1bf2bef8ff0ae496891227e22353b98e44 .claude/rules/kit-just.md a495ae7a54188bb82b73d50925012f9611af58396fa82c72c71d8a337c1bccf6 .claude/rules/kit-markdown.md 4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md diff --git a/.kit/rules/just.md b/.kit/rules/just.md index d47b1c7..5ce5ec2 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -44,6 +44,6 @@ applies to it as well. ## Invocation -1. The idiom is the space form, `just kit issue 42`, never `just kit/issue` and never `kit::issue` outside a +1. The idiom is the space form, `just tools check`, never `just tools/check` and never `tools::check` outside a dependency list. 2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/README.md b/README.md index bee69b9..7bd82da 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,22 @@ mise install kit install claude ``` -`kit install` copies `.kit/` into the repository, places `.github/workflows/kit-ci.yml`, ignores `worktrees/`, -points `core.hooksPath` at `.kit/hooks`, and writes `.kit/manifest`. Each target named adds what that harness -insists on having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and merges the kit's deny rules into -`.claude/settings.json`. A repository with no target installed still binds every agent that reads `AGENTS.md`, -through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` change the set later, and -`kit target list` prints it. Commit what it wrote. Then: +`kit install` copies `.kit/1st.md` and `.kit/rules/` into the repository, places `.github/workflows/kit-ci.yml`, +ignores `worktrees/`, points this clone's `core.hooksPath` at the release's hooks, and writes `.kit/manifest`. Each +target named adds what that harness insists on having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and +merges the kit's deny rules into `.claude/settings.json`. A repository with no target installed still binds every +agent that reads `AGENTS.md`, through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` +change the set later, and `kit target list` prints it. Commit what it wrote. Then: - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. -- Add `mod kit ".kit/kit.just"` to the root justfile for `just kit issue`, `just kit tidy`, and `just kit status`. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. +- For `just issue 42`, add a veneer recipe over `kit issue` to the root justfile; `kit issue 42` works as it is. +- In every other clone, run `kit setup` once to wire the hooks. + +The machinery, the `kit` command, the bash behind `kit issue`, `kit tidy`, and `kit status`, and the hooks, stays +in the release where mise installed it; the repository carries the text an agent reads and the record of what it +took. To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries @@ -51,6 +56,7 @@ git config gpg.format openpgp git config user.signingkey "${signing_key}" git config commit.gpgsign true mise install +bin/kit setup ``` This repository carries the installed copy of its own kit, and `just ci` checks it. @@ -60,13 +66,13 @@ This repository carries the installed copy of its own kit, and `just ci` checks Work here moves by the loop in [.kit/1st.md](.kit/1st.md): - An issue exists. A change without an issue has not started. -- `just kit issue ` makes `worktrees/-`, links the branch to the issue under Development, and opens a - draft pull request. It never switches the checkout it runs from. +- `just issue `, a veneer over `kit issue`, makes `worktrees/-`, links the branch to the issue under + Development, and opens a draft pull request. It never switches the checkout it runs from. - Work happens in that worktree. Commits are signed, and their subject is `# : `. - `just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and `bin/kit check`. - `gh pr ready` says the work is done, and the pull request body is the report. -- A human merges. `just kit tidy` then removes the worktree and its branch. +- A human merges. `just tidy` then removes the worktree and its branch. ## Release diff --git a/bin/kit b/bin/kit index 1f538ab..5fab173 100755 --- a/bin/kit +++ b/bin/kit @@ -2,27 +2,32 @@ # kit - install, update, and check the kit in a repository (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # -# kit install […] copy this release into the repository, place the files each named target needs, -# and write .kit/manifest +# kit install […] copy this release's text into the repository, place the files each named target +# needs, wire this clone's hooks, and write .kit/manifest # kit update after the pin is bumped: replace unchanged files, three-way merge changed ones # kit check fail when a delivered file differs from this release +# kit setup wire this clone: core.hooksPath to this release's hooks # kit target list print the targets this repository takes # kit target add … take a target: place its files and record it # kit target remove … drop a target: remove its files and the kit's entries from its settings +# kit issue […] start or resume an issue: linked branch, worktree, start commit, draft pull request +# kit tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches +# kit status [--no-fetch] show every worktree with its branch, pull request, and state # kit version print this release's version # +# The repository carries the text an agent reads, .kit/1st.md and .kit/rules/, the CI workflow .kit/ci.yml placed +# as .github/workflows/kit-ci.yml, the placed files of each target taken, and .kit/manifest, which records the +# release version, the targets, and a digest per delivered path. The machinery stays in the release where mise +# installed it: this command, the bash behind issue, tidy, and status, and the hooks, which a clone reaches through +# core.hooksPath. +# # A target is a harness that insists on files placed outside .kit/. The one this release knows is claude: # .claude/rules/kit-.md, each the rule file of that kind with a paths: header, and the kit's deny entries -# merged into .claude/settings.json. A repository takes only the targets it names, and .kit/manifest records them -# so update and check honor the same set. +# merged into .claude/settings.json. A repository takes only the targets it names. A shared settings file is +# merged by key: the kit owns only the entries it writes. # # KIT_BASE= names the previous release's root for `kit update` on a machine without mise. # -# The release this command belongs to is the directory above bin/: its .kit/ is the payload. The repository is -# the git checkout the command runs in. A delivered file is every file under .kit/ except manifest, the CI -# workflow .kit/ci.yml placed as .github/workflows/kit-ci.yml, and the placed files of each target taken. -# A shared settings file is merged by key: the kit owns only the entries it writes. -# # see: .kit/1st.md set -o errexit -o nounset -o pipefail @@ -35,7 +40,7 @@ source "${payload}/bash/1st.sh" known_targets=(claude) usage() { - sed -n '5,12p' "${BASH_SOURCE[0]}" | sed 's/^#//; s/^ //' >&2 + sed -n '5,16p' "${BASH_SOURCE[0]}" | sed 's/^#//; s/^ //' >&2 } # digest prints the sha256 of stdin, by whichever tool the machine carries. @@ -111,11 +116,11 @@ target-settings() { delivered-paths() { local root=$1 file t shift - while IFS= read -r file + printf '.kit/1st.md\n' + for file in "${root}"/rules/*.md do - [[ "${file}" == manifest ]] && continue - printf '.kit/%s\n' "${file}" - done < <(cd "${root}" && find . -type f | sed 's@^\./@@' | sort) + printf '.kit/rules/%s\n' "$(basename "${file}")" + done printf '.github/workflows/kit-ci.yml\n' for t in "$@" do @@ -155,8 +160,6 @@ write-expected() { mkdir -p "$(dirname "$2")" expected "$1" "$2" > "$2.kit-new" mv "$2.kit-new" "$2" - [[ "$2" == .kit/hooks/* ]] && chmod +x "$2" - return 0 } # merge-settings [] merges the kit's deny entries into the target's settings @@ -203,13 +206,39 @@ write-manifest() { } > .kit/manifest } -# finish sets the clone's hooks path and reports. +# wire-hooks points this clone's core.hooksPath at the release's hooks. +wire-hooks() { + git config core.hooksPath "${payload}/hooks" +} + +# finish wires the clone and reports. finish() { - git config core.hooksPath .kit/hooks - ok "kit $(cat "${payload}/version") is installed, targets: ${*:-none}. core.hooksPath is .kit/hooks, and .kit/manifest is written." + wire-hooks + ok "kit $(cat "${payload}/version") is installed, targets: ${*:-none}. core.hooksPath is ${payload}/hooks, and .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } +cmd-setup() { + guard:require tool:git + wire-hooks + ok "core.hooksPath is ${payload}/hooks" + if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] + then + warn "no signing key is configured in this clone, and the ruleset refuses an unsigned commit; see README.md § Setup" + fi +} + +# cmd-loop … runs one of the loop's commands from the release's bash. +cmd-loop() { + local verb=$1 + shift + case "${verb}" in + (issue) gh:issue "$@" ;; + (tidy) git:tidy "$@" ;; + (status) git:status "$@" ;; + esac +} + cmd-install() { guard:require tool:jq tool:git require-targets "$@" @@ -439,7 +468,9 @@ case "${1:-}" in (install) shift; cmd-install "$@" ;; (update) cmd-update ;; (check) cmd-check ;; + (setup) cmd-setup ;; (target) shift; cmd-target "$@" ;; + (issue | tidy | status) cmd-loop "$@" ;; (version) cat "${payload}/version" ;; (-h | --help | help | "") usage; [[ -n "${1:-}" ]] ;; (*) diff --git a/justfile b/justfile index 3b378f2..145d2c4 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,8 @@ #!/usr/bin/env just --justfile # justfile - see: https://just.systems/man/en/ # ---------------------------------------------------------------------------------------------------------------------- -# The root justfile: the kit's own module, taken the way a consumer takes it, and the gate. +# The root justfile: the loop's three commands as veneers over bin/kit, the way a consumer adds them over the kit +# on its PATH, the gate, and the refresh of this repository's own installed copy. set dotenv-load := true set positional-arguments := true @@ -19,8 +20,32 @@ _: help set export := true -[doc("📁 The kit's recipes: issue, tidy, status (invoke as `just kit `)")] -mod kit ".kit/kit.just" +# The loop's three commands, each a veneer over bin/kit. A consumer that wants the same spelling writes the same +# three recipes over the `kit` on its PATH. +# +# just issue 42 # start #42 +# just issue 42 43 # start #42, and close #43 with it +[doc("🌿 Start or resume an issue: linked branch, worktree, start commit, draft pull request")] +issue *numbers: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + bin/kit issue "$@" + +# just tidy # sweep +# just tidy --dry-run # say what a sweep would do +[doc("🧹 Remove merged worktrees and gone branches")] +tidy *flags: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + bin/kit tidy "$@" + +# just status # fetch --prune, then render +# just status --no-fetch # render from the current remote-tracking refs +[doc("📊 Show every worktree with its branch, pull request, and state")] +status *flags: + #!/usr/bin/env bash + set -o errexit -o nounset -o pipefail + bin/kit status "$@" # The gate a pull request passes: shellcheck over every bash file, actionlint over every workflow, the bats # suite over bin/kit, and bin/kit check over this repository's installed copy of its own kit. Every tool comes diff --git a/tests/kit.bats b/tests/kit.bats index 7b47795..15da482 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -29,7 +29,10 @@ later-release() { run "${kit_checkout}/bin/kit" install claude [ "$status" -eq 0 ] [ -f .kit/1st.md ] - [ -x .kit/hooks/pre-commit ] + [ -f .kit/rules/bash.md ] + [ ! -e .kit/bash ] + [ ! -e .kit/hooks ] + [ ! -e .kit/ci.yml ] [ -f .claude/rules/kit-markdown.md ] head -n 1 .claude/rules/kit-bash.md | grep --quiet --line-regexp -- '---' grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md @@ -38,7 +41,16 @@ later-release() { grep --quiet --line-regexp '/worktrees/' .gitignore grep --quiet '^version 0.1.0$' .kit/manifest grep --quiet '^targets claude$' .kit/manifest - [ "$(git config core.hooksPath)" = ".kit/hooks" ] + [ "$(git config core.hooksPath)" = "${kit_checkout}/.kit/hooks" ] +} + +@test "setup wires a clone's hooks to the release, and issue without a terminal prints usage" { + run "${kit_checkout}/bin/kit" setup + [ "$status" -eq 0 ] + [ "$(git config core.hooksPath)" = "${kit_checkout}/.kit/hooks" ] + run "${kit_checkout}/bin/kit" issue + [ "$status" -eq 2 ] + [[ "$output" == *"usage: kit issue"* ]] } @test "install with no target writes the neutral tree and nothing under .claude" { diff --git a/tests/release.bats b/tests/release.bats index e5a0494..64935df 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -29,6 +29,8 @@ teardown() { [ "$status" -eq 0 ] run "${work}/unpacked/kit-${version}/bin/kit" check [ "$status" -eq 0 ] + [ "$(git config core.hooksPath)" = "${work}/unpacked/kit-${version}/.kit/hooks" ] + [ ! -e .kit/bash ] [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] } From 88b6b1065cc9e10d81c1c28f268af4b7e7462cda Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:34:55 -0700 Subject: [PATCH 018/108] #1 README.md: a Maintaining section, and the placed copies say so Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-bash.md | 2 ++ .claude/rules/kit-gitignore.md | 2 ++ .claude/rules/kit-json.md | 2 ++ .claude/rules/kit-just.md | 2 ++ .claude/rules/kit-markdown.md | 2 ++ .claude/rules/kit-yaml.md | 2 ++ .kit/manifest | 13 +++++++------ AGENTS.md | 4 ++-- README.md | 18 ++++++++++++++++++ bin/kit | 6 +++++- 10 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md index dbecf1c..339d791 100644 --- a/.claude/rules/kit-bash.md +++ b/.claude/rules/kit-bash.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/bash.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - "**/*.sh" - "**/*.bash" diff --git a/.claude/rules/kit-gitignore.md b/.claude/rules/kit-gitignore.md index 41b87e4..a9ab4e1 100644 --- a/.claude/rules/kit-gitignore.md +++ b/.claude/rules/kit-gitignore.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/gitignore.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - ".gitignore" - "**/.gitignore" diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md index 9d0859d..390b0c9 100644 --- a/.claude/rules/kit-json.md +++ b/.claude/rules/kit-json.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/json.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - "**/*.json" - "**/*.jsonc" diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index 9505c0c..41ffc35 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/just.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - "justfile" - "**/justfile" diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md index 53c613f..8609837 100644 --- a/.claude/rules/kit-markdown.md +++ b/.claude/rules/kit-markdown.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/markdown.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - "**/*.md" --- diff --git a/.claude/rules/kit-yaml.md b/.claude/rules/kit-yaml.md index 69fe17c..8948004 100644 --- a/.claude/rules/kit-yaml.md +++ b/.claude/rules/kit-yaml.md @@ -1,4 +1,6 @@ --- +# Placed by kit install from the release's rules/yaml.md, and held to it by kit check. A delivered file is +# changed by pinning a new release and running kit update, never in place. paths: - "**/*.yml" - "**/*.yaml" diff --git a/.kit/manifest b/.kit/manifest index 1aa0c6e..569d275 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,3 +1,4 @@ +# .kit/manifest - written by kit install, update, and target; never edited by hand version 0.1.0 targets claude 46949c8976de559ef2d07573a2924a44d29a814980e5e8de1e84f275030b67bd .kit/1st.md @@ -8,9 +9,9 @@ cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bas 5e4ee708d4be30009de264ddf5a18df1d1257faee8ba18ca8fd703c1eb6e8a85 .kit/rules/markdown.md 52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md 75318ea548c175eeffcdedb521ac41061baab6ea3bedf3f85fcaa06ad8f61b6f .github/workflows/kit-ci.yml -1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .claude/rules/kit-bash.md -4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .claude/rules/kit-gitignore.md -1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .claude/rules/kit-json.md -a79bd22a451fb8b1d118f0921c3b3e1bf2bef8ff0ae496891227e22353b98e44 .claude/rules/kit-just.md -a495ae7a54188bb82b73d50925012f9611af58396fa82c72c71d8a337c1bccf6 .claude/rules/kit-markdown.md -4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .claude/rules/kit-yaml.md +1b174f1aa06216c0fb7f8956097b46e2ffd83645d08858b084055d79a6ac4dee .claude/rules/kit-bash.md +c458cb28cf792ff71196c42ce9f7ed53f089fcfe3fe434481750651c2feaf27c .claude/rules/kit-gitignore.md +e62643c3beef3ebbe9c762a469b9d1250cea32519ade0b600a1911e2a65ae2d0 .claude/rules/kit-json.md +370c3a2fc4c36435969fb2c9c79f6bb472832f3e50edea9b0edd27f819365433 .claude/rules/kit-just.md +00b26e882f08950b432b475a0e23e17a24638447e7b2e2b74ce4671bd528b2f0 .claude/rules/kit-markdown.md +44aaf9ecd3caa8dd7ce975c89842322e5efba14ead64550bc8b314382a6f7b71 .claude/rules/kit-yaml.md diff --git a/AGENTS.md b/AGENTS.md index 06f6e6c..557c911 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,6 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any other pairing, and a tag whose base version differs from the file. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are - this repository's installed copies of its own kit. Change a delivered file at its source under `.kit/`, then - run `just refresh` to rewrite the copies, and commit both. + this repository's installed copies of its own kit, written by `just refresh` from the source under `.kit/`; see + [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. - `just ci` is the gate: shellcheck, actionlint, the bats suite, and `bin/kit check`. diff --git a/README.md b/README.md index 7bd82da..dbeefe6 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,24 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): - `gh pr ready` says the work is done, and the pull request body is the report. - A human merges. `just tidy` then removes the worktree and its branch. +## Maintaining + +This repository is both the source of the kit and a consumer of it, so some committed files are written by +`bin/kit install` from others and are not edited by hand: + +| Installed copy | Written from | +| --- | --- | +| `.claude/rules/kit-.md` | `.kit/rules/.md`, with the `paths:` header the `claude` target adds | +| `.claude/settings.json` | `.kit/settings.json`, merged by key; here it carries only the kit's entries | +| `.github/workflows/kit-ci.yml` | `.kit/ci.yml` | +| `.kit/manifest` | the release version, the targets taken, and a digest per delivered path | + +Everything else is source, all of `.kit/` included: `1st.md`, `rules/`, `bash/`, `hooks/`, `ci.yml`, +`settings.json`, and `version`, together with `bin/kit`, the two workflows, and `tests/`. After changing a source +file under `.kit/`, run `just refresh`, which reinstalls the kit into this repository, and commit the result with +the change. `bin/kit check`, which `just ci` runs, names any installed copy that no longer matches its source. The +copies that can carry a comment say they are placed; `settings.json` cannot, so this table is its notice. + ## Release `.kit/version` carries the version under development, without a suffix. A release is the tag `v` on a diff --git a/bin/kit b/bin/kit index 5fab173..034d2e7 100755 --- a/bin/kit +++ b/bin/kit @@ -138,7 +138,10 @@ expected() { (.claude/rules/kit-*.md) kind=${path#.claude/rules/kit-} kind=${kind%.md} - printf -- '---\npaths:\n' + printf -- '---\n' + printf '# Placed by kit install from the release'"'"'s rules/%s.md, and held to it by kit check. A delivered file is\n' "${kind}" + printf '# changed by pinning a new release and running kit update, never in place.\n' + printf 'paths:\n' while IFS= read -r glob do printf ' - "%s"\n' "${glob}" @@ -197,6 +200,7 @@ ensure-ignored() { write-manifest() { local path { + printf '# .kit/manifest - written by kit install, update, and target; never edited by hand\n' printf 'version %s\n' "$(cat "${payload}/version")" printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path From 3229eb7762f0fb3f99bf703a6472cdbcd74dd40d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:47:55 -0700 Subject: [PATCH 019/108] #1 .kit/rules: each rule names its own paths; the placed notice is one line Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-bash.md | 3 +-- .claude/rules/kit-gitignore.md | 3 +-- .claude/rules/kit-json.md | 3 +-- .claude/rules/kit-just.md | 3 +-- .claude/rules/kit-markdown.md | 3 +-- .claude/rules/kit-yaml.md | 3 +-- .kit/1st.md | 7 ++++--- .kit/manifest | 28 ++++++++++++++-------------- .kit/rules/bash.md | 6 ++++++ .kit/rules/gitignore.md | 5 +++++ .kit/rules/json.md | 5 +++++ .kit/rules/just.md | 6 ++++++ .kit/rules/markdown.md | 4 ++++ .kit/rules/yaml.md | 5 +++++ README.md | 2 +- bin/kit | 32 ++++++-------------------------- 16 files changed, 62 insertions(+), 56 deletions(-) diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md index 339d791..3925045 100644 --- a/.claude/rules/kit-bash.md +++ b/.claude/rules/kit-bash.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/bash.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/bash.md, not edited by hand. paths: - "**/*.sh" - "**/*.bash" diff --git a/.claude/rules/kit-gitignore.md b/.claude/rules/kit-gitignore.md index a9ab4e1..564abb7 100644 --- a/.claude/rules/kit-gitignore.md +++ b/.claude/rules/kit-gitignore.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/gitignore.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/gitignore.md, not edited by hand. paths: - ".gitignore" - "**/.gitignore" diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md index 390b0c9..2bfa91d 100644 --- a/.claude/rules/kit-json.md +++ b/.claude/rules/kit-json.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/json.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/json.md, not edited by hand. paths: - "**/*.json" - "**/*.jsonc" diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index 41ffc35..b2873d8 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/just.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/just.md, not edited by hand. paths: - "justfile" - "**/justfile" diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md index 8609837..acd24ed 100644 --- a/.claude/rules/kit-markdown.md +++ b/.claude/rules/kit-markdown.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/markdown.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/markdown.md, not edited by hand. paths: - "**/*.md" --- diff --git a/.claude/rules/kit-yaml.md b/.claude/rules/kit-yaml.md index 8948004..006d844 100644 --- a/.claude/rules/kit-yaml.md +++ b/.claude/rules/kit-yaml.md @@ -1,6 +1,5 @@ --- -# Placed by kit install from the release's rules/yaml.md, and held to it by kit check. A delivered file is -# changed by pinning a new release and running kit update, never in place. +# Placed by kit install from .kit/rules/yaml.md, not edited by hand. paths: - "**/*.yml" - "**/*.yaml" diff --git a/.kit/1st.md b/.kit/1st.md index 5f99747..f32f7a4 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -52,9 +52,10 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type -Each file kind has a rule file under `.kit/rules/`. A harness that loads rules by path finds the same text -under its own rules directory when the repository takes that target, `.claude/rules/kit-.md` for Claude -Code. Read the rule for a kind before writing a file of that kind. +Each file kind has a rule file under `.kit/rules/`, and each rule file names the paths it binds in its `paths:` +header. A harness that loads rules by path finds a copy under its own rules directory when the repository takes +that target, `.claude/rules/kit-.md` for Claude Code. Read the rule for a kind before writing a file of +that kind. | Kind | Paths | Rule | | --- | --- | --- | diff --git a/.kit/manifest b/.kit/manifest index 569d275..895dffc 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,17 +1,17 @@ -# .kit/manifest - written by kit install, update, and target; never edited by hand +# .kit/manifest - written by kit, not by hand version 0.1.0 targets claude -46949c8976de559ef2d07573a2924a44d29a814980e5e8de1e84f275030b67bd .kit/1st.md -cb95242d5caeee5651433a7ed8fad571bbca45f2f07a07cf4feb1f7371867402 .kit/rules/bash.md -5c3667b1282bf6afe6cebc401e99faa2ba52e4a4873bbec9a6368a4623277f2d .kit/rules/gitignore.md -6ddb651356399b10dc33858cdf38fe111aa8803cde1d499135faa0e196f74f97 .kit/rules/json.md -84943496f3c31932bd0eeb84a7c0b6f83a69b877f38435fbcf9dedd02e723a5e .kit/rules/just.md -5e4ee708d4be30009de264ddf5a18df1d1257faee8ba18ca8fd703c1eb6e8a85 .kit/rules/markdown.md -52c854d31381d85c01ffd5cdb10ba1dfd8081ad80ff1a8108cf42b5e77d42bc8 .kit/rules/yaml.md +700cd37d14cf16d80adf4a96c1ef1037f0956d513d39e0682903625ecec4e32d .kit/1st.md +1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .kit/rules/bash.md +4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .kit/rules/gitignore.md +1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .kit/rules/json.md +a79bd22a451fb8b1d118f0921c3b3e1bf2bef8ff0ae496891227e22353b98e44 .kit/rules/just.md +a495ae7a54188bb82b73d50925012f9611af58396fa82c72c71d8a337c1bccf6 .kit/rules/markdown.md +4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .kit/rules/yaml.md 75318ea548c175eeffcdedb521ac41061baab6ea3bedf3f85fcaa06ad8f61b6f .github/workflows/kit-ci.yml -1b174f1aa06216c0fb7f8956097b46e2ffd83645d08858b084055d79a6ac4dee .claude/rules/kit-bash.md -c458cb28cf792ff71196c42ce9f7ed53f089fcfe3fe434481750651c2feaf27c .claude/rules/kit-gitignore.md -e62643c3beef3ebbe9c762a469b9d1250cea32519ade0b600a1911e2a65ae2d0 .claude/rules/kit-json.md -370c3a2fc4c36435969fb2c9c79f6bb472832f3e50edea9b0edd27f819365433 .claude/rules/kit-just.md -00b26e882f08950b432b475a0e23e17a24638447e7b2e2b74ce4671bd528b2f0 .claude/rules/kit-markdown.md -44aaf9ecd3caa8dd7ce975c89842322e5efba14ead64550bc8b314382a6f7b71 .claude/rules/kit-yaml.md +7fbc4682444cb12618109d35e70d4fe8b44800ea3c8a9650990600de377da743 .claude/rules/kit-bash.md +aa929618fa9c7f7453f1c6a2ada1a4cdb438ec75d5b27545bc3408e91009f9a4 .claude/rules/kit-gitignore.md +ca53518000325e42ca47f288277127ea3c4f7f6bcb8592b0a385e042831a3d63 .claude/rules/kit-json.md +57a57fd00cbd259373db63e925a565326a598946f68d96d17840e6d6a788968e .claude/rules/kit-just.md +ad78f52fbcc9aca6bc9c3a42b439376ae85bbe32323194571a309ac106e459cc .claude/rules/kit-markdown.md +416a84c9dabb8eacbcd5e2cfa4a3b22fe034d368263a43097f08d0a9a29b547c .claude/rules/kit-yaml.md diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md index 786ba9c..dbecf1c 100644 --- a/.kit/rules/bash.md +++ b/.kit/rules/bash.md @@ -1,3 +1,9 @@ +--- +paths: + - "**/*.sh" + - "**/*.bash" + - ".kit/hooks/*" +--- # Bash Applies to `*.sh`, `*.bash`, `.kit/hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in diff --git a/.kit/rules/gitignore.md b/.kit/rules/gitignore.md index bc034de..41b87e4 100644 --- a/.kit/rules/gitignore.md +++ b/.kit/rules/gitignore.md @@ -1,3 +1,8 @@ +--- +paths: + - ".gitignore" + - "**/.gitignore" +--- # Ignore lists Applies to `.gitignore` at any depth. diff --git a/.kit/rules/json.md b/.kit/rules/json.md index b11ca35..9d0859d 100644 --- a/.kit/rules/json.md +++ b/.kit/rules/json.md @@ -1,3 +1,8 @@ +--- +paths: + - "**/*.json" + - "**/*.jsonc" +--- # JSON Applies to `*.json` and `*.jsonc`. diff --git a/.kit/rules/just.md b/.kit/rules/just.md index 5ce5ec2..9505c0c 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -1,3 +1,9 @@ +--- +paths: + - "justfile" + - "**/justfile" + - "**/*.just" +--- # Just Applies to `justfile` and `*.just`. A recipe body under `#!/usr/bin/env bash` is bash, and the bash rule diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md index ce54df8..53c613f 100644 --- a/.kit/rules/markdown.md +++ b/.kit/rules/markdown.md @@ -1,3 +1,7 @@ +--- +paths: + - "**/*.md" +--- # Markdown Applies to `*.md`. diff --git a/.kit/rules/yaml.md b/.kit/rules/yaml.md index b39eca6..69fe17c 100644 --- a/.kit/rules/yaml.md +++ b/.kit/rules/yaml.md @@ -1,3 +1,8 @@ +--- +paths: + - "**/*.yml" + - "**/*.yaml" +--- # YAML Applies to `*.yml` and `*.yaml`, which here means GitHub Actions workflows. diff --git a/README.md b/README.md index dbeefe6..caeb611 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ This repository is both the source of the kit and a consumer of it, so some comm | Installed copy | Written from | | --- | --- | -| `.claude/rules/kit-.md` | `.kit/rules/.md`, with the `paths:` header the `claude` target adds | +| `.claude/rules/kit-.md` | `.kit/rules/.md`, header and all, with a one-line notice | | `.claude/settings.json` | `.kit/settings.json`, merged by key; here it carries only the kit's entries | | `.github/workflows/kit-ci.yml` | `.kit/ci.yml` | | `.kit/manifest` | the release version, the targets taken, and a digest per delivered path | diff --git a/bin/kit b/bin/kit index 034d2e7..5864fdb 100755 --- a/bin/kit +++ b/bin/kit @@ -22,7 +22,7 @@ # core.hooksPath. # # A target is a harness that insists on files placed outside .kit/. The one this release knows is claude: -# .claude/rules/kit-.md, each the rule file of that kind with a paths: header, and the kit's deny entries +# .claude/rules/kit-.md, a copy of each rule file, whose own paths: header the harness loads it by, and the deny entries # merged into .claude/settings.json. A repository takes only the targets it names. A shared settings file is # merged by key: the kit owns only the entries it writes. # @@ -80,19 +80,6 @@ require-targets() { manifest-version() { sed -n 's/^version //p' .kit/manifest; } manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } -# rule-paths prints the paths: globs a harness loads the rule of for, one per line. -rule-paths() { - case "$1" in - (markdown) printf '%s\n' '**/*.md' ;; - (bash) printf '%s\n' '**/*.sh' '**/*.bash' '.kit/hooks/*' ;; - (just) printf '%s\n' 'justfile' '**/justfile' '**/*.just' ;; - (yaml) printf '%s\n' '**/*.yml' '**/*.yaml' ;; - (json) printf '%s\n' '**/*.json' '**/*.jsonc' ;; - (gitignore) printf '%s\n' '.gitignore' '**/.gitignore' ;; - (*) printf '%s\n' "**/*.$1" - esac -} - # target-paths prints the repository paths a target places. target-paths() { local root=$1 file @@ -130,24 +117,17 @@ delivered-paths() { # expected prints the content the release at delivers at repository . expected() { - local root=$1 path=$2 kind glob + local root=$1 path=$2 kind case "${path}" in (.kit/*) cat "${root}/${path#.kit/}" ;; (.claude/rules/kit-*.md) + # The rule file as it is, its paths: header included, with a one-line notice inside the header. kind=${path#.claude/rules/kit-} kind=${kind%.md} - printf -- '---\n' - printf '# Placed by kit install from the release'"'"'s rules/%s.md, and held to it by kit check. A delivered file is\n' "${kind}" - printf '# changed by pinning a new release and running kit update, never in place.\n' - printf 'paths:\n' - while IFS= read -r glob - do - printf ' - "%s"\n' "${glob}" - done < <(rule-paths "${kind}") - printf -- '---\n' - cat "${root}/rules/${kind}.md" + printf -- '---\n# Placed by kit install from .kit/rules/%s.md, not edited by hand.\n' "${kind}" + tail -n +2 "${root}/rules/${kind}.md" ;; (.github/workflows/kit-ci.yml) cat "${root}/ci.yml" @@ -200,7 +180,7 @@ ensure-ignored() { write-manifest() { local path { - printf '# .kit/manifest - written by kit install, update, and target; never edited by hand\n' + printf '# .kit/manifest - written by kit, not by hand\n' printf 'version %s\n' "$(cat "${payload}/version")" printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path From 29dea0fa2df96accf2b18f1db2d672692c433676 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:56:20 -0700 Subject: [PATCH 020/108] #1 bin/kit: a release is a tree mirrored at the root; the only rule is a merge for json The release root holds tree/, mirrored verbatim at a consumer's root; targets//, mirrored when the target is taken; and bash/, hooks/, and version, the machinery. bin/kit walks those directories and copies what it finds, a symlink as the file it points at, so the Claude mirrors are links into tree/.kit/rules/ and a new rule is one file and one link. A .json destination is merged by key with one jq definition. Notices live in the delivered files themselves. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-bash.md | 6 +- .claude/rules/kit-gitignore.md | 2 +- .claude/rules/kit-json.md | 2 +- .claude/rules/kit-just.md | 2 +- .claude/rules/kit-markdown.md | 2 +- .claude/rules/kit-yaml.md | 2 +- .github/workflows/kit-ci.yml | 2 +- .github/workflows/release.yml | 17 +- .kit/1st.md | 11 +- .kit/manifest | 29 +- .kit/rules/bash.md | 5 +- .kit/rules/gitignore.md | 1 + .kit/rules/json.md | 1 + .kit/rules/just.md | 1 + .kit/rules/markdown.md | 1 + .kit/rules/yaml.md | 1 + AGENTS.md | 8 +- README.md | 36 +- {.kit/bash => bash}/1st.sh | 7 +- {.kit/bash => bash}/gh.sh | 4 + {.kit/bash => bash}/git.sh | 0 bin/kit | 460 +++++++++--------- {.kit/hooks => hooks}/pre-commit | 2 +- {.kit/hooks => hooks}/pre-rebase | 0 justfile | 8 +- targets/claude/.claude/rules/kit-bash.md | 1 + targets/claude/.claude/rules/kit-gitignore.md | 1 + targets/claude/.claude/rules/kit-json.md | 1 + targets/claude/.claude/rules/kit-just.md | 1 + targets/claude/.claude/rules/kit-markdown.md | 1 + targets/claude/.claude/rules/kit-yaml.md | 1 + .../claude/.claude}/settings.json | 0 tests/hooks.bats | 2 +- tests/kit.bats | 28 +- tests/release.bats | 16 +- .../.github/workflows/kit-ci.yml | 2 +- tree/.kit/1st.md | 90 ++++ tree/.kit/rules/bash.md | 50 ++ tree/.kit/rules/gitignore.md | 30 ++ tree/.kit/rules/json.md | 27 + tree/.kit/rules/just.md | 56 +++ tree/.kit/rules/markdown.md | 53 ++ tree/.kit/rules/yaml.md | 35 ++ .kit/version => version | 0 44 files changed, 671 insertions(+), 334 deletions(-) rename {.kit/bash => bash}/1st.sh (91%) rename {.kit/bash => bash}/gh.sh (97%) rename {.kit/bash => bash}/git.sh (100%) rename {.kit/hooks => hooks}/pre-commit (98%) rename {.kit/hooks => hooks}/pre-rebase (100%) create mode 120000 targets/claude/.claude/rules/kit-bash.md create mode 120000 targets/claude/.claude/rules/kit-gitignore.md create mode 120000 targets/claude/.claude/rules/kit-json.md create mode 120000 targets/claude/.claude/rules/kit-just.md create mode 120000 targets/claude/.claude/rules/kit-markdown.md create mode 120000 targets/claude/.claude/rules/kit-yaml.md rename {.kit => targets/claude/.claude}/settings.json (100%) rename .kit/ci.yml => tree/.github/workflows/kit-ci.yml (98%) create mode 100644 tree/.kit/1st.md create mode 100644 tree/.kit/rules/bash.md create mode 100644 tree/.kit/rules/gitignore.md create mode 100644 tree/.kit/rules/json.md create mode 100644 tree/.kit/rules/just.md create mode 100644 tree/.kit/rules/markdown.md create mode 100644 tree/.kit/rules/yaml.md rename .kit/version => version (100%) diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md index 3925045..86d45e8 100644 --- a/.claude/rules/kit-bash.md +++ b/.claude/rules/kit-bash.md @@ -1,13 +1,13 @@ --- -# Placed by kit install from .kit/rules/bash.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.sh" - "**/*.bash" - - ".kit/hooks/*" + - "**/hooks/*" --- # Bash -Applies to `*.sh`, `*.bash`, `.kit/hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in workflows. ## Shape diff --git a/.claude/rules/kit-gitignore.md b/.claude/rules/kit-gitignore.md index 564abb7..7465b65 100644 --- a/.claude/rules/kit-gitignore.md +++ b/.claude/rules/kit-gitignore.md @@ -1,5 +1,5 @@ --- -# Placed by kit install from .kit/rules/gitignore.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - ".gitignore" - "**/.gitignore" diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md index 2bfa91d..692c8aa 100644 --- a/.claude/rules/kit-json.md +++ b/.claude/rules/kit-json.md @@ -1,5 +1,5 @@ --- -# Placed by kit install from .kit/rules/json.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.json" - "**/*.jsonc" diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index b2873d8..0698212 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -1,5 +1,5 @@ --- -# Placed by kit install from .kit/rules/just.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "justfile" - "**/justfile" diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md index acd24ed..d830ce3 100644 --- a/.claude/rules/kit-markdown.md +++ b/.claude/rules/kit-markdown.md @@ -1,5 +1,5 @@ --- -# Placed by kit install from .kit/rules/markdown.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.md" --- diff --git a/.claude/rules/kit-yaml.md b/.claude/rules/kit-yaml.md index 006d844..3e5795c 100644 --- a/.claude/rules/kit-yaml.md +++ b/.claude/rules/kit-yaml.md @@ -1,5 +1,5 @@ --- -# Placed by kit install from .kit/rules/yaml.md, not edited by hand. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.yml" - "**/*.yaml" diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index d72042d..c4cb81d 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -6,7 +6,7 @@ # not linked to an open issue of this repository under Development. The last two run even when an earlier step # refused, so one run reports every finding. # -# This file is placed by `kit install` and verified by `kit check`. Change it by pinning a new release. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. # # see: .kit/1st.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 432d02f..189a2cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ # release.yml - a tag v on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/ and .kit/, the layout -# a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., +# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, bash/, +# hooks/, and version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., # is tagged on main (any commit on main, not only its head). A pre-release, the same with a -, is tagged on -# a commit that is not on main, and is published as a GitHub pre-release. Either way .kit/version at the tagged +# a commit that is not on main, and is published as a GitHub pre-release. Either way the file version at the tagged # commit is the version without the suffix, the one under development, and the packed kit carries the tag's full # version so a consumer pinning a pre-release sees it in kit version and .kit/manifest. # @@ -62,13 +62,13 @@ jobs: set -o errexit -o nounset -o pipefail tag_version=${GITHUB_REF_NAME#v} base_version=${tag_version%%-*} - file_version=$(cat .kit/version) + file_version=$(cat version) if [[ "${base_version}" != "${file_version}" ]] then - echo "::error::the tag says ${base_version} and .kit/version says ${file_version}; set .kit/version at the tagged commit first" + echo "::error::the tag says ${base_version} and version says ${file_version}; set version at the tagged commit first" exit 1 fi - echo "✅ .kit/version is ${file_version}." + echo "✅ version is ${file_version}." - name: pack-and-release env: @@ -78,10 +78,9 @@ jobs: version=${GITHUB_REF_NAME#v} staging=$(mktemp --directory) mkdir "${staging}/kit-${version}" - cp --recursive bin .kit "${staging}/kit-${version}/" - rm --force "${staging}/kit-${version}/.kit/manifest" + cp --recursive --no-dereference bin tree targets bash hooks version "${staging}/kit-${version}/" # the packed kit carries the tag's full version, suffix included; the repository file carries the base - printf '%s\n' "${version}" > "${staging}/kit-${version}/.kit/version" + printf '%s\n' "${version}" > "${staging}/kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" sha256sum "kit-${version}.tar.gz" > "kit-${version}.tar.gz.sha256" notes=$(printf 'Pin it in mise.toml:\n\n [tools]\n "github:empowerite/kit" = "%s"\n\nThen, in the repository: kit install\n' "${version}") diff --git a/.kit/1st.md b/.kit/1st.md index f32f7a4..db363e7 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -1,3 +1,4 @@ + # Read this first This file is the kit's entry. It binds every agent and every session in a repository that carries `.kit/`, and @@ -60,7 +61,7 @@ that kind. | Kind | Paths | Rule | | --- | --- | --- | | Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | -| Bash | `*.sh`, `*.bash`, `.kit/hooks/*` | [rules/bash.md](rules/bash.md) | +| Bash | `*.sh`, `*.bash`, `hooks/*` | [rules/bash.md](rules/bash.md) | | Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | | YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | | JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | @@ -70,7 +71,9 @@ that kind. The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, -carries the machinery, reached through the `kit` command on `PATH`: +carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the +repository root, and `targets//` for each target taken; a `.json` destination is merged by key, everything +else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands. A root justfile that wants `just issue 42` adds a veneer recipe over `kit issue`. @@ -78,9 +81,9 @@ carries the machinery, reached through the `kit` command on `PATH`: points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-.md` and merges six deny rules into `.claude/settings.json`. The kit owns only the + `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target back out. -- `.kit/ci.yml`, placed as `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, +- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a diff --git a/.kit/manifest b/.kit/manifest index 895dffc..e395d5b 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,17 +1,18 @@ # .kit/manifest - written by kit, not by hand version 0.1.0 targets claude -700cd37d14cf16d80adf4a96c1ef1037f0956d513d39e0682903625ecec4e32d .kit/1st.md -1af539b255a7e6110a5a35d64f6c804441354ea444fecd13e5bbb3086ddeee87 .kit/rules/bash.md -4c856c8a5e76aa7fe464f3ec3444a9607f653454ce1c1fc75d6e85ca3e67b35f .kit/rules/gitignore.md -1c3d02d2f022abecd49c6b4a38294e37e0df8cc97dbac0558a4f25595fb139d5 .kit/rules/json.md -a79bd22a451fb8b1d118f0921c3b3e1bf2bef8ff0ae496891227e22353b98e44 .kit/rules/just.md -a495ae7a54188bb82b73d50925012f9611af58396fa82c72c71d8a337c1bccf6 .kit/rules/markdown.md -4f2d5c15ec02e7549df338ff3ed51d5ebaf702b749dc078ae34392b04fb21c47 .kit/rules/yaml.md -75318ea548c175eeffcdedb521ac41061baab6ea3bedf3f85fcaa06ad8f61b6f .github/workflows/kit-ci.yml -7fbc4682444cb12618109d35e70d4fe8b44800ea3c8a9650990600de377da743 .claude/rules/kit-bash.md -aa929618fa9c7f7453f1c6a2ada1a4cdb438ec75d5b27545bc3408e91009f9a4 .claude/rules/kit-gitignore.md -ca53518000325e42ca47f288277127ea3c4f7f6bcb8592b0a385e042831a3d63 .claude/rules/kit-json.md -57a57fd00cbd259373db63e925a565326a598946f68d96d17840e6d6a788968e .claude/rules/kit-just.md -ad78f52fbcc9aca6bc9c3a42b439376ae85bbe32323194571a309ac106e459cc .claude/rules/kit-markdown.md -416a84c9dabb8eacbcd5e2cfa4a3b22fe034d368263a43097f08d0a9a29b547c .claude/rules/kit-yaml.md +04543fa071df19f28e15f62b678881660391b1c620b001af7932822c7183f774 .claude/rules/kit-bash.md +0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md +1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md +98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .claude/rules/kit-just.md +15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md +03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md +617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .claude/settings.json +b9a38f19b571c70bb4ef7d3f25fd2b56ac0f6fc076cbcc8e318d22ad3ffe594f .github/workflows/kit-ci.yml +c65a92daf5acfe3ed1bd93e263594f6932321d2c4d8cec71521046a8dd137aa6 .kit/1st.md +04543fa071df19f28e15f62b678881660391b1c620b001af7932822c7183f774 .kit/rules/bash.md +0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md +1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md +98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .kit/rules/just.md +15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .kit/rules/markdown.md +03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .kit/rules/yaml.md diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md index dbecf1c..86d45e8 100644 --- a/.kit/rules/bash.md +++ b/.kit/rules/bash.md @@ -1,12 +1,13 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.sh" - "**/*.bash" - - ".kit/hooks/*" + - "**/hooks/*" --- # Bash -Applies to `*.sh`, `*.bash`, `.kit/hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in workflows. ## Shape diff --git a/.kit/rules/gitignore.md b/.kit/rules/gitignore.md index 41b87e4..7465b65 100644 --- a/.kit/rules/gitignore.md +++ b/.kit/rules/gitignore.md @@ -1,4 +1,5 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - ".gitignore" - "**/.gitignore" diff --git a/.kit/rules/json.md b/.kit/rules/json.md index 9d0859d..692c8aa 100644 --- a/.kit/rules/json.md +++ b/.kit/rules/json.md @@ -1,4 +1,5 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.json" - "**/*.jsonc" diff --git a/.kit/rules/just.md b/.kit/rules/just.md index 9505c0c..0698212 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -1,4 +1,5 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "justfile" - "**/justfile" diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md index 53c613f..d830ce3 100644 --- a/.kit/rules/markdown.md +++ b/.kit/rules/markdown.md @@ -1,4 +1,5 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.md" --- diff --git a/.kit/rules/yaml.md b/.kit/rules/yaml.md index 69fe17c..3e5795c 100644 --- a/.kit/rules/yaml.md +++ b/.kit/rules/yaml.md @@ -1,4 +1,5 @@ --- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. paths: - "**/*.yml" - "**/*.yaml" diff --git a/AGENTS.md b/AGENTS.md index 557c911..e3b1109 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,12 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its ## This repository's own rules -- The delivered tree is `.kit/` and `bin/kit`. Everything in it is written for a consumer: no file names a - consumer, a person, or another repository as the source of a rule. -- `.kit/version` is the version under development, with no suffix. A release is the tag `v` on a commit on +- The release is `bin/`, `tree/`, `targets/`, `bash/`, `hooks/`, and `version`. Everything in it is written for a + consumer: no file names a consumer, a person, or another repository as the source of a rule. +- `version` is the version under development, with no suffix. A release is the tag `v` on a commit on `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any other pairing, and a tag whose base version differs from the file. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are - this repository's installed copies of its own kit, written by `just refresh` from the source under `.kit/`; see + this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. - `just ci` is the gate: shellcheck, actionlint, the bats suite, and `bin/kit check`. diff --git a/README.md b/README.md index caeb611..25d85ee 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ change the set later, and `kit target list` prints it. Commit what it wrote. The The machinery, the `kit` command, the bash behind `kit issue`, `kit tidy`, and `kit status`, and the hooks, stays in the release where mise installed it; the repository carries the text an agent reads and the record of what it -took. +took. The kit needs bash 4.4 or later, git, `gh`, and `jq`. To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries and settings keys stay. `kit check` fails, locally and in `CI`, when a delivered file differs from the pinned release. -The kit needs bash 4 or later, git, `gh`, and `jq`. macOS ships bash 3.2; `brew install bash` gives a current one. +macOS ships bash 3.2; `brew install bash` gives a current one. ## Setup @@ -76,29 +76,33 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): ## Maintaining -This repository is both the source of the kit and a consumer of it, so some committed files are written by -`bin/kit install` from others and are not edited by hand: +This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what +every consumer gets, mirrored at its root; `targets//` is what a consumer taking that target gets, mirrored +the same way; `bash/`, `hooks/`, and `version` are the machinery `bin/kit` runs. A symlink under `targets/` is +delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. + +The installed copies are written by `bin/kit install` and not edited by hand: | Installed copy | Written from | | --- | --- | -| `.claude/rules/kit-.md` | `.kit/rules/.md`, header and all, with a one-line notice | -| `.claude/settings.json` | `.kit/settings.json`, merged by key; here it carries only the kit's entries | -| `.github/workflows/kit-ci.yml` | `.kit/ci.yml` | +| `.kit/1st.md`, `.kit/rules/` | `tree/.kit/` | +| `.github/workflows/kit-ci.yml` | `tree/.github/workflows/kit-ci.yml` | +| `.claude/rules/kit-.md` | `targets/claude/.claude/rules/`, links into `tree/.kit/rules/` | +| `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here it carries only the kit's entries | | `.kit/manifest` | the release version, the targets taken, and a digest per delivered path | -Everything else is source, all of `.kit/` included: `1st.md`, `rules/`, `bash/`, `hooks/`, `ci.yml`, -`settings.json`, and `version`, together with `bin/kit`, the two workflows, and `tests/`. After changing a source -file under `.kit/`, run `just refresh`, which reinstalls the kit into this repository, and commit the result with -the change. `bin/kit check`, which `just ci` runs, names any installed copy that no longer matches its source. The -copies that can carry a comment say they are placed; `settings.json` cannot, so this table is its notice. +After changing a file under `tree/` or `targets/`, run `just refresh`, which reinstalls the kit into this +repository, and commit the result with the change. `bin/kit check`, which `just ci` runs, names any installed copy +that no longer matches its source. Each delivered text file says in its header that it is delivered by kit; +`settings.json` cannot, so this table is its notice. ## Release -`.kit/version` carries the version under development, without a suffix. A release is the tag `v` on a +`version` carries the version under development, without a suffix. A release is the tag `v` on a commit on `main`, not only its head. A pre-release is `v-`, say `v0.1.0-rc.1`, on a commit that is -not on `main`, and it is published as a GitHub pre-release. In both cases the workflow packs `bin/` and `.kit/` into -`kit-.tar.gz` with the tag's full version stamped into the packed `.kit/version`, and publishes the release. +not on `main`, and it is published as a GitHub pre-release. In both cases the workflow packs the release into +`kit-.tar.gz` with the tag's full version stamped into the packed `version`, and publishes the release. It refuses a tag that is not a version, a release tag off `main`, a pre-release tag on `main`, and a tag whose base -version is not `.kit/version`. +version is not `version`. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf diff --git a/.kit/bash/1st.sh b/bash/1st.sh similarity index 91% rename from .kit/bash/1st.sh rename to bash/1st.sh index 40299dc..e7dcd0b 100644 --- a/.kit/bash/1st.sh +++ b/bash/1st.sh @@ -2,9 +2,8 @@ # 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# Sourced by bin/kit, whose issue, tidy, and status run the bash under .kit/bash/, and by the hooks that need a -# verb. It defines functions only and sets no shell option, so the caller's options stand. A module under -# .kit/bash/ is loaded on first use with `use `, and each exported function has a stub here so a +# Sourced by bin/kit, whose issue, tidy, and status run the bash under bash/, and by the hooks that need a verb. It defines functions only and sets no shell option, so the caller's options stand. A module under +# bash/ is loaded on first use with `use `, and each exported function has a stub here so a # caller never has to load it by hand. # # Every message goes to stderr, so stdout stays clean for data. Color is off when stderr is not a terminal or @@ -14,7 +13,7 @@ kit_bash_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" declare -gr kit_bash_dir declare -Ag kit_modules=([1st]=1) -# use … loads .kit/bash/.sh once. An unknown module is a bug in the caller, so it is fatal. +# use … loads bash/.sh once. An unknown module is a bug in the caller, so it is fatal. use() { local module for module in "$@" diff --git a/.kit/bash/gh.sh b/bash/gh.sh similarity index 97% rename from .kit/bash/gh.sh rename to bash/gh.sh index 2367961..e79a1fe 100644 --- a/.kit/bash/gh.sh +++ b/bash/gh.sh @@ -161,6 +161,10 @@ gh:issue() { then warn "run: kit setup (core.hooksPath does not name this release's hooks, so the desk guard is off in this clone)" fi + if ! git -C "${main_root}" check-ignore --quiet worktrees 2>/dev/null + then + warn "worktrees/ is not ignored; add /worktrees/ to .gitignore so a worktree never appears in a status or a commit" + fi # GitHub cannot open a pull request on a branch with nothing to compare, so the first commit is an empty one. if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] diff --git a/.kit/bash/git.sh b/bash/git.sh similarity index 100% rename from .kit/bash/git.sh rename to bash/git.sh diff --git a/bin/kit b/bin/kit index 5864fdb..637a891 100755 --- a/bin/kit +++ b/bin/kit @@ -1,227 +1,203 @@ #!/usr/bin/env bash +# shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose # kit - install, update, and check the kit in a repository (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # -# kit install […] copy this release's text into the repository, place the files each named target -# needs, wire this clone's hooks, and write .kit/manifest -# kit update after the pin is bumped: replace unchanged files, three-way merge changed ones -# kit check fail when a delivered file differs from this release -# kit setup wire this clone: core.hooksPath to this release's hooks -# kit target list print the targets this repository takes -# kit target add … take a target: place its files and record it -# kit target remove … drop a target: remove its files and the kit's entries from its settings -# kit issue […] start or resume an issue: linked branch, worktree, start commit, draft pull request -# kit tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches -# kit status [--no-fetch] show every worktree with its branch, pull request, and state -# kit version print this release's version +# The release is the directory above bin/. Its tree/ is mirrored at the repository root, verbatim, and so is +# targets// for each target the repository takes; a symlink in either is copied as the file it points at. +# bash/, hooks/, and version are the machinery and stay in the release. The one exception to a verbatim copy is a +# destination ending in .json, which is merged by key: the kit's keys are set, the kit's array entries are added, +# and on update or removal only the entries the previous release wrote are taken out, so the rest of the file is +# the repository's. # -# The repository carries the text an agent reads, .kit/1st.md and .kit/rules/, the CI workflow .kit/ci.yml placed -# as .github/workflows/kit-ci.yml, the placed files of each target taken, and .kit/manifest, which records the -# release version, the targets, and a digest per delivered path. The machinery stays in the release where mise -# installed it: this command, the bash behind issue, tidy, and status, and the hooks, which a clone reaches through -# core.hooksPath. -# -# A target is a harness that insists on files placed outside .kit/. The one this release knows is claude: -# .claude/rules/kit-.md, a copy of each rule file, whose own paths: header the harness loads it by, and the deny entries -# merged into .claude/settings.json. A repository takes only the targets it names. A shared settings file is -# merged by key: the kit owns only the entries it writes. -# -# KIT_BASE= names the previous release's root for `kit update` on a machine without mise. +# .kit/manifest records the release version, the targets taken, and the release's digest for every delivered +# path. An update tells an untouched file from an edited one by that digest, and merges the edited one with the +# previous release as the base. KIT_BASE= names that release's root on a machine without mise. # # see: .kit/1st.md set -o errexit -o nounset -o pipefail -kit_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -payload="${kit_root}/.kit" -# shellcheck source=.kit/bash/1st.sh -source "${payload}/bash/1st.sh" +if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )) +then + echo "kit needs bash 4.4 or later; this is ${BASH_VERSION}" >&2 + exit 1 +fi -known_targets=(claude) +kit_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=bash/1st.sh +source "${kit_root}/bash/1st.sh" usage() { - sed -n '5,16p' "${BASH_SOURCE[0]}" | sed 's/^#//; s/^ //' >&2 -} + cat >&2 <<'USAGE' +usage: kit -# digest prints the sha256 of stdin, by whichever tool the machine carries. -digest() { - if command -v sha256sum >/dev/null 2>&1 - then - sha256sum | cut -d ' ' -f 1 - else - shasum -a 256 | cut -d ' ' -f 1 - fi + install […] mirror tree/ and each named target into the repository, wire this clone's hooks, + and write .kit/manifest + update after the pin is bumped: replace unchanged files, three-way merge changed ones + check fail when a delivered file differs from this release + setup wire this clone: core.hooksPath to this release's hooks + target list print the targets this repository takes + target add … take a target: mirror its directory and record it + target remove … drop a target: remove its files and the kit's entries from its settings + issue […] start or resume an issue: linked branch, worktree, start commit, draft pull request + tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches + status [--no-fetch] show every worktree with its branch, pull request, and state + version print this release's version +USAGE } -# known-target is true when this release knows the target. -known-target() { - local t - for t in "${known_targets[@]}" +# ── the release's layout ─────────────────────────────────────────────────────────────────────────────────────────────── + +# layers … prints the directories mirrored at the repository root, tree first. +layers() { + local root=$1 t + shift + printf '%s\n' "${root}/tree" + for t in "$@" do - [[ "$1" == "${t}" ]] && return 0 + printf '%s\n' "${root}/targets/${t}" done - return 1 } -# require-targets … refuses an unknown target, naming the known ones. +# delivered … prints every repository path the release at delivers for those targets. +delivered() { + local layer + while IFS= read -r layer + do + (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@') + done < <(layers "$@") | sort --unique +} + +# source-of … prints the release file behind a repository path, the last layer winning. +source-of() { + local root=$1 path=$2 layer found="" + shift 2 + while IFS= read -r layer + do + [[ -e "${layer}/${path}" ]] && found="${layer}/${path}" + done < <(layers "${root}" "$@") + [[ -n "${found}" ]] && printf '%s\n' "${found}" +} + +# known-targets prints the targets this release carries. +known-targets() { + (cd "${kit_root}/targets" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) +} + +# require-targets … refuses a target this release does not carry, naming the ones it does. require-targets() { local t for t in "$@" do - if ! known-target "${t}" + if [[ ! -d "${kit_root}/targets/${t}" ]] then - error "'${t}' is not a target this release knows; the targets are: ${known_targets[*]}" + error "'${t}' is not a target this release knows; the targets are: $(known-targets | paste -s -d ' ' -)" return 2 fi done } -# manifest-version and manifest-targets read .kit/manifest. -manifest-version() { sed -n 's/^version //p' .kit/manifest; } -manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } - -# target-paths prints the repository paths a target places. -target-paths() { - local root=$1 file - case "$2" in - (claude) - for file in "${root}"/rules/*.md - do - printf '.claude/rules/kit-%s\n' "$(basename "${file}")" - done - esac +# target-only prints the paths the target delivers that tree/ does not. +target-only() { + comm -13 <(delivered "${kit_root}") <(delivered "${kit_root}" "$1") } -# target-settings prints the settings file a target shares with the repository, or nothing. -target-settings() { - case "$1" in - (claude) printf '.claude/settings.json\n' - esac -} +merged() { [[ "$1" == *.json ]]; } -# delivered-paths […] prints every repository path the release delivers for those targets. -delivered-paths() { - local root=$1 file t - shift - printf '.kit/1st.md\n' - for file in "${root}"/rules/*.md - do - printf '.kit/rules/%s\n' "$(basename "${file}")" - done - printf '.github/workflows/kit-ci.yml\n' - for t in "$@" - do - target-paths "${root}" "${t}" - done -} +# ── the one merge rule, for a .json destination ───────────────────────────────────────────────────────────────────────── -# expected prints the content the release at delivers at repository . -expected() { - local root=$1 path=$2 kind - case "${path}" in - (.kit/*) - cat "${root}/${path#.kit/}" - ;; - (.claude/rules/kit-*.md) - # The rule file as it is, its paths: header included, with a one-line notice inside the header. - kind=${path#.claude/rules/kit-} - kind=${kind%.md} - printf -- '---\n# Placed by kit install from .kit/rules/%s.md, not edited by hand.\n' "${kind}" - tail -n +2 "${root}/rules/${kind}.md" - ;; - (.github/workflows/kit-ci.yml) - cat "${root}/ci.yml" - ;; - (*) - error "kit: '${path}' is not a delivered path" - return 2 - esac -} +# The parameters are values ($), not filters: a filter parameter is re-read against the input at each use, which +# inside the recursion is the wrong level of the object. +jq_lib=' + def merge($cur; $base; $new): + if ($new | type) == "object" then + reduce ($new | keys[]) as $k ($cur // {}; .[$k] = merge(($cur // {})[$k]; ($base // {})[$k]; $new[$k])) + elif ($new | type) == "array" then + ((($cur // []) - ($base // [])) as $kept | $kept + ($new - $kept)) + else $new end; + def unmerge($cur; $base): + if ($base | type) == "object" then + reduce ($base | keys[]) as $k ($cur; if has($k) then .[$k] = unmerge($cur[$k]; $base[$k]) else . end) + elif ($base | type) == "array" then ($cur // []) - $base + else $cur end; + def holds($cur; $new): + if ($new | type) == "object" then + all($new | keys[]; . as $k | ($cur | type) == "object" and ($cur | has($k)) and holds($cur[$k]; $new[$k])) + elif ($new | type) == "array" then all($new[]; . as $e | any(($cur // [])[]; . == $e)) + else $cur == $new end; +' -# write-expected writes the delivered content at , making its directory. -write-expected() { - mkdir -p "$(dirname "$2")" - expected "$1" "$2" > "$2.kit-new" - mv "$2.kit-new" "$2" +# json-write rewrites the JSON at through , making the file if it is missing. +json-write() { + local path=$1 filter=$2 + shift 2 + mkdir -p "$(dirname "${path}")" + [[ -f "${path}" ]] || printf '{}\n' > "${path}" + jq --indent 2 "${jq_lib} ${filter}" "$@" "${path}" > "${path}.kit-new" + mv "${path}.kit-new" "${path}" } -# merge-settings [] merges the kit's deny entries into the target's settings -# file: the base release's entries go, the new release's come, and everything else in the file stays. An empty -# takes the kit's entries out and adds none. -merge-settings() { - local file new_deny='[]' base_deny='[]' - file=$(target-settings "$1") - [[ -n "${file}" ]] || return 0 - [[ -n "$2" && -f "$2/settings.json" ]] && new_deny=$(jq '.permissions.deny // []' "$2/settings.json") - [[ -n "${3:-}" && -f "$3/settings.json" ]] && base_deny=$(jq '.permissions.deny // []' "$3/settings.json") - mkdir -p "$(dirname "${file}")" - [[ -f "${file}" ]] || printf '{}\n' > "${file}" - jq --indent 2 --argjson base "${base_deny}" --argjson new "${new_deny}" \ - '.permissions.deny = (((.permissions.deny // []) - $base) as $kept | $kept + ($new - $kept))' \ - "${file}" > "${file}.kit-new" - mv "${file}.kit-new" "${file}" +# ── placing, recording, wiring ────────────────────────────────────────────────────────────────────────────────────────── + +digest() { + if command -v sha256sum >/dev/null 2>&1 + then + sha256sum | cut -d ' ' -f 1 + else + shasum -a 256 | cut -d ' ' -f 1 + fi } -# ensure-ignored makes sure worktrees/ is ignored at the root, the way the loop needs. -ensure-ignored() { - local separator="" - if [[ -f .gitignore ]] && grep --quiet --line-regexp --extended-regexp '/?worktrees/' .gitignore +# place [] puts the release's content at : a merge for .json, a copy otherwise. +place() { + local src=$1 path=$2 base=${3:-/dev/null} + if merged "${path}" then - return 0 + json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" + else + mkdir -p "$(dirname "${path}")" + cat "${src}" > "${path}.kit-new" + mv "${path}.kit-new" "${path}" fi - [[ -f .gitignore && -s .gitignore ]] && separator=$'\n' - printf '%s# the per-issue worktrees of the loop; see: .kit/1st.md\n/worktrees/\n' "${separator}" >> .gitignore } -# write-manifest […] records the release version, the targets taken, and, per delivered path, the -# digest of the release's own content for it, never of the file as written: a later update tells an untouched -# file from an edited one by comparing the file to this, so a merge that kept a repository's edit is not mistaken -# for the release's text. +# write-manifest … records the version, the targets, and the release's digest for every delivered path. write-manifest() { local path { printf '# .kit/manifest - written by kit, not by hand\n' - printf 'version %s\n' "$(cat "${payload}/version")" + printf 'version %s\n' "$(cat "${kit_root}/version")" printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path do - printf '%s %s\n' "$(expected "${payload}" "${path}" | digest)" "${path}" - done < <(delivered-paths "${payload}" "$@") + printf '%s %s\n' "$(digest < "$(source-of "${kit_root}" "${path}" "$@")")" "${path}" + done < <(delivered "${kit_root}" "$@") } > .kit/manifest } -# wire-hooks points this clone's core.hooksPath at the release's hooks. +manifest-version() { sed -n 's/^version //p' .kit/manifest; } +manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } + +require-manifest() { + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi +} + wire-hooks() { - git config core.hooksPath "${payload}/hooks" + git config core.hooksPath "${kit_root}/hooks" } -# finish wires the clone and reports. finish() { wire-hooks - ok "kit $(cat "${payload}/version") is installed, targets: ${*:-none}. core.hooksPath is ${payload}/hooks, and .kit/manifest is written." + ok "kit $(cat "${kit_root}/version") is installed, targets: ${*:-none}. core.hooksPath is ${kit_root}/hooks, and .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } -cmd-setup() { - guard:require tool:git - wire-hooks - ok "core.hooksPath is ${payload}/hooks" - if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] - then - warn "no signing key is configured in this clone, and the ruleset refuses an unsigned commit; see README.md § Setup" - fi -} - -# cmd-loop … runs one of the loop's commands from the release's bash. -cmd-loop() { - local verb=$1 - shift - case "${verb}" in - (issue) gh:issue "$@" ;; - (tidy) git:tidy "$@" ;; - (status) git:status "$@" ;; - esac -} +# ── commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────── cmd-install() { guard:require tool:jq tool:git @@ -231,38 +207,28 @@ cmd-install() { error "run: kit update (this repository already carries kit $(manifest-version))" return 1 fi - section "kit install $(cat "${payload}/version")${*:+, targets: $*}" - local path t + section "kit install $(cat "${kit_root}/version")${*:+, targets: $*}" + local path while IFS= read -r path do step "${path}" - write-expected "${payload}" "${path}" - done < <(delivered-paths "${payload}" "$@") - for t in "$@" - do - merge-settings "${t}" "${payload}" - done - ensure-ignored + place "$(source-of "${kit_root}" "${path}" "$@")" "${path}" + done < <(delivered "${kit_root}" "$@") write-manifest "$@" finish "$@" } cmd-update() { guard:require tool:jq tool:git - if [[ ! -f .kit/manifest ]] - then - error "run: kit install (this repository carries no .kit/manifest)" - return 1 - fi - local previous current base_root="" path recorded actual conflicts=0 t + require-manifest + local previous current base_root="" path src base recorded conflicts=0 local -a targets read -r -a targets <<<"$(manifest-targets)" previous=$(manifest-version) - current=$(cat "${payload}/version") + current=$(cat "${kit_root}/version") section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" - # The previous release is the base of every three-way merge. KIT_BASE names its root directly; otherwise - # mise keeps it where it installed it. + # The previous release is the base of every merge. KIT_BASE names its root; otherwise mise knows where it is. if [[ -n "${KIT_BASE:-}" ]] then base_root=${KIT_BASE} @@ -274,46 +240,45 @@ cmd-update() { base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) fi fi - [[ -n "${base_root}" ]] && base_root="${base_root}/.kit" while IFS= read -r path do + src=$(source-of "${kit_root}" "${path}" "${targets[@]}") + base="" + [[ -n "${base_root}" ]] && base=$(source-of "${base_root}" "${path}" "${targets[@]}" || true) + if merged "${path}" + then + step "${path}: merged by key" + place "${src}" "${path}" "${base}" + continue + fi if [[ ! -f "${path}" ]] then step "${path}: missing, written" - write-expected "${payload}" "${path}" + place "${src}" "${path}" continue fi recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) - actual=$(digest < "${path}") - if [[ -z "${recorded}" || "${recorded}" == "${actual}" ]] + if [[ -z "${recorded}" || "${recorded}" == "$(digest < "${path}")" ]] then step "${path}: unchanged since ${previous}, replaced" - write-expected "${payload}" "${path}" + place "${src}" "${path}" continue fi - if [[ -z "${base_root}" || ! -d "${base_root}" ]] + if [[ -z "${base}" ]] then error "${path} was edited and the base release ${previous} is not installed; run: mise install github:empowerite/kit@${previous}" conflicts=$(( conflicts + 1 )) continue fi step "${path}: edited since ${previous}, three-way merged" - expected "${base_root}" "${path}" > "${path}.kit-base" - expected "${payload}" "${path}" > "${path}.kit-new" - if ! git merge-file --marker-size 7 "${path}" "${path}.kit-base" "${path}.kit-new" + if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" then warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" conflicts=$(( conflicts + 1 )) fi - rm -f "${path}.kit-base" "${path}.kit-new" - done < <(delivered-paths "${payload}" "${targets[@]}") + done < <(delivered "${kit_root}" "${targets[@]}") - for t in "${targets[@]}" - do - merge-settings "${t}" "${payload}" "${base_root}" - done - ensure-ignored write-manifest "${targets[@]}" if (( conflicts > 0 )) then @@ -324,51 +289,40 @@ cmd-update() { } cmd-check() { - local path findings=0 version t file + require-manifest + local path src findings=0 version local -a targets - if [[ ! -f .kit/manifest ]] - then - error "run: kit install (this repository carries no .kit/manifest)" - return 1 - fi read -r -a targets <<<"$(manifest-targets)" version=$(manifest-version) - if [[ "${version}" != "$(cat "${payload}/version")" ]] + if [[ "${version}" != "$(cat "${kit_root}/version")" ]] then - error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${payload}/version"); run: kit update" + error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${kit_root}/version"); run: kit update" findings=$(( findings + 1 )) fi while IFS= read -r path do + src=$(source-of "${kit_root}" "${path}" "${targets[@]}") if [[ ! -f "${path}" ]] then error "${path} is missing" findings=$(( findings + 1 )) - elif ! expected "${payload}" "${path}" | cmp --silent - "${path}" - then - error "${path} differs from the pinned release" - findings=$(( findings + 1 )) - fi - done < <(delivered-paths "${payload}" "${targets[@]}") - for t in "${targets[@]}" - do - file=$(target-settings "${t}") - [[ -n "${file}" ]] || continue - if [[ ! -f "${file}" ]] + elif merged "${path}" then - error "${file} is missing; run: kit update" - findings=$(( findings + 1 )) - elif ! command -v jq >/dev/null 2>&1 + if ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so ${path} could not be read)" + findings=$(( findings + 1 )) + elif ! jq --exit-status "${jq_lib} holds(.; \$new[0])" --slurpfile new "${src}" "${path}" >/dev/null + then + error "${path} is missing an entry the kit writes; run: kit update" + findings=$(( findings + 1 )) + fi + elif ! cmp --silent "${src}" "${path}" then - error "run: mise install (jq is not on PATH, so ${file} could not be read)" - findings=$(( findings + 1 )) - elif (( $(jq --argjson new "$(jq '.permissions.deny // []' "${payload}/settings.json")" \ - '($new - (.permissions.deny // [])) | length' "${file}") > 0 )) - then - error "${file} is missing a deny entry the kit writes; run: kit update" + error "${path} differs from the pinned release" findings=$(( findings + 1 )) fi - done + done < <(delivered "${kit_root}" "${targets[@]}") if (( findings > 0 )) then error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." @@ -377,15 +331,21 @@ cmd-check() { ok "kit ${version}, targets ${targets[*]:-none}: every delivered file matches the pinned release." } +cmd-setup() { + guard:require tool:git + wire-hooks + ok "core.hooksPath is ${kit_root}/hooks" + if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] + then + warn "no signing key is configured in this clone, and the ruleset refuses an unsigned commit; see README.md § Setup" + fi +} + cmd-target() { - local verb=${1:-list} t path + local verb=${1:-list} t path src local -a targets kept shift || true - if [[ ! -f .kit/manifest ]] - then - error "run: kit install (this repository carries no .kit/manifest)" - return 1 - fi + require-manifest read -r -a targets <<<"$(manifest-targets)" case "${verb}" in (list) @@ -406,9 +366,8 @@ cmd-target() { while IFS= read -r path do step "${path}" - write-expected "${payload}" "${path}" - done < <(target-paths "${payload}" "${t}") - merge-settings "${t}" "${payload}" + place "${kit_root}/targets/${t}/${path}" "${path}" + done < <(target-only "${t}") targets+=("${t}") done write-manifest "${targets[@]}" @@ -428,18 +387,25 @@ cmd-target() { section "kit target remove ${t}" while IFS= read -r path do - step "removing ${path}" - rm -f "${path}" - done < <(target-paths "${payload}" "${t}") - [[ -f "$(target-settings "${t}")" ]] && merge-settings "${t}" "" "${payload}" + src="${kit_root}/targets/${t}/${path}" + if merged "${path}" + then + [[ -f "${path}" ]] || continue + step "${path}: the kit's entries taken out" + json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" + else + step "removing ${path}" + rm -f "${path}" + fi + done < <(target-only "${t}") kept=() for path in "${targets[@]}" do [[ "${path}" == "${t}" ]] || kept+=("${path}") done - targets=("${kept[@]+"${kept[@]}"}") + targets=("${kept[@]}") done - write-manifest "${targets[@]+"${targets[@]}"}" + write-manifest "${targets[@]}" ok "targets: ${targets[*]:-none}" ;; (*) @@ -448,6 +414,16 @@ cmd-target() { esac } +cmd-loop() { + local verb=$1 + shift + case "${verb}" in + (issue) gh:issue "$@" ;; + (tidy) git:tidy "$@" ;; + (status) git:status "$@" ;; + esac +} + case "${1:-}" in (install) shift; cmd-install "$@" ;; (update) cmd-update ;; @@ -455,7 +431,7 @@ case "${1:-}" in (setup) cmd-setup ;; (target) shift; cmd-target "$@" ;; (issue | tidy | status) cmd-loop "$@" ;; - (version) cat "${payload}/version" ;; + (version) cat "${kit_root}/version" ;; (-h | --help | help | "") usage; [[ -n "${1:-}" ]] ;; (*) error "kit: unknown command '$1'" diff --git a/.kit/hooks/pre-commit b/hooks/pre-commit similarity index 98% rename from .kit/hooks/pre-commit rename to hooks/pre-commit index f84188a..35c3163 100755 --- a/.kit/hooks/pre-commit +++ b/hooks/pre-commit @@ -25,7 +25,7 @@ markdown_paths=() for path in "${staged[@]}" do case "${path}" in - (*.sh | *.bash | .kit/hooks/*) + (*.sh | *.bash | hooks/*) bash_paths+=("${path}") ;; (.github/workflows/*.yml | .github/workflows/*.yaml) diff --git a/.kit/hooks/pre-rebase b/hooks/pre-rebase similarity index 100% rename from .kit/hooks/pre-rebase rename to hooks/pre-rebase diff --git a/justfile b/justfile index 145d2c4..3dc1496 100644 --- a/justfile +++ b/justfile @@ -56,19 +56,19 @@ status *flags: ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - mise exec -- shellcheck --shell=bash --external-sources bin/kit .kit/bash/*.sh .kit/hooks/* + mise exec -- shellcheck --shell=bash --external-sources bin/kit bash/*.sh hooks/* mise exec -- actionlint mise exec -- bats --print-output-on-failure tests/ bin/kit check echo "✅ ci passed" -# Reinstall this repository's own kit from its source. Here the source under .kit/ is the payload, so an edit to -# a delivered file is an edit to the release, not a consumer's drift: the installed copies, .claude/settings.json +# Reinstall this repository's own kit from its source. Here the checkout is the release, so an edit to a file under +# tree/ or targets/ is an edit to the release, not a consumer's drift: the installed copies, .claude/settings.json # (which carries only the kit's entries here), and .kit/manifest are rewritten from it. Run after changing any # delivered file, and commit the result with the change. # # just refresh -[doc("🔁 Reinstall this repository's own kit from .kit/ and bin/kit")] +[doc("🔁 Reinstall this repository's own kit from tree/, targets/, and bin/kit")] refresh: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail diff --git a/targets/claude/.claude/rules/kit-bash.md b/targets/claude/.claude/rules/kit-bash.md new file mode 120000 index 0000000..bb642e0 --- /dev/null +++ b/targets/claude/.claude/rules/kit-bash.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/bash.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-gitignore.md b/targets/claude/.claude/rules/kit-gitignore.md new file mode 120000 index 0000000..1a1c591 --- /dev/null +++ b/targets/claude/.claude/rules/kit-gitignore.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/gitignore.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-json.md b/targets/claude/.claude/rules/kit-json.md new file mode 120000 index 0000000..d4e607d --- /dev/null +++ b/targets/claude/.claude/rules/kit-json.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/json.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-just.md b/targets/claude/.claude/rules/kit-just.md new file mode 120000 index 0000000..2b2f173 --- /dev/null +++ b/targets/claude/.claude/rules/kit-just.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/just.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-markdown.md b/targets/claude/.claude/rules/kit-markdown.md new file mode 120000 index 0000000..8d735ea --- /dev/null +++ b/targets/claude/.claude/rules/kit-markdown.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/markdown.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-yaml.md b/targets/claude/.claude/rules/kit-yaml.md new file mode 120000 index 0000000..d91b5eb --- /dev/null +++ b/targets/claude/.claude/rules/kit-yaml.md @@ -0,0 +1 @@ +../../../../tree/.kit/rules/yaml.md \ No newline at end of file diff --git a/.kit/settings.json b/targets/claude/.claude/settings.json similarity index 100% rename from .kit/settings.json rename to targets/claude/.claude/settings.json diff --git a/tests/hooks.bats b/tests/hooks.bats index be264ec..7559eae 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -8,7 +8,7 @@ setup() { cd "${repo}" git init --quiet --initial-branch=main git config user.name t && git config user.email t@example.com && git config commit.gpgsign false - git config core.hooksPath "${kit_checkout}/.kit/hooks" + git config core.hooksPath "${kit_checkout}/hooks" printf 'one\n' > a && git add a && git commit --quiet --message one } diff --git a/tests/kit.bats b/tests/kit.bats index 15da482..c1c16b8 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -16,13 +16,12 @@ teardown() { rm -rf "${repo}" } -# later-release copies this checkout's kit to as version 9.9.9 with one changed rule line. +# later-release copies this checkout's release to as version 9.9.9 with one changed rule line. later-release() { mkdir -p "$1" - cp -R "${kit_checkout}/bin" "${kit_checkout}/.kit" "$1/" - rm -f "$1/.kit/manifest" - printf '9.9.9\n' > "$1/.kit/version" - printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/.kit/rules/json.md" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/hooks" "$1/" + printf '9.9.9\n' > "$1/version" + printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/tree/.kit/rules/json.md" } @test "install with the claude target writes the tree, the mirrors, the workflow, the settings, and the manifest" { @@ -32,22 +31,22 @@ later-release() { [ -f .kit/rules/bash.md ] [ ! -e .kit/bash ] [ ! -e .kit/hooks ] - [ ! -e .kit/ci.yml ] [ -f .claude/rules/kit-markdown.md ] + [ ! -L .claude/rules/kit-markdown.md ] + cmp --silent .claude/rules/kit-markdown.md .kit/rules/markdown.md head -n 1 .claude/rules/kit-bash.md | grep --quiet --line-regexp -- '---' grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md [ -f .github/workflows/kit-ci.yml ] jq --exit-status '.permissions.deny | index("Bash(git push --force *)")' .claude/settings.json >/dev/null - grep --quiet --line-regexp '/worktrees/' .gitignore grep --quiet '^version 0.1.0$' .kit/manifest grep --quiet '^targets claude$' .kit/manifest - [ "$(git config core.hooksPath)" = "${kit_checkout}/.kit/hooks" ] + [ "$(git config core.hooksPath)" = "${kit_checkout}/hooks" ] } @test "setup wires a clone's hooks to the release, and issue without a terminal prints usage" { run "${kit_checkout}/bin/kit" setup [ "$status" -eq 0 ] - [ "$(git config core.hooksPath)" = "${kit_checkout}/.kit/hooks" ] + [ "$(git config core.hooksPath)" = "${kit_checkout}/hooks" ] run "${kit_checkout}/bin/kit" issue [ "$status" -eq 2 ] [[ "$output" == *"usage: kit issue"* ]] @@ -125,7 +124,7 @@ later-release() { [ "$status" -eq 1 ] [[ "$output" == *".kit/rules/yaml.md differs"* ]] [[ "$output" == *".claude/rules/kit-json.md is missing"* ]] - [[ "$output" == *"missing a deny entry"* ]] + [[ "$output" == *"missing an entry the kit writes"* ]] [[ "$output" == *"3 finding(s)"* ]] } @@ -139,7 +138,7 @@ later-release() { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" # a consumer edit to a file the later release also changes, on a different line - sed -i.bak '1s/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 0 ] grep --quiet '^version 9.9.9$' .kit/manifest @@ -147,7 +146,7 @@ later-release() { grep --quiet '^## Added in 9.9.9$' .kit/rules/json.md grep --quiet '^## Added in 9.9.9$' .claude/rules/kit-json.md # the manifest carries the release's digest, so the edit still reads as an edit - release_digest=$(shasum -a 256 "${repo}.later/.kit/rules/json.md" | cut -d ' ' -f 1) + release_digest=$(shasum -a 256 "${repo}.later/tree/.kit/rules/json.md" | cut -d ' ' -f 1) grep --quiet "^${release_digest} .kit/rules/json.md$" .kit/manifest # and check names the one file that still differs from the release run "${repo}.later/bin/kit" check @@ -170,9 +169,8 @@ later-release() { @test "update leaves conflict markers and fails when the same lines changed" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" - printf '9.9.9\n' > "${repo}.later/.kit/version" - sed -i.bak '1s/^# JSON$/# JSON (theirs)/' "${repo}.later/.kit/rules/json.md" && rm "${repo}.later/.kit/rules/json.md.bak" - sed -i.bak '1s/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${repo}.later/tree/.kit/rules/json.md" && rm "${repo}.later/tree/.kit/rules/json.md.bak" + sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] grep --quiet '^<<<<<<<' .kit/rules/json.md diff --git a/tests/release.bats b/tests/release.bats index 64935df..5667467 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -12,26 +12,26 @@ teardown() { rm -rf "${work}" } -@test "the tarball holds one directory with bin/ and .kit/, no manifest, and installs a fresh repository" { - version=$(cat "${kit_checkout}/.kit/version") +@test "the tarball holds one directory with the release's layout, and installs a fresh repository" { + version=$(cat "${kit_checkout}/version") mkdir "kit-${version}" - cp -R "${kit_checkout}/bin" "${kit_checkout}/.kit" "kit-${version}/" - rm -f "kit-${version}/.kit/manifest" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/hooks" "${kit_checkout}/version" "kit-${version}/" tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" rm -rf "kit-${version}" mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked [ "$(ls unpacked)" = "kit-${version}" ] [ -x "unpacked/kit-${version}/bin/kit" ] - [ ! -e "unpacked/kit-${version}/.kit/manifest" ] + [ -L "unpacked/kit-${version}/targets/claude/.claude/rules/kit-bash.md" ] mkdir fresh && git -C fresh init --quiet --initial-branch=main cd fresh - run "${work}/unpacked/kit-${version}/bin/kit" install + run "${work}/unpacked/kit-${version}/bin/kit" install claude [ "$status" -eq 0 ] run "${work}/unpacked/kit-${version}/bin/kit" check [ "$status" -eq 0 ] - [ "$(git config core.hooksPath)" = "${work}/unpacked/kit-${version}/.kit/hooks" ] - [ ! -e .kit/bash ] [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] + [ "$(git config core.hooksPath)" = "${work}/unpacked/kit-${version}/hooks" ] + [ ! -L .claude/rules/kit-bash.md ] + [ ! -e .kit/bash ] } # ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/ci.yml b/tree/.github/workflows/kit-ci.yml similarity index 98% rename from .kit/ci.yml rename to tree/.github/workflows/kit-ci.yml index d72042d..c4cb81d 100644 --- a/.kit/ci.yml +++ b/tree/.github/workflows/kit-ci.yml @@ -6,7 +6,7 @@ # not linked to an open issue of this repository under Development. The last two run even when an earlier step # refused, so one run reports every finding. # -# This file is placed by `kit install` and verified by `kit check`. Change it by pinning a new release. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. # # see: .kit/1st.md diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md new file mode 100644 index 0000000..db363e7 --- /dev/null +++ b/tree/.kit/1st.md @@ -0,0 +1,90 @@ + +# Read this first + +This file is the kit's entry. It binds every agent and every session in a repository that carries `.kit/`, and +the repository's own rules in `AGENTS.md` bind beside it. Where the two differ, the repository's rule wins. + +## Integrity + +1. Report only what happened. Write "done", "passing", or "verified" only for what ran and held. +2. A skip is a skip. A check not run, not implemented, or not implementable is reported as exactly that. It + clears no gate and earns no credit. +3. A claim carries its evidence, or says it has none. Name the command that ran and what it printed. +4. Being wrong is recoverable. Misreporting is not. +5. A rule lives in the repository, never in a session's memory. A convention learned or a correction given is + written into a rule file or `AGENTS.md` through the loop, where every agent and every person reads it. + +## `main` is the human's desk + +The checkout at the repository root stays on the default branch. It is where the human types, reviews, and +leaves work in progress. Never check out, reset, rebase, or otherwise change it in place. Work happens in a +worktree under `worktrees/`, one per issue, and reaches `main` only through a merged pull request. + +## The loop + +1. An issue exists. A change without an issue has not started. +2. `kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a draft + pull request. It never switches the checkout it is run from. +3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. +4. `gh pr ready` says the work is done. The pull request body is the report: what changed, what was checked + and how, and what was left out and why. +5. A human merges. Nothing reaches `main` any other way, and nothing is called merged until the merge is + observed. + +## Plans are prose + +A design, a spec, or a plan is the body of an issue, written in prose. It is never a committed file. When +the design is written, the issue is the deliverable, and the loop above starts from it. + +## The worker contract + +A worker is whoever implements one issue in the worktree that issue names. + +1. `cd` into the worktree named in the task and confirm `git rev-parse --abbrev-ref HEAD` prints `-`. +2. Do every command from there. Never touch the repository root checkout. +3. Never run `kit issue`, make a branch, or make a worktree. Those were done before the handoff. +4. Implement what the issue asks and nothing beside it. +5. Push as you go. +6. Run the repository's gate before calling anything done, and run it through the pinned toolchain. +7. End with `gh pr ready` and the report in the pull request body. +8. If the gate fails or the work is partial, leave the pull request a draft and say why. +9. Never merge. +10. Report what ran and what it printed, nothing more. + +## Rules by file type + +Each file kind has a rule file under `.kit/rules/`, and each rule file names the paths it binds in its `paths:` +header. A harness that loads rules by path finds a copy under its own rules directory when the repository takes +that target, `.claude/rules/kit-.md` for Claude Code. Read the rule for a kind before writing a file of +that kind. + +| Kind | Paths | Rule | +| --- | --- | --- | +| Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | +| Bash | `*.sh`, `*.bash`, `hooks/*` | [rules/bash.md](rules/bash.md) | +| Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | +| YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | +| JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | +| Ignore lists | `.gitignore` | [rules/gitignore.md](rules/gitignore.md) | + +## What the kit carries + +The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the +release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, +carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the +repository root, and `targets//` for each target taken; a `.json` destination is merged by key, everything +else is copied as it is. + +- `kit issue`, `kit tidy`, and `kit status`, the loop's commands. A root justfile that wants `just issue 42` adds + a veneer recipe over `kit issue`. +- Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` + points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. +- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets + it names: `kit install claude`, or `kit target add claude` later. The `claude` target places + `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the + entries it writes; the repository may add its own. `kit target remove` takes a target back out. +- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, + runs `kit check`, and refuses a pull request not linked to an open issue. + +`kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a +new release and running `kit update`, never by editing them in place. diff --git a/tree/.kit/rules/bash.md b/tree/.kit/rules/bash.md new file mode 100644 index 0000000..86d45e8 --- /dev/null +++ b/tree/.kit/rules/bash.md @@ -0,0 +1,50 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.sh" + - "**/*.bash" + - "**/hooks/*" +--- +# Bash + +Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +workflows. + +## Shape + +1. An executed script opens with `#!/usr/bin/env bash`, a `# - ` banner, a rule of dashes to + column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they + would change the caller's. +3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. +4. The file closes with the same rule of dashes. + +## Style + +1. `then`, `do`, and `else` go on their own line, never after a semicolon. +2. Spell `set` options out: `set -o errexit`, never `set -e`. +3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. + Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. +5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. +6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. +7. Quote every expansion outside `[[ ]]` and `(( ))`. +8. A function in a sourced module is named `:`, the module being the file's basename. +9. A function private to one executed script is bare kebab-case. + +## Behavior + +1. An error message is one line, names the remedy first, and is the same wherever the same condition arises. +2. A helper that refuses returns non-zero and lets the caller end the script. It does not `exit` itself. +3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. +4. Diagnostics go to stderr. Stdout carries data a caller can pipe. +5. A command that mutates a remote, a branch, or a worktree is guarded by a check that names what it is about + to do, and a `--dry-run` where the mutation is a sweep. +6. The default branch is read from `origin/HEAD`, with `main` as the fallback, never hard-coded. +7. shellcheck passes with no directive, or the directive carries the reason on the same line. + +## Comments + +1. A comment states what the code does and the non-obvious why. It carries no history and defends no obvious + choice. +2. A pointer is `# see: `, on its own line after the comment it supports. diff --git a/tree/.kit/rules/gitignore.md b/tree/.kit/rules/gitignore.md new file mode 100644 index 0000000..7465b65 --- /dev/null +++ b/tree/.kit/rules/gitignore.md @@ -0,0 +1,30 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - ".gitignore" + - "**/.gitignore" +--- +# Ignore lists + +Applies to `.gitignore` at any depth. + +## Shape + +1. The file opens with `# .gitignore - untracked files and folders`, a rule of dashes to column 120, and a + blank line. +2. Entries are grouped. Each group opens with `# ; see: ` and is followed by a blank + line. +3. The file closes with a rule of dashes. + +## Entries + +1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the + user's global ignore list, not here. +2. An entry lives in the deepest directory where it applies. The root carries only patterns that span the + repository. +3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. +4. `worktrees/` is ignored at the root: it is where the loop checks out a branch per issue. + +## Adding one + +Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/tree/.kit/rules/json.md b/tree/.kit/rules/json.md new file mode 100644 index 0000000..692c8aa --- /dev/null +++ b/tree/.kit/rules/json.md @@ -0,0 +1,27 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.json" + - "**/*.jsonc" +--- +# JSON + +Applies to `*.json` and `*.jsonc`. + +## Shape + +1. Two-space indentation. Keys in a stable order: the order the consuming tool documents, else alphabetical. +2. One trailing newline. No trailing commas in `.json`. +3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where + it reads plain JSON, the reason for a setting lives in the document that describes the file. + +## Settings + +1. A disabled linter rule carries a comment naming the rule and the convention it would fight. +2. A tool's settings file that the kit writes into is merged by key. The kit owns only the keys and entries + it writes, and anything else in the file is the repository's. + +## Comments + +1. A comment states what the entry does and the non-obvious why. It carries no history. +2. A pointer is `// see: `. diff --git a/tree/.kit/rules/just.md b/tree/.kit/rules/just.md new file mode 100644 index 0000000..0698212 --- /dev/null +++ b/tree/.kit/rules/just.md @@ -0,0 +1,56 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "justfile" + - "**/justfile" + - "**/*.just" +--- +# Just + +Applies to `justfile` and `*.just`. A recipe body under `#!/usr/bin/env bash` is bash, and the bash rule +applies to it as well. + +## Shape + +1. A module file opens with `# - see: https://just.systems/man/en/`, a rule of dashes to column 120, and + a description of what the file is and who imports it. +2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the + root, which has no parent), `set positional-arguments := true`, and `set export := true`. +3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: + + ```just + # This file's own path, assigned at parse time so the body below needs no interpolation. + export _justfile := source_file() + + # Default recipe: list this justfile's recipes. + _: help + + # List recipes in this justfile. + [no-exit-message] + @help *options: + just --justfile "${_justfile}" --list + ``` + +4. A module the root takes with `mod ""` carries `[no-cd]` on every recipe, so its body runs from + the invoking root rather than the module's directory. +5. The file closes with a rule of dashes. + +## Recipes + +1. Every recipe carries `[doc(" ")]`. Related recipes share an emoji. +2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe + does, when to run it, and what it produces. +3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. +4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. +5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported + variable, `export := `, and `set export := true` exports every parameter, so a body reads + `${name}` and nothing else. +6. Two-space indentation inside a body. Never a tab. +7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. +8. Comments wrap at 118 columns. Bodies do not wrap. + +## Invocation + +1. The idiom is the space form, `just tools check`, never `just tools/check` and never `tools::check` outside a + dependency list. +2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/tree/.kit/rules/markdown.md b/tree/.kit/rules/markdown.md new file mode 100644 index 0000000..d830ce3 --- /dev/null +++ b/tree/.kit/rules/markdown.md @@ -0,0 +1,53 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.md" +--- +# Markdown + +Applies to `*.md`. + +## Prose + +1. Write plain declarative sentences with the thing under discussion as the subject. +2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". +3. Use a list where the content is a list, and for a procedure or a loop, so it scans. Use a table where the + content has columns. +4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. +5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. +6. Use the serial comma in every list of three or more. +7. Expand an uncommon acronym the first time it appears. +8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline + when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. + +## Snippets + +1. A value the reader supplies is a shell variable the prose names, `"${signing_key}"`, never an angle-bracket + placeholder, so the block pastes after one export. +2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. + +## Layout + +1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns + per nesting level, to a limit of 12. +2. Balance the wrap: no line, in particular the last, reads as a stub. +3. Do not wrap fenced code, tables, HTML comments, or a line whose only content is a link. +4. Keep a list tight or loose as it was written. Reflow never flips one to the other. +5. Headings are ATX (`#`). One `#` heading per file, at the top. +6. Fenced code blocks declare a language and have a blank line on each side. +7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. + +## Links + +1. Use inline links with descriptive text, never a bare URL in angle brackets. +2. Use the same anchor text every time the same URL appears in a file. +3. When a label and the link text would say the same thing, make the label the link. +4. A relative link resolves from the file that carries it, and it resolves: a check reads them. + +## Files + +1. Name an index file `1st.md`. It lists its siblings and says what each is for. +2. A file says what its subject is in its first paragraph, before how to use it. +3. A README has no Status section and no paragraph on what the repository will do or does not yet do. It says + what the repository is. +4. No file names another repository as the source of a rule. diff --git a/tree/.kit/rules/yaml.md b/tree/.kit/rules/yaml.md new file mode 100644 index 0000000..3e5795c --- /dev/null +++ b/tree/.kit/rules/yaml.md @@ -0,0 +1,35 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.yml" + - "**/*.yaml" +--- +# YAML + +Applies to `*.yml` and `*.yaml`, which here means GitHub Actions workflows. + +## Shape + +1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a + reader would not guess from the keys. +2. Two-space indentation. No tabs. No trailing whitespace. +3. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. +4. One job per scope, one step per policy. A job's key is the check's name a ruleset requires, so renaming it + breaks the requirement. +5. `permissions` is declared at the top and names the least the jobs need. + +## Steps + +1. Every `uses:` pins a full commit SHA with the version as a trailing comment: `@ # v1.2.3`. +2. A step has a `name` that is the policy it enforces, in kebab-case. +3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. +4. A refusal prints `::error::`, writes `### ❌ ` and a remedy to `${GITHUB_STEP_SUMMARY}`, + and returns non-zero. The remedy is the action to take, with a link that is that action. +5. A pass writes one `✅` line to the summary. +6. A later step that must run after an earlier refusal carries `if: ${{ !cancelled() }}`. +7. A tool is on `PATH` because `mise.toml` pins it and `jdx/mise-action` installs it, never by `curl` in a step. + +## Comments + +1. A comment states the non-obvious why. It carries no history. +2. A pointer is `# see: `. diff --git a/.kit/version b/version similarity index 100% rename from .kit/version rename to version From 36044f83694804947bd0702d8a29aa83f0b0e405 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Fri, 23 Oct 2026 23:57:30 -0700 Subject: [PATCH 021/108] #1 justfile: no veneers; the loop's commands are kit issue, tidy, and status Co-Authored-By: Claude Fable 5.1 --- .kit/1st.md | 3 +-- .kit/manifest | 2 +- README.md | 5 ++--- justfile | 31 ++----------------------------- tree/.kit/1st.md | 3 +-- 5 files changed, 7 insertions(+), 37 deletions(-) diff --git a/.kit/1st.md b/.kit/1st.md index db363e7..e1ad167 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -75,8 +75,7 @@ carries the machinery, reached through the `kit` command on `PATH`. A release mi repository root, and `targets//` for each target taken; a `.json` destination is merged by key, everything else is copied as it is. -- `kit issue`, `kit tidy`, and `kit status`, the loop's commands. A root justfile that wants `just issue 42` adds - a veneer recipe over `kit issue`. +- `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. - Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets diff --git a/.kit/manifest b/.kit/manifest index e395d5b..ae36234 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -9,7 +9,7 @@ targets claude 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .claude/settings.json b9a38f19b571c70bb4ef7d3f25fd2b56ac0f6fc076cbcc8e318d22ad3ffe594f .github/workflows/kit-ci.yml -c65a92daf5acfe3ed1bd93e263594f6932321d2c4d8cec71521046a8dd137aa6 .kit/1st.md +1ff42ec67c8ccbb02fcca51db20d0d6661205701689252d33909fcffa1597562 .kit/1st.md 04543fa071df19f28e15f62b678881660391b1c620b001af7932822c7183f774 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md diff --git a/README.md b/README.md index 25d85ee..be62678 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ change the set later, and `kit target list` prints it. Commit what it wrote. The - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. -- For `just issue 42`, add a veneer recipe over `kit issue` to the root justfile; `kit issue 42` works as it is. - In every other clone, run `kit setup` once to wire the hooks. The machinery, the `kit` command, the bash behind `kit issue`, `kit tidy`, and `kit status`, and the hooks, stays @@ -66,13 +65,13 @@ This repository carries the installed copy of its own kit, and `just ci` checks Work here moves by the loop in [.kit/1st.md](.kit/1st.md): - An issue exists. A change without an issue has not started. -- `just issue `, a veneer over `kit issue`, makes `worktrees/-`, links the branch to the issue under +- `kit issue `, here `bin/kit issue `, makes `worktrees/-`, links the branch to the issue under Development, and opens a draft pull request. It never switches the checkout it runs from. - Work happens in that worktree. Commits are signed, and their subject is `# : `. - `just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and `bin/kit check`. - `gh pr ready` says the work is done, and the pull request body is the report. -- A human merges. `just tidy` then removes the worktree and its branch. +- A human merges. `kit tidy` then removes the worktree and its branch. ## Maintaining diff --git a/justfile b/justfile index 3dc1496..8beb849 100644 --- a/justfile +++ b/justfile @@ -1,8 +1,8 @@ #!/usr/bin/env just --justfile # justfile - see: https://just.systems/man/en/ # ---------------------------------------------------------------------------------------------------------------------- -# The root justfile: the loop's three commands as veneers over bin/kit, the way a consumer adds them over the kit -# on its PATH, the gate, and the refresh of this repository's own installed copy. +# The root justfile: the gate, and the refresh of this repository's own installed copy. The loop's commands are +# bin/kit issue, tidy, and status, spelled that way and no other. set dotenv-load := true set positional-arguments := true @@ -20,33 +20,6 @@ _: help set export := true -# The loop's three commands, each a veneer over bin/kit. A consumer that wants the same spelling writes the same -# three recipes over the `kit` on its PATH. -# -# just issue 42 # start #42 -# just issue 42 43 # start #42, and close #43 with it -[doc("🌿 Start or resume an issue: linked branch, worktree, start commit, draft pull request")] -issue *numbers: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - bin/kit issue "$@" - -# just tidy # sweep -# just tidy --dry-run # say what a sweep would do -[doc("🧹 Remove merged worktrees and gone branches")] -tidy *flags: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - bin/kit tidy "$@" - -# just status # fetch --prune, then render -# just status --no-fetch # render from the current remote-tracking refs -[doc("📊 Show every worktree with its branch, pull request, and state")] -status *flags: - #!/usr/bin/env bash - set -o errexit -o nounset -o pipefail - bin/kit status "$@" - # The gate a pull request passes: shellcheck over every bash file, actionlint over every workflow, the bats # suite over bin/kit, and bin/kit check over this repository's installed copy of its own kit. Every tool comes # from mise.toml. diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index db363e7..e1ad167 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -75,8 +75,7 @@ carries the machinery, reached through the `kit` command on `PATH`. A release mi repository root, and `targets//` for each target taken; a `.json` destination is merged by key, everything else is copied as it is. -- `kit issue`, `kit tidy`, and `kit status`, the loop's commands. A root justfile that wants `just issue 42` adds - a veneer recipe over `kit issue`. +- `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. - Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets From 6d832bed299ba1abf28a30940adcfab88dffc2b9 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:22:51 -0700 Subject: [PATCH 022/108] #1 bin/kit: a dispatcher; one bin/kit-.bash per verb; kit lint and kit rebase replace the hooks bin/kit resolves the release and execs bin/kit-.bash. The hooks are gone: kit lint is the body of a pre-commit hook, run by Lefthook through the delivered .kit/lefthook.yml, which a repository takes with extends; kit rebase refuses the default branch and otherwise rebases a worktree's branch onto origin/main and pushes it, and 1st.md names it as the spelling an agent uses, with git rebase added to the deny list. The human's desk is no longer bound by a hook. bash/kit.sh holds the layout and the merge rule the four install verbs share. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-bash.md | 3 +- .claude/settings.json | 1 + .github/workflows/release.yml | 6 +- .kit/1st.md | 22 +- .kit/lefthook.yml | 11 + .kit/manifest | 9 +- .kit/rules/bash.md | 3 +- AGENTS.md | 2 +- README.md | 36 ++- bash/1st.sh | 2 +- bash/gh.sh | 5 - bash/kit.sh | 161 ++++++++++ bin/kit | 452 ++------------------------- bin/kit-check.bash | 58 ++++ bin/kit-install.bash | 34 ++ bin/kit-issue.bash | 20 ++ bin/kit-lint.bash | 68 ++++ bin/kit-rebase.bash | 47 +++ bin/kit-status.bash | 20 ++ bin/kit-target.bash | 87 ++++++ bin/kit-tidy.bash | 20 ++ bin/kit-update.bash | 89 ++++++ bin/kit-version.bash | 8 + hooks/pre-commit | 85 ----- hooks/pre-rebase | 46 --- justfile | 4 +- lefthook.yml | 8 + mise.lock | 5 + mise.toml | 5 + targets/claude/.claude/settings.json | 1 + tests/hooks.bats | 67 ---- tests/kit.bats | 16 +- tests/lint.bats | 47 +++ tests/rebase.bats | 54 ++++ tests/release.bats | 4 +- tree/.kit/1st.md | 22 +- tree/.kit/lefthook.yml | 11 + tree/.kit/rules/bash.md | 3 +- 38 files changed, 862 insertions(+), 680 deletions(-) create mode 100644 .kit/lefthook.yml create mode 100644 bash/kit.sh create mode 100755 bin/kit-check.bash create mode 100755 bin/kit-install.bash create mode 100755 bin/kit-issue.bash create mode 100755 bin/kit-lint.bash create mode 100755 bin/kit-rebase.bash create mode 100755 bin/kit-status.bash create mode 100755 bin/kit-target.bash create mode 100755 bin/kit-tidy.bash create mode 100755 bin/kit-update.bash create mode 100755 bin/kit-version.bash delete mode 100755 hooks/pre-commit delete mode 100755 hooks/pre-rebase create mode 100644 lefthook.yml delete mode 100644 tests/hooks.bats create mode 100644 tests/lint.bats create mode 100644 tests/rebase.bats create mode 100644 tree/.kit/lefthook.yml diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md index 86d45e8..92d176e 100644 --- a/.claude/rules/kit-bash.md +++ b/.claude/rules/kit-bash.md @@ -3,11 +3,10 @@ paths: - "**/*.sh" - "**/*.bash" - - "**/hooks/*" --- # Bash -Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +Applies to `*.sh` and `*.bash`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in workflows. ## Shape diff --git a/.claude/settings.json b/.claude/settings.json index 7911d58..fde98cf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,6 +3,7 @@ "deny": [ "Bash(git checkout main *)", "Bash(git switch main *)", + "Bash(git rebase *)", "Bash(git reset --hard *)", "Bash(git push --force *)", "Bash(git push -f *)", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 189a2cb..707dd83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ # release.yml - a tag v on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, bash/, -# hooks/, and version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., +# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, bash/, and +# version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., # is tagged on main (any commit on main, not only its head). A pre-release, the same with a -, is tagged on # a commit that is not on main, and is published as a GitHub pre-release. Either way the file version at the tagged # commit is the version without the suffix, the one under development, and the packed kit carries the tag's full @@ -78,7 +78,7 @@ jobs: version=${GITHUB_REF_NAME#v} staging=$(mktemp --directory) mkdir "${staging}/kit-${version}" - cp --recursive --no-dereference bin tree targets bash hooks version "${staging}/kit-${version}/" + cp --recursive --no-dereference bin tree targets bash version "${staging}/kit-${version}/" # the packed kit carries the tag's full version, suffix included; the repository file carries the base printf '%s\n' "${version}" > "${staging}/kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" diff --git a/.kit/1st.md b/.kit/1st.md index e1ad167..24e573c 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -25,12 +25,25 @@ worktree under `worktrees/`, one per issue, and reaches `main` only through a me 1. An issue exists. A change without an issue has not started. 2. `kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a draft pull request. It never switches the checkout it is run from. -3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. +3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. `kit rebase` + brings the branch up to date with `main` and pushes it. 4. `gh pr ready` says the work is done. The pull request body is the report: what changed, what was checked and how, and what was left out and why. 5. A human merges. Nothing reaches `main` any other way, and nothing is called merged until the merge is observed. +## Spellings an agent uses + +Where the kit adds a rule to a git verb, the kit's spelling is the only one an agent uses. The rule runs inside +it, and a harness that can refuse the raw spelling does. + +- `kit rebase`, never `git rebase`. It refuses the default branch and otherwise rebases the worktree's branch onto + `origin/main` and pushes it. +- `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. +- `kit tidy`, never `git worktree remove` or `git branch -D`. + +The human at the desk is not bound by this; `main` is theirs to rebase. + ## Plans are prose A design, a spec, or a plan is the body of an issue, written in prose. It is never a committed file. When @@ -61,7 +74,7 @@ that kind. | Kind | Paths | Rule | | --- | --- | --- | | Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | -| Bash | `*.sh`, `*.bash`, `hooks/*` | [rules/bash.md](rules/bash.md) | +| Bash | `*.sh`, `*.bash` | [rules/bash.md](rules/bash.md) | | Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | | YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | | JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | @@ -76,8 +89,9 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` - points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. +- `kit lint`, the body of a pre-commit hook: shellcheck, actionlint, and markdownlint-cli2 when the repository + configures it, over the staged paths, with a missing linter reported as a skip. `.kit/lefthook.yml` runs it; + a repository's `lefthook.yml` takes that with `extends` and adds hooks of its own. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml new file mode 100644 index 0000000..882d7cf --- /dev/null +++ b/.kit/lefthook.yml @@ -0,0 +1,11 @@ +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# +# The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` and adds hooks and commands of its +# own; Lefthook merges them under the same hook names. Once per clone: lefthook install +# +# see: https://lefthook.dev + +pre-commit: + commands: + kit-lint: + run: kit lint {staged_files} diff --git a/.kit/manifest b/.kit/manifest index ae36234..be6b454 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,16 +1,17 @@ # .kit/manifest - written by kit, not by hand version 0.1.0 targets claude -04543fa071df19f28e15f62b678881660391b1c620b001af7932822c7183f774 .claude/rules/kit-bash.md +389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .claude/rules/kit-bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .claude/rules/kit-just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md -617c2e4ca3a72ae59ee898db2120519624a1ad58df5136d0d6f88df96ffdc13a .claude/settings.json +100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json b9a38f19b571c70bb4ef7d3f25fd2b56ac0f6fc076cbcc8e318d22ad3ffe594f .github/workflows/kit-ci.yml -1ff42ec67c8ccbb02fcca51db20d0d6661205701689252d33909fcffa1597562 .kit/1st.md -04543fa071df19f28e15f62b678881660391b1c620b001af7932822c7183f774 .kit/rules/bash.md +a39770f43e77f4e8ab7e97b5f56c5993e84f3e6778cff1e1e52296ce72b978c3 .kit/1st.md +e1282bfb261b23acad7d368ca8c2a3e47e728708eee16cb85e54a070f8b96381 .kit/lefthook.yml +389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .kit/rules/just.md diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md index 86d45e8..92d176e 100644 --- a/.kit/rules/bash.md +++ b/.kit/rules/bash.md @@ -3,11 +3,10 @@ paths: - "**/*.sh" - "**/*.bash" - - "**/hooks/*" --- # Bash -Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +Applies to `*.sh` and `*.bash`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in workflows. ## Shape diff --git a/AGENTS.md b/AGENTS.md index e3b1109..0f814a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its ## This repository's own rules -- The release is `bin/`, `tree/`, `targets/`, `bash/`, `hooks/`, and `version`. Everything in it is written for a +- The release is `bin/`, `tree/`, `targets/`, `bash/`, and `version`. Everything in it is written for a consumer: no file names a consumer, a person, or another repository as the source of a rule. - `version` is the version under development, with no suffix. A release is the tag `v` on a commit on `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any diff --git a/README.md b/README.md index be62678..7dee207 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,23 @@ mise install kit install claude ``` -`kit install` copies `.kit/1st.md` and `.kit/rules/` into the repository, places `.github/workflows/kit-ci.yml`, -ignores `worktrees/`, points this clone's `core.hooksPath` at the release's hooks, and writes `.kit/manifest`. Each -target named adds what that harness insists on having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and -merges the kit's deny rules into `.claude/settings.json`. A repository with no target installed still binds every -agent that reads `AGENTS.md`, through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` -change the set later, and `kit target list` prints it. Commit what it wrote. Then: +`kit install` copies `.kit/1st.md`, `.kit/rules/`, and `.kit/lefthook.yml` into the repository, places +`.github/workflows/kit-ci.yml`, and writes `.kit/manifest`. Each target named adds what that harness insists on +having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and merges the kit's deny rules into +`.claude/settings.json`. A repository with no target installed still binds every agent that reads `AGENTS.md`, +through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` change the set later, and +`kit target list` prints it. Commit what it wrote. Then: - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. -- In every other clone, run `kit setup` once to wire the hooks. +- Pin `lefthook` in `mise.toml`, put `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` + once per clone. That runs `kit lint` over the staged paths before each commit, beside any hooks of your own. A + repository on another hook manager runs `kit lint` from it instead. -The machinery, the `kit` command, the bash behind `kit issue`, `kit tidy`, and `kit status`, and the hooks, stays -in the release where mise installed it; the repository carries the text an agent reads and the record of what it -took. The kit needs bash 4.4 or later, git, `gh`, and `jq`. +The machinery, the `kit` command and the bash behind its verbs, stays in the release where mise installed it; the +repository carries the text an agent reads and the record of what it took. The kit needs bash 4.4 or later, git, +`gh`, and `jq`. To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries @@ -55,7 +57,7 @@ git config gpg.format openpgp git config user.signingkey "${signing_key}" git config commit.gpgsign true mise install -bin/kit setup +lefthook install ``` This repository carries the installed copy of its own kit, and `just ci` checks it. @@ -65,9 +67,10 @@ This repository carries the installed copy of its own kit, and `just ci` checks Work here moves by the loop in [.kit/1st.md](.kit/1st.md): - An issue exists. A change without an issue has not started. -- `kit issue `, here `bin/kit issue `, makes `worktrees/-`, links the branch to the issue under - Development, and opens a draft pull request. It never switches the checkout it runs from. -- Work happens in that worktree. Commits are signed, and their subject is `# : `. +- `kit issue ` makes `worktrees/-`, links the branch to the issue under Development, and opens a + draft pull request. It never switches the checkout it runs from. +- Work happens in that worktree. Commits are signed, and their subject is `# : `. `kit rebase` + brings the branch up to date with `main` and pushes it. - `just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and `bin/kit check`. - `gh pr ready` says the work is done, and the pull request body is the report. @@ -77,14 +80,15 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what every consumer gets, mirrored at its root; `targets//` is what a consumer taking that target gets, mirrored -the same way; `bash/`, `hooks/`, and `version` are the machinery `bin/kit` runs. A symlink under `targets/` is +the same way; `bin/`, `bash/`, and `version` are the machinery: `bin/kit` dispatches to one `bin/kit-.bash` +per verb, and `bash/` holds the modules they source. A symlink under `targets/` is delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. The installed copies are written by `bin/kit install` and not edited by hand: | Installed copy | Written from | | --- | --- | -| `.kit/1st.md`, `.kit/rules/` | `tree/.kit/` | +| `.kit/1st.md`, `.kit/rules/`, `.kit/lefthook.yml` | `tree/.kit/` | | `.github/workflows/kit-ci.yml` | `tree/.github/workflows/kit-ci.yml` | | `.claude/rules/kit-.md` | `targets/claude/.claude/rules/`, links into `tree/.kit/rules/` | | `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here it carries only the kit's entries | diff --git a/bash/1st.sh b/bash/1st.sh index e7dcd0b..2632223 100644 --- a/bash/1st.sh +++ b/bash/1st.sh @@ -2,7 +2,7 @@ # 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# Sourced by bin/kit, whose issue, tidy, and status run the bash under bash/, and by the hooks that need a verb. It defines functions only and sets no shell option, so the caller's options stand. A module under +# Sourced by every verb, bin/kit-.bash, which bin/kit dispatches to with kit_root exported. It defines functions only and sets no shell option, so the caller's options stand. A module under # bash/ is loaded on first use with `use `, and each exported function has a stub here so a # caller never has to load it by hand. # diff --git a/bash/gh.sh b/bash/gh.sh index e79a1fe..a4ef4b3 100644 --- a/bash/gh.sh +++ b/bash/gh.sh @@ -156,11 +156,6 @@ gh:issue() { fi fi - # shellcheck disable=SC2154 # kit_bash_dir is set by the loader, 1st.sh, which sources this file - if [[ "$(git config core.hooksPath 2>/dev/null)" != "$(cd "${kit_bash_dir}/.." && pwd)/hooks" ]] - then - warn "run: kit setup (core.hooksPath does not name this release's hooks, so the desk guard is off in this clone)" - fi if ! git -C "${main_root}" check-ignore --quiet worktrees 2>/dev/null then warn "worktrees/ is not ignored; add /worktrees/ to .gitignore so a worktree never appears in a status or a commit" diff --git a/bash/kit.sh b/bash/kit.sh new file mode 100644 index 0000000..6dfc7ae --- /dev/null +++ b/bash/kit.sh @@ -0,0 +1,161 @@ +# shellcheck shell=bash +# shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose +# shellcheck disable=SC2154 # kit_root is exported by bin/kit +# kit.sh - the release's layout, the one merge rule, and the manifest (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# A release mirrors its tree/ at the repository root, and targets// for each target taken; a symlink in +# either is delivered as the file it points at. A destination ending in .json is merged by key, with the kit's +# keys set, the kit's array entries added, and on update or removal only the previous release's entries taken +# out. .kit/manifest records the version, the targets, and the release's digest for every delivered path. +# +# Every function reads the release from kit_root, exported by bin/kit. + +# kit:layers … prints the directories mirrored at the repository root, tree first. +kit:layers() { + local root=$1 t + shift + printf '%s\n' "${root}/tree" + for t in "$@" + do + printf '%s\n' "${root}/targets/${t}" + done +} + +# kit:delivered … prints every repository path the release at delivers for those targets. +kit:delivered() { + local layer + while IFS= read -r layer + do + (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@') + done < <(kit:layers "$@") | sort --unique +} + +# kit:source-of … prints the release file behind a repository path, the last layer winning. +kit:source-of() { + local root=$1 path=$2 layer found="" + shift 2 + while IFS= read -r layer + do + [[ -e "${layer}/${path}" ]] && found="${layer}/${path}" + done < <(kit:layers "${root}" "$@") + [[ -n "${found}" ]] && printf '%s\n' "${found}" +} + +# kit:target-only prints the paths the target delivers that tree/ does not. +kit:target-only() { + comm -13 <(kit:delivered "${kit_root}") <(kit:delivered "${kit_root}" "$1") +} + +# kit:known-targets prints the targets this release carries. +kit:known-targets() { + (cd "${kit_root}/targets" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) +} + +# kit:require-targets … refuses a target this release does not carry, naming the ones it does. +kit:require-targets() { + local t + for t in "$@" + do + if [[ ! -d "${kit_root}/targets/${t}" ]] + then + error "'${t}' is not a target this release knows; the targets are: $(kit:known-targets | paste -s -d ' ' -)" + return 2 + fi + done +} + +kit:merged() { [[ "$1" == *.json ]]; } + +# The parameters are values ($), not filters: a filter parameter is re-read against the input at each use, which +# inside the recursion is the wrong level of the object. +kit_jq=' + def merge($cur; $base; $new): + if ($new | type) == "object" then + reduce ($new | keys[]) as $k ($cur // {}; .[$k] = merge(($cur // {})[$k]; ($base // {})[$k]; $new[$k])) + elif ($new | type) == "array" then + ((($cur // []) - ($base // [])) as $kept | $kept + ($new - $kept)) + else $new end; + def unmerge($cur; $base): + if ($base | type) == "object" then + reduce ($base | keys[]) as $k ($cur; if has($k) then .[$k] = unmerge($cur[$k]; $base[$k]) else . end) + elif ($base | type) == "array" then ($cur // []) - $base + else $cur end; + def holds($cur; $new): + if ($new | type) == "object" then + all($new | keys[]; . as $k | ($cur | type) == "object" and ($cur | has($k)) and holds($cur[$k]; $new[$k])) + elif ($new | type) == "array" then all($new[]; . as $e | any(($cur // [])[]; . == $e)) + else $cur == $new end; +' + +# kit:json-write […] rewrites the JSON at through , making it if missing. +kit:json-write() { + local path=$1 filter=$2 + shift 2 + mkdir -p "$(dirname "${path}")" + [[ -f "${path}" ]] || printf '{}\n' > "${path}" + jq --indent 2 "${kit_jq} ${filter}" "$@" "${path}" > "${path}.kit-new" + mv "${path}.kit-new" "${path}" +} + +# kit:json-holds is true when the JSON at carries every entry the release's writes. +kit:json-holds() { + jq --exit-status "${kit_jq} holds(.; \$new[0])" --slurpfile new "$2" "$1" >/dev/null +} + +# kit:digest prints the sha256 of stdin, by whichever tool the machine carries. +kit:digest() { + if command -v sha256sum >/dev/null 2>&1 + then + sha256sum | cut -d ' ' -f 1 + else + shasum -a 256 | cut -d ' ' -f 1 + fi +} + +# kit:place [] puts the release's content at : a merge for .json, a copy otherwise. +kit:place() { + local src=$1 path=$2 base=${3:-/dev/null} + if kit:merged "${path}" + then + kit:json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" + else + mkdir -p "$(dirname "${path}")" + cat "${src}" > "${path}.kit-new" + mv "${path}.kit-new" "${path}" + fi +} + +# kit:write-manifest … records the version, the targets, and the release's digest for every delivered path. +kit:write-manifest() { + local path + { + printf '# .kit/manifest - written by kit, not by hand\n' + printf 'version %s\n' "$(cat "${kit_root}/version")" + printf 'targets%s\n' "${*:+ $*}" + while IFS= read -r path + do + printf '%s %s\n' "$(kit:digest < "$(kit:source-of "${kit_root}" "${path}" "$@")")" "${path}" + done < <(kit:delivered "${kit_root}" "$@") + } > .kit/manifest +} + +kit:manifest-version() { sed -n 's/^version //p' .kit/manifest; } +kit:manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } + +# kit:require-manifest refuses to go on in a repository that never took the kit. +kit:require-manifest() { + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi +} + +# kit:finish … reports a completed install or update. +kit:finish() { + ok "kit $(cat "${kit_root}/version") is installed, targets: ${*:-none}; .kit/manifest is written." + step "Commit the delivered files. The loop is in .kit/1st.md." +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit b/bin/kit index 637a891..1d4e3bc 100755 --- a/bin/kit +++ b/bin/kit @@ -1,18 +1,9 @@ #!/usr/bin/env bash -# shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose -# kit - install, update, and check the kit in a repository (executed entrypoint) +# kit - the kit's command: each verb is the executable bin/kit-.bash beside it (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # -# The release is the directory above bin/. Its tree/ is mirrored at the repository root, verbatim, and so is -# targets// for each target the repository takes; a symlink in either is copied as the file it points at. -# bash/, hooks/, and version are the machinery and stay in the release. The one exception to a verbatim copy is a -# destination ending in .json, which is merged by key: the kit's keys are set, the kit's array entries are added, -# and on update or removal only the entries the previous release wrote are taken out, so the rest of the file is -# the repository's. -# -# .kit/manifest records the release version, the targets taken, and the release's digest for every delivered -# path. An update tells an untouched file from an edited one by that digest, and merges the edited one with the -# previous release as the base. KIT_BASE= names that release's root on a machine without mise. +# The release is the directory above bin/. This file resolves it, exports it as kit_root, and hands the verb its +# arguments. Everything else, the usage text included, lives with the verb. # # see: .kit/1st.md @@ -25,418 +16,37 @@ then fi kit_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -# shellcheck source=bash/1st.sh -source "${kit_root}/bash/1st.sh" - -usage() { - cat >&2 <<'USAGE' -usage: kit - - install […] mirror tree/ and each named target into the repository, wire this clone's hooks, - and write .kit/manifest - update after the pin is bumped: replace unchanged files, three-way merge changed ones - check fail when a delivered file differs from this release - setup wire this clone: core.hooksPath to this release's hooks - target list print the targets this repository takes - target add … take a target: mirror its directory and record it - target remove … drop a target: remove its files and the kit's entries from its settings - issue […] start or resume an issue: linked branch, worktree, start commit, draft pull request - tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches - status [--no-fetch] show every worktree with its branch, pull request, and state - version print this release's version +export kit_root + +verb=${1:-help} +case "${verb}" in + (help | -h | --help) + cat <<'USAGE' +usage: kit […] + + install […] mirror the release into the repository, take the named targets, write .kit/manifest + update after the pin is bumped: replace unchanged files, three-way merge changed ones + check fail when a delivered file differs from the pinned release + target […] + the targets this repository takes + issue […] start or resume an issue: linked branch, worktree, start commit, draft pull request + rebase [] rebase a worktree's branch onto the default branch and push it; never the default branch + tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches + status [--no-fetch] show every worktree with its branch, pull request, and state + lint […] lint the staged paths, or the paths given; a missing linter is a reported skip + version print this release's version + +Each verb answers --help. USAGE -} - -# ── the release's layout ─────────────────────────────────────────────────────────────────────────────────────────────── - -# layers … prints the directories mirrored at the repository root, tree first. -layers() { - local root=$1 t - shift - printf '%s\n' "${root}/tree" - for t in "$@" - do - printf '%s\n' "${root}/targets/${t}" - done -} - -# delivered … prints every repository path the release at delivers for those targets. -delivered() { - local layer - while IFS= read -r layer - do - (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@') - done < <(layers "$@") | sort --unique -} - -# source-of … prints the release file behind a repository path, the last layer winning. -source-of() { - local root=$1 path=$2 layer found="" - shift 2 - while IFS= read -r layer - do - [[ -e "${layer}/${path}" ]] && found="${layer}/${path}" - done < <(layers "${root}" "$@") - [[ -n "${found}" ]] && printf '%s\n' "${found}" -} - -# known-targets prints the targets this release carries. -known-targets() { - (cd "${kit_root}/targets" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) -} - -# require-targets … refuses a target this release does not carry, naming the ones it does. -require-targets() { - local t - for t in "$@" - do - if [[ ! -d "${kit_root}/targets/${t}" ]] - then - error "'${t}' is not a target this release knows; the targets are: $(known-targets | paste -s -d ' ' -)" - return 2 - fi - done -} - -# target-only prints the paths the target delivers that tree/ does not. -target-only() { - comm -13 <(delivered "${kit_root}") <(delivered "${kit_root}" "$1") -} - -merged() { [[ "$1" == *.json ]]; } - -# ── the one merge rule, for a .json destination ───────────────────────────────────────────────────────────────────────── - -# The parameters are values ($), not filters: a filter parameter is re-read against the input at each use, which -# inside the recursion is the wrong level of the object. -jq_lib=' - def merge($cur; $base; $new): - if ($new | type) == "object" then - reduce ($new | keys[]) as $k ($cur // {}; .[$k] = merge(($cur // {})[$k]; ($base // {})[$k]; $new[$k])) - elif ($new | type) == "array" then - ((($cur // []) - ($base // [])) as $kept | $kept + ($new - $kept)) - else $new end; - def unmerge($cur; $base): - if ($base | type) == "object" then - reduce ($base | keys[]) as $k ($cur; if has($k) then .[$k] = unmerge($cur[$k]; $base[$k]) else . end) - elif ($base | type) == "array" then ($cur // []) - $base - else $cur end; - def holds($cur; $new): - if ($new | type) == "object" then - all($new | keys[]; . as $k | ($cur | type) == "object" and ($cur | has($k)) and holds($cur[$k]; $new[$k])) - elif ($new | type) == "array" then all($new[]; . as $e | any(($cur // [])[]; . == $e)) - else $cur == $new end; -' - -# json-write rewrites the JSON at through , making the file if it is missing. -json-write() { - local path=$1 filter=$2 - shift 2 - mkdir -p "$(dirname "${path}")" - [[ -f "${path}" ]] || printf '{}\n' > "${path}" - jq --indent 2 "${jq_lib} ${filter}" "$@" "${path}" > "${path}.kit-new" - mv "${path}.kit-new" "${path}" -} - -# ── placing, recording, wiring ────────────────────────────────────────────────────────────────────────────────────────── - -digest() { - if command -v sha256sum >/dev/null 2>&1 - then - sha256sum | cut -d ' ' -f 1 - else - shasum -a 256 | cut -d ' ' -f 1 - fi -} - -# place [] puts the release's content at : a merge for .json, a copy otherwise. -place() { - local src=$1 path=$2 base=${3:-/dev/null} - if merged "${path}" - then - json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" - else - mkdir -p "$(dirname "${path}")" - cat "${src}" > "${path}.kit-new" - mv "${path}.kit-new" "${path}" - fi -} - -# write-manifest … records the version, the targets, and the release's digest for every delivered path. -write-manifest() { - local path - { - printf '# .kit/manifest - written by kit, not by hand\n' - printf 'version %s\n' "$(cat "${kit_root}/version")" - printf 'targets%s\n' "${*:+ $*}" - while IFS= read -r path - do - printf '%s %s\n' "$(digest < "$(source-of "${kit_root}" "${path}" "$@")")" "${path}" - done < <(delivered "${kit_root}" "$@") - } > .kit/manifest -} - -manifest-version() { sed -n 's/^version //p' .kit/manifest; } -manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } - -require-manifest() { - if [[ ! -f .kit/manifest ]] - then - error "run: kit install (this repository carries no .kit/manifest)" - return 1 - fi -} - -wire-hooks() { - git config core.hooksPath "${kit_root}/hooks" -} - -finish() { - wire-hooks - ok "kit $(cat "${kit_root}/version") is installed, targets: ${*:-none}. core.hooksPath is ${kit_root}/hooks, and .kit/manifest is written." - step "Commit the delivered files. The loop is in .kit/1st.md." -} - -# ── commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────── - -cmd-install() { - guard:require tool:jq tool:git - require-targets "$@" - if [[ -f .kit/manifest ]] - then - error "run: kit update (this repository already carries kit $(manifest-version))" - return 1 - fi - section "kit install $(cat "${kit_root}/version")${*:+, targets: $*}" - local path - while IFS= read -r path - do - step "${path}" - place "$(source-of "${kit_root}" "${path}" "$@")" "${path}" - done < <(delivered "${kit_root}" "$@") - write-manifest "$@" - finish "$@" -} - -cmd-update() { - guard:require tool:jq tool:git - require-manifest - local previous current base_root="" path src base recorded conflicts=0 - local -a targets - read -r -a targets <<<"$(manifest-targets)" - previous=$(manifest-version) - current=$(cat "${kit_root}/version") - section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" - - # The previous release is the base of every merge. KIT_BASE names its root; otherwise mise knows where it is. - if [[ -n "${KIT_BASE:-}" ]] - then - base_root=${KIT_BASE} - elif [[ "${previous}" != "${current}" ]] && command -v mise >/dev/null 2>&1 - then - base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) - if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null - then - base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) - fi - fi - - while IFS= read -r path - do - src=$(source-of "${kit_root}" "${path}" "${targets[@]}") - base="" - [[ -n "${base_root}" ]] && base=$(source-of "${base_root}" "${path}" "${targets[@]}" || true) - if merged "${path}" - then - step "${path}: merged by key" - place "${src}" "${path}" "${base}" - continue - fi - if [[ ! -f "${path}" ]] - then - step "${path}: missing, written" - place "${src}" "${path}" - continue - fi - recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) - if [[ -z "${recorded}" || "${recorded}" == "$(digest < "${path}")" ]] - then - step "${path}: unchanged since ${previous}, replaced" - place "${src}" "${path}" - continue - fi - if [[ -z "${base}" ]] - then - error "${path} was edited and the base release ${previous} is not installed; run: mise install github:empowerite/kit@${previous}" - conflicts=$(( conflicts + 1 )) - continue - fi - step "${path}: edited since ${previous}, three-way merged" - if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" - then - warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" - conflicts=$(( conflicts + 1 )) - fi - done < <(delivered "${kit_root}" "${targets[@]}") - - write-manifest "${targets[@]}" - if (( conflicts > 0 )) - then - error "${conflicts} file(s) need a hand; see above." - return 1 - fi - finish "${targets[@]}" -} - -cmd-check() { - require-manifest - local path src findings=0 version - local -a targets - read -r -a targets <<<"$(manifest-targets)" - version=$(manifest-version) - if [[ "${version}" != "$(cat "${kit_root}/version")" ]] - then - error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${kit_root}/version"); run: kit update" - findings=$(( findings + 1 )) - fi - while IFS= read -r path - do - src=$(source-of "${kit_root}" "${path}" "${targets[@]}") - if [[ ! -f "${path}" ]] - then - error "${path} is missing" - findings=$(( findings + 1 )) - elif merged "${path}" - then - if ! command -v jq >/dev/null 2>&1 - then - error "run: mise install (jq is not on PATH, so ${path} could not be read)" - findings=$(( findings + 1 )) - elif ! jq --exit-status "${jq_lib} holds(.; \$new[0])" --slurpfile new "${src}" "${path}" >/dev/null - then - error "${path} is missing an entry the kit writes; run: kit update" - findings=$(( findings + 1 )) - fi - elif ! cmp --silent "${src}" "${path}" + ;; + (*) + if [[ ! -x "${kit_root}/bin/kit-${verb}.bash" ]] then - error "${path} differs from the pinned release" - findings=$(( findings + 1 )) + echo "kit: unknown verb '${verb}'; run: kit help" >&2 + exit 2 fi - done < <(delivered "${kit_root}" "${targets[@]}") - if (( findings > 0 )) - then - error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." - return 1 - fi - ok "kit ${version}, targets ${targets[*]:-none}: every delivered file matches the pinned release." -} - -cmd-setup() { - guard:require tool:git - wire-hooks - ok "core.hooksPath is ${kit_root}/hooks" - if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] - then - warn "no signing key is configured in this clone, and the ruleset refuses an unsigned commit; see README.md § Setup" - fi -} - -cmd-target() { - local verb=${1:-list} t path src - local -a targets kept - shift || true - require-manifest - read -r -a targets <<<"$(manifest-targets)" - case "${verb}" in - (list) - printf '%s\n' "${targets[@]}" - ;; - (add) - (( $# > 0 )) || { error "usage: kit target add …"; return 2; } - guard:require tool:jq tool:git - require-targets "$@" - for t in "$@" - do - if [[ " ${targets[*]} " == *" ${t} "* ]] - then - step "${t}: already taken" - continue - fi - section "kit target add ${t}" - while IFS= read -r path - do - step "${path}" - place "${kit_root}/targets/${t}/${path}" "${path}" - done < <(target-only "${t}") - targets+=("${t}") - done - write-manifest "${targets[@]}" - ok "targets: ${targets[*]}" - ;; - (remove) - (( $# > 0 )) || { error "usage: kit target remove …"; return 2; } - guard:require tool:jq tool:git - require-targets "$@" - for t in "$@" - do - if [[ " ${targets[*]} " != *" ${t} "* ]] - then - step "${t}: not taken" - continue - fi - section "kit target remove ${t}" - while IFS= read -r path - do - src="${kit_root}/targets/${t}/${path}" - if merged "${path}" - then - [[ -f "${path}" ]] || continue - step "${path}: the kit's entries taken out" - json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" - else - step "removing ${path}" - rm -f "${path}" - fi - done < <(target-only "${t}") - kept=() - for path in "${targets[@]}" - do - [[ "${path}" == "${t}" ]] || kept+=("${path}") - done - targets=("${kept[@]}") - done - write-manifest "${targets[@]}" - ok "targets: ${targets[*]:-none}" - ;; - (*) - error "kit target: unknown verb '${verb}' (list, add, remove)" - return 2 - esac -} - -cmd-loop() { - local verb=$1 - shift - case "${verb}" in - (issue) gh:issue "$@" ;; - (tidy) git:tidy "$@" ;; - (status) git:status "$@" ;; - esac -} - -case "${1:-}" in - (install) shift; cmd-install "$@" ;; - (update) cmd-update ;; - (check) cmd-check ;; - (setup) cmd-setup ;; - (target) shift; cmd-target "$@" ;; - (issue | tidy | status) cmd-loop "$@" ;; - (version) cat "${kit_root}/version" ;; - (-h | --help | help | "") usage; [[ -n "${1:-}" ]] ;; - (*) - error "kit: unknown command '$1'" - usage - exit 2 + shift + exec "${kit_root}/bin/kit-${verb}.bash" "$@" esac # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-check.bash b/bin/kit-check.bash new file mode 100755 index 0000000..6225b80 --- /dev/null +++ b/bin/kit-check.bash @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# kit-check - fail when a delivered file differs from the pinned release (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/kit.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use kit + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit check" >&2 + exit 0 +fi + +kit:require-manifest +read -r -a targets <<<"$(kit:manifest-targets)" +version=$(kit:manifest-version) +findings=0 +if [[ "${version}" != "$(cat "${kit_root}/version")" ]] +then + error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${kit_root}/version"); run: kit update" + findings=$(( findings + 1 )) +fi +while IFS= read -r path +do + src=$(kit:source-of "${kit_root}" "${path}" "${targets[@]}") + if [[ ! -f "${path}" ]] + then + error "${path} is missing" + findings=$(( findings + 1 )) + elif kit:merged "${path}" + then + if ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so ${path} could not be read)" + findings=$(( findings + 1 )) + elif ! kit:json-holds "${path}" "${src}" + then + error "${path} is missing an entry the kit writes; run: kit update" + findings=$(( findings + 1 )) + fi + elif ! cmp --silent "${src}" "${path}" + then + error "${path} differs from the pinned release" + findings=$(( findings + 1 )) + fi +done < <(kit:delivered "${kit_root}" "${targets[@]}") +if (( findings > 0 )) +then + error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." + exit 1 +fi +ok "kit ${version}, targets ${targets[*]:-none}: every delivered file matches the pinned release." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-install.bash b/bin/kit-install.bash new file mode 100755 index 0000000..5bbfa87 --- /dev/null +++ b/bin/kit-install.bash @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# kit-install - mirror the release into the repository, take the named targets, and write .kit/manifest (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/kit.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use kit + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit install […]" >&2 + exit 0 +fi + +guard:require tool:jq tool:git +kit:require-targets "$@" +if [[ -f .kit/manifest ]] +then + error "run: kit update (this repository already carries kit $(kit:manifest-version))" + exit 1 +fi +section "kit install $(cat "${kit_root}/version")${*:+, targets: $*}" +while IFS= read -r path +do + step "${path}" + kit:place "$(kit:source-of "${kit_root}" "${path}" "$@")" "${path}" +done < <(kit:delivered "${kit_root}" "$@") +kit:write-manifest "$@" +kit:finish "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-issue.bash b/bin/kit-issue.bash new file mode 100755 index 0000000..422d993 --- /dev/null +++ b/bin/kit-issue.bash @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# kit-issue - start or resume an issue: linked branch, worktree, start commit, draft pull request (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/gh.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use gh + +if [[ "${1:-}" == --help ]] +then + sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + exit 0 +fi + +gh:issue "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-lint.bash b/bin/kit-lint.bash new file mode 100755 index 0000000..62d85de --- /dev/null +++ b/bin/kit-lint.bash @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# kit-lint - lint the staged paths, or the paths given; a missing linter is a reported skip (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# The body of a pre-commit hook, whatever runs the hook: shellcheck over bash, actionlint over workflows, and +# markdownlint-cli2 over markdown when the repository carries a config for it. A linter that is not on PATH is a +# reported skip, never a silent pass and never a refusal: lint is not the enforcement of record. The working +# tree's copy of each path is linted, so a partial add can be told about a line the commit does not carry. + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit lint […] (no path: the staged paths)" >&2 + exit 0 +fi + +paths=() +if (( $# > 0 )) +then + paths=("$@") +else + while IFS= read -r -d '' path + do + paths+=("${path}") + done < <(git diff --cached --name-only --diff-filter=ACMR -z) +fi + +bash_paths=() +workflow_paths=() +markdown_paths=() +for path in "${paths[@]}" +do + [[ -f "${path}" ]] || continue + case "${path}" in + (*.sh | *.bash) bash_paths+=("${path}") ;; + (.github/workflows/*.yml | .github/workflows/*.yaml) workflow_paths+=("${path}") ;; + (*.md) markdown_paths+=("${path}") + esac +done + +findings=0 +run-linter() { # … runs the linter over the paths, or reports the skip + local linter=$1 what=$2 + shift 2 + if ! command -v "${linter}" >/dev/null 2>&1 + then + warn "${linter} is not on PATH, so the ${what} were NOT linted" + return 0 + fi + "${linter}" "$@" || findings=$(( findings + 1 )) +} +(( ${#bash_paths[@]} == 0 )) || run-linter shellcheck "bash files" --shell=bash --external-sources "${bash_paths[@]}" +(( ${#workflow_paths[@]} == 0 )) || run-linter actionlint workflows "${workflow_paths[@]}" +if (( ${#markdown_paths[@]} > 0 )) && compgen -G ".markdownlint-cli2.*" >/dev/null +then + run-linter markdownlint-cli2 "markdown files" "${markdown_paths[@]}" +fi + +if (( findings > 0 )) +then + error "a linter found something in ${findings} group(s) of paths; each finding above names the file and line." + exit 1 +fi + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-rebase.bash b/bin/kit-rebase.bash new file mode 100755 index 0000000..ce17251 --- /dev/null +++ b/bin/kit-rebase.bash @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# kit-rebase - rebase a worktree's branch onto the default branch and push it; never the default branch (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# The default branch is the human's desk and is never rebased by an agent: this verb refuses it, and 1st.md says an +# agent spells a rebase this way and no other. Any other branch is rebased onto origin/ and pushed with +# --force-with-lease, which fails rather than overwrite a push that landed since. On a conflict the rebase is +# aborted and the worktree left as it was. + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit rebase [] (no path: the checkout this runs in)" >&2 + exit 0 +fi + +guard:require tool:git +worktree=${1:-.} +branch=$(git -C "${worktree}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) +if [[ -z "${branch}" ]] +then + error "${worktree} has no branch checked out, so there is nothing to rebase" + exit 1 +fi +default_branch=$(git -C "${worktree}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) +[[ -n "${default_branch}" ]] || default_branch=main +if [[ "${branch}" == "${default_branch}" ]] +then + error "refusing to rebase ${branch}: it is the default branch, the human's desk. It advances by fast-forward: git pull --ff-only" + exit 1 +fi + +section "kit rebase ${branch} onto origin/${default_branch}" +git -C "${worktree}" fetch --quiet --prune origin +if ! git -C "${worktree}" rebase --quiet "origin/${default_branch}" +then + git -C "${worktree}" rebase --abort + error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was. Resolve by hand." + exit 1 +fi +git -C "${worktree}" push --quiet --force-with-lease +ok "${branch} is rebased onto origin/${default_branch} and pushed." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-status.bash b/bin/kit-status.bash new file mode 100755 index 0000000..2e48cb2 --- /dev/null +++ b/bin/kit-status.bash @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# kit-status - show every worktree with its branch, pull request, and state (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/git.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use git + +if [[ "${1:-}" == --help ]] +then + sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + exit 0 +fi + +git:status "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-target.bash b/bin/kit-target.bash new file mode 100755 index 0000000..05a3aa2 --- /dev/null +++ b/bin/kit-target.bash @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# kit-target - list, take, or drop the targets this repository takes (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/kit.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use kit + +usage() { echo "usage: kit target list | kit target add … | kit target remove …" >&2; } + +verb=${1:-list} +shift || true +case "${verb}" in + (--help) usage; exit 0 ;; + (list | add | remove) ;; + (*) usage; exit 2 +esac +kit:require-manifest +read -r -a targets <<<"$(kit:manifest-targets)" + +case "${verb}" in + (list) + printf '%s\n' "${targets[@]}" + ;; + (add) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq tool:git + kit:require-targets "$@" + for t in "$@" + do + if [[ " ${targets[*]} " == *" ${t} "* ]] + then + step "${t}: already taken" + continue + fi + section "kit target add ${t}" + while IFS= read -r path + do + step "${path}" + kit:place "${kit_root}/targets/${t}/${path}" "${path}" + done < <(kit:target-only "${t}") + targets+=("${t}") + done + kit:write-manifest "${targets[@]}" + ok "targets: ${targets[*]}" + ;; + (remove) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq tool:git + kit:require-targets "$@" + for t in "$@" + do + if [[ " ${targets[*]} " != *" ${t} "* ]] + then + step "${t}: not taken" + continue + fi + section "kit target remove ${t}" + while IFS= read -r path + do + src="${kit_root}/targets/${t}/${path}" + if kit:merged "${path}" + then + [[ -f "${path}" ]] || continue + step "${path}: the kit's entries taken out" + # shellcheck disable=SC2016 # a jq filter, with jq variables + kit:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" + else + step "removing ${path}" + rm -f "${path}" + fi + done < <(kit:target-only "${t}") + kept=() + for path in "${targets[@]}" + do + [[ "${path}" == "${t}" ]] || kept+=("${path}") + done + targets=("${kept[@]}") + done + kit:write-manifest "${targets[@]}" + ok "targets: ${targets[*]:-none}" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-tidy.bash b/bin/kit-tidy.bash new file mode 100755 index 0000000..fd2e5e1 --- /dev/null +++ b/bin/kit-tidy.bash @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# kit-tidy - remove worktrees whose pull request is merged or closed, and gone branches (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bash/git.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use git + +if [[ "${1:-}" == --help ]] +then + sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + exit 0 +fi + +git:tidy "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-update.bash b/bin/kit-update.bash new file mode 100755 index 0000000..9e0a4bb --- /dev/null +++ b/bin/kit-update.bash @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# kit-update - after the pin is bumped: replace unchanged files, three-way merge changed ones (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# An untouched file is one whose digest still matches the manifest, which records the previous release's own +# content; it is replaced. An edited one is merged with the previous release as the base: KIT_BASE= names +# that release's root, otherwise mise knows where it installed it. +# +# see: bash/kit.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bash/1st.sh +source "${kit_root:?run through bin/kit}/bash/1st.sh" +use kit + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit update" >&2 + exit 0 +fi + +guard:require tool:jq tool:git +kit:require-manifest +read -r -a targets <<<"$(kit:manifest-targets)" +previous=$(kit:manifest-version) +current=$(cat "${kit_root}/version") +section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" + +base_root="" +if [[ -n "${KIT_BASE:-}" ]] +then + base_root=${KIT_BASE} +elif [[ "${previous}" != "${current}" ]] && command -v mise >/dev/null 2>&1 +then + base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) + if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null + then + base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) + fi +fi + +conflicts=0 +while IFS= read -r path +do + src=$(kit:source-of "${kit_root}" "${path}" "${targets[@]}") + base="" + [[ -n "${base_root}" ]] && base=$(kit:source-of "${base_root}" "${path}" "${targets[@]}" || true) + if kit:merged "${path}" + then + step "${path}: merged by key" + kit:place "${src}" "${path}" "${base}" + continue + fi + if [[ ! -f "${path}" ]] + then + step "${path}: missing, written" + kit:place "${src}" "${path}" + continue + fi + recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) + if [[ -z "${recorded}" || "${recorded}" == "$(kit:digest < "${path}")" ]] + then + step "${path}: unchanged since ${previous}, replaced" + kit:place "${src}" "${path}" + continue + fi + if [[ -z "${base}" ]] + then + error "${path} was edited and the base release ${previous} is not installed; run: mise install github:empowerite/kit@${previous}" + conflicts=$(( conflicts + 1 )) + continue + fi + step "${path}: edited since ${previous}, three-way merged" + if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" + then + warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" + conflicts=$(( conflicts + 1 )) + fi +done < <(kit:delivered "${kit_root}" "${targets[@]}") + +kit:write-manifest "${targets[@]}" +if (( conflicts > 0 )) +then + error "${conflicts} file(s) need a hand; see above." + exit 1 +fi +kit:finish "${targets[@]}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-version.bash b/bin/kit-version.bash new file mode 100755 index 0000000..14b2081 --- /dev/null +++ b/bin/kit-version.bash @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# kit-version - print this release's version (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- + +set -o errexit -o nounset -o pipefail +cat "${kit_root:?run through bin/kit}/version" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/hooks/pre-commit b/hooks/pre-commit deleted file mode 100755 index 35c3163..0000000 --- a/hooks/pre-commit +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# pre-commit - lint the staged paths and refuse the commit on a finding (executed git hook, fails open) -# ---------------------------------------------------------------------------------------------------------------------- -# -# Each linter runs over the staged paths of its own kind, when the linter is on PATH: shellcheck over bash, -# actionlint over workflows, and markdownlint-cli2 over markdown when the repository carries a config for it. -# A linter that is not on PATH is a reported skip, never a silent pass and never a refusal: lint is not the -# enforcement of record, and a GUI client with a short PATH must still be able to commit. -# -# The working tree's copy of each path is linted, not the staged blob, so a partial add can be told about a line -# the commit does not carry. The escape hatch is git's own: git commit --no-verify - -set -o nounset -o pipefail - -staged=() -while IFS= read -r -d '' path -do - [[ -f "${path}" ]] && staged+=("${path}") -done < <(git diff --cached --name-only --diff-filter=ACMR -z 2>/dev/null) -(( ${#staged[@]} )) || exit 0 - -bash_paths=() -workflow_paths=() -markdown_paths=() -for path in "${staged[@]}" -do - case "${path}" in - (*.sh | *.bash | hooks/*) - bash_paths+=("${path}") - ;; - (.github/workflows/*.yml | .github/workflows/*.yaml) - workflow_paths+=("${path}") - ;; - (*.md) - markdown_paths+=("${path}") - esac -done - -findings=0 -skip() { - printf '\xe2\x9a\xa0\xef\xb8\x8f %s is not on PATH, so the staged %s were NOT linted.\n' "$1" "$2" >&2 -} - -if (( ${#bash_paths[@]} )) -then - if command -v shellcheck >/dev/null 2>&1 - then - shellcheck --shell=bash --external-sources "${bash_paths[@]}" || findings=1 - else - skip shellcheck "bash files" - fi -fi - -if (( ${#workflow_paths[@]} )) -then - if command -v actionlint >/dev/null 2>&1 - then - actionlint "${workflow_paths[@]}" || findings=1 - else - skip actionlint workflows - fi -fi - -if (( ${#markdown_paths[@]} )) && compgen -G ".markdownlint-cli2.*" >/dev/null -then - if command -v markdownlint-cli2 >/dev/null 2>&1 - then - markdownlint-cli2 "${markdown_paths[@]}" || findings=1 - else - skip markdownlint-cli2 "markdown files" - fi -fi - -if (( findings )) -then - cat >&2 <<'MESSAGE' - -❌ refusing the commit: a linter found something in the staged paths. Each finding above names the file and line. - Fix it and commit again. If you mean it anyway, the escape hatch is git's own: git commit --no-verify -MESSAGE - exit 1 -fi -exit 0 - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/hooks/pre-rebase b/hooks/pre-rebase deleted file mode 100755 index 240bc3e..0000000 --- a/hooks/pre-rebase +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# pre-rebase - refuse a rebase of the default branch (executed git hook, fails open) -# ---------------------------------------------------------------------------------------------------------------------- -# -# git runs this before every rebase, however it was spelled: git rebase, git pull --rebase, an editor's button. -# It refuses one thing, a rebase whose branch is the default branch, because that branch is the human's desk and -# a rebase rewrites it. A rebase in worktrees/- is on an issue branch and passes. -# -# It fails open: a rung that cannot tell whether the branch is the default branch exits 0, since refusing every -# worktree rebase in a clone that cannot answer would be worse than the gap. The one exception is below. -# -# git passes as $1, and as $2 only for `git rebase `; an empty $2 means -# the branch checked out here. - -set -o nounset -o pipefail - -upstream=${1:-} -branch=${2:-} -[[ -n "${branch}" ]] || branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) -[[ -n "${branch}" ]] || exit 0 - -default_branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) -[[ -n "${default_branch}" ]] || default_branch=main -[[ "${branch}" == "${default_branch}" ]] || exit 0 - -# A branch with no commit of its own has nothing a rebase would replay: it is a fast-forward, and passes. Once -# the branch is known to be the default branch, an that cannot be resolved is refused, not passed: -# this is the one rung where "cannot tell" means no. -if [[ -n "${upstream}" ]] -then - unique=$(git rev-list --count "${upstream}..${branch}" 2>/dev/null) && (( unique == 0 )) && exit 0 -fi - -# The branch name goes through printf, never into the heredoc, since a ref may carry a backtick or a dollar. -printf '\xe2\x9d\x8c refusing to rebase %s: it is the default branch, and the default branch is never rebased.\n' \ - "'${branch}'" >&2 -cat >&2 <<'MESSAGE' - - The default branch is the human's desk. It advances by fast-forward: git pull --ff-only - A worktree's branch is rebased in that worktree: git -C worktrees/- rebase main - - If you mean it, the escape hatch is git's own, on the rebase itself: git rebase --no-verify -MESSAGE -exit 1 - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/justfile b/justfile index 8beb849..17444db 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ # justfile - see: https://just.systems/man/en/ # ---------------------------------------------------------------------------------------------------------------------- # The root justfile: the gate, and the refresh of this repository's own installed copy. The loop's commands are -# bin/kit issue, tidy, and status, spelled that way and no other. +# kit issue, rebase, tidy, and status, spelled that way and no other; mise.toml puts bin/ on PATH. set dotenv-load := true set positional-arguments := true @@ -29,7 +29,7 @@ set export := true ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - mise exec -- shellcheck --shell=bash --external-sources bin/kit bash/*.sh hooks/* + mise exec -- shellcheck --shell=bash --external-sources bin/kit bin/kit-*.bash bash/*.sh mise exec -- actionlint mise exec -- bats --print-output-on-failure tests/ bin/kit check diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..23ab42d --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,8 @@ +# lefthook.yml - this repository's git hooks; see: https://lefthook.dev +# ---------------------------------------------------------------------------------------------------------------------- +# +# The kit's own hooks come from its installed fragment, the way a consumer takes them. Once per clone: +# lefthook install + +extends: + - .kit/lefthook.yml diff --git a/mise.lock b/mise.lock index dd72d7a..bcae003 100644 --- a/mise.lock +++ b/mise.lock @@ -213,6 +213,11 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" +[[tools.lefthook]] +version = "2.2.2" +backend = "aqua:evilmartians/lefthook" +specifiers = ["2.2"] + [[tools."npm:bats"]] version = "1.13.0" aube = { path = ".mise/locks/npm-bats/1.13.0", digest = "sha256:b3795a7982457f07d0e04a547944558c3571830dd86994d282099c4c27082169" } diff --git a/mise.toml b/mise.toml index 53ce17c..0d4b438 100644 --- a/mise.toml +++ b/mise.toml @@ -7,10 +7,15 @@ [settings] lockfile = true +# bin/ on PATH, so `kit` is spelled here as a consumer spells it. +[env] +_.path = ["bin"] + [tools] actionlint = "1.7" gh = "2.103" jq = "1.8" just = "1.59" +lefthook = "2.2" shellcheck = "0.11" bats = "1.13" diff --git a/targets/claude/.claude/settings.json b/targets/claude/.claude/settings.json index 7911d58..fde98cf 100644 --- a/targets/claude/.claude/settings.json +++ b/targets/claude/.claude/settings.json @@ -3,6 +3,7 @@ "deny": [ "Bash(git checkout main *)", "Bash(git switch main *)", + "Bash(git rebase *)", "Bash(git reset --hard *)", "Bash(git push --force *)", "Bash(git push -f *)", diff --git a/tests/hooks.bats b/tests/hooks.bats deleted file mode 100644 index 7559eae..0000000 --- a/tests/hooks.bats +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bats -# hooks.bats - the desk guard: pre-rebase refuses the default branch, pre-commit refuses a lint finding -# ---------------------------------------------------------------------------------------------------------------------- - -setup() { - kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) - repo=$(mktemp -d) - cd "${repo}" - git init --quiet --initial-branch=main - git config user.name t && git config user.email t@example.com && git config commit.gpgsign false - git config core.hooksPath "${kit_checkout}/hooks" - printf 'one\n' > a && git add a && git commit --quiet --message one -} - -teardown() { - rm -rf "${repo}" -} - -@test "pre-rebase refuses a rebase of main that would rewrite a commit, and allows one in a branch" { - git switch --quiet --create feature - printf 'two\n' > b && git add b && git commit --quiet --message two - git switch --quiet main - printf 'three\n' > c && git add c && git commit --quiet --message three - run git rebase feature - [ "$status" -eq 1 ] - [[ "$output" == *"refusing to rebase 'main'"* ]] - git switch --quiet feature - run git rebase main - [ "$status" -eq 0 ] -} - -@test "pre-rebase lets main fast-forward" { - git switch --quiet --create feature - printf 'two\n' > b && git add b && git commit --quiet --message two - git switch --quiet main - run git rebase feature - [ "$status" -eq 0 ] -} - -@test "pre-commit refuses a bash file shellcheck faults, and passes a clean one" { - printf '#!/usr/bin/env bash\necho $x\n' > bad.sh - git add bad.sh - run git commit --quiet --message bad - [ "$status" -eq 1 ] - [[ "$output" == *"SC2086"* ]] - [[ "$output" == *"refusing the commit"* ]] - printf '#!/usr/bin/env bash\necho "${x:-}"\n' > bad.sh - git add bad.sh - run git commit --quiet --message good - [ "$status" -eq 0 ] -} - -@test "pre-commit reports a skip when the linter is not on PATH" { - printf '#!/usr/bin/env bash\necho $x\n' > bad.sh - git add bad.sh - # a PATH of one directory, carrying only what the hook itself runs, and no shellcheck - mkdir only - for tool in bash git env cat - do - ln -s "$(command -v "${tool}")" "only/${tool}" - done - run env PATH="${PWD}/only" git commit --quiet --message bad - [ "$status" -eq 0 ] - [[ "$output" == *"NOT linted"* ]] -} - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/kit.bats b/tests/kit.bats index c1c16b8..71b182a 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -19,7 +19,7 @@ teardown() { # later-release copies this checkout's release to as version 9.9.9 with one changed rule line. later-release() { mkdir -p "$1" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/hooks" "$1/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "$1/" printf '9.9.9\n' > "$1/version" printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/tree/.kit/rules/json.md" } @@ -30,26 +30,16 @@ later-release() { [ -f .kit/1st.md ] [ -f .kit/rules/bash.md ] [ ! -e .kit/bash ] - [ ! -e .kit/hooks ] + [ -f .kit/lefthook.yml ] [ -f .claude/rules/kit-markdown.md ] [ ! -L .claude/rules/kit-markdown.md ] cmp --silent .claude/rules/kit-markdown.md .kit/rules/markdown.md head -n 1 .claude/rules/kit-bash.md | grep --quiet --line-regexp -- '---' grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md [ -f .github/workflows/kit-ci.yml ] - jq --exit-status '.permissions.deny | index("Bash(git push --force *)")' .claude/settings.json >/dev/null + jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null grep --quiet '^version 0.1.0$' .kit/manifest grep --quiet '^targets claude$' .kit/manifest - [ "$(git config core.hooksPath)" = "${kit_checkout}/hooks" ] -} - -@test "setup wires a clone's hooks to the release, and issue without a terminal prints usage" { - run "${kit_checkout}/bin/kit" setup - [ "$status" -eq 0 ] - [ "$(git config core.hooksPath)" = "${kit_checkout}/hooks" ] - run "${kit_checkout}/bin/kit" issue - [ "$status" -eq 2 ] - [[ "$output" == *"usage: kit issue"* ]] } @test "install with no target writes the neutral tree and nothing under .claude" { diff --git a/tests/lint.bats b/tests/lint.bats new file mode 100644 index 0000000..06a3239 --- /dev/null +++ b/tests/lint.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats +# lint.bats - kit lint over given paths and over the staged set, and its reported skip +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + printf '#!/usr/bin/env bash\necho $x\n' > bad.sh + printf '#!/usr/bin/env bash\necho "${x:-}"\n' > good.sh +} + +teardown() { + rm -rf "${repo}" +} + +@test "lint refuses a bash file shellcheck faults, and passes a clean one" { + run "${kit_checkout}/bin/kit" lint bad.sh + [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] + run "${kit_checkout}/bin/kit" lint good.sh + [ "$status" -eq 0 ] +} + +@test "lint with no path reads the staged set" { + git add bad.sh + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 1 ] + git reset --quiet && git add good.sh + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 0 ] +} + +@test "lint reports a skip when the linter is not on PATH" { + # a PATH of one directory, carrying only what the verb itself runs, and no shellcheck + mkdir only + for tool in bash git env cat sed dirname + do + ln -s "$(command -v "${tool}")" "only/${tool}" + done + run env PATH="${PWD}/only" "${kit_checkout}/bin/kit" lint bad.sh + [ "$status" -eq 0 ] + [[ "$output" == *"NOT linted"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/rebase.bats b/tests/rebase.bats new file mode 100644 index 0000000..1aa67bb --- /dev/null +++ b/tests/rebase.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats +# rebase.bats - kit rebase refuses the default branch and brings a worktree's branch up to date +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main +} + +teardown() { + rm -rf "${work}" +} + +worktree() { # a worktree on branch 1-x, configured to commit + git worktree add --quiet ../wt -b 1-x + git -C ../wt config user.name t && git -C ../wt config user.email t@example.com && git -C ../wt config commit.gpgsign false +} + +@test "rebase refuses the default branch" { + run "${kit_checkout}/bin/kit" rebase + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase main"* ]] +} + +@test "rebase moves a worktree's branch onto origin/main and pushes it" { + worktree + printf 'two\n' > ../wt/b && git -C ../wt add b && git -C ../wt commit --quiet --message two && git -C ../wt push --quiet --set-upstream origin 1-x + printf 'three\n' > c && git add c && git commit --quiet --message three && git push --quiet origin main + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 0 ] + [ "$(git -C ../wt rev-parse HEAD~1)" = "$(git rev-parse origin/main)" ] + [ "$(git -C ../wt rev-parse HEAD)" = "$(git -C ../wt rev-parse origin/1-x)" ] +} + +@test "rebase aborts on a conflict and leaves the worktree as it was" { + worktree + printf 'theirs\n' > ../wt/a && git -C ../wt commit --quiet --all --message theirs && git -C ../wt push --quiet --set-upstream origin 1-x + printf 'ours\n' > a && git commit --quiet --all --message ours && git push --quiet origin main + before=$(git -C ../wt rev-parse HEAD) + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 1 ] + [[ "$output" == *"conflicts with origin/main"* ]] + [ "$(git -C ../wt rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C ../wt status --porcelain)" ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/release.bats b/tests/release.bats index 5667467..e4af478 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -15,12 +15,13 @@ teardown() { @test "the tarball holds one directory with the release's layout, and installs a fresh repository" { version=$(cat "${kit_checkout}/version") mkdir "kit-${version}" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/hooks" "${kit_checkout}/version" "kit-${version}/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/version" "kit-${version}/" tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" rm -rf "kit-${version}" mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked [ "$(ls unpacked)" = "kit-${version}" ] [ -x "unpacked/kit-${version}/bin/kit" ] + [ -x "unpacked/kit-${version}/bin/kit-install.bash" ] [ -L "unpacked/kit-${version}/targets/claude/.claude/rules/kit-bash.md" ] mkdir fresh && git -C fresh init --quiet --initial-branch=main cd fresh @@ -29,7 +30,6 @@ teardown() { run "${work}/unpacked/kit-${version}/bin/kit" check [ "$status" -eq 0 ] [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] - [ "$(git config core.hooksPath)" = "${work}/unpacked/kit-${version}/hooks" ] [ ! -L .claude/rules/kit-bash.md ] [ ! -e .kit/bash ] } diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index e1ad167..24e573c 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -25,12 +25,25 @@ worktree under `worktrees/`, one per issue, and reaches `main` only through a me 1. An issue exists. A change without an issue has not started. 2. `kit issue ` makes `worktrees/-`, a branch linked to the issue under Development, and a draft pull request. It never switches the checkout it is run from. -3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. +3. Work happens in that worktree. Every commit is signed, and its subject is `# : `. `kit rebase` + brings the branch up to date with `main` and pushes it. 4. `gh pr ready` says the work is done. The pull request body is the report: what changed, what was checked and how, and what was left out and why. 5. A human merges. Nothing reaches `main` any other way, and nothing is called merged until the merge is observed. +## Spellings an agent uses + +Where the kit adds a rule to a git verb, the kit's spelling is the only one an agent uses. The rule runs inside +it, and a harness that can refuse the raw spelling does. + +- `kit rebase`, never `git rebase`. It refuses the default branch and otherwise rebases the worktree's branch onto + `origin/main` and pushes it. +- `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. +- `kit tidy`, never `git worktree remove` or `git branch -D`. + +The human at the desk is not bound by this; `main` is theirs to rebase. + ## Plans are prose A design, a spec, or a plan is the body of an issue, written in prose. It is never a committed file. When @@ -61,7 +74,7 @@ that kind. | Kind | Paths | Rule | | --- | --- | --- | | Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | -| Bash | `*.sh`, `*.bash`, `hooks/*` | [rules/bash.md](rules/bash.md) | +| Bash | `*.sh`, `*.bash` | [rules/bash.md](rules/bash.md) | | Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | | YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | | JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | @@ -76,8 +89,9 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- Two hooks. `pre-rebase` refuses a rebase of the default branch. `pre-commit` lints the staged paths. `kit install` - points a clone's `core.hooksPath` at them, and every other clone runs `kit setup` once. +- `kit lint`, the body of a pre-commit hook: shellcheck, actionlint, and markdownlint-cli2 when the repository + configures it, over the staged paths, with a missing linter reported as a skip. `.kit/lefthook.yml` runs it; + a repository's `lefthook.yml` takes that with `extends` and adds hooks of its own. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the diff --git a/tree/.kit/lefthook.yml b/tree/.kit/lefthook.yml new file mode 100644 index 0000000..882d7cf --- /dev/null +++ b/tree/.kit/lefthook.yml @@ -0,0 +1,11 @@ +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# +# The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` and adds hooks and commands of its +# own; Lefthook merges them under the same hook names. Once per clone: lefthook install +# +# see: https://lefthook.dev + +pre-commit: + commands: + kit-lint: + run: kit lint {staged_files} diff --git a/tree/.kit/rules/bash.md b/tree/.kit/rules/bash.md index 86d45e8..92d176e 100644 --- a/tree/.kit/rules/bash.md +++ b/tree/.kit/rules/bash.md @@ -3,11 +3,10 @@ paths: - "**/*.sh" - "**/*.bash" - - "**/hooks/*" --- # Bash -Applies to `*.sh`, `*.bash`, `hooks/*`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in +Applies to `*.sh` and `*.bash`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in workflows. ## Shape From 000a240a5eeca99261015f13bde40131301ecd6b Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:25:15 -0700 Subject: [PATCH 023/108] #1 mise.desk.toml: a desk-only tool is pinned where only a desk reads it; one way into a bash module Lefthook moves to mise.desk.toml, read when MISE_ENV=desk, which a desk exports and a runner never sets; the Mac-written lock had no Linux entry for it and the runner has no use for it. lockfile_platforms makes the lock carry the runner's platform from now on. The loader's autoload stubs go: a verb says use , and that is the one mechanism. Co-Authored-By: Claude Fable 5.1 --- README.md | 13 +++++++++---- bash/1st.sh | 7 +------ mise.desk.lock | 32 ++++++++++++++++++++++++++++++++ mise.desk.toml | 7 +++++++ mise.lock | 11 ----------- mise.toml | 12 +++++++----- 6 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 mise.desk.lock create mode 100644 mise.desk.toml diff --git a/README.md b/README.md index 7dee207..58bb79b 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ through the rules table in `.kit/1st.md`. `kit target add` and `kit target remov - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. -- Pin `lefthook` in `mise.toml`, put `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` - once per clone. That runs `kit lint` over the staged paths before each commit, beside any hooks of your own. A - repository on another hook manager runs `kit lint` from it instead. +- Pin `lefthook` in `mise.desk.toml`, the file mise reads when `MISE_ENV=desk`, put + `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` once per clone. That runs + `kit lint` over the staged paths before each commit, beside any hooks of your own. A repository on another hook + manager runs `kit lint` from it instead. The machinery, the `kit` command and the bash behind its verbs, stays in the release where mise installed it; the repository carries the text an agent reads and the record of what it took. The kit needs bash 4.4 or later, git, @@ -56,10 +57,14 @@ Then, once per clone, with the key's id in `signing_key`: git config gpg.format openpgp git config user.signingkey "${signing_key}" git config commit.gpgsign true +export MISE_ENV=desk mise install lefthook install ``` +`MISE_ENV=desk` belongs in your shell profile: it makes mise read `mise.desk.toml`, the tools only a desk runs, +on top of `mise.toml`, the tools a desk and a CI runner share. A runner leaves it unset. + This repository carries the installed copy of its own kit, and `just ci` checks it. ## Getting started @@ -91,7 +96,7 @@ The installed copies are written by `bin/kit install` and not edited by hand: | `.kit/1st.md`, `.kit/rules/`, `.kit/lefthook.yml` | `tree/.kit/` | | `.github/workflows/kit-ci.yml` | `tree/.github/workflows/kit-ci.yml` | | `.claude/rules/kit-.md` | `targets/claude/.claude/rules/`, links into `tree/.kit/rules/` | -| `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here it carries only the kit's entries | +| `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here only the kit's entries | | `.kit/manifest` | the release version, the targets taken, and a digest per delivered path | After changing a file under `tree/` or `targets/`, run `just refresh`, which reinstalls the kit into this diff --git a/bash/1st.sh b/bash/1st.sh index 2632223..d0cd39c 100644 --- a/bash/1st.sh +++ b/bash/1st.sh @@ -3,8 +3,7 @@ # ---------------------------------------------------------------------------------------------------------------------- # # Sourced by every verb, bin/kit-.bash, which bin/kit dispatches to with kit_root exported. It defines functions only and sets no shell option, so the caller's options stand. A module under -# bash/ is loaded on first use with `use `, and each exported function has a stub here so a -# caller never has to load it by hand. +# bash/ is loaded with `use `, once, by the verb that needs it; there is no other way in. # # Every message goes to stderr, so stdout stays clean for data. Color is off when stderr is not a terminal or # NO_COLOR is set. Under GitHub Actions, warn and error also emit the matching workflow command. @@ -121,8 +120,4 @@ git:main-root() { git worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 } -gh:issue() { use gh && gh:issue "$@"; } -git:tidy() { use git && git:tidy "$@"; } -git:status() { use git && git:status "$@"; } - # ---------------------------------------------------------------------------------------------------------------------- diff --git a/mise.desk.lock b/mise.desk.lock new file mode 100644 index 0000000..dc8663d --- /dev/null +++ b/mise.desk.lock @@ -0,0 +1,32 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.lefthook]] +version = "2.2.2" +backend = "aqua:evilmartians/lefthook" +specifiers = ["2.2"] + +[tools.lefthook."platforms.linux-arm64"] +checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" +provenance = "github-attestations" + +[tools.lefthook."platforms.linux-x64"] +checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-arm64"] +checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-x64"] +checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" +provenance = "github-attestations" diff --git a/mise.desk.toml b/mise.desk.toml new file mode 100644 index 0000000..3a368bd --- /dev/null +++ b/mise.desk.toml @@ -0,0 +1,7 @@ +# mise.desk.toml - the tools only a desk runs; see: https://mise.jdx.dev/configuration/environments.html +# +# Read when MISE_ENV=desk, which a desk exports in its shell profile and a CI runner never sets. Lefthook runs the +# pre-commit hook, and a runner commits nothing. + +[tools] +lefthook = "2.2" diff --git a/mise.lock b/mise.lock index bcae003..e63f25e 100644 --- a/mise.lock +++ b/mise.lock @@ -213,17 +213,6 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" -[[tools.lefthook]] -version = "2.2.2" -backend = "aqua:evilmartians/lefthook" -specifiers = ["2.2"] - -[[tools."npm:bats"]] -version = "1.13.0" -aube = { path = ".mise/locks/npm-bats/1.13.0", digest = "sha256:b3795a7982457f07d0e04a547944558c3571830dd86994d282099c4c27082169" } -backend = "npm:bats" -specifiers = ["1.13"] - [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index 0d4b438..f0d2d40 100644 --- a/mise.toml +++ b/mise.toml @@ -1,11 +1,14 @@ -# mise.toml - the pinned toolchain; see: https://mise.jdx.dev/configuration.html +# mise.toml - the pinned toolchain a desk and a runner share; see: https://mise.jdx.dev/configuration.html # -# Every tool this repository runs, at one version, installed the same way on a desk (`mise install`) and on a -# runner (`jdx/mise-action`). `lockfile = true` records each pin's exact resolution in mise.lock. git and bash -# come from the machine. +# Every tool both a desk and a CI runner run, at one version, installed the same way by `mise install` and by +# `jdx/mise-action`. A tool only a desk runs is pinned in mise.desk.toml, which mise reads when MISE_ENV=desk, and +# a desk sets that in its shell profile; a runner leaves it unset. `lockfile = true` records each pin's exact +# resolution in mise.lock. git and bash come from the machine. [settings] lockfile = true +# A lock written on a desk carries the runner's platform too; see: https://mise.jdx.dev/dev-tools/mise-lock.html +lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] # bin/ on PATH, so `kit` is spelled here as a consumer spells it. [env] @@ -16,6 +19,5 @@ actionlint = "1.7" gh = "2.103" jq = "1.8" just = "1.59" -lefthook = "2.2" shellcheck = "0.11" bats = "1.13" From 374af01b49440dd3e2c6fcbc7a438672463d899e Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:26:20 -0700 Subject: [PATCH 024/108] #1 tests/rebase.bats: the bare origin starts on main whatever the machine's default Co-Authored-By: Claude Fable 5.1 --- tests/rebase.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rebase.bats b/tests/rebase.bats index 1aa67bb..571f696 100644 --- a/tests/rebase.bats +++ b/tests/rebase.bats @@ -6,7 +6,7 @@ setup() { kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) work=$(mktemp -d) cd "${work}" - git init --quiet --bare origin.git + git init --quiet --bare --initial-branch=main origin.git git clone --quiet origin.git desk 2>/dev/null cd desk git config user.name t && git config user.email t@example.com && git config commit.gpgsign false From a4d3fcbb3d28df19f633b35fae17e2e1366118cf Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:34:19 -0700 Subject: [PATCH 025/108] #1 .kit/lefthook.yml: the linters are declared where the hook manager reads them; kit lint runs that hook The fragment names which linter runs over which paths, a repository's lefthook.yml extends it, and kit lint is lefthook run pre-commit, over the staged paths or with --all-files over every tracked path, which is also the gate's spelling. Lefthook is back in mise.toml, since a runner runs the same linters; nothing is desk-only today, and the README says where a desk-only tool would go. Co-Authored-By: Claude Fable 5.1 --- .kit/1st.md | 11 ++++---- .kit/lefthook.yml | 14 +++++++--- .kit/manifest | 4 +-- README.md | 13 +++++----- bin/kit | 2 +- bin/kit-lint.bash | 59 +++++------------------------------------- justfile | 11 ++++---- lefthook.yml | 10 +++++-- mise.desk.lock | 32 ----------------------- mise.desk.toml | 7 ----- mise.toml | 5 ++-- tests/lint.bats | 31 +++++++++------------- tree/.kit/1st.md | 11 ++++---- tree/.kit/lefthook.yml | 14 +++++++--- 14 files changed, 75 insertions(+), 149 deletions(-) delete mode 100644 mise.desk.lock delete mode 100644 mise.desk.toml diff --git a/.kit/1st.md b/.kit/1st.md index 24e573c..4cc2154 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -89,13 +89,14 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- `kit lint`, the body of a pre-commit hook: shellcheck, actionlint, and markdownlint-cli2 when the repository - configures it, over the staged paths, with a missing linter reported as a skip. `.kit/lefthook.yml` runs it; - a repository's `lefthook.yml` takes that with `extends` and adds hooks of its own. +- `.kit/lefthook.yml`, which linter runs over which paths before a commit. A repository's `lefthook.yml` takes it + with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand, over the staged paths + or every path. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the - entries it writes; the repository may add its own. `kit target remove` takes a target back out. + `.claude/rules/kit-.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. + The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target + back out. - `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml index 882d7cf..aea7ecf 100644 --- a/.kit/lefthook.yml +++ b/.kit/lefthook.yml @@ -1,11 +1,17 @@ # Delivered by kit, not edited by hand. A change is a new release, then kit update. # -# The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` and adds hooks and commands of its -# own; Lefthook merges them under the same hook names. Once per clone: lefthook install +# Which linter runs over which paths. The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` +# and adds commands of its own under the same hook, or any other; Lefthook merges them by name, so a command named +# here is overridden by one of the same name there. `kit lint` runs this hook by hand, over the staged paths or, +# with --all-files, over every path. Once per clone: lefthook install # # see: https://lefthook.dev pre-commit: commands: - kit-lint: - run: kit lint {staged_files} + shellcheck: + glob: "*.{sh,bash}" + run: shellcheck --shell=bash --external-sources {staged_files} + actionlint: + glob: ".github/workflows/*.{yml,yaml}" + run: actionlint {staged_files} diff --git a/.kit/manifest b/.kit/manifest index be6b454..57409ac 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -9,8 +9,8 @@ targets claude 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json b9a38f19b571c70bb4ef7d3f25fd2b56ac0f6fc076cbcc8e318d22ad3ffe594f .github/workflows/kit-ci.yml -a39770f43e77f4e8ab7e97b5f56c5993e84f3e6778cff1e1e52296ce72b978c3 .kit/1st.md -e1282bfb261b23acad7d368ca8c2a3e47e728708eee16cb85e54a070f8b96381 .kit/lefthook.yml +f69ed575255ca7403c9daa1801058986d9e4845df581b05096c2aae6220d3d90 .kit/1st.md +4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md diff --git a/README.md b/README.md index 58bb79b..3c5c0c2 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ through the rules table in `.kit/1st.md`. `kit target add` and `kit target remov - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. -- Pin `lefthook` in `mise.desk.toml`, the file mise reads when `MISE_ENV=desk`, put - `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` once per clone. That runs - `kit lint` over the staged paths before each commit, beside any hooks of your own. A repository on another hook - manager runs `kit lint` from it instead. +- Pin `lefthook` in `mise.toml`, put `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` + once per clone. The fragment says which linter runs over which paths before a commit, and your `lefthook.yml` + adds commands of its own. `kit lint` runs the same hook by hand, and `kit lint --all-files` is the gate's + spelling of it. The machinery, the `kit` command and the bash behind its verbs, stays in the release where mise installed it; the repository carries the text an agent reads and the record of what it took. The kit needs bash 4.4 or later, git, @@ -57,13 +57,12 @@ Then, once per clone, with the key's id in `signing_key`: git config gpg.format openpgp git config user.signingkey "${signing_key}" git config commit.gpgsign true -export MISE_ENV=desk mise install lefthook install ``` -`MISE_ENV=desk` belongs in your shell profile: it makes mise read `mise.desk.toml`, the tools only a desk runs, -on top of `mise.toml`, the tools a desk and a CI runner share. A runner leaves it unset. +A tool only a desk runs, should one arrive, is pinned in `mise.desk.toml`, which mise reads on top of `mise.toml` +when `MISE_ENV=desk` is set; a desk exports that in its shell profile, and a runner leaves it unset. This repository carries the installed copy of its own kit, and `just ci` checks it. diff --git a/bin/kit b/bin/kit index 1d4e3bc..e7c90fa 100755 --- a/bin/kit +++ b/bin/kit @@ -33,7 +33,7 @@ usage: kit […] rebase [] rebase a worktree's branch onto the default branch and push it; never the default branch tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches status [--no-fetch] show every worktree with its branch, pull request, and state - lint […] lint the staged paths, or the paths given; a missing linter is a reported skip + lint [--all-files] run the pre-commit hook's linters by hand, over the staged paths or every path version print this release's version Each verb answers --help. diff --git a/bin/kit-lint.bash b/bin/kit-lint.bash index 62d85de..f07f516 100755 --- a/bin/kit-lint.bash +++ b/bin/kit-lint.bash @@ -1,11 +1,9 @@ #!/usr/bin/env bash -# kit-lint - lint the staged paths, or the paths given; a missing linter is a reported skip (kit verb, run through bin/kit) +# kit-lint - run the pre-commit hook by hand: the linters .kit/lefthook.yml and lefthook.yml declare (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# The body of a pre-commit hook, whatever runs the hook: shellcheck over bash, actionlint over workflows, and -# markdownlint-cli2 over markdown when the repository carries a config for it. A linter that is not on PATH is a -# reported skip, never a silent pass and never a refusal: lint is not the enforcement of record. The working -# tree's copy of each path is linted, so a partial add can be told about a line the commit does not carry. +# An alias for `lefthook run pre-commit`, so the linters a commit meets are the ones this runs, over the staged +# paths, or over every path with --all-files. The arguments are Lefthook's. set -o errexit -o nounset -o pipefail # shellcheck source=bash/1st.sh @@ -13,56 +11,11 @@ source "${kit_root:?run through bin/kit}/bash/1st.sh" if [[ "${1:-}" == --help ]] then - echo "usage: kit lint […] (no path: the staged paths)" >&2 + echo "usage: kit lint [--all-files] […]" >&2 exit 0 fi -paths=() -if (( $# > 0 )) -then - paths=("$@") -else - while IFS= read -r -d '' path - do - paths+=("${path}") - done < <(git diff --cached --name-only --diff-filter=ACMR -z) -fi - -bash_paths=() -workflow_paths=() -markdown_paths=() -for path in "${paths[@]}" -do - [[ -f "${path}" ]] || continue - case "${path}" in - (*.sh | *.bash) bash_paths+=("${path}") ;; - (.github/workflows/*.yml | .github/workflows/*.yaml) workflow_paths+=("${path}") ;; - (*.md) markdown_paths+=("${path}") - esac -done - -findings=0 -run-linter() { # … runs the linter over the paths, or reports the skip - local linter=$1 what=$2 - shift 2 - if ! command -v "${linter}" >/dev/null 2>&1 - then - warn "${linter} is not on PATH, so the ${what} were NOT linted" - return 0 - fi - "${linter}" "$@" || findings=$(( findings + 1 )) -} -(( ${#bash_paths[@]} == 0 )) || run-linter shellcheck "bash files" --shell=bash --external-sources "${bash_paths[@]}" -(( ${#workflow_paths[@]} == 0 )) || run-linter actionlint workflows "${workflow_paths[@]}" -if (( ${#markdown_paths[@]} > 0 )) && compgen -G ".markdownlint-cli2.*" >/dev/null -then - run-linter markdownlint-cli2 "markdown files" "${markdown_paths[@]}" -fi - -if (( findings > 0 )) -then - error "a linter found something in ${findings} group(s) of paths; each finding above names the file and line." - exit 1 -fi +guard:require tool:lefthook +exec lefthook run pre-commit "$@" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/justfile b/justfile index 17444db..005cf21 100644 --- a/justfile +++ b/justfile @@ -20,17 +20,16 @@ _: help set export := true -# The gate a pull request passes: shellcheck over every bash file, actionlint over every workflow, the bats -# suite over bin/kit, and bin/kit check over this repository's installed copy of its own kit. Every tool comes -# from mise.toml. +# The gate a pull request passes: the linters lefthook.yml declares, over every path, so a runner and a desk's +# pre-commit hook agree; the bats suite; and bin/kit check over this repository's installed copy of its own kit. +# Every tool comes from mise.toml. # # just ci -[doc("🚦 Run the gate: shellcheck, actionlint, bats, kit check")] +[doc("🚦 Run the gate: the linters over every path, bats, kit check")] ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - mise exec -- shellcheck --shell=bash --external-sources bin/kit bin/kit-*.bash bash/*.sh - mise exec -- actionlint + mise exec -- lefthook run pre-commit --all-files mise exec -- bats --print-output-on-failure tests/ bin/kit check echo "✅ ci passed" diff --git a/lefthook.yml b/lefthook.yml index 23ab42d..b1677f2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,8 +1,14 @@ # lefthook.yml - this repository's git hooks; see: https://lefthook.dev # ---------------------------------------------------------------------------------------------------------------------- # -# The kit's own hooks come from its installed fragment, the way a consumer takes them. Once per clone: -# lefthook install +# The kit's own linters come from its installed fragment, the way a consumer takes them, and the dispatcher, which +# carries no extension, is added here. Once per clone: lefthook install extends: - .kit/lefthook.yml + +pre-commit: + commands: + shellcheck-dispatcher: + glob: "bin/kit" + run: shellcheck --shell=bash --external-sources {staged_files} diff --git a/mise.desk.lock b/mise.desk.lock deleted file mode 100644 index dc8663d..0000000 --- a/mise.desk.lock +++ /dev/null @@ -1,32 +0,0 @@ -# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html - -lockfile_version = 3 - -[[tools.lefthook]] -version = "2.2.2" -backend = "aqua:evilmartians/lefthook" -specifiers = ["2.2"] - -[tools.lefthook."platforms.linux-arm64"] -checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" -provenance = "github-attestations" - -[tools.lefthook."platforms.linux-x64"] -checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" -provenance = "github-attestations" - -[tools.lefthook."platforms.macos-arm64"] -checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" -provenance = "github-attestations" - -[tools.lefthook."platforms.macos-x64"] -checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" -provenance = "github-attestations" diff --git a/mise.desk.toml b/mise.desk.toml deleted file mode 100644 index 3a368bd..0000000 --- a/mise.desk.toml +++ /dev/null @@ -1,7 +0,0 @@ -# mise.desk.toml - the tools only a desk runs; see: https://mise.jdx.dev/configuration/environments.html -# -# Read when MISE_ENV=desk, which a desk exports in its shell profile and a CI runner never sets. Lefthook runs the -# pre-commit hook, and a runner commits nothing. - -[tools] -lefthook = "2.2" diff --git a/mise.toml b/mise.toml index f0d2d40..dc9c9b7 100644 --- a/mise.toml +++ b/mise.toml @@ -1,8 +1,8 @@ # mise.toml - the pinned toolchain a desk and a runner share; see: https://mise.jdx.dev/configuration.html # # Every tool both a desk and a CI runner run, at one version, installed the same way by `mise install` and by -# `jdx/mise-action`. A tool only a desk runs is pinned in mise.desk.toml, which mise reads when MISE_ENV=desk, and -# a desk sets that in its shell profile; a runner leaves it unset. `lockfile = true` records each pin's exact +# `jdx/mise-action`. A tool only a desk runs would go in mise.desk.toml, which mise reads when MISE_ENV=desk; a desk +# sets that in its shell profile and a runner leaves it unset. Nothing is desk-only today. `lockfile = true` records each pin's exact # resolution in mise.lock. git and bash come from the machine. [settings] @@ -19,5 +19,6 @@ actionlint = "1.7" gh = "2.103" jq = "1.8" just = "1.59" +lefthook = "2.2" shellcheck = "0.11" bats = "1.13" diff --git a/tests/lint.bats b/tests/lint.bats index 06a3239..86cdc4b 100644 --- a/tests/lint.bats +++ b/tests/lint.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# lint.bats - kit lint over given paths and over the staged set, and its reported skip +# lint.bats - kit lint runs the linters the delivered fragment declares, over the staged paths or every path # ---------------------------------------------------------------------------------------------------------------------- setup() { @@ -7,6 +7,8 @@ setup() { repo=$(mktemp -d) cd "${repo}" git init --quiet --initial-branch=main + "${kit_checkout}/bin/kit" install >/dev/null + printf 'extends:\n - .kit/lefthook.yml\n' > lefthook.yml printf '#!/usr/bin/env bash\necho $x\n' > bad.sh printf '#!/usr/bin/env bash\necho "${x:-}"\n' > good.sh } @@ -15,33 +17,24 @@ teardown() { rm -rf "${repo}" } -@test "lint refuses a bash file shellcheck faults, and passes a clean one" { - run "${kit_checkout}/bin/kit" lint bad.sh - [ "$status" -eq 1 ] - [[ "$output" == *"SC2086"* ]] - run "${kit_checkout}/bin/kit" lint good.sh - [ "$status" -eq 0 ] -} - -@test "lint with no path reads the staged set" { +@test "lint refuses a staged bash file shellcheck faults, and passes a clean one" { git add bad.sh run "${kit_checkout}/bin/kit" lint [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] git reset --quiet && git add good.sh run "${kit_checkout}/bin/kit" lint [ "$status" -eq 0 ] } -@test "lint reports a skip when the linter is not on PATH" { - # a PATH of one directory, carrying only what the verb itself runs, and no shellcheck - mkdir only - for tool in bash git env cat sed dirname - do - ln -s "$(command -v "${tool}")" "only/${tool}" - done - run env PATH="${PWD}/only" "${kit_checkout}/bin/kit" lint bad.sh +@test "lint --all-files reads every tracked path, where lint alone reads only the staged ones" { + git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false add bad.sh + git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false commit --quiet --no-verify --message bad + run "${kit_checkout}/bin/kit" lint [ "$status" -eq 0 ] - [[ "$output" == *"NOT linted"* ]] + run "${kit_checkout}/bin/kit" lint --all-files + [ "$status" -eq 1 ] + [[ "$output" == *"bad.sh"* ]] } # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index 24e573c..4cc2154 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -89,13 +89,14 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- `kit lint`, the body of a pre-commit hook: shellcheck, actionlint, and markdownlint-cli2 when the repository - configures it, over the staged paths, with a missing linter reported as a skip. `.kit/lefthook.yml` runs it; - a repository's `lefthook.yml` takes that with `extends` and adds hooks of its own. +- `.kit/lefthook.yml`, which linter runs over which paths before a commit. A repository's `lefthook.yml` takes it + with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand, over the staged paths + or every path. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-.md`, copies of the rules, and merges six deny rules into `.claude/settings.json`. The kit owns only the - entries it writes; the repository may add its own. `kit target remove` takes a target back out. + `.claude/rules/kit-.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. + The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target + back out. - `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. diff --git a/tree/.kit/lefthook.yml b/tree/.kit/lefthook.yml index 882d7cf..aea7ecf 100644 --- a/tree/.kit/lefthook.yml +++ b/tree/.kit/lefthook.yml @@ -1,11 +1,17 @@ # Delivered by kit, not edited by hand. A change is a new release, then kit update. # -# The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` and adds hooks and commands of its -# own; Lefthook merges them under the same hook names. Once per clone: lefthook install +# Which linter runs over which paths. The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` +# and adds commands of its own under the same hook, or any other; Lefthook merges them by name, so a command named +# here is overridden by one of the same name there. `kit lint` runs this hook by hand, over the staged paths or, +# with --all-files, over every path. Once per clone: lefthook install # # see: https://lefthook.dev pre-commit: commands: - kit-lint: - run: kit lint {staged_files} + shellcheck: + glob: "*.{sh,bash}" + run: shellcheck --shell=bash --external-sources {staged_files} + actionlint: + glob: ".github/workflows/*.{yml,yaml}" + run: actionlint {staged_files} From 188ee3ef2b999ea5c5cfc9fa14c20264e31899d9 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:35:03 -0700 Subject: [PATCH 026/108] #1 mise.lock: lefthook, for every platform the lock covers Co-Authored-By: Claude Fable 5.1 --- mise.lock | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/mise.lock b/mise.lock index e63f25e..74927b8 100644 --- a/mise.lock +++ b/mise.lock @@ -213,6 +213,35 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" +[[tools.lefthook]] +version = "2.2.2" +backend = "aqua:evilmartians/lefthook" +specifiers = ["2.2"] + +[tools.lefthook."platforms.linux-arm64"] +checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" +provenance = "github-attestations" + +[tools.lefthook."platforms.linux-x64"] +checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-arm64"] +checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-x64"] +checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" +provenance = "github-attestations" + [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" From 96d3fcadb5934f7badf06a0f43989cccd6e2e722 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:39:06 -0700 Subject: [PATCH 027/108] #1 bin: verbs are kit-.sh, the modules sit beside them, and rebase lives in the git module bash/ is gone: a module is a sourced .sh in bin/, loaded with use; a verb is an executable kit-.sh there. The layout module is release.sh, so nothing is named kit.sh beside kit. git:default-branch and git:main-root move into git.sh, and git:rebase joins them, so kit rebase is one call like tidy and status. Each verb's --help is a plain line. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 5 +- AGENTS.md | 2 +- README.md | 4 +- {bash => bin}/1st.sh | 31 +++------ {bash => bin}/gh.sh | 1 + {bash => bin}/git.sh | 47 +++++++++++++- bin/kit | 6 +- bin/{kit-check.bash => kit-check.sh} | 22 +++---- bin/{kit-install.bash => kit-install.sh} | 20 +++--- bin/{kit-issue.bash => kit-issue.sh} | 8 +-- bin/{kit-lint.bash => kit-lint.sh} | 4 +- bin/kit-rebase.bash | 47 -------------- bin/kit-rebase.sh | 21 ++++++ bin/{kit-status.bash => kit-status.sh} | 8 +-- bin/{kit-target.bash => kit-target.sh} | 30 ++++----- bin/{kit-tidy.bash => kit-tidy.sh} | 8 +-- bin/{kit-update.bash => kit-update.sh} | 34 +++++----- bin/{kit-version.bash => kit-version.sh} | 0 bash/kit.sh => bin/release.sh | 82 ++++++++++++------------ tests/kit.bats | 2 +- tests/release.bats | 4 +- 21 files changed, 196 insertions(+), 190 deletions(-) rename {bash => bin}/1st.sh (70%) rename {bash => bin}/gh.sh (99%) rename {bash => bin}/git.sh (79%) rename bin/{kit-check.bash => kit-check.sh} (77%) rename bin/{kit-install.bash => kit-install.sh} (69%) rename bin/{kit-issue.bash => kit-issue.sh} (70%) rename bin/{kit-lint.bash => kit-lint.sh} (90%) delete mode 100755 bin/kit-rebase.bash create mode 100755 bin/kit-rebase.sh rename bin/{kit-status.bash => kit-status.sh} (70%) rename bin/{kit-target.bash => kit-target.sh} (75%) rename bin/{kit-tidy.bash => kit-tidy.sh} (70%) rename bin/{kit-update.bash => kit-update.sh} (75%) rename bin/{kit-version.bash => kit-version.sh} (100%) rename bash/kit.sh => bin/release.sh (58%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 707dd83..85bd9cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,7 @@ # release.yml - a tag v on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, bash/, and -# version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., +# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, and version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., # is tagged on main (any commit on main, not only its head). A pre-release, the same with a -, is tagged on # a commit that is not on main, and is published as a GitHub pre-release. Either way the file version at the tagged # commit is the version without the suffix, the one under development, and the packed kit carries the tag's full @@ -78,7 +77,7 @@ jobs: version=${GITHUB_REF_NAME#v} staging=$(mktemp --directory) mkdir "${staging}/kit-${version}" - cp --recursive --no-dereference bin tree targets bash version "${staging}/kit-${version}/" + cp --recursive --no-dereference bin tree targets version "${staging}/kit-${version}/" # the packed kit carries the tag's full version, suffix included; the repository file carries the base printf '%s\n' "${version}" > "${staging}/kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" diff --git a/AGENTS.md b/AGENTS.md index 0f814a0..34c98a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its ## This repository's own rules -- The release is `bin/`, `tree/`, `targets/`, `bash/`, and `version`. Everything in it is written for a +- The release is `bin/`, `tree/`, `targets/`, and `version`. Everything in it is written for a consumer: no file names a consumer, a person, or another repository as the source of a rule. - `version` is the version under development, with no suffix. A release is the tag `v` on a commit on `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any diff --git a/README.md b/README.md index 3c5c0c2..002145e 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what every consumer gets, mirrored at its root; `targets//` is what a consumer taking that target gets, mirrored -the same way; `bin/`, `bash/`, and `version` are the machinery: `bin/kit` dispatches to one `bin/kit-.bash` -per verb, and `bash/` holds the modules they source. A symlink under `targets/` is +the same way; `bin/` and `version` are the machinery: `bin/kit` dispatches to one executable `bin/kit-.sh` +per verb, and the verbs source the modules beside them, `1st.sh`, `gh.sh`, `git.sh`, and `release.sh`. A symlink under `targets/` is delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. The installed copies are written by `bin/kit install` and not edited by hand: diff --git a/bash/1st.sh b/bin/1st.sh similarity index 70% rename from bash/1st.sh rename to bin/1st.sh index d0cd39c..8a8e904 100644 --- a/bash/1st.sh +++ b/bin/1st.sh @@ -2,30 +2,31 @@ # 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# Sourced by every verb, bin/kit-.bash, which bin/kit dispatches to with kit_root exported. It defines functions only and sets no shell option, so the caller's options stand. A module under -# bash/ is loaded with `use `, once, by the verb that needs it; there is no other way in. +# Sourced by every verb, bin/kit-.sh, which bin/kit dispatches to with kit_root exported. It defines functions only and sets no shell option, so the caller's options stand. A module is a +# sourced .sh beside this file, loaded with `use `, once, by the verb or module that needs it; there +# is no other way in. A verb is an executable kit-.sh beside it. # # Every message goes to stderr, so stdout stays clean for data. Color is off when stderr is not a terminal or # NO_COLOR is set. Under GitHub Actions, warn and error also emit the matching workflow command. -kit_bash_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -declare -gr kit_bash_dir +kit_bin_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +declare -gr kit_bin_dir declare -Ag kit_modules=([1st]=1) -# use … loads bash/.sh once. An unknown module is a bug in the caller, so it is fatal. +# use … loads bin/.sh once. An unknown module is a bug in the caller, so it is fatal. use() { local module for module in "$@" do [[ ${kit_modules[${module}]+x} ]] && continue - if [[ ! -f "${kit_bash_dir}/${module}.sh" ]] + if [[ ! -f "${kit_bin_dir}/${module}.sh" ]] then - error "no such kit bash module: ${module} (looked in ${kit_bash_dir})" + error "no such kit bash module: ${module} (looked in ${kit_bin_dir})" return 2 fi kit_modules[${module}]=1 # shellcheck disable=SC1090 # the path is computed from the module name - source "${kit_bash_dir}/${module}.sh" + source "${kit_bin_dir}/${module}.sh" done } @@ -106,18 +107,4 @@ guard:require() { done } -# git:default-branch prints the default branch: origin/HEAD, with main as the fallback. -git:default-branch() { - local branch - branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) - [[ -n "${branch}" ]] || branch=main - printf '%s\n' "${branch}" -} - -# git:main-root prints the root of the main checkout, the first entry of the worktree list. worktrees/ lives -# under it whether the caller stands in the main checkout or in a linked worktree. -git:main-root() { - git worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 -} - # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bash/gh.sh b/bin/gh.sh similarity index 99% rename from bash/gh.sh rename to bin/gh.sh index a4ef4b3..42ff316 100644 --- a/bash/gh.sh +++ b/bin/gh.sh @@ -21,6 +21,7 @@ # lost. gh:issue() { + use git if (( $# == 0 )) then if [[ ! -t 0 ]] diff --git a/bash/git.sh b/bin/git.sh similarity index 79% rename from bash/git.sh rename to bin/git.sh index a8544b8..9feac52 100644 --- a/bash/git.sh +++ b/bin/git.sh @@ -1,5 +1,5 @@ # shellcheck shell=bash -# git.sh - the picture of the worktrees, and the sweep that returns a checkout to pristine (sourced module) +# git.sh - the default branch and the main root, the picture of the worktrees, the sweep, and the rebase (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # # git:status [--no-fetch] renders the main checkout on one line and one block per worktree under worktrees/ @@ -10,6 +10,24 @@ # the worktree bookkeeping, and deletes every local branch whose upstream is gone. It never touches the default # branch, a branch checked out in a live worktree, a dirty worktree, or the worktree it runs in. --dry-run # prints what a sweep would do and changes nothing. +# +# git:rebase [] rebases the branch checked out at , the current checkout by default, onto +# origin/ and pushes it with --force-with-lease. It refuses the default branch, the human's desk; on a +# conflict it aborts and leaves the worktree as it was. + +# git:default-branch prints the default branch: origin/HEAD, with main as the fallback. +git:default-branch() { + local branch + branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) + [[ -n "${branch}" ]] || branch=main + printf '%s\n' "${branch}" +} + +# git:main-root prints the root of the main checkout, the first entry of the worktree list. worktrees/ lives +# under it whether the caller stands in the main checkout or in a linked worktree. +git:main-root() { + git worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 +} # git:pr-summary prints "\t" for the open pull request on , or nothing. git:pr-summary() { @@ -259,4 +277,31 @@ git:tidy() { fi } +git:rebase() { + local worktree=${1:-.} branch default_branch + branch=$(git -C "${worktree}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) + if [[ -z "${branch}" ]] + then + error "${worktree} has no branch checked out, so there is nothing to rebase" + return 1 + fi + default_branch=$(git -C "${worktree}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) + [[ -n "${default_branch}" ]] || default_branch=main + if [[ "${branch}" == "${default_branch}" ]] + then + error "refusing to rebase ${branch}: it is the default branch, the human's desk. It advances by fast-forward: git pull --ff-only" + return 1 + fi + section "kit rebase ${branch} onto origin/${default_branch}" + git -C "${worktree}" fetch --quiet --prune origin + if ! git -C "${worktree}" rebase --quiet "origin/${default_branch}" + then + git -C "${worktree}" rebase --abort + error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was. Resolve by hand." + return 1 + fi + git -C "${worktree}" push --quiet --force-with-lease + ok "${branch} is rebased onto origin/${default_branch} and pushed." +} + # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit b/bin/kit index e7c90fa..d786a21 100755 --- a/bin/kit +++ b/bin/kit @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit - the kit's command: each verb is the executable bin/kit-.bash beside it (executed entrypoint) +# kit - the kit's command: each verb is the executable bin/kit-.sh beside it (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # # The release is the directory above bin/. This file resolves it, exports it as kit_root, and hands the verb its @@ -40,13 +40,13 @@ Each verb answers --help. USAGE ;; (*) - if [[ ! -x "${kit_root}/bin/kit-${verb}.bash" ]] + if [[ ! -x "${kit_root}/bin/kit-${verb}.sh" ]] then echo "kit: unknown verb '${verb}'; run: kit help" >&2 exit 2 fi shift - exec "${kit_root}/bin/kit-${verb}.bash" "$@" + exec "${kit_root}/bin/kit-${verb}.sh" "$@" esac # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-check.bash b/bin/kit-check.sh similarity index 77% rename from bin/kit-check.bash rename to bin/kit-check.sh index 6225b80..f549188 100755 --- a/bin/kit-check.bash +++ b/bin/kit-check.sh @@ -2,12 +2,12 @@ # kit-check - fail when a delivered file differs from the pinned release (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/kit.sh +# see: bin/release.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" -use kit +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release if [[ "${1:-}" == --help ]] then @@ -15,9 +15,9 @@ then exit 0 fi -kit:require-manifest -read -r -a targets <<<"$(kit:manifest-targets)" -version=$(kit:manifest-version) +release:require-manifest +read -r -a targets <<<"$(release:manifest-targets)" +version=$(release:manifest-version) findings=0 if [[ "${version}" != "$(cat "${kit_root}/version")" ]] then @@ -26,18 +26,18 @@ then fi while IFS= read -r path do - src=$(kit:source-of "${kit_root}" "${path}" "${targets[@]}") + src=$(release:source-of "${kit_root}" "${path}" "${targets[@]}") if [[ ! -f "${path}" ]] then error "${path} is missing" findings=$(( findings + 1 )) - elif kit:merged "${path}" + elif release:merged "${path}" then if ! command -v jq >/dev/null 2>&1 then error "run: mise install (jq is not on PATH, so ${path} could not be read)" findings=$(( findings + 1 )) - elif ! kit:json-holds "${path}" "${src}" + elif ! release:json-holds "${path}" "${src}" then error "${path} is missing an entry the kit writes; run: kit update" findings=$(( findings + 1 )) @@ -47,7 +47,7 @@ do error "${path} differs from the pinned release" findings=$(( findings + 1 )) fi -done < <(kit:delivered "${kit_root}" "${targets[@]}") +done < <(release:delivered "${kit_root}" "${targets[@]}") if (( findings > 0 )) then error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." diff --git a/bin/kit-install.bash b/bin/kit-install.sh similarity index 69% rename from bin/kit-install.bash rename to bin/kit-install.sh index 5bbfa87..7f83e57 100755 --- a/bin/kit-install.bash +++ b/bin/kit-install.sh @@ -2,12 +2,12 @@ # kit-install - mirror the release into the repository, take the named targets, and write .kit/manifest (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/kit.sh +# see: bin/release.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" -use kit +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release if [[ "${1:-}" == --help ]] then @@ -16,19 +16,19 @@ then fi guard:require tool:jq tool:git -kit:require-targets "$@" +release:require-targets "$@" if [[ -f .kit/manifest ]] then - error "run: kit update (this repository already carries kit $(kit:manifest-version))" + error "run: kit update (this repository already carries kit $(release:manifest-version))" exit 1 fi section "kit install $(cat "${kit_root}/version")${*:+, targets: $*}" while IFS= read -r path do step "${path}" - kit:place "$(kit:source-of "${kit_root}" "${path}" "$@")" "${path}" -done < <(kit:delivered "${kit_root}" "$@") -kit:write-manifest "$@" -kit:finish "$@" + release:place "$(release:source-of "${kit_root}" "${path}" "$@")" "${path}" +done < <(release:delivered "${kit_root}" "$@") +release:write-manifest "$@" +release:finish "$@" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-issue.bash b/bin/kit-issue.sh similarity index 70% rename from bin/kit-issue.bash rename to bin/kit-issue.sh index 422d993..90edef1 100755 --- a/bin/kit-issue.bash +++ b/bin/kit-issue.sh @@ -2,16 +2,16 @@ # kit-issue - start or resume an issue: linked branch, worktree, start commit, draft pull request (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/gh.sh +# see: bin/gh.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" use gh if [[ "${1:-}" == --help ]] then - sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + echo "usage: kit issue […] (no number, at a terminal: ask for a title and create the issue)" >&2 exit 0 fi diff --git a/bin/kit-lint.bash b/bin/kit-lint.sh similarity index 90% rename from bin/kit-lint.bash rename to bin/kit-lint.sh index f07f516..c4a04aa 100755 --- a/bin/kit-lint.bash +++ b/bin/kit-lint.sh @@ -6,8 +6,8 @@ # paths, or over every path with --all-files. The arguments are Lefthook's. set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" if [[ "${1:-}" == --help ]] then diff --git a/bin/kit-rebase.bash b/bin/kit-rebase.bash deleted file mode 100755 index ce17251..0000000 --- a/bin/kit-rebase.bash +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# kit-rebase - rebase a worktree's branch onto the default branch and push it; never the default branch (kit verb, run through bin/kit) -# ---------------------------------------------------------------------------------------------------------------------- -# -# The default branch is the human's desk and is never rebased by an agent: this verb refuses it, and 1st.md says an -# agent spells a rebase this way and no other. Any other branch is rebased onto origin/ and pushed with -# --force-with-lease, which fails rather than overwrite a push that landed since. On a conflict the rebase is -# aborted and the worktree left as it was. - -set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" - -if [[ "${1:-}" == --help ]] -then - echo "usage: kit rebase [] (no path: the checkout this runs in)" >&2 - exit 0 -fi - -guard:require tool:git -worktree=${1:-.} -branch=$(git -C "${worktree}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) -if [[ -z "${branch}" ]] -then - error "${worktree} has no branch checked out, so there is nothing to rebase" - exit 1 -fi -default_branch=$(git -C "${worktree}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) -[[ -n "${default_branch}" ]] || default_branch=main -if [[ "${branch}" == "${default_branch}" ]] -then - error "refusing to rebase ${branch}: it is the default branch, the human's desk. It advances by fast-forward: git pull --ff-only" - exit 1 -fi - -section "kit rebase ${branch} onto origin/${default_branch}" -git -C "${worktree}" fetch --quiet --prune origin -if ! git -C "${worktree}" rebase --quiet "origin/${default_branch}" -then - git -C "${worktree}" rebase --abort - error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was. Resolve by hand." - exit 1 -fi -git -C "${worktree}" push --quiet --force-with-lease -ok "${branch} is rebased onto origin/${default_branch} and pushed." - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-rebase.sh b/bin/kit-rebase.sh new file mode 100755 index 0000000..4cb80e6 --- /dev/null +++ b/bin/kit-rebase.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# kit-rebase - rebase a worktree's branch onto the default branch and push it; never the default branch (kit verb, run through bin/kit) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bin/git.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use git + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit rebase [] (no path: the checkout this runs in)" >&2 + exit 0 +fi + +guard:require tool:git +git:rebase "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-status.bash b/bin/kit-status.sh similarity index 70% rename from bin/kit-status.bash rename to bin/kit-status.sh index 2e48cb2..2345130 100755 --- a/bin/kit-status.bash +++ b/bin/kit-status.sh @@ -2,16 +2,16 @@ # kit-status - show every worktree with its branch, pull request, and state (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/git.sh +# see: bin/git.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" use git if [[ "${1:-}" == --help ]] then - sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + echo "usage: kit status [--no-fetch]" >&2 exit 0 fi diff --git a/bin/kit-target.bash b/bin/kit-target.sh similarity index 75% rename from bin/kit-target.bash rename to bin/kit-target.sh index 05a3aa2..1281aab 100755 --- a/bin/kit-target.bash +++ b/bin/kit-target.sh @@ -2,12 +2,12 @@ # kit-target - list, take, or drop the targets this repository takes (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/kit.sh +# see: bin/release.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" -use kit +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release usage() { echo "usage: kit target list | kit target add … | kit target remove …" >&2; } @@ -18,8 +18,8 @@ case "${verb}" in (list | add | remove) ;; (*) usage; exit 2 esac -kit:require-manifest -read -r -a targets <<<"$(kit:manifest-targets)" +release:require-manifest +read -r -a targets <<<"$(release:manifest-targets)" case "${verb}" in (list) @@ -28,7 +28,7 @@ case "${verb}" in (add) (( $# > 0 )) || { usage; exit 2; } guard:require tool:jq tool:git - kit:require-targets "$@" + release:require-targets "$@" for t in "$@" do if [[ " ${targets[*]} " == *" ${t} "* ]] @@ -40,17 +40,17 @@ case "${verb}" in while IFS= read -r path do step "${path}" - kit:place "${kit_root}/targets/${t}/${path}" "${path}" - done < <(kit:target-only "${t}") + release:place "${kit_root}/targets/${t}/${path}" "${path}" + done < <(release:target-only "${t}") targets+=("${t}") done - kit:write-manifest "${targets[@]}" + release:write-manifest "${targets[@]}" ok "targets: ${targets[*]}" ;; (remove) (( $# > 0 )) || { usage; exit 2; } guard:require tool:jq tool:git - kit:require-targets "$@" + release:require-targets "$@" for t in "$@" do if [[ " ${targets[*]} " != *" ${t} "* ]] @@ -62,17 +62,17 @@ case "${verb}" in while IFS= read -r path do src="${kit_root}/targets/${t}/${path}" - if kit:merged "${path}" + if release:merged "${path}" then [[ -f "${path}" ]] || continue step "${path}: the kit's entries taken out" # shellcheck disable=SC2016 # a jq filter, with jq variables - kit:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" + release:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" else step "removing ${path}" rm -f "${path}" fi - done < <(kit:target-only "${t}") + done < <(release:target-only "${t}") kept=() for path in "${targets[@]}" do @@ -80,7 +80,7 @@ case "${verb}" in done targets=("${kept[@]}") done - kit:write-manifest "${targets[@]}" + release:write-manifest "${targets[@]}" ok "targets: ${targets[*]:-none}" esac diff --git a/bin/kit-tidy.bash b/bin/kit-tidy.sh similarity index 70% rename from bin/kit-tidy.bash rename to bin/kit-tidy.sh index fd2e5e1..2ac2bea 100755 --- a/bin/kit-tidy.bash +++ b/bin/kit-tidy.sh @@ -2,16 +2,16 @@ # kit-tidy - remove worktrees whose pull request is merged or closed, and gone branches (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bash/git.sh +# see: bin/git.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" use git if [[ "${1:-}" == --help ]] then - sed -n '2s/^# kit-[a-z]* - \(.*\) (kit verb, run through bin/kit)$/\1/p' "${BASH_SOURCE[0]}" >&2 + echo "usage: kit tidy [--dry-run]" >&2 exit 0 fi diff --git a/bin/kit-update.bash b/bin/kit-update.sh similarity index 75% rename from bin/kit-update.bash rename to bin/kit-update.sh index 9e0a4bb..1580695 100755 --- a/bin/kit-update.bash +++ b/bin/kit-update.sh @@ -6,12 +6,12 @@ # content; it is replaced. An edited one is merged with the previous release as the base: KIT_BASE= names # that release's root, otherwise mise knows where it installed it. # -# see: bash/kit.sh +# see: bin/release.sh set -o errexit -o nounset -o pipefail -# shellcheck source=bash/1st.sh -source "${kit_root:?run through bin/kit}/bash/1st.sh" -use kit +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release if [[ "${1:-}" == --help ]] then @@ -20,9 +20,9 @@ then fi guard:require tool:jq tool:git -kit:require-manifest -read -r -a targets <<<"$(kit:manifest-targets)" -previous=$(kit:manifest-version) +release:require-manifest +read -r -a targets <<<"$(release:manifest-targets)" +previous=$(release:manifest-version) current=$(cat "${kit_root}/version") section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" @@ -42,26 +42,26 @@ fi conflicts=0 while IFS= read -r path do - src=$(kit:source-of "${kit_root}" "${path}" "${targets[@]}") + src=$(release:source-of "${kit_root}" "${path}" "${targets[@]}") base="" - [[ -n "${base_root}" ]] && base=$(kit:source-of "${base_root}" "${path}" "${targets[@]}" || true) - if kit:merged "${path}" + [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${targets[@]}" || true) + if release:merged "${path}" then step "${path}: merged by key" - kit:place "${src}" "${path}" "${base}" + release:place "${src}" "${path}" "${base}" continue fi if [[ ! -f "${path}" ]] then step "${path}: missing, written" - kit:place "${src}" "${path}" + release:place "${src}" "${path}" continue fi recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) - if [[ -z "${recorded}" || "${recorded}" == "$(kit:digest < "${path}")" ]] + if [[ -z "${recorded}" || "${recorded}" == "$(release:digest < "${path}")" ]] then step "${path}: unchanged since ${previous}, replaced" - kit:place "${src}" "${path}" + release:place "${src}" "${path}" continue fi if [[ -z "${base}" ]] @@ -76,14 +76,14 @@ do warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" conflicts=$(( conflicts + 1 )) fi -done < <(kit:delivered "${kit_root}" "${targets[@]}") +done < <(release:delivered "${kit_root}" "${targets[@]}") -kit:write-manifest "${targets[@]}" +release:write-manifest "${targets[@]}" if (( conflicts > 0 )) then error "${conflicts} file(s) need a hand; see above." exit 1 fi -kit:finish "${targets[@]}" +release:finish "${targets[@]}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-version.bash b/bin/kit-version.sh similarity index 100% rename from bin/kit-version.bash rename to bin/kit-version.sh diff --git a/bash/kit.sh b/bin/release.sh similarity index 58% rename from bash/kit.sh rename to bin/release.sh index 6dfc7ae..16c8a15 100644 --- a/bash/kit.sh +++ b/bin/release.sh @@ -1,7 +1,7 @@ # shellcheck shell=bash # shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose # shellcheck disable=SC2154 # kit_root is exported by bin/kit -# kit.sh - the release's layout, the one merge rule, and the manifest (sourced module) +# release.sh - the release's layout, the one merge rule, and the manifest (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # # A release mirrors its tree/ at the repository root, and targets// for each target taken; a symlink in @@ -11,8 +11,8 @@ # # Every function reads the release from kit_root, exported by bin/kit. -# kit:layers … prints the directories mirrored at the repository root, tree first. -kit:layers() { +# release:layers … prints the directories mirrored at the repository root, tree first. +release:layers() { local root=$1 t shift printf '%s\n' "${root}/tree" @@ -22,54 +22,54 @@ kit:layers() { done } -# kit:delivered … prints every repository path the release at delivers for those targets. -kit:delivered() { +# release:delivered … prints every repository path the release at delivers for those targets. +release:delivered() { local layer while IFS= read -r layer do (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@') - done < <(kit:layers "$@") | sort --unique + done < <(release:layers "$@") | sort --unique } -# kit:source-of … prints the release file behind a repository path, the last layer winning. -kit:source-of() { +# release:source-of … prints the release file behind a repository path, the last layer winning. +release:source-of() { local root=$1 path=$2 layer found="" shift 2 while IFS= read -r layer do [[ -e "${layer}/${path}" ]] && found="${layer}/${path}" - done < <(kit:layers "${root}" "$@") + done < <(release:layers "${root}" "$@") [[ -n "${found}" ]] && printf '%s\n' "${found}" } -# kit:target-only prints the paths the target delivers that tree/ does not. -kit:target-only() { - comm -13 <(kit:delivered "${kit_root}") <(kit:delivered "${kit_root}" "$1") +# release:target-only prints the paths the target delivers that tree/ does not. +release:target-only() { + comm -13 <(release:delivered "${kit_root}") <(release:delivered "${kit_root}" "$1") } -# kit:known-targets prints the targets this release carries. -kit:known-targets() { +# release:known-targets prints the targets this release carries. +release:known-targets() { (cd "${kit_root}/targets" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) } -# kit:require-targets … refuses a target this release does not carry, naming the ones it does. -kit:require-targets() { +# release:require-targets … refuses a target this release does not carry, naming the ones it does. +release:require-targets() { local t for t in "$@" do if [[ ! -d "${kit_root}/targets/${t}" ]] then - error "'${t}' is not a target this release knows; the targets are: $(kit:known-targets | paste -s -d ' ' -)" + error "'${t}' is not a target this release knows; the targets are: $(release:known-targets | paste -s -d ' ' -)" return 2 fi done } -kit:merged() { [[ "$1" == *.json ]]; } +release:merged() { [[ "$1" == *.json ]]; } # The parameters are values ($), not filters: a filter parameter is re-read against the input at each use, which # inside the recursion is the wrong level of the object. -kit_jq=' +release_jq=' def merge($cur; $base; $new): if ($new | type) == "object" then reduce ($new | keys[]) as $k ($cur // {}; .[$k] = merge(($cur // {})[$k]; ($base // {})[$k]; $new[$k])) @@ -88,23 +88,23 @@ kit_jq=' else $cur == $new end; ' -# kit:json-write […] rewrites the JSON at through , making it if missing. -kit:json-write() { +# release:json-write […] rewrites the JSON at through , making it if missing. +release:json-write() { local path=$1 filter=$2 shift 2 mkdir -p "$(dirname "${path}")" [[ -f "${path}" ]] || printf '{}\n' > "${path}" - jq --indent 2 "${kit_jq} ${filter}" "$@" "${path}" > "${path}.kit-new" + jq --indent 2 "${release_jq} ${filter}" "$@" "${path}" > "${path}.kit-new" mv "${path}.kit-new" "${path}" } -# kit:json-holds is true when the JSON at carries every entry the release's writes. -kit:json-holds() { - jq --exit-status "${kit_jq} holds(.; \$new[0])" --slurpfile new "$2" "$1" >/dev/null +# release:json-holds is true when the JSON at carries every entry the release's writes. +release:json-holds() { + jq --exit-status "${release_jq} holds(.; \$new[0])" --slurpfile new "$2" "$1" >/dev/null } -# kit:digest prints the sha256 of stdin, by whichever tool the machine carries. -kit:digest() { +# release:digest prints the sha256 of stdin, by whichever tool the machine carries. +release:digest() { if command -v sha256sum >/dev/null 2>&1 then sha256sum | cut -d ' ' -f 1 @@ -113,12 +113,12 @@ kit:digest() { fi } -# kit:place [] puts the release's content at : a merge for .json, a copy otherwise. -kit:place() { +# release:place [] puts the release's content at : a merge for .json, a copy otherwise. +release:place() { local src=$1 path=$2 base=${3:-/dev/null} - if kit:merged "${path}" + if release:merged "${path}" then - kit:json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" + release:json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" else mkdir -p "$(dirname "${path}")" cat "${src}" > "${path}.kit-new" @@ -126,8 +126,8 @@ kit:place() { fi } -# kit:write-manifest … records the version, the targets, and the release's digest for every delivered path. -kit:write-manifest() { +# release:write-manifest … records the version, the targets, and the release's digest for every delivered path. +release:write-manifest() { local path { printf '# .kit/manifest - written by kit, not by hand\n' @@ -135,16 +135,16 @@ kit:write-manifest() { printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path do - printf '%s %s\n' "$(kit:digest < "$(kit:source-of "${kit_root}" "${path}" "$@")")" "${path}" - done < <(kit:delivered "${kit_root}" "$@") + printf '%s %s\n' "$(release:digest < "$(release:source-of "${kit_root}" "${path}" "$@")")" "${path}" + done < <(release:delivered "${kit_root}" "$@") } > .kit/manifest } -kit:manifest-version() { sed -n 's/^version //p' .kit/manifest; } -kit:manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } +release:manifest-version() { sed -n 's/^version //p' .kit/manifest; } +release:manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } -# kit:require-manifest refuses to go on in a repository that never took the kit. -kit:require-manifest() { +# release:require-manifest refuses to go on in a repository that never took the kit. +release:require-manifest() { if [[ ! -f .kit/manifest ]] then error "run: kit install (this repository carries no .kit/manifest)" @@ -152,8 +152,8 @@ kit:require-manifest() { fi } -# kit:finish … reports a completed install or update. -kit:finish() { +# release:finish … reports a completed install or update. +release:finish() { ok "kit $(cat "${kit_root}/version") is installed, targets: ${*:-none}; .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } diff --git a/tests/kit.bats b/tests/kit.bats index 71b182a..1902d2d 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -19,7 +19,7 @@ teardown() { # later-release copies this checkout's release to as version 9.9.9 with one changed rule line. later-release() { mkdir -p "$1" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "$1/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "$1/" printf '9.9.9\n' > "$1/version" printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/tree/.kit/rules/json.md" } diff --git a/tests/release.bats b/tests/release.bats index e4af478..a437c5d 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -15,13 +15,13 @@ teardown() { @test "the tarball holds one directory with the release's layout, and installs a fresh repository" { version=$(cat "${kit_checkout}/version") mkdir "kit-${version}" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/bash" "${kit_checkout}/version" "kit-${version}/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/version" "kit-${version}/" tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" rm -rf "kit-${version}" mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked [ "$(ls unpacked)" = "kit-${version}" ] [ -x "unpacked/kit-${version}/bin/kit" ] - [ -x "unpacked/kit-${version}/bin/kit-install.bash" ] + [ -x "unpacked/kit-${version}/bin/kit-install.sh" ] [ -L "unpacked/kit-${version}/targets/claude/.claude/rules/kit-bash.md" ] mkdir fresh && git -C fresh init --quiet --initial-branch=main cd fresh From 8ae181e42f417f7d616545e36b7c0bef2118e98d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:45:52 -0700 Subject: [PATCH 028/108] #1 mise.desk.toml, mise.ci.toml: a tool is pinned in the file of whoever runs it Lefthook is a desk's, in mise.desk.toml under MISE_ENV=desk; actionlint is the runner's, in mise.ci.toml under MISE_ENV=ci, which the delivered CI job sets. just ci is the runner's gate, run on a desk as MISE_ENV=ci just ci, and the pre-commit hook is the narrower check the fragment declares. The lint tests skip where Lefthook does not answer. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/kit-ci.yml | 3 ++ .kit/1st.md | 5 +- .kit/lefthook.yml | 3 -- .kit/manifest | 6 +-- README.md | 18 ++++---- justfile | 14 +++--- mise.ci.lock | 32 +++++++++++++ mise.ci.toml | 6 +++ mise.desk.lock | 32 +++++++++++++ mise.desk.toml | 6 +++ mise.lock | 76 ------------------------------- mise.toml | 13 +++--- tests/lint.bats | 1 + tree/.github/workflows/kit-ci.yml | 3 ++ tree/.kit/1st.md | 5 +- tree/.kit/lefthook.yml | 3 -- 16 files changed, 114 insertions(+), 112 deletions(-) create mode 100644 mise.ci.lock create mode 100644 mise.ci.toml create mode 100644 mise.desk.lock create mode 100644 mise.desk.toml diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index c4cb81d..b4752d6 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -25,6 +25,9 @@ jobs: CI: if: github.event.pull_request.base.ref == github.event.repository.default_branch runs-on: ubuntu-latest + env: + # mise reads mise.ci.toml on top of mise.toml: the tools only a runner runs. + MISE_ENV: ci steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.kit/1st.md b/.kit/1st.md index 4cc2154..b17c5cc 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -89,9 +89,8 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- `.kit/lefthook.yml`, which linter runs over which paths before a commit. A repository's `lefthook.yml` takes it - with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand, over the staged paths - or every path. +- `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's + `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places `.claude/rules/kit-.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml index aea7ecf..bd0f61d 100644 --- a/.kit/lefthook.yml +++ b/.kit/lefthook.yml @@ -12,6 +12,3 @@ pre-commit: shellcheck: glob: "*.{sh,bash}" run: shellcheck --shell=bash --external-sources {staged_files} - actionlint: - glob: ".github/workflows/*.{yml,yaml}" - run: actionlint {staged_files} diff --git a/.kit/manifest b/.kit/manifest index 57409ac..00e5bd7 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -8,9 +8,9 @@ targets claude 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json -b9a38f19b571c70bb4ef7d3f25fd2b56ac0f6fc076cbcc8e318d22ad3ffe594f .github/workflows/kit-ci.yml -f69ed575255ca7403c9daa1801058986d9e4845df581b05096c2aae6220d3d90 .kit/1st.md -4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml +5d6f7c4590fe03be20b8208a14539203ee252fe74012f7796a2c1e9bcc42be08 .github/workflows/kit-ci.yml +a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md +dc9d98436a1f4ac03a7b92cc844d7a2064fbca89ebe21bc8ff243e25f8d0225c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md diff --git a/README.md b/README.md index 002145e..2c086c5 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ through the rules table in `.kit/1st.md`. `kit target add` and `kit target remov - Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. - Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull request not linked to an open issue. -- Pin `lefthook` in `mise.toml`, put `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and run `lefthook install` - once per clone. The fragment says which linter runs over which paths before a commit, and your `lefthook.yml` - adds commands of its own. `kit lint` runs the same hook by hand, and `kit lint --all-files` is the gate's - spelling of it. +- Pin `lefthook` in `mise.desk.toml`, which mise reads when `MISE_ENV=desk`, put `extends: [.kit/lefthook.yml]` + in `lefthook.yml`, and run `lefthook install` once per clone. The fragment says which linter runs over which + paths before a commit, and your `lefthook.yml` adds commands of its own. `kit lint` runs the same hook by hand. + The delivered `CI` job sets `MISE_ENV=ci`, so a tool only the runner needs is pinned in `mise.ci.toml`. The machinery, the `kit` command and the bash behind its verbs, stays in the release where mise installed it; the repository carries the text an agent reads and the record of what it took. The kit needs bash 4.4 or later, git, @@ -57,12 +57,14 @@ Then, once per clone, with the key's id in `signing_key`: git config gpg.format openpgp git config user.signingkey "${signing_key}" git config commit.gpgsign true +export MISE_ENV=desk mise install lefthook install ``` -A tool only a desk runs, should one arrive, is pinned in `mise.desk.toml`, which mise reads on top of `mise.toml` -when `MISE_ENV=desk` is set; a desk exports that in its shell profile, and a runner leaves it unset. +`MISE_ENV=desk` belongs in your shell profile. mise reads `mise.toml` always, `mise.desk.toml` on top of it when +`MISE_ENV=desk`, and `mise.ci.toml` when `MISE_ENV=ci`, which the CI workflow sets: a tool goes in the file of +whoever runs it. Lefthook is a desk's; actionlint is the runner's. This repository carries the installed copy of its own kit, and `just ci` checks it. @@ -85,8 +87,8 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what every consumer gets, mirrored at its root; `targets//` is what a consumer taking that target gets, mirrored the same way; `bin/` and `version` are the machinery: `bin/kit` dispatches to one executable `bin/kit-.sh` -per verb, and the verbs source the modules beside them, `1st.sh`, `gh.sh`, `git.sh`, and `release.sh`. A symlink under `targets/` is -delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. +per verb, and the verbs source the modules beside them, `1st.sh`, `gh.sh`, `git.sh`, and `release.sh`. A symlink +under `targets/` is delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. The installed copies are written by `bin/kit install` and not edited by hand: diff --git a/justfile b/justfile index 005cf21..f06328f 100644 --- a/justfile +++ b/justfile @@ -20,16 +20,18 @@ _: help set export := true -# The gate a pull request passes: the linters lefthook.yml declares, over every path, so a runner and a desk's -# pre-commit hook agree; the bats suite; and bin/kit check over this repository's installed copy of its own kit. -# Every tool comes from mise.toml. +# The runner's gate, run with MISE_ENV=ci so mise.ci.toml's tools are on hand: shellcheck over every bash file, +# actionlint over every workflow, the bats suite, and bin/kit check over this repository's installed copy of its +# own kit. A desk runs it the same way: MISE_ENV=ci just ci. The pre-commit hook on a desk is the narrower, +# faster check lefthook.yml declares. # -# just ci -[doc("🚦 Run the gate: the linters over every path, bats, kit check")] +# MISE_ENV=ci just ci +[doc("🚦 The runner's gate: shellcheck, actionlint, bats, kit check (MISE_ENV=ci)")] ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - mise exec -- lefthook run pre-commit --all-files + mise exec -- shellcheck --shell=bash --external-sources bin/kit bin/*.sh + mise exec -- actionlint mise exec -- bats --print-output-on-failure tests/ bin/kit check echo "✅ ci passed" diff --git a/mise.ci.lock b/mise.ci.lock new file mode 100644 index 0000000..6d43300 --- /dev/null +++ b/mise.ci.lock @@ -0,0 +1,32 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.actionlint]] +version = "1.7.12" +backend = "aqua:rhysd/actionlint" +specifiers = ["1.7"] + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" +provenance = "github-attestations" diff --git a/mise.ci.toml b/mise.ci.toml new file mode 100644 index 0000000..face1c9 --- /dev/null +++ b/mise.ci.toml @@ -0,0 +1,6 @@ +# mise.ci.toml - the tools only a runner runs, read when MISE_ENV=ci; see: mise.toml +# +# actionlint checks the workflows, which only the runner executes; a desk runs it with MISE_ENV=ci when it wants to. + +[tools] +actionlint = "1.7" diff --git a/mise.desk.lock b/mise.desk.lock new file mode 100644 index 0000000..dc8663d --- /dev/null +++ b/mise.desk.lock @@ -0,0 +1,32 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.lefthook]] +version = "2.2.2" +backend = "aqua:evilmartians/lefthook" +specifiers = ["2.2"] + +[tools.lefthook."platforms.linux-arm64"] +checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" +provenance = "github-attestations" + +[tools.lefthook."platforms.linux-x64"] +checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-arm64"] +checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-x64"] +checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" +provenance = "github-attestations" diff --git a/mise.desk.toml b/mise.desk.toml new file mode 100644 index 0000000..968a865 --- /dev/null +++ b/mise.desk.toml @@ -0,0 +1,6 @@ +# mise.desk.toml - the tools only a desk runs, read when MISE_ENV=desk; see: mise.toml +# +# Lefthook runs the pre-commit hook, and a runner commits nothing. + +[tools] +lefthook = "2.2" diff --git a/mise.lock b/mise.lock index 74927b8..7a2a4a8 100644 --- a/mise.lock +++ b/mise.lock @@ -2,53 +2,6 @@ lockfile_version = 3 -[[tools.actionlint]] -version = "1.7.12" -backend = "aqua:rhysd/actionlint" -specifiers = ["1.7"] - -[tools.actionlint."platforms.linux-arm64"] -checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" -provenance = "github-attestations" - -[tools.actionlint."platforms.linux-arm64-musl"] -checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" -provenance = "github-attestations" - -[tools.actionlint."platforms.linux-x64"] -checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" -provenance = "github-attestations" - -[tools.actionlint."platforms.linux-x64-musl"] -checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" -provenance = "github-attestations" - -[tools.actionlint."platforms.macos-arm64"] -checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" -provenance = "github-attestations" - -[tools.actionlint."platforms.macos-x64"] -checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" -provenance = "github-attestations" - -[tools.actionlint."platforms.windows-x64"] -checksum = "sha256:6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_windows_amd64.zip" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924919" -provenance = "github-attestations" - [[tools.bats]] version = "1.13.0" backend = "aqua:bats-core/bats-core" @@ -213,35 +166,6 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" -[[tools.lefthook]] -version = "2.2.2" -backend = "aqua:evilmartians/lefthook" -specifiers = ["2.2"] - -[tools.lefthook."platforms.linux-arm64"] -checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" -provenance = "github-attestations" - -[tools.lefthook."platforms.linux-x64"] -checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" -provenance = "github-attestations" - -[tools.lefthook."platforms.macos-arm64"] -checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" -provenance = "github-attestations" - -[tools.lefthook."platforms.macos-x64"] -checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" -url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" -url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" -provenance = "github-attestations" - [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index dc9c9b7..98f3348 100644 --- a/mise.toml +++ b/mise.toml @@ -1,9 +1,10 @@ -# mise.toml - the pinned toolchain a desk and a runner share; see: https://mise.jdx.dev/configuration.html +# mise.toml - the pinned tools a desk and a runner share; see: https://mise.jdx.dev/configuration/environments.html # -# Every tool both a desk and a CI runner run, at one version, installed the same way by `mise install` and by -# `jdx/mise-action`. A tool only a desk runs would go in mise.desk.toml, which mise reads when MISE_ENV=desk; a desk -# sets that in its shell profile and a runner leaves it unset. Nothing is desk-only today. `lockfile = true` records each pin's exact -# resolution in mise.lock. git and bash come from the machine. +# Three files, one mechanism: mise reads this one always, mise.desk.toml on top of it when MISE_ENV=desk, which a +# desk exports in its shell profile, and mise.ci.toml when MISE_ENV=ci, which the CI workflow sets. A tool goes in +# the file of whoever runs it. `lockfile = true` records each pin's exact resolution in a lock beside its file, +# and lockfile_platforms makes a lock written on a desk carry the runner's platform. git and bash come from the +# machine. [settings] lockfile = true @@ -15,10 +16,8 @@ lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] _.path = ["bin"] [tools] -actionlint = "1.7" gh = "2.103" jq = "1.8" just = "1.59" -lefthook = "2.2" shellcheck = "0.11" bats = "1.13" diff --git a/tests/lint.bats b/tests/lint.bats index 86cdc4b..96aa0cd 100644 --- a/tests/lint.bats +++ b/tests/lint.bats @@ -3,6 +3,7 @@ # ---------------------------------------------------------------------------------------------------------------------- setup() { + lefthook version >/dev/null 2>&1 || skip "lefthook is a desk tool (mise.desk.toml, MISE_ENV=desk)" kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) repo=$(mktemp -d) cd "${repo}" diff --git a/tree/.github/workflows/kit-ci.yml b/tree/.github/workflows/kit-ci.yml index c4cb81d..b4752d6 100644 --- a/tree/.github/workflows/kit-ci.yml +++ b/tree/.github/workflows/kit-ci.yml @@ -25,6 +25,9 @@ jobs: CI: if: github.event.pull_request.base.ref == github.event.repository.default_branch runs-on: ubuntu-latest + env: + # mise reads mise.ci.toml on top of mise.toml: the tools only a runner runs. + MISE_ENV: ci steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index 4cc2154..b17c5cc 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -89,9 +89,8 @@ repository root, and `targets//` for each target taken; a `.json` destinat else is copied as it is. - `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. -- `.kit/lefthook.yml`, which linter runs over which paths before a commit. A repository's `lefthook.yml` takes it - with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand, over the staged paths - or every path. +- `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's + `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets it names: `kit install claude`, or `kit target add claude` later. The `claude` target places `.claude/rules/kit-.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. diff --git a/tree/.kit/lefthook.yml b/tree/.kit/lefthook.yml index aea7ecf..bd0f61d 100644 --- a/tree/.kit/lefthook.yml +++ b/tree/.kit/lefthook.yml @@ -12,6 +12,3 @@ pre-commit: shellcheck: glob: "*.{sh,bash}" run: shellcheck --shell=bash --external-sources {staged_files} - actionlint: - glob: ".github/workflows/*.{yml,yaml}" - run: actionlint {staged_files} From 0d9a53305c5113f866b74b7f310b9d76dea01fc0 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:49:10 -0700 Subject: [PATCH 029/108] #1 version: the tag is the version; no file carries it The release workflow stamps the tag's version into the packed kit, which is the only copy: kit version prints it, the manifest records it, and kit update names it to mise for the base of a merge. A checkout reports unreleased. The tag-matches-version check goes, and with it the bump-then-tag two-step; a published release is immutable, so a tag is never reused. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 26 +++++++------------------- .kit/manifest | 2 +- AGENTS.md | 8 ++++---- README.md | 16 ++++++++-------- bin/kit-check.sh | 4 ++-- bin/kit-install.sh | 2 +- bin/kit-update.sh | 4 ++-- bin/kit-version.sh | 5 ++++- bin/release.sh | 10 ++++++++-- tests/kit.bats | 4 ++-- tests/release.bats | 6 ++++-- version | 1 - 12 files changed, 43 insertions(+), 45 deletions(-) delete mode 100644 version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85bd9cf..8dafe85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,12 @@ # release.yml - a tag v on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, and version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., +# The tarball is kit-.tar.gz, holding one directory kit-/ with bin/, tree/, targets/, and a stamped version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v.., # is tagged on main (any commit on main, not only its head). A pre-release, the same with a -, is tagged on -# a commit that is not on main, and is published as a GitHub pre-release. Either way the file version at the tagged -# commit is the version without the suffix, the one under development, and the packed kit carries the tag's full -# version so a consumer pinning a pre-release sees it in kit version and .kit/manifest. +# a commit that is not on main, and is published as a GitHub pre-release. The tag is the version: the repository +# carries no version file, and the one in the packed kit is stamped here from the tag, so a consumer sees it in +# kit version and .kit/manifest and kit update can name the previous release to mise. A release, once published, +# is immutable, so a tag is never reused. # # see: .kit/1st.md @@ -56,19 +57,6 @@ jobs: fi echo "✅ ${GITHUB_REF_NAME} is on the right side of ${default_branch} (on it: ${on_main})." - - name: tag-matches-version - run: | - set -o errexit -o nounset -o pipefail - tag_version=${GITHUB_REF_NAME#v} - base_version=${tag_version%%-*} - file_version=$(cat version) - if [[ "${base_version}" != "${file_version}" ]] - then - echo "::error::the tag says ${base_version} and version says ${file_version}; set version at the tagged commit first" - exit 1 - fi - echo "✅ version is ${file_version}." - - name: pack-and-release env: GH_TOKEN: ${{ github.token }} @@ -77,8 +65,8 @@ jobs: version=${GITHUB_REF_NAME#v} staging=$(mktemp --directory) mkdir "${staging}/kit-${version}" - cp --recursive --no-dereference bin tree targets version "${staging}/kit-${version}/" - # the packed kit carries the tag's full version, suffix included; the repository file carries the base + cp --recursive --no-dereference bin tree targets "${staging}/kit-${version}/" + # the packed kit's version is the tag's, suffix included printf '%s\n' "${version}" > "${staging}/kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" sha256sum "kit-${version}.tar.gz" > "kit-${version}.tar.gz.sha256" diff --git a/.kit/manifest b/.kit/manifest index 00e5bd7..ba6e89f 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,5 +1,5 @@ # .kit/manifest - written by kit, not by hand -version 0.1.0 +version unreleased targets claude 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .claude/rules/kit-bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md diff --git a/AGENTS.md b/AGENTS.md index 34c98a2..b369027 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,11 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its ## This repository's own rules -- The release is `bin/`, `tree/`, `targets/`, and `version`. Everything in it is written for a +- The release is `bin/`, `tree/`, and `targets/`. Everything in it is written for a consumer: no file names a consumer, a person, or another repository as the source of a rule. -- `version` is the version under development, with no suffix. A release is the tag `v` on a commit on - `main`. A pre-release is `v-` on a commit that is not on `main`. The release workflow refuses any - other pairing, and a tag whose base version differs from the file. +- A release is the tag `v` on a commit on `main`; a pre-release is `v-` on a commit that is + not on `main`. The tag is the version: no file carries it, the workflow stamps it into the packed kit, and a + published release is immutable, so a tag is never reused. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. diff --git a/README.md b/README.md index 2c086c5..3f41014 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what every consumer gets, mirrored at its root; `targets//` is what a consumer taking that target gets, mirrored -the same way; `bin/` and `version` are the machinery: `bin/kit` dispatches to one executable `bin/kit-.sh` +the same way; `bin/` is the machinery: `bin/kit` dispatches to one executable `bin/kit-.sh` per verb, and the verbs source the modules beside them, `1st.sh`, `gh.sh`, `git.sh`, and `release.sh`. A symlink under `targets/` is delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. @@ -98,7 +98,7 @@ The installed copies are written by `bin/kit install` and not edited by hand: | `.github/workflows/kit-ci.yml` | `tree/.github/workflows/kit-ci.yml` | | `.claude/rules/kit-.md` | `targets/claude/.claude/rules/`, links into `tree/.kit/rules/` | | `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here only the kit's entries | -| `.kit/manifest` | the release version, the targets taken, and a digest per delivered path | +| `.kit/manifest` | the release version (`unreleased` here), the targets taken, and a digest per delivered path | After changing a file under `tree/` or `targets/`, run `just refresh`, which reinstalls the kit into this repository, and commit the result with the change. `bin/kit check`, which `just ci` runs, names any installed copy @@ -107,11 +107,11 @@ that no longer matches its source. Each delivered text file says in its header t ## Release -`version` carries the version under development, without a suffix. A release is the tag `v` on a -commit on `main`, not only its head. A pre-release is `v-`, say `v0.1.0-rc.1`, on a commit that is -not on `main`, and it is published as a GitHub pre-release. In both cases the workflow packs the release into -`kit-.tar.gz` with the tag's full version stamped into the packed `version`, and publishes the release. -It refuses a tag that is not a version, a release tag off `main`, a pre-release tag on `main`, and a tag whose base -version is not `version`. +A release is the tag `v` on a commit on `main`, not only its head. A pre-release is `v-`, +say `v0.1.0-rc.1`, on a commit that is not on `main`, and it is published as a GitHub pre-release. The tag is the +version: the repository carries no version file, the workflow stamps the tag's version into the packed kit, and a +published release is immutable, so a tag is never reused. The workflow packs `bin/`, `tree/`, and `targets/` into +`kit-.tar.gz` and publishes the release; it refuses a tag that is not a version, a release tag off +`main`, and a pre-release tag on `main`. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf diff --git a/bin/kit-check.sh b/bin/kit-check.sh index f549188..0cb5f12 100755 --- a/bin/kit-check.sh +++ b/bin/kit-check.sh @@ -19,9 +19,9 @@ release:require-manifest read -r -a targets <<<"$(release:manifest-targets)" version=$(release:manifest-version) findings=0 -if [[ "${version}" != "$(cat "${kit_root}/version")" ]] +if [[ "${version}" != "$(release:version)" ]] then - error ".kit/manifest records kit ${version}, and the pinned release is $(cat "${kit_root}/version"); run: kit update" + error ".kit/manifest records kit ${version}, and the pinned release is $(release:version); run: kit update" findings=$(( findings + 1 )) fi while IFS= read -r path diff --git a/bin/kit-install.sh b/bin/kit-install.sh index 7f83e57..69f27bb 100755 --- a/bin/kit-install.sh +++ b/bin/kit-install.sh @@ -22,7 +22,7 @@ then error "run: kit update (this repository already carries kit $(release:manifest-version))" exit 1 fi -section "kit install $(cat "${kit_root}/version")${*:+, targets: $*}" +section "kit install $(release:version)${*:+, targets: $*}" while IFS= read -r path do step "${path}" diff --git a/bin/kit-update.sh b/bin/kit-update.sh index 1580695..3552913 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -23,14 +23,14 @@ guard:require tool:jq tool:git release:require-manifest read -r -a targets <<<"$(release:manifest-targets)" previous=$(release:manifest-version) -current=$(cat "${kit_root}/version") +current=$(release:version) section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" base_root="" if [[ -n "${KIT_BASE:-}" ]] then base_root=${KIT_BASE} -elif [[ "${previous}" != "${current}" ]] && command -v mise >/dev/null 2>&1 +elif [[ "${previous}" != "${current}" && "${previous}" != unreleased ]] && command -v mise >/dev/null 2>&1 then base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null diff --git a/bin/kit-version.sh b/bin/kit-version.sh index 14b2081..0895204 100755 --- a/bin/kit-version.sh +++ b/bin/kit-version.sh @@ -3,6 +3,9 @@ # ---------------------------------------------------------------------------------------------------------------------- set -o errexit -o nounset -o pipefail -cat "${kit_root:?run through bin/kit}/version" +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release +release:version # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/release.sh b/bin/release.sh index 16c8a15..2ddbc54 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -11,6 +11,12 @@ # # Every function reads the release from kit_root, exported by bin/kit. +# release:version prints this release's version: the file the release workflow stamps from the tag, and +# "unreleased" in a checkout, which is what this repository's own installed copy records. +release:version() { + cat "${kit_root}/version" 2>/dev/null || printf 'unreleased\n' +} + # release:layers … prints the directories mirrored at the repository root, tree first. release:layers() { local root=$1 t @@ -131,7 +137,7 @@ release:write-manifest() { local path { printf '# .kit/manifest - written by kit, not by hand\n' - printf 'version %s\n' "$(cat "${kit_root}/version")" + printf 'version %s\n' "$(release:version)" printf 'targets%s\n' "${*:+ $*}" while IFS= read -r path do @@ -154,7 +160,7 @@ release:require-manifest() { # release:finish … reports a completed install or update. release:finish() { - ok "kit $(cat "${kit_root}/version") is installed, targets: ${*:-none}; .kit/manifest is written." + ok "kit $(release:version) is installed, targets: ${*:-none}; .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } diff --git a/tests/kit.bats b/tests/kit.bats index 1902d2d..55ba6cb 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -38,7 +38,7 @@ later-release() { grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md [ -f .github/workflows/kit-ci.yml ] jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null - grep --quiet '^version 0.1.0$' .kit/manifest + grep --quiet '^version unreleased$' .kit/manifest grep --quiet '^targets claude$' .kit/manifest } @@ -176,7 +176,7 @@ later-release() { mkdir shims && printf '#!/usr/bin/env bash\nexit 1\n' > shims/mise && chmod +x shims/mise run env PATH="${PWD}/shims:${PATH}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] - [[ "$output" == *"base release 0.1.0 is not installed"* ]] + [[ "$output" == *"base release unreleased is not installed"* ]] rm -rf "${repo}.later" } diff --git a/tests/release.bats b/tests/release.bats index a437c5d..6da12a3 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -13,9 +13,11 @@ teardown() { } @test "the tarball holds one directory with the release's layout, and installs a fresh repository" { - version=$(cat "${kit_checkout}/version") + # packed the way the workflow packs it, with the tag's version stamped in + version=9.9.9 mkdir "kit-${version}" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "${kit_checkout}/version" "kit-${version}/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "kit-${version}/" + printf '%s\n' "${version}" > "kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" rm -rf "kit-${version}" mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked diff --git a/version b/version deleted file mode 100644 index 6e8bf73..0000000 --- a/version +++ /dev/null @@ -1 +0,0 @@ -0.1.0 From 4bbed00a79d6751b3ee6013ddbaf77803ae7576f Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:52:55 -0700 Subject: [PATCH 030/108] #1 .github/workflows/release.yml: a release retires its pre-releases, tags and all Checked by hand on this repository: an immutable pre-release deletes with its tag, and the tag name can never be reused. The step runs only for a release, never a pre-release. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 21 ++++++++++++++++++++- AGENTS.md | 2 +- README.md | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dafe85..30e7211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ # a commit that is not on main, and is published as a GitHub pre-release. The tag is the version: the repository # carries no version file, and the one in the packed kit is stamped here from the tag, so a consumer sees it in # kit version and .kit/manifest and kit update can name the previous release to mise. A release, once published, -# is immutable, so a tag is never reused. +# is immutable, so a tag is never reused. A release retires its pre-releases: every v- goes, tag +# and all, since the release is what they led to; a consumer still pinned to one re-pins to the release. # # see: .kit/1st.md @@ -77,4 +78,22 @@ jobs: --title "kit ${version}" --notes "${notes}" "${flags[@]}" echo "✅ Released kit ${version}." + - name: retire-pre-releases + if: ${{ !contains(github.ref_name, '-') }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o errexit -o nounset -o pipefail + version=${GITHUB_REF_NAME#v} + retired=0 + while IFS= read -r tag + do + [[ -n "${tag}" ]] || continue + gh release delete "${tag}" --cleanup-tag --yes + echo "retired ${tag}" + retired=$(( retired + 1 )) + done < <(gh release list --json tagName,isPrerelease \ + --jq ".[] | select(.isPrerelease and (.tagName | startswith(\"v${version}-\"))) | .tagName") + echo "✅ Retired ${retired} pre-release(s) of ${version}." + # ---------------------------------------------------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index b369027..d54c444 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its consumer: no file names a consumer, a person, or another repository as the source of a rule. - A release is the tag `v` on a commit on `main`; a pre-release is `v-` on a commit that is not on `main`. The tag is the version: no file carries it, the workflow stamps it into the packed kit, and a - published release is immutable, so a tag is never reused. + published release is immutable, so a tag is never reused. A release retires its pre-releases, tags and all. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. diff --git a/README.md b/README.md index 3f41014..a81f4b9 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,6 @@ say `v0.1.0-rc.1`, on a commit that is not on `main`, and it is published as a G version: the repository carries no version file, the workflow stamps the tag's version into the packed kit, and a published release is immutable, so a tag is never reused. The workflow packs `bin/`, `tree/`, and `targets/` into `kit-.tar.gz` and publishes the release; it refuses a tag that is not a version, a release tag off -`main`, and a pre-release tag on `main`. +`main`, and a pre-release tag on `main`. A release retires its pre-releases, `v-*`, tags and all. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf From 28d246eca5e767b1efb8c32607b83b943df404c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:54:59 -0700 Subject: [PATCH 031/108] #1 .github/workflows/retire-pre-releases.yml: a pre-release lives as long as its branch A release is permanent. On a branch deletion, every pre-release whose commit no remaining branch reaches is deleted with its tag. The release workflow no longer touches pre-releases. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 22 +-------- .github/workflows/retire-pre-releases.yml | 58 +++++++++++++++++++++++ AGENTS.md | 2 +- README.md | 3 +- 4 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/retire-pre-releases.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30e7211..6bf2899 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ # a commit that is not on main, and is published as a GitHub pre-release. The tag is the version: the repository # carries no version file, and the one in the packed kit is stamped here from the tag, so a consumer sees it in # kit version and .kit/manifest and kit update can name the previous release to mise. A release, once published, -# is immutable, so a tag is never reused. A release retires its pre-releases: every v- goes, tag -# and all, since the release is what they led to; a consumer still pinned to one re-pins to the release. +# is immutable, so a tag is never reused. A pre-release lives as long as the branch it was tagged on; see: +# .github/workflows/retire-pre-releases.yml # # see: .kit/1st.md @@ -78,22 +78,4 @@ jobs: --title "kit ${version}" --notes "${notes}" "${flags[@]}" echo "✅ Released kit ${version}." - - name: retire-pre-releases - if: ${{ !contains(github.ref_name, '-') }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -o errexit -o nounset -o pipefail - version=${GITHUB_REF_NAME#v} - retired=0 - while IFS= read -r tag - do - [[ -n "${tag}" ]] || continue - gh release delete "${tag}" --cleanup-tag --yes - echo "retired ${tag}" - retired=$(( retired + 1 )) - done < <(gh release list --json tagName,isPrerelease \ - --jq ".[] | select(.isPrerelease and (.tagName | startswith(\"v${version}-\"))) | .tagName") - echo "✅ Retired ${retired} pre-release(s) of ${version}." - # ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/workflows/retire-pre-releases.yml b/.github/workflows/retire-pre-releases.yml new file mode 100644 index 0000000..8229061 --- /dev/null +++ b/.github/workflows/retire-pre-releases.yml @@ -0,0 +1,58 @@ +# retire-pre-releases.yml - a pre-release lives as long as the branch it was tagged on +# ---------------------------------------------------------------------------------------------------------------------- +# +# A pre-release is tagged on a commit that is not on main, and main takes a branch by squash, so once the branch is +# deleted its commits are reachable from nothing but the pre-release's own tag. This runs on every branch deletion +# and retires, tag and all, each pre-release whose commit no remaining branch reaches. A release is never touched: +# it is on main, which is never deleted. A deleted tag is a delete event too, and that one is ignored here. +# +# Checked by hand: an immutable pre-release deletes with its tag, and the tag name can never be reused. +# +# see: .github/workflows/release.yml + +name: retire-pre-releases + +on: + delete: + +permissions: + contents: write + +jobs: + retire: + if: github.event.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: retire-unreachable-pre-releases + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o errexit -o nounset -o pipefail + git fetch --quiet --tags origin + deleted=$(jq --raw-output '.ref' "${GITHUB_EVENT_PATH}") + retired=0 + while IFS= read -r tag + do + [[ -n "${tag}" ]] || continue + commit=$(git rev-parse --verify --quiet "${tag}^{commit}" || true) + if [[ -z "${commit}" ]] + then + echo "::warning::${tag} has no commit here; leaving it" + continue + fi + if git branch --remotes --contains "${commit}" | grep --quiet --invert-match 'origin/HEAD' + then + echo "keeping ${tag}: a branch still reaches ${commit:0:7}" + continue + fi + gh release delete "${tag}" --cleanup-tag --yes + echo "retired ${tag}: no branch reaches ${commit:0:7} since ${deleted} was deleted" + retired=$(( retired + 1 )) + done < <(gh release list --json tagName,isPrerelease --jq '.[] | select(.isPrerelease) | .tagName') + echo "✅ Retired ${retired} pre-release(s)." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index d54c444..a670656 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its consumer: no file names a consumer, a person, or another repository as the source of a rule. - A release is the tag `v` on a commit on `main`; a pre-release is `v-` on a commit that is not on `main`. The tag is the version: no file carries it, the workflow stamps it into the packed kit, and a - published release is immutable, so a tag is never reused. A release retires its pre-releases, tags and all. + published release is immutable, so a tag is never reused. A release is permanent; a pre-release is retired when the branch it was tagged on is deleted. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. diff --git a/README.md b/README.md index a81f4b9..9dcb06a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ say `v0.1.0-rc.1`, on a commit that is not on `main`, and it is published as a G version: the repository carries no version file, the workflow stamps the tag's version into the packed kit, and a published release is immutable, so a tag is never reused. The workflow packs `bin/`, `tree/`, and `targets/` into `kit-.tar.gz` and publishes the release; it refuses a tag that is not a version, a release tag off -`main`, and a pre-release tag on `main`. A release retires its pre-releases, `v-*`, tags and all. +`main`, and a pre-release tag on `main`. A release is permanent. A pre-release is retired, tag and all, when the +branch it was tagged on is deleted and no other branch reaches its commit. Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf From 76f202c0c1bae1e790661627127faa666af6c369 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 00:58:07 -0700 Subject: [PATCH 032/108] #1 bin/gh.sh, bin/git.sh, kit-ci.yml: three findings from review The start commit is made with --only, so work already staged in a reused worktree stays in the index. A clean detached worktree is residue only when origin/main already has its commit; otherwise tidy keeps it. The delivered gate fails when the root justfile cannot be read, and skips only when the recipe is absent. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/kit-ci.yml | 12 +++++++- .kit/manifest | 2 +- bin/gh.sh | 3 +- bin/git.sh | 17 ++++++++--- tests/tidy.bats | 51 +++++++++++++++++++++++++++++++ tree/.github/workflows/kit-ci.yml | 12 +++++++- 6 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 tests/tidy.bats diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index b4752d6..5b3b9de 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -37,7 +37,17 @@ jobs: - name: gate run: | set -o errexit -o nounset -o pipefail - if [[ ! -f justfile ]] || ! just --show ci >/dev/null 2>&1 + # A missing recipe is a skip; a justfile that cannot be read is a failure, never a silent skip. + if [[ ! -f justfile ]] + then + echo "::warning::there is no root justfile, so the gate was skipped" + printf '⚠️ The gate was skipped: there is no root justfile.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + elif ! recipes=$(just --summary 2>&1) + then + echo "::error::the root justfile could not be read: ${recipes}" + printf '### ❌ The root justfile could not be read\n\n%s\n' "${recipes}" | tee --append "${GITHUB_STEP_SUMMARY}" + exit 1 + elif ! tr ' ' '\n' <<< "${recipes}" | grep --quiet --line-regexp ci then echo "::warning::the root justfile has no ci recipe, so the gate was skipped" printf '⚠️ The gate was skipped: the root justfile has no ci recipe.\n' | tee --append "${GITHUB_STEP_SUMMARY}" diff --git a/.kit/manifest b/.kit/manifest index ba6e89f..d9696d9 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -8,7 +8,7 @@ targets claude 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json -5d6f7c4590fe03be20b8208a14539203ee252fe74012f7796a2c1e9bcc42be08 .github/workflows/kit-ci.yml +54014b457cbb469ee2d47d80edd5bc74cb1670afc235fc223d1f4e77ea78e859 .github/workflows/kit-ci.yml a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md dc9d98436a1f4ac03a7b92cc844d7a2064fbca89ebe21bc8ff243e25f8d0225c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md diff --git a/bin/gh.sh b/bin/gh.sh index 42ff316..368870b 100644 --- a/bin/gh.sh +++ b/bin/gh.sh @@ -163,10 +163,11 @@ gh:issue() { fi # GitHub cannot open a pull request on a branch with nothing to compare, so the first commit is an empty one. + # --only keeps it empty: work already staged in a reused worktree stays in the index, not in this commit. if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] then step "Adding the start commit" - git -C "${wt}" commit --quiet --allow-empty --message "start: #${primary} ${title}" + git -C "${wt}" commit --quiet --allow-empty --only --message "start: #${primary} ${title}" fi git -C "${wt}" push --quiet --set-upstream origin "${branch}" diff --git a/bin/git.sh b/bin/git.sh index 9feac52..4738cd0 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -6,9 +6,10 @@ # with its branch, pull request, and dirty or clean state, then the local branches whose upstream is gone. It # changes nothing. # -# git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, prunes -# the worktree bookkeeping, and deletes every local branch whose upstream is gone. It never touches the default -# branch, a branch checked out in a live worktree, a dirty worktree, or the worktree it runs in. --dry-run +# git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, or that is +# detached, clean, and at a commit the default branch already has; prunes the worktree bookkeeping; and deletes +# every local branch whose upstream is gone. It never touches the default branch, a branch checked out in a live +# worktree, a dirty worktree, a detached worktree whose commit nothing else reaches, or the worktree it runs in. --dry-run # prints what a sweep would do and changes nothing. # # git:rebase [] rebases the branch checked out at , the current checkout by default, onto @@ -120,7 +121,15 @@ git:tidy-worktree() { fi elif [[ "${detached}" == true && -z "$(git -C "${path}" status --porcelain 2>/dev/null)" ]] then - state=DETACHED + # A clean detached checkout is residue only when its commit is already on the default branch; otherwise its + # HEAD may be the only thing that reaches that commit, and removing the worktree would lose it. + if git -C "${path}" merge-base --is-ancestor HEAD "origin/${default_branch}" 2>/dev/null + then + state=DETACHED + else + step "keeping ${path} (detached at a commit origin/${default_branch} does not reach)" + return 0 + fi fi case "${state}" in (MERGED | CLOSED | DETACHED) diff --git a/tests/tidy.bats b/tests/tidy.bats new file mode 100644 index 0000000..285e523 --- /dev/null +++ b/tests/tidy.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats +# tidy.bats - the one decision in tidy that needs no GitHub: a clean detached worktree is residue only when the +# default branch already has its commit +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main + mkdir worktrees +} + +teardown() { + rm -rf "${work}" +} + +# decide runs tidy's per-worktree decision on , a detached checkout, with tidy's globals in place. +decide() { + bash -c ' + source "${kit_root}/bin/1st.sh" && use git + found=false; dry_run=false; removed_worktrees=0; declare -A freed=() + self_root=$(git rev-parse --show-toplevel); default_branch=main + git:tidy-worktree "$1" "" true + ' _ "$1" +} + +@test "a clean detached worktree at a commit on main is removed" { + git worktree add --quiet --detach worktrees/old HEAD + run decide "${PWD}/worktrees/old" + [ "$status" -eq 0 ] + [[ "$output" == *"removing"* ]] + [ ! -e worktrees/old ] +} + +@test "a clean detached worktree at a commit main does not reach is kept" { + git worktree add --quiet --detach worktrees/only HEAD + printf 'two\n' > worktrees/only/b && git -C worktrees/only add b && git -C worktrees/only commit --quiet --message two + run decide "${PWD}/worktrees/only" + [ "$status" -eq 0 ] + [[ "$output" == *"keeping"* ]] + [ -e worktrees/only ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tree/.github/workflows/kit-ci.yml b/tree/.github/workflows/kit-ci.yml index b4752d6..5b3b9de 100644 --- a/tree/.github/workflows/kit-ci.yml +++ b/tree/.github/workflows/kit-ci.yml @@ -37,7 +37,17 @@ jobs: - name: gate run: | set -o errexit -o nounset -o pipefail - if [[ ! -f justfile ]] || ! just --show ci >/dev/null 2>&1 + # A missing recipe is a skip; a justfile that cannot be read is a failure, never a silent skip. + if [[ ! -f justfile ]] + then + echo "::warning::there is no root justfile, so the gate was skipped" + printf '⚠️ The gate was skipped: there is no root justfile.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + elif ! recipes=$(just --summary 2>&1) + then + echo "::error::the root justfile could not be read: ${recipes}" + printf '### ❌ The root justfile could not be read\n\n%s\n' "${recipes}" | tee --append "${GITHUB_STEP_SUMMARY}" + exit 1 + elif ! tr ' ' '\n' <<< "${recipes}" | grep --quiet --line-regexp ci then echo "::warning::the root justfile has no ci recipe, so the gate was skipped" printf '⚠️ The gate was skipped: the root justfile has no ci recipe.\n' | tee --append "${GITHUB_STEP_SUMMARY}" From 070a500c563a52a60b40799c3ffb5f6fd18687eb Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:04:15 -0700 Subject: [PATCH 033/108] #1 lefthook.yml: a commit that changes a delivered file's source refreshes the installed copies Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 6 ++++-- README.md | 5 +++-- lefthook.yml | 11 +++++++++-- tree/.kit/rules/json.md | 2 ++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a670656..d73b5d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,10 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its consumer: no file names a consumer, a person, or another repository as the source of a rule. - A release is the tag `v` on a commit on `main`; a pre-release is `v-` on a commit that is not on `main`. The tag is the version: no file carries it, the workflow stamps it into the packed kit, and a - published release is immutable, so a tag is never reused. A release is permanent; a pre-release is retired when the branch it was tagged on is deleted. + published release is immutable, so a tag is never reused. A release is permanent; a pre-release is retired when + the branch it was tagged on is deleted. - `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see - [README.md](README.md) § Maintaining. Edit the source, run `just refresh`, and commit both. + [README.md](README.md) § Maintaining. Edit the source; the pre-commit hook refreshes the copies into the same + commit, and a desk without the hook runs `just refresh` and commits both. - `just ci` is the gate: shellcheck, actionlint, the bats suite, and `bin/kit check`. diff --git a/README.md b/README.md index 9dcb06a..1a24c47 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ The installed copies are written by `bin/kit install` and not edited by hand: | `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here only the kit's entries | | `.kit/manifest` | the release version (`unreleased` here), the targets taken, and a digest per delivered path | -After changing a file under `tree/` or `targets/`, run `just refresh`, which reinstalls the kit into this -repository, and commit the result with the change. `bin/kit check`, which `just ci` runs, names any installed copy +A commit that changes a file under `tree/` or `targets/` rewrites the installed copies in the same commit: the +pre-commit hook runs `just refresh` and stages what it rewrote. On a desk without the hook, run `just refresh` +by hand and commit the result with the change. `bin/kit check`, which `just ci` runs, names any installed copy that no longer matches its source. Each delivered text file says in its header that it is delivered by kit; `settings.json` cannot, so this table is its notice. diff --git a/lefthook.yml b/lefthook.yml index b1677f2..6d163c4 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,8 +1,9 @@ # lefthook.yml - this repository's git hooks; see: https://lefthook.dev # ---------------------------------------------------------------------------------------------------------------------- # -# The kit's own linters come from its installed fragment, the way a consumer takes them, and the dispatcher, which -# carries no extension, is added here. Once per clone: lefthook install +# The kit's own linters come from its installed fragment, the way a consumer takes them; the dispatcher, which +# carries no extension, and the refresh of the installed copies are this repository's own. Once per clone: +# lefthook install extends: - .kit/lefthook.yml @@ -12,3 +13,9 @@ pre-commit: shellcheck-dispatcher: glob: "bin/kit" run: shellcheck --shell=bash --external-sources {staged_files} + # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, so + # nobody remembers just refresh. stage_fixed adds what the refresh rewrote to the commit. + refresh: + glob: "{tree,targets}/**" + run: just refresh + stage_fixed: true diff --git a/tree/.kit/rules/json.md b/tree/.kit/rules/json.md index 692c8aa..18391b6 100644 --- a/tree/.kit/rules/json.md +++ b/tree/.kit/rules/json.md @@ -25,3 +25,5 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. + + From 0f750556715c893a877c5c1bb27066d9b4385799 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:04:16 -0700 Subject: [PATCH 034/108] #1 tree/.kit/rules/json.md: the proof line goes Co-Authored-By: Claude Fable 5.1 --- tree/.kit/rules/json.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/tree/.kit/rules/json.md b/tree/.kit/rules/json.md index 18391b6..692c8aa 100644 --- a/tree/.kit/rules/json.md +++ b/tree/.kit/rules/json.md @@ -25,5 +25,3 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. - - From fc04e3d0faf28007900ba1e3fdcd450e7629d92d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:05:27 -0700 Subject: [PATCH 035/108] #1 lefthook.yml: the refresh stages the copies it rewrote Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-json.md | 2 ++ .kit/manifest | 4 ++-- .kit/rules/json.md | 2 ++ lefthook.yml | 6 +++--- tree/.kit/rules/json.md | 2 ++ 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md index 692c8aa..18391b6 100644 --- a/.claude/rules/kit-json.md +++ b/.claude/rules/kit-json.md @@ -25,3 +25,5 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. + + diff --git a/.kit/manifest b/.kit/manifest index d9696d9..28e8b54 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -3,7 +3,7 @@ version unreleased targets claude 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .claude/rules/kit-bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md -1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md +85506123b2e18da1ecca6f4342c344667fe2f1bfb185dc78bc76958ead1fb7c6 .claude/rules/kit-json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .claude/rules/kit-just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md @@ -13,7 +13,7 @@ a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md dc9d98436a1f4ac03a7b92cc844d7a2064fbca89ebe21bc8ff243e25f8d0225c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md -1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md +85506123b2e18da1ecca6f4342c344667fe2f1bfb185dc78bc76958ead1fb7c6 .kit/rules/json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .kit/rules/just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .kit/rules/markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .kit/rules/yaml.md diff --git a/.kit/rules/json.md b/.kit/rules/json.md index 692c8aa..18391b6 100644 --- a/.kit/rules/json.md +++ b/.kit/rules/json.md @@ -25,3 +25,5 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. + + diff --git a/lefthook.yml b/lefthook.yml index 6d163c4..a348d5f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -14,8 +14,8 @@ pre-commit: glob: "bin/kit" run: shellcheck --shell=bash --external-sources {staged_files} # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, so - # nobody remembers just refresh. stage_fixed adds what the refresh rewrote to the commit. + # nobody remembers just refresh. The command stages what it rewrote itself: stage_fixed would re-stage only + # the source paths it was handed, and the copies live elsewhere. refresh: glob: "{tree,targets}/**" - run: just refresh - stage_fixed: true + run: just refresh && git add .kit .claude .github/workflows/kit-ci.yml diff --git a/tree/.kit/rules/json.md b/tree/.kit/rules/json.md index 692c8aa..18391b6 100644 --- a/tree/.kit/rules/json.md +++ b/tree/.kit/rules/json.md @@ -25,3 +25,5 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. + + From 030ef20c00ae51fb0cdca0f6a9210603d72edeb0 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:05:27 -0700 Subject: [PATCH 036/108] #1 tree/.kit/rules/json.md: the proof line goes Co-Authored-By: Claude Fable 5.1 --- .claude/rules/kit-json.md | 2 -- .kit/manifest | 4 ++-- .kit/rules/json.md | 2 -- tree/.kit/rules/json.md | 2 -- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md index 18391b6..692c8aa 100644 --- a/.claude/rules/kit-json.md +++ b/.claude/rules/kit-json.md @@ -25,5 +25,3 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. - - diff --git a/.kit/manifest b/.kit/manifest index 28e8b54..d9696d9 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -3,7 +3,7 @@ version unreleased targets claude 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .claude/rules/kit-bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md -85506123b2e18da1ecca6f4342c344667fe2f1bfb185dc78bc76958ead1fb7c6 .claude/rules/kit-json.md +1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .claude/rules/kit-just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md @@ -13,7 +13,7 @@ a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md dc9d98436a1f4ac03a7b92cc844d7a2064fbca89ebe21bc8ff243e25f8d0225c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md -85506123b2e18da1ecca6f4342c344667fe2f1bfb185dc78bc76958ead1fb7c6 .kit/rules/json.md +1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md 98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .kit/rules/just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .kit/rules/markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .kit/rules/yaml.md diff --git a/.kit/rules/json.md b/.kit/rules/json.md index 18391b6..692c8aa 100644 --- a/.kit/rules/json.md +++ b/.kit/rules/json.md @@ -25,5 +25,3 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. - - diff --git a/tree/.kit/rules/json.md b/tree/.kit/rules/json.md index 18391b6..692c8aa 100644 --- a/tree/.kit/rules/json.md +++ b/tree/.kit/rules/json.md @@ -25,5 +25,3 @@ Applies to `*.json` and `*.jsonc`. 1. A comment states what the entry does and the non-obvious why. It carries no history. 2. A pointer is `// see: `. - - From fa8881916611a90067e10ae662ed3d4c75b916ae Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:07:35 -0700 Subject: [PATCH 037/108] #1 mise.toml: actionlint runs on a desk too, so it is shared and mise.ci.toml goes actionlint reads workflow files and needs no runner. It is pinned with the shared tools, back in the pre-commit fragment over the workflows, and the gate is just ci wherever it runs. MISE_ENV=ci stays in the delivered job for a consumer with a runner-only tool. Co-Authored-By: Claude Fable 5.1 --- .kit/lefthook.yml | 3 +++ .kit/manifest | 2 +- README.md | 2 +- justfile | 11 +++++------ mise.ci.lock | 32 -------------------------------- mise.ci.toml | 6 ------ mise.lock | 29 +++++++++++++++++++++++++++++ mise.toml | 7 ++++--- tree/.kit/lefthook.yml | 3 +++ 9 files changed, 46 insertions(+), 49 deletions(-) delete mode 100644 mise.ci.lock delete mode 100644 mise.ci.toml diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml index bd0f61d..aea7ecf 100644 --- a/.kit/lefthook.yml +++ b/.kit/lefthook.yml @@ -12,3 +12,6 @@ pre-commit: shellcheck: glob: "*.{sh,bash}" run: shellcheck --shell=bash --external-sources {staged_files} + actionlint: + glob: ".github/workflows/*.{yml,yaml}" + run: actionlint {staged_files} diff --git a/.kit/manifest b/.kit/manifest index d9696d9..7e1a54a 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -10,7 +10,7 @@ targets claude 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json 54014b457cbb469ee2d47d80edd5bc74cb1670afc235fc223d1f4e77ea78e859 .github/workflows/kit-ci.yml a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md -dc9d98436a1f4ac03a7b92cc844d7a2064fbca89ebe21bc8ff243e25f8d0225c .kit/lefthook.yml +4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md diff --git a/README.md b/README.md index 1a24c47..7b705bc 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ lefthook install `MISE_ENV=desk` belongs in your shell profile. mise reads `mise.toml` always, `mise.desk.toml` on top of it when `MISE_ENV=desk`, and `mise.ci.toml` when `MISE_ENV=ci`, which the CI workflow sets: a tool goes in the file of -whoever runs it. Lefthook is a desk's; actionlint is the runner's. +whoever runs it. Lefthook is a desk's; nothing here is the runner's alone. This repository carries the installed copy of its own kit, and `just ci` checks it. diff --git a/justfile b/justfile index f06328f..04a8e40 100644 --- a/justfile +++ b/justfile @@ -20,13 +20,12 @@ _: help set export := true -# The runner's gate, run with MISE_ENV=ci so mise.ci.toml's tools are on hand: shellcheck over every bash file, -# actionlint over every workflow, the bats suite, and bin/kit check over this repository's installed copy of its -# own kit. A desk runs it the same way: MISE_ENV=ci just ci. The pre-commit hook on a desk is the narrower, -# faster check lefthook.yml declares. +# The gate: shellcheck over every bash file, actionlint over every workflow, the bats suite, and bin/kit check over +# this repository's installed copy of its own kit. The runner runs it on every pull request, and a desk runs it the +# same way; the pre-commit hook is the narrower, faster check over the staged paths. # -# MISE_ENV=ci just ci -[doc("🚦 The runner's gate: shellcheck, actionlint, bats, kit check (MISE_ENV=ci)")] +# just ci +[doc("🚦 The gate: shellcheck, actionlint, bats, kit check")] ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail diff --git a/mise.ci.lock b/mise.ci.lock deleted file mode 100644 index 6d43300..0000000 --- a/mise.ci.lock +++ /dev/null @@ -1,32 +0,0 @@ -# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html - -lockfile_version = 3 - -[[tools.actionlint]] -version = "1.7.12" -backend = "aqua:rhysd/actionlint" -specifiers = ["1.7"] - -[tools.actionlint."platforms.linux-arm64"] -checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" -provenance = "github-attestations" - -[tools.actionlint."platforms.linux-x64"] -checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" -provenance = "github-attestations" - -[tools.actionlint."platforms.macos-arm64"] -checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" -provenance = "github-attestations" - -[tools.actionlint."platforms.macos-x64"] -checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" -url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" -url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" -provenance = "github-attestations" diff --git a/mise.ci.toml b/mise.ci.toml deleted file mode 100644 index face1c9..0000000 --- a/mise.ci.toml +++ /dev/null @@ -1,6 +0,0 @@ -# mise.ci.toml - the tools only a runner runs, read when MISE_ENV=ci; see: mise.toml -# -# actionlint checks the workflows, which only the runner executes; a desk runs it with MISE_ENV=ci when it wants to. - -[tools] -actionlint = "1.7" diff --git a/mise.lock b/mise.lock index 7a2a4a8..9ad23a6 100644 --- a/mise.lock +++ b/mise.lock @@ -2,6 +2,35 @@ lockfile_version = 3 +[[tools.actionlint]] +version = "1.7.12" +backend = "aqua:rhysd/actionlint" +specifiers = ["1.7"] + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" +provenance = "github-attestations" + [[tools.bats]] version = "1.13.0" backend = "aqua:bats-core/bats-core" diff --git a/mise.toml b/mise.toml index 98f3348..9b061db 100644 --- a/mise.toml +++ b/mise.toml @@ -1,8 +1,8 @@ # mise.toml - the pinned tools a desk and a runner share; see: https://mise.jdx.dev/configuration/environments.html # -# Three files, one mechanism: mise reads this one always, mise.desk.toml on top of it when MISE_ENV=desk, which a -# desk exports in its shell profile, and mise.ci.toml when MISE_ENV=ci, which the CI workflow sets. A tool goes in -# the file of whoever runs it. `lockfile = true` records each pin's exact resolution in a lock beside its file, +# One mechanism: mise reads this file always, mise.desk.toml on top of it when MISE_ENV=desk, which a desk exports +# in its shell profile, and mise.ci.toml when MISE_ENV=ci, which the CI workflow sets. A tool goes in the file of +# whoever runs it; today only Lefthook is a desk's alone, and nothing is the runner's alone. `lockfile = true` records each pin's exact resolution in a lock beside its file, # and lockfile_platforms makes a lock written on a desk carry the runner's platform. git and bash come from the # machine. @@ -20,4 +20,5 @@ gh = "2.103" jq = "1.8" just = "1.59" shellcheck = "0.11" +actionlint = "1.7" bats = "1.13" diff --git a/tree/.kit/lefthook.yml b/tree/.kit/lefthook.yml index bd0f61d..aea7ecf 100644 --- a/tree/.kit/lefthook.yml +++ b/tree/.kit/lefthook.yml @@ -12,3 +12,6 @@ pre-commit: shellcheck: glob: "*.{sh,bash}" run: shellcheck --shell=bash --external-sources {staged_files} + actionlint: + glob: ".github/workflows/*.{yml,yaml}" + run: actionlint {staged_files} From a3ff18b396aa024b41346d2ee125be37335e022a Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:10:02 -0700 Subject: [PATCH 038/108] #1 markdownlint, .editorconfig, dependabot: the repository's own gates and upkeep markdownlint-cli2 over every markdown file, in the pre-commit hook and the gate, with the rule state in .markdownlint.jsonc naming the convention behind each entry. .editorconfig for editors. Dependabot for the SHA-pinned actions, weekly; mise outdated answers for the tools. Co-Authored-By: Claude Fable 5.1 --- .editorconfig | 18 + .github/dependabot.yml | 18 + .markdownlint-cli2.jsonc | 10 + .markdownlint.jsonc | 23 + .../0.24.0/aube-lock.yaml | 735 ++++++++++++++++++ .../npm-markdownlint-cli2/0.24.0/package.json | 7 + justfile | 6 +- lefthook.yml | 3 + mise.lock | 6 + mise.toml | 1 + 10 files changed, 825 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .markdownlint-cli2.jsonc create mode 100644 .markdownlint.jsonc create mode 100644 .mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml create mode 100644 .mise/locks/npm-markdownlint-cli2/0.24.0/package.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f5a3108 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# .editorconfig - see: https://editorconfig.org +# ---------------------------------------------------------------------------------------------------------------------- + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# a trailing double space is a hard break +[*.md] +trim_trailing_whitespace = false + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4fa4348 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# dependabot.yml - keeps the SHA-pinned actions in the workflows current +# ---------------------------------------------------------------------------------------------------------------------- +# +# Every `uses:` in .github/workflows/ is pinned to a full commit SHA with the version as a trailing comment, and a +# SHA never advances on its own. Dependabot's github-actions ecosystem opens a pull request that moves the SHA and +# the comment together, weekly. The tools come from mise.toml, which Dependabot does not read; `mise outdated` +# answers for those. +# +# see: https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..c676c99 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,10 @@ +// .markdownlint-cli2.jsonc - markdownlint-cli2 options: what the linter scopes in and out +// +// Rule configuration lives in .markdownlint.jsonc; this file defines scope. The repository's .gitignore files are +// the source of truth for exclusions, so worktrees/ is never linted, and the glob form scopes to ignore files +// inside the tree being linted, so a per-issue worktree lints identically to the repository root checkout. +// +// see: https://github.com/DavidAnson/markdownlint-cli2#markdownlint-cli2jsonc +{ + "gitignore": "**/.gitignore" +} diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..53d88f5 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,23 @@ +// .markdownlint.jsonc - markdownlint rule configuration: which built-in rules this repository turns off or tightens +// +// Scope lives in .markdownlint-cli2.jsonc; this file carries only rule state. A rule is off because it collides +// with a convention the markdown rule states its own way, so every entry names the rule and the convention; a +// rule is tightened where the default is looser than the convention. +// +// see: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +// see: tree/.kit/rules/markdown.md +{ + // MD003 heading-style, tightened: the markdown rule says ATX, and "consistent" would accept a setext file. + "MD003": { "style": "atx" }, + + // MD013 line-length: the 120-column ceiling the markdown rule sets; fenced code and tables do not wrap. + "MD013": { "line_length": 120, "code_blocks": false, "tables": false }, + + // MD041 first-line-heading: AGENTS.md opens with the `@.kit/1st.md` import, and a delivered file opens with its + // notice; the title follows. + "MD041": false, + + // MD060 table-column-style: the markdown rule asks for ` | ` around each pipe and nothing about column widths, + // which this rule's aligned default would fight column by column. + "MD060": false +} diff --git a/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml b/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml new file mode 100644 index 0000000..4a6b0cf --- /dev/null +++ b/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml @@ -0,0 +1,735 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + markdownlint-cli2: + specifier: 0.24.0 + version: 0.24.0 + +packages: + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + argparse@3.0.3: + resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@16.2.4: + resolution: {integrity: sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==} + engines: {node: '>=20'} + + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} + engines: {node: '>= 4'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + + markdown-it@15.0.3: + resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==} + hasBin: true + + markdownlint-cli2-formatter-default@0.0.6: + resolution: {integrity: sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==} + peerDependencies: + markdownlint-cli2: '>=0.0.4' + + markdownlint-cli2@0.24.0: + resolution: {integrity: sha512-c5hU2MDoTlS230MLzdNlkIyxImXq5acxGKz4nYz4aDhH/2BgFtpsA7iK9/OoTHMgZ4FuR0wedTQA31K6Lp9I/Q==} + engines: {node: '>=22'} + hasBin: true + + markdownlint@0.42.0: + resolution: {integrity: sha512-kzsCV/rPWRFn3jUhcB919YF4Qgeh0g9tcogoXFadGSo6Zxj6662YOqWXv0jDn8nv5KCCxDfMKJZv6N4HkNkvTg==} + engines: {node: '>=22'} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + re2js@2.8.6: + resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==} + engines: {node: '>=18.0.0'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + + unicorn-magic@0.4.1: + resolution: {integrity: sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==} + engines: {node: '>=20'} + +snapshots: + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@sindresorhus/merge-streams@4.0.0': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/katex@0.16.8': {} + + '@types/ms@2.1.0': {} + + '@types/unist@2.0.11': {} + + ansi-regex@6.4.0: {} + + argparse@2.0.1: {} + + argparse@3.0.3: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + commander@8.3.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + entities@8.1.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + get-east-asian-width@1.7.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@16.2.4: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.13 + is-path-inside: 4.0.0 + micromatch: 4.0.8 + slash: 5.1.0 + unicorn-magic: 0.4.1 + + ignore@7.0.13: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + js-yaml@5.4.3: + dependencies: + argparse: 2.0.1 + + jsonc-parser@3.3.1: {} + + jsonpointer@5.0.1: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + + markdown-it@15.0.3: + dependencies: + argparse: 3.0.3 + entities: 8.1.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + + markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.24.0): + dependencies: + markdownlint-cli2: 0.24.0 + + markdownlint-cli2@0.24.0: + dependencies: + globby: 16.2.4 + js-yaml: 5.4.3 + jsonc-parser: 3.3.1 + jsonpointer: 5.0.1 + markdown-it: 15.0.3 + markdownlint: 0.42.0 + markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.24.0) + micromatch: 4.0.8 + smol-toml: 1.9.1 + transitivePeerDependencies: + - supports-color + + markdownlint@0.42.0: + dependencies: + micromark: 4.0.3 + micromark-core-commonmark: 2.0.4 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-math: 3.1.0 + micromark-util-types: 2.0.3 + re2js: 2.8.6 + string-width: 8.3.0 + transitivePeerDependencies: + - supports-color + + mdurl@2.1.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + ms@2.1.3: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picomatch@2.3.2: {} + + punycode.js@2.3.1: {} + + queue-microtask@1.2.3: {} + + re2js@2.8.6: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + slash@5.1.0: {} + + smol-toml@1.9.1: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + uc.micro@3.0.0: {} + + unicorn-magic@0.4.1: {} diff --git a/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json b/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json new file mode 100644 index 0000000..52130b8 --- /dev/null +++ b/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "markdownlint-cli2": "0.24.0" + } +} diff --git a/justfile b/justfile index 04a8e40..c0d6a74 100644 --- a/justfile +++ b/justfile @@ -20,17 +20,19 @@ _: help set export := true -# The gate: shellcheck over every bash file, actionlint over every workflow, the bats suite, and bin/kit check over +# The gate: shellcheck over every bash file, actionlint over every workflow, markdownlint over every markdown file, +# the bats suite, and bin/kit check over # this repository's installed copy of its own kit. The runner runs it on every pull request, and a desk runs it the # same way; the pre-commit hook is the narrower, faster check over the staged paths. # # just ci -[doc("🚦 The gate: shellcheck, actionlint, bats, kit check")] +[doc("🚦 The gate: shellcheck, actionlint, markdownlint, bats, kit check")] ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail mise exec -- shellcheck --shell=bash --external-sources bin/kit bin/*.sh mise exec -- actionlint + mise exec -- markdownlint-cli2 '**/*.md' mise exec -- bats --print-output-on-failure tests/ bin/kit check echo "✅ ci passed" diff --git a/lefthook.yml b/lefthook.yml index a348d5f..a9a1ced 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -13,6 +13,9 @@ pre-commit: shellcheck-dispatcher: glob: "bin/kit" run: shellcheck --shell=bash --external-sources {staged_files} + markdownlint: + glob: "*.md" + run: markdownlint-cli2 {staged_files} # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, so # nobody remembers just refresh. The command stages what it rewrote itself: stage_fixed would re-stage only # the source paths it was handed, and the copies live elsewhere. diff --git a/mise.lock b/mise.lock index 9ad23a6..67b6288 100644 --- a/mise.lock +++ b/mise.lock @@ -195,6 +195,12 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" +[[tools."npm:markdownlint-cli2"]] +version = "0.24.0" +aube = { path = ".mise/locks/npm-markdownlint-cli2/0.24.0", digest = "sha256:689e86a3f956c317400e5d3bcf0915902a43dbbb8aeec7210a3aa3efb6859684" } +backend = "npm:markdownlint-cli2" +specifiers = ["0.24"] + [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index 9b061db..ee6ed0c 100644 --- a/mise.toml +++ b/mise.toml @@ -18,6 +18,7 @@ _.path = ["bin"] [tools] gh = "2.103" jq = "1.8" +"npm:markdownlint-cli2" = "0.24" just = "1.59" shellcheck = "0.11" actionlint = "1.7" From 3d2aeab68958d759740bf3cab0cd74605c051fbd Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:15:20 -0700 Subject: [PATCH 039/108] #1 mise.toml, mise.desk.toml: every tool at its newest release Co-Authored-By: Claude Fable 5.1 --- mise.lock | 74 ++++++++++++++++++------------------------------------- mise.toml | 2 +- 2 files changed, 25 insertions(+), 51 deletions(-) diff --git a/mise.lock b/mise.lock index 67b6288..937bbd2 100644 --- a/mise.lock +++ b/mise.lock @@ -32,80 +32,54 @@ url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/3849248 provenance = "github-attestations" [[tools.bats]] -version = "1.13.0" +version = "1.14.0" backend = "aqua:bats-core/bats-core" -specifiers = ["1.13"] +specifiers = ["1.14"] [tools.bats."platforms.linux-arm64"] -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" - -[tools.bats."platforms.linux-arm64-musl"] -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" [tools.bats."platforms.linux-x64"] -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" - -[tools.bats."platforms.linux-x64-musl"] -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" [tools.bats."platforms.macos-arm64"] -checksum = "blake3:d34af9fd0f9101d5309b2de31f3d1699e56a574d7d4b5e6380580a9e758a9744" -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" +checksum = "blake3:9b3dc5d296f3da85ce1062c7f9648c979cf2b982f0151f785eaa84d448dec779" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" [tools.bats."platforms.macos-x64"] -url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.13.0.tar.gz" -url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.13.0" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" [[tools.gh]] -version = "2.103.0" +version = "2.103.2" backend = "aqua:cli/cli" specifiers = ["2.103"] [tools.gh."platforms.linux-arm64"] -checksum = "sha256:7f322df34e624d14f5ec0c4acb121fd6dfa14392fdbbd1066aa8e0a05ac3721e" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956401" -provenance = "github-attestations" - -[tools.gh."platforms.linux-arm64-musl"] -checksum = "sha256:7f322df34e624d14f5ec0c4acb121fd6dfa14392fdbbd1066aa8e0a05ac3721e" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956401" +checksum = "sha256:4c3e99a50bd8b181a3fe9d69b25dd978bac768148d88f01507c6b4d1ef61a443" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305069" provenance = "github-attestations" [tools.gh."platforms.linux-x64"] -checksum = "sha256:fdd768eb70f6409f80fcfa9f3a1c0adf0fc98d827ab3ce0bed2ac22762c5d786" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956379" -provenance = "github-attestations" - -[tools.gh."platforms.linux-x64-musl"] -checksum = "sha256:fdd768eb70f6409f80fcfa9f3a1c0adf0fc98d827ab3ce0bed2ac22762c5d786" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956379" +checksum = "sha256:62ccf747115ddc61237efffdde66a3c8f70ba249433dc64f70801089b59c59c8" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305062" provenance = "github-attestations" [tools.gh."platforms.macos-arm64"] -checksum = "sha256:54be566e9f614bea5de8021e95b9ad33dfee3b9d2ea120c9c294c00defaa5014" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_macOS_arm64.zip" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956481" +checksum = "sha256:5416000f5d17580a187339e0c4068a01ca5c87f7d4fe6ca64933696f967bc418" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_macOS_arm64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305145" provenance = "github-attestations" [tools.gh."platforms.macos-x64"] -checksum = "sha256:64d1734941f104f996271c820daa50e96902c2618f9b4c1e63c41519517a9c6b" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_macOS_amd64.zip" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956458" -provenance = "github-attestations" - -[tools.gh."platforms.windows-x64"] -checksum = "sha256:80c846a5b8432672ca6373b34ce9de998c9686aaa1885c845f2e1ee81f713726" -url = "https://github.com/cli/cli/releases/download/v2.103.0/gh_2.103.0_windows_amd64.zip" -url_api = "https://api.github.com/repos/cli/cli/releases/assets/662956535" +checksum = "sha256:2dc7f1fc4761f96732995f6e4f4c6b4d705f2635da4126620c8b30eaa67b9c98" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_macOS_amd64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305131" provenance = "github-attestations" [[tools.jq]] diff --git a/mise.toml b/mise.toml index ee6ed0c..00c0da8 100644 --- a/mise.toml +++ b/mise.toml @@ -22,4 +22,4 @@ jq = "1.8" just = "1.59" shellcheck = "0.11" actionlint = "1.7" -bats = "1.13" +bats = "1.14" From a6e5177f56a8943d7defd29d9c4b4ad8553c665d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:16:38 -0700 Subject: [PATCH 040/108] #1 README.md: a range pin sees a new kit a day late unless the kit is excluded from the release-age hold Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7b705bc..b890439 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ mise install kit install claude ``` +An exact pin installs the minute a release exists. If you pin a range instead, add +`minimum_release_age_excludes = ["github:empowerite/kit"]` under `[settings]`, or `mise outdated` and +`mise upgrade` offer a new kit only a day after it ships. + `kit install` copies `.kit/1st.md`, `.kit/rules/`, and `.kit/lefthook.yml` into the repository, places `.github/workflows/kit-ci.yml`, and writes `.kit/manifest`. Each target named adds what that harness insists on having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and merges the kit's deny rules into From c1fd8d76f6e9b1cdd573e04551682053976e8bb1 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:29:29 -0700 Subject: [PATCH 041/108] #1 bin: four defects from review kit update refuses a path new in the release where the repository already has a different file, and refuses to update any file the previous release delivered without that release as the base, so a merged .json never keeps stale entries. A dangling link in the release is refused where it is found. kit version answers --help. The retire workflow lists every release, not thirty. release:delivered prints path and source together, so the layers are walked once. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/retire-pre-releases.yml | 2 +- .gitignore | 2 +- bin/kit-check.sh | 3 +- bin/kit-install.sh | 4 +- bin/kit-update.sh | 71 ++++++++++++++--------- bin/kit-version.sh | 7 +++ bin/release.sh | 46 +++++++++------ tests/kit.bats | 14 ++++- 8 files changed, 97 insertions(+), 52 deletions(-) diff --git a/.github/workflows/retire-pre-releases.yml b/.github/workflows/retire-pre-releases.yml index 8229061..891c73a 100644 --- a/.github/workflows/retire-pre-releases.yml +++ b/.github/workflows/retire-pre-releases.yml @@ -52,7 +52,7 @@ jobs: gh release delete "${tag}" --cleanup-tag --yes echo "retired ${tag}: no branch reaches ${commit:0:7} since ${deleted} was deleted" retired=$(( retired + 1 )) - done < <(gh release list --json tagName,isPrerelease --jq '.[] | select(.isPrerelease) | .tagName') + done < <(gh release list --limit 1000 --json tagName,isPrerelease --jq '.[] | select(.isPrerelease) | .tagName') echo "✅ Retired ${retired} pre-release(s)." # ---------------------------------------------------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index 2fb096f..ba8b0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # .gitignore - untracked files and folders # ---------------------------------------------------------------------------------------------------------------------- -# the per-issue worktrees of the loop; see: .kit/1st.md +# per-issue worktrees for agents; see: .kit/1st.md /worktrees/ # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-check.sh b/bin/kit-check.sh index 0cb5f12..257c13a 100755 --- a/bin/kit-check.sh +++ b/bin/kit-check.sh @@ -24,9 +24,8 @@ then error ".kit/manifest records kit ${version}, and the pinned release is $(release:version); run: kit update" findings=$(( findings + 1 )) fi -while IFS= read -r path +while IFS=$'\t' read -r path src do - src=$(release:source-of "${kit_root}" "${path}" "${targets[@]}") if [[ ! -f "${path}" ]] then error "${path} is missing" diff --git a/bin/kit-install.sh b/bin/kit-install.sh index 69f27bb..eb0ed00 100755 --- a/bin/kit-install.sh +++ b/bin/kit-install.sh @@ -23,10 +23,10 @@ then exit 1 fi section "kit install $(release:version)${*:+, targets: $*}" -while IFS= read -r path +while IFS=$'\t' read -r path src do step "${path}" - release:place "$(release:source-of "${kit_root}" "${path}" "$@")" "${path}" + release:place "${src}" "${path}" done < <(release:delivered "${kit_root}" "$@") release:write-manifest "$@" release:finish "$@" diff --git a/bin/kit-update.sh b/bin/kit-update.sh index 3552913..88eb004 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -2,9 +2,9 @@ # kit-update - after the pin is bumped: replace unchanged files, three-way merge changed ones (kit verb, run through bin/kit) # ---------------------------------------------------------------------------------------------------------------------- # -# An untouched file is one whose digest still matches the manifest, which records the previous release's own -# content; it is replaced. An edited one is merged with the previous release as the base: KIT_BASE= names -# that release's root, otherwise mise knows where it installed it. +# A file whose digest still matches the manifest, which records the previous release's own content, is replaced. +# An edited one is three-way merged with the previous release as the base. A path the previous release did not +# deliver is written only where the repository has nothing, or the same thing, there. # # see: bin/release.sh @@ -26,11 +26,14 @@ previous=$(release:manifest-version) current=$(release:version) section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" +# The previous release is the base of every merge, and a merge is needed only when the release changed. KIT_BASE +# names the base's root; otherwise mise knows where it installed it. base_root="" +# shellcheck disable=SC2154 # release_unreleased is declared by bin/release.sh, which use loaded above if [[ -n "${KIT_BASE:-}" ]] then base_root=${KIT_BASE} -elif [[ "${previous}" != "${current}" && "${previous}" != unreleased ]] && command -v mise >/dev/null 2>&1 +elif [[ "${previous}" != "${current}" && "${previous}" != "${release_unreleased}" ]] && command -v mise >/dev/null 2>&1 then base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null @@ -38,43 +41,57 @@ then base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) fi fi +needs_base=false +[[ "${previous}" != "${current}" ]] && needs_base=true conflicts=0 -while IFS= read -r path +while IFS=$'\t' read -r path src do - src=$(release:source-of "${kit_root}" "${path}" "${targets[@]}") + recorded=$(awk -v p="${path}" '$2 == p { print $1 }' .kit/manifest) + if [[ -z "${recorded}" ]] + then + # A path the previous release did not deliver: written where the repository has nothing, or the same thing. + if release:merged "${path}" || [[ ! -f "${path}" ]] + then + step "${path}: new in ${current}, written" + release:place "${src}" "${path}" + elif cmp --silent "${src}" "${path}" + then + step "${path}: new in ${current}, and already as the release has it" + else + error "${path} is new in ${current} and the repository has a file there; move yours aside, then run: kit update" + conflicts=$(( conflicts + 1 )) + fi + continue + fi base="" - [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${targets[@]}" || true) + [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${targets[@]}") + if [[ "${needs_base}" == true && -z "${base}" ]] + then + # Without the base, neither a merge nor the removal of the previous release's entries is possible. + error "${path} cannot be updated without the base release ${previous}; run: mise install github:empowerite/kit@${previous}" + conflicts=$(( conflicts + 1 )) + continue + fi if release:merged "${path}" then step "${path}: merged by key" release:place "${src}" "${path}" "${base}" - continue - fi - if [[ ! -f "${path}" ]] + elif [[ ! -f "${path}" ]] then step "${path}: missing, written" release:place "${src}" "${path}" - continue - fi - recorded=$(awk -v p="${path}" '$2==p{print $1}' .kit/manifest) - if [[ -z "${recorded}" || "${recorded}" == "$(release:digest < "${path}")" ]] + elif [[ "${recorded}" == "$(release:digest < "${path}")" ]] then step "${path}: unchanged since ${previous}, replaced" release:place "${src}" "${path}" - continue - fi - if [[ -z "${base}" ]] - then - error "${path} was edited and the base release ${previous} is not installed; run: mise install github:empowerite/kit@${previous}" - conflicts=$(( conflicts + 1 )) - continue - fi - step "${path}: edited since ${previous}, three-way merged" - if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" - then - warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" - conflicts=$(( conflicts + 1 )) + else + step "${path}: edited since ${previous}, three-way merged" + if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" + then + warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" + conflicts=$(( conflicts + 1 )) + fi fi done < <(release:delivered "${kit_root}" "${targets[@]}") diff --git a/bin/kit-version.sh b/bin/kit-version.sh index 0895204..f4e07e3 100755 --- a/bin/kit-version.sh +++ b/bin/kit-version.sh @@ -6,6 +6,13 @@ set -o errexit -o nounset -o pipefail # shellcheck source=bin/1st.sh source "${kit_root:?run through bin/kit}/bin/1st.sh" use release + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit version" >&2 + exit 0 +fi + release:version # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/release.sh b/bin/release.sh index 2ddbc54..a4d7663 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -11,10 +11,13 @@ # # Every function reads the release from kit_root, exported by bin/kit. -# release:version prints this release's version: the file the release workflow stamps from the tag, and -# "unreleased" in a checkout, which is what this repository's own installed copy records. +# A release carries a version file the release workflow stamps from the tag; a checkout carries none and is +# "unreleased", the one version kit update never asks mise for. +declare -gr release_unreleased=unreleased + +# release:version prints this release's version. release:version() { - cat "${kit_root}/version" 2>/dev/null || printf 'unreleased\n' + cat "${kit_root}/version" 2>/dev/null || printf '%s\n' "${release_unreleased}" } # release:layers … prints the directories mirrored at the repository root, tree first. @@ -28,29 +31,36 @@ release:layers() { done } -# release:delivered … prints every repository path the release at delivers for those targets. +# release:delivered … prints "\t" for every repository path the release at +# delivers for those targets, sorted by path, the last layer winning. A symlink is a source only when it points +# at a file; a dangling one is refused here, where it is found. release:delivered() { - local layer + local layer path src while IFS= read -r layer do - (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@') - done < <(release:layers "$@") | sort --unique + (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@' | awk -v l="${layer}" '{ print $0 "\t" l "/" $0 }') + done < <(release:layers "$@") | awk -F '\t' '{ src[$1] = $2 } END { for (p in src) print p "\t" src[p] }' | sort \ + | while IFS=$'\t' read -r path src + do + if [[ -L "${src}" && ! -e "${src}" ]] + then + error "${src} is a link to nothing; the release is broken" + return 1 + fi + printf '%s\t%s\n' "${path}" "${src}" + done } -# release:source-of … prints the release file behind a repository path, the last layer winning. +# release:source-of … prints the release file behind one repository path, or nothing. release:source-of() { - local root=$1 path=$2 layer found="" + local root=$1 path=$2 shift 2 - while IFS= read -r layer - do - [[ -e "${layer}/${path}" ]] && found="${layer}/${path}" - done < <(release:layers "${root}" "$@") - [[ -n "${found}" ]] && printf '%s\n' "${found}" + release:delivered "${root}" "$@" | awk -F '\t' -v p="${path}" '$1 == p { print $2 }' } # release:target-only prints the paths the target delivers that tree/ does not. release:target-only() { - comm -13 <(release:delivered "${kit_root}") <(release:delivered "${kit_root}" "$1") + comm -13 <(release:delivered "${kit_root}" | cut -f 1) <(release:delivered "${kit_root}" "$1" | cut -f 1) } # release:known-targets prints the targets this release carries. @@ -134,14 +144,14 @@ release:place() { # release:write-manifest … records the version, the targets, and the release's digest for every delivered path. release:write-manifest() { - local path + local path src { printf '# .kit/manifest - written by kit, not by hand\n' printf 'version %s\n' "$(release:version)" printf 'targets%s\n' "${*:+ $*}" - while IFS= read -r path + while IFS=$'\t' read -r path src do - printf '%s %s\n' "$(release:digest < "$(release:source-of "${kit_root}" "${path}" "$@")")" "${path}" + printf '%s %s\n' "$(release:digest < "${src}")" "${path}" done < <(release:delivered "${kit_root}" "$@") } > .kit/manifest } diff --git a/tests/kit.bats b/tests/kit.bats index 55ba6cb..75b5e02 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -146,6 +146,18 @@ later-release() { rm -rf "${repo}.later" } +@test "update refuses a path new in the release where the repository already has a different file" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + printf 'new rule\n' > "${repo}.later/tree/.kit/rules/toml.md" + printf 'mine\n' > .kit/rules/toml.md + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/toml.md is new in 9.9.9"* ]] + [ "$(cat .kit/rules/toml.md)" = "mine" ] + rm -rf "${repo}.later" +} + @test "update of an untouched install leaves check green" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" @@ -176,7 +188,7 @@ later-release() { mkdir shims && printf '#!/usr/bin/env bash\nexit 1\n' > shims/mise && chmod +x shims/mise run env PATH="${PWD}/shims:${PATH}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] - [[ "$output" == *"base release unreleased is not installed"* ]] + [[ "$output" == *"cannot be updated without the base release unreleased"* ]] rm -rf "${repo}.later" } From 71d3cca5a94d4ec37c9b82fcb77aca86175acea6 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:31:05 -0700 Subject: [PATCH 042/108] #1 bin/git.sh: no state shared through dynamic scope git:worktrees prints one record per linked worktree and the callers loop over it. git:status-block and git:tidy-worktree take what they need as arguments; tidy-worktree answers with its status, and the caller counts and records what was freed. git:default-branch and git:main-root take a path, so git:rebase no longer re-derives the default branch. The tidy test calls the decision directly. Co-Authored-By: Claude Fable 5.1 --- bin/git.sh | 163 ++++++++++++++++++++++++------------------------ tests/tidy.bats | 11 +--- 2 files changed, 85 insertions(+), 89 deletions(-) diff --git a/bin/git.sh b/bin/git.sh index 4738cd0..6e7cec1 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -1,50 +1,39 @@ # shellcheck shell=bash -# git.sh - the default branch and the main root, the picture of the worktrees, the sweep, and the rebase (sourced module) +# git.sh - the default branch, the worktrees, the sweep, and the rebase (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# git:status [--no-fetch] renders the main checkout on one line and one block per worktree under worktrees/ -# with its branch, pull request, and dirty or clean state, then the local branches whose upstream is gone. It -# changes nothing. +# git:status [--no-fetch] renders the main checkout on one line and one block per worktree under worktrees/, with +# its branch, pull request, and dirty or clean state, then the local branches whose upstream is gone. It changes +# nothing. # # git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, or that is # detached, clean, and at a commit the default branch already has; prunes the worktree bookkeeping; and deletes -# every local branch whose upstream is gone. It never touches the default branch, a branch checked out in a live -# worktree, a dirty worktree, a detached worktree whose commit nothing else reaches, or the worktree it runs in. --dry-run -# prints what a sweep would do and changes nothing. +# every local branch whose upstream is gone. It never touches the default branch, a branch checked out in a +# live worktree, a dirty worktree, a detached worktree whose commit nothing else reaches, or the worktree it +# runs in. --dry-run says what a sweep would do and changes nothing. # # git:rebase [] rebases the branch checked out at , the current checkout by default, onto -# origin/ and pushes it with --force-with-lease. It refuses the default branch, the human's desk; on a -# conflict it aborts and leaves the worktree as it was. +# origin/ and pushes it with --force-with-lease. It refuses the default branch, the human's desk, and +# on a conflict aborts and leaves the worktree as it was. -# git:default-branch prints the default branch: origin/HEAD, with main as the fallback. +# git:default-branch [] prints the default branch of the repository at : origin/HEAD, else main. git:default-branch() { local branch - branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) + branch=$(git -C "${1:-.}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) [[ -n "${branch}" ]] || branch=main printf '%s\n' "${branch}" } -# git:main-root prints the root of the main checkout, the first entry of the worktree list. worktrees/ lives -# under it whether the caller stands in the main checkout or in a linked worktree. +# git:main-root [] prints the root of the main checkout, the first entry of the worktree list. worktrees/ +# lives under it whether the caller stands in the main checkout or in a linked worktree. git:main-root() { - git worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 + git -C "${1:-.}" worktree list --porcelain | sed -n 's@^worktree @@p' | head -n 1 } -# git:pr-summary prints "\t" for the open pull request on , or nothing. -git:pr-summary() { - gh pr list --head "$1" --state open --json number,isDraft \ - --jq '.[0] | select(.number) | "\(.number)\t\(if .isDraft then "Draft" else "Ready" end)"' 2>/dev/null || true -} - -# git:gone-branches prints every local branch whose upstream no longer exists. -git:gone-branches() { - git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2=="[gone]"{print $1}' -} - -# git:each-worktree calls for every linked worktree under -# worktrees/. is empty and is true for a detached checkout. -git:each-worktree() { - local callback=$1 worktree_root path="" branch="" detached=false line +# git:worktrees prints "\t\t" for every linked worktree under worktrees/; is +# empty and is true for a detached checkout. +git:worktrees() { + local worktree_root path="" branch="" detached=false line worktree_root="$(git:main-root)/worktrees" while IFS= read -r line do @@ -61,23 +50,33 @@ git:each-worktree() { detached=true ;; ("") - [[ -n "${path}" ]] || continue - case "${path}" in - ("${worktree_root}"/*) - "${callback}" "${path}" "${branch}" "${detached}" - esac + # git ends every record with a blank line; the printf below adds one so the last record ends too. + [[ "${path}" == "${worktree_root}"/* ]] && printf '%s\t%s\t%s\n' "${path}" "${branch}" "${detached}" path="" esac done < <(git worktree list --porcelain; printf '\n') } -# git:status-block renders one worktree. git:status sets found, main_root, and -# default_branch before calling it through git:each-worktree. +# git:pr-summary prints "\t" for the open pull request on , or nothing. +git:pr-summary() { + gh pr list --head "$1" --state open --json number,isDraft \ + --jq '.[0] | select(.number) | "\(.number)\t\(if .isDraft then "Draft" else "Ready" end)"' 2>/dev/null || true +} + +# git:gone-branches prints every local branch whose upstream no longer exists. +git:gone-branches() { + git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2 == "[gone]" { print $1 }' +} + +# git:clean is true when the checkout at has no uncommitted change. +git:clean() { + [[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ]] +} + +# git:status-block renders one worktree. git:status-block() { - local path=$1 branch=$2 rel pr ahead_behind porcelain - found=true - rel=${path#"${main_root}"/} - step "${rel}" + local path=$1 branch=$2 main_root=$3 default_branch=$4 pr ahead_behind + step "${path#"${main_root}"/}" info " branch ${branch:-(detached)}" if [[ -n "${branch}" ]] then @@ -85,31 +84,35 @@ git:status-block() { ahead_behind=$(git rev-list --left-right --count "${branch}...${default_branch}" 2>/dev/null || printf '0\t0') if [[ -n "${pr}" ]] then - info " pr #${pr%%$'\t'*} ${pr##*$'\t'} · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" + pr="#${pr%%$'\t'*} ${pr##*$'\t'}" else - info " pr (none open) · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" + pr="(none open)" fi + info " pr ${pr} · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" else info " pr (no branch)" fi - porcelain=$(git -C "${path}" status --porcelain 2>/dev/null || true) - if [[ -z "${porcelain}" ]] + if git:clean "${path}" then info " status clean" else - info " status $(grep --count . <<<"${porcelain}") file(s) modified" + info " status $(git -C "${path}" status --porcelain | grep --count .) file(s) modified" fi } -# git:tidy-worktree decides one worktree. git:tidy sets found, self_root, dry_run, -# removed_worktrees, and freed before calling it through git:each-worktree. +# git:tidy-worktree decides one worktree and +# says why. It returns 0 when the worktree was removed, or would be under a dry run, and 1 when it is kept. git:tidy-worktree() { - local path=$1 branch=$2 detached=$3 state="" number - found=true + local path=$1 branch=$2 detached=$3 default_branch=$4 self_root=$5 dry_run=$6 state="" number if [[ "${path}" == "${self_root}" ]] then step "keeping ${path} (this command runs in it)" - return 0 + return 1 + fi + if ! git:clean "${path}" + then + step "keeping ${path} (${branch:-detached}, it has uncommitted changes)" + return 1 fi if [[ -n "${branch}" ]] then @@ -119,25 +122,20 @@ git:tidy-worktree() { number=${BASH_REMATCH[1]} [[ "$(gh issue view "${number}" --json state --jq .state 2>/dev/null)" == CLOSED ]] && state=CLOSED fi - elif [[ "${detached}" == true && -z "$(git -C "${path}" status --porcelain 2>/dev/null)" ]] + elif [[ "${detached}" == true ]] then - # A clean detached checkout is residue only when its commit is already on the default branch; otherwise its - # HEAD may be the only thing that reaches that commit, and removing the worktree would lose it. + # A detached checkout is residue only when its commit is already on the default branch; otherwise its HEAD + # may be the only thing that reaches that commit. if git -C "${path}" merge-base --is-ancestor HEAD "origin/${default_branch}" 2>/dev/null then state=DETACHED else step "keeping ${path} (detached at a commit origin/${default_branch} does not reach)" - return 0 + return 1 fi fi case "${state}" in (MERGED | CLOSED | DETACHED) - if [[ -n "$(git -C "${path}" status --porcelain 2>/dev/null)" ]] - then - step "keeping ${path} (${state}, but it has uncommitted changes)" - return 0 - fi if [[ "${dry_run}" == true ]] then step "would remove ${path} (${branch:-detached}, ${state})" @@ -145,17 +143,16 @@ git:tidy-worktree() { step "removing ${path} (${branch:-detached}, ${state})" git worktree remove "${path}" fi - [[ -n "${branch}" ]] && freed[${branch}]=1 - (( ++removed_worktrees )) + return 0 ;; (*) step "keeping ${path} (${branch:-detached}, pull request ${state:-open or unknown})" + return 1 esac - return 0 } git:status() { - local no_fetch=false arg + local no_fetch=false arg main_root default_branch main_branch main_dirty counts found=false path branch detached gone for arg in "$@" do case "${arg}" in @@ -172,22 +169,23 @@ git:status() { git fetch --quiet --prune origin || warn "git fetch --prune failed; ahead and behind counts may be stale" fi - local main_branch main_dirty counts main_root=$(git:main-root) default_branch=$(git:default-branch) main_branch=$(git -C "${main_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') - main_dirty=clean - [[ -z "$(git -C "${main_root}" status --porcelain 2>/dev/null)" ]] || main_dirty=dirty + main_dirty=dirty + git:clean "${main_root}" && main_dirty=clean counts=$(git -C "${main_root}" rev-list --left-right --count "HEAD...origin/${default_branch}" 2>/dev/null || printf '0\t0') section "${main_root##*/} (${main_branch}, ${main_dirty}, ↑${counts%%$'\t'*} ↓${counts##*$'\t'} vs origin/${default_branch})" section "worktrees/" - found=false - git:each-worktree git:status-block + while IFS=$'\t' read -r path branch detached + do + found=true + git:status-block "${path}" "${branch}" "${main_root}" "${default_branch}" + done < <(git:worktrees) [[ "${found}" == true ]] || ok "none" section "branches whose upstream is gone (tidy removes these)" - local gone gone=$(git:gone-branches) if [[ -n "${gone}" ]] then @@ -201,7 +199,9 @@ git:status() { } git:tidy() { - local dry_run=false flag + local dry_run=false flag repo default_branch self_root found=false path branch detached + local removed_worktrees=0 removed_branches=0 checked_out + local -A freed=() for flag in "$@" do case "${flag}" in @@ -214,8 +214,6 @@ git:tidy() { esac done guard:require gh || return 1 - - local repo repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true) if [[ -z "${repo}" ]] then @@ -240,10 +238,15 @@ git:tidy() { fi section "worktrees whose pull request is merged or closed" - removed_worktrees=0 - found=false - declare -A freed=() - git:each-worktree git:tidy-worktree + while IFS=$'\t' read -r path branch detached + do + found=true + if git:tidy-worktree "${path}" "${branch}" "${detached}" "${default_branch}" "${self_root}" "${dry_run}" + then + (( ++removed_worktrees )) + [[ -n "${branch}" ]] && freed[${branch}]=1 + fi + done < <(git:worktrees) [[ "${found}" == true ]] || ok "none" section "worktree bookkeeping" @@ -256,7 +259,6 @@ git:tidy() { ok "pruned" section "local branches whose upstream is gone" - local removed_branches=0 checked_out branch checked_out=$(git worktree list --porcelain | sed -n 's@^branch refs/heads/@@p') while IFS= read -r branch do @@ -294,11 +296,10 @@ git:rebase() { error "${worktree} has no branch checked out, so there is nothing to rebase" return 1 fi - default_branch=$(git -C "${worktree}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true) - [[ -n "${default_branch}" ]] || default_branch=main + default_branch=$(git:default-branch "${worktree}") if [[ "${branch}" == "${default_branch}" ]] then - error "refusing to rebase ${branch}: it is the default branch, the human's desk. It advances by fast-forward: git pull --ff-only" + error "refusing to rebase ${branch}: it is the default branch, the human's desk; it advances by: git pull --ff-only" return 1 fi section "kit rebase ${branch} onto origin/${default_branch}" @@ -306,7 +307,7 @@ git:rebase() { if ! git -C "${worktree}" rebase --quiet "origin/${default_branch}" then git -C "${worktree}" rebase --abort - error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was. Resolve by hand." + error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was" return 1 fi git -C "${worktree}" push --quiet --force-with-lease diff --git a/tests/tidy.bats b/tests/tidy.bats index 285e523..5815f8d 100644 --- a/tests/tidy.bats +++ b/tests/tidy.bats @@ -21,14 +21,9 @@ teardown() { rm -rf "${work}" } -# decide runs tidy's per-worktree decision on , a detached checkout, with tidy's globals in place. +# decide runs tidy's decision on , a detached checkout, with main as the default branch and no dry run. decide() { - bash -c ' - source "${kit_root}/bin/1st.sh" && use git - found=false; dry_run=false; removed_worktrees=0; declare -A freed=() - self_root=$(git rev-parse --show-toplevel); default_branch=main - git:tidy-worktree "$1" "" true - ' _ "$1" + bash -c 'source "${kit_root}/bin/1st.sh" && use git && git:tidy-worktree "$1" "" true main "$(git rev-parse --show-toplevel)" false' _ "$1" } @test "a clean detached worktree at a commit on main is removed" { @@ -43,7 +38,7 @@ decide() { git worktree add --quiet --detach worktrees/only HEAD printf 'two\n' > worktrees/only/b && git -C worktrees/only add b && git -C worktrees/only commit --quiet --message two run decide "${PWD}/worktrees/only" - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] [[ "$output" == *"keeping"* ]] [ -e worktrees/only ] } From af51d2b82436f8eeae244a4eb7055f1fea1fc7c8 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera Date: Sat, 24 Oct 2026 01:32:30 -0700 Subject: [PATCH 043/108] #1 bin/gh.sh: the on-ramp as named steps gh:issue orchestrates gh:me, gh:issue-create, gh:assign, gh:issue-branch, gh:worktree, gh:start-commit, gh:sweep-push, gh:sweep-apply, gh:pull-request, and gh:link-secondaries, each printing its one result. The user is read once. kit target uses release:among and release:without instead of two inline idioms. Co-Authored-By: Claude Fable 5.1 --- bin/gh.sh | 309 +++++++++++++++++++++++++--------------------- bin/kit-target.sh | 11 +- bin/release.sh | 21 ++++ 3 files changed, 189 insertions(+), 152 deletions(-) diff --git a/bin/gh.sh b/bin/gh.sh index 368870b..e097d06 100644 --- a/bin/gh.sh +++ b/bin/gh.sh @@ -2,141 +2,91 @@ # gh.sh - the issue on-ramp: a linked branch, a worktree, a start commit, and a draft pull request (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# gh:issue […] is the one command the loop needs and no vendor provides. The first number is the -# primary issue, linked to the branch under Development by `gh issue develop`, which closes it on merge. Any -# further number is a secondary, closed by a `Closes #N` line in the pull request body. Every listed issue is -# assigned to the caller. +# gh:issue […] is the one command the loop needs and no vendor provides. The first number is the primary +# issue, linked to the branch under Development by `gh issue develop`, which closes it on merge; any further +# number is a secondary, closed by a `Closes #N` line in the pull request body. Every listed issue is assigned to +# the caller. It never switches the checkout it runs from: the branch is checked out in worktrees/- +# under the main checkout, and an existing branch, worktree, or pull request is reused, so a re-run reconciles. # -# It never switches the checkout it is run from. The branch is checked out in worktrees/- under the -# main checkout's root, and an existing branch, worktree, or pull request is reused, so a re-run reconciles -# rather than duplicates. +# With no argument and a terminal on stdin it asks for a title and creates the issue; without a terminal the +# usage error stands, so an agent never hangs on a prompt. Uncommitted work on the default branch is swept into +# the new worktree, uncommitted, and the desk is left clean and fast-forwarded; inside any other checkout, dirty +# files are that issue's own and stay. A sweep that cannot apply leaves the stash in place and stops. # -# With no argument and a terminal on stdin it asks for a title, creates the issue, and continues. Without a -# terminal the usage error stands, so an agent never hangs on a prompt. -# -# If the invoking checkout is on the default branch and carries uncommitted changes, they are swept into the -# new worktree as uncommitted changes and the invoking checkout is left clean, then fast-forwarded to origin. -# The sweep runs only from the default-branch checkout: inside another worktree, dirty files are that issue's -# own work and are never moved. A sweep that cannot apply leaves the stash in place and stops, so nothing is -# lost. - -gh:issue() { - use git - if (( $# == 0 )) - then - if [[ ! -t 0 ]] - then - error "usage: kit issue […]" - return 2 - fi - guard:require gh || return 1 - local new_title new_number - read -r -p "Issue title: " new_title - if [[ -z "${new_title}" ]] - then - error "an issue title is required" - return 2 - fi - new_number=$(gh issue create --title "${new_title}" --body "" 2>/dev/null | sed -n 's@.*/issues/@@p') - if [[ ! "${new_number}" =~ ^[0-9]+$ ]] - then - error "run: gh auth status (the issue could not be created)" - return 1 - fi - ok "Created #${new_number}: ${new_title}" - set -- "${new_number}" - fi - - local n - for n in "$@" - do - if [[ ! "${n}" =~ ^[0-9]+$ ]] - then - error "not an issue number: '${n}'" - return 2 - fi - done - local primary=$1 - - guard:require gh || return 1 - git fetch --quiet --prune origin +# Each step below prints its one result on stdout and talks on stderr, so gh:issue reads them in order. - local default_branch main_root invoking_root invoking_branch - default_branch=$(git:default-branch) - main_root=$(git:main-root) - invoking_root=$(git rev-parse --show-toplevel) - invoking_branch=$(git -C "${invoking_root}" rev-parse --abbrev-ref HEAD) - - local do_sweep=false stash_ref="" - if [[ "${invoking_branch}" == "${default_branch}" && -n "$(git -C "${invoking_root}" status --porcelain)" ]] +# gh:me prints the authenticated login. A revoked token passes `gh auth status`, so this is the real probe. +gh:me() { + local me + me=$(gh api user --jq '.login' 2>/dev/null || true) + if [[ -z "${me}" ]] then - do_sweep=true + error "run: gh auth login (the GitHub CLI is not authenticated)" + return 1 fi + printf '%s\n' "${me}" +} - local title - title=$(gh issue view "${primary}" --json title --jq '.title') +# gh:issue-create asks for a title at the terminal and prints the number of the issue it creates. +gh:issue-create() { + local title number + read -r -p "Issue title: " title if [[ -z "${title}" ]] then - error "run: gh auth status (the title of #${primary} could not be read)" - return 1 + error "an issue title is required" + return 2 fi - - local me assignees - me=$(gh api user --jq '.login') - if [[ -z "${me}" ]] + number=$(gh issue create --title "${title}" --body "" 2>/dev/null | sed -n 's@.*/issues/@@p') + if [[ ! "${number}" =~ ^[0-9]+$ ]] then - error "run: gh auth status (the current user could not be read)" + error "run: gh auth status (the issue could not be created)" return 1 fi + ok "Created #${number}: ${title}" + printf '%s\n' "${number}" +} + +# gh:assign … adds to each issue's assignees, warning where someone else is there already. +gh:assign() { + local me=$1 n assignees + shift for n in "$@" do assignees=$(gh issue view "${n}" --json assignees --jq '[.assignees[].login] | join(" ")') [[ " ${assignees} " == *" ${me} "* ]] && continue - if [[ -n "${assignees}" ]] - then - warn "#${n} is already assigned to ${assignees}; adding you as well. Coordinate before continuing." - fi - if ! gh issue edit "${n}" --add-assignee "${me}" >/dev/null 2>&1 - then - warn "#${n} could not be assigned to ${me}; assign it by hand." - fi + [[ -z "${assignees}" ]] || warn "#${n} is already assigned to ${assignees}; adding you as well. Coordinate before continuing." + gh issue edit "${n}" --add-assignee "${me}" >/dev/null 2>&1 || warn "#${n} could not be assigned to ${me}; assign it by hand." done +} - section "Starting #${primary}: ${title}" - - # The branch gh made for the issue, found by an open pull request's head, then by the remote ref. Any hit is - # reused, so a second run never forks a --1 duplicate. - local branch +# gh:issue-branch prints the branch linked to the issue, making and linking one if none +# exists. An existing one is found by an open pull request's head, then by the remote ref, so a second run never +# forks a --1 duplicate. +gh:issue-branch() { + local number=$1 default_branch=$2 branch branch=$(gh pr list --state open --json headRefName \ - --jq "[.[] | select(.headRefName | test(\"^${primary}-\"))] | .[0].headRefName // empty" 2>/dev/null || true) - [[ -n "${branch}" ]] || branch=$(git ls-remote --heads origin "${primary}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + --jq "[.[] | select(.headRefName | test(\"^${number}-\"))] | .[0].headRefName // empty" 2>/dev/null || true) + [[ -n "${branch}" ]] || branch=$(git ls-remote --heads origin "${number}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') if [[ -z "${branch}" ]] then - step "Linking a branch to #${primary} (gh issue develop)" - gh issue develop "${primary}" --base "${default_branch}" >/dev/null + step "Linking a branch to #${number} (gh issue develop)" + gh issue develop "${number}" --base "${default_branch}" >/dev/null git fetch --quiet origin - branch=$(git ls-remote --heads origin "${primary}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + branch=$(git ls-remote --heads origin "${number}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') if [[ -z "${branch}" ]] then - error "the branch gh made for #${primary} could not be found; run: gh issue develop ${primary} --list" - return 1 - fi - fi - - if [[ "${do_sweep}" == true ]] - then - step "Sweeping the uncommitted work on ${default_branch} into the worktree" - if ! git -C "${invoking_root}" stash push --include-untracked --message "kit issue #${primary} → ${branch}" >/dev/null - then - error "the uncommitted work on ${default_branch} could not be stashed; resolve it by hand and re-run." + error "the branch gh made for #${number} could not be found; run: gh issue develop ${number} --list" return 1 fi - stash_ref=$(git -C "${invoking_root}" rev-parse 'stash@{0}') fi + printf '%s\n' "${branch}" +} - local wt +# gh:worktree prints the worktree the branch is checked out in, making it if there is none. +gh:worktree() { + local main_root=$1 branch=$2 wt wt=$(git worktree list --porcelain 2>/dev/null \ - | awk -v b="branch refs/heads/${branch}" '/^worktree /{w=substr($0,10)} $0==b{print w; exit}') + | awk -v b="branch refs/heads/${branch}" '/^worktree / { w = substr($0, 10) } $0 == b { print w; exit }') if [[ -n "${wt}" ]] then step "Reusing worktree ${wt}" @@ -156,40 +106,53 @@ gh:issue() { git worktree add --quiet "${wt}" -b "${branch}" "origin/${branch}" fi fi + printf '%s\n' "${wt}" +} - if ! git -C "${main_root}" check-ignore --quiet worktrees 2>/dev/null +# gh:start-commit gives the branch its first commit when it has +# none: GitHub cannot open a pull request with nothing to compare. --only keeps the commit empty, so work already +# staged in a reused worktree stays in the index. +gh:start-commit() { + local wt=$1 default_branch=$2 number=$3 title=$4 + if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] then - warn "worktrees/ is not ignored; add /worktrees/ to .gitignore so a worktree never appears in a status or a commit" + step "Adding the start commit" + git -C "${wt}" commit --quiet --allow-empty --only --message "start: #${number} ${title}" fi +} - # GitHub cannot open a pull request on a branch with nothing to compare, so the first commit is an empty one. - # --only keeps it empty: work already staged in a reused worktree stays in the index, not in this commit. - if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] +# gh:sweep-push <root> <message> stashes everything uncommitted at <root>, untracked files included, and prints the +# stash's commit, which stays valid however the stash list shifts. +gh:sweep-push() { + local root=$1 message=$2 + if ! git -C "${root}" stash push --include-untracked --message "${message}" >/dev/null then - step "Adding the start commit" - git -C "${wt}" commit --quiet --allow-empty --only --message "start: #${primary} ${title}" + error "the uncommitted work at ${root} could not be stashed; resolve it by hand and re-run." + return 1 fi - git -C "${wt}" push --quiet --set-upstream origin "${branch}" + git -C "${root}" rev-parse 'stash@{0}' +} - if [[ -n "${stash_ref}" ]] +# gh:sweep-apply <root> <worktree> <stash> <default-branch> lands the stash in the worktree, uncommitted, drops it, +# and fast-forwards the now-clean desk. On a conflict the stash is kept and the caller stops. +gh:sweep-apply() { + local root=$1 wt=$2 stash=$3 default_branch=$4 slot + if ! git -C "${wt}" stash apply "${stash}" >/dev/null 2>&1 then - if git -C "${wt}" stash apply "${stash_ref}" >/dev/null 2>&1 - then - local slot - slot=$(git -C "${invoking_root}" stash list --format='%gd %H' | awk -v h="${stash_ref}" '$2==h{print $1; exit}') - [[ -n "${slot}" ]] && git -C "${invoking_root}" stash drop "${slot}" >/dev/null - ok "Swept the work into ${wt}, uncommitted; commit it there with a real message." - if ! git -C "${invoking_root}" merge --ff-only "origin/${default_branch}" >/dev/null 2>&1 - then - warn "${default_branch} could not fast-forward to origin; it is left as it was." - fi - else - error "the swept work conflicts in ${wt}. It is kept in the stash: git stash apply ${stash_ref}" - return 1 - fi + error "the swept work conflicts in ${wt}. It is kept in the stash: git stash apply ${stash}" + return 1 fi + slot=$(git -C "${root}" stash list --format='%gd %H' | awk -v h="${stash}" '$2 == h { print $1; exit }') + [[ -n "${slot}" ]] && git -C "${root}" stash drop "${slot}" >/dev/null + ok "Swept the work into ${wt}, uncommitted; commit it there with a real message." + git -C "${root}" merge --ff-only "origin/${default_branch}" >/dev/null 2>&1 \ + || warn "${default_branch} could not fast-forward to origin; it is left as it was." +} - local pr +# gh:pull-request <branch> <default-branch> <title> prints the number of the open pull request for the branch, +# opening a draft if there is none. +gh:pull-request() { + local branch=$1 default_branch=$2 title=$3 pr pr=$(gh pr list --head "${branch}" --state open --json number --jq '.[0].number // empty') if [[ -z "${pr}" ]] then @@ -197,25 +160,83 @@ gh:issue() { gh pr create --draft --base "${default_branch}" --head "${branch}" --title "${title}" --body "" >/dev/null pr=$(gh pr list --head "${branch}" --state open --json number --jq '.[0].number') fi + printf '%s\n' "${pr}" +} + +# gh:link-secondaries <pr> <number>… adds a `Closes #N` line to the pull request body for each number not there. +gh:link-secondaries() { + local pr=$1 n body changed=false + shift + body=$(gh pr view "${pr}" --json body --jq '.body // ""') + for n in "$@" + do + if ! grep --quiet --ignore-case --extended-regexp "(close[sd]?|fix(e[sd])?|resolve[sd]?):? #${n}([^0-9]|\$)" <<<"${body}" + then + body="${body}"$'\n'"Closes #${n}" + changed=true + fi + done + if [[ "${changed}" == true ]] + then + step "Linking the secondary issues in the pull request body" + gh pr edit "${pr}" --body "${body}" >/dev/null + fi +} - if (( $# > 1 )) +gh:issue() { + use git + local me n primary title default_branch main_root desk desk_branch branch wt stash="" pr + me=$(gh:me) || return 1 + if (( $# == 0 )) then - local body changed=false - body=$(gh pr view "${pr}" --json body --jq '.body // ""') - for n in "${@:2}" - do - if ! grep --quiet --ignore-case --extended-regexp "(close[sd]?|fix(e[sd])?|resolve[sd]?):? #${n}([^0-9]|\$)" <<<"${body}" - then - body="${body}"$'\n'"Closes #${n}" - changed=true - fi - done - if [[ "${changed}" == true ]] + if [[ ! -t 0 ]] then - step "Linking the secondary issues in the pull request body" - gh pr edit "${pr}" --body "${body}" >/dev/null + error "usage: kit issue <issue-number> [<issue-number>…]" + return 2 + fi + set -- "$(gh:issue-create)" || return + fi + for n in "$@" + do + if [[ ! "${n}" =~ ^[0-9]+$ ]] + then + error "not an issue number: '${n}'" + return 2 fi + done + primary=$1 + + git fetch --quiet --prune origin + default_branch=$(git:default-branch) + main_root=$(git:main-root) + desk=$(git rev-parse --show-toplevel) + desk_branch=$(git -C "${desk}" rev-parse --abbrev-ref HEAD) + title=$(gh issue view "${primary}" --json title --jq '.title') + if [[ -z "${title}" ]] + then + error "run: gh auth status (the title of #${primary} could not be read)" + return 1 + fi + gh:assign "${me}" "$@" + section "Starting #${primary}: ${title}" + + branch=$(gh:issue-branch "${primary}" "${default_branch}") || return + if [[ "${desk_branch}" == "${default_branch}" ]] && ! git:clean "${desk}" + then + step "Sweeping the uncommitted work on ${default_branch} into the worktree" + stash=$(gh:sweep-push "${desk}" "kit issue #${primary} → ${branch}") || return + fi + wt=$(gh:worktree "${main_root}" "${branch}") || return + git -C "${main_root}" check-ignore --quiet worktrees 2>/dev/null \ + || warn "worktrees/ is not ignored; add /worktrees/ to .gitignore so a worktree never appears in a status or a commit" + gh:start-commit "${wt}" "${default_branch}" "${primary}" "${title}" + git -C "${wt}" push --quiet --set-upstream origin "${branch}" + if [[ -n "${stash}" ]] + then + gh:sweep-apply "${desk}" "${wt}" "${stash}" "${default_branch}" || return fi + pr=$(gh:pull-request "${branch}" "${default_branch}" "${title}") + (( $# > 1 )) && gh:link-secondaries "${pr}" "${@:2}" ok "Draft pull request #${pr} on ${branch}, primary #${primary}${2:+, secondaries: ${*:2}}" ok "Worktree ready: ${wt}" diff --git a/bin/kit-target.sh b/bin/kit-target.sh index 1281aab..9d27907 100755 --- a/bin/kit-target.sh +++ b/bin/kit-target.sh @@ -31,7 +31,7 @@ case "${verb}" in release:require-targets "$@" for t in "$@" do - if [[ " ${targets[*]} " == *" ${t} "* ]] + if release:among "${t}" "${targets[@]}" then step "${t}: already taken" continue @@ -53,7 +53,7 @@ case "${verb}" in release:require-targets "$@" for t in "$@" do - if [[ " ${targets[*]} " != *" ${t} "* ]] + if ! release:among "${t}" "${targets[@]}" then step "${t}: not taken" continue @@ -73,12 +73,7 @@ case "${verb}" in rm -f "${path}" fi done < <(release:target-only "${t}") - kept=() - for path in "${targets[@]}" - do - [[ "${path}" == "${t}" ]] || kept+=("${path}") - done - targets=("${kept[@]}") + read -r -a targets <<<"$(release:without "${t}" "${targets[@]}" | paste -s -d ' ' -)" done release:write-manifest "${targets[@]}" ok "targets: ${targets[*]:-none}" diff --git a/bin/release.sh b/bin/release.sh index a4d7663..8a71577 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -83,6 +83,27 @@ release:require-targets() { release:merged() { [[ "$1" == *.json ]]; } +# release:among <name> <name>… is true when the first name is among the rest. +release:among() { + local needle=$1 item + shift + for item in "$@" + do + [[ "${item}" == "${needle}" ]] && return 0 + done + return 1 +} + +# release:without <name> <name>… prints the rest without the first name, one per line. +release:without() { + local needle=$1 item + shift + for item in "$@" + do + [[ "${item}" == "${needle}" ]] || printf '%s\n' "${item}" + done +} + # The parameters are values ($), not filters: a filter parameter is re-read against the input at each use, which # inside the recursion is the wrong level of the object. release_jq=' From 388233438fdb8aa1e5aa8d37de80580e34de9c22 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:34:12 -0700 Subject: [PATCH 044/108] #1 justfile, kit-ci.yml, 1st.md: three couplings undone just refresh reads the targets and the paths from the manifest, drops and retakes the targets, and stages every path either manifest names, so the hook only calls it and nothing else knows what install delivers. The delivered workflow no longer carries a bin/kit fallback for this repository; mise.toml puts bin/ on PATH here. The rules table names each rule and no longer restates its paths header. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/kit-ci.yml | 16 ++++++---------- .kit/1st.md | 16 ++++++++-------- .kit/manifest | 4 ++-- justfile | 22 +++++++++++++++------- lefthook.yml | 6 +++--- tree/.github/workflows/kit-ci.yml | 16 ++++++---------- tree/.kit/1st.md | 16 ++++++++-------- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index 5b3b9de..3cf9a98 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -2,9 +2,9 @@ # ---------------------------------------------------------------------------------------------------------------------- # # One job, CI, which is the check name a ruleset requires. Three steps: the repository's `just ci` when its -# root justfile has one, `kit check` against the release mise pins, and a refusal of a pull request that is -# not linked to an open issue of this repository under Development. The last two run even when an earlier step -# refused, so one run reports every finding. +# root justfile has one, `kit check` with the kit mise pins, and a refusal of a pull request not linked to an +# open issue of this repository under Development. The last two run even when an earlier step refused, so one +# run reports every finding. # # Delivered by kit, not edited by hand. A change is a new release, then kit update. # @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Every tool below comes from mise.toml, the kit included. + # Every tool below comes from mise.toml, the kit included; this repository's mise.toml puts bin/ on PATH. - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - name: gate @@ -60,18 +60,14 @@ jobs: if: ${{ !cancelled() }} run: | set -o errexit -o nounset -o pipefail - if command -v kit >/dev/null 2>&1 + if ! command -v kit >/dev/null 2>&1 then - kit check - elif [[ -x bin/kit ]] - then - bin/kit check - else echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml' printf '### ❌ kit is not on PATH\n\nPin the release in mise.toml under [tools]: "github:empowerite/kit" = "<version>".\n' \ | tee --append "${GITHUB_STEP_SUMMARY}" exit 1 fi + kit check printf '✅ Every delivered file matches the pinned kit release.\n' | tee --append "${GITHUB_STEP_SUMMARY}" - name: linked-open-issue diff --git a/.kit/1st.md b/.kit/1st.md index b17c5cc..f03805a 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -71,14 +71,14 @@ header. A harness that loads rules by path finds a copy under its own rules dire that target, `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that kind. -| Kind | Paths | Rule | -| --- | --- | --- | -| Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | -| Bash | `*.sh`, `*.bash` | [rules/bash.md](rules/bash.md) | -| Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | -| YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | -| JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | -| Ignore lists | `.gitignore` | [rules/gitignore.md](rules/gitignore.md) | +| Kind | Rule | +| --- | --- | +| Markdown | [rules/markdown.md](rules/markdown.md) | +| Bash | [rules/bash.md](rules/bash.md) | +| Just | [rules/just.md](rules/just.md) | +| YAML | [rules/yaml.md](rules/yaml.md) | +| JSON | [rules/json.md](rules/json.md) | +| Ignore lists | [rules/gitignore.md](rules/gitignore.md) | ## What the kit carries diff --git a/.kit/manifest b/.kit/manifest index 7e1a54a..0153712 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -8,8 +8,8 @@ targets claude 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json -54014b457cbb469ee2d47d80edd5bc74cb1670afc235fc223d1f4e77ea78e859 .github/workflows/kit-ci.yml -a8bc796a9e2a6c907ca61e59f02946dca1cb03833c01f34da6fbdc091e604d23 .kit/1st.md +f03e0fa3ae07c74235558bffc38d5cb24c388c7c440e469cf8a8e58604edfc4b .github/workflows/kit-ci.yml +9fe231c16440c5346c9ed4fe2ef21e893d9e9048e507dd3b805c44b8c27624dc .kit/1st.md 4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml 389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md diff --git a/justfile b/justfile index c0d6a74..ed56571 100644 --- a/justfile +++ b/justfile @@ -37,17 +37,25 @@ ci: bin/kit check echo "✅ ci passed" -# Reinstall this repository's own kit from its source. Here the checkout is the release, so an edit to a file under -# tree/ or targets/ is an edit to the release, not a consumer's drift: the installed copies, .claude/settings.json -# (which carries only the kit's entries here), and .kit/manifest are rewritten from it. Run after changing any -# delivered file, and commit the result with the change. +# Reinstall this repository's own kit from its source and stage the result. Here the checkout is the release, so an +# edit under tree/ or targets/ is an edit to the release, not a consumer's drift: the targets the manifest names +# are dropped and taken again, so the installed copies and .kit/manifest are rewritten from the source, and every +# path the old or the new manifest names is staged. The pre-commit hook runs this on a commit that touches the +# source; by hand it is for a desk without the hook. # # just refresh -[doc("🔁 Reinstall this repository's own kit from tree/, targets/, and bin/kit")] +[doc("🔁 Reinstall this repository's own kit from tree/ and targets/, and stage the result")] refresh: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - rm -f .kit/manifest .claude/settings.json - bin/kit install claude + # a delivered path's line is a 64-hex digest, two spaces, the path; the version and targets lines are not + readarray -t before < <(awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest) + read -r -a targets <<<"$(sed -n 's/^targets *//p' .kit/manifest)" + (( ${#targets[@]} == 0 )) || bin/kit target remove "${targets[@]}" >/dev/null + rm -f .kit/manifest + bin/kit install "${targets[@]}" >/dev/null + readarray -t after < <(awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest) + git add --all -- "${before[@]}" "${after[@]}" .kit/manifest + echo "✅ refreshed and staged: ${#after[@]} delivered paths, targets ${targets[*]:-none}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/lefthook.yml b/lefthook.yml index a9a1ced..ab8ed6c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -17,8 +17,8 @@ pre-commit: glob: "*.md" run: markdownlint-cli2 {staged_files} # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, so - # nobody remembers just refresh. The command stages what it rewrote itself: stage_fixed would re-stage only - # the source paths it was handed, and the copies live elsewhere. + # nobody remembers just refresh. The recipe stages what it rewrote; stage_fixed would re-stage only the + # source paths it was handed. refresh: glob: "{tree,targets}/**" - run: just refresh && git add .kit .claude .github/workflows/kit-ci.yml + run: just refresh diff --git a/tree/.github/workflows/kit-ci.yml b/tree/.github/workflows/kit-ci.yml index 5b3b9de..3cf9a98 100644 --- a/tree/.github/workflows/kit-ci.yml +++ b/tree/.github/workflows/kit-ci.yml @@ -2,9 +2,9 @@ # ---------------------------------------------------------------------------------------------------------------------- # # One job, CI, which is the check name a ruleset requires. Three steps: the repository's `just ci` when its -# root justfile has one, `kit check` against the release mise pins, and a refusal of a pull request that is -# not linked to an open issue of this repository under Development. The last two run even when an earlier step -# refused, so one run reports every finding. +# root justfile has one, `kit check` with the kit mise pins, and a refusal of a pull request not linked to an +# open issue of this repository under Development. The last two run even when an earlier step refused, so one +# run reports every finding. # # Delivered by kit, not edited by hand. A change is a new release, then kit update. # @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Every tool below comes from mise.toml, the kit included. + # Every tool below comes from mise.toml, the kit included; this repository's mise.toml puts bin/ on PATH. - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - name: gate @@ -60,18 +60,14 @@ jobs: if: ${{ !cancelled() }} run: | set -o errexit -o nounset -o pipefail - if command -v kit >/dev/null 2>&1 + if ! command -v kit >/dev/null 2>&1 then - kit check - elif [[ -x bin/kit ]] - then - bin/kit check - else echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml' printf '### ❌ kit is not on PATH\n\nPin the release in mise.toml under [tools]: "github:empowerite/kit" = "<version>".\n' \ | tee --append "${GITHUB_STEP_SUMMARY}" exit 1 fi + kit check printf '✅ Every delivered file matches the pinned kit release.\n' | tee --append "${GITHUB_STEP_SUMMARY}" - name: linked-open-issue diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index b17c5cc..f03805a 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -71,14 +71,14 @@ header. A harness that loads rules by path finds a copy under its own rules dire that target, `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that kind. -| Kind | Paths | Rule | -| --- | --- | --- | -| Markdown | `*.md` | [rules/markdown.md](rules/markdown.md) | -| Bash | `*.sh`, `*.bash` | [rules/bash.md](rules/bash.md) | -| Just | `justfile`, `*.just` | [rules/just.md](rules/just.md) | -| YAML | `*.yml`, `*.yaml` | [rules/yaml.md](rules/yaml.md) | -| JSON | `*.json`, `*.jsonc` | [rules/json.md](rules/json.md) | -| Ignore lists | `.gitignore` | [rules/gitignore.md](rules/gitignore.md) | +| Kind | Rule | +| --- | --- | +| Markdown | [rules/markdown.md](rules/markdown.md) | +| Bash | [rules/bash.md](rules/bash.md) | +| Just | [rules/just.md](rules/just.md) | +| YAML | [rules/yaml.md](rules/yaml.md) | +| JSON | [rules/json.md](rules/json.md) | +| Ignore lists | [rules/gitignore.md](rules/gitignore.md) | ## What the kit carries From 0337c0a79d1e73ae30f258f803a8a9d8ce378991 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:35:00 -0700 Subject: [PATCH 045/108] #1 .kit/rules: the rules say what the code does The bash rule allows the short option where only the short one is portable and names the loader's bare verbs as the exception; the item about fail-open hooks goes with the hooks. The just rule says every justfile, puts set export after the default-and-help pair, and puts intent before usage, as both justfiles do. The retire workflow's header no longer narrates its own verification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .claude/rules/kit-bash.md | 15 ++++++++------- .claude/rules/kit-just.md | 12 +++++++----- .github/workflows/retire-pre-releases.yml | 5 ++--- .kit/manifest | 8 ++++---- .kit/rules/bash.md | 15 ++++++++------- .kit/rules/just.md | 12 +++++++----- tree/.kit/rules/bash.md | 15 ++++++++------- tree/.kit/rules/just.md | 12 +++++++----- 8 files changed, 51 insertions(+), 43 deletions(-) diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md index 92d176e..33b1128 100644 --- a/.claude/rules/kit-bash.md +++ b/.claude/rules/kit-bash.md @@ -11,24 +11,25 @@ workflows. ## Shape -1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner, a rule of dashes to - column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner under 120 columns, a + rule of dashes to column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. 2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they would change the caller's. -3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. -4. The file closes with the same rule of dashes. +3. The file closes with the same rule of dashes. ## Style 1. `then`, `do`, and `else` go on their own line, never after a semicolon. 2. Spell `set` options out: `set -o errexit`, never `set -e`. -3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. - Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +3. Spell a tool's options out where every platform accepts the long form: `--raw-output`, `--field`, + `--quiet`. Where BSD and GNU differ, use the form both accept, which is the short one: `sed -n`, `head -n 1`, + `mkdir -p`, `rm -f`, `cut -d`, `cp -R`, `find -type`, `read -r`, and `git -C`. 4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. 5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. 6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. 7. Quote every expansion outside `[[ ]]` and `(( ))`. -8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. +8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. The + loader's own verbs are the exception and stay bare: `use`, `info`, `step`, `ok`, `section`, `warn`, `error`. 9. A function private to one executed script is bare kebab-case. ## Behavior diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md index 0698212..d91dd1f 100644 --- a/.claude/rules/kit-just.md +++ b/.claude/rules/kit-just.md @@ -12,10 +12,12 @@ applies to it as well. ## Shape -1. A module file opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, and - a description of what the file is and who imports it. +1. Every justfile opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, + and a description of what the file is and who imports it. The root carries the `#!/usr/bin/env just + --justfile` shebang above that; a module does not. 2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the - root, which has no parent), `set positional-arguments := true`, and `set export := true`. + root, which has no parent), and `set positional-arguments := true`. `set export := true` comes after the + default-and-help pair, so that pair reads the same in every justfile. 3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: ```just @@ -38,8 +40,8 @@ applies to it as well. ## Recipes 1. Every recipe carries `[doc("<emoji> <short, action-first description>")]`. Related recipes share an emoji. -2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe - does, when to run it, and what it produces. +2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. It answers what the + recipe does, when to run it, and what it produces. 3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. 4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. 5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported diff --git a/.github/workflows/retire-pre-releases.yml b/.github/workflows/retire-pre-releases.yml index 891c73a..a6d2ecd 100644 --- a/.github/workflows/retire-pre-releases.yml +++ b/.github/workflows/retire-pre-releases.yml @@ -4,9 +4,8 @@ # A pre-release is tagged on a commit that is not on main, and main takes a branch by squash, so once the branch is # deleted its commits are reachable from nothing but the pre-release's own tag. This runs on every branch deletion # and retires, tag and all, each pre-release whose commit no remaining branch reaches. A release is never touched: -# it is on main, which is never deleted. A deleted tag is a delete event too, and that one is ignored here. -# -# Checked by hand: an immutable pre-release deletes with its tag, and the tag name can never be reused. +# it is on main, which is never deleted. A deleted tag is a delete event too, and that one is ignored here. An +# immutable release deletes with its tag, and its tag name can never be reused. # # see: .github/workflows/release.yml diff --git a/.kit/manifest b/.kit/manifest index 0153712..079b546 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,19 +1,19 @@ # .kit/manifest - written by kit, not by hand version unreleased targets claude -389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .claude/rules/kit-bash.md +b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .claude/rules/kit-bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md -98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .claude/rules/kit-just.md +edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .claude/rules/kit-just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json f03e0fa3ae07c74235558bffc38d5cb24c388c7c440e469cf8a8e58604edfc4b .github/workflows/kit-ci.yml 9fe231c16440c5346c9ed4fe2ef21e893d9e9048e507dd3b805c44b8c27624dc .kit/1st.md 4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml -389d14279648d69dbb1bf5454d22467f0f2d3aeec6b1844b01b2f2aeeaaeb9f5 .kit/rules/bash.md +b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md -98eb71bb5e6b885d5168ec1f47177c39d8bff3da720545c27e6995581e97ad12 .kit/rules/just.md +edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .kit/rules/just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .kit/rules/markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .kit/rules/yaml.md diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md index 92d176e..33b1128 100644 --- a/.kit/rules/bash.md +++ b/.kit/rules/bash.md @@ -11,24 +11,25 @@ workflows. ## Shape -1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner, a rule of dashes to - column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner under 120 columns, a + rule of dashes to column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. 2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they would change the caller's. -3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. -4. The file closes with the same rule of dashes. +3. The file closes with the same rule of dashes. ## Style 1. `then`, `do`, and `else` go on their own line, never after a semicolon. 2. Spell `set` options out: `set -o errexit`, never `set -e`. -3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. - Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +3. Spell a tool's options out where every platform accepts the long form: `--raw-output`, `--field`, + `--quiet`. Where BSD and GNU differ, use the form both accept, which is the short one: `sed -n`, `head -n 1`, + `mkdir -p`, `rm -f`, `cut -d`, `cp -R`, `find -type`, `read -r`, and `git -C`. 4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. 5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. 6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. 7. Quote every expansion outside `[[ ]]` and `(( ))`. -8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. +8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. The + loader's own verbs are the exception and stay bare: `use`, `info`, `step`, `ok`, `section`, `warn`, `error`. 9. A function private to one executed script is bare kebab-case. ## Behavior diff --git a/.kit/rules/just.md b/.kit/rules/just.md index 0698212..d91dd1f 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -12,10 +12,12 @@ applies to it as well. ## Shape -1. A module file opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, and - a description of what the file is and who imports it. +1. Every justfile opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, + and a description of what the file is and who imports it. The root carries the `#!/usr/bin/env just + --justfile` shebang above that; a module does not. 2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the - root, which has no parent), `set positional-arguments := true`, and `set export := true`. + root, which has no parent), and `set positional-arguments := true`. `set export := true` comes after the + default-and-help pair, so that pair reads the same in every justfile. 3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: ```just @@ -38,8 +40,8 @@ applies to it as well. ## Recipes 1. Every recipe carries `[doc("<emoji> <short, action-first description>")]`. Related recipes share an emoji. -2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe - does, when to run it, and what it produces. +2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. It answers what the + recipe does, when to run it, and what it produces. 3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. 4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. 5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported diff --git a/tree/.kit/rules/bash.md b/tree/.kit/rules/bash.md index 92d176e..33b1128 100644 --- a/tree/.kit/rules/bash.md +++ b/tree/.kit/rules/bash.md @@ -11,24 +11,25 @@ workflows. ## Shape -1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner, a rule of dashes to - column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. +1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner under 120 columns, a + rule of dashes to column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. 2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they would change the caller's. -3. A git hook that must fail open sets `-o nounset -o pipefail` and no `errexit`, and says so in its banner. -4. The file closes with the same rule of dashes. +3. The file closes with the same rule of dashes. ## Style 1. `then`, `do`, and `else` go on their own line, never after a semicolon. 2. Spell `set` options out: `set -o errexit`, never `set -e`. -3. Spell command options out: `--raw-output`, `--field`, `--delimiter`, never a one-letter flag in a script. - Where BSD and GNU differ, use the form both accept: `sed -n`, `head -n 1`, `mkdir -p`. +3. Spell a tool's options out where every platform accepts the long form: `--raw-output`, `--field`, + `--quiet`. Where BSD and GNU differ, use the form both accept, which is the short one: `sed -n`, `head -n 1`, + `mkdir -p`, `rm -f`, `cut -d`, `cp -R`, `find -type`, `read -r`, and `git -C`. 4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. 5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. 6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. 7. Quote every expansion outside `[[ ]]` and `(( ))`. -8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. +8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. The + loader's own verbs are the exception and stay bare: `use`, `info`, `step`, `ok`, `section`, `warn`, `error`. 9. A function private to one executed script is bare kebab-case. ## Behavior diff --git a/tree/.kit/rules/just.md b/tree/.kit/rules/just.md index 0698212..d91dd1f 100644 --- a/tree/.kit/rules/just.md +++ b/tree/.kit/rules/just.md @@ -12,10 +12,12 @@ applies to it as well. ## Shape -1. A module file opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, and - a description of what the file is and who imports it. +1. Every justfile opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, + and a description of what the file is and who imports it. The root carries the `#!/usr/bin/env just + --justfile` shebang above that; a module does not. 2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the - root, which has no parent), `set positional-arguments := true`, and `set export := true`. + root, which has no parent), and `set positional-arguments := true`. `set export := true` comes after the + default-and-help pair, so that pair reads the same in every justfile. 3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: ```just @@ -38,8 +40,8 @@ applies to it as well. ## Recipes 1. Every recipe carries `[doc("<emoji> <short, action-first description>")]`. Related recipes share an emoji. -2. A comment block precedes every recipe: usage lines, then a paragraph of intent. It answers what the recipe - does, when to run it, and what it produces. +2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. It answers what the + recipe does, when to run it, and what it produces. 3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. 4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. 5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported From a3902287abead6c4555bf6cb5c60745a37fb9ffc Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:36:21 -0700 Subject: [PATCH 046/108] #1 documentation: banners, headers, and one description of the gate Every verb's banner fits a line and its summary moves below the rule; the loader's and release workflow's run-together headers are rewrapped; mise.toml's header reads in order and its tools are alphabetical; the justfile's gate comment wraps where it should; the fragment says every tracked path; the entry file names all five loop commands and loses a comma splice; AGENTS.md points at the justfile for what the gate runs; the tests lose a stale assertion, a pointless -c, and three over-long chains. guard:require tool:git goes: nothing here runs without git. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/release.yml | 15 ++++++++------- .kit/1st.md | 17 +++++++++-------- .kit/lefthook.yml | 8 ++++---- .kit/manifest | 4 ++-- AGENTS.md | 2 +- bin/1st.sh | 7 ++++--- bin/kit-check.sh | 2 +- bin/kit-install.sh | 6 ++++-- bin/kit-issue.sh | 4 +++- bin/kit-lint.sh | 2 +- bin/kit-rebase.sh | 4 +++- bin/kit-status.sh | 2 +- bin/kit-target.sh | 6 +++--- bin/kit-tidy.sh | 2 +- bin/kit-update.sh | 4 ++-- bin/kit-version.sh | 2 +- bin/release.sh | 2 +- justfile | 8 ++++---- mise.toml | 15 +++++++-------- tests/lint.bats | 2 +- tests/rebase.bats | 22 ++++++++++++++++------ tests/release.bats | 1 - tree/.kit/1st.md | 17 +++++++++-------- tree/.kit/lefthook.yml | 8 ++++---- 24 files changed, 90 insertions(+), 72 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bf2899..b78d5a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,14 @@ # release.yml - a tag v<version> on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-<version>.tar.gz, holding one directory kit-<version>/ with bin/, tree/, targets/, and a stamped version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A release, v<major>.<minor>.<patch>, -# is tagged on main (any commit on main, not only its head). A pre-release, the same with a -<suffix>, is tagged on -# a commit that is not on main, and is published as a GitHub pre-release. The tag is the version: the repository -# carries no version file, and the one in the packed kit is stamped here from the tag, so a consumer sees it in -# kit version and .kit/manifest and kit update can name the previous release to mise. A release, once published, -# is immutable, so a tag is never reused. A pre-release lives as long as the branch it was tagged on; see: -# .github/workflows/retire-pre-releases.yml +# The tarball is kit-<version>.tar.gz, holding one directory kit-<version>/ with bin/, tree/, targets/, and a +# stamped version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A +# release, v<major>.<minor>.<patch>, is tagged on main, any commit on main and not only its head. A pre-release, +# the same with a -<suffix>, is tagged on a commit that is not on main and is published as a GitHub pre-release. +# The tag is the version: the repository carries no version file, and the one in the packed kit is stamped here +# from the tag, so a consumer sees it in kit version and .kit/manifest, and kit update can name the previous +# release to mise. A published release is immutable, so a tag is never reused. A pre-release lives as long as +# the branch it was tagged on; see: .github/workflows/retire-pre-releases.yml # # see: .kit/1st.md diff --git a/.kit/1st.md b/.kit/1st.md index f03805a..cf4b2a1 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -66,10 +66,10 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type -Each file kind has a rule file under `.kit/rules/`, and each rule file names the paths it binds in its `paths:` -header. A harness that loads rules by path finds a copy under its own rules directory when the repository takes -that target, `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of -that kind. +Each file kind has a rule file under `.kit/rules/`, and each rule file's `paths:` header names the paths it +binds. A harness that loads rules by path gets a copy in its own rules directory when the repository takes that +target: `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that +kind. | Kind | Rule | | --- | --- | @@ -85,10 +85,11 @@ that kind. The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the -repository root, and `targets/<name>/` for each target taken; a `.json` destination is merged by key, everything +repository root, and `targets/<name>/` for each target taken. A `.json` destination is merged by key; everything else is copied as it is. -- `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. +- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, and `kit lint`, spelled that way and + no other. - `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets @@ -96,8 +97,8 @@ else is copied as it is. `.claude/rules/kit-<kind>.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target back out. -- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, - runs `kit check`, and refuses a pull request not linked to an open issue. +- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses + a pull request not linked to an open issue. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml index aea7ecf..c0b9cfc 100644 --- a/.kit/lefthook.yml +++ b/.kit/lefthook.yml @@ -1,9 +1,9 @@ # Delivered by kit, not edited by hand. A change is a new release, then kit update. # -# Which linter runs over which paths. The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` -# and adds commands of its own under the same hook, or any other; Lefthook merges them by name, so a command named -# here is overridden by one of the same name there. `kit lint` runs this hook by hand, over the staged paths or, -# with --all-files, over every path. Once per clone: lefthook install +# Which linter runs over which paths before a commit. The repository's lefthook.yml takes this with +# `extends: [.kit/lefthook.yml]` and adds commands of its own; Lefthook merges by name, so a command of the same +# name there overrides one here. `kit lint` runs this hook by hand, over the staged paths or, with --all-files, +# over every tracked path. Once per clone: lefthook install # # see: https://lefthook.dev diff --git a/.kit/manifest b/.kit/manifest index 079b546..4ba3e5c 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -9,8 +9,8 @@ edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .claude/rules/ 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json f03e0fa3ae07c74235558bffc38d5cb24c388c7c440e469cf8a8e58604edfc4b .github/workflows/kit-ci.yml -9fe231c16440c5346c9ed4fe2ef21e893d9e9048e507dd3b805c44b8c27624dc .kit/1st.md -4460940e906891560f03ee5a5c2b2a61e9f814b05cf32a5917933e902a6ed45c .kit/lefthook.yml +22275196b95f53fe4b0bef7d309748161fcd2ede466a0ab3b67265fc2fa6814c .kit/1st.md +953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md diff --git a/AGENTS.md b/AGENTS.md index d73b5d2..536a177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,4 +14,4 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see [README.md](README.md) § Maintaining. Edit the source; the pre-commit hook refreshes the copies into the same commit, and a desk without the hook runs `just refresh` and commits both. -- `just ci` is the gate: shellcheck, actionlint, the bats suite, and `bin/kit check`. +- `just ci` is the gate; the justfile says what it runs. diff --git a/bin/1st.sh b/bin/1st.sh index 8a8e904..3425cad 100644 --- a/bin/1st.sh +++ b/bin/1st.sh @@ -2,9 +2,10 @@ # 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# Sourced by every verb, bin/kit-<verb>.sh, which bin/kit dispatches to with kit_root exported. It defines functions only and sets no shell option, so the caller's options stand. A module is a -# sourced <module>.sh beside this file, loaded with `use <module>`, once, by the verb or module that needs it; there -# is no other way in. A verb is an executable kit-<verb>.sh beside it. +# Sourced by every verb, bin/kit-<verb>.sh, which bin/kit dispatches to with kit_root exported. It defines +# functions only and sets no shell option, so the caller's options stand. A module is a sourced <module>.sh +# beside this file, loaded once with `use <module>` by the verb or module that needs it; there is no other way +# in. A verb is an executable kit-<verb>.sh beside it. # # Every message goes to stderr, so stdout stays clean for data. Color is off when stderr is not a terminal or # NO_COLOR is set. Under GitHub Actions, warn and error also emit the matching workflow command. diff --git a/bin/kit-check.sh b/bin/kit-check.sh index 257c13a..732c1c3 100755 --- a/bin/kit-check.sh +++ b/bin/kit-check.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-check - fail when a delivered file differs from the pinned release (kit verb, run through bin/kit) +# kit-check - fail when a delivered file differs from the pinned release (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # see: bin/release.sh diff --git a/bin/kit-install.sh b/bin/kit-install.sh index eb0ed00..4ee448c 100755 --- a/bin/kit-install.sh +++ b/bin/kit-install.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# kit-install - mirror the release into the repository, take the named targets, and write .kit/manifest (kit verb, run through bin/kit) +# kit-install - mirror the release into the repository (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # +# Takes the named targets and writes .kit/manifest. +# # see: bin/release.sh set -o errexit -o nounset -o pipefail @@ -15,7 +17,7 @@ then exit 0 fi -guard:require tool:jq tool:git +guard:require tool:jq release:require-targets "$@" if [[ -f .kit/manifest ]] then diff --git a/bin/kit-issue.sh b/bin/kit-issue.sh index 90edef1..b59451f 100755 --- a/bin/kit-issue.sh +++ b/bin/kit-issue.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# kit-issue - start or resume an issue: linked branch, worktree, start commit, draft pull request (kit verb, run through bin/kit) +# kit-issue - start or resume an issue (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # +# A linked branch, a worktree, a start commit, and a draft pull request. +# # see: bin/gh.sh set -o errexit -o nounset -o pipefail diff --git a/bin/kit-lint.sh b/bin/kit-lint.sh index c4a04aa..5b7fd7d 100755 --- a/bin/kit-lint.sh +++ b/bin/kit-lint.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-lint - run the pre-commit hook by hand: the linters .kit/lefthook.yml and lefthook.yml declare (kit verb) +# kit-lint - run the pre-commit hook's linters by hand (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # An alias for `lefthook run pre-commit`, so the linters a commit meets are the ones this runs, over the staged diff --git a/bin/kit-rebase.sh b/bin/kit-rebase.sh index 4cb80e6..649451c 100755 --- a/bin/kit-rebase.sh +++ b/bin/kit-rebase.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# kit-rebase - rebase a worktree's branch onto the default branch and push it; never the default branch (kit verb, run through bin/kit) +# kit-rebase - rebase a worktree's branch onto the default branch and push it (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # +# Never the default branch itself. +# # see: bin/git.sh set -o errexit -o nounset -o pipefail diff --git a/bin/kit-status.sh b/bin/kit-status.sh index 2345130..ac38f9b 100755 --- a/bin/kit-status.sh +++ b/bin/kit-status.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-status - show every worktree with its branch, pull request, and state (kit verb, run through bin/kit) +# kit-status - show every worktree with its branch, pull request, and state (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # see: bin/git.sh diff --git a/bin/kit-target.sh b/bin/kit-target.sh index 9d27907..1d770ef 100755 --- a/bin/kit-target.sh +++ b/bin/kit-target.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-target - list, take, or drop the targets this repository takes (kit verb, run through bin/kit) +# kit-target - list, take, or drop the targets this repository takes (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # see: bin/release.sh @@ -27,7 +27,7 @@ case "${verb}" in ;; (add) (( $# > 0 )) || { usage; exit 2; } - guard:require tool:jq tool:git + guard:require tool:jq release:require-targets "$@" for t in "$@" do @@ -49,7 +49,7 @@ case "${verb}" in ;; (remove) (( $# > 0 )) || { usage; exit 2; } - guard:require tool:jq tool:git + guard:require tool:jq release:require-targets "$@" for t in "$@" do diff --git a/bin/kit-tidy.sh b/bin/kit-tidy.sh index 2ac2bea..d604c9a 100755 --- a/bin/kit-tidy.sh +++ b/bin/kit-tidy.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-tidy - remove worktrees whose pull request is merged or closed, and gone branches (kit verb, run through bin/kit) +# kit-tidy - remove merged worktrees and gone branches (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # see: bin/git.sh diff --git a/bin/kit-update.sh b/bin/kit-update.sh index 88eb004..40ced66 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-update - after the pin is bumped: replace unchanged files, three-way merge changed ones (kit verb, run through bin/kit) +# kit-update - move the repository to the pinned release (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # # A file whose digest still matches the manifest, which records the previous release's own content, is replaced. @@ -19,7 +19,7 @@ then exit 0 fi -guard:require tool:jq tool:git +guard:require tool:jq release:require-manifest read -r -a targets <<<"$(release:manifest-targets)" previous=$(release:manifest-version) diff --git a/bin/kit-version.sh b/bin/kit-version.sh index f4e07e3..5f2e04a 100755 --- a/bin/kit-version.sh +++ b/bin/kit-version.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kit-version - print this release's version (kit verb, run through bin/kit) +# kit-version - print this release's version (kit verb) # ---------------------------------------------------------------------------------------------------------------------- set -o errexit -o nounset -o pipefail diff --git a/bin/release.sh b/bin/release.sh index 8a71577..36be28c 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -150,7 +150,7 @@ release:digest() { fi } -# release:place <source> <path> [<base-source>] puts the release's content at <path>: a merge for .json, a copy otherwise. +# release:place <source> <path> [<base-source>] puts the release's content at <path>: a merge for .json, else a copy. release:place() { local src=$1 path=$2 base=${3:-/dev/null} if release:merged "${path}" diff --git a/justfile b/justfile index ed56571..83ab83c 100644 --- a/justfile +++ b/justfile @@ -20,10 +20,10 @@ _: help set export := true -# The gate: shellcheck over every bash file, actionlint over every workflow, markdownlint over every markdown file, -# the bats suite, and bin/kit check over -# this repository's installed copy of its own kit. The runner runs it on every pull request, and a desk runs it the -# same way; the pre-commit hook is the narrower, faster check over the staged paths. +# The gate: shellcheck over every bash file, actionlint over every workflow, markdownlint over every markdown +# file, the bats suite, and bin/kit check over this repository's installed copy of its own kit. The runner runs +# it on every pull request, and a desk runs it the same way; the pre-commit hook is the narrower, faster check +# over the staged paths. # # just ci [doc("🚦 The gate: shellcheck, actionlint, markdownlint, bats, kit check")] diff --git a/mise.toml b/mise.toml index 00c0da8..a5ead37 100644 --- a/mise.toml +++ b/mise.toml @@ -1,10 +1,9 @@ # mise.toml - the pinned tools a desk and a runner share; see: https://mise.jdx.dev/configuration/environments.html # -# One mechanism: mise reads this file always, mise.desk.toml on top of it when MISE_ENV=desk, which a desk exports -# in its shell profile, and mise.ci.toml when MISE_ENV=ci, which the CI workflow sets. A tool goes in the file of -# whoever runs it; today only Lefthook is a desk's alone, and nothing is the runner's alone. `lockfile = true` records each pin's exact resolution in a lock beside its file, -# and lockfile_platforms makes a lock written on a desk carry the runner's platform. git and bash come from the -# machine. +# mise reads this file always, mise.desk.toml on top of it when MISE_ENV=desk, which a desk exports in its shell +# profile, and mise.ci.toml when MISE_ENV=ci, which the CI workflow sets. A tool goes in the file of whoever runs +# it; today Lefthook is a desk's alone, and nothing is the runner's alone. `lockfile = true` records each pin's +# exact resolution in a lock beside its file. git and bash come from the machine. [settings] lockfile = true @@ -16,10 +15,10 @@ lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] _.path = ["bin"] [tools] +actionlint = "1.7" +bats = "1.14" gh = "2.103" jq = "1.8" -"npm:markdownlint-cli2" = "0.24" just = "1.59" +"npm:markdownlint-cli2" = "0.24" shellcheck = "0.11" -actionlint = "1.7" -bats = "1.14" diff --git a/tests/lint.bats b/tests/lint.bats index 96aa0cd..461fb11 100644 --- a/tests/lint.bats +++ b/tests/lint.bats @@ -29,7 +29,7 @@ teardown() { } @test "lint --all-files reads every tracked path, where lint alone reads only the staged ones" { - git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false add bad.sh + git add bad.sh git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false commit --quiet --no-verify --message bad run "${kit_checkout}/bin/kit" lint [ "$status" -eq 0 ] diff --git a/tests/rebase.bats b/tests/rebase.bats index 571f696..fbb922b 100644 --- a/tests/rebase.bats +++ b/tests/rebase.bats @@ -18,9 +18,19 @@ teardown() { rm -rf "${work}" } -worktree() { # a worktree on branch 1-x, configured to commit +# worktree makes a worktree on branch 1-x beside the desk, configured to commit. +worktree() { git worktree add --quiet ../wt -b 1-x - git -C ../wt config user.name t && git -C ../wt config user.email t@example.com && git -C ../wt config commit.gpgsign false + git -C ../wt config user.name t + git -C ../wt config user.email t@example.com + git -C ../wt config commit.gpgsign false +} + +# commit-in <checkout> <file> <content> writes the file and commits it there. +commit-in() { + printf '%s\n' "$3" > "$1/$2" + git -C "$1" add "$2" + git -C "$1" commit --quiet --message "$2" } @test "rebase refuses the default branch" { @@ -31,8 +41,8 @@ worktree() { # a worktree on branch 1-x, configured to commit @test "rebase moves a worktree's branch onto origin/main and pushes it" { worktree - printf 'two\n' > ../wt/b && git -C ../wt add b && git -C ../wt commit --quiet --message two && git -C ../wt push --quiet --set-upstream origin 1-x - printf 'three\n' > c && git add c && git commit --quiet --message three && git push --quiet origin main + commit-in ../wt b two && git -C ../wt push --quiet --set-upstream origin 1-x + commit-in . c three && git push --quiet origin main run "${kit_checkout}/bin/kit" rebase ../wt [ "$status" -eq 0 ] [ "$(git -C ../wt rev-parse HEAD~1)" = "$(git rev-parse origin/main)" ] @@ -41,8 +51,8 @@ worktree() { # a worktree on branch 1-x, configured to commit @test "rebase aborts on a conflict and leaves the worktree as it was" { worktree - printf 'theirs\n' > ../wt/a && git -C ../wt commit --quiet --all --message theirs && git -C ../wt push --quiet --set-upstream origin 1-x - printf 'ours\n' > a && git commit --quiet --all --message ours && git push --quiet origin main + commit-in ../wt a theirs && git -C ../wt push --quiet --set-upstream origin 1-x + commit-in . a ours && git push --quiet origin main before=$(git -C ../wt rev-parse HEAD) run "${kit_checkout}/bin/kit" rebase ../wt [ "$status" -eq 1 ] diff --git a/tests/release.bats b/tests/release.bats index 6da12a3..ba5c9e3 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -33,7 +33,6 @@ teardown() { [ "$status" -eq 0 ] [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] [ ! -L .claude/rules/kit-bash.md ] - [ ! -e .kit/bash ] } # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index f03805a..cf4b2a1 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -66,10 +66,10 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type -Each file kind has a rule file under `.kit/rules/`, and each rule file names the paths it binds in its `paths:` -header. A harness that loads rules by path finds a copy under its own rules directory when the repository takes -that target, `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of -that kind. +Each file kind has a rule file under `.kit/rules/`, and each rule file's `paths:` header names the paths it +binds. A harness that loads rules by path gets a copy in its own rules directory when the repository takes that +target: `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that +kind. | Kind | Rule | | --- | --- | @@ -85,10 +85,11 @@ that kind. The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the -repository root, and `targets/<name>/` for each target taken; a `.json` destination is merged by key, everything +repository root, and `targets/<name>/` for each target taken. A `.json` destination is merged by key; everything else is copied as it is. -- `kit issue`, `kit tidy`, and `kit status`, the loop's commands, spelled that way and no other. +- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, and `kit lint`, spelled that way and + no other. - `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets @@ -96,8 +97,8 @@ else is copied as it is. `.claude/rules/kit-<kind>.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target back out. -- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, - runs `kit check`, and refuses a pull request not linked to an open issue. +- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses + a pull request not linked to an open issue. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/tree/.kit/lefthook.yml b/tree/.kit/lefthook.yml index aea7ecf..c0b9cfc 100644 --- a/tree/.kit/lefthook.yml +++ b/tree/.kit/lefthook.yml @@ -1,9 +1,9 @@ # Delivered by kit, not edited by hand. A change is a new release, then kit update. # -# Which linter runs over which paths. The repository's lefthook.yml takes this with `extends: [.kit/lefthook.yml]` -# and adds commands of its own under the same hook, or any other; Lefthook merges them by name, so a command named -# here is overridden by one of the same name there. `kit lint` runs this hook by hand, over the staged paths or, -# with --all-files, over every path. Once per clone: lefthook install +# Which linter runs over which paths before a commit. The repository's lefthook.yml takes this with +# `extends: [.kit/lefthook.yml]` and adds commands of its own; Lefthook merges by name, so a command of the same +# name there overrides one here. `kit lint` runs this hook by hand, over the staged paths or, with --all-files, +# over every tracked path. Once per clone: lefthook install # # see: https://lefthook.dev From bdadcf4218a5931af194dc74b2574cc6abf4640a Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:50:56 -0700 Subject: [PATCH 047/108] #1 justfile: refresh generates from the index after removing the old copies The copies are made from what the commit will carry, never the working tree, so a half-staged edit cannot put a copy into a commit that lacks its source. Every path the old manifest lists is removed before the new release is installed, so a deleted source leaves no copy and a retired entry does not survive in a merged .json. This repository keeps nothing of its own in a delivered path, which makes the removal safe here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- justfile | 31 ++++++++++++++++++------------- lefthook.yml | 6 +++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/justfile b/justfile index 83ab83c..eee51c1 100644 --- a/justfile +++ b/justfile @@ -37,25 +37,30 @@ ci: bin/kit check echo "✅ ci passed" -# Reinstall this repository's own kit from its source and stage the result. Here the checkout is the release, so an -# edit under tree/ or targets/ is an edit to the release, not a consumer's drift: the targets the manifest names -# are dropped and taken again, so the installed copies and .kit/manifest are rewritten from the source, and every -# path the old or the new manifest names is staged. The pre-commit hook runs this on a commit that touches the -# source; by hand it is for a desk without the hook. +# Reinstall this repository's own kit from the index and stage the result. Here the checkout is the release, so an +# edit under tree/ or targets/ is an edit to the release, not a consumer's drift. The copies are made from the +# index, what the commit will carry, never from the working tree, so a half-staged edit cannot put a copy into a +# commit that lacks its source. Every path the old manifest lists is removed first and written again from the +# new release: a source that was deleted leaves no copy behind, and an entry that was retired does not survive +# in a merged .json. This repository keeps nothing of its own in a delivered path, which is what makes that +# removal safe here. The pre-commit hook runs this on a commit that touches the source; by hand it is for a desk +# without the hook. # # just refresh -[doc("🔁 Reinstall this repository's own kit from tree/ and targets/, and stage the result")] +[doc("🔁 Reinstall this repository's own kit from the index, and stage the result")] refresh: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail - # a delivered path's line is a 64-hex digest, two spaces, the path; the version and targets lines are not - readarray -t before < <(awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest) + paths() { awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest; } + readarray -t before < <(paths) read -r -a targets <<<"$(sed -n 's/^targets *//p' .kit/manifest)" - (( ${#targets[@]} == 0 )) || bin/kit target remove "${targets[@]}" >/dev/null - rm -f .kit/manifest - bin/kit install "${targets[@]}" >/dev/null - readarray -t after < <(awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest) + index=$(mktemp -d) + trap 'rm -rf "${index}"' EXIT + git checkout-index --all --prefix="${index}/" + rm -f "${before[@]}" .kit/manifest + "${index}/bin/kit" install "${targets[@]}" >/dev/null + readarray -t after < <(paths) git add --all -- "${before[@]}" "${after[@]}" .kit/manifest - echo "✅ refreshed and staged: ${#after[@]} delivered paths, targets ${targets[*]:-none}" + echo "✅ refreshed from the index and staged: ${#after[@]} delivered paths, targets ${targets[*]:-none}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/lefthook.yml b/lefthook.yml index ab8ed6c..840a2a8 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -16,9 +16,9 @@ pre-commit: markdownlint: glob: "*.md" run: markdownlint-cli2 {staged_files} - # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, so - # nobody remembers just refresh. The recipe stages what it rewrote; stage_fixed would re-stage only the - # source paths it was handed. + # A commit that changes the source of a delivered file rewrites the installed copies in the same commit, from + # the index, so nobody remembers just refresh. The recipe stages what it rewrote; stage_fixed would re-stage + # only the source paths it was handed. refresh: glob: "{tree,targets}/**" run: just refresh From 8ced48412a8baaf9783c8dd85fbfc5706692ef3d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:53:21 -0700 Subject: [PATCH 048/108] #1 README.md: keep it trustworthy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b890439..f4c2063 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# kit +# empowerite/kit: **k**eep **i**t **t**rustworthy kit is the agentic guardrails a repository takes as a versioned mixin: the policy text an agent reads first, the rules that bind each kind of file, the guard that keeps `main` as the human's desk, and the one command no harness From 32994b84f7e1afc87ea8a202aea91e587089963f Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 01:53:39 -0700 Subject: [PATCH 049/108] #1 README.md: the three steps first, and each thing said once Take the kit leads with the pin, the install, and the AGENTS.md lines; what install writes is said in Maintaining; the bash note joins its sentence; the MISE_ENV mechanism is explained in one place; the gate is described in the justfile alone; Maintaining says the copies come from the index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- README.md | 67 ++++++++++++++++++++++++------------------------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f4c2063..ceff989 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ installs it with `kit install`, and keeps rules of its own beside it. The design ## Take the kit -Pin a release in the repository's `mise.toml`, then install it, naming the harnesses the team uses: +Pin a release in `mise.toml`, install it, and point `AGENTS.md` at it: ```toml [tools] @@ -20,35 +20,30 @@ mise install kit install claude ``` -An exact pin installs the minute a release exists. If you pin a range instead, add -`minimum_release_age_excludes = ["github:empowerite/kit"]` under `[settings]`, or `mise outdated` and -`mise upgrade` offer a new kit only a day after it ships. - -`kit install` copies `.kit/1st.md`, `.kit/rules/`, and `.kit/lefthook.yml` into the repository, places -`.github/workflows/kit-ci.yml`, and writes `.kit/manifest`. Each target named adds what that harness insists on -having outside `.kit/`; `claude` places `.claude/rules/kit-*.md` and merges the kit's deny rules into -`.claude/settings.json`. A repository with no target installed still binds every agent that reads `AGENTS.md`, -through the rules table in `.kit/1st.md`. `kit target add` and `kit target remove` change the set later, and -`kit target list` prints it. Commit what it wrote. Then: - -- Open `AGENTS.md` with `@.kit/1st.md` and the sentence "Read .kit/1st.md first." The rest of the file is yours. -- Keep a `ci` recipe in the root justfile. The delivered `CI` job runs it, then `kit check`, then refuses a pull - request not linked to an open issue. -- Pin `lefthook` in `mise.desk.toml`, which mise reads when `MISE_ENV=desk`, put `extends: [.kit/lefthook.yml]` - in `lefthook.yml`, and run `lefthook install` once per clone. The fragment says which linter runs over which - paths before a commit, and your `lefthook.yml` adds commands of its own. `kit lint` runs the same hook by hand. - The delivered `CI` job sets `MISE_ENV=ci`, so a tool only the runner needs is pinned in `mise.ci.toml`. - -The machinery, the `kit` command and the bash behind its verbs, stays in the release where mise installed it; the -repository carries the text an agent reads and the record of what it took. The kit needs bash 4.4 or later, git, -`gh`, and `jq`. +```markdown +@.kit/1st.md + +Read .kit/1st.md first. +``` + +The rest of `AGENTS.md` is yours. `install` writes `.kit/`, the CI workflow, and the files of each target named; +`claude` is the one target this release knows, and a repository with no target still binds every agent that +reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the root justfile for the delivered `CI` job to +run, and take the pre-commit linters by pinning `lefthook` in `mise.desk.toml`, putting +`extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit target +add`, `kit target remove`, and `kit target list` change and show the targets later. + +An exact pin installs the minute a release exists. A range pin sees a new kit a day after it ships unless +`[settings]` carries `minimum_release_age_excludes = ["github:empowerite/kit"]`. To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries and settings keys stay. `kit check` fails, locally and in `CI`, when a delivered file differs from the pinned release. -macOS ships bash 3.2; `brew install bash` gives a current one. +The kit needs bash 4.4 or later, git, `gh`, and `jq`; macOS ships bash 3.2, and `brew install bash` gives a +current one. The `kit` command and the bash behind its verbs stay in the release where mise installed it; the +repository carries the text an agent reads and the record of what it took. ## Setup @@ -66,11 +61,8 @@ mise install lefthook install ``` -`MISE_ENV=desk` belongs in your shell profile. mise reads `mise.toml` always, `mise.desk.toml` on top of it when -`MISE_ENV=desk`, and `mise.ci.toml` when `MISE_ENV=ci`, which the CI workflow sets: a tool goes in the file of -whoever runs it. Lefthook is a desk's; nothing here is the runner's alone. - -This repository carries the installed copy of its own kit, and `just ci` checks it. +`MISE_ENV=desk` belongs in your shell profile: it makes mise read `mise.desk.toml`, the tools only a desk runs, +on top of `mise.toml`; the delivered `CI` job sets `MISE_ENV=ci` the same way for `mise.ci.toml`. ## Getting started @@ -81,8 +73,7 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): draft pull request. It never switches the checkout it runs from. - Work happens in that worktree. Commits are signed, and their subject is `#<N> <area>: <subject>`. `kit rebase` brings the branch up to date with `main` and pushes it. -- `just ci` is the gate: shellcheck over the bash, actionlint over the workflows, the bats suite in `tests/`, and - `bin/kit check`. +- `just ci` is the gate; the justfile says what it runs. - `gh pr ready` says the work is done, and the pull request body is the report. - A human merges. `kit tidy` then removes the worktree and its branch. @@ -90,9 +81,9 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what every consumer gets, mirrored at its root; `targets/<name>/` is what a consumer taking that target gets, mirrored -the same way; `bin/` is the machinery: `bin/kit` dispatches to one executable `bin/kit-<verb>.sh` -per verb, and the verbs source the modules beside them, `1st.sh`, `gh.sh`, `git.sh`, and `release.sh`. A symlink -under `targets/` is delivered as the file it points at, so a rule lives once, in `tree/.kit/rules/`. +the same way; and `bin/` is the machinery, `bin/kit` dispatching to one executable `bin/kit-<verb>.sh` per verb, +each sourcing the modules beside it. A symlink under `targets/` is delivered as the file it points at, so a rule +lives once, in `tree/.kit/rules/`. The installed copies are written by `bin/kit install` and not edited by hand: @@ -105,10 +96,10 @@ The installed copies are written by `bin/kit install` and not edited by hand: | `.kit/manifest` | the release version (`unreleased` here), the targets taken, and a digest per delivered path | A commit that changes a file under `tree/` or `targets/` rewrites the installed copies in the same commit: the -pre-commit hook runs `just refresh` and stages what it rewrote. On a desk without the hook, run `just refresh` -by hand and commit the result with the change. `bin/kit check`, which `just ci` runs, names any installed copy -that no longer matches its source. Each delivered text file says in its header that it is delivered by kit; -`settings.json` cannot, so this table is its notice. +pre-commit hook runs `just refresh`, which regenerates them from the index and stages the result. On a desk +without the hook, stage the change, run `just refresh`, and commit. `bin/kit check`, which `just ci` runs, names +any installed copy that no longer matches its source. Each delivered text file says in its header that it is +delivered by kit; `settings.json` cannot, so this table is its notice. ## Release From 1f36d5c39895982aa7d3bba4dc0d759ed846cbbe Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 09:51:48 -0700 Subject: [PATCH 050/108] #1 bin: kit status as a dashboard, a release root through a symlink, and a base fetched without mise kit status draws the pull requests in flight: state, age, checks, the commits each carries, and the worktree it is written in with its distance from main and the size of its diff; one request to GitHub. bin/kit resolves the release through any symlink, as Homebrew links it. release:fetch finds a previous release in the kit's cache, in mise if mise is here, or downloads the GitHub release, so kit update's base needs no particular installer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/git.sh | 133 ++++++++++++++++++++++++++++++++++------------ bin/kit | 7 +-- bin/kit-update.sh | 19 +++---- bin/release.sh | 32 +++++++++++ 4 files changed, 144 insertions(+), 47 deletions(-) diff --git a/bin/git.sh b/bin/git.sh index 6e7cec1..3f8c0e7 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -2,9 +2,10 @@ # git.sh - the default branch, the worktrees, the sweep, and the rebase (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# git:status [--no-fetch] renders the main checkout on one line and one block per worktree under worktrees/, with -# its branch, pull request, and dirty or clean state, then the local branches whose upstream is gone. It changes -# nothing. +# git:status [--no-fetch] draws the picture of the work in flight: the open pull requests, and under each its +# state, age, checks, the commits it carries, and the worktree it is written in, with how far that is ahead of and +# behind the default branch and the size of its diff; then the worktrees with no pull request; then the local +# branches whose upstream is gone. It changes nothing, and asks GitHub once. # # git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, or that is # detached, clean, and at a commit the default branch already has; prunes the worktree bookkeeping; and deletes @@ -73,31 +74,68 @@ git:clean() { [[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ]] } -# git:status-block <path> <branch> <main-root> <default-branch> renders one worktree. -git:status-block() { - local path=$1 branch=$2 main_root=$3 default_branch=$4 pr ahead_behind - step "${path#"${main_root}"/}" - info " branch ${branch:-(detached)}" - if [[ -n "${branch}" ]] +# git:open-prs prints one line per open pull request: "<branch>\t<number>\t<Draft|Ready>\t<age in days>\t<checks>\t<title>", +# where <checks> is "✅ n/n", "❌ k/n", "⏳ n", or "·" for none. One request, for the whole picture. +git:open-prs() { + # shellcheck disable=SC2016 # a jq filter, with jq variables + gh pr list --state open --limit 100 --json number,title,isDraft,headRefName,createdAt,statusCheckRollup --jq ' + .[] | [ + .headRefName, + (.number | tostring), + (if .isDraft then "Draft" else "Ready" end), + (((now - (.createdAt | fromdateiso8601)) / 86400 | floor | tostring) + "d"), + ((.statusCheckRollup // []) as $c + | ($c | length) as $n + | ([$c[] | select(.conclusion == "SUCCESS")] | length) as $ok + | ([$c[] | select(.conclusion != null and .conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length) as $bad + | if $n == 0 then "·" + elif $bad > 0 then "❌ \($bad)/\($n)" + elif $ok == $n then "✅ \($n)/\($n)" + else "⏳ \($n - $ok)/\($n)" end), + .title + ] | @tsv' 2>/dev/null || true +} + +# git:short <text> [<width>] prints <text> cut to <width> columns, 16 by default, with an ellipsis when cut. +git:short() { + local text=$1 width=${2:-16} + if (( ${#text} > width )) then - pr=$(git:pr-summary "${branch}") - ahead_behind=$(git rev-list --left-right --count "${branch}...${default_branch}" 2>/dev/null || printf '0\t0') - if [[ -n "${pr}" ]] - then - pr="#${pr%%$'\t'*} ${pr##*$'\t'}" - else - pr="(none open)" - fi - info " pr ${pr} · ↑${ahead_behind%%$'\t'*} ↓${ahead_behind##*$'\t'} vs ${default_branch}" + printf '%s…\n' "${text:0:width-1}" else - info " pr (no branch)" + printf '%s\n' "${text}" fi - if git:clean "${path}" +} + +# git:worktree-line <path> <branch> <main-root> <default-branch> prints the 🌿 line: the worktree's path, how far +# its branch is ahead of and behind the default branch, and the size of its diff. +git:worktree-line() { + local path=$1 branch=$2 main_root=$3 default_branch=$4 counts="0\t0" stat added=0 removed=0 dirty="" + if [[ -n "${branch}" ]] then - info " status clean" - else - info " status $(git -C "${path}" status --porcelain | grep --count .) file(s) modified" + counts=$(git rev-list --left-right --count "${branch}...origin/${default_branch}" 2>/dev/null || printf '0\t0') + stat=$(git diff --shortstat "origin/${default_branch}...${branch}" 2>/dev/null || true) + [[ "${stat}" =~ ([0-9]+)\ insertion ]] && added=${BASH_REMATCH[1]} + [[ "${stat}" =~ ([0-9]+)\ deletion ]] && removed=${BASH_REMATCH[1]} fi + git:clean "${path}" || dirty=" · $(git -C "${path}" status --porcelain | grep --count .) uncommitted" + printf ' 🌿 %s ↑%s ↓%s · +%s -%s%s\n' "$(git:short "${path#"${main_root}"/}" 40)" "${counts%%$'\t'*}" "${counts##*$'\t'}" "${added}" "${removed}" "${dirty}" >&2 +} + +# git:pr-block <pr-line> <path> <main-root> <default-branch> prints one pull request: its line, the commits it +# carries over the default branch, up to ten, and its worktree's line. +git:pr-block() { + local branch number state age checks title path=$2 main_root=$3 default_branch=$4 shown=0 total line + IFS=$'\t' read -r branch number state age checks title <<<"$1" + printf '🔀 #%s %-17s %-6s %4s %s\n' "${number}" "$(git:short "${title}")" "${state}" "${age}" "${checks}" >&2 + total=$(git rev-list --count "origin/${default_branch}..${branch}" 2>/dev/null || printf 0) + while IFS= read -r line + do + printf ' %s\n' "${line}" >&2 + (( ++shown )) + done < <(git log --format='%h %s' --max-count 10 "origin/${default_branch}..${branch}" 2>/dev/null) + (( total > shown )) && printf ' … and %s more\n' "$(( total - shown ))" >&2 + git:worktree-line "${path}" "${branch}" "${main_root}" "${default_branch}" } # git:tidy-worktree <path> <branch> <detached> <default-branch> <self-root> <dry-run> decides one worktree and @@ -152,7 +190,8 @@ git:tidy-worktree() { } git:status() { - local no_fetch=false arg main_root default_branch main_branch main_dirty counts found=false path branch detached gone + local no_fetch=false arg main_root default_branch prs path branch detached pr_line counts gone + local -A worktree_of=() for arg in "$@" do case "${arg}" in @@ -168,22 +207,50 @@ git:status() { then git fetch --quiet --prune origin || warn "git fetch --prune failed; ahead and behind counts may be stale" fi - main_root=$(git:main-root) default_branch=$(git:default-branch) - main_branch=$(git -C "${main_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') - main_dirty=dirty - git:clean "${main_root}" && main_dirty=clean + prs=$(git:open-prs) + counts=$(git -C "${main_root}" rev-list --left-right --count "HEAD...origin/${default_branch}" 2>/dev/null || printf '0\t0') - section "${main_root##*/} (${main_branch}, ${main_dirty}, ↑${counts%%$'\t'*} ↓${counts##*$'\t'} vs origin/${default_branch})" + printf '📋 %s open pull request(s) · %s %s%s, ↑%s ↓%s vs origin/%s\n' \ + "$(grep --count . <<<"${prs}" || true)" "${main_root##*/}" \ + "$(git -C "${main_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')" \ + "$(git:clean "${main_root}" || printf ' (dirty)')" "${counts%%$'\t'*}" "${counts##*$'\t'}" "${default_branch}" >&2 - section "worktrees/" while IFS=$'\t' read -r path branch detached do - found=true - git:status-block "${path}" "${branch}" "${main_root}" "${default_branch}" + [[ -n "${branch}" ]] && worktree_of[${branch}]=${path} done < <(git:worktrees) - [[ "${found}" == true ]] || ok "none" + + section "pull requests" + if [[ -n "${prs}" ]] + then + while IFS= read -r pr_line + do + branch=${pr_line%%$'\t'*} + if [[ -n "${worktree_of[${branch}]+x}" ]] + then + git:pr-block "${pr_line}" "${worktree_of[${branch}]}" "${main_root}" "${default_branch}" + unset 'worktree_of[${branch}]' + else + git:pr-block "${pr_line}" "" "${main_root}" "${default_branch}" 2>&1 | sed '$d' >&2 + printf ' (no worktree here)\n' >&2 + fi + done <<<"${prs}" + else + ok "none" + fi + + section "worktrees with no open pull request" + if (( ${#worktree_of[@]} > 0 )) + then + for branch in "${!worktree_of[@]}" + do + git:worktree-line "${worktree_of[${branch}]}" "${branch}" "${main_root}" "${default_branch}" + done + else + ok "none" + fi section "branches whose upstream is gone (tidy removes these)" gone=$(git:gone-branches) diff --git a/bin/kit b/bin/kit index d786a21..87bc9cf 100755 --- a/bin/kit +++ b/bin/kit @@ -2,8 +2,9 @@ # kit - the kit's command: each verb is the executable bin/kit-<verb>.sh beside it (executed entrypoint) # ---------------------------------------------------------------------------------------------------------------------- # -# The release is the directory above bin/. This file resolves it, exports it as kit_root, and hands the verb its -# arguments. Everything else, the usage text included, lives with the verb. +# The release is the directory above bin/, found through any symlink this file is reached by, as when Homebrew +# links it into its own bin. This file resolves it, exports it as kit_root, and hands the verb its arguments. +# Everything else, the usage text included, lives with the verb. # # see: .kit/1st.md @@ -15,7 +16,7 @@ then exit 1 fi -kit_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +kit_root=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) export kit_root verb=${1:-help} diff --git a/bin/kit-update.sh b/bin/kit-update.sh index 40ced66..4a3e181 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -3,8 +3,9 @@ # ---------------------------------------------------------------------------------------------------------------------- # # A file whose digest still matches the manifest, which records the previous release's own content, is replaced. -# An edited one is three-way merged with the previous release as the base. A path the previous release did not -# deliver is written only where the repository has nothing, or the same thing, there. +# An edited one is three-way merged with the previous release as the base, which release:fetch finds or fetches. +# A path the previous release did not deliver is written only where the repository has nothing, or the same +# thing, there. # # see: bin/release.sh @@ -26,20 +27,16 @@ previous=$(release:manifest-version) current=$(release:version) section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" -# The previous release is the base of every merge, and a merge is needed only when the release changed. KIT_BASE -# names the base's root; otherwise mise knows where it installed it. +# The previous release is the base of every merge, needed only when the release changed. KIT_BASE names its +# root; otherwise release:fetch finds or fetches it. base_root="" # shellcheck disable=SC2154 # release_unreleased is declared by bin/release.sh, which use loaded above if [[ -n "${KIT_BASE:-}" ]] then base_root=${KIT_BASE} -elif [[ "${previous}" != "${current}" && "${previous}" != "${release_unreleased}" ]] && command -v mise >/dev/null 2>&1 +elif [[ "${previous}" != "${current}" && "${previous}" != "${release_unreleased}" ]] then - base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) - if [[ -z "${base_root}" ]] && mise install --quiet "github:empowerite/kit@${previous}" 2>/dev/null - then - base_root=$(mise where "github:empowerite/kit@${previous}" 2>/dev/null || true) - fi + base_root=$(release:fetch "${previous}" || true) fi needs_base=false [[ "${previous}" != "${current}" ]] && needs_base=true @@ -69,7 +66,7 @@ do if [[ "${needs_base}" == true && -z "${base}" ]] then # Without the base, neither a merge nor the removal of the previous release's entries is possible. - error "${path} cannot be updated without the base release ${previous}; run: mise install github:empowerite/kit@${previous}" + error "${path} cannot be updated without the base release ${previous}; set KIT_BASE to a copy of it" conflicts=$(( conflicts + 1 )) continue fi diff --git a/bin/release.sh b/bin/release.sh index 36be28c..5be9ee4 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -83,6 +83,38 @@ release:require-targets() { release:merged() { [[ "$1" == *.json ]]; } +# release:fetch <version> prints the root of that release, fetching it if need be: the kit's cache first, then a +# mise install if mise is here, then the GitHub release itself, unpacked into the cache. Nothing here assumes how +# this release was installed. +release:fetch() { + local version=$1 root cache + cache="${XDG_CACHE_HOME:-${HOME}/.cache}/kit/kit-${version}" + if [[ -x "${cache}/bin/kit" ]] + then + printf '%s\n' "${cache}" + return 0 + fi + if command -v mise >/dev/null 2>&1 + then + root=$(mise where "github:empowerite/kit@${version}" 2>/dev/null || true) + if [[ -x "${root}/bin/kit" ]] + then + printf '%s\n' "${root}" + return 0 + fi + fi + step "Fetching kit ${version} from its GitHub release" + mkdir -p "${cache}" + if ! gh release download "v${version}" --repo empowerite/kit --pattern "kit-${version}.tar.gz" --output - 2>/dev/null \ + | tar --extract --gzip --directory "${cache}" --strip-components 1 + then + rm -rf "${cache}" + error "kit ${version} could not be fetched (run: gh auth status, or set KIT_BASE to a copy of it)" + return 1 + fi + printf '%s\n' "${cache}" +} + # release:among <name> <name>… is true when the first name is among the rest. release:among() { local needle=$1 item From 84b066adee3523dd65323d3ee43b52165ab75f1f Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 09:51:48 -0700 Subject: [PATCH 051/108] #1 README.md: why the kit exists, and three ways to install it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- README.md | 84 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ceff989..4664520 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,61 @@ # empowerite/kit: **k**eep **i**t **t**rustworthy -kit is the agentic guardrails a repository takes as a versioned mixin: the policy text an agent reads first, the -rules that bind each kind of file, the guard that keeps `main` as the human's desk, and the one command no harness -provides, the issue-to-worktree on-ramp. A tag on `main` publishes a release. A consumer pins it in `mise.toml`, -installs it with `kit install`, and keeps rules of its own beside it. The design is -[#1](https://github.com/empowerite/kit/issues/1), and the entry file every agent reads is [.kit/1st.md](.kit/1st.md). +Do you trust your agents? They were trained on the whole of what people have written, and that includes every +shortcut anyone ever took: the "done" that was not, the test that was skipped and reported as passing, the rebase +that quietly rewrote a colleague's branch. An agent is a tireless new colleague who has read everything and holds +none of your house rules, unless you put them where it looks. -## Take the kit +kit is the house rules, put where agents look, as a versioned release any repository can take and keep taking. +It is small on purpose: the text an agent reads first, a rule file per kind of file, a guard that keeps `main` as +the human's desk, and the one command no harness provides, which turns an issue into a worktree with a draft pull +request. A tag on `main` publishes a release. A repository pins it, installs it, and keeps rules of its own +beside it. The design is [#1](https://github.com/empowerite/kit/issues/1), and the entry file every agent reads is +[.kit/1st.md](.kit/1st.md). + +## Why a release, and not a copy + +A copy drifts. Six repositories with six copies of the same rules have six sets of rules within a month, and no +one can say which is current. A release is pinned, so a repository knows which rules bind it; updated, so a +change to the rules reaches every repository as a pull request that bumps a version; and checked, so `kit check` +fails the build when a delivered file was edited in place. The rules a repository adds live beside the delivered +ones, in its own files, and survive every update. + +## Install + +The `kit` command is a release: a tarball holding `bin/`, `tree/`, and `targets/`. Take it whichever way the +repository takes its other tools. -Pin a release in `mise.toml`, install it, and point `AGENTS.md` at it: +With mise, pin it in `mise.toml` and the version is part of the repository: ```toml [tools] "github:empowerite/kit" = "0.1.0" ``` +With Homebrew, from the empowerite tap: + +```sh +brew install empowerite/tap/kit +``` + +Or by hand, with the version in `version`: unpack the release and put its `bin/` on `PATH`: + +```sh +curl --location --output "kit-${version}.tar.gz" \ + "https://github.com/empowerite/kit/releases/download/v${version}/kit-${version}.tar.gz" +tar --extract --gzip --file "kit-${version}.tar.gz" +export PATH="${PWD}/kit-${version}/bin:${PATH}" +``` + +The kit needs bash 4.4 or later, git, `gh`, and `jq`. macOS ships bash 3.2, and `brew install bash` gives a +current one. With mise, an exact pin installs the minute a release exists; a range pin sees a new kit a day after +it ships unless `[settings]` carries `minimum_release_age_excludes = ["github:empowerite/kit"]`. + +## Take the kit + +In the repository, install it and point `AGENTS.md` at it: + ```sh -mise install kit install claude ``` @@ -29,26 +68,20 @@ Read .kit/1st.md first. The rest of `AGENTS.md` is yours. `install` writes `.kit/`, the CI workflow, and the files of each target named; `claude` is the one target this release knows, and a repository with no target still binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the root justfile for the delivered `CI` job to -run, and take the pre-commit linters by pinning `lefthook` in `mise.desk.toml`, putting -`extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit target -add`, `kit target remove`, and `kit target list` change and show the targets later. - -An exact pin installs the minute a release exists. A range pin sees a new kit a day after it ships unless -`[settings]` carries `minimum_release_age_excludes = ["github:empowerite/kit"]`. - -To move to a new release, bump the pin, run `mise install`, then `kit update`: a delivered file you left alone is -replaced, one you edited is three-way merged with the previous release as the base, and your own deny entries -and settings keys stay. `kit check` fails, locally and in `CI`, when a delivered file differs from the pinned -release. +run, and take the pre-commit linters by pinning `lefthook`, putting `extends: [.kit/lefthook.yml]` in +`lefthook.yml`, and running `lefthook install` once per clone. `kit target add`, `kit target remove`, and +`kit target list` change and show the targets later. -The kit needs bash 4.4 or later, git, `gh`, and `jq`; macOS ships bash 3.2, and `brew install bash` gives a -current one. The `kit` command and the bash behind its verbs stay in the release where mise installed it; the -repository carries the text an agent reads and the record of what it took. +To move to a new release, bump the pin or reinstall, then run `kit update`: a delivered file you left alone is +replaced, one you edited is three-way merged with the previous release as the base, which `update` fetches from +GitHub if it is not already on the machine, and your own deny entries and settings keys stay. `kit check` fails, +locally and in `CI`, when a delivered file differs from the pinned release. ## Setup -Every commit is signed, and the branch ruleset refuses one that is not. If you have no signing key GitHub -verifies, make one and register it, following [generating a new GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) +This repository uses its own kit, and its tools come from mise. Every commit is signed, and the branch ruleset +refuses one that is not. If you have no signing key GitHub verifies, make one and register it, following +[generating a new GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) and [adding a GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account). Then, once per clone, with the key's id in `signing_key`: @@ -72,7 +105,8 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): - `kit issue <N>` makes `worktrees/<N>-<slug>`, links the branch to the issue under Development, and opens a draft pull request. It never switches the checkout it runs from. - Work happens in that worktree. Commits are signed, and their subject is `#<N> <area>: <subject>`. `kit rebase` - brings the branch up to date with `main` and pushes it. + brings the branch up to date with `main` and pushes it, and `kit status` shows every pull request in flight + and the worktree it is written in. - `just ci` is the gate; the justfile says what it runs. - `gh pr ready` says the work is done, and the pull request body is the report. - A human merges. `kit tidy` then removes the worktree and its branch. From 9e7e3183120c9af6b23b0a10520876a2d88bb59d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 09:53:46 -0700 Subject: [PATCH 052/108] #1 bin/release.sh: a fetched version is held to a version's shape, and unpacked before it is placed The version release:fetch builds a path from comes out of a manifest, which is repository content. It is now refused unless it has a version's shape, and the tarball is unpacked into a directory made here and moved into the cache only when whole, so nothing is ever removed outside what this function made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/release.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bin/release.sh b/bin/release.sh index 5be9ee4..755453a 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -87,7 +87,14 @@ release:merged() { [[ "$1" == *.json ]]; } # mise install if mise is here, then the GitHub release itself, unpacked into the cache. Nothing here assumes how # this release was installed. release:fetch() { - local version=$1 root cache + local version=$1 root cache fresh + # The version comes from a manifest, which is repository content, so it is held to a version's shape before it + # becomes part of a path; the unpack goes into a directory made here and is moved into place only when whole. + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] + then + error "'${version}' is not a kit version" + return 1 + fi cache="${XDG_CACHE_HOME:-${HOME}/.cache}/kit/kit-${version}" if [[ -x "${cache}/bin/kit" ]] then @@ -104,14 +111,16 @@ release:fetch() { fi fi step "Fetching kit ${version} from its GitHub release" - mkdir -p "${cache}" + fresh=$(mktemp -d) if ! gh release download "v${version}" --repo empowerite/kit --pattern "kit-${version}.tar.gz" --output - 2>/dev/null \ - | tar --extract --gzip --directory "${cache}" --strip-components 1 + | tar --extract --gzip --directory "${fresh}" --strip-components 1 then - rm -rf "${cache}" + rm -rf "${fresh}" error "kit ${version} could not be fetched (run: gh auth status, or set KIT_BASE to a copy of it)" return 1 fi + mkdir -p "$(dirname "${cache}")" + mv "${fresh}" "${cache}" printf '%s\n' "${cache}" } From 9d3b77ac14d2b089a2cde1b81a4d6b0b7774d8e9 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:04:09 -0700 Subject: [PATCH 053/108] #1 bin/git.sh: kit status in the dashboard's shape One GraphQL request fetches the open pull requests with the issues they close and their latest commit's checks. Each pull request prints its full title in a name column sized across every row, then its state, age, and checks; under it the linked issues and up to ten commits with dim hashes, and its worktree with colored ahead, behind, and diff counts. Worktrees with no pull request are rows in the same table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/git.sh | 199 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 118 insertions(+), 81 deletions(-) diff --git a/bin/git.sh b/bin/git.sh index 3f8c0e7..7f8a509 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -74,68 +74,111 @@ git:clean() { [[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ]] } -# git:open-prs prints one line per open pull request: "<branch>\t<number>\t<Draft|Ready>\t<age in days>\t<checks>\t<title>", -# where <checks> is "✅ n/n", "❌ k/n", "⏳ n", or "·" for none. One request, for the whole picture. +# git:open-prs prints one record per open pull request, fields separated by tabs: branch, number, Draft or +# Ready, age in days, checks, title, and the issues it closes as "number:title" joined by unit separators. git:open-prs() { + local repo query filter + repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || return 0 + # shellcheck disable=SC2016 # GraphQL variables, and a jq filter with jq variables + query='query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 50, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + number title isDraft headRefName createdAt + closingIssuesReferences(first: 10) { nodes { number title } } + commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { + ... on CheckRun { conclusion status } + ... on StatusContext { state } + } } } } } } + } + } + } + }' # shellcheck disable=SC2016 # a jq filter, with jq variables - gh pr list --state open --limit 100 --json number,title,isDraft,headRefName,createdAt,statusCheckRollup --jq ' - .[] | [ + filter=' + .data.repository.pullRequests.nodes[] | [ .headRefName, (.number | tostring), (if .isDraft then "Draft" else "Ready" end), (((now - (.createdAt | fromdateiso8601)) / 86400 | floor | tostring) + "d"), - ((.statusCheckRollup // []) as $c + ((.commits.nodes[0].commit.statusCheckRollup.contexts.nodes // []) as $c | ($c | length) as $n - | ([$c[] | select(.conclusion == "SUCCESS")] | length) as $ok - | ([$c[] | select(.conclusion != null and .conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length) as $bad + | ([$c[] | select(.conclusion == "SUCCESS" or .state == "SUCCESS")] | length) as $ok + | ([$c[] | select((.conclusion // .state // "") as $r | $r == "FAILURE" or $r == "ERROR" or $r == "TIMED_OUT" or $r == "CANCELLED" or $r == "ACTION_REQUIRED" or $r == "STARTUP_FAILURE")] | length) as $bad | if $n == 0 then "·" - elif $bad > 0 then "❌ \($bad)/\($n)" - elif $ok == $n then "✅ \($n)/\($n)" - else "⏳ \($n - $ok)/\($n)" end), - .title - ] | @tsv' 2>/dev/null || true + elif $bad > 0 then "❌ \($n - $bad) / \($n)" + elif $ok == $n then "✅ \($n) / \($n)" + else "⏳ \($ok) / \($n)" end), + .title, + ([.closingIssuesReferences.nodes[] | "\(.number):\(.title)"] | join("\u001f")) + ] | @tsv' + gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ + 2>/dev/null || true } -# git:short <text> [<width>] prints <text> cut to <width> columns, 16 by default, with an ellipsis when cut. -git:short() { - local text=$1 width=${2:-16} - if (( ${#text} > width )) - then - printf '%s…\n' "${text:0:width-1}" - else - printf '%s\n' "${text}" - fi +# git:pad <text> <width> prints <text> padded with spaces to <width> characters, counted as characters. +git:pad() { + local text=$1 width=$2 + printf '%s' "${text}" + (( ${#text} < width )) && printf '%*s' $(( width - ${#text} )) '' + return 0 } -# git:worktree-line <path> <branch> <main-root> <default-branch> prints the 🌿 line: the worktree's path, how far -# its branch is ahead of and behind the default branch, and the size of its diff. -git:worktree-line() { - local path=$1 branch=$2 main_root=$3 default_branch=$4 counts="0\t0" stat added=0 removed=0 dirty="" - if [[ -n "${branch}" ]] - then - counts=$(git rev-list --left-right --count "${branch}...origin/${default_branch}" 2>/dev/null || printf '0\t0') - stat=$(git diff --shortstat "origin/${default_branch}...${branch}" 2>/dev/null || true) - [[ "${stat}" =~ ([0-9]+)\ insertion ]] && added=${BASH_REMATCH[1]} - [[ "${stat}" =~ ([0-9]+)\ deletion ]] && removed=${BASH_REMATCH[1]} - fi - git:clean "${path}" || dirty=" · $(git -C "${path}" status --porcelain | grep --count .) uncommitted" - printf ' 🌿 %s ↑%s ↓%s · +%s -%s%s\n' "$(git:short "${path#"${main_root}"/}" 40)" "${counts%%$'\t'*}" "${counts##*$'\t'}" "${added}" "${removed}" "${dirty}" >&2 +# git:dim, git:red, git:green wrap a text in a color when stderr is a terminal. +git:dim() { printf '%s%s%s' "$(out:sgr 90)" "$*" "$(out:sgr 0)"; } +git:red() { printf '%s%s%s' "$(out:sgr 31)" "$*" "$(out:sgr 0)"; } +git:green() { printf '%s%s%s' "$(out:sgr 32)" "$*" "$(out:sgr 0)"; } + +# git:divergence <branch> <default-branch> prints "↑a ↓b · +ins -del", colored, for the branch against origin. +git:divergence() { + local branch=$1 default_branch=$2 counts stat added=0 removed=0 + counts=$(git rev-list --left-right --count "${branch}...origin/${default_branch}" 2>/dev/null || printf '0\t0') + stat=$(git diff --shortstat "origin/${default_branch}...${branch}" 2>/dev/null || true) + [[ "${stat}" =~ ([0-9]+)\ insertion ]] && added=${BASH_REMATCH[1]} + [[ "${stat}" =~ ([0-9]+)\ deletion ]] && removed=${BASH_REMATCH[1]} + printf '%s %s · %s %s' "$(git:green "↑${counts%%$'\t'*}")" "$(git:red "↓${counts##*$'\t'}")" \ + "$(git:green "+${added}")" "$(git:red "-${removed}")" } -# git:pr-block <pr-line> <path> <main-root> <default-branch> prints one pull request: its line, the commits it -# carries over the default branch, up to ten, and its worktree's line. +# git:pr-block <record> <path> <main-root> <default-branch> <width> prints one pull request: its line, the issues +# it closes, up to ten of the commits it carries over the default branch, and its worktree's line. git:pr-block() { - local branch number state age checks title path=$2 main_root=$3 default_branch=$4 shown=0 total line - IFS=$'\t' read -r branch number state age checks title <<<"$1" - printf '🔀 #%s %-17s %-6s %4s %s\n' "${number}" "$(git:short "${title}")" "${state}" "${age}" "${checks}" >&2 + local branch number state age checks title issues path=$2 main_root=$3 default_branch=$4 width=$5 + local total shown=0 line issue colored + IFS=$'\t' read -r branch number state age checks title issues <<<"$1" + if [[ "${state}" == Draft ]] + then + colored=$(git:dim "draft") + else + colored=$(git:green "ready") + fi + printf '🔀 %s %s %4s %s\n' "$(git:pad "#${number} ${title}" "${width}")" "${colored}" "${age}" "${checks}" >&2 + if [[ -n "${issues}" ]] + then + while IFS= read -r issue + do + printf ' %s\n' "$(git:dim "#${issue%%:*} ${issue#*:}")" >&2 + done < <(tr '\037' '\n' <<<"${issues}") + fi total=$(git rev-list --count "origin/${default_branch}..${branch}" 2>/dev/null || printf 0) while IFS= read -r line do - printf ' %s\n' "${line}" >&2 + printf ' %s %s\n' "$(git:dim "${line%% *}")" "${line#* }" >&2 (( ++shown )) done < <(git log --format='%h %s' --max-count 10 "origin/${default_branch}..${branch}" 2>/dev/null) - (( total > shown )) && printf ' … and %s more\n' "$(( total - shown ))" >&2 - git:worktree-line "${path}" "${branch}" "${main_root}" "${default_branch}" + (( total > shown )) && printf ' %s\n' "$(git:dim "… and $(( total - shown )) more")" >&2 + if [[ -n "${path}" ]] + then + printf ' 🌿 %s %s%s\n' "$(git:dim "${path#"${main_root}"/}")" "$(git:divergence "${branch}" "${default_branch}")" "$(git:uncommitted "${path}")" >&2 + else + printf ' %s\n' "$(git:dim "no worktree here")" >&2 + fi +} + +# git:uncommitted <path> prints " · n uncommitted" for a dirty checkout, nothing for a clean one. +git:uncommitted() { + git:clean "$1" && return 0 + printf ' · %s' "$(git:red "$(git -C "$1" status --porcelain | grep --count .) uncommitted")" } # git:tidy-worktree <path> <branch> <detached> <default-branch> <self-root> <dry-run> decides one worktree and @@ -190,7 +233,7 @@ git:tidy-worktree() { } git:status() { - local no_fetch=false arg main_root default_branch prs path branch detached pr_line counts gone + local no_fetch=false arg main_root default_branch prs path branch detached record width=40 text drafts total gone local -A worktree_of=() for arg in "$@" do @@ -210,58 +253,52 @@ git:status() { main_root=$(git:main-root) default_branch=$(git:default-branch) prs=$(git:open-prs) - - counts=$(git -C "${main_root}" rev-list --left-right --count "HEAD...origin/${default_branch}" 2>/dev/null || printf '0\t0') - printf '📋 %s open pull request(s) · %s %s%s, ↑%s ↓%s vs origin/%s\n' \ - "$(grep --count . <<<"${prs}" || true)" "${main_root##*/}" \ - "$(git -C "${main_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')" \ - "$(git:clean "${main_root}" || printf ' (dirty)')" "${counts%%$'\t'*}" "${counts##*$'\t'}" "${default_branch}" >&2 - while IFS=$'\t' read -r path branch detached do [[ -n "${branch}" ]] && worktree_of[${branch}]=${path} done < <(git:worktrees) - section "pull requests" - if [[ -n "${prs}" ]] - then - while IFS= read -r pr_line - do - branch=${pr_line%%$'\t'*} - if [[ -n "${worktree_of[${branch}]+x}" ]] - then - git:pr-block "${pr_line}" "${worktree_of[${branch}]}" "${main_root}" "${default_branch}" - unset 'worktree_of[${branch}]' - else - git:pr-block "${pr_line}" "" "${main_root}" "${default_branch}" 2>&1 | sed '$d' >&2 - printf ' (no worktree here)\n' >&2 - fi - done <<<"${prs}" - else - ok "none" - fi + # The name column is as wide as the widest name, within what the terminal allows beside the other columns. + while IFS=$'\t' read -r branch text _ + do + [[ -n "${branch}" ]] || continue + text="#${text}" + (( ${#text} > width )) && width=${#text} + done < <(awk -F '\t' '{ print $1 "\t" $2 " " $6 }' <<<"${prs}") + for branch in "${!worktree_of[@]}" + do + text=${worktree_of[${branch}]#"${main_root}"/} + (( ${#text} > width )) && width=${#text} + done + (( width > $(tput cols 2>/dev/null || echo 160) - 40 )) && width=$(( $(tput cols 2>/dev/null || echo 160) - 40 )) - section "worktrees with no open pull request" - if (( ${#worktree_of[@]} > 0 )) - then - for branch in "${!worktree_of[@]}" - do - git:worktree-line "${worktree_of[${branch}]}" "${branch}" "${main_root}" "${default_branch}" - done - else - ok "none" - fi + drafts=$(awk -F '\t' '$3 == "Draft"' <<<"${prs}" | grep --count . || true) + total=$(grep --count . <<<"${prs}" || true) + printf '📋 %s open pull request(s) · %s draft · %s ready\n' "${total}" "${drafts}" "$(( total - drafts ))" >&2 + section "worktrees" + while IFS= read -r record + do + [[ -n "${record}" ]] || continue + branch=${record%%$'\t'*} + git:pr-block "${record}" "${worktree_of[${branch}]:-}" "${main_root}" "${default_branch}" "${width}" + unset 'worktree_of[${branch}]' + done <<<"${prs}" + for branch in $(printf '%s\n' "${!worktree_of[@]}" | sort) + do + path=${worktree_of[${branch}]} + printf '🌿 %s %s %s%s\n' "$(git:pad "${path#"${main_root}"/}" "${width}")" "$(git:dim 'no open pull request')" \ + "$(git:divergence "${branch}" "${default_branch}")" "$(git:uncommitted "${path}")" >&2 + done + [[ -n "${prs}" || ${#worktree_of[@]} -gt 0 ]] || ok "none" - section "branches whose upstream is gone (tidy removes these)" gone=$(git:gone-branches) if [[ -n "${gone}" ]] then + section "branches whose upstream is gone (tidy removes these)" while IFS= read -r branch do info "${branch}" done <<<"${gone}" - else - ok "none" fi } From c3ce1012bf7c69c942ba2b2d6aacddc80e36570d Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:24:53 -0700 Subject: [PATCH 054/108] #1 bin/status.sh: kit status drawn the way enchiridion's dashboard is The rendering moves out of git.sh into its own module. One GraphQL request brings every open pull request with the issues it closes, its review threads, and the checks on its head. Each pull request is classified into one of eight states, in the dashboard's order, and drawn with that state's glyph and its family's color: green for ready, yellow for a decision a human owes, blue for waiting, dim for a draft. The check cell says how many checks passed and how long the run took, or which check failed. Commits are listed past the branch's fork from main, with the middle elided and the oldest kept; ahead, behind, and line counts are measured from the same fork, bold green and red when non-zero and dim at zero. Worktrees with no pull request are rows in the same table, by issue number, and a detached one is marked. The column widths are measured over every row and clipped to the terminal. Four bats cases cover the cell, the commit list, and the worktree rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/git.sh | 182 ------------------- bin/kit-status.sh | 6 +- bin/status.sh | 436 ++++++++++++++++++++++++++++++++++++++++++++++ tests/status.bats | 65 +++++++ 4 files changed, 504 insertions(+), 185 deletions(-) create mode 100644 bin/status.sh create mode 100644 tests/status.bats diff --git a/bin/git.sh b/bin/git.sh index 7f8a509..4dd85e6 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -2,11 +2,6 @@ # git.sh - the default branch, the worktrees, the sweep, and the rebase (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# git:status [--no-fetch] draws the picture of the work in flight: the open pull requests, and under each its -# state, age, checks, the commits it carries, and the worktree it is written in, with how far that is ahead of and -# behind the default branch and the size of its diff; then the worktrees with no pull request; then the local -# branches whose upstream is gone. It changes nothing, and asks GitHub once. -# # git:tidy [--dry-run] removes every worktree under worktrees/ whose pull request is merged or closed, or that is # detached, clean, and at a commit the default branch already has; prunes the worktree bookkeeping; and deletes # every local branch whose upstream is gone. It never touches the default branch, a branch checked out in a @@ -74,113 +69,6 @@ git:clean() { [[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ]] } -# git:open-prs prints one record per open pull request, fields separated by tabs: branch, number, Draft or -# Ready, age in days, checks, title, and the issues it closes as "number:title" joined by unit separators. -git:open-prs() { - local repo query filter - repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || return 0 - # shellcheck disable=SC2016 # GraphQL variables, and a jq filter with jq variables - query='query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - pullRequests(states: OPEN, first: 50, orderBy: {field: CREATED_AT, direction: ASC}) { - nodes { - number title isDraft headRefName createdAt - closingIssuesReferences(first: 10) { nodes { number title } } - commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { - ... on CheckRun { conclusion status } - ... on StatusContext { state } - } } } } } } - } - } - } - }' - # shellcheck disable=SC2016 # a jq filter, with jq variables - filter=' - .data.repository.pullRequests.nodes[] | [ - .headRefName, - (.number | tostring), - (if .isDraft then "Draft" else "Ready" end), - (((now - (.createdAt | fromdateiso8601)) / 86400 | floor | tostring) + "d"), - ((.commits.nodes[0].commit.statusCheckRollup.contexts.nodes // []) as $c - | ($c | length) as $n - | ([$c[] | select(.conclusion == "SUCCESS" or .state == "SUCCESS")] | length) as $ok - | ([$c[] | select((.conclusion // .state // "") as $r | $r == "FAILURE" or $r == "ERROR" or $r == "TIMED_OUT" or $r == "CANCELLED" or $r == "ACTION_REQUIRED" or $r == "STARTUP_FAILURE")] | length) as $bad - | if $n == 0 then "·" - elif $bad > 0 then "❌ \($n - $bad) / \($n)" - elif $ok == $n then "✅ \($n) / \($n)" - else "⏳ \($ok) / \($n)" end), - .title, - ([.closingIssuesReferences.nodes[] | "\(.number):\(.title)"] | join("\u001f")) - ] | @tsv' - gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ - 2>/dev/null || true -} - -# git:pad <text> <width> prints <text> padded with spaces to <width> characters, counted as characters. -git:pad() { - local text=$1 width=$2 - printf '%s' "${text}" - (( ${#text} < width )) && printf '%*s' $(( width - ${#text} )) '' - return 0 -} - -# git:dim, git:red, git:green wrap a text in a color when stderr is a terminal. -git:dim() { printf '%s%s%s' "$(out:sgr 90)" "$*" "$(out:sgr 0)"; } -git:red() { printf '%s%s%s' "$(out:sgr 31)" "$*" "$(out:sgr 0)"; } -git:green() { printf '%s%s%s' "$(out:sgr 32)" "$*" "$(out:sgr 0)"; } - -# git:divergence <branch> <default-branch> prints "↑a ↓b · +ins -del", colored, for the branch against origin. -git:divergence() { - local branch=$1 default_branch=$2 counts stat added=0 removed=0 - counts=$(git rev-list --left-right --count "${branch}...origin/${default_branch}" 2>/dev/null || printf '0\t0') - stat=$(git diff --shortstat "origin/${default_branch}...${branch}" 2>/dev/null || true) - [[ "${stat}" =~ ([0-9]+)\ insertion ]] && added=${BASH_REMATCH[1]} - [[ "${stat}" =~ ([0-9]+)\ deletion ]] && removed=${BASH_REMATCH[1]} - printf '%s %s · %s %s' "$(git:green "↑${counts%%$'\t'*}")" "$(git:red "↓${counts##*$'\t'}")" \ - "$(git:green "+${added}")" "$(git:red "-${removed}")" -} - -# git:pr-block <record> <path> <main-root> <default-branch> <width> prints one pull request: its line, the issues -# it closes, up to ten of the commits it carries over the default branch, and its worktree's line. -git:pr-block() { - local branch number state age checks title issues path=$2 main_root=$3 default_branch=$4 width=$5 - local total shown=0 line issue colored - IFS=$'\t' read -r branch number state age checks title issues <<<"$1" - if [[ "${state}" == Draft ]] - then - colored=$(git:dim "draft") - else - colored=$(git:green "ready") - fi - printf '🔀 %s %s %4s %s\n' "$(git:pad "#${number} ${title}" "${width}")" "${colored}" "${age}" "${checks}" >&2 - if [[ -n "${issues}" ]] - then - while IFS= read -r issue - do - printf ' %s\n' "$(git:dim "#${issue%%:*} ${issue#*:}")" >&2 - done < <(tr '\037' '\n' <<<"${issues}") - fi - total=$(git rev-list --count "origin/${default_branch}..${branch}" 2>/dev/null || printf 0) - while IFS= read -r line - do - printf ' %s %s\n' "$(git:dim "${line%% *}")" "${line#* }" >&2 - (( ++shown )) - done < <(git log --format='%h %s' --max-count 10 "origin/${default_branch}..${branch}" 2>/dev/null) - (( total > shown )) && printf ' %s\n' "$(git:dim "… and $(( total - shown )) more")" >&2 - if [[ -n "${path}" ]] - then - printf ' 🌿 %s %s%s\n' "$(git:dim "${path#"${main_root}"/}")" "$(git:divergence "${branch}" "${default_branch}")" "$(git:uncommitted "${path}")" >&2 - else - printf ' %s\n' "$(git:dim "no worktree here")" >&2 - fi -} - -# git:uncommitted <path> prints " · n uncommitted" for a dirty checkout, nothing for a clean one. -git:uncommitted() { - git:clean "$1" && return 0 - printf ' · %s' "$(git:red "$(git -C "$1" status --porcelain | grep --count .) uncommitted")" -} - # git:tidy-worktree <path> <branch> <detached> <default-branch> <self-root> <dry-run> decides one worktree and # says why. It returns 0 when the worktree was removed, or would be under a dry run, and 1 when it is kept. git:tidy-worktree() { @@ -232,76 +120,6 @@ git:tidy-worktree() { esac } -git:status() { - local no_fetch=false arg main_root default_branch prs path branch detached record width=40 text drafts total gone - local -A worktree_of=() - for arg in "$@" - do - case "${arg}" in - (--no-fetch) - no_fetch=true - ;; - (*) - error "unknown flag '${arg}' (only --no-fetch is recognized)" - return 2 - esac - done - if [[ "${no_fetch}" != true ]] - then - git fetch --quiet --prune origin || warn "git fetch --prune failed; ahead and behind counts may be stale" - fi - main_root=$(git:main-root) - default_branch=$(git:default-branch) - prs=$(git:open-prs) - while IFS=$'\t' read -r path branch detached - do - [[ -n "${branch}" ]] && worktree_of[${branch}]=${path} - done < <(git:worktrees) - - # The name column is as wide as the widest name, within what the terminal allows beside the other columns. - while IFS=$'\t' read -r branch text _ - do - [[ -n "${branch}" ]] || continue - text="#${text}" - (( ${#text} > width )) && width=${#text} - done < <(awk -F '\t' '{ print $1 "\t" $2 " " $6 }' <<<"${prs}") - for branch in "${!worktree_of[@]}" - do - text=${worktree_of[${branch}]#"${main_root}"/} - (( ${#text} > width )) && width=${#text} - done - (( width > $(tput cols 2>/dev/null || echo 160) - 40 )) && width=$(( $(tput cols 2>/dev/null || echo 160) - 40 )) - - drafts=$(awk -F '\t' '$3 == "Draft"' <<<"${prs}" | grep --count . || true) - total=$(grep --count . <<<"${prs}" || true) - printf '📋 %s open pull request(s) · %s draft · %s ready\n' "${total}" "${drafts}" "$(( total - drafts ))" >&2 - section "worktrees" - while IFS= read -r record - do - [[ -n "${record}" ]] || continue - branch=${record%%$'\t'*} - git:pr-block "${record}" "${worktree_of[${branch}]:-}" "${main_root}" "${default_branch}" "${width}" - unset 'worktree_of[${branch}]' - done <<<"${prs}" - for branch in $(printf '%s\n' "${!worktree_of[@]}" | sort) - do - path=${worktree_of[${branch}]} - printf '🌿 %s %s %s%s\n' "$(git:pad "${path#"${main_root}"/}" "${width}")" "$(git:dim 'no open pull request')" \ - "$(git:divergence "${branch}" "${default_branch}")" "$(git:uncommitted "${path}")" >&2 - done - [[ -n "${prs}" || ${#worktree_of[@]} -gt 0 ]] || ok "none" - - gone=$(git:gone-branches) - if [[ -n "${gone}" ]] - then - section "branches whose upstream is gone (tidy removes these)" - while IFS= read -r branch - do - info "${branch}" - done <<<"${gone}" - fi -} - git:tidy() { local dry_run=false flag repo default_branch self_root found=false path branch detached local removed_worktrees=0 removed_branches=0 checked_out diff --git a/bin/kit-status.sh b/bin/kit-status.sh index ac38f9b..a0c04a3 100755 --- a/bin/kit-status.sh +++ b/bin/kit-status.sh @@ -2,12 +2,12 @@ # kit-status - show every worktree with its branch, pull request, and state (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# see: bin/git.sh +# see: bin/status.sh set -o errexit -o nounset -o pipefail # shellcheck source=bin/1st.sh source "${kit_root:?run through bin/kit}/bin/1st.sh" -use git +use status if [[ "${1:-}" == --help ]] then @@ -15,6 +15,6 @@ then exit 0 fi -git:status "$@" +status:render "$@" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/status.sh b/bin/status.sh new file mode 100644 index 0000000..6a00ba6 --- /dev/null +++ b/bin/status.sh @@ -0,0 +1,436 @@ +# shellcheck shell=bash +# status.sh - the picture of the work in flight (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# status:render [--no-fetch] prints the open pull requests and the linked worktrees, and changes nothing. It asks +# GitHub once, for every open pull request with the issues it closes, its review threads, and the checks on its +# head; everything else is a local git read. +# +# The picture is a headline counting the pull requests by state, then three sections, each omitted when empty: +# +# worktrees — every pull request written in a worktree under worktrees/, then the worktrees with no pull +# request. A pull request's block is its row, the issues it closes, up to ten of the commits it +# carries past its fork from the default branch, and its worktree with how far that has moved +# from the fork and the size of its diff. +# pull requests — every open pull request with no worktree here, the same block without the worktree. +# gone branches — every local branch whose upstream is gone, which kit tidy removes. +# +# A row is the title, the state with its glyph, the age, and the checks. Every state has its own glyph, so a row is +# recognizable without reading the token. Color is by family, never by state: green when nothing is left to decide, +# yellow when a human owes a decision, blue when the next move is a machine's or a reviewer's, dim when someone +# already owns the work. The state is the first of these that holds: +# +# draft 🔨 dim unfinished work that belongs to someone +# queued 🚦 blue a check is still running +# conflicted 💥 yellow the branch does not apply cleanly to the default branch +# failing:gate 🔥 yellow a check failed; the row names which +# no-check 📭 blue nothing has reported on its head +# comments:open 💬 yellow unresolved review threads +# blocked:approval 👀 blue a review is required or requested and has not arrived +# ready 🛬 green green, thread-free, approved, and mergeable + +use git + +# status_commit_cap is how many commits a block lists before eliding the rest. +status_commit_cap=10 + +# status:width prints the terminal's width in columns: COLUMNS, else the terminal on stderr, else 100. +status:width() { + local cols='' + if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] + then + cols=$(( 10#${COLUMNS} )) + elif [[ -t 2 ]] + then + cols=$(stty size <&2 2>/dev/null | cut -d' ' -f2 || true) + fi + if [[ "${cols}" =~ ^[0-9]+$ ]] && (( cols >= 60 )) + then + printf '%s\n' "${cols}" + else + printf '100\n' + fi +} + +# status:cell <text> <width> prints <text> padded to <width> characters, or clipped to it with an ellipsis. +status:cell() { + local text=$1 width=$2 + if (( ${#text} > width )) + then + printf '%s…' "${text:0:width-1}" + else + printf '%s%*s' "${text}" $(( width - ${#text} )) '' + fi +} + +# status:paint <sgr> <text> prints <text> in the color when stderr is a terminal. +status:paint() { + printf '%s%s%s' "$(out:sgr "$1")" "$2" "$(out:sgr 0)" +} + +# status:state-sgr <state> prints the family color of a state. +status:state-sgr() { + case "$1" in + (ready) printf '32' ;; + (conflicted | failing:gate | comments:open) printf '33' ;; + (queued | no-check | blocked:approval) printf '34' ;; + (*) printf '90' ;; + esac +} + +# status:state-emoji <state> prints the glyph of a state. Every glyph is one code point drawn two columns wide, so +# the column after it stays aligned. +status:state-emoji() { + case "$1" in + (draft) printf '🔨' ;; + (queued) printf '🚦' ;; + (conflicted) printf '💥' ;; + (failing:gate) printf '🔥' ;; + (no-check) printf '📭' ;; + (comments:open) printf '💬' ;; + (blocked:approval) printf '👀' ;; + (ready) printf '🛬' ;; + (*) printf '🔳' ;; + esac +} + +# status:collect prints one record per open pull request, oldest first, with the fields separated by the unit +# separator: number, branch, title, state, age, check glyph, check text, and the issues it closes as "#n title" +# separated by the record separator. The one request to GitHub. +status:collect() { + local repo query filter + repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || return 0 + # shellcheck disable=SC2016 # GraphQL variables + query='query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + number title isDraft headRefName createdAt mergeStateStatus reviewDecision + closingIssuesReferences(first: 10) { nodes { number title } } + reviewThreads(first: 100) { nodes { isResolved } } + commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { + __typename + ... on CheckRun { name status conclusion startedAt completedAt } + ... on StatusContext { context state createdAt } + } } } } } } + } + } + } + }' + # shellcheck disable=SC2016 # a jq filter, with jq variables + filter=' + def age: if . < 120 then "\(.)s" elif . < 7200 then "\(. / 60 | floor)m" + elif . < 172800 then "\(. / 3600 | floor)h" else "\(. / 86400 | floor)d" end; + def verdict: if .__typename == "CheckRun" then (if .status == "COMPLETED" then .conclusion else "PENDING" end) + else .state end; + def pending: IN("PENDING", "EXPECTED"); + def passed: IN("SUCCESS", "NEUTRAL", "SKIPPED"); + (now | floor) as $now + | .data.repository.pullRequests.nodes[] + | [.commits.nodes[0].commit.statusCheckRollup.contexts.nodes[]?] as $checks + | ($checks | length) as $n + | ([$checks[] | verdict | select(pending)] | length) as $running + | ([$checks[] | verdict | select(passed)] | length) as $ok + | ([$checks[] | select((verdict | pending or passed) | not) | (.name // .context)] | first // "") as $failing + | (if $n == 0 then "NONE" elif $failing != "" then "FAILURE" elif $running > 0 then "PENDING" else "SUCCESS" end) + as $check + | ([$checks[] | .startedAt // .createdAt // empty | fromdateiso8601] | min) as $started + | ([$checks[] | .completedAt // empty | fromdateiso8601] | max) as $completed + | (if $started == null then "" + elif $check == "PENDING" then (($now - $started) | age) + elif $completed == null then "" + else (($completed - $started) | age) end) as $took + | (if $check == "SUCCESS" then ["✅", "\($n) / \($n)"] + elif $check == "FAILURE" then ["❌", "\($failing) · \($ok) / \($n)"] + elif $check == "PENDING" then ["🏃", "\($n - $running) / \($n)"] + else ["", ""] end) as $cell + | ([.reviewThreads.nodes[]? | select(.isResolved | not)] | length) as $unresolved + | (if .isDraft then "draft" + elif $check == "PENDING" then "queued" + elif .mergeStateStatus == "DIRTY" then "conflicted" + elif $check == "FAILURE" then "failing:gate" + elif $check == "NONE" then "no-check" + elif $unresolved > 0 then "comments:open" + elif (.reviewDecision // "") | IN("REVIEW_REQUIRED", "CHANGES_REQUESTED") then "blocked:approval" + else "ready" end) as $state + | [ (.number | tostring), .headRefName, .title, $state, + (($now - (.createdAt | fromdateiso8601)) | age), + $cell[0], ($cell[1] + (if $took == "" or $cell[1] == "" then "" else " · \($took)" end)), + ([.closingIssuesReferences.nodes[] | "#\(.number) \(.title)"] | join("\u001e")) ] + | join("\u001f")' + gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ + 2>/dev/null || true +} + +# status:fork <branch> <default-branch> prints the commit where <branch> left the default branch, or nothing. +status:fork() { + git merge-base "origin/$2" "$1" 2>/dev/null || git merge-base "$2" "$1" 2>/dev/null || true +} + +# status:divergence <path> <branch> <fork> prints "<ahead>\t<behind>\t<added>\t<removed>": the commits on <branch> +# past <fork>, the commits on the default branch past it, and the lines the working tree at <path> adds and removes +# against it. A detached checkout has no branch and no fork, and measures its lines against its own head. +status:divergence() { + local path=$1 branch=$2 fork=$3 default_branch=$4 ahead=0 behind=0 added=0 removed=0 a r _ + if [[ -n "${fork}" ]] + then + ahead=$(git rev-list --count "${fork}..${branch}" 2>/dev/null || printf 0) + behind=$(git rev-list --count "${fork}..origin/${default_branch}" 2>/dev/null || printf 0) + fi + while IFS=$'\t' read -r a r _ + do + [[ -n "${a}" && "${a}" != '-' ]] && added=$(( added + a )) + [[ -n "${r}" && "${r}" != '-' ]] && removed=$(( removed + r )) + done < <(git -C "${path}" diff --numstat "${fork:-HEAD}" 2>/dev/null || true) + printf '%s\t%s\t%s\t%s\n' "${ahead}" "${behind}" "${added}" "${removed}" +} + +# status:paint-counts <ahead> <behind> <added> <removed> prints "↑a ↓b · +x -y": a non-zero count in bold green or +# bold red, a zero dim. +status:paint-counts() { + local ahead=$1 behind=$2 added=$3 removed=$4 + printf '%s %s · %s %s' \ + "$(status:paint-count "${ahead}" '↑' '1;32')" "$(status:paint-count "${behind}" '↓' '1;31')" \ + "$(status:paint-count "${added}" '+' '1;32')" "$(status:paint-count "${removed}" '-' '1;31')" +} + +# status:paint-count <count> <sign> <sgr> prints <sign><count>, in <sgr> when the count is above zero and dim otherwise. +status:paint-count() { + local count=$1 sign=$2 sgr=$3 + (( count > 0 )) || sgr=90 + status:paint "${sgr}" "${sign}${count}" +} + +# status:commits <path> <range> prints the commits in <range>, dim hash then subject, indented under a row. Past +# status_commit_cap, the first commits, a dim "… and N more", and the oldest. +status:commits() { + local path=$1 range=$2 line total hidden=0 i + local -a commits=() + mapfile -t commits < <(git -C "${path}" log --format='%h %s' "${range}" 2>/dev/null || true) + total=${#commits[@]} + (( total > status_commit_cap + 1 )) && hidden=$(( total - status_commit_cap )) + for (( i = 0; i < total; i++ )) + do + if (( hidden > 0 && i == status_commit_cap - 1 )) + then + printf ' %s\n' "$(status:paint 90 "… and ${hidden} more")" >&2 + i=$(( total - 2 )) + continue + fi + line=${commits[i]} + printf ' %s %s\n' "$(status:paint 90 "${line%% *}")" "${line#* }" >&2 + done +} + +# status:pr-block <record> <title-width> <state-width> <age-width> <path> <main-root> <default-branch> prints one +# pull request: its row, the issues it closes, and, when <path> names its worktree, the commits it carries past its +# fork and the worktree's line. +status:pr-block() { + local title_w=$2 state_w=$3 age_w=$4 path=$5 main_root=$6 default_branch=$7 + local number branch title state age glyph check issues issue row fork counts + IFS=$'\x1f' read -r number branch title state age glyph check issues <<<"$1" + row="🔀 $(status:cell "#${number} ${title}" "${title_w}")" + row+=" $(status:state-emoji "${state}") $(status:paint "$(status:state-sgr "${state}")" "$(status:cell "${state}" "${state_w}")")" + row+=" $(status:paint 90 "$(status:cell "${age}" "${age_w}")")" + [[ -n "${glyph}" ]] && row+=" ${glyph} ${check}" + printf '%s\n' "${row}" >&2 + if [[ -n "${issues}" ]] + then + while IFS= read -r issue + do + printf ' %s\n' "$(status:paint 90 "${issue}")" >&2 + done < <(tr '\036' '\n' <<<"${issues}") + fi + [[ -n "${path}" ]] || return 0 + fork=$(status:fork "${branch}" "${default_branch}") + [[ -n "${fork}" ]] && status:commits "${path}" "${fork}..${branch}" + counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") + # shellcheck disable=SC2086 # the four counts are tab-separated words + printf ' 🌿 %s %s%s\n' "$(status:paint 90 "${path#"${main_root}"/}")" "$(status:paint-counts ${counts})" \ + "$(status:uncommitted "${path}")" >&2 +} + +# status:worktree-alone <path> <branch> <title-width> <main-root> <default-branch> prints a worktree with no open +# pull request as a row: its path, why it stands alone, and its divergence. +status:worktree-alone() { + local path=$1 branch=$2 title_w=$3 main_root=$4 default_branch=$5 mark fork='' counts + if [[ -n "${branch}" ]] + then + mark=$(status:paint 90 'no open pull request') + fork=$(status:fork "${branch}" "${default_branch}") + else + mark=$(status:paint 33 '❗ detached HEAD') + fi + counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") + # shellcheck disable=SC2086 # the four counts are tab-separated words + printf '🌿 %s %s %s%s\n' "$(status:cell "${path#"${main_root}"/}" "${title_w}")" "${mark}" \ + "$(status:paint-counts ${counts})" "$(status:uncommitted "${path}")" >&2 +} + +# status:uncommitted <path> prints " · n uncommitted" for a dirty checkout, nothing for a clean one. +status:uncommitted() { + git:clean "$1" && return 0 + printf ' · %s' "$(status:paint 33 "$(git -C "$1" status --porcelain | grep --count .) uncommitted")" +} + +# status:by-number <branch>… prints "<number>\t<branch>" for each branch whose worktree directory opens with an issue +# number, 0 for one that does not, sorted by that number. +status:by-number() { + local branch dir number + for branch in "$@" + do + dir=${worktree_of[${branch}]##*/} + number=${dir%%-*} + [[ "${number}" =~ ^[0-9]+$ ]] || number=0 + printf '%s\t%s\n' "${number}" "${branch}" + done | sort -n -k1,1 +} + +# status:badges <state>… prints "<glyph> <count>" per state, most populous first. +status:badges() { + local -A tally=() + local state count out='' + for state in "$@" + do + tally[${state}]=$(( ${tally[${state}]:-0} + 1 )) + done + while IFS=$'\t' read -r count state + do + out+="${out:+ }$(status:state-emoji "${state}") ${count}" + done < <( + for state in "${!tally[@]}" + do + printf '%s\t%s\n' "${tally[${state}]}" "${state}" + done | sort -t$'\t' -k1,1nr -k2,2 + ) + printf '%s' "${out}" +} + +status:render() { + local no_fetch=false arg main_root default_branch records path branch record + for arg in "$@" + do + case "${arg}" in + (--no-fetch) + no_fetch=true + ;; + (*) + error "unknown flag '${arg}' (only --no-fetch is recognized)" + return 2 + esac + done + if [[ "${no_fetch}" != true ]] + then + git fetch --quiet --prune origin || warn "git fetch --prune failed; ahead and behind counts may be stale" + fi + main_root=$(git:main-root) + default_branch=$(git:default-branch) + records=$(status:collect) + + # The linked worktrees: by branch, and the detached ones. + local -A worktree_of=() + local -a detached_paths=() + while IFS=$'\t' read -r path branch _ + do + [[ -n "${path}" ]] || continue + if [[ -n "${branch}" ]] + then + worktree_of[${branch}]=${path} + else + detached_paths+=("${path}") + fi + done < <(git:worktrees) + + # The rows, and the width of each column across them. + local -a rows=() states=() + local -A pr_on=() + local number title state age glyph check issues count=0 state_w=0 age_w=0 check_w=0 title_w=0 issue + while IFS= read -r record + do + [[ -n "${record}" ]] || continue + IFS=$'\x1f' read -r number branch title state age glyph check issues <<<"${record}" + rows+=("${record}") + states+=("${state}") + pr_on[${branch}]=1 + (( ${#state} > state_w )) && state_w=${#state} + (( ${#age} > age_w )) && age_w=${#age} + [[ -n "${glyph}" ]] && (( ${#check} + 3 > check_w )) && check_w=$(( ${#check} + 3 )) + title="#${number} ${title}" + (( ${#title} > title_w )) && title_w=${#title} + while IFS= read -r issue + do + (( ${#issue} > title_w )) && title_w=${#issue} + done < <(tr '\036' '\n' <<<"${issues}") + done <<<"${records}" + count=${#rows[@]} + for path in "${worktree_of[@]}" "${detached_paths[@]}" + do + path=${path#"${main_root}"/} + (( ${#path} > title_w )) && title_w=${#path} + done + local avail + avail=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - check_w )) + (( avail < 20 )) && avail=20 + (( title_w > avail )) && title_w=${avail} + + if (( count == 0 )) + then + out:emit '📋' '1' 'no open pull requests' + else + out:emit '📋' '1' "${count} open pull request$( (( count == 1 )) || printf s) · $(status:badges "${states[@]}")" + fi + + # Worktrees: the pull requests written in one, then the worktrees without one, by issue number. + local -A drawn=() + local -a lone=() + for branch in "${!worktree_of[@]}" + do + [[ -n "${pr_on[${branch}]:-}" ]] || lone+=("${branch}") + done + if (( ${#worktree_of[@]} + ${#detached_paths[@]} > 0 )) + then + section "worktrees" + for record in "${rows[@]}" + do + IFS=$'\x1f' read -r number branch _ <<<"${record}" + [[ -n "${worktree_of[${branch}]:-}" ]] || continue + drawn[${number}]=1 + status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" "${worktree_of[${branch}]}" "${main_root}" \ + "${default_branch}" + done + while IFS=$'\t' read -r _ branch + do + [[ -n "${branch}" ]] || continue + status:worktree-alone "${worktree_of[${branch}]}" "${branch}" "${title_w}" "${main_root}" "${default_branch}" + done < <(status:by-number "${lone[@]}") + for path in "${detached_paths[@]}" + do + status:worktree-alone "${path}" '' "${title_w}" "${main_root}" "${default_branch}" + done + fi + + # Pull requests with no worktree here. + if (( count > ${#drawn[@]} )) + then + section "pull requests" + for record in "${rows[@]}" + do + number=${record%%$'\x1f'*} + [[ -n "${drawn[${number}]:-}" ]] && continue + status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" '' "${main_root}" "${default_branch}" + done + fi + + local gone + gone=$(git:gone-branches) + if [[ -n "${gone}" ]] + then + section "branches whose upstream is gone (tidy removes these)" + while IFS= read -r branch + do + info "${branch}" + done <<<"${gone}" + fi +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/status.bats b/tests/status.bats new file mode 100644 index 0000000..ec298f2 --- /dev/null +++ b/tests/status.bats @@ -0,0 +1,65 @@ +#!/usr/bin/env bats +# status.bats - the parts of the picture that need no GitHub: a cell, the commit list, and a worktree's row +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main + mkdir worktrees +} + +teardown() { + rm -rf "${work}" +} + +# in_status <command>… runs a command with the status module loaded. +in_status() { + bash -c 'source "${kit_root}/bin/1st.sh" && use status && "$@"' _ "$@" +} + +@test "a cell is padded to its width, or clipped with an ellipsis" { + run in_status status:cell abc 5 + [ "$output" = "abc " ] + run in_status status:cell abcdefgh 5 + [ "$output" = "abcd…" ] +} + +@test "the commit list shows the newest, says how many are hidden, and ends with the oldest" { + git worktree add --quiet -b topic worktrees/topic main + for i in $(seq 1 15) + do + printf '%s\n' "${i}" > "worktrees/topic/f${i}" && git -C worktrees/topic add . && git -C worktrees/topic commit --quiet --message "c${i}" + done + run in_status status:commits "${PWD}/worktrees/topic" main..topic + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 11 ] + [[ "${lines[0]}" == *" c15" ]] + [[ "${lines[8]}" == *" c7" ]] + [[ "${lines[9]}" == *"… and 5 more" ]] + [[ "${lines[10]}" == *" c1" ]] +} + +@test "a worktree with no pull request says so, with its distance from main and the size of its diff" { + git worktree add --quiet -b topic worktrees/topic main + printf 'two\nthree\n' > worktrees/topic/b && git -C worktrees/topic add b && git -C worktrees/topic commit --quiet --message two + printf 'four\n' > c && git add c && git commit --quiet --message four && git push --quiet origin main + run in_status status:worktree-alone "${PWD}/worktrees/topic" topic 20 "${PWD}" main + [ "$status" -eq 0 ] + [ "$output" = "🌿 worktrees/topic no open pull request ↑1 ↓1 · +2 -0" ] +} + +@test "a detached worktree is marked, and a dirty one counts its uncommitted paths" { + git worktree add --quiet --detach worktrees/loose main + printf 'x\n' > worktrees/loose/x + run in_status status:worktree-alone "${PWD}/worktrees/loose" '' 16 "${PWD}" main + [ "$status" -eq 0 ] + [ "$output" = "🌿 worktrees/loose ❗ detached HEAD ↑0 ↓0 · +0 -0 · 1 uncommitted" ] +} From 3b7cfd96d8a52766058c9094ad8fc1cb4d8cc27a Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:48:51 -0700 Subject: [PATCH 055/108] #1 bin/status.sh: every pull request in one section, dim timings, closes lines, unresolved threads A pull request is a pull request whether or not a worktree holds its branch, so the first section is every open pull request, each with its worktree line when there is one, and the second is the worktrees with no pull request, which no longer repeat the heading on every row. The run duration in the check cell is dim like every other timing. The issues a pull request closes read "closes #N title". A row with unresolved review threads ends with their count, in yellow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/status.sh | 117 +++++++++++++++++++++++++--------------------- tests/status.bats | 4 +- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/bin/status.sh b/bin/status.sh index 6a00ba6..7f31228 100644 --- a/bin/status.sh +++ b/bin/status.sh @@ -8,17 +8,17 @@ # # The picture is a headline counting the pull requests by state, then three sections, each omitted when empty: # -# worktrees — every pull request written in a worktree under worktrees/, then the worktrees with no pull -# request. A pull request's block is its row, the issues it closes, up to ten of the commits it -# carries past its fork from the default branch, and its worktree with how far that has moved -# from the fork and the size of its diff. -# pull requests — every open pull request with no worktree here, the same block without the worktree. +# pull requests — every open pull request: its row, the issues it closes, and, when a worktree under worktrees/ +# holds its branch, up to ten of the commits it carries past its fork from the default branch +# and the worktree with how far that has moved from the fork and the size of its diff. +# worktrees — every worktree under worktrees/ with no open pull request, with the same measures. # gone branches — every local branch whose upstream is gone, which kit tidy removes. # -# A row is the title, the state with its glyph, the age, and the checks. Every state has its own glyph, so a row is -# recognizable without reading the token. Color is by family, never by state: green when nothing is left to decide, -# yellow when a human owes a decision, blue when the next move is a machine's or a reviewer's, dim when someone -# already owns the work. The state is the first of these that holds: +# A row is the title, the state with its glyph, the age, the checks, and, when there are any, the unresolved review +# threads. Every state has its own glyph, so a row is recognizable without reading the token. Color is by family, +# never by state: green when nothing is left to decide, yellow when a human owes a decision, blue when the next move +# is a machine's or a reviewer's, dim when someone already owns the work, and every timing is dim. The state is the +# first of these that holds: # # draft 🔨 dim unfinished work that belongs to someone # queued 🚦 blue a check is still running @@ -95,8 +95,9 @@ status:state-emoji() { } # status:collect prints one record per open pull request, oldest first, with the fields separated by the unit -# separator: number, branch, title, state, age, check glyph, check text, and the issues it closes as "#n title" -# separated by the record separator. The one request to GitHub. +# separator: number, branch, title, state, age, check glyph, check text, how long the checks took, the count of +# unresolved review threads, and the issues it closes as "#n title" separated by the record separator. The one +# request to GitHub. status:collect() { local repo query filter repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || return 0 @@ -155,7 +156,7 @@ status:collect() { else "ready" end) as $state | [ (.number | tostring), .headRefName, .title, $state, (($now - (.createdAt | fromdateiso8601)) | age), - $cell[0], ($cell[1] + (if $took == "" or $cell[1] == "" then "" else " · \($took)" end)), + $cell[0], $cell[1], (if $cell[1] == "" then "" else $took end), ($unresolved | tostring), ([.closingIssuesReferences.nodes[] | "#\(.number) \(.title)"] | join("\u001e")) ] | join("\u001f")' gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ @@ -222,23 +223,29 @@ status:commits() { done } -# status:pr-block <record> <title-width> <state-width> <age-width> <path> <main-root> <default-branch> prints one -# pull request: its row, the issues it closes, and, when <path> names its worktree, the commits it carries past its -# fork and the worktree's line. +# status:pr-block <record> <title-width> <state-width> <age-width> <check-width> <path> <main-root> <default-branch> +# prints one pull request: its row, the issues it closes, and, when <path> names its worktree, the commits it +# carries past its fork and the worktree's line. status:pr-block() { - local title_w=$2 state_w=$3 age_w=$4 path=$5 main_root=$6 default_branch=$7 - local number branch title state age glyph check issues issue row fork counts - IFS=$'\x1f' read -r number branch title state age glyph check issues <<<"$1" + local title_w=$2 state_w=$3 age_w=$4 check_w=$5 path=$6 main_root=$7 default_branch=$8 + local number branch title state age glyph check took unresolved issues issue row fork counts + IFS=$'\x1f' read -r number branch title state age glyph check took unresolved issues <<<"$1" row="🔀 $(status:cell "#${number} ${title}" "${title_w}")" row+=" $(status:state-emoji "${state}") $(status:paint "$(status:state-sgr "${state}")" "$(status:cell "${state}" "${state_w}")")" row+=" $(status:paint 90 "$(status:cell "${age}" "${age_w}")")" - [[ -n "${glyph}" ]] && row+=" ${glyph} ${check}" + if (( unresolved > 0 )) + then + row+=" $(status:check-cell "${glyph}" "${check}" "${took}" "${check_w}") $(status:paint 33 "💬 ${unresolved} unresolved")" + elif [[ -n "${glyph}" ]] + then + row+=" $(status:check-cell "${glyph}" "${check}" "${took}" 0)" + fi printf '%s\n' "${row}" >&2 if [[ -n "${issues}" ]] then while IFS= read -r issue do - printf ' %s\n' "$(status:paint 90 "${issue}")" >&2 + printf ' %s\n' "$(status:paint 90 "closes ${issue}")" >&2 done < <(tr '\036' '\n' <<<"${issues}") fi [[ -n "${path}" ]] || return 0 @@ -250,20 +257,32 @@ status:pr-block() { "$(status:uncommitted "${path}")" >&2 } +# status:check-cell <glyph> <text> <took> <width> prints the checks as "<glyph> <text> · <took>", the duration dim, +# padded to <width> characters when something follows it; nothing when no check has reported. +status:check-cell() { + local glyph=$1 text=$2 took=$3 width=$4 plain='' + if [[ -n "${glyph}" ]] + then + plain="${glyph} ${text}${took:+ · ${took}}" + printf '%s %s%s' "${glyph}" "${text}" "${took:+ · $(status:paint 90 "${took}")}" + fi + (( ${#plain} < width )) && printf '%*s' $(( width - ${#plain} )) '' + return 0 +} + # status:worktree-alone <path> <branch> <title-width> <main-root> <default-branch> prints a worktree with no open -# pull request as a row: its path, why it stands alone, and its divergence. +# pull request as a row: its path, a mark when it is detached, and its divergence. status:worktree-alone() { - local path=$1 branch=$2 title_w=$3 main_root=$4 default_branch=$5 mark fork='' counts + local path=$1 branch=$2 title_w=$3 main_root=$4 default_branch=$5 mark='' fork='' counts if [[ -n "${branch}" ]] then - mark=$(status:paint 90 'no open pull request') fork=$(status:fork "${branch}" "${default_branch}") else - mark=$(status:paint 33 '❗ detached HEAD') + mark="$(status:paint 33 '❗ detached HEAD') " fi counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") # shellcheck disable=SC2086 # the four counts are tab-separated words - printf '🌿 %s %s %s%s\n' "$(status:cell "${path#"${main_root}"/}" "${title_w}")" "${mark}" \ + printf '🌿 %s %s%s%s\n' "$(status:cell "${path#"${main_root}"/}" "${title_w}")" "${mark}" \ "$(status:paint-counts ${counts})" "$(status:uncommitted "${path}")" >&2 } @@ -344,17 +363,18 @@ status:render() { # The rows, and the width of each column across them. local -a rows=() states=() local -A pr_on=() - local number title state age glyph check issues count=0 state_w=0 age_w=0 check_w=0 title_w=0 issue + local number title state age glyph check took unresolved issues count=0 state_w=0 age_w=0 check_w=0 title_w=0 issue while IFS= read -r record do [[ -n "${record}" ]] || continue - IFS=$'\x1f' read -r number branch title state age glyph check issues <<<"${record}" + IFS=$'\x1f' read -r number branch title state age glyph check took unresolved issues <<<"${record}" rows+=("${record}") states+=("${state}") pr_on[${branch}]=1 (( ${#state} > state_w )) && state_w=${#state} (( ${#age} > age_w )) && age_w=${#age} - [[ -n "${glyph}" ]] && (( ${#check} + 3 > check_w )) && check_w=$(( ${#check} + 3 )) + check="${glyph:+${glyph} ${check}${took:+ · ${took}}}" + (( ${#check} + 1 > check_w )) && check_w=$(( ${#check} + 1 )) title="#${number} ${title}" (( ${#title} > title_w )) && title_w=${#title} while IFS= read -r issue @@ -369,7 +389,7 @@ status:render() { (( ${#path} > title_w )) && title_w=${#path} done local avail - avail=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - check_w )) + avail=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - check_w - 3 - 16 )) (( avail < 20 )) && avail=20 (( title_w > avail )) && title_w=${avail} @@ -380,24 +400,27 @@ status:render() { out:emit '📋' '1' "${count} open pull request$( (( count == 1 )) || printf s) · $(status:badges "${states[@]}")" fi - # Worktrees: the pull requests written in one, then the worktrees without one, by issue number. - local -A drawn=() + # Every pull request, with its worktree when one here holds its branch. + if (( count > 0 )) + then + section "pull requests" + for record in "${rows[@]}" + do + IFS=$'\x1f' read -r _ branch _ <<<"${record}" + status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" "${check_w}" "${worktree_of[${branch}]:-}" \ + "${main_root}" "${default_branch}" + done + fi + + # The worktrees with no pull request, by issue number, then the detached ones. local -a lone=() for branch in "${!worktree_of[@]}" do [[ -n "${pr_on[${branch}]:-}" ]] || lone+=("${branch}") done - if (( ${#worktree_of[@]} + ${#detached_paths[@]} > 0 )) + if (( ${#lone[@]} + ${#detached_paths[@]} > 0 )) then - section "worktrees" - for record in "${rows[@]}" - do - IFS=$'\x1f' read -r number branch _ <<<"${record}" - [[ -n "${worktree_of[${branch}]:-}" ]] || continue - drawn[${number}]=1 - status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" "${worktree_of[${branch}]}" "${main_root}" \ - "${default_branch}" - done + section "worktrees with no open pull request" while IFS=$'\t' read -r _ branch do [[ -n "${branch}" ]] || continue @@ -409,18 +432,6 @@ status:render() { done fi - # Pull requests with no worktree here. - if (( count > ${#drawn[@]} )) - then - section "pull requests" - for record in "${rows[@]}" - do - number=${record%%$'\x1f'*} - [[ -n "${drawn[${number}]:-}" ]] && continue - status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" '' "${main_root}" "${default_branch}" - done - fi - local gone gone=$(git:gone-branches) if [[ -n "${gone}" ]] diff --git a/tests/status.bats b/tests/status.bats index ec298f2..c48139a 100644 --- a/tests/status.bats +++ b/tests/status.bats @@ -47,13 +47,13 @@ in_status() { [[ "${lines[10]}" == *" c1" ]] } -@test "a worktree with no pull request says so, with its distance from main and the size of its diff" { +@test "a worktree with no pull request shows its distance from main and the size of its diff" { git worktree add --quiet -b topic worktrees/topic main printf 'two\nthree\n' > worktrees/topic/b && git -C worktrees/topic add b && git -C worktrees/topic commit --quiet --message two printf 'four\n' > c && git add c && git commit --quiet --message four && git push --quiet origin main run in_status status:worktree-alone "${PWD}/worktrees/topic" topic 20 "${PWD}" main [ "$status" -eq 0 ] - [ "$output" = "🌿 worktrees/topic no open pull request ↑1 ↓1 · +2 -0" ] + [ "$output" = "🌿 worktrees/topic ↑1 ↓1 · +2 -0" ] } @test "a detached worktree is marked, and a dirty one counts its uncommitted paths" { From a51fa702c8bcca8fa56e24aebc5595fe539f460f Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:52:49 -0700 Subject: [PATCH 056/108] #1 README.md: the by-hand install goes through gh, which the kit needs anyway Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4664520..ae981be 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,13 @@ With Homebrew, from the empowerite tap: brew install empowerite/tap/kit ``` -Or by hand, with the version in `version`: unpack the release and put its `bin/` on `PATH`: +Or by hand, with `gh`, which the kit needs anyway. Without a tag this takes the latest release, never a +pre-release; `gh release download "v${version}" ...` takes a particular one. Unpack it and put its `bin/` on `PATH`: ```sh -curl --location --output "kit-${version}.tar.gz" \ - "https://github.com/empowerite/kit/releases/download/v${version}/kit-${version}.tar.gz" -tar --extract --gzip --file "kit-${version}.tar.gz" -export PATH="${PWD}/kit-${version}/bin:${PATH}" +gh release download --repo empowerite/kit --pattern 'kit-*.tar.gz' +tar --extract --gzip --file kit-*.tar.gz +export PATH="${PWD}/$(ls -d kit-*/ | head -n 1)bin:${PATH}" ``` The kit needs bash 4.4 or later, git, `gh`, and `jq`. macOS ships bash 3.2, and `brew install bash` gives a From 1377f068f379aa183afba18918898dfcc9dc428c Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:53:33 -0700 Subject: [PATCH 057/108] #1 README.md: the by-hand install unpacks into a named directory Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae981be..a08b898 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ Or by hand, with `gh`, which the kit needs anyway. Without a tag this takes the pre-release; `gh release download "v${version}" ...` takes a particular one. Unpack it and put its `bin/` on `PATH`: ```sh -gh release download --repo empowerite/kit --pattern 'kit-*.tar.gz' -tar --extract --gzip --file kit-*.tar.gz -export PATH="${PWD}/$(ls -d kit-*/ | head -n 1)bin:${PATH}" +gh release download --repo empowerite/kit --pattern 'kit-*.tar.gz' --output kit.tar.gz +mkdir kit && tar --extract --gzip --file kit.tar.gz --directory kit --strip-components 1 +export PATH="${PWD}/kit/bin:${PATH}" ``` The kit needs bash 4.4 or later, git, `gh`, and `jq`. macOS ships bash 3.2, and `brew install bash` gives a From 50b3fec39d0e64fb6372be647b22a12723e11139 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 10:59:30 -0700 Subject: [PATCH 058/108] #1 the desk is the root checkout, not main; the kit is the checks, not only the rules The human's desk is the checkout at the repository root, whatever branch it has: usually main, and otherwise any branch no worktree holds. The entry file says so. kit rebase refuses the root checkout whatever its branch, and the default branch anywhere. kit issue sweeps uncommitted work out of the root checkout whatever its branch, and fast-forwards it only when it is on the default branch. The README says what the kit is for: verification that does not depend on anyone's word, with the rules as the text the checks hold the work against. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .kit/1st.md | 15 ++++++++------- .kit/manifest | 2 +- README.md | 12 ++++++++---- bin/gh.sh | 17 ++++++++++------- bin/git.sh | 11 ++++++++--- bin/kit-rebase.sh | 2 +- tests/rebase.bats | 18 +++++++++++++++--- tree/.kit/1st.md | 15 ++++++++------- 8 files changed, 59 insertions(+), 33 deletions(-) diff --git a/.kit/1st.md b/.kit/1st.md index cf4b2a1..1792650 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -14,11 +14,12 @@ the repository's own rules in `AGENTS.md` bind beside it. Where the two differ, 5. A rule lives in the repository, never in a session's memory. A convention learned or a correction given is written into a rule file or `AGENTS.md` through the loop, where every agent and every person reads it. -## `main` is the human's desk +## The root checkout is the human's desk -The checkout at the repository root stays on the default branch. It is where the human types, reviews, and -leaves work in progress. Never check out, reset, rebase, or otherwise change it in place. Work happens in a -worktree under `worktrees/`, one per issue, and reaches `main` only through a merged pull request. +The checkout at the repository root is the human's. It is where they type, review, and leave work in progress. +It usually has `main` checked out, and may have any branch no worktree holds. An agent never checks out, resets, +rebases, or otherwise changes it in place. Work happens in a worktree under `worktrees/`, one per issue, and +reaches `main` only through a merged pull request. ## The loop @@ -37,12 +38,12 @@ worktree under `worktrees/`, one per issue, and reaches `main` only through a me Where the kit adds a rule to a git verb, the kit's spelling is the only one an agent uses. The rule runs inside it, and a harness that can refuse the raw spelling does. -- `kit rebase`, never `git rebase`. It refuses the default branch and otherwise rebases the worktree's branch onto - `origin/main` and pushes it. +- `kit rebase`, never `git rebase`. It refuses the root checkout and the default branch, and otherwise rebases the + worktree's branch onto `origin/main` and pushes it. - `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. - `kit tidy`, never `git worktree remove` or `git branch -D`. -The human at the desk is not bound by this; `main` is theirs to rebase. +The human at the desk is not bound by this; the root checkout is theirs. ## Plans are prose diff --git a/.kit/manifest b/.kit/manifest index 4ba3e5c..0723404 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -9,7 +9,7 @@ edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .claude/rules/ 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md 100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json f03e0fa3ae07c74235558bffc38d5cb24c388c7c440e469cf8a8e58604edfc4b .github/workflows/kit-ci.yml -22275196b95f53fe4b0bef7d309748161fcd2ede466a0ab3b67265fc2fa6814c .kit/1st.md +d83f24086d807bf6e9c6ed55d0fd7d77aa130a5b41a3db41d806e4ce84918aa1 .kit/1st.md 953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md diff --git a/README.md b/README.md index a08b898..ebbaafc 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ shortcut anyone ever took: the "done" that was not, the test that was skipped an that quietly rewrote a colleague's branch. An agent is a tireless new colleague who has read everything and holds none of your house rules, unless you put them where it looks. -kit is the house rules, put where agents look, as a versioned release any repository can take and keep taking. -It is small on purpose: the text an agent reads first, a rule file per kind of file, a guard that keeps `main` as -the human's desk, and the one command no harness provides, which turns an issue into a worktree with a draft pull -request. A tag on `main` publishes a release. A repository pins it, installs it, and keeps rules of its own +kit is the house rules, put where agents look, and the checks that hold agents to them, as a versioned release any +repository can take and keep taking. The rules are the smaller part. The point is verification that does not depend on +anyone's word: a delivered CI job that refuses a pull request without a linked open issue, `kit check` holding every +delivered file to its release, a pre-commit hook that runs the linters over what is staged, a deny list that refuses +the spellings the rules forbid, and `kit status` showing what every pull request's checks said. The text is what those +checks hold the work against: the entry file an agent reads first, a rule file per kind of file, the root checkout +kept as the human's desk, and the one command no harness provides, which turns an issue into a worktree with a draft +pull request. A tag on `main` publishes a release. A repository pins it, installs it, and keeps rules of its own beside it. The design is [#1](https://github.com/empowerite/kit/issues/1), and the entry file every agent reads is [.kit/1st.md](.kit/1st.md). diff --git a/bin/gh.sh b/bin/gh.sh index e097d06..3ced0f9 100644 --- a/bin/gh.sh +++ b/bin/gh.sh @@ -9,9 +9,10 @@ # under the main checkout, and an existing branch, worktree, or pull request is reused, so a re-run reconciles. # # With no argument and a terminal on stdin it asks for a title and creates the issue; without a terminal the -# usage error stands, so an agent never hangs on a prompt. Uncommitted work on the default branch is swept into -# the new worktree, uncommitted, and the desk is left clean and fast-forwarded; inside any other checkout, dirty -# files are that issue's own and stay. A sweep that cannot apply leaves the stash in place and stops. +# usage error stands, so an agent never hangs on a prompt. Uncommitted work in the root checkout, the human's +# desk, is swept into the new worktree, uncommitted, and the desk is left clean, and fast-forwarded when it is on +# the default branch; inside any other checkout, dirty files are that issue's own and stay. A sweep that cannot +# apply leaves the stash in place and stops. # # Each step below prints its one result on stdout and talks on stderr, so gh:issue reads them in order. @@ -134,7 +135,8 @@ gh:sweep-push() { } # gh:sweep-apply <root> <worktree> <stash> <default-branch> lands the stash in the worktree, uncommitted, drops it, -# and fast-forwards the now-clean desk. On a conflict the stash is kept and the caller stops. +# and fast-forwards the now-clean desk when it is on the default branch. On a conflict the stash is kept and the +# caller stops. gh:sweep-apply() { local root=$1 wt=$2 stash=$3 default_branch=$4 slot if ! git -C "${wt}" stash apply "${stash}" >/dev/null 2>&1 @@ -145,6 +147,7 @@ gh:sweep-apply() { slot=$(git -C "${root}" stash list --format='%gd %H' | awk -v h="${stash}" '$2 == h { print $1; exit }') [[ -n "${slot}" ]] && git -C "${root}" stash drop "${slot}" >/dev/null ok "Swept the work into ${wt}, uncommitted; commit it there with a real message." + [[ "$(git -C "${root}" rev-parse --abbrev-ref HEAD)" == "${default_branch}" ]] || return 0 git -C "${root}" merge --ff-only "origin/${default_branch}" >/dev/null 2>&1 \ || warn "${default_branch} could not fast-forward to origin; it is left as it was." } @@ -221,9 +224,9 @@ gh:issue() { section "Starting #${primary}: ${title}" branch=$(gh:issue-branch "${primary}" "${default_branch}") || return - if [[ "${desk_branch}" == "${default_branch}" ]] && ! git:clean "${desk}" + if [[ "${desk}" == "${main_root}" ]] && ! git:clean "${desk}" then - step "Sweeping the uncommitted work on ${default_branch} into the worktree" + step "Sweeping the uncommitted work on ${desk_branch} into the worktree" stash=$(gh:sweep-push "${desk}" "kit issue #${primary} → ${branch}") || return fi wt=$(gh:worktree "${main_root}" "${branch}") || return @@ -240,7 +243,7 @@ gh:issue() { ok "Draft pull request #${pr} on ${branch}, primary #${primary}${2:+, secondaries: ${*:2}}" ok "Worktree ready: ${wt}" - step "cd '${wt}' # do all work there; this checkout stays on ${default_branch}" + step "cd '${wt}' # do all work there; this checkout stays on ${desk_branch}" } # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/git.sh b/bin/git.sh index 4dd85e6..3597230 100644 --- a/bin/git.sh +++ b/bin/git.sh @@ -9,8 +9,8 @@ # runs in. --dry-run says what a sweep would do and changes nothing. # # git:rebase [<path>] rebases the branch checked out at <path>, the current checkout by default, onto -# origin/<default> and pushes it with --force-with-lease. It refuses the default branch, the human's desk, and -# on a conflict aborts and leaves the worktree as it was. +# origin/<default> and pushes it with --force-with-lease. It refuses the root checkout, the human's desk, whatever +# branch it has, and the default branch anywhere. On a conflict it aborts and leaves the worktree as it was. # git:default-branch [<path>] prints the default branch of the repository at <path>: origin/HEAD, else main. git:default-branch() { @@ -218,10 +218,15 @@ git:rebase() { error "${worktree} has no branch checked out, so there is nothing to rebase" return 1 fi + if [[ "$(git -C "${worktree}" rev-parse --show-toplevel)" == "$(git:main-root "${worktree}")" ]] + then + error "refusing to rebase ${branch}: it is checked out at the root, the human's desk; work is rebased in its worktree" + return 1 + fi default_branch=$(git:default-branch "${worktree}") if [[ "${branch}" == "${default_branch}" ]] then - error "refusing to rebase ${branch}: it is the default branch, the human's desk; it advances by: git pull --ff-only" + error "refusing to rebase ${branch}: it is the default branch; it advances by: git pull --ff-only" return 1 fi section "kit rebase ${branch} onto origin/${default_branch}" diff --git a/bin/kit-rebase.sh b/bin/kit-rebase.sh index 649451c..e9e7e6e 100755 --- a/bin/kit-rebase.sh +++ b/bin/kit-rebase.sh @@ -2,7 +2,7 @@ # kit-rebase - rebase a worktree's branch onto the default branch and push it (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# Never the default branch itself. +# Never the root checkout, the human's desk, and never the default branch. # # see: bin/git.sh diff --git a/tests/rebase.bats b/tests/rebase.bats index fbb922b..f02f194 100644 --- a/tests/rebase.bats +++ b/tests/rebase.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# rebase.bats - kit rebase refuses the default branch and brings a worktree's branch up to date +# rebase.bats - kit rebase refuses the desk and the default branch, and brings a worktree's branch up to date # ---------------------------------------------------------------------------------------------------------------------- setup() { @@ -33,10 +33,22 @@ commit-in() { git -C "$1" commit --quiet --message "$2" } -@test "rebase refuses the default branch" { +@test "rebase refuses the root checkout, whatever branch it has" { run "${kit_checkout}/bin/kit" rebase [ "$status" -eq 1 ] - [[ "$output" == *"refusing to rebase main"* ]] + [[ "$output" == *"refusing to rebase main: it is checked out at the root"* ]] + git switch --quiet --create desk-topic + run "${kit_checkout}/bin/kit" rebase + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase desk-topic: it is checked out at the root"* ]] +} + +@test "rebase refuses the default branch in a worktree" { + git switch --quiet --create desk-topic + git worktree add --quiet ../wt main + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase main: it is the default branch"* ]] } @test "rebase moves a worktree's branch onto origin/main and pushes it" { diff --git a/tree/.kit/1st.md b/tree/.kit/1st.md index cf4b2a1..1792650 100644 --- a/tree/.kit/1st.md +++ b/tree/.kit/1st.md @@ -14,11 +14,12 @@ the repository's own rules in `AGENTS.md` bind beside it. Where the two differ, 5. A rule lives in the repository, never in a session's memory. A convention learned or a correction given is written into a rule file or `AGENTS.md` through the loop, where every agent and every person reads it. -## `main` is the human's desk +## The root checkout is the human's desk -The checkout at the repository root stays on the default branch. It is where the human types, reviews, and -leaves work in progress. Never check out, reset, rebase, or otherwise change it in place. Work happens in a -worktree under `worktrees/`, one per issue, and reaches `main` only through a merged pull request. +The checkout at the repository root is the human's. It is where they type, review, and leave work in progress. +It usually has `main` checked out, and may have any branch no worktree holds. An agent never checks out, resets, +rebases, or otherwise changes it in place. Work happens in a worktree under `worktrees/`, one per issue, and +reaches `main` only through a merged pull request. ## The loop @@ -37,12 +38,12 @@ worktree under `worktrees/`, one per issue, and reaches `main` only through a me Where the kit adds a rule to a git verb, the kit's spelling is the only one an agent uses. The rule runs inside it, and a harness that can refuse the raw spelling does. -- `kit rebase`, never `git rebase`. It refuses the default branch and otherwise rebases the worktree's branch onto - `origin/main` and pushes it. +- `kit rebase`, never `git rebase`. It refuses the root checkout and the default branch, and otherwise rebases the + worktree's branch onto `origin/main` and pushes it. - `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. - `kit tidy`, never `git worktree remove` or `git branch -D`. -The human at the desk is not bound by this; `main` is theirs to rebase. +The human at the desk is not bound by this; the root checkout is theirs. ## Plans are prose From 3781843baff56c9f74d962fc999ee4f119a9ab0a Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 13:08:37 -0700 Subject: [PATCH 059/108] #1 bin/status.sh: the desk is a row of its own Under the headline, before the sections, kit status draws the root checkout: its branch, how far it has moved from where that branch left origin's default branch, the size of that diff, and its uncommitted paths. The worktree row and the desk row are one function, told apart by glyph and label. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/status.sh | 24 ++++++++++++++++-------- tests/status.bats | 14 +++++++++++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/bin/status.sh b/bin/status.sh index 7f31228..c7b4a58 100644 --- a/bin/status.sh +++ b/bin/status.sh @@ -6,7 +6,9 @@ # GitHub once, for every open pull request with the issues it closes, its review threads, and the checks on its # head; everything else is a local git read. # -# The picture is a headline counting the pull requests by state, then three sections, each omitted when empty: +# The picture is a headline counting the pull requests by state; the desk, the root checkout, with its branch, how +# far it has moved from where that branch left origin's default branch, the size of that diff, and its uncommitted +# paths; then three sections, each omitted when empty: # # pull requests — every open pull request: its row, the issues it closes, and, when a worktree under worktrees/ # holds its branch, up to ten of the commits it carries past its fork from the default branch @@ -270,10 +272,11 @@ status:check-cell() { return 0 } -# status:worktree-alone <path> <branch> <title-width> <main-root> <default-branch> prints a worktree with no open -# pull request as a row: its path, a mark when it is detached, and its divergence. -status:worktree-alone() { - local path=$1 branch=$2 title_w=$3 main_root=$4 default_branch=$5 mark='' fork='' counts +# status:checkout-row <glyph> <label> <path> <branch> <title-width> <default-branch> prints a checkout with no pull +# request row of its own as one row: the glyph, the label in the title column, a mark when it is detached, its +# divergence, and its uncommitted paths. The desk is drawn with 🏠 and its branch; a worktree with 🌿 and its path. +status:checkout-row() { + local glyph=$1 label=$2 path=$3 branch=$4 title_w=$5 default_branch=$6 mark='' fork='' counts if [[ -n "${branch}" ]] then fork=$(status:fork "${branch}" "${default_branch}") @@ -282,7 +285,7 @@ status:worktree-alone() { fi counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") # shellcheck disable=SC2086 # the four counts are tab-separated words - printf '🌿 %s %s%s%s\n' "$(status:cell "${path#"${main_root}"/}" "${title_w}")" "${mark}" \ + printf '%s %s %s%s%s\n' "${glyph}" "$(status:cell "${label}" "${title_w}")" "${mark}" \ "$(status:paint-counts ${counts})" "$(status:uncommitted "${path}")" >&2 } @@ -345,6 +348,8 @@ status:render() { main_root=$(git:main-root) default_branch=$(git:default-branch) records=$(status:collect) + local desk_branch + desk_branch=$(git -C "${main_root}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) # The linked worktrees: by branch, and the detached ones. local -A worktree_of=() @@ -388,6 +393,7 @@ status:render() { path=${path#"${main_root}"/} (( ${#path} > title_w )) && title_w=${#path} done + (( ${#desk_branch} > title_w )) && title_w=${#desk_branch} local avail avail=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - check_w - 3 - 16 )) (( avail < 20 )) && avail=20 @@ -399,6 +405,7 @@ status:render() { else out:emit '📋' '1' "${count} open pull request$( (( count == 1 )) || printf s) · $(status:badges "${states[@]}")" fi + status:checkout-row '🏠' "${desk_branch:-HEAD}" "${main_root}" "${desk_branch}" "${title_w}" "${default_branch}" # Every pull request, with its worktree when one here holds its branch. if (( count > 0 )) @@ -424,11 +431,12 @@ status:render() { while IFS=$'\t' read -r _ branch do [[ -n "${branch}" ]] || continue - status:worktree-alone "${worktree_of[${branch}]}" "${branch}" "${title_w}" "${main_root}" "${default_branch}" + path=${worktree_of[${branch}]} + status:checkout-row '🌿' "${path#"${main_root}"/}" "${path}" "${branch}" "${title_w}" "${default_branch}" done < <(status:by-number "${lone[@]}") for path in "${detached_paths[@]}" do - status:worktree-alone "${path}" '' "${title_w}" "${main_root}" "${default_branch}" + status:checkout-row '🌿' "${path#"${main_root}"/}" "${path}" '' "${title_w}" "${default_branch}" done fi diff --git a/tests/status.bats b/tests/status.bats index c48139a..a118a69 100644 --- a/tests/status.bats +++ b/tests/status.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# status.bats - the parts of the picture that need no GitHub: a cell, the commit list, and a worktree's row +# status.bats - the parts of the picture that need no GitHub: a cell, the commit list, and a checkout's row # ---------------------------------------------------------------------------------------------------------------------- setup() { @@ -51,7 +51,7 @@ in_status() { git worktree add --quiet -b topic worktrees/topic main printf 'two\nthree\n' > worktrees/topic/b && git -C worktrees/topic add b && git -C worktrees/topic commit --quiet --message two printf 'four\n' > c && git add c && git commit --quiet --message four && git push --quiet origin main - run in_status status:worktree-alone "${PWD}/worktrees/topic" topic 20 "${PWD}" main + run in_status status:checkout-row 🌿 worktrees/topic "${PWD}/worktrees/topic" topic 20 main [ "$status" -eq 0 ] [ "$output" = "🌿 worktrees/topic ↑1 ↓1 · +2 -0" ] } @@ -59,7 +59,15 @@ in_status() { @test "a detached worktree is marked, and a dirty one counts its uncommitted paths" { git worktree add --quiet --detach worktrees/loose main printf 'x\n' > worktrees/loose/x - run in_status status:worktree-alone "${PWD}/worktrees/loose" '' 16 "${PWD}" main + run in_status status:checkout-row 🌿 worktrees/loose "${PWD}/worktrees/loose" '' 16 main [ "$status" -eq 0 ] [ "$output" = "🌿 worktrees/loose ❗ detached HEAD ↑0 ↓0 · +0 -0 · 1 uncommitted" ] } + +@test "the desk row shows its branch, its unpushed commits, and its uncommitted paths" { + printf 'two\n' > b && git add b && git commit --quiet --message two + printf 'x\n' > c + run in_status status:checkout-row 🏠 main "${PWD}" main 4 main + [ "$status" -eq 0 ] + [ "$output" = "🏠 main ↑1 ↓0 · +1 -0 · 1 uncommitted" ] +} From 73903e9fa68263f057fc5528d4e8ab2a353a99e2 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 23:34:44 -0700 Subject: [PATCH 060/108] #1 bin/status.sh: the desk first, every worktree with its commits, then the pull requests; --json Three groups of blocks with a blank line between and no headings. The desk and each worktree are one shape: the label, which is the path from the current directory with the branch underlined or "branch on path", then ahead, behind, lines, and uncommitted paths, then the commits past the fork with the oldest kept. A pull request is its row, the issues it closes, and the worktree holding its branch, with no commit list. Everything that is detail is dim. --json prints the same facts as one document: desk, worktrees, pullRequests, goneBranches. Each measured checkout is a JSON object first, and the table is drawn from those, so the two outputs cannot disagree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/kit-status.sh | 2 +- bin/status.sh | 488 ++++++++++++++++++++++------------------------ tests/status.bats | 85 +++++--- 3 files changed, 297 insertions(+), 278 deletions(-) diff --git a/bin/kit-status.sh b/bin/kit-status.sh index a0c04a3..dc47e5c 100755 --- a/bin/kit-status.sh +++ b/bin/kit-status.sh @@ -11,7 +11,7 @@ use status if [[ "${1:-}" == --help ]] then - echo "usage: kit status [--no-fetch]" >&2 + echo "usage: kit status [--no-fetch] [--json]" >&2 exit 0 fi diff --git a/bin/status.sh b/bin/status.sh index c7b4a58..b9f9e72 100644 --- a/bin/status.sh +++ b/bin/status.sh @@ -2,25 +2,29 @@ # status.sh - the picture of the work in flight (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# status:render [--no-fetch] prints the open pull requests and the linked worktrees, and changes nothing. It asks +# status:render [--no-fetch] [--json] prints the checkouts and the open pull requests, and changes nothing. It asks # GitHub once, for every open pull request with the issues it closes, its review threads, and the checks on its # head; everything else is a local git read. # -# The picture is a headline counting the pull requests by state; the desk, the root checkout, with its branch, how -# far it has moved from where that branch left origin's default branch, the size of that diff, and its uncommitted -# paths; then three sections, each omitted when empty: +# The picture is three groups of blocks, a blank line between blocks and no headings: # -# pull requests — every open pull request: its row, the issues it closes, and, when a worktree under worktrees/ -# holds its branch, up to ten of the commits it carries past its fork from the default branch -# and the worktree with how far that has moved from the fork and the size of its diff. -# worktrees — every worktree under worktrees/ with no open pull request, with the same measures. -# gone branches — every local branch whose upstream is gone, which kit tidy removes. +# 🏠 the desk the root checkout, the human's. Its branch, then how far it has moved from where that branch +# left origin's default branch, the lines its working tree adds and removes against that point, +# and its uncommitted paths; under it the commits it carries past that point, dim hash and +# subject, up to ten with the rest counted. +# 🌿 each worktree under worktrees/, by issue number, drawn exactly as the desk is. The label is the path from +# the current directory with the branch underlined when the path ends in it, or "branch on +# path" when it does not. +# 🔀 each pull request its title, its state with the state's glyph, its age, its checks (how many passed and how +# long they took, or which failed), and its unresolved review threads when there are any; under +# it the issues it closes and the worktree that holds its branch, when one here does. # -# A row is the title, the state with its glyph, the age, the checks, and, when there are any, the unresolved review -# threads. Every state has its own glyph, so a row is recognizable without reading the token. Color is by family, -# never by state: green when nothing is left to decide, yellow when a human owes a decision, blue when the next move -# is a machine's or a reviewer's, dim when someone already owns the work, and every timing is dim. The state is the -# first of these that holds: +# Then every local branch whose upstream is gone, which kit tidy removes, each on a 🔖 line. +# +# Color is by family, never by state: green when nothing is left to decide, yellow when a human owes a decision, +# blue when the next move is a machine's or a reviewer's, dim when someone already owns the work. Everything that +# is detail rather than signal is dim: hashes, ages, durations, zero counts, directory prefixes, and the lines under +# a row. The state is the first of these that holds: # # draft 🔨 dim unfinished work that belongs to someone # queued 🚦 blue a check is still running @@ -30,10 +34,13 @@ # comments:open 💬 yellow unresolved review threads # blocked:approval 👀 blue a review is required or requested and has not arrived # ready 🛬 green green, thread-free, approved, and mergeable +# +# --json prints the same facts as one document instead: {desk, worktrees, pullRequests, goneBranches}, every +# measure a number and every list complete, for a consumer that draws its own picture. use git -# status_commit_cap is how many commits a block lists before eliding the rest. +# status_commit_cap is how many commits a block lists before counting the rest. status_commit_cap=10 # status:width prints the terminal's width in columns: COLUMNS, else the terminal on stderr, else 100. @@ -96,10 +103,23 @@ status:state-emoji() { esac } -# status:collect prints one record per open pull request, oldest first, with the fields separated by the unit -# separator: number, branch, title, state, age, check glyph, check text, how long the checks took, the count of -# unresolved review threads, and the issues it closes as "#n title" separated by the record separator. The one -# request to GitHub. +# status:age <seconds> prints a duration in its coarsest informative unit: 45s, 12m, 3h, 47d. +status:age() { + local secs=$1 + (( secs < 0 )) && secs=0 + if (( secs < 120 )) + then printf '%ds' "${secs}" + elif (( secs < 7200 )) + then printf '%dm' $(( secs / 60 )) + elif (( secs < 172800 )) + then printf '%dh' $(( secs / 3600 )) + else printf '%dd' $(( secs / 86400 )) + fi +} + +# status:collect prints one JSON object per open pull request, oldest first: number, branch, title, createdAt, age +# in seconds, state, checks {state, passed, total, failing, took in seconds or null}, unresolved, and closes +# [{number, title}]. The one request to GitHub. status:collect() { local repo query filter repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || return 0 @@ -122,8 +142,6 @@ status:collect() { }' # shellcheck disable=SC2016 # a jq filter, with jq variables filter=' - def age: if . < 120 then "\(.)s" elif . < 7200 then "\(. / 60 | floor)m" - elif . < 172800 then "\(. / 3600 | floor)h" else "\(. / 86400 | floor)d" end; def verdict: if .__typename == "CheckRun" then (if .status == "COMPLETED" then .conclusion else "PENDING" end) else .state end; def pending: IN("PENDING", "EXPECTED"); @@ -139,14 +157,10 @@ status:collect() { as $check | ([$checks[] | .startedAt // .createdAt // empty | fromdateiso8601] | min) as $started | ([$checks[] | .completedAt // empty | fromdateiso8601] | max) as $completed - | (if $started == null then "" - elif $check == "PENDING" then (($now - $started) | age) - elif $completed == null then "" - else (($completed - $started) | age) end) as $took - | (if $check == "SUCCESS" then ["✅", "\($n) / \($n)"] - elif $check == "FAILURE" then ["❌", "\($failing) · \($ok) / \($n)"] - elif $check == "PENDING" then ["🏃", "\($n - $running) / \($n)"] - else ["", ""] end) as $cell + | (if $started == null then null + elif $check == "PENDING" then $now - $started + elif $completed == null then null + else $completed - $started end) as $took | ([.reviewThreads.nodes[]? | select(.isResolved | not)] | length) as $unresolved | (if .isDraft then "draft" elif $check == "PENDING" then "queued" @@ -156,11 +170,11 @@ status:collect() { elif $unresolved > 0 then "comments:open" elif (.reviewDecision // "") | IN("REVIEW_REQUIRED", "CHANGES_REQUESTED") then "blocked:approval" else "ready" end) as $state - | [ (.number | tostring), .headRefName, .title, $state, - (($now - (.createdAt | fromdateiso8601)) | age), - $cell[0], $cell[1], (if $cell[1] == "" then "" else $took end), ($unresolved | tostring), - ([.closingIssuesReferences.nodes[] | "#\(.number) \(.title)"] | join("\u001e")) ] - | join("\u001f")' + | { number, branch: .headRefName, title, createdAt, age: ($now - (.createdAt | fromdateiso8601)), state: $state, + checks: { state: $check, passed: $ok, total: $n, failing: $failing, took: $took }, + unresolved: $unresolved, + closes: [.closingIssuesReferences.nodes[] | { number, title }] } + | tojson' gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ 2>/dev/null || true } @@ -170,11 +184,35 @@ status:fork() { git merge-base "origin/$2" "$1" 2>/dev/null || git merge-base "$2" "$1" 2>/dev/null || true } -# status:divergence <path> <branch> <fork> prints "<ahead>\t<behind>\t<added>\t<removed>": the commits on <branch> -# past <fork>, the commits on the default branch past it, and the lines the working tree at <path> adds and removes -# against it. A detached checkout has no branch and no fork, and measures its lines against its own head. -status:divergence() { - local path=$1 branch=$2 fork=$3 default_branch=$4 ahead=0 behind=0 added=0 removed=0 a r _ +# status:relative <path> prints <path> relative to the current directory: ".", a descent, or a climb. +status:relative() { + local target=$1 here=${PWD} up='' + if [[ "${target}" == "${here}" ]] + then + printf '.\n' + return 0 + fi + while [[ -n "${here}" && "${target}" != "${here}" && "${target}" != "${here}"/* ]] + do + here=${here%/*} + up+='../' + done + if [[ "${target}" == "${here}" ]] + then + printf '%s\n' "${up%/}" + else + printf '%s%s\n' "${up}" "${target#"${here}"/}" + fi +} + +# status:checkout <path> <branch> <default-branch> prints one JSON object measuring the checkout at <path>: path, +# relative, branch (empty when detached), detached, ahead of and behind the point where the branch left origin's +# default branch, the lines the working tree adds and removes against that point, the count of uncommitted paths, +# and the commits past that point as [{sha, subject}]. A detached checkout measures its lines against its own head +# and lists no commits. +status:checkout() { + local path=$1 branch=$2 default_branch=$3 fork='' ahead=0 behind=0 added=0 removed=0 uncommitted a r _ + [[ -n "${branch}" ]] && fork=$(status:fork "${branch}" "${default_branch}") if [[ -n "${fork}" ]] then ahead=$(git rev-list --count "${fork}..${branch}" 2>/dev/null || printf 0) @@ -185,159 +223,131 @@ status:divergence() { [[ -n "${a}" && "${a}" != '-' ]] && added=$(( added + a )) [[ -n "${r}" && "${r}" != '-' ]] && removed=$(( removed + r )) done < <(git -C "${path}" diff --numstat "${fork:-HEAD}" 2>/dev/null || true) - printf '%s\t%s\t%s\t%s\n' "${ahead}" "${behind}" "${added}" "${removed}" + uncommitted=$(git -C "${path}" status --porcelain | grep --count . || true) + { + [[ -n "${fork}" ]] && git -C "${path}" log --format='%h%x09%s' "${fork}..${branch}" 2>/dev/null + true + } | jq --raw-input --slurp --compact-output \ + --arg path "${path}" --arg relative "$(status:relative "${path}")" --arg branch "${branch}" \ + --argjson ahead "${ahead}" --argjson behind "${behind}" --argjson added "${added}" --argjson removed "${removed}" \ + --argjson uncommitted "${uncommitted}" ' + { path: $path, relative: $relative, branch: $branch, detached: ($branch == ""), + ahead: $ahead, behind: $behind, added: $added, removed: $removed, uncommitted: $uncommitted, + commits: [split("\n")[] | select(. != "") | split("\t") | { sha: .[0], subject: (.[1:] | join("\t")) }] }' } -# status:paint-counts <ahead> <behind> <added> <removed> prints "↑a ↓b · +x -y": a non-zero count in bold green or -# bold red, a zero dim. -status:paint-counts() { - local ahead=$1 behind=$2 added=$3 removed=$4 - printf '%s %s · %s %s' \ - "$(status:paint-count "${ahead}" '↑' '1;32')" "$(status:paint-count "${behind}" '↓' '1;31')" \ - "$(status:paint-count "${added}" '+' '1;32')" "$(status:paint-count "${removed}" '-' '1;31')" +# status:label <relative> <branch> prints the label of a checkout and its plain width, tab-separated: the path with +# the directory dim and the branch underlined when the path's last component is the branch or ends in "-<branch>", +# the branch alone at ".", or "branch on path" otherwise. A detached checkout is its path. +status:label() { + local relative=$1 branch=$2 dir + if [[ -z "${branch}" ]] + then + printf '%s\t%s\n' "${relative}" "${#relative}" + elif [[ "${relative}" == "." ]] + then + printf '%s\t%s\n' "${branch}" "${#branch}" + elif [[ "${relative##*/}" == "${branch}" || "${relative##*/}" == *"-${branch}" ]] + then + dir=${relative%"${branch}"} + printf '%s%s\t%s\n' "$(status:paint 90 "${dir}")" "$(status:paint 4 "${branch}")" "${#relative}" + else + printf '%s %s %s\t%s\n' "${branch}" "$(status:paint 90 on)" "${relative}" $(( ${#branch} + 4 + ${#relative} )) + fi } -# status:paint-count <count> <sign> <sgr> prints <sign><count>, in <sgr> when the count is above zero and dim otherwise. +# status:paint-count <count> <sign> <sgr> prints <sign><count>, in <sgr> above zero and dim at zero. status:paint-count() { local count=$1 sign=$2 sgr=$3 (( count > 0 )) || sgr=90 status:paint "${sgr}" "${sign}${count}" } -# status:commits <path> <range> prints the commits in <range>, dim hash then subject, indented under a row. Past -# status_commit_cap, the first commits, a dim "… and N more", and the oldest. -status:commits() { - local path=$1 range=$2 line total hidden=0 i - local -a commits=() - mapfile -t commits < <(git -C "${path}" log --format='%h %s' "${range}" 2>/dev/null || true) - total=${#commits[@]} - (( total > status_commit_cap + 1 )) && hidden=$(( total - status_commit_cap )) - for (( i = 0; i < total; i++ )) +# status:measures <checkout-json> prints "↑a ↓b · +x -y", and " · n uncommitted" in yellow when n is above zero. +status:measures() { + local ahead behind added removed uncommitted + IFS=$'\t' read -r ahead behind added removed uncommitted \ + < <(jq --raw-output '[.ahead, .behind, .added, .removed, .uncommitted] | @tsv' <<<"$1") + printf '%s %s · %s %s' "$(status:paint-count "${ahead}" '↑' '1;32')" "$(status:paint-count "${behind}" '↓' '1;31')" \ + "$(status:paint-count "${added}" '+' '1;32')" "$(status:paint-count "${removed}" '-' '1;31')" + (( uncommitted > 0 )) && printf ' · %s' "$(status:paint 33 "${uncommitted} uncommitted")" + return 0 +} + +# status:checkout-block <glyph> <checkout-json> <label-width> prints a checkout: its row, then its commits, dim +# hash and subject. Past status_commit_cap it prints the newest, a dim count of the hidden, and the oldest. +status:checkout-block() { + local glyph=$1 doc=$2 label_w=$3 relative branch label width mark='' sha subject + IFS=$'\x1f' read -r relative branch < <(jq --raw-output '[.relative, .branch] | join("\u001f")' <<<"${doc}") + IFS=$'\t' read -r label width < <(status:label "${relative}" "${branch}") + [[ -n "${branch}" ]] || mark=" $(status:paint 33 '❗ detached HEAD')" + printf '%s %s%*s%s %s\n' "${glyph}" "${label}" $(( label_w - width )) '' "${mark}" "$(status:measures "${doc}")" >&2 + while IFS=$'\t' read -r sha subject do - if (( hidden > 0 && i == status_commit_cap - 1 )) + [[ -n "${sha}" ]] || continue + if [[ "${sha}" == '…' ]] then - printf ' %s\n' "$(status:paint 90 "… and ${hidden} more")" >&2 - i=$(( total - 2 )) - continue + printf ' %s\n' "$(status:paint 90 "… ${subject}")" >&2 + else + printf ' %s %s\n' "$(status:paint 90 "${sha}")" "${subject}" >&2 fi - line=${commits[i]} - printf ' %s %s\n' "$(status:paint 90 "${line%% *}")" "${line#* }" >&2 - done + done < <(jq --raw-output --argjson cap "${status_commit_cap}" ' + (.commits | length) as $n + | (if $n > $cap + 1 + then (.commits[:$cap - 1][] | [.sha, .subject]), ["…", "and \($n - $cap) more"], (.commits[-1] | [.sha, .subject]) + else .commits[] | [.sha, .subject] end) + | @tsv' <<<"${doc}") } -# status:pr-block <record> <title-width> <state-width> <age-width> <check-width> <path> <main-root> <default-branch> -# prints one pull request: its row, the issues it closes, and, when <path> names its worktree, the commits it -# carries past its fork and the worktree's line. -status:pr-block() { - local title_w=$2 state_w=$3 age_w=$4 check_w=$5 path=$6 main_root=$7 default_branch=$8 - local number branch title state age glyph check took unresolved issues issue row fork counts - IFS=$'\x1f' read -r number branch title state age glyph check took unresolved issues <<<"$1" - row="🔀 $(status:cell "#${number} ${title}" "${title_w}")" - row+=" $(status:state-emoji "${state}") $(status:paint "$(status:state-sgr "${state}")" "$(status:cell "${state}" "${state_w}")")" - row+=" $(status:paint 90 "$(status:cell "${age}" "${age_w}")")" - if (( unresolved > 0 )) - then - row+=" $(status:check-cell "${glyph}" "${check}" "${took}" "${check_w}") $(status:paint 33 "💬 ${unresolved} unresolved")" - elif [[ -n "${glyph}" ]] - then - row+=" $(status:check-cell "${glyph}" "${check}" "${took}" 0)" - fi - printf '%s\n' "${row}" >&2 - if [[ -n "${issues}" ]] - then - while IFS= read -r issue - do - printf ' %s\n' "$(status:paint 90 "closes ${issue}")" >&2 - done < <(tr '\036' '\n' <<<"${issues}") - fi - [[ -n "${path}" ]] || return 0 - fork=$(status:fork "${branch}" "${default_branch}") - [[ -n "${fork}" ]] && status:commits "${path}" "${fork}..${branch}" - counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") - # shellcheck disable=SC2086 # the four counts are tab-separated words - printf ' 🌿 %s %s%s\n' "$(status:paint 90 "${path#"${main_root}"/}")" "$(status:paint-counts ${counts})" \ - "$(status:uncommitted "${path}")" >&2 -} - -# status:check-cell <glyph> <text> <took> <width> prints the checks as "<glyph> <text> · <took>", the duration dim, -# padded to <width> characters when something follows it; nothing when no check has reported. +# status:check-cell <checks-json> prints the checks as "✅ <passed> / <total> · <took>", the duration dim, or +# "❌ <failing> · <passed> / <total> · <took>", or "🏃 <passed> / <total> · <elapsed>"; nothing when none has +# reported. status:check-cell() { - local glyph=$1 text=$2 took=$3 width=$4 plain='' - if [[ -n "${glyph}" ]] - then - plain="${glyph} ${text}${took:+ · ${took}}" - printf '%s %s%s' "${glyph}" "${text}" "${took:+ · $(status:paint 90 "${took}")}" - fi - (( ${#plain} < width )) && printf '%*s' $(( width - ${#plain} )) '' + local state passed total failing took text + # A field here can be empty, and a tab read collapses an empty field, so the separator is one that cannot. + IFS=$'\x1f' read -r state passed total failing took \ + < <(jq --raw-output '[.state, .passed, .total, .failing, (.took // "")] | map(tostring) | join("\u001f")' <<<"$1") + case "${state}" in + (SUCCESS) text="✅ ${total} / ${total}" ;; + (FAILURE) text="❌ ${failing} · ${passed} / ${total}" ;; + (PENDING) text="🏃 ${passed} / ${total}" ;; + (*) return 0 ;; + esac + printf '%s' "${text}" + [[ -n "${took}" ]] && printf ' · %s' "$(status:paint 90 "$(status:age "${took}")")" return 0 } -# status:checkout-row <glyph> <label> <path> <branch> <title-width> <default-branch> prints a checkout with no pull -# request row of its own as one row: the glyph, the label in the title column, a mark when it is detached, its -# divergence, and its uncommitted paths. The desk is drawn with 🏠 and its branch; a worktree with 🌿 and its path. -status:checkout-row() { - local glyph=$1 label=$2 path=$3 branch=$4 title_w=$5 default_branch=$6 mark='' fork='' counts - if [[ -n "${branch}" ]] - then - fork=$(status:fork "${branch}" "${default_branch}") - else - mark="$(status:paint 33 '❗ detached HEAD') " - fi - counts=$(status:divergence "${path}" "${branch}" "${fork}" "${default_branch}") - # shellcheck disable=SC2086 # the four counts are tab-separated words - printf '%s %s %s%s%s\n' "${glyph}" "$(status:cell "${label}" "${title_w}")" "${mark}" \ - "$(status:paint-counts ${counts})" "$(status:uncommitted "${path}")" >&2 -} - -# status:uncommitted <path> prints " · n uncommitted" for a dirty checkout, nothing for a clean one. -status:uncommitted() { - git:clean "$1" && return 0 - printf ' · %s' "$(status:paint 33 "$(git -C "$1" status --porcelain | grep --count .) uncommitted")" -} - -# status:by-number <branch>… prints "<number>\t<branch>" for each branch whose worktree directory opens with an issue -# number, 0 for one that does not, sorted by that number. -status:by-number() { - local branch dir number - for branch in "$@" - do - dir=${worktree_of[${branch}]##*/} - number=${dir%%-*} - [[ "${number}" =~ ^[0-9]+$ ]] || number=0 - printf '%s\t%s\n' "${number}" "${branch}" - done | sort -n -k1,1 -} - -# status:badges <state>… prints "<glyph> <count>" per state, most populous first. -status:badges() { - local -A tally=() - local state count out='' - for state in "$@" - do - tally[${state}]=$(( ${tally[${state}]:-0} + 1 )) - done - while IFS=$'\t' read -r count state +# status:pr-block <pr-json> <title-width> <state-width> <age-width> <worktree-relative> prints a pull request: its +# row, the issues it closes, and the worktree here that holds its branch, when one does. +status:pr-block() { + local doc=$1 title_w=$2 state_w=$3 age_w=$4 worktree=$5 number title state age unresolved row issue checks + IFS=$'\t' read -r number title state age unresolved \ + < <(jq --raw-output '[.number, .title, .state, .age, .unresolved] | @tsv' <<<"${doc}") + row="🔀 $(status:cell "#${number} ${title}" "${title_w}")" + row+=" $(status:state-emoji "${state}") $(status:paint "$(status:state-sgr "${state}")" "$(status:cell "${state}" "${state_w}")")" + row+=" $(status:paint 90 "$(status:cell "$(status:age "${age}")" "${age_w}")")" + checks=$(status:check-cell "$(jq --compact-output .checks <<<"${doc}")") + [[ -n "${checks}" ]] && row+=" ${checks}" + (( unresolved > 0 )) && row+=" $(status:paint 33 "💬 ${unresolved} unresolved")" + printf '%s\n' "${row}" >&2 + while IFS= read -r issue do - out+="${out:+ }$(status:state-emoji "${state}") ${count}" - done < <( - for state in "${!tally[@]}" - do - printf '%s\t%s\n' "${tally[${state}]}" "${state}" - done | sort -t$'\t' -k1,1nr -k2,2 - ) - printf '%s' "${out}" + [[ -n "${issue}" ]] && printf ' %s\n' "$(status:paint 90 "closes ${issue}")" >&2 + done < <(jq --raw-output '.closes[] | "#\(.number) \(.title)"' <<<"${doc}") + [[ -n "${worktree}" ]] && printf ' %s\n' "$(status:paint 90 "🌿 ${worktree}")" >&2 + return 0 } status:render() { - local no_fetch=false arg main_root default_branch records path branch record + local no_fetch=false json=false arg main_root default_branch desk_branch path branch doc gone for arg in "$@" do case "${arg}" in - (--no-fetch) - no_fetch=true - ;; + (--no-fetch) no_fetch=true ;; + (--json) json=true ;; (*) - error "unknown flag '${arg}' (only --no-fetch is recognized)" + error "unknown flag '${arg}' (usage: kit status [--no-fetch] [--json])" return 2 esac done @@ -347,109 +357,87 @@ status:render() { fi main_root=$(git:main-root) default_branch=$(git:default-branch) - records=$(status:collect) - local desk_branch desk_branch=$(git -C "${main_root}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) + gone=$(git:gone-branches) - # The linked worktrees: by branch, and the detached ones. - local -A worktree_of=() - local -a detached_paths=() + # The desk, the worktrees by issue number, and the pull requests, each as JSON. + local desk prs + local -a worktrees=() + desk=$(status:checkout "${main_root}" "${desk_branch}" "${default_branch}") while IFS=$'\t' read -r path branch _ do [[ -n "${path}" ]] || continue - if [[ -n "${branch}" ]] - then - worktree_of[${branch}]=${path} - else - detached_paths+=("${path}") - fi - done < <(git:worktrees) - - # The rows, and the width of each column across them. - local -a rows=() states=() - local -A pr_on=() - local number title state age glyph check took unresolved issues count=0 state_w=0 age_w=0 check_w=0 title_w=0 issue - while IFS= read -r record - do - [[ -n "${record}" ]] || continue - IFS=$'\x1f' read -r number branch title state age glyph check took unresolved issues <<<"${record}" - rows+=("${record}") - states+=("${state}") - pr_on[${branch}]=1 - (( ${#state} > state_w )) && state_w=${#state} - (( ${#age} > age_w )) && age_w=${#age} - check="${glyph:+${glyph} ${check}${took:+ · ${took}}}" - (( ${#check} + 1 > check_w )) && check_w=$(( ${#check} + 1 )) - title="#${number} ${title}" - (( ${#title} > title_w )) && title_w=${#title} - while IFS= read -r issue - do - (( ${#issue} > title_w )) && title_w=${#issue} - done < <(tr '\036' '\n' <<<"${issues}") - done <<<"${records}" - count=${#rows[@]} - for path in "${worktree_of[@]}" "${detached_paths[@]}" - do - path=${path#"${main_root}"/} - (( ${#path} > title_w )) && title_w=${#path} - done - (( ${#desk_branch} > title_w )) && title_w=${#desk_branch} - local avail - avail=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - check_w - 3 - 16 )) - (( avail < 20 )) && avail=20 - (( title_w > avail )) && title_w=${avail} - - if (( count == 0 )) - then - out:emit '📋' '1' 'no open pull requests' - else - out:emit '📋' '1' "${count} open pull request$( (( count == 1 )) || printf s) · $(status:badges "${states[@]}")" - fi - status:checkout-row '🏠' "${desk_branch:-HEAD}" "${main_root}" "${desk_branch}" "${title_w}" "${default_branch}" + worktrees+=("$(status:checkout "${path}" "${branch}" "${default_branch}")") + done < <(git:worktrees | status:by-number) + prs=$(status:collect) - # Every pull request, with its worktree when one here holds its branch. - if (( count > 0 )) + if [[ "${json}" == true ]] then - section "pull requests" - for record in "${rows[@]}" - do - IFS=$'\x1f' read -r _ branch _ <<<"${record}" - status:pr-block "${record}" "${title_w}" "${state_w}" "${age_w}" "${check_w}" "${worktree_of[${branch}]:-}" \ - "${main_root}" "${default_branch}" - done + jq --null-input --argjson desk "${desk}" \ + --argjson worktrees "$(printf '%s\n' "${worktrees[@]}" | jq --slurp .)" \ + --argjson pullRequests "$(jq --slurp . <<<"${prs}")" \ + --argjson goneBranches "$(jq --raw-input . <<<"${gone}" | jq --slurp 'map(select(. != ""))')" \ + '{ desk: $desk, worktrees: $worktrees, pullRequests: $pullRequests, goneBranches: $goneBranches }' + return 0 fi - # The worktrees with no pull request, by issue number, then the detached ones. - local -a lone=() - for branch in "${!worktree_of[@]}" + # Column widths across the rows that share a column. + local label_w=0 width title_w=0 state_w=0 age_w=0 text state_text age_secs relative + local -A worktree_on=() + for doc in "${desk}" "${worktrees[@]}" do - [[ -n "${pr_on[${branch}]:-}" ]] || lone+=("${branch}") + IFS=$'\x1f' read -r relative branch < <(jq --raw-output '[.relative, .branch] | join("\u001f")' <<<"${doc}") + IFS=$'\t' read -r _ width < <(status:label "${relative}" "${branch}") + (( width > label_w )) && label_w=${width} + [[ -n "${branch}" && "${doc}" != "${desk}" ]] && worktree_on[${branch}]=${relative} done - if (( ${#lone[@]} + ${#detached_paths[@]} > 0 )) - then - section "worktrees with no open pull request" - while IFS=$'\t' read -r _ branch - do - [[ -n "${branch}" ]] || continue - path=${worktree_of[${branch}]} - status:checkout-row '🌿' "${path#"${main_root}"/}" "${path}" "${branch}" "${title_w}" "${default_branch}" - done < <(status:by-number "${lone[@]}") - for path in "${detached_paths[@]}" - do - status:checkout-row '🌿' "${path#"${main_root}"/}" "${path}" '' "${title_w}" "${default_branch}" - done - fi + while IFS=$'\t' read -r text state_text age_secs + do + [[ -n "${text}" ]] || continue + (( ${#text} > title_w )) && title_w=${#text} + (( ${#state_text} > state_w )) && state_w=${#state_text} + text=$(status:age "${age_secs}") + (( ${#text} > age_w )) && age_w=${#text} + done < <(jq --raw-output '["#\(.number) \(.title)", .state, .age] | @tsv' <<<"${prs}") + width=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - 24 )) + (( width < 20 )) && width=20 + (( title_w > width )) && title_w=${width} - local gone - gone=$(git:gone-branches) + status:checkout-block '🏠' "${desk}" "${label_w}" + for doc in "${worktrees[@]}" + do + printf '\n' >&2 + status:checkout-block '🌿' "${doc}" "${label_w}" + done + while IFS= read -r doc + do + [[ -n "${doc}" ]] || continue + printf '\n' >&2 + branch=$(jq --raw-output .branch <<<"${doc}") + status:pr-block "${doc}" "${title_w}" "${state_w}" "${age_w}" "${worktree_on[${branch}]:-}" + done <<<"${prs}" if [[ -n "${gone}" ]] then - section "branches whose upstream is gone (tidy removes these)" + printf '\n' >&2 while IFS= read -r branch do - info "${branch}" + printf '🔖 %s %s\n' "${branch}" "$(status:paint 90 'upstream gone · kit tidy removes it')" >&2 done <<<"${gone}" fi } +# status:by-number sorts git:worktrees lines by the issue number their directory opens with; a directory that +# opens with no number sorts first. +status:by-number() { + local path rest dir number + while IFS=$'\t' read -r path rest + do + [[ -n "${path}" ]] || continue + dir=${path##*/} + number=${dir%%-*} + [[ "${number}" =~ ^[0-9]+$ ]] || number=0 + printf '%s\t%s\t%s\n' "${number}" "${path}" "${rest}" + done | sort -n -k1,1 | cut -f2- +} + # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/status.bats b/tests/status.bats index a118a69..1f6569f 100644 --- a/tests/status.bats +++ b/tests/status.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats -# status.bats - the parts of the picture that need no GitHub: a cell, the commit list, and a checkout's row +# status.bats - the parts of the picture that need no GitHub: a cell, a checkout's measures and block, its label, +# and its path from the current directory # ---------------------------------------------------------------------------------------------------------------------- setup() { @@ -25,6 +26,15 @@ in_status() { bash -c 'source "${kit_root}/bin/1st.sh" && use status && "$@"' _ "$@" } +# commits-in <path> <n> makes <n> commits in the checkout at <path>. +commits-in() { + local i + for (( i = 1; i <= $2; i++ )) + do + printf '%s\n' "${i}" > "$1/f${i}" && git -C "$1" add . && git -C "$1" commit --quiet --message "c${i}" + done +} + @test "a cell is padded to its width, or clipped with an ellipsis" { run in_status status:cell abc 5 [ "$output" = "abc " ] @@ -32,42 +42,63 @@ in_status() { [ "$output" = "abcd…" ] } -@test "the commit list shows the newest, says how many are hidden, and ends with the oldest" { - git worktree add --quiet -b topic worktrees/topic main - for i in $(seq 1 15) - do - printf '%s\n' "${i}" > "worktrees/topic/f${i}" && git -C worktrees/topic add . && git -C worktrees/topic commit --quiet --message "c${i}" - done - run in_status status:commits "${PWD}/worktrees/topic" main..topic - [ "$status" -eq 0 ] - [ "${#lines[@]}" -eq 11 ] - [[ "${lines[0]}" == *" c15" ]] - [[ "${lines[8]}" == *" c7" ]] - [[ "${lines[9]}" == *"… and 5 more" ]] - [[ "${lines[10]}" == *" c1" ]] -} - -@test "a worktree with no pull request shows its distance from main and the size of its diff" { +@test "a checkout is measured from where its branch left origin's default branch" { git worktree add --quiet -b topic worktrees/topic main printf 'two\nthree\n' > worktrees/topic/b && git -C worktrees/topic add b && git -C worktrees/topic commit --quiet --message two printf 'four\n' > c && git add c && git commit --quiet --message four && git push --quiet origin main - run in_status status:checkout-row 🌿 worktrees/topic "${PWD}/worktrees/topic" topic 20 main + printf 'x\n' > worktrees/topic/x + run in_status status:checkout "${PWD}/worktrees/topic" topic main [ "$status" -eq 0 ] - [ "$output" = "🌿 worktrees/topic ↑1 ↓1 · +2 -0" ] + [ "$(jq -r '[.relative, .branch, .ahead, .behind, .added, .removed, .uncommitted, (.commits | length)] | @tsv' <<<"$output")" \ + = "$(printf 'worktrees/topic\ttopic\t1\t1\t2\t0\t1\t1')" ] } -@test "a detached worktree is marked, and a dirty one counts its uncommitted paths" { - git worktree add --quiet --detach worktrees/loose main - printf 'x\n' > worktrees/loose/x - run in_status status:checkout-row 🌿 worktrees/loose "${PWD}/worktrees/loose" '' 16 main +@test "a block shows the newest commits, counts the hidden ones, and ends with the oldest" { + git worktree add --quiet -b topic worktrees/topic main + commits-in worktrees/topic 15 + doc=$(in_status status:checkout "${PWD}/worktrees/topic" topic main) + run in_status status:checkout-block 🌿 "${doc}" 15 [ "$status" -eq 0 ] - [ "$output" = "🌿 worktrees/loose ❗ detached HEAD ↑0 ↓0 · +0 -0 · 1 uncommitted" ] + [ "${lines[0]}" = "🌿 worktrees/topic ↑15 ↓0 · +15 -0" ] + [ "${#lines[@]}" -eq 12 ] + [[ "${lines[1]}" == *" c15" ]] + [[ "${lines[9]}" == *" c7" ]] + [[ "${lines[10]}" == *"… and 5 more" ]] + [[ "${lines[11]}" == *" c1" ]] } -@test "the desk row shows its branch, its unpushed commits, and its uncommitted paths" { +@test "the desk block is its branch, its unpushed commits, and its uncommitted paths" { printf 'two\n' > b && git add b && git commit --quiet --message two printf 'x\n' > c - run in_status status:checkout-row 🏠 main "${PWD}" main 4 main + doc=$(in_status status:checkout "${PWD}" main main) + run in_status status:checkout-block 🏠 "${doc}" 4 [ "$status" -eq 0 ] - [ "$output" = "🏠 main ↑1 ↓0 · +1 -0 · 1 uncommitted" ] + [ "${lines[0]}" = "🏠 main ↑1 ↓0 · +1 -0 · 1 uncommitted" ] + [[ "${lines[1]}" == *" two" ]] +} + +@test "a detached checkout is marked and lists no commits" { + git worktree add --quiet --detach worktrees/loose main + doc=$(in_status status:checkout "${PWD}/worktrees/loose" '' main) + run in_status status:checkout-block 🌿 "${doc}" 15 + [ "$output" = "🌿 worktrees/loose ❗ detached HEAD ↑0 ↓0 · +0 -0" ] +} + +@test "a label is the path when the branch ends it, the branch at the current directory, or branch on path" { + run in_status status:label worktrees/topic topic + [ "$output" = "$(printf 'worktrees/topic\t15')" ] + run in_status status:label . main + [ "$output" = "$(printf 'main\t4')" ] + run in_status status:label ../other topic + [ "$output" = "$(printf 'topic on ../other\t17')" ] +} + +@test "a path is printed relative to the current directory" { + run in_status status:relative "${PWD}" + [ "$output" = "." ] + run in_status status:relative "${PWD}/worktrees/topic" + [ "$output" = "worktrees/topic" ] + cd worktrees + run in_status status:relative "${PWD%/worktrees}" + [ "$output" = ".." ] } From 216cb360f4faebb2753288fc14feccb13d7e2521 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sat, 24 Oct 2026 23:44:13 -0700 Subject: [PATCH 061/108] #1 bin/status.sh: the counts of every checkout in columns, so the dots align Ahead and added are right-aligned, behind and removed left-aligned, each in a column as wide as the widest value across the desk and the worktrees. Padding is by character, since the arrows are multibyte and printf's field width counts bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/status.sh | 55 ++++++++++++++++++++++++++++++++++++----------- tests/status.bats | 11 ++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/bin/status.sh b/bin/status.sh index b9f9e72..0e4077f 100644 --- a/bin/status.sh +++ b/bin/status.sh @@ -256,32 +256,59 @@ status:label() { fi } -# status:paint-count <count> <sign> <sgr> prints <sign><count>, in <sgr> above zero and dim at zero. +# status:paint-count <count> <sign> <sgr> <width> <side> prints <sign><count> padded to <width> characters on the +# left or the right, in <sgr> above zero and dim at zero. The padding is by character, since the arrows are +# multibyte and printf's field width counts bytes. status:paint-count() { - local count=$1 sign=$2 sgr=$3 + local count=$1 sign=$2 sgr=$3 width=$4 side=$5 text pad='' (( count > 0 )) || sgr=90 - status:paint "${sgr}" "${sign}${count}" + text="${sign}${count}" + (( ${#text} < width )) && printf -v pad '%*s' $(( width - ${#text} )) '' + if [[ "${side}" == right ]] + then + printf '%s%s' "${pad}" "$(status:paint "${sgr}" "${text}")" + else + printf '%s%s' "$(status:paint "${sgr}" "${text}")" "${pad}" + fi } -# status:measures <checkout-json> prints "↑a ↓b · +x -y", and " · n uncommitted" in yellow when n is above zero. +# status:measures <checkout-json> [<widths>] prints "↑a ↓b · +x -y", and " · n uncommitted" in yellow when n is +# above zero. <widths> is status:count-widths' line; with it, ahead and added are right-aligned and behind and +# removed left-aligned in columns of those widths, so the dots of every row fall under each other. status:measures() { - local ahead behind added removed uncommitted + local ahead behind added removed uncommitted ahead_w=0 behind_w=0 added_w=0 removed_w=0 IFS=$'\t' read -r ahead behind added removed uncommitted \ < <(jq --raw-output '[.ahead, .behind, .added, .removed, .uncommitted] | @tsv' <<<"$1") - printf '%s %s · %s %s' "$(status:paint-count "${ahead}" '↑' '1;32')" "$(status:paint-count "${behind}" '↓' '1;31')" \ - "$(status:paint-count "${added}" '+' '1;32')" "$(status:paint-count "${removed}" '-' '1;31')" + [[ -n "${2:-}" ]] && IFS=' ' read -r ahead_w behind_w added_w removed_w <<<"$2" + # The last column is padded only when something follows it, so no row ends in blanks. + (( uncommitted > 0 )) || removed_w=0 + printf '%s %s · %s %s' \ + "$(status:paint-count "${ahead}" '↑' '1;32' "${ahead_w}" right)" \ + "$(status:paint-count "${behind}" '↓' '1;31' "${behind_w}" left)" \ + "$(status:paint-count "${added}" '+' '1;32' "${added_w}" right)" \ + "$(status:paint-count "${removed}" '-' '1;31' "${removed_w}" left)" (( uncommitted > 0 )) && printf ' · %s' "$(status:paint 33 "${uncommitted} uncommitted")" return 0 } -# status:checkout-block <glyph> <checkout-json> <label-width> prints a checkout: its row, then its commits, dim -# hash and subject. Past status_commit_cap it prints the newest, a dim count of the hidden, and the oldest. +# status:count-widths <checkout-json>… prints the widest "↑a", "↓b", "+x", and "-y" across the checkouts, as four +# numbers on one line. +status:count-widths() { + printf '%s\n' "$@" | jq --raw-output --slurp ' + [ (map(.ahead) | max), (map(.behind) | max), (map(.added) | max), (map(.removed) | max) ] + | map(tostring | length + 1) | join(" ")' +} + +# status:checkout-block <glyph> <checkout-json> <label-width> [<widths>] prints a checkout: its row, with the +# counts in status:count-widths' columns, then its commits, dim hash and subject. Past status_commit_cap it prints +# the newest, a dim count of the hidden, and the oldest. status:checkout-block() { - local glyph=$1 doc=$2 label_w=$3 relative branch label width mark='' sha subject + local glyph=$1 doc=$2 label_w=$3 widths=${4:-} relative branch label width mark='' sha subject IFS=$'\x1f' read -r relative branch < <(jq --raw-output '[.relative, .branch] | join("\u001f")' <<<"${doc}") IFS=$'\t' read -r label width < <(status:label "${relative}" "${branch}") [[ -n "${branch}" ]] || mark=" $(status:paint 33 '❗ detached HEAD')" - printf '%s %s%*s%s %s\n' "${glyph}" "${label}" $(( label_w - width )) '' "${mark}" "$(status:measures "${doc}")" >&2 + printf '%s %s%*s%s %s\n' "${glyph}" "${label}" $(( label_w - width )) '' "${mark}" \ + "$(status:measures "${doc}" "${widths}")" >&2 while IFS=$'\t' read -r sha subject do [[ -n "${sha}" ]] || continue @@ -403,11 +430,13 @@ status:render() { (( width < 20 )) && width=20 (( title_w > width )) && title_w=${width} - status:checkout-block '🏠' "${desk}" "${label_w}" + local widths + widths=$(status:count-widths "${desk}" "${worktrees[@]}") + status:checkout-block '🏠' "${desk}" "${label_w}" "${widths}" for doc in "${worktrees[@]}" do printf '\n' >&2 - status:checkout-block '🌿' "${doc}" "${label_w}" + status:checkout-block '🌿' "${doc}" "${label_w}" "${widths}" done while IFS= read -r doc do diff --git a/tests/status.bats b/tests/status.bats index 1f6569f..92ec1f8 100644 --- a/tests/status.bats +++ b/tests/status.bats @@ -84,6 +84,17 @@ commits-in() { [ "$output" = "🌿 worktrees/loose ❗ detached HEAD ↑0 ↓0 · +0 -0" ] } +@test "the counts of every checkout sit in columns, ahead and added right-aligned, behind and removed left-aligned" { + one=$(jq -nc '{ahead: 1, behind: 22, added: 3, removed: 4444, uncommitted: 0}') + two=$(jq -nc '{ahead: 100, behind: 2, added: 30000, removed: 4, uncommitted: 2}') + widths=$(in_status status:count-widths "${one}" "${two}") + [ "${widths}" = "4 3 6 5" ] + run in_status status:measures "${one}" "${widths}" + [ "$output" = " ↑1 ↓22 · +3 -4444" ] + run in_status status:measures "${two}" "${widths}" + [ "$output" = "↑100 ↓2 · +30000 -4 · 2 uncommitted" ] +} + @test "a label is the path when the branch ends it, the branch at the current directory, or branch on path" { run in_status status:label worktrees/topic topic [ "$output" = "$(printf 'worktrees/topic\t15')" ] From 3209e512082967fdf29fb2640ac34284c7dbcdd7 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 00:56:05 -0700 Subject: [PATCH 062/108] #4 modules: the release as modules, and a file's rules found through kit context Everything the release delivers is a module under modules/<name>/: a module file of metadata (description, default, depends, answer), a tree/ mirrored at the consumer's root, templates/ rendered from the consumer's .kit/answers with @key@ keys, and docs/. kit install activates the default modules and the named ones with their dependencies; kit module list, add, and remove change the set, and remove refuses a module another active module depends on. The manifest records the set on a modules line. The resolver is the closure over depends, ordered by tsort. A rule lives once and is copied nowhere. kit context <path> names the rules governing a file by the paths: globs and content: first-line patterns in its frontmatter, and logs every ask under the git directory. kit context --claude answers in Claude Code's hook protocol, a rule's text once per session and its path after that. The claude module merges the deny list and that hook into .claude/settings.json and delivers no rule mirror; this repository's .claude/rules/kit-*.md copies are removed by the refresh. The ci module renders its workflow from a template with one answer, ci_runner. docs/modules.md and docs/context.md describe the two mechanisms, and each module has a docs/README.md. The release workflow packs bin/ and modules/. 35 bats cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .claude/rules/kit-bash.md | 50 ---- .claude/rules/kit-gitignore.md | 30 --- .claude/rules/kit-json.md | 27 --- .claude/rules/kit-just.md | 58 ----- .claude/rules/kit-markdown.md | 53 ---- .claude/rules/kit-yaml.md | 35 --- .claude/settings.json | 13 + .github/workflows/kit-ci.yml | 3 +- .github/workflows/release.yml | 6 +- .kit/1st.md | 37 ++- .kit/manifest | 18 +- .kit/rules/bash.md | 2 + .kit/rules/just.md | 2 + AGENTS.md | 8 +- README.md | 36 +-- bin/context.sh | 170 +++++++++++++ bin/kit | 12 +- bin/kit-check.sh | 7 +- bin/kit-context.sh | 24 ++ bin/kit-install.sh | 18 +- bin/kit-module.sh | 82 +++++++ bin/kit-target.sh | 82 ------- bin/kit-update.sh | 16 +- bin/release.sh | 227 +++++++++++++++--- docs/context.md | 50 ++++ docs/modules.md | 61 +++++ justfile | 8 +- modules/ci/docs/README.md | 7 + modules/ci/module | 8 + .../templates}/.github/workflows/kit-ci.yml | 5 +- modules/claude/docs/README.md | 10 + modules/claude/module | 6 + .../claude/tree}/.claude/settings.json | 13 + modules/kit/docs/README.md | 11 + modules/kit/module | 6 + {tree => modules/kit/tree}/.kit/1st.md | 37 ++- {tree => modules/kit/tree}/.kit/rules/bash.md | 2 + .../kit/tree}/.kit/rules/gitignore.md | 0 {tree => modules/kit/tree}/.kit/rules/json.md | 0 {tree => modules/kit/tree}/.kit/rules/just.md | 2 + .../kit/tree}/.kit/rules/markdown.md | 0 {tree => modules/kit/tree}/.kit/rules/yaml.md | 0 modules/lefthook/docs/README.md | 5 + modules/lefthook/module | 7 + .../lefthook/tree}/.kit/lefthook.yml | 0 targets/claude/.claude/rules/kit-bash.md | 1 - targets/claude/.claude/rules/kit-gitignore.md | 1 - targets/claude/.claude/rules/kit-json.md | 1 - targets/claude/.claude/rules/kit-just.md | 1 - targets/claude/.claude/rules/kit-markdown.md | 1 - targets/claude/.claude/rules/kit-yaml.md | 1 - tests/context.bats | 62 +++++ tests/kit.bats | 113 +++++---- tests/release.bats | 6 +- 54 files changed, 906 insertions(+), 535 deletions(-) delete mode 100644 .claude/rules/kit-bash.md delete mode 100644 .claude/rules/kit-gitignore.md delete mode 100644 .claude/rules/kit-json.md delete mode 100644 .claude/rules/kit-just.md delete mode 100644 .claude/rules/kit-markdown.md delete mode 100644 .claude/rules/kit-yaml.md create mode 100644 bin/context.sh create mode 100755 bin/kit-context.sh create mode 100755 bin/kit-module.sh delete mode 100755 bin/kit-target.sh create mode 100644 docs/context.md create mode 100644 docs/modules.md create mode 100644 modules/ci/docs/README.md create mode 100644 modules/ci/module rename {tree => modules/ci/templates}/.github/workflows/kit-ci.yml (97%) create mode 100644 modules/claude/docs/README.md create mode 100644 modules/claude/module rename {targets/claude => modules/claude/tree}/.claude/settings.json (54%) create mode 100644 modules/kit/docs/README.md create mode 100644 modules/kit/module rename {tree => modules/kit/tree}/.kit/1st.md (76%) rename {tree => modules/kit/tree}/.kit/rules/bash.md (98%) rename {tree => modules/kit/tree}/.kit/rules/gitignore.md (100%) rename {tree => modules/kit/tree}/.kit/rules/json.md (100%) rename {tree => modules/kit/tree}/.kit/rules/just.md (98%) rename {tree => modules/kit/tree}/.kit/rules/markdown.md (100%) rename {tree => modules/kit/tree}/.kit/rules/yaml.md (100%) create mode 100644 modules/lefthook/docs/README.md create mode 100644 modules/lefthook/module rename {tree => modules/lefthook/tree}/.kit/lefthook.yml (100%) delete mode 120000 targets/claude/.claude/rules/kit-bash.md delete mode 120000 targets/claude/.claude/rules/kit-gitignore.md delete mode 120000 targets/claude/.claude/rules/kit-json.md delete mode 120000 targets/claude/.claude/rules/kit-just.md delete mode 120000 targets/claude/.claude/rules/kit-markdown.md delete mode 120000 targets/claude/.claude/rules/kit-yaml.md create mode 100644 tests/context.bats diff --git a/.claude/rules/kit-bash.md b/.claude/rules/kit-bash.md deleted file mode 100644 index 33b1128..0000000 --- a/.claude/rules/kit-bash.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - "**/*.sh" - - "**/*.bash" ---- -# Bash - -Applies to `*.sh` and `*.bash`, recipe bodies under `#!/usr/bin/env bash`, and `run:` blocks in -workflows. - -## Shape - -1. An executed script opens with `#!/usr/bin/env bash`, a `# <file> - <purpose>` banner under 120 columns, a - rule of dashes to column 120, and a blank line, then `set -o errexit -o nounset -o pipefail`. -2. A sourced module opens with `# shellcheck shell=bash` and the same banner, and sets no shell options: they - would change the caller's. -3. The file closes with the same rule of dashes. - -## Style - -1. `then`, `do`, and `else` go on their own line, never after a semicolon. -2. Spell `set` options out: `set -o errexit`, never `set -e`. -3. Spell a tool's options out where every platform accepts the long form: `--raw-output`, `--field`, - `--quiet`. Where BSD and GNU differ, use the form both accept, which is the short one: `sed -n`, `head -n 1`, - `mkdir -p`, `rm -f`, `cut -d`, `cp -R`, `find -type`, `read -r`, and `git -C`. -4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. -5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. -6. A `case` label carries both parentheses: `(vanilla)`. The last group has no `;;`. -7. Quote every expansion outside `[[ ]]` and `(( ))`. -8. A function in a sourced module is named `<module>:<kebab-name>`, the module being the file's basename. The - loader's own verbs are the exception and stay bare: `use`, `info`, `step`, `ok`, `section`, `warn`, `error`. -9. A function private to one executed script is bare kebab-case. - -## Behavior - -1. An error message is one line, names the remedy first, and is the same wherever the same condition arises. -2. A helper that refuses returns non-zero and lets the caller end the script. It does not `exit` itself. -3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. -4. Diagnostics go to stderr. Stdout carries data a caller can pipe. -5. A command that mutates a remote, a branch, or a worktree is guarded by a check that names what it is about - to do, and a `--dry-run` where the mutation is a sweep. -6. The default branch is read from `origin/HEAD`, with `main` as the fallback, never hard-coded. -7. shellcheck passes with no directive, or the directive carries the reason on the same line. - -## Comments - -1. A comment states what the code does and the non-obvious why. It carries no history and defends no obvious - choice. -2. A pointer is `# see: <ref>`, on its own line after the comment it supports. diff --git a/.claude/rules/kit-gitignore.md b/.claude/rules/kit-gitignore.md deleted file mode 100644 index 7465b65..0000000 --- a/.claude/rules/kit-gitignore.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - ".gitignore" - - "**/.gitignore" ---- -# Ignore lists - -Applies to `.gitignore` at any depth. - -## Shape - -1. The file opens with `# .gitignore - untracked files and folders`, a rule of dashes to column 120, and a - blank line. -2. Entries are grouped. Each group opens with `# <what produces it>; see: <ref>` and is followed by a blank - line. -3. The file closes with a rule of dashes. - -## Entries - -1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the - user's global ignore list, not here. -2. An entry lives in the deepest directory where it applies. The root carries only patterns that span the - repository. -3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. -4. `worktrees/` is ignored at the root: it is where the loop checks out a branch per issue. - -## Adding one - -Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/.claude/rules/kit-json.md b/.claude/rules/kit-json.md deleted file mode 100644 index 692c8aa..0000000 --- a/.claude/rules/kit-json.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - "**/*.json" - - "**/*.jsonc" ---- -# JSON - -Applies to `*.json` and `*.jsonc`. - -## Shape - -1. Two-space indentation. Keys in a stable order: the order the consuming tool documents, else alphabetical. -2. One trailing newline. No trailing commas in `.json`. -3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where - it reads plain JSON, the reason for a setting lives in the document that describes the file. - -## Settings - -1. A disabled linter rule carries a comment naming the rule and the convention it would fight. -2. A tool's settings file that the kit writes into is merged by key. The kit owns only the keys and entries - it writes, and anything else in the file is the repository's. - -## Comments - -1. A comment states what the entry does and the non-obvious why. It carries no history. -2. A pointer is `// see: <ref>`. diff --git a/.claude/rules/kit-just.md b/.claude/rules/kit-just.md deleted file mode 100644 index d91dd1f..0000000 --- a/.claude/rules/kit-just.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - "justfile" - - "**/justfile" - - "**/*.just" ---- -# Just - -Applies to `justfile` and `*.just`. A recipe body under `#!/usr/bin/env bash` is bash, and the bash rule -applies to it as well. - -## Shape - -1. Every justfile opens with `# <file> - see: https://just.systems/man/en/`, a rule of dashes to column 120, - and a description of what the file is and who imports it. The root carries the `#!/usr/bin/env just - --justfile` shebang above that; a module does not. -2. The settings block follows: `set dotenv-load := true`, `set fallback := true` in a module (never in the - root, which has no parent), and `set positional-arguments := true`. `set export := true` comes after the - default-and-help pair, so that pair reads the same in every justfile. -3. Every justfile carries the default-and-help pair, with the file's own path in an exported variable: - - ```just - # This file's own path, assigned at parse time so the body below needs no interpolation. - export <name>_justfile := source_file() - - # Default recipe: list this justfile's recipes. - _: help - - # List recipes in this justfile. - [no-exit-message] - @help *options: - just --justfile "${<name>_justfile}" --list - ``` - -4. A module the root takes with `mod <name> "<path>"` carries `[no-cd]` on every recipe, so its body runs from - the invoking root rather than the module's directory. -5. The file closes with a rule of dashes. - -## Recipes - -1. Every recipe carries `[doc("<emoji> <short, action-first description>")]`. Related recipes share an emoji. -2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. It answers what the - recipe does, when to run it, and what it produces. -3. A non-trivial body is `#!/usr/bin/env bash` with `set -o errexit -o nounset -o pipefail` as its first line. -4. Non-trivial logic lives in a bash module the recipe loads, not in the body. The body stays a few lines. -5. No `{{...}}` interpolation anywhere, in a body or a comment. A value just computes reaches a body as an exported - variable, `export <name> := <expression>`, and `set export := true` exports every parameter, so a body reads - `${name}` and nothing else. -6. Two-space indentation inside a body. Never a tab. -7. Recipe and module names are spelled out, never abbreviated. A second `mod` at the same target is an alias. -8. Comments wrap at 118 columns. Bodies do not wrap. - -## Invocation - -1. The idiom is the space form, `just tools check`, never `just tools/check` and never `tools::check` outside a - dependency list. -2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/.claude/rules/kit-markdown.md b/.claude/rules/kit-markdown.md deleted file mode 100644 index d830ce3..0000000 --- a/.claude/rules/kit-markdown.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - "**/*.md" ---- -# Markdown - -Applies to `*.md`. - -## Prose - -1. Write plain declarative sentences with the thing under discussion as the subject. -2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". -3. Use a list where the content is a list, and for a procedure or a loop, so it scans. Use a table where the - content has columns. -4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. -5. Do not close a paragraph on a slogan, and do not restate what the code or config already says. -6. Use the serial comma in every list of three or more. -7. Expand an uncommon acronym the first time it appears. -8. A pointer is `see: <ref>`, where `<ref>` is a URL, a repository-relative path, or `issue #N`. Put it inline - when it keeps the sentence on one line, otherwise on its own line at the end of the paragraph. - -## Snippets - -1. A value the reader supplies is a shell variable the prose names, `"${signing_key}"`, never an angle-bracket - placeholder, so the block pastes after one export. -2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. - -## Layout - -1. Wrap prose at 118 columns, 120 being the ceiling. Inside a list item, pull the right margin in by 4 columns - per nesting level, to a limit of 12. -2. Balance the wrap: no line, in particular the last, reads as a stub. -3. Do not wrap fenced code, tables, HTML comments, or a line whose only content is a link. -4. Keep a list tight or loose as it was written. Reflow never flips one to the other. -5. Headings are ATX (`#`). One `#` heading per file, at the top. -6. Fenced code blocks declare a language and have a blank line on each side. -7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. - -## Links - -1. Use inline links with descriptive text, never a bare URL in angle brackets. -2. Use the same anchor text every time the same URL appears in a file. -3. When a label and the link text would say the same thing, make the label the link. -4. A relative link resolves from the file that carries it, and it resolves: a check reads them. - -## Files - -1. Name an index file `1st.md`. It lists its siblings and says what each is for. -2. A file says what its subject is in its first paragraph, before how to use it. -3. A README has no Status section and no paragraph on what the repository will do or does not yet do. It says - what the repository is. -4. No file names another repository as the source of a rule. diff --git a/.claude/rules/kit-yaml.md b/.claude/rules/kit-yaml.md deleted file mode 100644 index 3e5795c..0000000 --- a/.claude/rules/kit-yaml.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -# Delivered by kit, not edited by hand. A change is a new release, then kit update. -paths: - - "**/*.yml" - - "**/*.yaml" ---- -# YAML - -Applies to `*.yml` and `*.yaml`, which here means GitHub Actions workflows. - -## Shape - -1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a - reader would not guess from the keys. -2. Two-space indentation. No tabs. No trailing whitespace. -3. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. -4. One job per scope, one step per policy. A job's key is the check's name a ruleset requires, so renaming it - breaks the requirement. -5. `permissions` is declared at the top and names the least the jobs need. - -## Steps - -1. Every `uses:` pins a full commit SHA with the version as a trailing comment: `@<sha> # v1.2.3`. -2. A step has a `name` that is the policy it enforces, in kebab-case. -3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. -4. A refusal prints `::error::<message>`, writes `### ❌ <Message>` and a remedy to `${GITHUB_STEP_SUMMARY}`, - and returns non-zero. The remedy is the action to take, with a link that is that action. -5. A pass writes one `✅` line to the summary. -6. A later step that must run after an earlier refusal carries `if: ${{ !cancelled() }}`. -7. A tool is on `PATH` because `mise.toml` pins it and `jdx/mise-action` installs it, never by `curl` in a step. - -## Comments - -1. A comment states the non-obvious why. It carries no history. -2. A pointer is `# see: <ref>`. diff --git a/.claude/settings.json b/.claude/settings.json index fde98cf..5beeca9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,17 @@ { + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "kit context --claude" + } + ] + } + ] + }, "permissions": { "deny": [ "Bash(git checkout main *)", diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index 3cf9a98..72b648a 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -6,7 +6,8 @@ # open issue of this repository under Development. The last two run even when an earlier step refused, so one # run reports every finding. # -# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. Rendered from a template: the +# job runs on the ci_runner answer, ubuntu-latest unless .kit/answers says otherwise. # # see: .kit/1st.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b78d5a6..e208617 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ # release.yml - a tag v<version> on main becomes a GitHub release carrying the kit as one tarball # ---------------------------------------------------------------------------------------------------------------------- # -# The tarball is kit-<version>.tar.gz, holding one directory kit-<version>/ with bin/, tree/, targets/, and a -# stamped version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A +# The tarball is kit-<version>.tar.gz, holding one directory kit-<version>/ with bin/, modules/, and a stamped +# version, the layout a consumer's mise unpacks and puts on PATH. Only a version tag starts a run. A # release, v<major>.<minor>.<patch>, is tagged on main, any commit on main and not only its head. A pre-release, # the same with a -<suffix>, is tagged on a commit that is not on main and is published as a GitHub pre-release. # The tag is the version: the repository carries no version file, and the one in the packed kit is stamped here @@ -67,7 +67,7 @@ jobs: version=${GITHUB_REF_NAME#v} staging=$(mktemp --directory) mkdir "${staging}/kit-${version}" - cp --recursive --no-dereference bin tree targets "${staging}/kit-${version}/" + cp --recursive --no-dereference bin modules "${staging}/kit-${version}/" # the packed kit's version is the tag's, suffix included printf '%s\n' "${version}" > "${staging}/kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" diff --git a/.kit/1st.md b/.kit/1st.md index 1792650..0d4fc49 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -67,10 +67,10 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type -Each file kind has a rule file under `.kit/rules/`, and each rule file's `paths:` header names the paths it -binds. A harness that loads rules by path gets a copy in its own rules directory when the repository takes that -target: `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that -kind. +Each file kind has a rule file under `.kit/rules/`, and each rule file's header names what it governs: `paths:`, +globs on the repository path, and `content:`, patterns on the first line. Before you create or change a file, run +`kit context <path>`. It names the rules that govern the file and records that you asked. Read what it names. No +harness holds a copy of a rule; this call is how every harness, and every person, finds it. | Kind | Rule | | --- | --- | @@ -84,22 +84,21 @@ kind. ## What the kit carries The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the -release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, -carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the -repository root, and `targets/<name>/` for each target taken. A `.json` destination is merged by key; everything -else is copied as it is. - -- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, and `kit lint`, spelled that way and - no other. -- `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's +release version, the active modules, and a checksum per delivered file. The release, where it is installed, +carries the machinery, reached through the `kit` command on `PATH`. Everything the release delivers is a module, +and a repository activates the modules it takes: `kit install claude`, or `kit module add claude` later, and +`kit module remove` to take one back out. A `.json` destination is merged by key, the kit owning only the entries +it writes; everything else is copied as it is. + +- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, `kit context`, and `kit lint`, spelled + that way and no other. +- `kit`: this file and the rules, discovered through `kit context`. +- `lefthook`: `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. -- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets - it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-<kind>.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. - The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target - back out. -- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses - a pull request not linked to an open issue. +- `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and + refuses a pull request not linked to an open issue. +- `claude`: for Claude Code, seven deny rules merged into `.claude/settings.json`, and a hook that runs + `kit context` before every write so the rules reach the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/manifest b/.kit/manifest index 0723404..5697a2c 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,19 +1,13 @@ # .kit/manifest - written by kit, not by hand version unreleased -targets claude -b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .claude/rules/kit-bash.md -0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .claude/rules/kit-gitignore.md -1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .claude/rules/kit-json.md -edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .claude/rules/kit-just.md -15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .claude/rules/kit-markdown.md -03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .claude/rules/kit-yaml.md -100646bca2e00429393e788cf2b7f09121216feb551144ece67890dc40932e86 .claude/settings.json -f03e0fa3ae07c74235558bffc38d5cb24c388c7c440e469cf8a8e58604edfc4b .github/workflows/kit-ci.yml -d83f24086d807bf6e9c6ed55d0fd7d77aa130a5b41a3db41d806e4ce84918aa1 .kit/1st.md +modules kit claude ci lefthook +86563533cd11f87831320d57b24d68d83c8d3a47382b09afeb71f5ee46e93584 .claude/settings.json +cd66dc3b7fe79643f0a706e0ea571962906ffd117743c68e23aadbab7c91e143 .github/workflows/kit-ci.yml +f7db408e409f39c36c9906efbedfd800054a654b44615b021bd02fd93fa4d57e .kit/1st.md 953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml -b7f7f37d89165d6364fbb4e91c0c637eeb26fa72391a76f3419926efb7923522 .kit/rules/bash.md +6f1101c9b19bfbe2d42d6e602e92d0df4dc650a4a04ccbd06c5a03a5e6e5edc2 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md 1e45ffbcca4dbcbd295e992bfa5874ffa9537b6f89911bf22608aa5785c54346 .kit/rules/json.md -edcd5198f76362ef2890f2dfa5ca2df1a81bce27864dc7c19fd64a683e040e23 .kit/rules/just.md +f2610c647eb99c3951cfb2e079c8d2a19ea0ddd03bdb4cbf3dbfea89b414d3da .kit/rules/just.md 15e2bc949cf1c034140989fbf00ebc39dc6b719eb1f87f88ac4c77ff3d37b549 .kit/rules/markdown.md 03c1e6962c59627d3de12b57f7c6d682c5aea58fb9336613bb66ef6fd5145985 .kit/rules/yaml.md diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md index 33b1128..e04e9b0 100644 --- a/.kit/rules/bash.md +++ b/.kit/rules/bash.md @@ -3,6 +3,8 @@ paths: - "**/*.sh" - "**/*.bash" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" --- # Bash diff --git a/.kit/rules/just.md b/.kit/rules/just.md index d91dd1f..69f6620 100644 --- a/.kit/rules/just.md +++ b/.kit/rules/just.md @@ -4,6 +4,8 @@ paths: - "justfile" - "**/justfile" - "**/*.just" +content: + - "^#!.*/(env +)?just( |$)" --- # Just diff --git a/AGENTS.md b/AGENTS.md index 536a177..c1f467d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ Read .kit/1st.md first. It is the kit's entry file, and this repository uses its not on `main`. The tag is the version: no file carries it, the workflow stamps it into the packed kit, and a published release is immutable, so a tag is never reused. A release is permanent; a pre-release is retired when the branch it was tagged on is deleted. -- `.claude/rules/kit-*.md`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and `.kit/manifest` are - this repository's installed copies of its own kit, written by `just refresh` from `tree/` and `targets/`; see - [README.md](README.md) § Maintaining. Edit the source; the pre-commit hook refreshes the copies into the same - commit, and a desk without the hook runs `just refresh` and commits both. +- `.kit/`, `.github/workflows/kit-ci.yml`, and `.claude/settings.json` are this repository's installed copies of its + own kit, written by `just refresh` from `modules/`; see [README.md](README.md) § Maintaining. Edit the source; the + pre-commit hook refreshes the copies into the same commit, and a desk without the hook runs `just refresh` and + commits both. - `just ci` is the gate; the justfile says what it runs. diff --git a/README.md b/README.md index ebbaafc..63080ea 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,14 @@ kit install claude Read .kit/1st.md first. ``` -The rest of `AGENTS.md` is yours. `install` writes `.kit/`, the CI workflow, and the files of each target named; -`claude` is the one target this release knows, and a repository with no target still binds every agent that -reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the root justfile for the delivered `CI` job to -run, and take the pre-commit linters by pinning `lefthook`, putting `extends: [.kit/lefthook.yml]` in -`lefthook.yml`, and running `lefthook install` once per clone. `kit target add`, `kit target remove`, and -`kit target list` change and show the targets later. +The rest of `AGENTS.md` is yours. Everything the kit delivers is a module, and `install` activates the default +ones, `kit`, `lefthook`, and `ci`, plus those named: `claude` is the adapter for Claude Code, and a repository +with no adapter still binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the +root justfile for the delivered `CI` job to run, and take the pre-commit linters by pinning `lefthook`, putting +`extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit module list`, +`kit module add`, and `kit module remove` show and change the set later, and [docs/modules.md](docs/modules.md) +describes a module. An agent asks `kit context <path>` which rules govern a file before it creates or changes +it; [docs/context.md](docs/context.md) describes the call and the Claude Code hook that makes it for the agent. To move to a new release, bump the pin or reinstall, then run `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, which `update` fetches from @@ -117,23 +119,23 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): ## Maintaining -This repository is both the source of the kit and a consumer of it. The release is the checkout: `tree/` is what -every consumer gets, mirrored at its root; `targets/<name>/` is what a consumer taking that target gets, mirrored -the same way; and `bin/` is the machinery, `bin/kit` dispatching to one executable `bin/kit-<verb>.sh` per verb, -each sourcing the modules beside it. A symlink under `targets/` is delivered as the file it points at, so a rule -lives once, in `tree/.kit/rules/`. +This repository is both the source of the kit and a consumer of it. The release is the checkout: `modules/<name>/` +is what a consumer activating that module gets, its `tree/` mirrored at the consumer's root and its `templates/` +rendered from the consumer's answers first; `bin/` is the machinery, `bin/kit` dispatching to one executable +`bin/kit-<verb>.sh` per verb, each sourcing the bash modules beside it; and `docs/` with each module's `docs/` is +the documentation. [docs/modules.md](docs/modules.md) is the module format. The installed copies are written by `bin/kit install` and not edited by hand: | Installed copy | Written from | | --- | --- | -| `.kit/1st.md`, `.kit/rules/`, `.kit/lefthook.yml` | `tree/.kit/` | -| `.github/workflows/kit-ci.yml` | `tree/.github/workflows/kit-ci.yml` | -| `.claude/rules/kit-<kind>.md` | `targets/claude/.claude/rules/`, links into `tree/.kit/rules/` | -| `.claude/settings.json` | `targets/claude/.claude/settings.json`, merged by key; here only the kit's entries | -| `.kit/manifest` | the release version (`unreleased` here), the targets taken, and a digest per delivered path | +| `.kit/1st.md`, `.kit/rules/` | `modules/kit/tree/.kit/` | +| `.kit/lefthook.yml` | `modules/lefthook/tree/.kit/lefthook.yml` | +| `.github/workflows/kit-ci.yml` | `modules/ci/templates/.github/workflows/kit-ci.yml`, with `ci_runner` answered | +| `.claude/settings.json` | `modules/claude/tree/.claude/settings.json`, merged by key; here only the kit's entries | +| `.kit/manifest` | the release version (`unreleased` here), the active modules, and a digest per delivered path | -A commit that changes a file under `tree/` or `targets/` rewrites the installed copies in the same commit: the +A commit that changes a file under `modules/` rewrites the installed copies in the same commit: the pre-commit hook runs `just refresh`, which regenerates them from the index and stages the result. On a desk without the hook, stage the change, run `just refresh`, and commit. `bin/kit check`, which `just ci` runs, names any installed copy that no longer matches its source. Each delivered text file says in its header that it is diff --git a/bin/context.sh b/bin/context.sh new file mode 100644 index 0000000..975ff96 --- /dev/null +++ b/bin/context.sh @@ -0,0 +1,170 @@ +# shellcheck shell=bash +# context.sh - which rules govern a file (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# context:render <path>… [--json] names the rule files under .kit/rules/ that govern each path, and records each +# ask in the repository's context log. A rule's frontmatter declares what it governs: `paths:`, globs matched +# against the repository-relative path, where ** stands for any depth; and `content:`, patterns matched against +# the first line of a file that exists, so a bash script with no extension is bash by its shebang. The rules are +# discovered here and copied nowhere, which is what lets every harness, and every person, find them the same way. +# +# context:claude is the same answer in Claude Code's hook protocol: the hook input on stdin, the rules as +# additional context on stdout. The first time a rule is named in a session its text is given; after that, its +# path. docs/context.md is the description. + +shopt -s extglob + +# context_log is where every ask is recorded, under the repository's git directory, shared by its worktrees. +context:log-path() { + printf '%s/kit/context.log\n' "$(git rev-parse --path-format=absolute --git-common-dir)" +} + +# context:frontmatter <file> <key> prints the items of the list under <key> in the file's frontmatter, unquoted. +context:frontmatter() { + awk -v key="$2" ' + NR == 1 && $0 != "---" { exit } + NR > 1 && $0 == "---" { exit } + $0 == key ":" { in_key = 1; next } + /^[a-z]+:/ { in_key = 0 } + in_key && /^ - / { item = $0; sub(/^ - /, "", item); gsub(/^"|"$/, "", item); print item } + ' "$1" +} + +# context:relative <path> prints <path> relative to the repository root. A relative path is taken from the current +# directory. The nearest directory of the path that exists is resolved physically, as git reports the root, so a +# path through a symlinked directory, or to a file not yet created, still cuts at the root. +context:relative() { + local path=$1 root dir rest + root=$(git rev-parse --show-toplevel) + [[ "${path}" == /* ]] || path="${PWD}/${path}" + dir=${path%/*} + rest=${path##*/} + while [[ -n "${dir}" && ! -d "${dir}" ]] + do + rest="${dir##*/}/${rest}" + dir=${dir%/*} + done + dir=$(cd "${dir:-/}" && pwd -P) + path="${dir}/${rest}" + printf '%s\n' "${path#"${root}"/}" +} + +# context:glob-matches <path> <glob> is true when the repository-relative path matches the glob; ** is any depth. +context:glob-matches() { + local path=$1 glob=$2 + glob=${glob//\*\*\//*(*\/)} + glob=${glob//\*\*/*} + # shellcheck disable=SC2053 # the glob is the point + [[ "${path}" == ${glob} ]] +} + +# context:rules <path> prints the rule files governing <path>, by its path or by its first line. +context:rules() { + local path=$1 rule glob pattern first='' + [[ -f "${path}" ]] && first=$(head -n 1 "${path}" 2>/dev/null || true) + for rule in .kit/rules/*.md + do + [[ -f "${rule}" ]] || continue + # Read, never word-split: an unquoted glob would be expanded against the working tree before it is matched. + while IFS= read -r glob + do + [[ -n "${glob}" ]] || continue + if context:glob-matches "${path}" "${glob}" + then + printf '%s\n' "${rule}" + continue 2 + fi + done < <(context:frontmatter "${rule}" paths) + [[ -n "${first}" ]] || continue + while IFS= read -r pattern + do + [[ -n "${pattern}" ]] || continue + if [[ "${first}" =~ ${pattern} ]] + then + printf '%s\n' "${rule}" + continue 2 + fi + done < <(context:frontmatter "${rule}" content) + done +} + +# context:log <path> records that the context of <path> was asked for. +context:log() { + local log + log=$(context:log-path) + mkdir -p "$(dirname "${log}")" + printf '%s\t%s\n' "$(date +%s)" "$1" >> "${log}" +} + +# context:render <path>… [--json] prints the rules governing the paths: once each, as paths to read; with --json, +# one object per path with its rules. +context:render() { + local json=false arg path rule + local -a paths=() + local -A seen=() + for arg in "$@" + do + case "${arg}" in + (--json) json=true ;; + (-*) + error "unknown flag '${arg}' (usage: kit context <path>… [--json] | kit context --claude)" + return 2 + ;; + (*) paths+=("${arg}") + esac + done + if (( ${#paths[@]} == 0 )) + then + error "usage: kit context <path>… [--json] | kit context --claude" + return 2 + fi + cd "$(git rev-parse --show-toplevel)" || return 1 + for path in "${paths[@]}" + do + path=$(context:relative "${path}") + context:log "${path}" + if [[ "${json}" == true ]] + then + context:rules "${path}" | jq --raw-input --slurp --compact-output --arg path "${path}" \ + '{ path: $path, rules: [split("\n")[] | select(. != "")] }' + continue + fi + while IFS= read -r rule + do + [[ -n "${rule}" && -z "${seen[${rule}]:-}" ]] || continue + seen[${rule}]=1 + printf '%s\n' "${rule}" + done < <(context:rules "${path}") + done +} + +# context:claude reads Claude Code's PreToolUse hook input on stdin and answers with the rules governing the file +# the tool is about to write, as additional context: the rule's text the first time it is named in the session, +# its path after that. A tool input with no file path, or a file no rule governs, gets no answer. +context:claude() { + local input path session rule text='' given + input=$(cat) + path=$(jq --raw-output '.tool_input.file_path // empty' <<<"${input}") + session=$(jq --raw-output '.session_id // "none"' <<<"${input}") + [[ -n "${path}" ]] || return 0 + cd "$(git rev-parse --show-toplevel)" || return 0 + path=$(context:relative "${path}") + context:log "${path}" + given="${TMPDIR:-/tmp}/kit-context-${session//[^A-Za-z0-9_-]/_}" + while IFS= read -r rule + do + [[ -n "${rule}" ]] || continue + if grep --quiet --line-regexp --fixed-strings "${rule}" "${given}" 2>/dev/null + then + text+="${path} is governed by ${rule}, which you have read this session."$'\n' + else + printf '%s\n' "${rule}" >> "${given}" + text+="${path} is governed by ${rule}. Its text:"$'\n\n'"$(cat "${rule}")"$'\n\n' + fi + done < <(context:rules "${path}") + [[ -n "${text}" ]] || return 0 + jq --null-input --compact-output --arg text "${text}" \ + '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $text } }' +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit b/bin/kit index 87bc9cf..4a22f8c 100755 --- a/bin/kit +++ b/bin/kit @@ -25,15 +25,17 @@ case "${verb}" in cat <<'USAGE' usage: kit <verb> [<argument>…] - install [<target>…] mirror the release into the repository, take the named targets, write .kit/manifest + install [<module>…] activate the default modules and the named ones, place their files, write .kit/manifest update after the pin is bumped: replace unchanged files, three-way merge changed ones check fail when a delivered file differs from the pinned release - target <list|add|remove> [<target>…] - the targets this repository takes + module <list|add|remove> [<module>…] + the modules this repository takes + context <path>… name the rules that govern a file, and record the ask issue [<number>…] start or resume an issue: linked branch, worktree, start commit, draft pull request - rebase [<path>] rebase a worktree's branch onto the default branch and push it; never the default branch + rebase [<path>] rebase a worktree's branch onto the default branch and push it; never the desk tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches - status [--no-fetch] show every worktree with its branch, pull request, and state + status [--no-fetch] [--json] + the desk, the worktrees, and the open pull requests lint [--all-files] run the pre-commit hook's linters by hand, over the staged paths or every path version print this release's version diff --git a/bin/kit-check.sh b/bin/kit-check.sh index 732c1c3..8f50bdc 100755 --- a/bin/kit-check.sh +++ b/bin/kit-check.sh @@ -16,8 +16,9 @@ then fi release:require-manifest -read -r -a targets <<<"$(release:manifest-targets)" +read -r -a modules <<<"$(release:manifest-modules)" version=$(release:manifest-version) +release:render-all "${kit_root}" "${modules[@]}" findings=0 if [[ "${version}" != "$(release:version)" ]] then @@ -46,12 +47,12 @@ do error "${path} differs from the pinned release" findings=$(( findings + 1 )) fi -done < <(release:delivered "${kit_root}" "${targets[@]}") +done < <(release:delivered "${kit_root}" "${modules[@]}") if (( findings > 0 )) then error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." exit 1 fi -ok "kit ${version}, targets ${targets[*]:-none}: every delivered file matches the pinned release." +ok "kit ${version}, modules ${modules[*]:-none}: every delivered file matches the pinned release." # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-context.sh b/bin/kit-context.sh new file mode 100755 index 0000000..0faa33c --- /dev/null +++ b/bin/kit-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# kit-context - name the rules that govern a file, and record the ask (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bin/context.sh, docs/context.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use context + +case "${1:-}" in + (--help) + echo "usage: kit context <path>… [--json] | kit context --claude" >&2 + exit 0 + ;; + (--claude) + context:claude + ;; + (*) + context:render "$@" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-install.sh b/bin/kit-install.sh index 4ee448c..a290581 100755 --- a/bin/kit-install.sh +++ b/bin/kit-install.sh @@ -2,7 +2,8 @@ # kit-install - mirror the release into the repository (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# Takes the named targets and writes .kit/manifest. +# Activates the default modules and the named ones, with the modules they depend on, places what they deliver, +# and writes .kit/manifest. # # see: bin/release.sh @@ -13,24 +14,27 @@ use release if [[ "${1:-}" == --help ]] then - echo "usage: kit install [<target>…]" >&2 + echo "usage: kit install [<module>…]" >&2 exit 0 fi guard:require tool:jq -release:require-targets "$@" +release:require-modules "${kit_root}" "$@" if [[ -f .kit/manifest ]] then error "run: kit update (this repository already carries kit $(release:manifest-version))" exit 1 fi -section "kit install $(release:version)${*:+, targets: $*}" +read -r -a defaults <<<"$(release:default-modules "${kit_root}" | paste -s -d ' ' -)" +read -r -a modules <<<"$(release:resolve "${kit_root}" "${defaults[@]}" "$@" | paste -s -d ' ' -)" +release:render-all "${kit_root}" "${modules[@]}" +section "kit install $(release:version), modules: ${modules[*]}" while IFS=$'\t' read -r path src do step "${path}" release:place "${src}" "${path}" -done < <(release:delivered "${kit_root}" "$@") -release:write-manifest "$@" -release:finish "$@" +done < <(release:delivered "${kit_root}" "${modules[@]}") +release:write-manifest "${modules[@]}" +release:finish "${modules[@]}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-module.sh b/bin/kit-module.sh new file mode 100755 index 0000000..0d37281 --- /dev/null +++ b/bin/kit-module.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# kit-module - list, activate, or deactivate the modules this repository takes (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# see: bin/release.sh, docs/modules.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +usage() { echo "usage: kit module list | kit module add <module>… | kit module remove <module>…" >&2; } + +verb=${1:-list} +shift || true +case "${verb}" in + (--help) usage; exit 0 ;; + (list | add | remove) ;; + (*) usage; exit 2 +esac +release:require-manifest +read -r -a active <<<"$(release:manifest-modules)" + +case "${verb}" in + (list) + for module in $(release:modules) + do + if release:among "${module}" "${active[@]}" + then mark='✅' + else mark=' ' + fi + printf '%s %-10s %s\n' "${mark}" "${module}" "$(release:field "${kit_root}" "${module}" description)" + done + ;; + (add) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq + release:require-modules "${kit_root}" "$@" + read -r -a wanted <<<"$(release:resolve "${kit_root}" "${active[@]}" "$@" | paste -s -d ' ' -)" + release:render-all "${kit_root}" "${wanted[@]}" + section "kit module add $*" + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + step "${path}" + release:place "$(release:source-of "${kit_root}" "${path}" "${wanted[@]}")" "${path}" + done < <(release:paths-beyond "${kit_root}" "${active[*]}" "${wanted[*]}") + release:write-manifest "${wanted[@]}" + ok "modules: ${wanted[*]}" + ;; + (remove) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq + release:require-modules "${kit_root}" "$@" + remaining=("${active[@]}") + for module in "$@" + do + if ! release:among "${module}" "${remaining[@]}" + then + step "${module}: not active" + continue + fi + read -r -a dependents <<<"$(release:dependents "${kit_root}" "${module}" "${remaining[@]}" | paste -s -d ' ' -)" + if (( ${#dependents[@]} > 0 )) + then + error "${module} cannot be removed while ${dependents[*]} depend(s) on it" + exit 1 + fi + read -r -a remaining <<<"$(release:without "${module}" "${remaining[@]}" | paste -s -d ' ' -)" + done + release:render-all "${kit_root}" "${active[@]}" + section "kit module remove $*" + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + release:unplace "$(release:source-of "${kit_root}" "${path}" "${active[@]}")" "${path}" + done < <(release:paths-beyond "${kit_root}" "${remaining[*]}" "${active[*]}") + release:write-manifest "${remaining[@]}" + ok "modules: ${remaining[*]:-none}" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-target.sh b/bin/kit-target.sh deleted file mode 100755 index 1d770ef..0000000 --- a/bin/kit-target.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# kit-target - list, take, or drop the targets this repository takes (kit verb) -# ---------------------------------------------------------------------------------------------------------------------- -# -# see: bin/release.sh - -set -o errexit -o nounset -o pipefail -# shellcheck source=bin/1st.sh -source "${kit_root:?run through bin/kit}/bin/1st.sh" -use release - -usage() { echo "usage: kit target list | kit target add <target>… | kit target remove <target>…" >&2; } - -verb=${1:-list} -shift || true -case "${verb}" in - (--help) usage; exit 0 ;; - (list | add | remove) ;; - (*) usage; exit 2 -esac -release:require-manifest -read -r -a targets <<<"$(release:manifest-targets)" - -case "${verb}" in - (list) - printf '%s\n' "${targets[@]}" - ;; - (add) - (( $# > 0 )) || { usage; exit 2; } - guard:require tool:jq - release:require-targets "$@" - for t in "$@" - do - if release:among "${t}" "${targets[@]}" - then - step "${t}: already taken" - continue - fi - section "kit target add ${t}" - while IFS= read -r path - do - step "${path}" - release:place "${kit_root}/targets/${t}/${path}" "${path}" - done < <(release:target-only "${t}") - targets+=("${t}") - done - release:write-manifest "${targets[@]}" - ok "targets: ${targets[*]}" - ;; - (remove) - (( $# > 0 )) || { usage; exit 2; } - guard:require tool:jq - release:require-targets "$@" - for t in "$@" - do - if ! release:among "${t}" "${targets[@]}" - then - step "${t}: not taken" - continue - fi - section "kit target remove ${t}" - while IFS= read -r path - do - src="${kit_root}/targets/${t}/${path}" - if release:merged "${path}" - then - [[ -f "${path}" ]] || continue - step "${path}: the kit's entries taken out" - # shellcheck disable=SC2016 # a jq filter, with jq variables - release:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" - else - step "removing ${path}" - rm -f "${path}" - fi - done < <(release:target-only "${t}") - read -r -a targets <<<"$(release:without "${t}" "${targets[@]}" | paste -s -d ' ' -)" - done - release:write-manifest "${targets[@]}" - ok "targets: ${targets[*]:-none}" -esac - -# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-update.sh b/bin/kit-update.sh index 4a3e181..d29aed6 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -5,7 +5,7 @@ # A file whose digest still matches the manifest, which records the previous release's own content, is replaced. # An edited one is three-way merged with the previous release as the base, which release:fetch finds or fetches. # A path the previous release did not deliver is written only where the repository has nothing, or the same -# thing, there. +# thing, there. A template is rendered from the repository's answers in both releases before either is compared. # # see: bin/release.sh @@ -22,10 +22,11 @@ fi guard:require tool:jq release:require-manifest -read -r -a targets <<<"$(release:manifest-targets)" +read -r -a modules <<<"$(release:manifest-modules)" previous=$(release:manifest-version) current=$(release:version) -section "kit update ${previous} → ${current}${targets[*]:+, targets: ${targets[*]}}" +release:render-all "${kit_root}" "${modules[@]}" +section "kit update ${previous} → ${current}, modules: ${modules[*]}" # The previous release is the base of every merge, needed only when the release changed. KIT_BASE names its # root; otherwise release:fetch finds or fetches it. @@ -38,6 +39,7 @@ elif [[ "${previous}" != "${current}" && "${previous}" != "${release_unreleased} then base_root=$(release:fetch "${previous}" || true) fi +[[ -n "${base_root}" ]] && release:render-all "${base_root}" "${modules[@]}" needs_base=false [[ "${previous}" != "${current}" ]] && needs_base=true @@ -62,7 +64,7 @@ do continue fi base="" - [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${targets[@]}") + [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${modules[@]}") if [[ "${needs_base}" == true && -z "${base}" ]] then # Without the base, neither a merge nor the removal of the previous release's entries is possible. @@ -90,14 +92,14 @@ do conflicts=$(( conflicts + 1 )) fi fi -done < <(release:delivered "${kit_root}" "${targets[@]}") +done < <(release:delivered "${kit_root}" "${modules[@]}") -release:write-manifest "${targets[@]}" +release:write-manifest "${modules[@]}" if (( conflicts > 0 )) then error "${conflicts} file(s) need a hand; see above." exit 1 fi -release:finish "${targets[@]}" +release:finish "${modules[@]}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/release.sh b/bin/release.sh index 755453a..e8d03a3 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -1,15 +1,16 @@ # shellcheck shell=bash # shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose # shellcheck disable=SC2154 # kit_root is exported by bin/kit -# release.sh - the release's layout, the one merge rule, and the manifest (sourced module) +# release.sh - the release's modules, the one merge rule, the templates, and the manifest (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# A release mirrors its tree/ at the repository root, and targets/<name>/ for each target taken; a symlink in -# either is delivered as the file it points at. A destination ending in .json is merged by key, with the kit's -# keys set, the kit's array entries added, and on update or removal only the previous release's entries taken -# out. .kit/manifest records the version, the targets, and the release's digest for every delivered path. +# A release is bin/ and modules/. Each modules/<name>/ is a module: a `module` file of metadata, a tree/ mirrored +# at the repository root, and templates/ with @key@ rendered from the repository's answers, then placed the same way. A +# destination ending in .json is merged by key, with the kit's keys set, the kit's array entries added, and on +# update or removal only the previous release's entries taken out. .kit/manifest records the version, the active +# modules, and the release's digest for every delivered path. docs/modules.md is the format's description. # -# Every function reads the release from kit_root, exported by bin/kit. +# Every function reads a release from a root, kit_root by default, which bin/kit exports. # A release carries a version file the release workflow stamps from the tag; a checkout carries none and is # "unreleased", the one version kit update never asks mise for. @@ -20,19 +21,162 @@ release:version() { cat "${kit_root}/version" 2>/dev/null || printf '%s\n' "${release_unreleased}" } -# release:layers <root> <target>… prints the directories mirrored at the repository root, tree first. +# release:modules [<root>] prints the modules the release carries. +release:modules() { + (cd "${1:-${kit_root}}/modules" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) +} + +# release:field <root> <module> <keyword> prints the value of every <keyword> line in the module's metadata. +release:field() { + sed -n "s/^$3 //p; s/^$3\$//p" "$1/modules/$2/module" 2>/dev/null || true +} + +# release:default-modules <root> prints the modules active unless removed. +release:default-modules() { + local module + for module in $(release:modules "$1") + do + grep --quiet --line-regexp default "$1/modules/${module}/module" && printf '%s\n' "${module}" + done + return 0 +} + +# release:require-modules <root> <name>… refuses a module the release does not carry, naming the ones it does. +release:require-modules() { + local root=$1 module + shift + for module in "$@" + do + if [[ ! -f "${root}/modules/${module}/module" ]] + then + error "'${module}' is not a module this release knows; the modules are: $(release:modules "${root}" | paste -s -d ' ' -)" + return 2 + fi + done +} + +# release:resolve <root> <name>… prints the named modules and everything they depend on, each once, a module +# after the modules it depends on: the closure over depends, ordered by tsort over its edges. +release:resolve() { + local root=$1 module dep + shift + local -A wanted=() + local -a queue=("$@") + while (( ${#queue[@]} > 0 )) + do + module=${queue[0]} + queue=("${queue[@]:1}") + [[ -n "${wanted[${module}]:-}" ]] && continue + wanted[${module}]=1 + for dep in $(release:field "${root}" "${module}" depends) + do + queue+=("${dep}") + done + done + for module in "${!wanted[@]}" + do + printf '%s %s\n' "${module}" "${module}" + for dep in $(release:field "${root}" "${module}" depends) + do + printf '%s %s\n' "${dep}" "${module}" + done + done | sort | tsort +} + +# release:dependents <root> <module> <active>… prints the active modules that depend on <module>. +release:dependents() { + local root=$1 module=$2 other dep + shift 2 + for other in "$@" + do + for dep in $(release:field "${root}" "${other}" depends) + do + [[ "${dep}" == "${module}" ]] && printf '%s\n' "${other}" + done + done +} + +# release:answers <root> <module> prints "<key>\t<value>" for every answer the module's templates may use: the +# module's defaults, each overridden by a "<key> <value>" line in the repository's .kit/answers. +release:answers() { + { + release:field "$1" "$2" answer + [[ -f .kit/answers ]] && grep --invert-match '^#' .kit/answers + true + } | awk 'NF >= 2 { v = $0; sub(/^[^ ]+ +/, "", v); a[$1] = v } END { for (k in a) print k "\t" a[k] }' +} + +# release:tmp prints a directory for this process's rendered templates, made once and removed at exit. +release:tmp() { + if [[ -z "${release_tmp:-}" ]] + then + release_tmp=$(mktemp -d) + # shellcheck disable=SC2064 # expanded now on purpose: the path is fixed from here on + trap "rm -rf '${release_tmp}'" EXIT + fi + printf '%s\n' "${release_tmp}" +} + +# release:render <root> <module> renders the module's templates once per process and prints the directory holding +# them, or prints nothing for a module with no templates. A key is written @key@, autoconf's form, which neither +# bash's ${var} nor GitHub's ${{ }} can be mistaken for; one with no answer refuses, naming the key. +release:render() { + local root=$1 module=$2 templates="$1/modules/$2/templates" out key value text file rel + local -A answer=() + [[ -d "${templates}" ]] || return 0 + out="$(release:tmp)/$(printf '%s' "${root}" | release:digest)/${module}" + if [[ ! -d "${out}" ]] + then + while IFS=$'\t' read -r key value + do + [[ -n "${key}" ]] && answer[${key}]=${value} + done < <(release:answers "${root}" "${module}") + while IFS= read -r file + do + rel=${file#"${templates}"/} + text=$(<"${file}") + for key in $(grep --only-matching '@[a-z_]*@' "${file}" | sort --unique | tr -d '@') + do + if [[ -z "${answer[${key}]+x}" ]] + then + error "${rel} needs an answer for '${key}'; add a line '${key} <value>' to .kit/answers (module ${module})" + return 1 + fi + text=${text//"@${key}@"/"${answer[${key}]}"} + done + mkdir -p "${out}/$(dirname "${rel}")" + printf '%s\n' "${text}" > "${out}/${rel}" + done < <(find "${templates}" -type f) + fi + printf '%s\n' "${out}" +} + +# release:render-all <root> <module>… renders every named module's templates, refusing on the first unanswered key. +release:render-all() { + local root=$1 module + shift + for module in "$@" + do + release:render "${root}" "${module}" >/dev/null || return 1 + done +} + +# release:layers <root> <module>… prints the directories mirrored at the repository root, in module order: each +# module's tree/, then its rendered templates. release:layers() { - local root=$1 t + local root=$1 module rendered shift - printf '%s\n' "${root}/tree" - for t in "$@" + for module in "$@" do - printf '%s\n' "${root}/targets/${t}" + [[ -d "${root}/modules/${module}/tree" ]] && printf '%s\n' "${root}/modules/${module}/tree" + rendered=$(release:render "${root}" "${module}") + [[ -n "${rendered}" ]] && printf '%s\n' "${rendered}" done + return 0 } -# release:delivered <root> <target>… prints "<path>\t<source>" for every repository path the release at <root> -# delivers for those targets, sorted by path, the last layer winning. A symlink is a source only when it points +# release:delivered <root> <module>… prints "<path>\t<source>" for every repository path the release at <root> +# delivers for those modules, sorted by path, the last layer winning. A symlink is a source only when it points # at a file; a dangling one is refused here, where it is found. release:delivered() { local layer path src @@ -51,34 +195,21 @@ release:delivered() { done } -# release:source-of <root> <path> <target>… prints the release file behind one repository path, or nothing. +# release:source-of <root> <path> <module>… prints the release file behind one repository path, or nothing. release:source-of() { local root=$1 path=$2 shift 2 release:delivered "${root}" "$@" | awk -F '\t' -v p="${path}" '$1 == p { print $2 }' } -# release:target-only <target> prints the paths the target delivers that tree/ does not. -release:target-only() { - comm -13 <(release:delivered "${kit_root}" | cut -f 1) <(release:delivered "${kit_root}" "$1" | cut -f 1) -} - -# release:known-targets prints the targets this release carries. -release:known-targets() { - (cd "${kit_root}/targets" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) -} - -# release:require-targets <name>… refuses a target this release does not carry, naming the ones it does. -release:require-targets() { - local t - for t in "$@" - do - if [[ ! -d "${kit_root}/targets/${t}" ]] - then - error "'${t}' is not a target this release knows; the targets are: $(release:known-targets | paste -s -d ' ' -)" - return 2 - fi - done +# release:paths-beyond <root> <before> <after> prints the paths the module set <after> delivers that <before> +# does not; each set is one space-separated word. +release:paths-beyond() { + local root=$1 + local -a before after + read -r -a before <<<"$2" + read -r -a after <<<"$3" + comm -13 <(release:delivered "${root}" "${before[@]}" | cut -f 1) <(release:delivered "${root}" "${after[@]}" | cut -f 1) } release:merged() { [[ "$1" == *.json ]]; } @@ -204,13 +335,29 @@ release:place() { fi } -# release:write-manifest <target>… records the version, the targets, and the release's digest for every delivered path. +# release:unplace <source> <path> takes the release's content back out of <path>: the kit's entries for .json, +# the file itself otherwise. +release:unplace() { + local src=$1 path=$2 + if release:merged "${path}" + then + [[ -f "${path}" ]] || return 0 + step "${path}: the kit's entries taken out" + release:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" + else + step "removing ${path}" + rm -f "${path}" + fi +} + +# release:write-manifest <module>… records the version, the active modules, and the release's digest for every +# delivered path. release:write-manifest() { local path src { printf '# .kit/manifest - written by kit, not by hand\n' printf 'version %s\n' "$(release:version)" - printf 'targets%s\n' "${*:+ $*}" + printf 'modules%s\n' "${*:+ $*}" while IFS=$'\t' read -r path src do printf '%s %s\n' "$(release:digest < "${src}")" "${path}" @@ -219,7 +366,7 @@ release:write-manifest() { } release:manifest-version() { sed -n 's/^version //p' .kit/manifest; } -release:manifest-targets() { sed -n 's/^targets *//p' .kit/manifest; } +release:manifest-modules() { sed -n 's/^modules *//p' .kit/manifest; } # release:require-manifest refuses to go on in a repository that never took the kit. release:require-manifest() { @@ -230,9 +377,9 @@ release:require-manifest() { fi } -# release:finish <target>… reports a completed install or update. +# release:finish <module>… reports a completed install or update. release:finish() { - ok "kit $(release:version) is installed, targets: ${*:-none}; .kit/manifest is written." + ok "kit $(release:version) is installed, modules: ${*:-none}; .kit/manifest is written." step "Commit the delivered files. The loop is in .kit/1st.md." } diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..f28b4df --- /dev/null +++ b/docs/context.md @@ -0,0 +1,50 @@ +# Context + +A rule is written once, in `.kit/rules/<kind>.md`, and copied nowhere. Before an agent creates or changes a file, +it asks which rules govern that file: + +```sh +kit context <path>… +``` + +The answer is the rule files to read, one per line. `--json` gives one object per path with its rules. Every ask +is appended to `.git/kit/context.log` as `<epoch>\t<path>`, which is the evidence a check can read to ask whether +every path a commit touched had its context asked for. + +## How a rule declares what it governs + +A rule file opens with a frontmatter of two optional lists: + +```yaml +--- +paths: + - "**/*.sh" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" +--- +``` + +- `paths:` are globs matched against the repository-relative path. `**` stands for any depth, so `**/*.sh` is + every `.sh` file and `justfile` is the one at the root. +- `content:` are extended regular expressions matched against the first line of a file that already exists, so a + bash script with no extension is bash by its shebang, and a file about to be created is governed by its path + alone. + +A rule governs a file when any glob or any pattern matches. + +## Harness adapters + +A harness that can run a command before a write runs `kit context` itself, so the rules reach the agent without +the agent remembering to ask. The `claude` module wires Claude Code's `PreToolUse` hook on `Write`, `Edit`, and +`MultiEdit` to: + +```sh +kit context --claude +``` + +It reads the hook's input on stdin, takes the file path from the tool's input, and answers in the hook's output +with the rules as additional context: a rule's text the first time it is named in the session, its path after +that. The session is told apart by the hook input's `session_id`, and what was given is kept under `TMPDIR`. + +A harness with no hook, and a person, use the plain call. `.kit/1st.md` names it as the step before any file is +created or changed. diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..5da9de1 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,61 @@ +# Modules + +Everything the kit delivers is a module: a directory under `modules/` in the release. A repository activates the +modules it wants, and `kit install`, `kit update`, and `kit check` work over the active set. + +## Layout + +```text +modules/<name>/ + module metadata, one keyword per line + tree/ files mirrored at the repository root + templates/ files rendered from the repository's answers, then mirrored the same way + docs/ the module's documentation, for a reader of this repository; not delivered +``` + +A module may have any of `tree/`, `templates/`, and `docs/`, and must have `module`. + +## Metadata + +The `module` file is lines of one keyword each. A line starting with `#` is a comment. + +| Keyword | Meaning | +| --- | --- | +| `description <text>` | One line, shown by `kit module list`. | +| `default` | Active unless removed. `kit install` with no names activates every default module. | +| `depends <name>…` | Activated with this module. `kit module remove` refuses a module another active module depends on. | +| `answer <key> <value>` | The default value of a template key. A repository overrides it in `.kit/answers`. | + +## Delivery + +A file under `tree/` is placed at the same path under the repository root. A destination ending in `.json` is +merged by key: the kit's keys are set, its array entries added, and on update or removal only the previous +release's entries are taken out, so a repository's own keys and entries survive. Every other destination is a copy. +Where two active modules deliver the same path, the later module in dependency order wins. + +A file under `templates/` is rendered first. Every `@key@` in it, autoconf's form, is replaced by the key's +answer, then the result is placed, digested, and merged exactly as a `tree/` file is. The form is one neither +bash's `${var}` nor GitHub's `${{ }}` can be mistaken for, so a template may carry both untouched. The repository's +answers live in `.kit/answers`, a file of `key value` lines the repository owns and the kit never writes; a key +with no line there takes the module's `answer` default, and a key with neither refuses the install, naming the key +and the module. + +## Activation + +- `kit install [<module>…]` activates the default modules and the named ones, with their dependencies, and records + the set on the `modules` line of `.kit/manifest`. +- `kit module list` shows every module the release carries and which are active. +- `kit module add <module>…` activates more, placing only the paths the new set delivers beyond the old. +- `kit module remove <module>…` deactivates, removing the paths only the removed modules delivered and taking the + kit's entries back out of a merged `.json`. + +## This release's modules + +| Module | Default | Depends on | Delivers | +| --- | --- | --- | --- | +| `kit` | yes | | `.kit/1st.md`, `.kit/rules/<kind>.md` | +| `lefthook` | yes | `kit` | `.kit/lefthook.yml` | +| `ci` | yes | `kit` | `.github/workflows/kit-ci.yml`, from a template; answer `ci_runner` | +| `claude` | no | `kit` | `.claude/settings.json`, merged: the deny list and the `kit context --claude` hook | + +Each module's `docs/README.md` says what it places and why. diff --git a/justfile b/justfile index eee51c1..b271160 100644 --- a/justfile +++ b/justfile @@ -38,7 +38,7 @@ ci: echo "✅ ci passed" # Reinstall this repository's own kit from the index and stage the result. Here the checkout is the release, so an -# edit under tree/ or targets/ is an edit to the release, not a consumer's drift. The copies are made from the +# edit under modules/ is an edit to the release, not a consumer's drift. The copies are made from the # index, what the commit will carry, never from the working tree, so a half-staged edit cannot put a copy into a # commit that lacks its source. Every path the old manifest lists is removed first and written again from the # new release: a source that was deleted leaves no copy behind, and an entry that was retired does not survive @@ -53,14 +53,14 @@ refresh: set -o errexit -o nounset -o pipefail paths() { awk '$1 ~ /^[0-9a-f]{64}$/ { print $2 }' .kit/manifest; } readarray -t before < <(paths) - read -r -a targets <<<"$(sed -n 's/^targets *//p' .kit/manifest)" + read -r -a modules <<<"$(sed -n 's/^modules *//p' .kit/manifest)" index=$(mktemp -d) trap 'rm -rf "${index}"' EXIT git checkout-index --all --prefix="${index}/" rm -f "${before[@]}" .kit/manifest - "${index}/bin/kit" install "${targets[@]}" >/dev/null + "${index}/bin/kit" install "${modules[@]}" >/dev/null readarray -t after < <(paths) git add --all -- "${before[@]}" "${after[@]}" .kit/manifest - echo "✅ refreshed from the index and staged: ${#after[@]} delivered paths, targets ${targets[*]:-none}" + echo "✅ refreshed from the index and staged: ${#after[@]} delivered paths, modules ${modules[*]:-none}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/ci/docs/README.md b/modules/ci/docs/README.md new file mode 100644 index 0000000..41912f8 --- /dev/null +++ b/modules/ci/docs/README.md @@ -0,0 +1,7 @@ +# The ci module + +Places `.github/workflows/kit-ci.yml`: one job, `CI`, which runs the repository's `just ci` when its root justfile +has that recipe, runs `kit check`, and refuses a pull request not linked to an open issue of the repository. + +The workflow is a template with one key, `ci_runner`, the label the job runs on. Its default is `ubuntu-latest`; +a repository with runners of its own puts `ci_runner <label>` in `.kit/answers`. diff --git a/modules/ci/module b/modules/ci/module new file mode 100644 index 0000000..9c3302f --- /dev/null +++ b/modules/ci/module @@ -0,0 +1,8 @@ +# modules/ci/module - the ci module: one CI job, the gate, kit check, and the linked-open-issue refusal +# ---------------------------------------------------------------------------------------------------------------------- +# One keyword per line. docs/modules.md says what each keyword means. + +description One CI job: the repository's gate, kit check, and the refusal of a pull request without an open issue +default +depends kit +answer ci_runner ubuntu-latest diff --git a/tree/.github/workflows/kit-ci.yml b/modules/ci/templates/.github/workflows/kit-ci.yml similarity index 97% rename from tree/.github/workflows/kit-ci.yml rename to modules/ci/templates/.github/workflows/kit-ci.yml index 3cf9a98..b4c1ba6 100644 --- a/tree/.github/workflows/kit-ci.yml +++ b/modules/ci/templates/.github/workflows/kit-ci.yml @@ -6,7 +6,8 @@ # open issue of this repository under Development. The last two run even when an earlier step refused, so one # run reports every finding. # -# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# Delivered by kit, not edited by hand. A change is a new release, then kit update. Rendered from a template: the +# job runs on the ci_runner answer, ubuntu-latest unless .kit/answers says otherwise. # # see: .kit/1st.md @@ -24,7 +25,7 @@ permissions: jobs: CI: if: github.event.pull_request.base.ref == github.event.repository.default_branch - runs-on: ubuntu-latest + runs-on: @ci_runner@ env: # mise reads mise.ci.toml on top of mise.toml: the tools only a runner runs. MISE_ENV: ci diff --git a/modules/claude/docs/README.md b/modules/claude/docs/README.md new file mode 100644 index 0000000..f6052df --- /dev/null +++ b/modules/claude/docs/README.md @@ -0,0 +1,10 @@ +# The claude module + +The adapter for Claude Code. It merges into `.claude/settings.json`, by key, leaving the repository's own entries: + +- `permissions.deny`: the seven spellings the rules forbid an agent, such as `git rebase` and `git push --force`, + which `kit rebase` and the loop replace. +- `hooks.PreToolUse`: on `Write`, `Edit`, and `MultiEdit`, `kit context --claude`, which puts the rules governing + the file before the agent, described in [docs/context.md](../../../docs/context.md). + +It copies no rule. `.claude/rules/` is the repository's own. diff --git a/modules/claude/module b/modules/claude/module new file mode 100644 index 0000000..13eda3e --- /dev/null +++ b/modules/claude/module @@ -0,0 +1,6 @@ +# modules/claude/module - the claude module: Claude Code's deny list and its hook into kit context +# ---------------------------------------------------------------------------------------------------------------------- +# One keyword per line. docs/modules.md says what each keyword means. + +description Claude Code: the deny list for the spellings the rules forbid, and the hook that puts a file's rules before the agent +depends kit diff --git a/targets/claude/.claude/settings.json b/modules/claude/tree/.claude/settings.json similarity index 54% rename from targets/claude/.claude/settings.json rename to modules/claude/tree/.claude/settings.json index fde98cf..d762f8d 100644 --- a/targets/claude/.claude/settings.json +++ b/modules/claude/tree/.claude/settings.json @@ -9,5 +9,18 @@ "Bash(git push -f *)", "Bash(git worktree remove --force *)" ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "kit context --claude" + } + ] + } + ] } } diff --git a/modules/kit/docs/README.md b/modules/kit/docs/README.md new file mode 100644 index 0000000..472dc3c --- /dev/null +++ b/modules/kit/docs/README.md @@ -0,0 +1,11 @@ +# The kit module + +The core, active in every repository that takes the kit. It places: + +- `.kit/1st.md`, the entry every agent reads, imported by the repository's `AGENTS.md`. +- `.kit/rules/<kind>.md`, one rule file per kind of file, each declaring in its frontmatter which paths and which + first lines it governs. + +The rules are discovered through `kit context <path>`, described in [docs/context.md](../../../docs/context.md), +and copied into no harness's directory. Adding a kind is adding one rule file here and one row to the table in +`1st.md`; every harness and every person finds it the same way. diff --git a/modules/kit/module b/modules/kit/module new file mode 100644 index 0000000..ed1b74c --- /dev/null +++ b/modules/kit/module @@ -0,0 +1,6 @@ +# modules/kit/module - the kit module: the entry every agent reads, the rules by file kind, and kit context +# ---------------------------------------------------------------------------------------------------------------------- +# One keyword per line. docs/modules.md says what each keyword means. + +description The entry every agent reads, the rules by file kind, and the context call that names them +default diff --git a/tree/.kit/1st.md b/modules/kit/tree/.kit/1st.md similarity index 76% rename from tree/.kit/1st.md rename to modules/kit/tree/.kit/1st.md index 1792650..0d4fc49 100644 --- a/tree/.kit/1st.md +++ b/modules/kit/tree/.kit/1st.md @@ -67,10 +67,10 @@ A worker is whoever implements one issue in the worktree that issue names. ## Rules by file type -Each file kind has a rule file under `.kit/rules/`, and each rule file's `paths:` header names the paths it -binds. A harness that loads rules by path gets a copy in its own rules directory when the repository takes that -target: `.claude/rules/kit-<kind>.md` for Claude Code. Read the rule for a kind before writing a file of that -kind. +Each file kind has a rule file under `.kit/rules/`, and each rule file's header names what it governs: `paths:`, +globs on the repository path, and `content:`, patterns on the first line. Before you create or change a file, run +`kit context <path>`. It names the rules that govern the file and records that you asked. Read what it names. No +harness holds a copy of a rule; this call is how every harness, and every person, finds it. | Kind | Rule | | --- | --- | @@ -84,22 +84,21 @@ kind. ## What the kit carries The repository carries the text: this file, the rules under `.kit/rules/`, and `.kit/manifest`, which records the -release version, the targets taken, and a checksum per delivered file. The release, where mise installed it, -carries the machinery, reached through the `kit` command on `PATH`. A release mirrors its `tree/` at the -repository root, and `targets/<name>/` for each target taken. A `.json` destination is merged by key; everything -else is copied as it is. - -- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, and `kit lint`, spelled that way and - no other. -- `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's +release version, the active modules, and a checksum per delivered file. The release, where it is installed, +carries the machinery, reached through the `kit` command on `PATH`. Everything the release delivers is a module, +and a repository activates the modules it takes: `kit install claude`, or `kit module add claude` later, and +`kit module remove` to take one back out. A `.json` destination is merged by key, the kit owning only the entries +it writes; everything else is copied as it is. + +- The loop's commands, `kit issue`, `kit rebase`, `kit tidy`, `kit status`, `kit context`, and `kit lint`, spelled + that way and no other. +- `kit`: this file and the rules, discovered through `kit context`. +- `lefthook`: `.kit/lefthook.yml`, which linter runs over which paths before a commit on a desk. A repository's `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. -- Targets. A harness that insists on files outside `.kit/` is a target, and a repository takes only the targets - it names: `kit install claude`, or `kit target add claude` later. The `claude` target places - `.claude/rules/kit-<kind>.md`, copies of the rules, and merges seven deny rules into `.claude/settings.json`. - The kit owns only the entries it writes; the repository may add its own. `kit target remove` takes a target - back out. -- `.github/workflows/kit-ci.yml`: one `CI` job that runs the consumer's `just ci`, runs `kit check`, and refuses - a pull request not linked to an open issue. +- `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and + refuses a pull request not linked to an open issue. +- `claude`: for Claude Code, seven deny rules merged into `.claude/settings.json`, and a hook that runs + `kit context` before every write so the rules reach the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/tree/.kit/rules/bash.md b/modules/kit/tree/.kit/rules/bash.md similarity index 98% rename from tree/.kit/rules/bash.md rename to modules/kit/tree/.kit/rules/bash.md index 33b1128..e04e9b0 100644 --- a/tree/.kit/rules/bash.md +++ b/modules/kit/tree/.kit/rules/bash.md @@ -3,6 +3,8 @@ paths: - "**/*.sh" - "**/*.bash" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" --- # Bash diff --git a/tree/.kit/rules/gitignore.md b/modules/kit/tree/.kit/rules/gitignore.md similarity index 100% rename from tree/.kit/rules/gitignore.md rename to modules/kit/tree/.kit/rules/gitignore.md diff --git a/tree/.kit/rules/json.md b/modules/kit/tree/.kit/rules/json.md similarity index 100% rename from tree/.kit/rules/json.md rename to modules/kit/tree/.kit/rules/json.md diff --git a/tree/.kit/rules/just.md b/modules/kit/tree/.kit/rules/just.md similarity index 98% rename from tree/.kit/rules/just.md rename to modules/kit/tree/.kit/rules/just.md index d91dd1f..69f6620 100644 --- a/tree/.kit/rules/just.md +++ b/modules/kit/tree/.kit/rules/just.md @@ -4,6 +4,8 @@ paths: - "justfile" - "**/justfile" - "**/*.just" +content: + - "^#!.*/(env +)?just( |$)" --- # Just diff --git a/tree/.kit/rules/markdown.md b/modules/kit/tree/.kit/rules/markdown.md similarity index 100% rename from tree/.kit/rules/markdown.md rename to modules/kit/tree/.kit/rules/markdown.md diff --git a/tree/.kit/rules/yaml.md b/modules/kit/tree/.kit/rules/yaml.md similarity index 100% rename from tree/.kit/rules/yaml.md rename to modules/kit/tree/.kit/rules/yaml.md diff --git a/modules/lefthook/docs/README.md b/modules/lefthook/docs/README.md new file mode 100644 index 0000000..139f18b --- /dev/null +++ b/modules/lefthook/docs/README.md @@ -0,0 +1,5 @@ +# The lefthook module + +Places `.kit/lefthook.yml`, which says which linter runs over which paths before a commit. The repository's own +`lefthook.yml` takes it with `extends: [.kit/lefthook.yml]` and adds commands of its own; Lefthook merges by name. +`kit lint` runs the same hook by hand. The repository pins `lefthook` itself, since only a desk runs a hook. diff --git a/modules/lefthook/module b/modules/lefthook/module new file mode 100644 index 0000000..f7d56b1 --- /dev/null +++ b/modules/lefthook/module @@ -0,0 +1,7 @@ +# modules/lefthook/module - the lefthook module: which linter runs over which paths before a commit +# ---------------------------------------------------------------------------------------------------------------------- +# One keyword per line. docs/modules.md says what each keyword means. + +description Which linter runs over which paths before a commit, for the repository's lefthook.yml to extend +default +depends kit diff --git a/tree/.kit/lefthook.yml b/modules/lefthook/tree/.kit/lefthook.yml similarity index 100% rename from tree/.kit/lefthook.yml rename to modules/lefthook/tree/.kit/lefthook.yml diff --git a/targets/claude/.claude/rules/kit-bash.md b/targets/claude/.claude/rules/kit-bash.md deleted file mode 120000 index bb642e0..0000000 --- a/targets/claude/.claude/rules/kit-bash.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/bash.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-gitignore.md b/targets/claude/.claude/rules/kit-gitignore.md deleted file mode 120000 index 1a1c591..0000000 --- a/targets/claude/.claude/rules/kit-gitignore.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/gitignore.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-json.md b/targets/claude/.claude/rules/kit-json.md deleted file mode 120000 index d4e607d..0000000 --- a/targets/claude/.claude/rules/kit-json.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/json.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-just.md b/targets/claude/.claude/rules/kit-just.md deleted file mode 120000 index 2b2f173..0000000 --- a/targets/claude/.claude/rules/kit-just.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/just.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-markdown.md b/targets/claude/.claude/rules/kit-markdown.md deleted file mode 120000 index 8d735ea..0000000 --- a/targets/claude/.claude/rules/kit-markdown.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/markdown.md \ No newline at end of file diff --git a/targets/claude/.claude/rules/kit-yaml.md b/targets/claude/.claude/rules/kit-yaml.md deleted file mode 120000 index d91b5eb..0000000 --- a/targets/claude/.claude/rules/kit-yaml.md +++ /dev/null @@ -1 +0,0 @@ -../../../../tree/.kit/rules/yaml.md \ No newline at end of file diff --git a/tests/context.bats b/tests/context.bats new file mode 100644 index 0000000..080304a --- /dev/null +++ b/tests/context.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats +# context.bats - kit context names a file's rules by path or by first line, logs the ask, and speaks Claude's hook +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + repo=$(mktemp -d) + git -C "${repo}" init --quiet --initial-branch=main + cd "${repo}" + "${kit_checkout}/bin/kit" install >/dev/null + export TMPDIR="${repo}/tmp" + mkdir "${TMPDIR}" +} + +teardown() { + rm -rf "${repo}" +} + +@test "a file is governed by its path, by its first line, or by nothing" { + # a real file the rule's glob would expand to, were the glob ever word-split + mkdir docs && printf '# x\n' > docs/x.md + run "${kit_checkout}/bin/kit" context README.md + [ "$output" = ".kit/rules/markdown.md" ] + run "${kit_checkout}/bin/kit" context justfile + [ "$status" -eq 0 ] + [ "$output" = ".kit/rules/just.md" ] + mkdir bin && printf '#!/usr/bin/env bash\necho\n' > bin/tool + run "${kit_checkout}/bin/kit" context bin/tool + [ "$output" = ".kit/rules/bash.md" ] + run "${kit_checkout}/bin/kit" context bin/other + [ "$status" -eq 0 ] + [ -z "$output" ] + run "${kit_checkout}/bin/kit" context docs/a.md "${PWD}/.github/workflows/x.yml" lib/x.md + [ "${lines[0]}" = ".kit/rules/markdown.md" ] + [ "${lines[1]}" = ".kit/rules/yaml.md" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "the json answer is one object per path, and every ask is logged" { + run "${kit_checkout}/bin/kit" context --json justfile README.md + [ "$status" -eq 0 ] + [ "$(jq -c .rules <<<"${lines[0]}")" = '[".kit/rules/just.md"]' ] + [ "$(jq -r .path <<<"${lines[1]}")" = "README.md" ] + [ "$(cut -f 2 .git/kit/context.log | paste -s -d ' ' -)" = "justfile README.md" ] +} + +@test "the claude hook gives a rule's text once per session, then its path" { + input='{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"'"${PWD}"'/lib/run.sh","content":""}}' + run "${kit_checkout}/bin/kit" context --claude <<<"${input}" + [ "$status" -eq 0 ] + text=$(jq -r .hookSpecificOutput.additionalContext <<<"$output") + [[ "${text}" == "lib/run.sh is governed by .kit/rules/bash.md. Its text:"* ]] + [[ "${text}" == *"# Bash"* ]] + run "${kit_checkout}/bin/kit" context --claude <<<"${input}" + text=$(jq -r .hookSpecificOutput.additionalContext <<<"$output") + [ "${text}" = "lib/run.sh is governed by .kit/rules/bash.md, which you have read this session." ] + run "${kit_checkout}/bin/kit" context --claude <<<'{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"x.bin"}}' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/kit.bats b/tests/kit.bats index 75b5e02..ed11d71 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -1,9 +1,9 @@ #!/usr/bin/env bats -# kit.bats - bin/kit install, check, and update against a fresh repository +# kit.bats - bin/kit install, module, check, and update against a fresh repository # ---------------------------------------------------------------------------------------------------------------------- # # Each test gets an empty git repository under a temporary directory and runs the kit from this checkout against -# it. The update tests build a second, later payload from the first so the three-way merge has a base. +# it. The update tests build a second, later release from the first so the three-way merge has a base. setup() { kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) @@ -13,80 +13,103 @@ setup() { } teardown() { - rm -rf "${repo}" + rm -rf "${repo}" "${repo}.later" } # later-release <dir> copies this checkout's release to <dir> as version 9.9.9 with one changed rule line. later-release() { mkdir -p "$1" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "$1/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/modules" "$1/" printf '9.9.9\n' > "$1/version" - printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/tree/.kit/rules/json.md" + printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/modules/kit/tree/.kit/rules/json.md" } -@test "install with the claude target writes the tree, the mirrors, the workflow, the settings, and the manifest" { - run "${kit_checkout}/bin/kit" install claude +@test "install with no module writes the default modules and nothing under .claude" { + run "${kit_checkout}/bin/kit" install [ "$status" -eq 0 ] [ -f .kit/1st.md ] [ -f .kit/rules/bash.md ] - [ ! -e .kit/bash ] [ -f .kit/lefthook.yml ] - [ -f .claude/rules/kit-markdown.md ] - [ ! -L .claude/rules/kit-markdown.md ] - cmp --silent .claude/rules/kit-markdown.md .kit/rules/markdown.md - head -n 1 .claude/rules/kit-bash.md | grep --quiet --line-regexp -- '---' - grep --quiet '"\*\*/\*\.sh"' .claude/rules/kit-bash.md - [ -f .github/workflows/kit-ci.yml ] - jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null - grep --quiet '^version unreleased$' .kit/manifest - grep --quiet '^targets claude$' .kit/manifest -} - -@test "install with no target writes the neutral tree and nothing under .claude" { - run "${kit_checkout}/bin/kit" install - [ "$status" -eq 0 ] - [ -f .kit/1st.md ] [ -f .github/workflows/kit-ci.yml ] + grep --quiet '^ runs-on: ubuntu-latest$' .github/workflows/kit-ci.yml [ ! -e .claude ] - grep --quiet '^targets$' .kit/manifest + grep --quiet '^version unreleased$' .kit/manifest + grep --quiet '^modules kit ci lefthook$' .kit/manifest run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] - # a stray file under a target not taken is nobody's business - mkdir -p .claude/rules && printf 'theirs\n' > .claude/rules/kit-json.md + # a stray file under a module not active is nobody's business + mkdir -p .claude && printf '{"model":"x"}\n' > .claude/settings.json run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] } -@test "install refuses a target this release does not know and names the known ones" { +@test "install with claude adds the deny list and the context hook, and copies no rule" { + run "${kit_checkout}/bin/kit" install claude + [ "$status" -eq 0 ] + grep --quiet '^modules kit claude ci lefthook$' .kit/manifest + jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null + jq --exit-status '.hooks.PreToolUse[0].hooks[0].command == "kit context --claude"' .claude/settings.json >/dev/null + [ ! -e .claude/rules ] +} + +@test "install refuses a module this release does not know and names the known ones" { run "${kit_checkout}/bin/kit" install gemini [ "$status" -eq 2 ] - [[ "$output" == *"the targets are: claude"* ]] + [[ "$output" == *"the modules are: ci claude kit lefthook"* ]] [ ! -e .kit/manifest ] } -@test "target add places a target's files, and target remove takes them and the kit's entries back out" { +@test "module add places a module's files, and module remove takes them and the kit's entries back out" { mkdir .claude printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json "${kit_checkout}/bin/kit" install - [ "$("${kit_checkout}/bin/kit" target list)" = "" ] - run "${kit_checkout}/bin/kit" target add claude + run "${kit_checkout}/bin/kit" module list + [[ "$output" == *"✅ kit "* ]] + [[ "$output" == *" claude "* ]] + run "${kit_checkout}/bin/kit" module add claude [ "$status" -eq 0 ] - [ "$("${kit_checkout}/bin/kit" target list)" = "claude" ] - [ -f .claude/rules/kit-bash.md ] jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null - grep --quiet '^targets claude$' .kit/manifest + grep --quiet '^modules kit claude ci lefthook$' .kit/manifest run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] - run "${kit_checkout}/bin/kit" target remove claude + run "${kit_checkout}/bin/kit" module remove claude [ "$status" -eq 0 ] - [ ! -e .claude/rules/kit-bash.md ] [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] jq --exit-status '.permissions.deny == ["Bash(rm -rf /)"]' .claude/settings.json >/dev/null - grep --quiet '^targets$' .kit/manifest + jq --exit-status '.hooks == {"PreToolUse": []}' .claude/settings.json >/dev/null + grep --quiet '^modules kit ci lefthook$' .kit/manifest run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] } +@test "module remove refuses a module another active module depends on, and removes a leaf" { + "${kit_checkout}/bin/kit" install claude + run "${kit_checkout}/bin/kit" module remove kit + [ "$status" -eq 1 ] + [[ "$output" == *"kit cannot be removed while"* ]] + [ -f .kit/1st.md ] + run "${kit_checkout}/bin/kit" module remove ci + [ "$status" -eq 0 ] + [ ! -e .github/workflows/kit-ci.yml ] + grep --quiet '^modules kit claude lefthook$' .kit/manifest +} + +@test "a template takes the repository's answer over the module's default, and refuses a key with neither" { + mkdir .kit && printf 'ci_runner blacksmith-2vcpu\n' > .kit/answers + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + grep --quiet '^ runs-on: blacksmith-2vcpu$' .github/workflows/kit-ci.yml + grep --quiet 'uses: actions/checkout@' .github/workflows/kit-ci.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + later-release "${repo}.later" + printf 'runner @ci_runner@ owner @owner@\n' > "${repo}.later/modules/ci/templates/OWNER" + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *"OWNER needs an answer for 'owner'"* ]] + [ ! -e OWNER ] +} + @test "install keeps the repository's own settings keys and deny entries" { mkdir .claude printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json @@ -108,12 +131,12 @@ later-release() { run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] printf 'edited\n' >> .kit/rules/yaml.md - rm .claude/rules/kit-json.md + rm .kit/lefthook.yml jq '.permissions.deny -= ["Bash(git reset --hard *)"]' .claude/settings.json > s && mv s .claude/settings.json run "${kit_checkout}/bin/kit" check [ "$status" -eq 1 ] [[ "$output" == *".kit/rules/yaml.md differs"* ]] - [[ "$output" == *".claude/rules/kit-json.md is missing"* ]] + [[ "$output" == *".kit/lefthook.yml is missing"* ]] [[ "$output" == *"missing an entry the kit writes"* ]] [[ "$output" == *"3 finding(s)"* ]] } @@ -134,28 +157,25 @@ later-release() { grep --quiet '^version 9.9.9$' .kit/manifest grep --quiet '^# JSON (ours)$' .kit/rules/json.md grep --quiet '^## Added in 9.9.9$' .kit/rules/json.md - grep --quiet '^## Added in 9.9.9$' .claude/rules/kit-json.md # the manifest carries the release's digest, so the edit still reads as an edit - release_digest=$(shasum -a 256 "${repo}.later/tree/.kit/rules/json.md" | cut -d ' ' -f 1) + release_digest=$(shasum -a 256 "${repo}.later/modules/kit/tree/.kit/rules/json.md" | cut -d ' ' -f 1) grep --quiet "^${release_digest} .kit/rules/json.md$" .kit/manifest # and check names the one file that still differs from the release run "${repo}.later/bin/kit" check [ "$status" -eq 1 ] [[ "$output" == *".kit/rules/json.md differs"* ]] [[ "$output" == *"1 finding(s)"* ]] - rm -rf "${repo}.later" } @test "update refuses a path new in the release where the repository already has a different file" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" - printf 'new rule\n' > "${repo}.later/tree/.kit/rules/toml.md" + printf 'new rule\n' > "${repo}.later/modules/kit/tree/.kit/rules/toml.md" printf 'mine\n' > .kit/rules/toml.md run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] [[ "$output" == *".kit/rules/toml.md is new in 9.9.9"* ]] [ "$(cat .kit/rules/toml.md)" = "mine" ] - rm -rf "${repo}.later" } @test "update of an untouched install leaves check green" { @@ -165,19 +185,17 @@ later-release() { [ "$status" -eq 0 ] run "${repo}.later/bin/kit" check [ "$status" -eq 0 ] - rm -rf "${repo}.later" } @test "update leaves conflict markers and fails when the same lines changed" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" - sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${repo}.later/tree/.kit/rules/json.md" && rm "${repo}.later/tree/.kit/rules/json.md.bak" + sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${repo}.later/modules/kit/tree/.kit/rules/json.md" && rm "${repo}.later/modules/kit/tree/.kit/rules/json.md.bak" sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] grep --quiet '^<<<<<<<' .kit/rules/json.md [[ "$output" == *"conflict markers"* ]] - rm -rf "${repo}.later" } @test "update without the base release refuses an edited file and names the base" { @@ -189,7 +207,6 @@ later-release() { run env PATH="${PWD}/shims:${PATH}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] [[ "$output" == *"cannot be updated without the base release unreleased"* ]] - rm -rf "${repo}.later" } # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/release.bats b/tests/release.bats index ba5c9e3..2469254 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -16,7 +16,7 @@ teardown() { # packed the way the workflow packs it, with the tag's version stamped in version=9.9.9 mkdir "kit-${version}" - cp -R "${kit_checkout}/bin" "${kit_checkout}/tree" "${kit_checkout}/targets" "kit-${version}/" + cp -R "${kit_checkout}/bin" "${kit_checkout}/modules" "kit-${version}/" printf '%s\n' "${version}" > "kit-${version}/version" tar --create --gzip --file "kit-${version}.tar.gz" "kit-${version}" rm -rf "kit-${version}" @@ -24,7 +24,7 @@ teardown() { [ "$(ls unpacked)" = "kit-${version}" ] [ -x "unpacked/kit-${version}/bin/kit" ] [ -x "unpacked/kit-${version}/bin/kit-install.sh" ] - [ -L "unpacked/kit-${version}/targets/claude/.claude/rules/kit-bash.md" ] + [ -f "unpacked/kit-${version}/modules/claude/module" ] mkdir fresh && git -C fresh init --quiet --initial-branch=main cd fresh run "${work}/unpacked/kit-${version}/bin/kit" install claude @@ -32,7 +32,7 @@ teardown() { run "${work}/unpacked/kit-${version}/bin/kit" check [ "$status" -eq 0 ] [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] - [ ! -L .claude/rules/kit-bash.md ] + grep --quiet "^version ${version}$" .kit/manifest } # ---------------------------------------------------------------------------------------------------------------------- From efeee135f8e90cd4bed82428a6a811df0d1379b4 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 00:58:32 -0700 Subject: [PATCH 063/108] #4 bin/release.sh: the module order is the kit's own, not tsort's A module after its dependencies, alphabetical otherwise, so the manifest's modules line is the same on every machine; GNU and BSD tsort broke ties differently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/release.sh | 36 ++++++++++++++++++++++++++---------- tests/kit.bats | 4 ++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/bin/release.sh b/bin/release.sh index e8d03a3..9f2647d 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -55,13 +55,14 @@ release:require-modules() { done } -# release:resolve <root> <name>… prints the named modules and everything they depend on, each once, a module -# after the modules it depends on: the closure over depends, ordered by tsort over its edges. +# release:resolve <root> <name>… prints the named modules and everything they depend on, each once: a module +# after the modules it depends on, and alphabetical where that leaves a choice, so the order is the same on +# every machine. A cycle is refused. release:resolve() { - local root=$1 module dep + local root=$1 module dep ready placed_count=0 shift - local -A wanted=() - local -a queue=("$@") + local -A wanted=() placed=() + local -a queue=("$@") names=() while (( ${#queue[@]} > 0 )) do module=${queue[0]} @@ -73,14 +74,29 @@ release:resolve() { queue+=("${dep}") done done - for module in "${!wanted[@]}" + readarray -t names < <(printf '%s\n' "${!wanted[@]}" | sort) + while (( placed_count < ${#names[@]} )) do - printf '%s %s\n' "${module}" "${module}" - for dep in $(release:field "${root}" "${module}" depends) + ready='' + for module in "${names[@]}" do - printf '%s %s\n' "${dep}" "${module}" + [[ -n "${placed[${module}]:-}" ]] && continue + for dep in $(release:field "${root}" "${module}" depends) + do + [[ -n "${placed[${dep}]:-}" ]] || continue 2 + done + ready=${module} + break done - done | sort | tsort + if [[ -z "${ready}" ]] + then + error "the modules' depends lines form a cycle among: ${names[*]}" + return 1 + fi + placed[${ready}]=1 + placed_count=$(( placed_count + 1 )) + printf '%s\n' "${ready}" + done } # release:dependents <root> <module> <active>… prints the active modules that depend on <module>. diff --git a/tests/kit.bats b/tests/kit.bats index ed11d71..eebf5fc 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -46,7 +46,7 @@ later-release() { @test "install with claude adds the deny list and the context hook, and copies no rule" { run "${kit_checkout}/bin/kit" install claude [ "$status" -eq 0 ] - grep --quiet '^modules kit claude ci lefthook$' .kit/manifest + grep --quiet '^modules kit ci claude lefthook$' .kit/manifest jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null jq --exit-status '.hooks.PreToolUse[0].hooks[0].command == "kit context --claude"' .claude/settings.json >/dev/null [ ! -e .claude/rules ] @@ -69,7 +69,7 @@ later-release() { run "${kit_checkout}/bin/kit" module add claude [ "$status" -eq 0 ] jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null - grep --quiet '^modules kit claude ci lefthook$' .kit/manifest + grep --quiet '^modules kit ci claude lefthook$' .kit/manifest run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] run "${kit_checkout}/bin/kit" module remove claude From 450e27fd573d6d17ee3cbc41898d84dbd7eda6b6 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 01:00:03 -0700 Subject: [PATCH 064/108] #4 .kit/manifest: the modules line in the resolver's order Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .kit/manifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.kit/manifest b/.kit/manifest index 5697a2c..cb7a597 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,6 +1,6 @@ # .kit/manifest - written by kit, not by hand version unreleased -modules kit claude ci lefthook +modules kit ci claude lefthook 86563533cd11f87831320d57b24d68d83c8d3a47382b09afeb71f5ee46e93584 .claude/settings.json cd66dc3b7fe79643f0a706e0ea571962906ffd117743c68e23aadbab7c91e143 .github/workflows/kit-ci.yml f7db408e409f39c36c9906efbedfd800054a654b44615b021bd02fd93fa4d57e .kit/1st.md From 8ce7398eb46693f9f79108eb0b0a22619e81780e Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 01:10:53 -0700 Subject: [PATCH 065/108] #4 bin/context.sh: the Claude hook names the rules and never quotes them The same answer the plain call prints, so there is no session state, no compaction case, and reading a rule stays an act the agent takes, one a hook on Read can record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- bin/context.sh | 29 +++++++++-------------------- docs/context.md | 5 +++-- tests/context.bats | 15 +++++---------- 3 files changed, 17 insertions(+), 32 deletions(-) diff --git a/bin/context.sh b/bin/context.sh index 975ff96..f1eec91 100644 --- a/bin/context.sh +++ b/bin/context.sh @@ -8,9 +8,9 @@ # the first line of a file that exists, so a bash script with no extension is bash by its shebang. The rules are # discovered here and copied nowhere, which is what lets every harness, and every person, find them the same way. # -# context:claude is the same answer in Claude Code's hook protocol: the hook input on stdin, the rules as -# additional context on stdout. The first time a rule is named in a session its text is given; after that, its -# path. docs/context.md is the description. +# context:claude is the same answer in Claude Code's hook protocol: the hook input on stdin, the rules' paths as +# additional context on stdout. It names, never quotes: reading the rule stays the agent's own act, one a hook on +# Read can record. docs/context.md is the description. shopt -s extglob @@ -139,30 +139,19 @@ context:render() { } # context:claude reads Claude Code's PreToolUse hook input on stdin and answers with the rules governing the file -# the tool is about to write, as additional context: the rule's text the first time it is named in the session, -# its path after that. A tool input with no file path, or a file no rule governs, gets no answer. +# the tool is about to write, as additional context naming each rule's path. A tool input with no file path, or +# a file no rule governs, gets no answer. context:claude() { - local input path session rule text='' given + local input path rules text='' input=$(cat) path=$(jq --raw-output '.tool_input.file_path // empty' <<<"${input}") - session=$(jq --raw-output '.session_id // "none"' <<<"${input}") [[ -n "${path}" ]] || return 0 cd "$(git rev-parse --show-toplevel)" || return 0 path=$(context:relative "${path}") context:log "${path}" - given="${TMPDIR:-/tmp}/kit-context-${session//[^A-Za-z0-9_-]/_}" - while IFS= read -r rule - do - [[ -n "${rule}" ]] || continue - if grep --quiet --line-regexp --fixed-strings "${rule}" "${given}" 2>/dev/null - then - text+="${path} is governed by ${rule}, which you have read this session."$'\n' - else - printf '%s\n' "${rule}" >> "${given}" - text+="${path} is governed by ${rule}. Its text:"$'\n\n'"$(cat "${rule}")"$'\n\n' - fi - done < <(context:rules "${path}") - [[ -n "${text}" ]] || return 0 + rules=$(context:rules "${path}" | paste -s -d ' ' -) + [[ -n "${rules}" ]] || return 0 + text="kit: ${path} is governed by ${rules}; read it before this change." jq --null-input --compact-output --arg text "${text}" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $text } }' } diff --git a/docs/context.md b/docs/context.md index f28b4df..a9108df 100644 --- a/docs/context.md +++ b/docs/context.md @@ -43,8 +43,9 @@ kit context --claude ``` It reads the hook's input on stdin, takes the file path from the tool's input, and answers in the hook's output -with the rules as additional context: a rule's text the first time it is named in the session, its path after -that. The session is told apart by the hook input's `session_id`, and what was given is kept under `TMPDIR`. +with the rules' paths as additional context, the same answer the plain call prints. It names and never quotes, so +reading the rule stays an act the agent takes, one a hook on `Read` can record and a check can compare against +the context log. No state is kept between calls. A harness with no hook, and a person, use the plain call. `.kit/1st.md` names it as the step before any file is created or changed. diff --git a/tests/context.bats b/tests/context.bats index 080304a..268c40d 100644 --- a/tests/context.bats +++ b/tests/context.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# context.bats - kit context names a file's rules by path or by first line, logs the ask, and speaks Claude's hook +# context.bats - kit context names a file's rules by path or by first line, logs the ask, and answers Claude's hook # ---------------------------------------------------------------------------------------------------------------------- setup() { @@ -8,8 +8,6 @@ setup() { git -C "${repo}" init --quiet --initial-branch=main cd "${repo}" "${kit_checkout}/bin/kit" install >/dev/null - export TMPDIR="${repo}/tmp" - mkdir "${TMPDIR}" } teardown() { @@ -44,16 +42,13 @@ teardown() { [ "$(cut -f 2 .git/kit/context.log | paste -s -d ' ' -)" = "justfile README.md" ] } -@test "the claude hook gives a rule's text once per session, then its path" { +@test "the claude hook names the rules of the file about to be written, and nothing for a file none governs" { input='{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"'"${PWD}"'/lib/run.sh","content":""}}' run "${kit_checkout}/bin/kit" context --claude <<<"${input}" [ "$status" -eq 0 ] - text=$(jq -r .hookSpecificOutput.additionalContext <<<"$output") - [[ "${text}" == "lib/run.sh is governed by .kit/rules/bash.md. Its text:"* ]] - [[ "${text}" == *"# Bash"* ]] - run "${kit_checkout}/bin/kit" context --claude <<<"${input}" - text=$(jq -r .hookSpecificOutput.additionalContext <<<"$output") - [ "${text}" = "lib/run.sh is governed by .kit/rules/bash.md, which you have read this session." ] + [ "$(jq -r .hookSpecificOutput.hookEventName <<<"$output")" = "PreToolUse" ] + [ "$(jq -r .hookSpecificOutput.additionalContext <<<"$output")" = "kit: lib/run.sh is governed by .kit/rules/bash.md; read it before this change." ] + [ "$(cut -f 2 .git/kit/context.log)" = "lib/run.sh" ] run "${kit_checkout}/bin/kit" context --claude <<<'{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"x.bin"}}' [ "$status" -eq 0 ] [ -z "$output" ] From a6965e9c37fedbae2d65e680d49b5579f04e34ee Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 01:24:32 -0700 Subject: [PATCH 066/108] #4 modules: module.yml, files/<base>/ with repo and home, and Claude reads AGENTS.md like everyone else A module's metadata is module.yml, in the small subset of YAML bin/yaml.sh reads, the same reader the rules' frontmatter goes through. A module delivers from files/<base>/ and templates/<base>/, with two bases: repo, the repository root, recorded in .kit/manifest; and home, the user's ~, recorded in ~/.kit/manifest, the desk's own record, which kit check reads where it exists and reports absent where it does not. A module naming another base is refused with the bases listed. KIT_HOME stands in for the home directory, and every test sets it, so no test touches the real one. Claude Code reads CLAUDE.md and not AGENTS.md, so the claude module places .claude/CLAUDE.md, two lines importing AGENTS.md, and AGENTS.md opens with the sentence every agent reads, in prose, and no import of Claude's own. 38 bats cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .claude/CLAUDE.md | 2 + .kit/1st.md | 5 +- .kit/manifest | 3 +- AGENTS.md | 5 +- README.md | 36 ++-- bin/context.sh | 18 +- bin/kit-check.sh | 47 +++-- bin/kit-install.sh | 22 ++- bin/kit-module.sh | 32 ++-- bin/kit-update.sh | 101 +++++----- bin/release.sh | 175 +++++++++++++----- bin/yaml.sh | 50 +++++ docs/modules.md | 55 ++++-- modules/ci/module | 8 - modules/ci/module.yml | 10 + .../{ => repo}/.github/workflows/kit-ci.yml | 0 modules/claude/docs/README.md | 8 +- modules/claude/files/repo/.claude/CLAUDE.md | 2 + .../repo}/.claude/settings.json | 0 modules/claude/module | 6 - modules/claude/module.yml | 7 + modules/kit/{tree => files/repo}/.kit/1st.md | 5 +- .../{tree => files/repo}/.kit/rules/bash.md | 0 .../repo}/.kit/rules/gitignore.md | 0 .../{tree => files/repo}/.kit/rules/json.md | 0 .../{tree => files/repo}/.kit/rules/just.md | 0 .../repo}/.kit/rules/markdown.md | 0 .../{tree => files/repo}/.kit/rules/yaml.md | 0 modules/kit/module | 6 - modules/kit/module.yml | 6 + .../{tree => files/repo}/.kit/lefthook.yml | 0 modules/lefthook/module | 7 - modules/lefthook/module.yml | 8 + tests/context.bats | 2 + tests/kit.bats | 57 +++++- tests/release.bats | 4 +- 36 files changed, 466 insertions(+), 221 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 bin/yaml.sh delete mode 100644 modules/ci/module create mode 100644 modules/ci/module.yml rename modules/ci/templates/{ => repo}/.github/workflows/kit-ci.yml (100%) create mode 100644 modules/claude/files/repo/.claude/CLAUDE.md rename modules/claude/{tree => files/repo}/.claude/settings.json (100%) delete mode 100644 modules/claude/module create mode 100644 modules/claude/module.yml rename modules/kit/{tree => files/repo}/.kit/1st.md (95%) rename modules/kit/{tree => files/repo}/.kit/rules/bash.md (100%) rename modules/kit/{tree => files/repo}/.kit/rules/gitignore.md (100%) rename modules/kit/{tree => files/repo}/.kit/rules/json.md (100%) rename modules/kit/{tree => files/repo}/.kit/rules/just.md (100%) rename modules/kit/{tree => files/repo}/.kit/rules/markdown.md (100%) rename modules/kit/{tree => files/repo}/.kit/rules/yaml.md (100%) delete mode 100644 modules/kit/module create mode 100644 modules/kit/module.yml rename modules/lefthook/{tree => files/repo}/.kit/lefthook.yml (100%) delete mode 100644 modules/lefthook/module create mode 100644 modules/lefthook/module.yml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..819bb57 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,2 @@ +<!-- Delivered by kit, not edited by hand. A change is a new release, then kit update. --> +@../AGENTS.md diff --git a/.kit/1st.md b/.kit/1st.md index 0d4fc49..ccb0fc6 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -97,8 +97,9 @@ it writes; everything else is copied as it is. `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `claude`: for Claude Code, seven deny rules merged into `.claude/settings.json`, and a hook that runs - `kit context` before every write so the rules reach the agent without being asked for. +- `claude`: for Claude Code, which reads `CLAUDE.md` and not `AGENTS.md`: `.claude/CLAUDE.md` importing + `AGENTS.md`, seven deny rules merged into `.claude/settings.json`, and a hook that runs `kit context` before + every write so the rules are named to the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/manifest b/.kit/manifest index cb7a597..4ad7cd4 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -1,9 +1,10 @@ # .kit/manifest - written by kit, not by hand version unreleased modules kit ci claude lefthook +930488befed8ab56355d33f55032b5e7bc6d57a1c0ee867b51d9495cdec9f3cd .claude/CLAUDE.md 86563533cd11f87831320d57b24d68d83c8d3a47382b09afeb71f5ee46e93584 .claude/settings.json cd66dc3b7fe79643f0a706e0ea571962906ffd117743c68e23aadbab7c91e143 .github/workflows/kit-ci.yml -f7db408e409f39c36c9906efbedfd800054a654b44615b021bd02fd93fa4d57e .kit/1st.md +37df46c8bbe5a02b972412f0fea25246e14ab66cb0421c8cb23857c3fdb94189 .kit/1st.md 953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml 6f1101c9b19bfbe2d42d6e602e92d0df4dc650a4a04ccbd06c5a03a5e6e5edc2 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md diff --git a/AGENTS.md b/AGENTS.md index c1f467d..43408df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,5 @@ -@.kit/1st.md - -Read .kit/1st.md first. It is the kit's entry file, and this repository uses its own kit. +Read .kit/1st.md at the start of every session. It defines how any agent uses empowerite/kit to keep this +repository trustworthy. This repository is the kit's own source, and uses it. ## This repository's own rules diff --git a/README.md b/README.md index 63080ea..9c6aa6d 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,26 @@ it ships unless `[settings]` carries `minimum_release_age_excludes = ["github:em ## Take the kit -In the repository, install it and point `AGENTS.md` at it: +In the repository, install it and point `AGENTS.md` at it, in prose, the same sentence for every agent: ```sh kit install claude ``` ```markdown -@.kit/1st.md - -Read .kit/1st.md first. +Read .kit/1st.md at the start of every session. It defines how any agent uses empowerite/kit to keep this +repository trustworthy. ``` -The rest of `AGENTS.md` is yours. Everything the kit delivers is a module, and `install` activates the default -ones, `kit`, `lefthook`, and `ci`, plus those named: `claude` is the adapter for Claude Code, and a repository -with no adapter still binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the -root justfile for the delivered `CI` job to run, and take the pre-commit linters by pinning `lefthook`, putting -`extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit module list`, -`kit module add`, and `kit module remove` show and change the set later, and [docs/modules.md](docs/modules.md) -describes a module. An agent asks `kit context <path>` which rules govern a file before it creates or changes -it; [docs/context.md](docs/context.md) describes the call and the Claude Code hook that makes it for the agent. +The rest of `AGENTS.md` is yours. Everything the kit delivers is a module, and `install` activates the default ones, +`kit`, `lefthook`, and `ci`, plus those named: `claude` is the adapter for Claude Code, which reads `CLAUDE.md` and +not `AGENTS.md`, so the module places `.claude/CLAUDE.md` importing `AGENTS.md` and Claude reads what everyone else +reads. A repository with no adapter still binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` +recipe in the root justfile for the delivered `CI` job to run, and take the pre-commit linters by pinning `lefthook`, +putting `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit module +list`, `kit module add`, and `kit module remove` show and change the set later, and [docs/modules.md](docs/modules.md) +describes a module. An agent asks `kit context <path>` which rules govern a file before it creates or changes it; +[docs/context.md](docs/context.md) describes the call and the Claude Code hook that makes it for the agent. To move to a new release, bump the pin or reinstall, then run `kit update`: a delivered file you left alone is replaced, one you edited is three-way merged with the previous release as the base, which `update` fetches from @@ -120,8 +120,8 @@ Work here moves by the loop in [.kit/1st.md](.kit/1st.md): ## Maintaining This repository is both the source of the kit and a consumer of it. The release is the checkout: `modules/<name>/` -is what a consumer activating that module gets, its `tree/` mirrored at the consumer's root and its `templates/` -rendered from the consumer's answers first; `bin/` is the machinery, `bin/kit` dispatching to one executable +is what a consumer activating that module gets, its `files/repo/` mirrored at the consumer's root and its +`templates/repo/` rendered from the consumer's answers first; `bin/` is the machinery, `bin/kit` dispatching to one executable `bin/kit-<verb>.sh` per verb, each sourcing the bash modules beside it; and `docs/` with each module's `docs/` is the documentation. [docs/modules.md](docs/modules.md) is the module format. @@ -129,10 +129,10 @@ The installed copies are written by `bin/kit install` and not edited by hand: | Installed copy | Written from | | --- | --- | -| `.kit/1st.md`, `.kit/rules/` | `modules/kit/tree/.kit/` | -| `.kit/lefthook.yml` | `modules/lefthook/tree/.kit/lefthook.yml` | -| `.github/workflows/kit-ci.yml` | `modules/ci/templates/.github/workflows/kit-ci.yml`, with `ci_runner` answered | -| `.claude/settings.json` | `modules/claude/tree/.claude/settings.json`, merged by key; here only the kit's entries | +| `.kit/1st.md`, `.kit/rules/` | `modules/kit/files/repo/.kit/` | +| `.kit/lefthook.yml` | `modules/lefthook/files/repo/.kit/lefthook.yml` | +| `.github/workflows/kit-ci.yml` | `modules/ci/templates/repo/.github/workflows/kit-ci.yml`, with `ci_runner` answered | +| `.claude/settings.json` | `modules/claude/files/repo/.claude/settings.json`, merged by key; here only the kit's entries | | `.kit/manifest` | the release version (`unreleased` here), the active modules, and a digest per delivered path | A commit that changes a file under `modules/` rewrites the installed copies in the same commit: the diff --git a/bin/context.sh b/bin/context.sh index f1eec91..9529455 100644 --- a/bin/context.sh +++ b/bin/context.sh @@ -12,24 +12,14 @@ # additional context on stdout. It names, never quotes: reading the rule stays the agent's own act, one a hook on # Read can record. docs/context.md is the description. +use yaml shopt -s extglob -# context_log is where every ask is recorded, under the repository's git directory, shared by its worktrees. +# context:log-path prints where every ask is recorded: under the repository's git directory, shared by its worktrees. context:log-path() { printf '%s/kit/context.log\n' "$(git rev-parse --path-format=absolute --git-common-dir)" } -# context:frontmatter <file> <key> prints the items of the list under <key> in the file's frontmatter, unquoted. -context:frontmatter() { - awk -v key="$2" ' - NR == 1 && $0 != "---" { exit } - NR > 1 && $0 == "---" { exit } - $0 == key ":" { in_key = 1; next } - /^[a-z]+:/ { in_key = 0 } - in_key && /^ - / { item = $0; sub(/^ - /, "", item); gsub(/^"|"$/, "", item); print item } - ' "$1" -} - # context:relative <path> prints <path> relative to the repository root. A relative path is taken from the current # directory. The nearest directory of the path that exists is resolved physically, as git reports the root, so a # path through a symlinked directory, or to a file not yet created, still cuts at the root. @@ -74,7 +64,7 @@ context:rules() { printf '%s\n' "${rule}" continue 2 fi - done < <(context:frontmatter "${rule}" paths) + done < <(yaml:list "${rule}" paths) [[ -n "${first}" ]] || continue while IFS= read -r pattern do @@ -84,7 +74,7 @@ context:rules() { printf '%s\n' "${rule}" continue 2 fi - done < <(context:frontmatter "${rule}" content) + done < <(yaml:list "${rule}" content) done } diff --git a/bin/kit-check.sh b/bin/kit-check.sh index 8f50bdc..aaed143 100755 --- a/bin/kit-check.sh +++ b/bin/kit-check.sh @@ -2,6 +2,9 @@ # kit-check - fail when a delivered file differs from the pinned release (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # +# Every path under the repository is checked. A path under home is checked where ~/.kit/manifest exists, that is, +# on a desk that installed; a machine with none, such as a runner, is told so and the home base is skipped. +# # see: bin/release.sh set -o errexit -o nounset -o pipefail @@ -25,29 +28,41 @@ then error ".kit/manifest records kit ${version}, and the pinned release is $(release:version); run: kit update" findings=$(( findings + 1 )) fi -while IFS=$'\t' read -r path src +# shellcheck disable=SC2154 # release_bases is declared by bin/release.sh, which use loaded above +for base in "${release_bases[@]}" do - if [[ ! -f "${path}" ]] - then - error "${path} is missing" - findings=$(( findings + 1 )) - elif release:merged "${path}" + delivered=$(release:delivered "${kit_root}" "${base}" "${modules[@]}") + [[ -n "${delivered}" ]] || continue + if [[ "${base}" != repo && ! -f "$(release:manifest "${base}")" ]] then - if ! command -v jq >/dev/null 2>&1 + info "${base}: nothing is installed under $(release:base-root "${base}") on this machine, so its paths were not checked" + continue + fi + while IFS=$'\t' read -r path src + do + dest=$(release:dest "${base}" "${path}") + if [[ ! -f "${dest}" ]] then - error "run: mise install (jq is not on PATH, so ${path} could not be read)" + error "${dest} is missing" findings=$(( findings + 1 )) - elif ! release:json-holds "${path}" "${src}" + elif release:merged "${dest}" then - error "${path} is missing an entry the kit writes; run: kit update" + if ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so ${dest} could not be read)" + findings=$(( findings + 1 )) + elif ! release:json-holds "${dest}" "${src}" + then + error "${dest} is missing an entry the kit writes; run: kit update" + findings=$(( findings + 1 )) + fi + elif ! cmp --silent "${src}" "${dest}" + then + error "${dest} differs from the pinned release" findings=$(( findings + 1 )) fi - elif ! cmp --silent "${src}" "${path}" - then - error "${path} differs from the pinned release" - findings=$(( findings + 1 )) - fi -done < <(release:delivered "${kit_root}" "${modules[@]}") + done <<<"${delivered}" +done if (( findings > 0 )) then error "${findings} finding(s). A delivered file is changed by pinning a new release and running kit update, not in place." diff --git a/bin/kit-install.sh b/bin/kit-install.sh index a290581..137e596 100755 --- a/bin/kit-install.sh +++ b/bin/kit-install.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# kit-install - mirror the release into the repository (kit verb) +# kit-install - mirror the release into the repository, and into the home directory (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# Activates the default modules and the named ones, with the modules they depend on, places what they deliver, -# and writes .kit/manifest. +# Activates the default modules and the named ones, with the modules they depend on, places what they deliver +# under each base, and writes the manifests: .kit/manifest, and ~/.kit/manifest when a module delivers to home. # # see: bin/release.sh @@ -29,12 +29,18 @@ read -r -a defaults <<<"$(release:default-modules "${kit_root}" | paste -s -d ' read -r -a modules <<<"$(release:resolve "${kit_root}" "${defaults[@]}" "$@" | paste -s -d ' ' -)" release:render-all "${kit_root}" "${modules[@]}" section "kit install $(release:version), modules: ${modules[*]}" -while IFS=$'\t' read -r path src +# shellcheck disable=SC2154 # release_bases is declared by bin/release.sh, which use loaded above +for base in "${release_bases[@]}" do - step "${path}" - release:place "${src}" "${path}" -done < <(release:delivered "${kit_root}" "${modules[@]}") -release:write-manifest "${modules[@]}" + while IFS=$'\t' read -r path src + do + [[ -n "${path}" ]] || continue + dest=$(release:dest "${base}" "${path}") + step "${dest}" + release:place "${src}" "${dest}" + done < <(release:delivered "${kit_root}" "${base}" "${modules[@]}") +done +release:write-manifests "${modules[@]}" release:finish "${modules[@]}" # ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-module.sh b/bin/kit-module.sh index 0d37281..1fd3556 100755 --- a/bin/kit-module.sh +++ b/bin/kit-module.sh @@ -21,6 +21,7 @@ esac release:require-manifest read -r -a active <<<"$(release:manifest-modules)" +# shellcheck disable=SC2154 # release_bases is declared by bin/release.sh, which use loaded above case "${verb}" in (list) for module in $(release:modules) @@ -29,7 +30,7 @@ case "${verb}" in then mark='✅' else mark=' ' fi - printf '%s %-10s %s\n' "${mark}" "${module}" "$(release:field "${kit_root}" "${module}" description)" + printf '%s %-10s %s\n' "${mark}" "${module}" "$(release:description "${kit_root}" "${module}")" done ;; (add) @@ -39,13 +40,17 @@ case "${verb}" in read -r -a wanted <<<"$(release:resolve "${kit_root}" "${active[@]}" "$@" | paste -s -d ' ' -)" release:render-all "${kit_root}" "${wanted[@]}" section "kit module add $*" - while IFS= read -r path + for base in "${release_bases[@]}" do - [[ -n "${path}" ]] || continue - step "${path}" - release:place "$(release:source-of "${kit_root}" "${path}" "${wanted[@]}")" "${path}" - done < <(release:paths-beyond "${kit_root}" "${active[*]}" "${wanted[*]}") - release:write-manifest "${wanted[@]}" + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + dest=$(release:dest "${base}" "${path}") + step "${dest}" + release:place "$(release:source-of "${kit_root}" "${base}" "${path}" "${wanted[@]}")" "${dest}" + done < <(release:paths-beyond "${kit_root}" "${base}" "${active[*]}" "${wanted[*]}") + done + release:write-manifests "${wanted[@]}" ok "modules: ${wanted[*]}" ;; (remove) @@ -70,12 +75,15 @@ case "${verb}" in done release:render-all "${kit_root}" "${active[@]}" section "kit module remove $*" - while IFS= read -r path + for base in "${release_bases[@]}" do - [[ -n "${path}" ]] || continue - release:unplace "$(release:source-of "${kit_root}" "${path}" "${active[@]}")" "${path}" - done < <(release:paths-beyond "${kit_root}" "${remaining[*]}" "${active[*]}") - release:write-manifest "${remaining[@]}" + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + release:unplace "$(release:source-of "${kit_root}" "${base}" "${path}" "${active[@]}")" "$(release:dest "${base}" "${path}")" + done < <(release:paths-beyond "${kit_root}" "${base}" "${remaining[*]}" "${active[*]}") + done + release:write-manifests "${remaining[@]}" ok "modules: ${remaining[*]:-none}" esac diff --git a/bin/kit-update.sh b/bin/kit-update.sh index d29aed6..87c81c0 100755 --- a/bin/kit-update.sh +++ b/bin/kit-update.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# kit-update - move the repository to the pinned release (kit verb) +# kit-update - move the repository, and the home directory, to the pinned release (kit verb) # ---------------------------------------------------------------------------------------------------------------------- # -# A file whose digest still matches the manifest, which records the previous release's own content, is replaced. +# A file whose digest still matches its manifest, which records the previous release's own content, is replaced. # An edited one is three-way merged with the previous release as the base, which release:fetch finds or fetches. -# A path the previous release did not deliver is written only where the repository has nothing, or the same -# thing, there. A template is rendered from the repository's answers in both releases before either is compared. +# A path the previous release did not deliver is written only where there is nothing, or the same thing, there. +# A template is rendered from the repository's answers in both releases before either is compared. The home base +# is updated against ~/.kit/manifest the same way. # # see: bin/release.sh @@ -44,57 +45,63 @@ needs_base=false [[ "${previous}" != "${current}" ]] && needs_base=true conflicts=0 -while IFS=$'\t' read -r path src +# shellcheck disable=SC2154 # release_bases is declared by bin/release.sh, which use loaded above +for base in "${release_bases[@]}" do - recorded=$(awk -v p="${path}" '$2 == p { print $1 }' .kit/manifest) - if [[ -z "${recorded}" ]] - then - # A path the previous release did not deliver: written where the repository has nothing, or the same thing. - if release:merged "${path}" || [[ ! -f "${path}" ]] + while IFS=$'\t' read -r path src + do + [[ -n "${path}" ]] || continue + dest=$(release:dest "${base}" "${path}") + recorded=$(release:recorded "${base}" "${path}") + if [[ -z "${recorded}" ]] then - step "${path}: new in ${current}, written" - release:place "${src}" "${path}" - elif cmp --silent "${src}" "${path}" + # A path the previous release did not deliver: written where there is nothing, or the same thing. + if release:merged "${dest}" || [[ ! -f "${dest}" ]] + then + step "${dest}: new in ${current}, written" + release:place "${src}" "${dest}" + elif cmp --silent "${src}" "${dest}" + then + step "${dest}: new in ${current}, and already as the release has it" + else + error "${dest} is new in ${current} and a file is there already; move yours aside, then run: kit update" + conflicts=$(( conflicts + 1 )) + fi + continue + fi + previous_src="" + [[ -n "${base_root}" ]] && previous_src=$(release:source-of "${base_root}" "${base}" "${path}" "${modules[@]}") + if [[ "${needs_base}" == true && -z "${previous_src}" ]] then - step "${path}: new in ${current}, and already as the release has it" - else - error "${path} is new in ${current} and the repository has a file there; move yours aside, then run: kit update" + # Without the base, neither a merge nor the removal of the previous release's entries is possible. + error "${dest} cannot be updated without the base release ${previous}; set KIT_BASE to a copy of it" conflicts=$(( conflicts + 1 )) + continue fi - continue - fi - base="" - [[ -n "${base_root}" ]] && base=$(release:source-of "${base_root}" "${path}" "${modules[@]}") - if [[ "${needs_base}" == true && -z "${base}" ]] - then - # Without the base, neither a merge nor the removal of the previous release's entries is possible. - error "${path} cannot be updated without the base release ${previous}; set KIT_BASE to a copy of it" - conflicts=$(( conflicts + 1 )) - continue - fi - if release:merged "${path}" - then - step "${path}: merged by key" - release:place "${src}" "${path}" "${base}" - elif [[ ! -f "${path}" ]] - then - step "${path}: missing, written" - release:place "${src}" "${path}" - elif [[ "${recorded}" == "$(release:digest < "${path}")" ]] - then - step "${path}: unchanged since ${previous}, replaced" - release:place "${src}" "${path}" - else - step "${path}: edited since ${previous}, three-way merged" - if ! git merge-file --marker-size 7 "${path}" "${base}" "${src}" + if release:merged "${dest}" then - warn "${path}: conflict markers are left in the file; resolve them, then run: kit check" - conflicts=$(( conflicts + 1 )) + step "${dest}: merged by key" + release:place "${src}" "${dest}" "${previous_src}" + elif [[ ! -f "${dest}" ]] + then + step "${dest}: missing, written" + release:place "${src}" "${dest}" + elif [[ "${recorded}" == "$(release:digest < "${dest}")" ]] + then + step "${dest}: unchanged since ${previous}, replaced" + release:place "${src}" "${dest}" + else + step "${dest}: edited since ${previous}, three-way merged" + if ! git merge-file --marker-size 7 "${dest}" "${previous_src}" "${src}" + then + warn "${dest}: conflict markers are left in the file; resolve them, then run: kit check" + conflicts=$(( conflicts + 1 )) + fi fi - fi -done < <(release:delivered "${kit_root}" "${modules[@]}") + done < <(release:delivered "${kit_root}" "${base}" "${modules[@]}") +done -release:write-manifest "${modules[@]}" +release:write-manifests "${modules[@]}" if (( conflicts > 0 )) then error "${conflicts} file(s) need a hand; see above." diff --git a/bin/release.sh b/bin/release.sh index 9f2647d..9a9045e 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -4,14 +4,44 @@ # release.sh - the release's modules, the one merge rule, the templates, and the manifest (sourced module) # ---------------------------------------------------------------------------------------------------------------------- # -# A release is bin/ and modules/. Each modules/<name>/ is a module: a `module` file of metadata, a tree/ mirrored -# at the repository root, and templates/ with @key@ rendered from the repository's answers, then placed the same way. A +# A release is bin/ and modules/. Each modules/<name>/ is a module: module.yml, its metadata; files/<base>/, +# mirrored at the base's root; and templates/<base>/, with @key@ rendered from the repository's answers, then +# mirrored the same way. Two bases: repo, the repository root, recorded in .kit/manifest; and home, the user's +# home directory, recorded in ~/.kit/manifest, which is the desk's record rather than the repository's. A # destination ending in .json is merged by key, with the kit's keys set, the kit's array entries added, and on # update or removal only the previous release's entries taken out. .kit/manifest records the version, the active # modules, and the release's digest for every delivered path. docs/modules.md is the format's description. # # Every function reads a release from a root, kit_root by default, which bin/kit exports. +use yaml + +# release_bases are the destinations a module may deliver to, as the subdirectories of files/ and templates/. +declare -gra release_bases=(repo home) + +# release:base-root <base> prints the directory the base's files are mirrored at: the repository root, which the +# verbs run from, or the home directory, KIT_HOME when set so a test can stand in for it. +release:base-root() { + case "$1" in + (repo) printf '.\n' ;; + (home) printf '%s\n' "${KIT_HOME:-${HOME}}" ;; + (*) + error "'$1' is not a base; the bases are: ${release_bases[*]}" + return 2 + esac +} + +# release:dest <base> <path> prints where a delivered path lands. +release:dest() { + if [[ "$1" == repo ]] + then printf '%s\n' "$2" + else printf '%s/%s\n' "$(release:base-root "$1")" "$2" + fi +} + +# release:manifest <base> prints the base's manifest: .kit/manifest in the repository, ~/.kit/manifest for home. +release:manifest() { release:dest "$1" .kit/manifest; } + # A release carries a version file the release workflow stamps from the tag; a checkout carries none and is # "unreleased", the one version kit update never asks mise for. declare -gr release_unreleased=unreleased @@ -21,22 +51,26 @@ release:version() { cat "${kit_root}/version" 2>/dev/null || printf '%s\n' "${release_unreleased}" } -# release:modules [<root>] prints the modules the release carries. +# release:modules [<root>] prints the modules the release carries: every modules/<name>/ with a module.yml. release:modules() { - (cd "${1:-${kit_root}}/modules" && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@' | sort) + (cd "${1:-${kit_root}}/modules" && find . -mindepth 2 -maxdepth 2 -name module.yml | sed 's@^\./@@; s@/module.yml$@@' | sort) } -# release:field <root> <module> <keyword> prints the value of every <keyword> line in the module's metadata. -release:field() { - sed -n "s/^$3 //p; s/^$3\$//p" "$1/modules/$2/module" 2>/dev/null || true -} +# release:meta <root> <module> prints the path of the module's metadata. +release:meta() { printf '%s/modules/%s/module.yml\n' "$1" "$2"; } + +# release:description <root> <module> prints the module's one-line description. +release:description() { yaml:value "$(release:meta "$1" "$2")" description; } + +# release:depends <root> <module> prints the modules this one depends on, one per line. +release:depends() { yaml:list "$(release:meta "$1" "$2")" depends; } -# release:default-modules <root> prints the modules active unless removed. +# release:default-modules <root> prints the modules active unless removed: those whose default is true. release:default-modules() { local module for module in $(release:modules "$1") do - grep --quiet --line-regexp default "$1/modules/${module}/module" && printf '%s\n' "${module}" + [[ "$(yaml:value "$(release:meta "$1" "${module}")" default)" == true ]] && printf '%s\n' "${module}" done return 0 } @@ -47,7 +81,7 @@ release:require-modules() { shift for module in "$@" do - if [[ ! -f "${root}/modules/${module}/module" ]] + if [[ ! -f "$(release:meta "${root}" "${module}")" ]] then error "'${module}' is not a module this release knows; the modules are: $(release:modules "${root}" | paste -s -d ' ' -)" return 2 @@ -69,7 +103,7 @@ release:resolve() { queue=("${queue[@]:1}") [[ -n "${wanted[${module}]:-}" ]] && continue wanted[${module}]=1 - for dep in $(release:field "${root}" "${module}" depends) + for dep in $(release:depends "${root}" "${module}") do queue+=("${dep}") done @@ -81,7 +115,7 @@ release:resolve() { for module in "${names[@]}" do [[ -n "${placed[${module}]:-}" ]] && continue - for dep in $(release:field "${root}" "${module}" depends) + for dep in $(release:depends "${root}" "${module}") do [[ -n "${placed[${dep}]:-}" ]] || continue 2 done @@ -105,7 +139,7 @@ release:dependents() { shift 2 for other in "$@" do - for dep in $(release:field "${root}" "${other}" depends) + for dep in $(release:depends "${root}" "${other}") do [[ "${dep}" == "${module}" ]] && printf '%s\n' "${other}" done @@ -113,10 +147,10 @@ release:dependents() { } # release:answers <root> <module> prints "<key>\t<value>" for every answer the module's templates may use: the -# module's defaults, each overridden by a "<key> <value>" line in the repository's .kit/answers. +# module's defaults under answers:, each overridden by a "<key> <value>" line in the repository's .kit/answers. release:answers() { { - release:field "$1" "$2" answer + yaml:map "$(release:meta "$1" "$2")" answers | tr '\t' ' ' [[ -f .kit/answers ]] && grep --invert-match '^#' .kit/answers true } | awk 'NF >= 2 { v = $0; sub(/^[^ ]+ +/, "", v); a[$1] = v } END { for (k in a) print k "\t" a[k] }' @@ -133,14 +167,14 @@ release:tmp() { printf '%s\n' "${release_tmp}" } -# release:render <root> <module> renders the module's templates once per process and prints the directory holding -# them, or prints nothing for a module with no templates. A key is written @key@, autoconf's form, which neither -# bash's ${var} nor GitHub's ${{ }} can be mistaken for; one with no answer refuses, naming the key. +# release:render <root> <module> <base> renders the module's templates for <base> once per process and prints the +# directory holding them, or prints nothing where the module has none. A key is written @key@, autoconf's form, +# which neither bash's ${var} nor GitHub's ${{ }} can be mistaken for; one with no answer refuses, naming the key. release:render() { - local root=$1 module=$2 templates="$1/modules/$2/templates" out key value text file rel + local root=$1 module=$2 base=$3 templates="$1/modules/$2/templates/$3" out key value text file rel local -A answer=() [[ -d "${templates}" ]] || return 0 - out="$(release:tmp)/$(printf '%s' "${root}" | release:digest)/${module}" + out="$(release:tmp)/$(printf '%s' "${root}" | release:digest)/${module}/${base}" if [[ ! -d "${out}" ]] then while IFS=$'\t' read -r key value @@ -167,33 +201,48 @@ release:render() { printf '%s\n' "${out}" } -# release:render-all <root> <module>… renders every named module's templates, refusing on the first unanswered key. +# release:render-all <root> <module>… renders every named module's templates for every base, and refuses a base +# the kit does not know, or an unanswered key, naming it. release:render-all() { - local root=$1 module + local root=$1 module kind base shift for module in "$@" do - release:render "${root}" "${module}" >/dev/null || return 1 + for kind in files templates + do + for base in $(cd "${root}/modules/${module}/${kind}" 2>/dev/null && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@') + do + if ! release:among "${base}" "${release_bases[@]}" + then + error "modules/${module}/${kind}/${base} names a base the kit does not deliver to; the bases are: ${release_bases[*]}" + return 1 + fi + done + done + for base in "${release_bases[@]}" + do + release:render "${root}" "${module}" "${base}" >/dev/null || return 1 + done done } -# release:layers <root> <module>… prints the directories mirrored at the repository root, in module order: each -# module's tree/, then its rendered templates. +# release:layers <root> <base> <module>… prints the directories mirrored at the base's root, in module order: each +# module's files/<base>, then its rendered templates/<base>. release:layers() { - local root=$1 module rendered - shift + local root=$1 base=$2 module rendered + shift 2 for module in "$@" do - [[ -d "${root}/modules/${module}/tree" ]] && printf '%s\n' "${root}/modules/${module}/tree" - rendered=$(release:render "${root}" "${module}") + [[ -d "${root}/modules/${module}/files/${base}" ]] && printf '%s\n' "${root}/modules/${module}/files/${base}" + rendered=$(release:render "${root}" "${module}" "${base}") [[ -n "${rendered}" ]] && printf '%s\n' "${rendered}" done return 0 } -# release:delivered <root> <module>… prints "<path>\t<source>" for every repository path the release at <root> -# delivers for those modules, sorted by path, the last layer winning. A symlink is a source only when it points -# at a file; a dangling one is refused here, where it is found. +# release:delivered <root> <base> <module>… prints "<path>\t<source>" for every path under the base's root that +# the release at <root> delivers for those modules, sorted by path, the last layer winning. A symlink is a source +# only when it points at a file; a dangling one is refused here, where it is found. release:delivered() { local layer path src while IFS= read -r layer @@ -211,21 +260,22 @@ release:delivered() { done } -# release:source-of <root> <path> <module>… prints the release file behind one repository path, or nothing. +# release:source-of <root> <base> <path> <module>… prints the release file behind one delivered path, or nothing. release:source-of() { - local root=$1 path=$2 - shift 2 - release:delivered "${root}" "$@" | awk -F '\t' -v p="${path}" '$1 == p { print $2 }' + local root=$1 base=$2 path=$3 + shift 3 + release:delivered "${root}" "${base}" "$@" | awk -F '\t' -v p="${path}" '$1 == p { print $2 }' } -# release:paths-beyond <root> <before> <after> prints the paths the module set <after> delivers that <before> -# does not; each set is one space-separated word. +# release:paths-beyond <root> <base> <before> <after> prints the paths the module set <after> delivers under the +# base that <before> does not; each set is one space-separated word. release:paths-beyond() { - local root=$1 + local root=$1 base=$2 local -a before after - read -r -a before <<<"$2" - read -r -a after <<<"$3" - comm -13 <(release:delivered "${root}" "${before[@]}" | cut -f 1) <(release:delivered "${root}" "${after[@]}" | cut -f 1) + read -r -a before <<<"$3" + read -r -a after <<<"$4" + comm -13 <(release:delivered "${root}" "${base}" "${before[@]}" | cut -f 1) \ + <(release:delivered "${root}" "${base}" "${after[@]}" | cut -f 1) } release:merged() { [[ "$1" == *.json ]]; } @@ -366,24 +416,51 @@ release:unplace() { fi } -# release:write-manifest <module>… records the version, the active modules, and the release's digest for every -# delivered path. +# release:write-manifest <base> <module>… records, in the base's manifest, the version, the active modules, and +# the release's digest for every path delivered under the base. The repository's is always written; home's only +# when something is delivered there, and it is removed when nothing is any more. release:write-manifest() { - local path src + local base=$1 manifest path src delivered + shift + manifest=$(release:manifest "${base}") + delivered=$(release:delivered "${kit_root}" "${base}" "$@") + if [[ "${base}" != repo && -z "${delivered}" ]] + then + rm -f "${manifest}" + return 0 + fi + mkdir -p "$(dirname "${manifest}")" { printf '# .kit/manifest - written by kit, not by hand\n' printf 'version %s\n' "$(release:version)" printf 'modules%s\n' "${*:+ $*}" while IFS=$'\t' read -r path src do - printf '%s %s\n' "$(release:digest < "${src}")" "${path}" - done < <(release:delivered "${kit_root}" "$@") - } > .kit/manifest + [[ -n "${path}" ]] && printf '%s %s\n' "$(release:digest < "${src}")" "${path}" + done <<<"${delivered}" + } > "${manifest}" +} + +# release:write-manifests <module>… writes every base's manifest. +release:write-manifests() { + local base + for base in "${release_bases[@]}" + do + release:write-manifest "${base}" "$@" + done } release:manifest-version() { sed -n 's/^version //p' .kit/manifest; } release:manifest-modules() { sed -n 's/^modules *//p' .kit/manifest; } +# release:recorded <base> <path> prints the digest the base's manifest records for <path>, or nothing. +release:recorded() { + local manifest + manifest=$(release:manifest "$1") + [[ -f "${manifest}" ]] || return 0 + awk -v p="$2" '$2 == p { print $1 }' "${manifest}" +} + # release:require-manifest refuses to go on in a repository that never took the kit. release:require-manifest() { if [[ ! -f .kit/manifest ]] diff --git a/bin/yaml.sh b/bin/yaml.sh new file mode 100644 index 0000000..d93f6fa --- /dev/null +++ b/bin/yaml.sh @@ -0,0 +1,50 @@ +# shellcheck shell=bash +# yaml.sh - the subset of YAML the kit's own files are written in (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Two kinds of file are read here: a module's module.yml, and the frontmatter between the opening and closing `---` +# of a rule file. Both are written in the same small subset, and one reader serves both so they cannot drift: +# +# key: value a scalar, quoted or not +# key: a list, one ` - item` per line +# - item +# key: a map, one ` name: value` per line +# name: value +# +# A line starting with # is a comment. Nothing deeper is read, and a file that needs more is a file the kit does +# not write. When the file opens with `---`, only the frontmatter up to the next `---` is read. + +# yaml:value <file> <key> prints the scalar under <key>, unquoted, or nothing. +yaml:value() { + yaml:read "$1" | awk -v key="$2" ' + $0 ~ "^" key ": " { v = $0; sub("^" key ": *", "", v); gsub(/^"|"$/, "", v); print v; exit }' +} + +# yaml:list <file> <key> prints the items of the list under <key>, unquoted, one per line. +yaml:list() { + yaml:read "$1" | awk -v key="$2" ' + $0 == key ":" { on = 1; next } + /^[A-Za-z]/ { on = 0 } + on && /^ - / { v = $0; sub(/^ - /, "", v); gsub(/^"|"$/, "", v); print v }' +} + +# yaml:map <file> <key> prints "<name>\t<value>" for every entry of the map under <key>, values unquoted. +yaml:map() { + yaml:read "$1" | awk -v key="$2" ' + $0 == key ":" { on = 1; next } + /^[A-Za-z]/ { on = 0 } + on && /^ [A-Za-z_]+: / { n = $0; sub(/^ /, "", n); sub(/: .*$/, "", n); v = $0; sub(/^ [A-Za-z_]+: */, "", v); + gsub(/^"|"$/, "", v); print n "\t" v }' +} + +# yaml:read <file> prints the file's YAML: the frontmatter when the file opens with ---, the whole file otherwise. +yaml:read() { + [[ -f "$1" ]] || return 0 + awk ' + NR == 1 && $0 == "---" { front = 1; next } + front && $0 == "---" { exit } + /^#/ { next } + { print }' "$1" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/docs/modules.md b/docs/modules.md index 5da9de1..0b7c553 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -7,39 +7,63 @@ modules it wants, and `kit install`, `kit update`, and `kit check` work over the ```text modules/<name>/ - module metadata, one keyword per line - tree/ files mirrored at the repository root - templates/ files rendered from the repository's answers, then mirrored the same way - docs/ the module's documentation, for a reader of this repository; not delivered + module.yml metadata + files/<base>/ files mirrored at the base's root + templates/<base>/ files rendered from the repository's answers, then mirrored the same way + docs/ the module's documentation, for a reader of this repository; not delivered ``` -A module may have any of `tree/`, `templates/`, and `docs/`, and must have `module`. +A module may have any of `files/`, `templates/`, and `docs/`, and must have `module.yml`. A base names where a +file lands: + +| Base | Root | Record | +| --- | --- | --- | +| `repo` | the repository root | `.kit/manifest`, committed with the repository | +| `home` | the user's home directory, `~` | `~/.kit/manifest`, the desk's own; a runner has none | + +So `files/home/.kit/<name>` is a user-level setting, delivered once per desk by whichever repository installs or +updates last. `kit check` holds home paths to the release where `~/.kit/manifest` exists and says so where it does +not, since a runner installs nothing there. A module naming any other base is refused at install with the known +bases listed. `KIT_HOME` stands in for the home directory, which is how the tests keep out of the real one. ## Metadata -The `module` file is lines of one keyword each. A line starting with `#` is a comment. +`module.yml` is the small subset of YAML `bin/yaml.sh` reads: scalars, lists of `- item`, and one level of map. +A line starting with `#` is a comment. + +```yaml +description: One CI job: the repository's gate, kit check, and the linked-open-issue refusal +default: true +depends: + - kit +answers: + ci_runner: ubuntu-latest +``` -| Keyword | Meaning | +| Key | Meaning | | --- | --- | -| `description <text>` | One line, shown by `kit module list`. | -| `default` | Active unless removed. `kit install` with no names activates every default module. | -| `depends <name>…` | Activated with this module. `kit module remove` refuses a module another active module depends on. | -| `answer <key> <value>` | The default value of a template key. A repository overrides it in `.kit/answers`. | +| `description` | One line, shown by `kit module list`. | +| `default` | `true` for a module active unless removed. `kit install` with no names activates every default module. | +| `depends` | Modules activated with this one. `kit module remove` refuses a module another active module depends on. | +| `answers` | The default value of each template key. A repository overrides one in `.kit/answers`. | ## Delivery -A file under `tree/` is placed at the same path under the repository root. A destination ending in `.json` is +A file under `files/<base>/` is placed at the same path under the base's root. A destination ending in `.json` is merged by key: the kit's keys are set, its array entries added, and on update or removal only the previous release's entries are taken out, so a repository's own keys and entries survive. Every other destination is a copy. Where two active modules deliver the same path, the later module in dependency order wins. -A file under `templates/` is rendered first. Every `@key@` in it, autoconf's form, is replaced by the key's +A file under `templates/<base>/` is rendered first. Every `@key@` in it, autoconf's form, is replaced by the key's answer, then the result is placed, digested, and merged exactly as a `tree/` file is. The form is one neither bash's `${var}` nor GitHub's `${{ }}` can be mistaken for, so a template may carry both untouched. The repository's answers live in `.kit/answers`, a file of `key value` lines the repository owns and the kit never writes; a key with no line there takes the module's `answer` default, and a key with neither refuses the install, naming the key and the module. +The same file is also what a rule's frontmatter is written in, read by the same functions, so a rule's `paths:` and +`content:` lists and a module's `depends:` list have one reader between them. + ## Activation - `kit install [<module>…]` activates the default modules and the named ones, with their dependencies, and records @@ -56,6 +80,9 @@ and the module. | `kit` | yes | | `.kit/1st.md`, `.kit/rules/<kind>.md` | | `lefthook` | yes | `kit` | `.kit/lefthook.yml` | | `ci` | yes | `kit` | `.github/workflows/kit-ci.yml`, from a template; answer `ci_runner` | -| `claude` | no | `kit` | `.claude/settings.json`, merged: the deny list and the `kit context --claude` hook | +| `claude` | no | `kit` | `.claude/CLAUDE.md` importing `AGENTS.md`; `.claude/settings.json`, merged: the deny list and the `kit context --claude` hook | + +No module delivers to `home` yet. A module's `files/repo/` and `templates/repo/` carry the paths above; its +`docs/README.md` says why. Each module's `docs/README.md` says what it places and why. diff --git a/modules/ci/module b/modules/ci/module deleted file mode 100644 index 9c3302f..0000000 --- a/modules/ci/module +++ /dev/null @@ -1,8 +0,0 @@ -# modules/ci/module - the ci module: one CI job, the gate, kit check, and the linked-open-issue refusal -# ---------------------------------------------------------------------------------------------------------------------- -# One keyword per line. docs/modules.md says what each keyword means. - -description One CI job: the repository's gate, kit check, and the refusal of a pull request without an open issue -default -depends kit -answer ci_runner ubuntu-latest diff --git a/modules/ci/module.yml b/modules/ci/module.yml new file mode 100644 index 0000000..6e90e90 --- /dev/null +++ b/modules/ci/module.yml @@ -0,0 +1,10 @@ +# modules/ci/module.yml - the ci module: one CI job, the gate, kit check, and the linked-open-issue refusal +# ---------------------------------------------------------------------------------------------------------------------- +# docs/modules.md says what each key means. + +description: "One CI job: the repository's gate, kit check, and the refusal of a pull request without an open issue" +default: true +depends: + - kit +answers: + ci_runner: ubuntu-latest diff --git a/modules/ci/templates/.github/workflows/kit-ci.yml b/modules/ci/templates/repo/.github/workflows/kit-ci.yml similarity index 100% rename from modules/ci/templates/.github/workflows/kit-ci.yml rename to modules/ci/templates/repo/.github/workflows/kit-ci.yml diff --git a/modules/claude/docs/README.md b/modules/claude/docs/README.md index f6052df..c8be779 100644 --- a/modules/claude/docs/README.md +++ b/modules/claude/docs/README.md @@ -1,6 +1,9 @@ # The claude module -The adapter for Claude Code. It merges into `.claude/settings.json`, by key, leaving the repository's own entries: +The adapter for Claude Code. Claude Code reads `CLAUDE.md` and not `AGENTS.md`, so the module places +`.claude/CLAUDE.md`, two lines, the second `@../AGENTS.md`: Claude then reads what every other agent reads, and a +root `CLAUDE.md` of the repository's own stays its own. It also merges into `.claude/settings.json`, by key, +leaving the repository's own entries: - `permissions.deny`: the seven spellings the rules forbid an agent, such as `git rebase` and `git push --force`, which `kit rebase` and the loop replace. @@ -8,3 +11,6 @@ The adapter for Claude Code. It merges into `.claude/settings.json`, by key, lea the file before the agent, described in [docs/context.md](../../../docs/context.md). It copies no rule. `.claude/rules/` is the repository's own. + +Why not `@.kit/1st.md` in `AGENTS.md`: that is Claude's import syntax in a file every harness reads, and Claude, +having inlined the file, would then be told to read it again by the sentence beside it. diff --git a/modules/claude/files/repo/.claude/CLAUDE.md b/modules/claude/files/repo/.claude/CLAUDE.md new file mode 100644 index 0000000..819bb57 --- /dev/null +++ b/modules/claude/files/repo/.claude/CLAUDE.md @@ -0,0 +1,2 @@ +<!-- Delivered by kit, not edited by hand. A change is a new release, then kit update. --> +@../AGENTS.md diff --git a/modules/claude/tree/.claude/settings.json b/modules/claude/files/repo/.claude/settings.json similarity index 100% rename from modules/claude/tree/.claude/settings.json rename to modules/claude/files/repo/.claude/settings.json diff --git a/modules/claude/module b/modules/claude/module deleted file mode 100644 index 13eda3e..0000000 --- a/modules/claude/module +++ /dev/null @@ -1,6 +0,0 @@ -# modules/claude/module - the claude module: Claude Code's deny list and its hook into kit context -# ---------------------------------------------------------------------------------------------------------------------- -# One keyword per line. docs/modules.md says what each keyword means. - -description Claude Code: the deny list for the spellings the rules forbid, and the hook that puts a file's rules before the agent -depends kit diff --git a/modules/claude/module.yml b/modules/claude/module.yml new file mode 100644 index 0000000..ec279bb --- /dev/null +++ b/modules/claude/module.yml @@ -0,0 +1,7 @@ +# modules/claude/module.yml - the claude module: Claude Code's deny list and its hook into kit context +# ---------------------------------------------------------------------------------------------------------------------- +# docs/modules.md says what each key means. + +description: "Claude Code: the deny list for the spellings the rules forbid, and the hook that names a file's rules before a write" +depends: + - kit diff --git a/modules/kit/tree/.kit/1st.md b/modules/kit/files/repo/.kit/1st.md similarity index 95% rename from modules/kit/tree/.kit/1st.md rename to modules/kit/files/repo/.kit/1st.md index 0d4fc49..ccb0fc6 100644 --- a/modules/kit/tree/.kit/1st.md +++ b/modules/kit/files/repo/.kit/1st.md @@ -97,8 +97,9 @@ it writes; everything else is copied as it is. `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `claude`: for Claude Code, seven deny rules merged into `.claude/settings.json`, and a hook that runs - `kit context` before every write so the rules reach the agent without being asked for. +- `claude`: for Claude Code, which reads `CLAUDE.md` and not `AGENTS.md`: `.claude/CLAUDE.md` importing + `AGENTS.md`, seven deny rules merged into `.claude/settings.json`, and a hook that runs `kit context` before + every write so the rules are named to the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/modules/kit/tree/.kit/rules/bash.md b/modules/kit/files/repo/.kit/rules/bash.md similarity index 100% rename from modules/kit/tree/.kit/rules/bash.md rename to modules/kit/files/repo/.kit/rules/bash.md diff --git a/modules/kit/tree/.kit/rules/gitignore.md b/modules/kit/files/repo/.kit/rules/gitignore.md similarity index 100% rename from modules/kit/tree/.kit/rules/gitignore.md rename to modules/kit/files/repo/.kit/rules/gitignore.md diff --git a/modules/kit/tree/.kit/rules/json.md b/modules/kit/files/repo/.kit/rules/json.md similarity index 100% rename from modules/kit/tree/.kit/rules/json.md rename to modules/kit/files/repo/.kit/rules/json.md diff --git a/modules/kit/tree/.kit/rules/just.md b/modules/kit/files/repo/.kit/rules/just.md similarity index 100% rename from modules/kit/tree/.kit/rules/just.md rename to modules/kit/files/repo/.kit/rules/just.md diff --git a/modules/kit/tree/.kit/rules/markdown.md b/modules/kit/files/repo/.kit/rules/markdown.md similarity index 100% rename from modules/kit/tree/.kit/rules/markdown.md rename to modules/kit/files/repo/.kit/rules/markdown.md diff --git a/modules/kit/tree/.kit/rules/yaml.md b/modules/kit/files/repo/.kit/rules/yaml.md similarity index 100% rename from modules/kit/tree/.kit/rules/yaml.md rename to modules/kit/files/repo/.kit/rules/yaml.md diff --git a/modules/kit/module b/modules/kit/module deleted file mode 100644 index ed1b74c..0000000 --- a/modules/kit/module +++ /dev/null @@ -1,6 +0,0 @@ -# modules/kit/module - the kit module: the entry every agent reads, the rules by file kind, and kit context -# ---------------------------------------------------------------------------------------------------------------------- -# One keyword per line. docs/modules.md says what each keyword means. - -description The entry every agent reads, the rules by file kind, and the context call that names them -default diff --git a/modules/kit/module.yml b/modules/kit/module.yml new file mode 100644 index 0000000..d183e78 --- /dev/null +++ b/modules/kit/module.yml @@ -0,0 +1,6 @@ +# modules/kit/module.yml - the kit module: the entry every agent reads, the rules by file kind, and kit context +# ---------------------------------------------------------------------------------------------------------------------- +# docs/modules.md says what each key means. + +description: "The entry every agent reads, the rules by file kind, and the context call that names them" +default: true diff --git a/modules/lefthook/tree/.kit/lefthook.yml b/modules/lefthook/files/repo/.kit/lefthook.yml similarity index 100% rename from modules/lefthook/tree/.kit/lefthook.yml rename to modules/lefthook/files/repo/.kit/lefthook.yml diff --git a/modules/lefthook/module b/modules/lefthook/module deleted file mode 100644 index f7d56b1..0000000 --- a/modules/lefthook/module +++ /dev/null @@ -1,7 +0,0 @@ -# modules/lefthook/module - the lefthook module: which linter runs over which paths before a commit -# ---------------------------------------------------------------------------------------------------------------------- -# One keyword per line. docs/modules.md says what each keyword means. - -description Which linter runs over which paths before a commit, for the repository's lefthook.yml to extend -default -depends kit diff --git a/modules/lefthook/module.yml b/modules/lefthook/module.yml new file mode 100644 index 0000000..3cc9b6b --- /dev/null +++ b/modules/lefthook/module.yml @@ -0,0 +1,8 @@ +# modules/lefthook/module.yml - the lefthook module: which linter runs over which paths before a commit +# ---------------------------------------------------------------------------------------------------------------------- +# docs/modules.md says what each key means. + +description: "Which linter runs over which paths before a commit, for the repository's lefthook.yml to extend" +default: true +depends: + - kit diff --git a/tests/context.bats b/tests/context.bats index 268c40d..5cd955c 100644 --- a/tests/context.bats +++ b/tests/context.bats @@ -4,6 +4,8 @@ setup() { kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" + mkdir -p "${KIT_HOME}" repo=$(mktemp -d) git -C "${repo}" init --quiet --initial-branch=main cd "${repo}" diff --git a/tests/kit.bats b/tests/kit.bats index eebf5fc..2d92f65 100644 --- a/tests/kit.bats +++ b/tests/kit.bats @@ -9,11 +9,14 @@ setup() { kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) repo=$(mktemp -d) git -C "${repo}" init --quiet --initial-branch=main + # a home directory of the test's own, so no test ever writes under the real one + export KIT_HOME="${repo}.home" + mkdir "${KIT_HOME}" cd "${repo}" } teardown() { - rm -rf "${repo}" "${repo}.later" + rm -rf "${repo}" "${repo}.later" "${repo}.home" } # later-release <dir> copies this checkout's release to <dir> as version 9.9.9 with one changed rule line. @@ -21,7 +24,7 @@ later-release() { mkdir -p "$1" cp -R "${kit_checkout}/bin" "${kit_checkout}/modules" "$1/" printf '9.9.9\n' > "$1/version" - printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/modules/kit/tree/.kit/rules/json.md" + printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/modules/kit/files/repo/.kit/rules/json.md" } @test "install with no module writes the default modules and nothing under .claude" { @@ -49,6 +52,7 @@ later-release() { grep --quiet '^modules kit ci claude lefthook$' .kit/manifest jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null jq --exit-status '.hooks.PreToolUse[0].hooks[0].command == "kit context --claude"' .claude/settings.json >/dev/null + [ "$(tail -n 1 .claude/CLAUDE.md)" = "@../AGENTS.md" ] [ ! -e .claude/rules ] } @@ -74,6 +78,7 @@ later-release() { [ "$status" -eq 0 ] run "${kit_checkout}/bin/kit" module remove claude [ "$status" -eq 0 ] + [ ! -e .claude/CLAUDE.md ] [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] jq --exit-status '.permissions.deny == ["Bash(rm -rf /)"]' .claude/settings.json >/dev/null jq --exit-status '.hooks == {"PreToolUse": []}' .claude/settings.json >/dev/null @@ -103,13 +108,53 @@ later-release() { run "${kit_checkout}/bin/kit" check [ "$status" -eq 0 ] later-release "${repo}.later" - printf 'runner @ci_runner@ owner @owner@\n' > "${repo}.later/modules/ci/templates/OWNER" + printf 'runner @ci_runner@ owner @owner@\n' > "${repo}.later/modules/ci/templates/repo/OWNER" run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] [[ "$output" == *"OWNER needs an answer for 'owner'"* ]] [ ! -e OWNER ] } +@test "a module delivering to a base the kit does not know is refused, naming the bases" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/kit/files/etc" && printf 'x\n' > "${repo}.later/modules/kit/files/etc/.x" + run "${repo}.later/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"modules/kit/files/etc names a base the kit does not deliver to; the bases are: repo home"* ]] + [ ! -e .kit/manifest ] +} + +@test "a module delivering to home lands under it with a manifest of its own, and leaves with it" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/claude/files/home/.kit" && printf 'user level\n' > "${repo}.later/modules/claude/files/home/.kit/claude" + run "${repo}.later/bin/kit" install claude + [ "$status" -eq 0 ] + [ "$(cat "${KIT_HOME}/.kit/claude")" = "user level" ] + grep --quiet '^version 9.9.9$' "${KIT_HOME}/.kit/manifest" + grep --quiet ' .kit/claude$' "${KIT_HOME}/.kit/manifest" + grep --quiet --invert-match 'claude$' .kit/manifest + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] + printf 'edited\n' >> "${KIT_HOME}/.kit/claude" + run "${repo}.later/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"/.kit/claude differs"* ]] + run "${repo}.later/bin/kit" module remove claude + [ "$status" -eq 0 ] + [ ! -e "${KIT_HOME}/.kit/claude" ] + [ ! -e "${KIT_HOME}/.kit/manifest" ] +} + +@test "check skips the home base where nothing was installed there, and says so" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/kit/files/home/.kit" && printf 'x\n' > "${repo}.later/modules/kit/files/home/.kit/x" + "${repo}.later/bin/kit" install + rm -rf "${KIT_HOME}/.kit" + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] + [[ "$output" == *"home: nothing is installed under ${KIT_HOME} on this machine"* ]] +} + @test "install keeps the repository's own settings keys and deny entries" { mkdir .claude printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json @@ -158,7 +203,7 @@ later-release() { grep --quiet '^# JSON (ours)$' .kit/rules/json.md grep --quiet '^## Added in 9.9.9$' .kit/rules/json.md # the manifest carries the release's digest, so the edit still reads as an edit - release_digest=$(shasum -a 256 "${repo}.later/modules/kit/tree/.kit/rules/json.md" | cut -d ' ' -f 1) + release_digest=$(shasum -a 256 "${repo}.later/modules/kit/files/repo/.kit/rules/json.md" | cut -d ' ' -f 1) grep --quiet "^${release_digest} .kit/rules/json.md$" .kit/manifest # and check names the one file that still differs from the release run "${repo}.later/bin/kit" check @@ -170,7 +215,7 @@ later-release() { @test "update refuses a path new in the release where the repository already has a different file" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" - printf 'new rule\n' > "${repo}.later/modules/kit/tree/.kit/rules/toml.md" + printf 'new rule\n' > "${repo}.later/modules/kit/files/repo/.kit/rules/toml.md" printf 'mine\n' > .kit/rules/toml.md run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] @@ -190,7 +235,7 @@ later-release() { @test "update leaves conflict markers and fails when the same lines changed" { "${kit_checkout}/bin/kit" install claude later-release "${repo}.later" - sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${repo}.later/modules/kit/tree/.kit/rules/json.md" && rm "${repo}.later/modules/kit/tree/.kit/rules/json.md.bak" + sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${repo}.later/modules/kit/files/repo/.kit/rules/json.md" && rm "${repo}.later/modules/kit/files/repo/.kit/rules/json.md.bak" sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update [ "$status" -eq 1 ] diff --git a/tests/release.bats b/tests/release.bats index 2469254..6529eb4 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -4,6 +4,8 @@ setup() { kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" + mkdir -p "${KIT_HOME}" work=$(mktemp -d) cd "${work}" } @@ -24,7 +26,7 @@ teardown() { [ "$(ls unpacked)" = "kit-${version}" ] [ -x "unpacked/kit-${version}/bin/kit" ] [ -x "unpacked/kit-${version}/bin/kit-install.sh" ] - [ -f "unpacked/kit-${version}/modules/claude/module" ] + [ -f "unpacked/kit-${version}/modules/claude/module.yml" ] mkdir fresh && git -C fresh init --quiet --initial-branch=main cd fresh run "${work}/unpacked/kit-${version}/bin/kit" install claude From 598b6977d2121a423eb6ab078e807e267ce5def8 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 01:34:04 -0700 Subject: [PATCH 067/108] #4 documentation: Claude Code reads AGENTS.md as a conditional fallback, which is why the adapter imports it Since 2.1.277 Claude Code reads AGENTS.md when no CLAUDE.md, .claude/CLAUDE.md, or CLAUDE.local.md is present and its configuration allows. The claude module's .claude/CLAUDE.md makes AGENTS.md reach Claude in every case; the text said Claude did not read it at all, which was wrong. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .kit/1st.md | 6 +++--- .kit/manifest | 2 +- README.md | 15 ++++++++------- modules/claude/docs/README.md | 10 ++++++---- modules/kit/files/repo/.kit/1st.md | 6 +++--- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.kit/1st.md b/.kit/1st.md index ccb0fc6..0852482 100644 --- a/.kit/1st.md +++ b/.kit/1st.md @@ -97,9 +97,9 @@ it writes; everything else is copied as it is. `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `claude`: for Claude Code, which reads `CLAUDE.md` and not `AGENTS.md`: `.claude/CLAUDE.md` importing - `AGENTS.md`, seven deny rules merged into `.claude/settings.json`, and a hook that runs `kit context` before - every write so the rules are named to the agent without being asked for. +- `claude`: for Claude Code, which reads `AGENTS.md` only when it finds no `CLAUDE.md`: `.claude/CLAUDE.md` + importing `AGENTS.md`, so it is read in every case; seven deny rules merged into `.claude/settings.json`; and a + hook that runs `kit context` before every write so the rules are named to the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. diff --git a/.kit/manifest b/.kit/manifest index 4ad7cd4..c58e693 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -4,7 +4,7 @@ modules kit ci claude lefthook 930488befed8ab56355d33f55032b5e7bc6d57a1c0ee867b51d9495cdec9f3cd .claude/CLAUDE.md 86563533cd11f87831320d57b24d68d83c8d3a47382b09afeb71f5ee46e93584 .claude/settings.json cd66dc3b7fe79643f0a706e0ea571962906ffd117743c68e23aadbab7c91e143 .github/workflows/kit-ci.yml -37df46c8bbe5a02b972412f0fea25246e14ab66cb0421c8cb23857c3fdb94189 .kit/1st.md +6a4818d08c59d04d47533ca22cd1da112050fb07a7c604e684d1431ceca73ae3 .kit/1st.md 953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml 6f1101c9b19bfbe2d42d6e602e92d0df4dc650a4a04ccbd06c5a03a5e6e5edc2 .kit/rules/bash.md 0eedef778e1cdae63c731eec7a08055820726192006fba2148be7421535a22d5 .kit/rules/gitignore.md diff --git a/README.md b/README.md index 9c6aa6d..0cd7189 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,14 @@ repository trustworthy. ``` The rest of `AGENTS.md` is yours. Everything the kit delivers is a module, and `install` activates the default ones, -`kit`, `lefthook`, and `ci`, plus those named: `claude` is the adapter for Claude Code, which reads `CLAUDE.md` and -not `AGENTS.md`, so the module places `.claude/CLAUDE.md` importing `AGENTS.md` and Claude reads what everyone else -reads. A repository with no adapter still binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` -recipe in the root justfile for the delivered `CI` job to run, and take the pre-commit linters by pinning `lefthook`, -putting `extends: [.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit module -list`, `kit module add`, and `kit module remove` show and change the set later, and [docs/modules.md](docs/modules.md) -describes a module. An agent asks `kit context <path>` which rules govern a file before it creates or changes it; +`kit`, `lefthook`, and `ci`, plus those named: `claude` is the adapter for Claude Code, which reads `AGENTS.md` only +as a fallback, when no `CLAUDE.md` is present and its configuration allows, so the module places `.claude/CLAUDE.md` +importing `AGENTS.md` and Claude reads what everyone else reads in every case. A repository with no adapter still +binds every agent that reads `AGENTS.md`. Commit what it wrote, keep a `ci` recipe in the root justfile for the +delivered `CI` job to run, and take the pre-commit linters by pinning `lefthook`, putting `extends: +[.kit/lefthook.yml]` in `lefthook.yml`, and running `lefthook install` once per clone. `kit module list`, `kit module +add`, and `kit module remove` show and change the set later, and [docs/modules.md](docs/modules.md) describes a +module. An agent asks `kit context <path>` which rules govern a file before it creates or changes it; [docs/context.md](docs/context.md) describes the call and the Claude Code hook that makes it for the agent. To move to a new release, bump the pin or reinstall, then run `kit update`: a delivered file you left alone is diff --git a/modules/claude/docs/README.md b/modules/claude/docs/README.md index c8be779..cb6ae19 100644 --- a/modules/claude/docs/README.md +++ b/modules/claude/docs/README.md @@ -1,9 +1,11 @@ # The claude module -The adapter for Claude Code. Claude Code reads `CLAUDE.md` and not `AGENTS.md`, so the module places -`.claude/CLAUDE.md`, two lines, the second `@../AGENTS.md`: Claude then reads what every other agent reads, and a -root `CLAUDE.md` of the repository's own stays its own. It also merges into `.claude/settings.json`, by key, -leaving the repository's own entries: +The adapter for Claude Code. Since 2.1.277, Claude Code reads `AGENTS.md` as a fallback: only when no `CLAUDE.md`, +`.claude/CLAUDE.md`, or `CLAUDE.local.md` is present in the working directory or above it, and only where its +configuration allows. A repository with a `CLAUDE.md` of its own would have `AGENTS.md` skipped. So the module +places `.claude/CLAUDE.md`, two lines, the second `@../AGENTS.md`: Claude then reads what every other agent reads +in every case, and a root `CLAUDE.md` of the repository's own stays its own. It also merges into +`.claude/settings.json`, by key, leaving the repository's own entries: - `permissions.deny`: the seven spellings the rules forbid an agent, such as `git rebase` and `git push --force`, which `kit rebase` and the loop replace. diff --git a/modules/kit/files/repo/.kit/1st.md b/modules/kit/files/repo/.kit/1st.md index ccb0fc6..0852482 100644 --- a/modules/kit/files/repo/.kit/1st.md +++ b/modules/kit/files/repo/.kit/1st.md @@ -97,9 +97,9 @@ it writes; everything else is copied as it is. `lefthook.yml` takes it with `extends` and adds commands of its own, and `kit lint` runs the same hook by hand. - `ci`: `.github/workflows/kit-ci.yml`, one `CI` job that runs the repository's `just ci`, runs `kit check`, and refuses a pull request not linked to an open issue. -- `claude`: for Claude Code, which reads `CLAUDE.md` and not `AGENTS.md`: `.claude/CLAUDE.md` importing - `AGENTS.md`, seven deny rules merged into `.claude/settings.json`, and a hook that runs `kit context` before - every write so the rules are named to the agent without being asked for. +- `claude`: for Claude Code, which reads `AGENTS.md` only when it finds no `CLAUDE.md`: `.claude/CLAUDE.md` + importing `AGENTS.md`, so it is read in every case; seven deny rules merged into `.claude/settings.json`; and a + hook that runs `kit context` before every write so the rules are named to the agent without being asked for. `kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new release and running `kit update`, never by editing them in place. From 0aeeb60d9885ba302aa95cce3e5fd68b2624afb6 Mon Sep 17 00:00:00 2001 From: Jeffrey Aguilera <jeffrey.aguilera@me.com> Date: Sun, 25 Oct 2026 01:44:05 -0700 Subject: [PATCH 068/108] #4 docs/architecture.md, a schema for module.yml, and a CI workflow of one-liners over kit gate and kit linked docs/architecture.md draws the whole in three SVG figures: delivery from a release into a repository and a home, context discovery with the Claude hook, and the loop with its gate; and closes with a table of what verifies what. modules/module.schema.json is the schema for module.yml, JSON Schema 2020-12. The gate validates every module.yml against it with ajv, pinned through mise, and the kit holds every module a release carries to the same contract before it installs, updates, or checks: an unknown key, a default that is not true or false, or a dependency the release lacks is refused, naming it. The delivered workflow's steps are one kit command each. kit gate runs the root justfile's ci recipe and reports a missing one as a skip, never a pass; kit linked refuses a pull request not linked to an open issue of the repository, from the argument, the runner's event, or the current branch. Both are verbs a desk can run. The template's key is quoted, since a YAML scalar cannot open with @. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/kit-ci.yml | 120 ++--------- .kit/manifest | 2 +- .mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml | 202 ++++++++++++++++++ .mise/locks/npm-ajv-cli/5.0.0/package.json | 7 + README.md | 2 + bin/1st.sh | 6 + bin/gh.sh | 53 +++++ bin/kit | 2 + bin/kit-gate.sh | 42 ++++ bin/kit-linked.sh | 37 ++++ bin/release.sh | 45 +++- bin/yaml.sh | 5 + docs/architecture.md | 75 +++++++ docs/context.svg | 85 ++++++++ docs/delivery.svg | 100 +++++++++ docs/loop.svg | 95 ++++++++ docs/modules.md | 5 + justfile | 6 +- mise.lock | 6 + mise.toml | 1 + modules/ci/docs/README.md | 6 +- .../repo/.github/workflows/kit-ci.yml | 120 ++--------- modules/module.schema.json | 40 ++++ tests/gate.bats | 55 +++++ tests/kit.bats | 17 +- 25 files changed, 909 insertions(+), 225 deletions(-) create mode 100644 .mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml create mode 100644 .mise/locks/npm-ajv-cli/5.0.0/package.json create mode 100755 bin/kit-gate.sh create mode 100755 bin/kit-linked.sh create mode 100644 docs/architecture.md create mode 100644 docs/context.svg create mode 100644 docs/delivery.svg create mode 100644 docs/loop.svg create mode 100644 modules/module.schema.json create mode 100644 tests/gate.bats diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml index 72b648a..a835c42 100644 --- a/.github/workflows/kit-ci.yml +++ b/.github/workflows/kit-ci.yml @@ -1,10 +1,11 @@ # kit-ci.yml - the check the kit delivers: the repository's gate, the kit's own check, and the issue link # ---------------------------------------------------------------------------------------------------------------------- # -# One job, CI, which is the check name a ruleset requires. Three steps: the repository's `just ci` when its -# root justfile has one, `kit check` with the kit mise pins, and a refusal of a pull request not linked to an -# open issue of this repository under Development. The last two run even when an earlier step refused, so one -# run reports every finding. +# One job, CI, which is the check name a ruleset requires. Each step is one kit command, so what the runner does +# is what a desk can do: `kit gate` runs the root justfile's ci recipe and reports a missing one as a skip, +# `kit check` holds every delivered file to the pinned release, and `kit linked` refuses a pull request not linked +# to an open issue of this repository under Development. The last two run even when an earlier step refused, so +# one run reports every finding. # # Delivered by kit, not edited by hand. A change is a new release, then kit update. Rendered from a template: the # job runs on the ci_runner answer, ubuntu-latest unless .kit/answers says otherwise. @@ -25,7 +26,7 @@ permissions: jobs: CI: if: github.event.pull_request.base.ref == github.event.repository.default_branch - runs-on: ubuntu-latest + runs-on: "ubuntu-latest" env: # mise reads mise.ci.toml on top of mise.toml: the tools only a runner runs. MISE_ENV: ci @@ -35,117 +36,20 @@ jobs: # Every tool below comes from mise.toml, the kit included; this repository's mise.toml puts bin/ on PATH. - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: kit-on-path + run: command -v kit >/dev/null || { echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml'; exit 1; } + - name: gate - run: | - set -o errexit -o nounset -o pipefail - # A missing recipe is a skip; a justfile that cannot be read is a failure, never a silent skip. - if [[ ! -f justfile ]] - then - echo "::warning::there is no root justfile, so the gate was skipped" - printf '⚠️ The gate was skipped: there is no root justfile.\n' | tee --append "${GITHUB_STEP_SUMMARY}" - elif ! recipes=$(just --summary 2>&1) - then - echo "::error::the root justfile could not be read: ${recipes}" - printf '### ❌ The root justfile could not be read\n\n%s\n' "${recipes}" | tee --append "${GITHUB_STEP_SUMMARY}" - exit 1 - elif ! tr ' ' '\n' <<< "${recipes}" | grep --quiet --line-regexp ci - then - echo "::warning::the root justfile has no ci recipe, so the gate was skipped" - printf '⚠️ The gate was skipped: the root justfile has no ci recipe.\n' | tee --append "${GITHUB_STEP_SUMMARY}" - else - just ci - printf '✅ just ci passed.\n' | tee --append "${GITHUB_STEP_SUMMARY}" - fi + run: kit gate - name: kit-check if: ${{ !cancelled() }} - run: | - set -o errexit -o nounset -o pipefail - if ! command -v kit >/dev/null 2>&1 - then - echo '::error::kit is not on PATH; pin "github:empowerite/kit" in mise.toml' - printf '### ❌ kit is not on PATH\n\nPin the release in mise.toml under [tools]: "github:empowerite/kit" = "<version>".\n' \ - | tee --append "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - kit check - printf '✅ Every delivered file matches the pinned kit release.\n' | tee --append "${GITHUB_STEP_SUMMARY}" + run: kit check - name: linked-open-issue if: ${{ !cancelled() }} env: GH_TOKEN: ${{ github.token }} - run: | - set -o errexit -o nounset -o pipefail - - repo_slug="${GITHUB_REPOSITORY}" - pr=$(jq --raw-output '.pull_request.number' "${GITHUB_EVENT_PATH}") - pull="${GITHUB_SERVER_URL}/${repo_slug}/pull/${pr}" - run="${GITHUB_SERVER_URL}/${repo_slug}/actions/runs/${GITHUB_RUN_ID}" - - remedy=$(cat <<REMEDY - Link an open issue of this repository under **Development** in [the sidebar](${pull}): - - 1. click the gear (⚙) beside **Development** - 2. pick one or more open issues - 3. [re-run this check](${run}): linking fires no event, so it does not re-run on its own - - \`kit issue <N>\` makes this link when it starts the work. - REMEDY - ) - - refuse() { - echo "::error::$1" - printf '### ❌ %s\n\n%s\n' "${1^}" "${remedy}" | tee --append "${GITHUB_STEP_SUMMARY}" - return 1 - } - - query=$(cat <<'GRAPHQL' - query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - closingIssuesReferences(first: 100) { - nodes { number state repository { nameWithOwner } } - } - } - } - } - GRAPHQL - ) - refs=$(gh api graphql \ - --field owner="${repo_slug%%/*}" \ - --field name="${repo_slug#*/}" \ - --field number="${pr}" \ - --raw-field query="${query}" \ - --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] - | "\(.number) \(.state) \(.repository.nameWithOwner)"') - - if [[ -z "${refs}" ]] - then - refuse 'this pull request is not linked to any issue' - else - open_local=0 - while read -r n state repo - do - if [[ "${repo}" != "${repo_slug}" ]] - then - echo "::warning::skipping ${repo}#${n}: it belongs to another repository" - continue - fi - if [[ "${state}" != OPEN ]] - then - echo "::warning::${repo}#${n} is ${state}; merging will not change it" - continue - fi - open_local=$(( open_local + 1 )) - done <<< "${refs}" - if (( open_local == 0 )) - then - refuse 'every issue this pull request is linked to is closed or belongs to another repository' - else - closes=$(cut --delimiter=' ' --fields=1 <<< "${refs}" | sed 's/^/#/' | paste --serial --delimiters=' ') - printf '✅ Linked to %s.\n' "${closes}" | tee --append "${GITHUB_STEP_SUMMARY}" - fi - fi + run: kit linked # ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/manifest b/.kit/manifest index c58e693..82a69d5 100644 --- a/.kit/manifest +++ b/.kit/manifest @@ -3,7 +3,7 @@ version unreleased modules kit ci claude lefthook 930488befed8ab56355d33f55032b5e7bc6d57a1c0ee867b51d9495cdec9f3cd .claude/CLAUDE.md 86563533cd11f87831320d57b24d68d83c8d3a47382b09afeb71f5ee46e93584 .claude/settings.json -cd66dc3b7fe79643f0a706e0ea571962906ffd117743c68e23aadbab7c91e143 .github/workflows/kit-ci.yml +972303cda70ea028bc266e64a56dc44bbb1689ba11e4023fd88173bbc11670fb .github/workflows/kit-ci.yml 6a4818d08c59d04d47533ca22cd1da112050fb07a7c604e684d1431ceca73ae3 .kit/1st.md 953e2f7c9804ca39922ca1ccb3f9b136b0283d56a200dcebfbbce594e9102faa .kit/lefthook.yml 6f1101c9b19bfbe2d42d6e602e92d0df4dc650a4a04ccbd06c5a03a5e6e5edc2 .kit/rules/bash.md diff --git a/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml b/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml new file mode 100644 index 0000000..0e702f7 --- /dev/null +++ b/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml @@ -0,0 +1,202 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ajv-cli: + specifier: 5.0.0 + version: 5.0.0 + +packages: + + ajv-cli@5.0.0: + resolution: {integrity: sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==} + hasBin: true + peerDependencies: + ts-node: '>=9.0.0' + peerDependenciesMeta: + ts-node: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-patch@2.2.1: + resolution: {integrity: sha512-4j5uBaTnsYAV5ebkidvxiLUYOwjQ+JSFljeqfTxCrH9bDmlCQaOJFS84oDJ2rAXZq2yskmk3ORfoP9DCwqFNig==} + engines: {node: '>= 0.4.0'} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + + json-schema-migrate@2.0.0: + resolution: {integrity: sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + ajv-cli@5.0.0: + dependencies: + ajv: 8.20.0 + fast-json-patch: 2.2.1 + glob: 7.2.3 + js-yaml: 3.15.2 + json-schema-migrate: 2.0.0 + json5: 2.2.3 + minimist: 1.2.8 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + balanced-match@1.0.2: {} + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + concat-map@0.0.1: {} + + esprima@4.0.1: {} + + fast-deep-equal@2.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-patch@2.2.1: + dependencies: + fast-deep-equal: 2.0.1 + + fast-uri@3.1.9: {} + + fs.realpath@1.0.0: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + json-schema-migrate@2.0.0: + dependencies: + ajv: 8.20.0 + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimist@1.2.8: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-is-absolute@1.0.1: {} + + require-from-string@2.0.2: {} + + sprintf-js@1.0.3: {} + + wrappy@1.0.2: {} diff --git a/.mise/locks/npm-ajv-cli/5.0.0/package.json b/.mise/locks/npm-ajv-cli/5.0.0/package.json new file mode 100644 index 0000000..3203299 --- /dev/null +++ b/.mise/locks/npm-ajv-cli/5.0.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "ajv-cli": "5.0.0" + } +} diff --git a/README.md b/README.md index 0cd7189..f9a6e77 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ is what a consumer activating that module gets, its `files/repo/` mirrored at th `bin/kit-<verb>.sh` per verb, each sourcing the bash modules beside it; and `docs/` with each module's `docs/` is the documentation. [docs/modules.md](docs/modules.md) is the module format. +[docs/architecture.md](docs/architecture.md) is the shape of the whole, with its figures. + The installed copies are written by `bin/kit install` and not edited by hand: | Installed copy | Written from | diff --git a/bin/1st.sh b/bin/1st.sh index 3425cad..2976763 100644 --- a/bin/1st.sh +++ b/bin/1st.sh @@ -70,6 +70,12 @@ error() { return 0 } +# summary <line>… appends lines to the runner's step summary, where there is one, and is silent elsewhere. +summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 + printf '%s\n' "$@" >> "${GITHUB_STEP_SUMMARY}" +} + # guard:require <predicate>… returns non-zero on the first predicate that does not hold, with the remedy. # gh gh is usably authenticated (a real API call, since a revoked token still passes gh auth status) # signing-key git user.signingkey is set diff --git a/bin/gh.sh b/bin/gh.sh index 3ced0f9..68177f0 100644 --- a/bin/gh.sh +++ b/bin/gh.sh @@ -186,6 +186,59 @@ gh:link-secondaries() { fi } +# gh:linked-open-issue <number> refuses pull request <number> unless it is linked, under Development, to an issue +# of this repository that is open. A linked issue of another repository, or a closed one, is noted and does not +# count. On a runner the remedy also lands in the step summary. +gh:linked-open-issue() { + local number=$1 repo query refs n state issue_repo open=0 closes url remedy + repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner) + url=$(gh repo view --json url --jq .url) + remedy=$(printf '%s\n' \ + "Link an open issue of this repository under **Development** in [the sidebar](${url}/pull/${number}):" "" \ + "1. click the gear (⚙) beside **Development**" \ + "2. pick one or more open issues" \ + "3. re-run the check: linking fires no event, so it does not re-run on its own" "" \ + "\`kit issue <N>\` makes this link when it starts the work.") + # shellcheck disable=SC2016 # GraphQL variables + query='query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100) { nodes { number state repository { nameWithOwner } } } + } + } + }' + refs=$(gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --field number="${number}" \ + --raw-field query="${query}" \ + --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] | "\(.number) \(.state) \(.repository.nameWithOwner)"') + if [[ -z "${refs}" ]] + then + error "pull request #${number} is not linked to any issue" + summary "### ❌ Pull request #${number} is not linked to any issue" "" "${remedy}" + return 1 + fi + while read -r n state issue_repo + do + if [[ "${issue_repo}" != "${repo}" ]] + then + warn "skipping ${issue_repo}#${n}: it belongs to another repository" + elif [[ "${state}" != OPEN ]] + then + warn "#${n} is ${state}; merging will not change it" + else + open=$(( open + 1 )) + fi + done <<<"${refs}" + if (( open == 0 )) + then + error "every issue pull request #${number} is linked to is closed or belongs to another repository" + summary "### ❌ Every linked issue is closed or belongs to another repository" "" "${remedy}" + return 1 + fi + closes=$(cut -d ' ' -f 1 <<<"${refs}" | sed 's/^/#/' | paste -s -d ' ' -) + ok "pull request #${number} is linked to ${closes}" + summary "✅ Linked to ${closes}." +} + gh:issue() { use git local me n primary title default_branch main_root desk desk_branch branch wt stash="" pr diff --git a/bin/kit b/bin/kit index 4a22f8c..e4ab597 100755 --- a/bin/kit +++ b/bin/kit @@ -31,6 +31,8 @@ usage: kit <verb> [<argument>…] module <list|add|remove> [<module>…] the modules this repository takes context <path>… name the rules that govern a file, and record the ask + gate run the root justfile's ci recipe; a missing one is a skip, said so + linked [<number>] refuse a pull request not linked to an open issue of this repository issue [<number>…] start or resume an issue: linked branch, worktree, start commit, draft pull request rebase [<path>] rebase a worktree's branch onto the default branch and push it; never the desk tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches diff --git a/bin/kit-gate.sh b/bin/kit-gate.sh new file mode 100755 index 0000000..5858004 --- /dev/null +++ b/bin/kit-gate.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# kit-gate - run the repository's gate, the root justfile's ci recipe, and say plainly when there is none (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# The delivered CI job's first step. A missing justfile or a justfile with no ci recipe is a skip, reported as a +# skip, never as a pass; a justfile that cannot be read is a failure. On a runner, each outcome also lands in the +# step summary. +# +# see: modules/ci/templates/repo/.github/workflows/kit-ci.yml + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit gate" >&2 + exit 0 +fi + +if [[ ! -f justfile ]] +then + warn "there is no root justfile, so the gate was skipped" + summary "⚠️ The gate was skipped: there is no root justfile." + exit 0 +fi +if ! recipes=$(just --summary 2>&1) +then + error "the root justfile could not be read: ${recipes}" + summary "### ❌ The root justfile could not be read" "" "${recipes}" + exit 1 +fi +if ! tr ' ' '\n' <<<"${recipes}" | grep --quiet --line-regexp ci +then + warn "the root justfile has no ci recipe, so the gate was skipped" + summary "⚠️ The gate was skipped: the root justfile has no ci recipe." + exit 0 +fi +just ci +summary "✅ just ci passed." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-linked.sh b/bin/kit-linked.sh new file mode 100755 index 0000000..10c9330 --- /dev/null +++ b/bin/kit-linked.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# kit-linked - refuse a pull request not linked to an open issue of this repository (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# The delivered CI job's last step, and a check a desk can run on any pull request. The pull request is the +# argument, else the one the GitHub event names on a runner, else the one for the current branch. +# +# see: bin/gh.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use gh + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit linked [<pull-request>]" >&2 + exit 0 +fi + +number=${1:-} +if [[ -z "${number}" && -n "${GITHUB_EVENT_PATH:-}" ]] +then + number=$(jq --raw-output '.pull_request.number // empty' "${GITHUB_EVENT_PATH}") +fi +if [[ -z "${number}" ]] +then + number=$(gh pr view --json number --jq .number 2>/dev/null || true) +fi +if [[ ! "${number}" =~ ^[0-9]+$ ]] +then + error "name the pull request: kit linked <number> (none was given, and the current branch has none)" + exit 2 +fi +gh:linked-open-issue "${number}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/release.sh b/bin/release.sh index 9a9045e..c2cd00b 100644 --- a/bin/release.sh +++ b/bin/release.sh @@ -201,11 +201,52 @@ release:render() { printf '%s\n' "${out}" } -# release:render-all <root> <module>… renders every named module's templates for every base, and refuses a base -# the kit does not know, or an unanswered key, naming it. +# release:validate <root> <module> refuses metadata the schema would: a key the kit does not know, a default that +# is not true or false, a dependency on a module the release lacks. The gate validates against +# modules/module.schema.json with a real validator; this is the same contract where no validator is, at install. +release:validate() { + local root=$1 module=$2 meta key value dep + meta=$(release:meta "${root}" "${module}") + for key in $(yaml:keys "${meta}") + do + case "${key}" in + (description | default | depends | answers) ;; + (*) + error "modules/${module}/module.yml has a key the kit does not know: ${key} (the schema is modules/module.schema.json)" + return 1 + esac + done + if [[ -z "$(yaml:value "${meta}" description)" ]] + then + error "modules/${module}/module.yml has no description" + return 1 + fi + value=$(yaml:value "${meta}" default) + if [[ -n "${value}" && "${value}" != true && "${value}" != false ]] + then + error "modules/${module}/module.yml: default must be true or false, not '${value}'" + return 1 + fi + for dep in $(release:depends "${root}" "${module}") + do + if [[ ! -f "$(release:meta "${root}" "${dep}")" ]] + then + error "modules/${module}/module.yml depends on '${dep}', which this release does not carry" + return 1 + fi + done +} + +# release:render-all <root> <module>… validates the metadata of every module the release carries, active or not, +# since a release with a broken module is a broken release; then renders the named modules' templates for every +# base, refusing a base the kit does not know, or an unanswered key, naming it. release:render-all() { local root=$1 module kind base shift + for module in $(release:modules "${root}") + do + release:validate "${root}" "${module}" || return 1 + done for module in "$@" do for kind in files templates diff --git a/bin/yaml.sh b/bin/yaml.sh index d93f6fa..f57052f 100644 --- a/bin/yaml.sh +++ b/bin/yaml.sh @@ -37,6 +37,11 @@ yaml:map() { gsub(/^"|"$/, "", v); print n "\t" v }' } +# yaml:keys <file> prints the top-level keys, one per line. +yaml:keys() { + yaml:read "$1" | awk '/^[A-Za-z_]+:/ { k = $0; sub(/:.*$/, "", k); print k }' +} + # yaml:read <file> prints the file's YAML: the frontmatter when the file opens with ---, the whole file otherwise. yaml:read() { [[ -f "$1" ]] || return 0 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c33375a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# Architecture + +The kit is a release that a repository installs, a set of verbs an agent and a person both run, and the checks +that hold the work to the rules. This page is the shape of it: what a release is, how it reaches a repository, how +a file's rules are found, and how a change moves from an issue to `main`. [modules.md](modules.md) and +[context.md](context.md) are the two mechanisms in detail. + +## The release and its delivery + +![Delivery: a release's modules, through the verbs, into a repository and a home](delivery.svg) + +A release is one tarball, `kit-<version>.tar.gz`, and its layout is the checkout's: `bin/` is the machinery, and +`modules/` is everything delivered. `bin/kit` is the command; each verb is an executable `bin/kit-<verb>.sh` +beside it, and the verbs share bash modules loaded with `use`. The release workflow stamps the tag's version into +a `version` file; a checkout has none and is `unreleased`. + +A module is a directory under `modules/`: `module.yml` names it, says whether it is active by default, which +modules it depends on, and the default answers for its templates; `files/<base>/` is mirrored at the base's root; +`templates/<base>/` is rendered first, every `@key@` replaced by an answer; `docs/` is for a reader of this +repository and is never delivered. Two bases exist: `repo`, the repository root, and `home`, the user's `~`. + +The verbs are the only way content moves. `kit install` resolves the active set, default modules plus those named +plus their dependencies, renders, places, and writes the manifests. `kit update` moves to a new pinned release: +an untouched file is replaced, an edited one three-way merged with the previous release as the base. `kit check` +fails when a delivered file differs from the release. `kit module` changes the active set. Two rules govern +placement: a `.json` destination is merged by key, the kit owning only the entries it writes; everything else is a +copy. Each base has a manifest, `.kit/manifest` committed with the repository and `~/.kit/manifest` on the desk, +recording the version, the active modules, and the release's digest of every delivered path, which is how an edit +reads as an edit at every later update. + +## Finding a file's rules + +![Context: how any agent, and Claude Code's hook, find the rules that govern a file](context.svg) + +A rule is written once, in `.kit/rules/<kind>.md`, and copied into no harness's directory. Its frontmatter says +what it governs: `paths:`, globs on the repository-relative path, and `content:`, patterns on the first line of a +file that exists. `kit context <path>` names the rules governing a path and appends the ask to +`.git/kit/context.log`. Every caller gets the same answer: an agent of any harness following the sentence in +`.kit/1st.md`, a person at the shell, a tool reading `--json`, and Claude Code's `PreToolUse` hook, which the +`claude` module wires to `kit context --claude` so the rules are named before every write. The call names and +never quotes: reading the rule stays an act the caller takes, which is what lets a later check compare the log of +asks against the reads. + +## The loop and the gate + +![The loop: an issue becomes a worktree and a draft pull request, the gate holds it, a human merges](loop.svg) + +The desk is the root checkout and belongs to the human; it usually has `main` checked out and may have any branch +no worktree holds, and no agent changes it in place. Work starts from an issue: `kit issue <N>` makes +`worktrees/<N>-<slug>`, a branch linked to the issue under Development, a signed start commit, and a draft pull +request, and sweeps uncommitted work off the desk into the worktree. Everything after that happens in the worktree: +`kit context` before a change, `kit lint` for the pre-commit linters by hand, `kit rebase` to bring the branch onto +`origin/main` and push it, and `gh pr ready` when the pull request body is the report. + +The gate is the delivered CI job, three steps that are each one kit command. `kit gate` runs the root justfile's +`ci` recipe and reports a missing one as a skip, never a pass. `kit check` holds the delivered files to the +release. `kit linked` refuses a pull request not linked to an open issue of the repository. A human merges, and +`kit tidy` removes the worktree and the branch afterwards. `kit status` draws the whole picture: the desk, every +worktree with its commits past the fork, and every open pull request with its state, age, and checks. + +## What verifies what + +| Claim | Held by | +| --- | --- | +| A delivered file is the release's | `kit check`, in the gate and on a desk, against the manifest's digests | +| A change has an issue | `kit linked` in the gate; `kit issue` makes the link when it starts the work | +| The repository's own gate ran | `kit gate`, which reports a skip as a skip | +| A rule's text reached the agent | the Claude Code hook; the plain call for every other harness | +| A file's rules were asked for | `.git/kit/context.log`, one line per ask | +| The forbidden spellings were not used | the harness's deny list, delivered by its adapter module | +| The desk was not disturbed | `kit rebase` refusing the root checkout; `kit issue` never switching it | + +What no check holds yet is whether a rule, once read, was followed. The linters hold the mechanical part of that +for the kinds they cover; the rest is the pull request body's report, which the integrity rules in `.kit/1st.md` +bind. diff --git a/docs/context.svg b/docs/context.svg new file mode 100644 index 0000000..a5e83e1 --- /dev/null +++ b/docs/context.svg @@ -0,0 +1,85 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1040 440" width="1040" height="440" role="img" + aria-labelledby="title desc"> + <title id="title">Context: how any agent, and Claude Code's hook, find the rules that govern a file + An agent or a person runs kit context with a path. The kit matches the path and the file's first + line against each rule's frontmatter, names the governing rules, and appends the ask to the context log. Claude + Code's PreToolUse hook makes the same call before every write. + + + + + + + + + + Any agent, any harness + Before creating or changing a file, + as .kit/1st.md says: + kit context <path>… + + + A person + The same call, by hand or in a + script, or with --json for a tool. + kit context --json <path>… + + + Claude Code + PreToolUse on Write, Edit, MultiEdit, + wired by the claude module: + kit context --claude + + + + kit context + For each path, every rule file whose + frontmatter matches: + paths: + globs on the repository path + content: + patterns on the first line + Then the ask is appended to the log. + Nothing is copied; the rule is named, + and reading it stays the caller's act. + + + + The rules, once + .kit/rules/<kind>.md + --- + paths: ["**/*.sh"] + content: ["^#!.*/(env +)?(ba)?sh( |$)"] + --- + + + + The record + .git/kit/context.log + epoch and path per ask: the evidence a check reads + + + + The answer + rule paths, one per line; as JSON; or as the + hook's additionalContext naming them + + + + + + + reads + + appends + + prints + diff --git a/docs/delivery.svg b/docs/delivery.svg new file mode 100644 index 0000000..68d4d39 --- /dev/null +++ b/docs/delivery.svg @@ -0,0 +1,100 @@ + + Delivery: a release's modules, through the kit's verbs, into a repository and a home + The release carries bin/ and modules/. The verbs render templates, merge JSON by key, and copy the + rest into the repository root and the user's home, each recorded in its own manifest. + + + + + + + + + + Release + kit-<version>/ + + + bin/ + kit + the command + kit-<verb>.sh + one per verb + release.sh context.sh … + sourced modules + + + modules/<name>/ + module.yml + description, default, + depends, answers + files/repo/ + mirrored at the root + files/home/ + mirrored at ~ + templates/<base>/ + @key@ rendered first + docs/ + not delivered + kit, lefthook, ci are default; + claude is named; each may depend + on others, resolved at install. + version + stamped by the release workflow + + + + The verbs + kit install [<module>…] + kit update + kit check + kit module list|add|remove + + resolve the active set and its depends + render @key@ from .kit/answers + merge a .json destination by key + copy everything else + digest each path into the manifest + three-way merge an edited file on update + + + + Repository root + base: repo + .kit/1st.md + .kit/rules/<kind>.md + .kit/lefthook.yml + .github/workflows/kit-ci.yml + .claude/CLAUDE.md + .claude/settings.json + merged + + .kit/manifest + version, modules, digests + .kit/answers + yours, never written + AGENTS.md + yours; names .kit/1st.md + + + + Home + base: home, the user's ~ + ~/.kit/manifest + the desk's record, not the repository's; + a runner has none, and kit check says so. + No module delivers here yet. + + + + + + diff --git a/docs/loop.svg b/docs/loop.svg new file mode 100644 index 0000000..ce8d423 --- /dev/null +++ b/docs/loop.svg @@ -0,0 +1,95 @@ + + The loop: an issue becomes a worktree and a draft pull request, the gate holds it, a human merges + The desk is the root checkout and stays the human's. kit issue makes a worktree per issue with a + linked branch and a draft pull request. Work happens there under kit context, kit lint, and kit rebase. The + delivered CI job runs the gate, kit check, and the linked-open-issue refusal. A human merges, and kit tidy sweeps. + kit status draws the whole picture. + + + + + + + + + + The desk + The root checkout, the human's. Usually on main, + sometimes on a branch no worktree holds. No agent + checks out, resets, or rebases it. + kit status + draws desk, worktrees, pull requests + + + + An issue + A change without one has not started. + A design is prose in its body. + + + + The worktree + kit issue <N> + worktrees/<N>-<slug>, a branch linked under + Development, a start commit, a draft pull request. + + Work happens here, and only here: + kit context <path> + before a change + kit lint + the hook by hand + kit rebase + onto origin/main, pushed + gh pr ready + the body is the report + Signed commits, subject #<N> <area>: <subject>. + + + + The gate + The delivered CI job, on every pull request: + just ci + the repository's own gate + kit check + every delivered file matches the release + linked-open-issue + refused without an open issue of this + repository under Development + On a desk, the pre-commit hook runs the + linters over what is staged. + + + + A human merges + Nothing reaches main any other way, and + nothing is called merged until it is observed. + kit tidy + removes the worktree and its branch + + + + The harness adapter + Deny list: git rebase, push --force, reset --hard, + checkout main, worktree remove --force. + Hook: kit context before every write. + Nothing copied from the rules. + + + + kit issue + + push + + green, reviewed + + main advances; the desk pulls + diff --git a/docs/modules.md b/docs/modules.md index 0b7c553..0fe0fcd 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -47,6 +47,11 @@ answers: | `depends` | Modules activated with this one. `kit module remove` refuses a module another active module depends on. | | `answers` | The default value of each template key. A repository overrides one in `.kit/answers`. | +[module.schema.json](../modules/module.schema.json) is the schema, JSON Schema 2020-12. The gate validates every +`module.yml` against it with `ajv`, and `kit install` holds a module to the same contract where no validator is: +a key it does not know, a `default` that is not `true` or `false`, or a dependency the release lacks is refused, +naming it. + ## Delivery A file under `files//` is placed at the same path under the base's root. A destination ending in `.json` is diff --git a/justfile b/justfile index b271160..84f9c04 100644 --- a/justfile +++ b/justfile @@ -21,18 +21,20 @@ _: help set export := true # The gate: shellcheck over every bash file, actionlint over every workflow, markdownlint over every markdown -# file, the bats suite, and bin/kit check over this repository's installed copy of its own kit. The runner runs +# file, every module.yml against modules/module.schema.json, the bats suite, and bin/kit check over this +# repository's installed copy of its own kit. The runner runs # it on every pull request, and a desk runs it the same way; the pre-commit hook is the narrower, faster check # over the staged paths. # # just ci -[doc("🚦 The gate: shellcheck, actionlint, markdownlint, bats, kit check")] +[doc("🚦 The gate: shellcheck, actionlint, markdownlint, the module schema, bats, kit check")] ci: #!/usr/bin/env bash set -o errexit -o nounset -o pipefail mise exec -- shellcheck --shell=bash --external-sources bin/kit bin/*.sh mise exec -- actionlint mise exec -- markdownlint-cli2 '**/*.md' + mise exec -- ajv validate --spec=draft2020 -s modules/module.schema.json -d 'modules/*/module.yml' mise exec -- bats --print-output-on-failure tests/ bin/kit check echo "✅ ci passed" diff --git a/mise.lock b/mise.lock index 937bbd2..87f18eb 100644 --- a/mise.lock +++ b/mise.lock @@ -169,6 +169,12 @@ checksum = "sha256:9c5463e2d0974e31bee71346aa5cbf19167de1585a4abc1bae7b3b04604ea url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/casey/just/releases/assets/643131303" +[[tools."npm:ajv-cli"]] +version = "5.0.0" +aube = { path = ".mise/locks/npm-ajv-cli/5.0.0", digest = "sha256:36d7da953aa2dac85d8e85af560d39cd2802e0b55dbc19ab9416b6ca0a1c3f9e" } +backend = "npm:ajv-cli" +specifiers = ["5"] + [[tools."npm:markdownlint-cli2"]] version = "0.24.0" aube = { path = ".mise/locks/npm-markdownlint-cli2/0.24.0", digest = "sha256:689e86a3f956c317400e5d3bcf0915902a43dbbb8aeec7210a3aa3efb6859684" } diff --git a/mise.toml b/mise.toml index a5ead37..6a3b9e5 100644 --- a/mise.toml +++ b/mise.toml @@ -20,5 +20,6 @@ bats = "1.14" gh = "2.103" jq = "1.8" just = "1.59" +"npm:ajv-cli" = "5" "npm:markdownlint-cli2" = "0.24" shellcheck = "0.11" diff --git a/modules/ci/docs/README.md b/modules/ci/docs/README.md index 41912f8..8e59d3b 100644 --- a/modules/ci/docs/README.md +++ b/modules/ci/docs/README.md @@ -1,7 +1,9 @@ # The ci module -Places `.github/workflows/kit-ci.yml`: one job, `CI`, which runs the repository's `just ci` when its root justfile -has that recipe, runs `kit check`, and refuses a pull request not linked to an open issue of the repository. +Places `.github/workflows/kit-ci.yml`: one job, `CI`, whose steps are one kit command each. `kit gate` runs the +repository's `just ci` when its root justfile has that recipe and reports a missing one as a skip, `kit check` +holds every delivered file to the pinned release, and `kit linked` refuses a pull request not linked to an open +issue of the repository. Each is a verb a desk can run, so the runner does nothing a person cannot repeat. The workflow is a template with one key, `ci_runner`, the label the job runs on. Its default is `ubuntu-latest`; a repository with runners of its own puts `ci_runner