diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c238874..47eee9a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "km", - "version": "1.10.0", + "version": "1.10.1", "description": "Knowledge management for markdown knowledge bases: answer from it with sources, save notes, decisions and transcripts with validated frontmatter, lint, mount peer brains, contribute to a team brain by PR, and set up a shared team brain with CI (/km init --team).", "author": { "name": "Roman Passler" diff --git a/README.md b/README.md index 39329ed..e0f82d6 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,14 @@ km list [PATH...] [filters] docs from their frontmatter: --status, --signoff, -- `km doctor` reads what to expect from the brain alone: its origin and upstream (the same upstream `km sync` follows: on a fork, both the checked-out branch and the fork's default branch on the host must have everything the upstream brain has), the brains it mounts (`.gitmodules`), the km version it pins, and the plugins its `.claude/settings.json` enables (`enabledPlugins`, with the marketplaces in `extraKnownMarketplaces`). A brain lists its plugins there for Claude Code anyway, which offers to add the marketplace when the folder is trusted. It also checks that the pre-commit hook is installed, that text files keep LF line endings, and, for a brain on GitHub, that `gh` is signed in and whether you push to the brain or through a fork. Each check that fails names its fix; it needs no PyYAML, so it also runs on a computer that is half set up. -Every command takes `--root DIR`; the default is the git work tree around the current directory. Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.10.0` shows it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.10.0`, or run it without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.10.0`. +Every command takes `--root DIR`; the default is the git work tree around the current directory. Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.10.1` shows it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.10.1`, or run it without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.10.1`. ## What a brain gets Brains carry no km code (the one exception, the script of the mounted-pins workflow, is below). `km init` writes `CONVENTIONS.md`, `CLAUDE.md`, `schema.local.yaml`, `inbox/`, a `.gitattributes` that keeps text files LF, and a pre-commit hook pinned to a km version. `km init --team` adds domain folders with generated indexes, a README, and CI that installs km through this repository's GitHub Action: ```yaml -- uses: pasrom/km@ # v1.10.0, installs the km CLI at that commit +- uses: pasrom/km@ # v1.10.1, installs the km CLI at that commit - run: km validate - run: km gen-index --check ``` @@ -80,7 +80,7 @@ The team brain's ci workflow also runs on `workflow_dispatch`: a pull request th A team brain also gets `bump-mounted-pins`, a workflow that moves the pins of the mounted brains (submodules) by pull request, in place of Dependabot's submodule updates, which need an organisation-wide grant for private submodules and stop every submodule of a group on one refusal. On weekday mornings it walks each mount's tracked branch (its `branch =`, else the repository's default branch) back from the tip to the newest commit whose CI on that branch did not fail (runs of a side branch or a tag do not count; a commit without a run of its own, such as a bot's, inherits the state of the nearest older commit that has runs; a pin never moves backwards), sets the gitlinks without cloning, force-pushes `chore/bump-mounted-pins` with a lease and opens or updates the pull request, which lists short shas only, and dispatches ci on it. A brain without mounts stops after reading `.gitmodules`. It reads the mounts with a GitHub App (Contents and Actions: read) whose secrets `CI_APP_CLIENT_ID` and `CI_APP_PRIVATE_KEY` live in an environment `mounted-pins` restricted to `main`; the workflow's header says how to set it up, and `km doctor` notes a missing or unrestricted environment. Its script, `.github/scripts/bump-mounted-pins.py`, is the one piece of km code a brain carries: the job that holds the app token installs nothing, so it cannot run the km package. `km upgrade` adds both files when missing and replaces a copy that is still a version km shipped, unchanged but for the action pins its Dependabot moves (a pin moved past the template's version is kept), so later fixes reach the brain; a copy the brain edited is its own, left alone with a note. -The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`, `rev: # frozen: v1.10.0`, `id: km-validate`). Every pin names a release by its commit, so a tag moved later cannot change what a brain runs; the version rides along as a comment, and `km upgrade` checks that the commit is that release's. km's own dependencies are pinned to exact versions. `km init` and `km upgrade` look a release's commit up by its tag once, so a release tag here must never be moved or deleted; a repository ruleset on `v*` makes sure of it. Dependabot and `pre-commit autoupdate --freeze` propose newer versions; `/km upgrade` moves the pins and turns a brain from the copy-in days into one that pins km. +The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`, `rev: # frozen: v1.10.1`, `id: km-validate`). Every pin names a release by its commit, so a tag moved later cannot change what a brain runs; the version rides along as a comment, and `km upgrade` checks that the commit is that release's. km's own dependencies are pinned to exact versions. `km init` and `km upgrade` look a release's commit up by its tag once, so a release tag here must never be moved or deleted; a repository ruleset on `v*` makes sure of it. Dependabot and `pre-commit autoupdate --freeze` propose newer versions; `/km upgrade` moves the pins and turns a brain from the copy-in days into one that pins km. A team brain also keeps direct pushes off `main`, which the host may not enforce for a private repository on a free plan: its pre-commit config adds `no-commit-to-branch` for `main` from `pre-commit/pre-commit-hooks` and a pre-push hook that refuses `git push origin main` (`pre-commit install` installs both, the config names both hook types and needs pre-commit 3.2 or newer; pre-commit checks the first ref of a push only and skips a push whose commits the remote already has on another ref, so a push of several refs at once or a first push of `main` gets past it), and a `main-guard` workflow fails and opens an issue when a commit reaches `main` without a merged pull request. `km upgrade` adds whichever a team brain lacks. The plugin itself refuses the file-edit tools (Edit, Write, MultiEdit, NotebookEdit) inside a mounted brain (a brain checked out as a submodule of a brain, found through git, not by path) and in a mount that is not checked out yet: a correction goes through a pull request against that brain, in a throwaway clone, as the skill describes; a shell edit is not caught. diff --git a/src/km/__init__.py b/src/km/__init__.py index 95afa36..ae4cbdd 100644 --- a/src/km/__init__.py +++ b/src/km/__init__.py @@ -1,4 +1,4 @@ """km: knowledge management for markdown knowledge bases.""" -__version__ = "1.10.0" +__version__ = "1.10.1" KM_REF = f"v{__version__}" # this km's release tag; brains pin the commit it points at diff --git a/src/km/templates/team/bump-mounted-pins.py b/src/km/templates/team/bump-mounted-pins.py index b7abae5..ced077c 100644 --- a/src/km/templates/team/bump-mounted-pins.py +++ b/src/km/templates/team/bump-mounted-pins.py @@ -13,7 +13,9 @@ --emit-message FILE write a commit message for the change (only when something moves) --emit-body FILE write a pull-request body for it (only when something moves) --hold-if-moved REF write nothing when a submodule that fails is one REF's commit moves (the - open pull request's branch: its move would be lost over a passing failure) + open pull request's branch: its move would be lost over a passing failure). + Pass it only for a head --branch-prs accepted, else a commit pushed there + by hand stays under the pull request while a submodule fails. Every submodule in .gitmodules is read at run time: its path, its repository (a GitHub URL, or a URL relative to this repository's own, `../name.git` or `../../owner/name.git`) and its tracked diff --git a/src/km/templates/team/bump-mounted-pins.yml b/src/km/templates/team/bump-mounted-pins.yml index cfb520e..3c2f841 100644 --- a/src/km/templates/team/bump-mounted-pins.yml +++ b/src/km/templates/team/bump-mounted-pins.yml @@ -169,10 +169,12 @@ jobs: # from the branch, so no pin pushed there by hand is committed under this workflow's # name. While a pull request is open and its commit moves such a submodule, the run # writes nothing (--hold-if-moved): the move would be lost over a passing failure and - # come back the next day. One that fails for good (a private mount of another owner, - # say) and that the branch does not move holds nothing back. + # come back the next day. Only for a head --branch-prs accepted (seen): a branch someone + # pushed more onto is regenerated, the failing submodule at main's pin. One that fails + # for good (a private mount of another owner, say) and that the branch does not move + # holds nothing back. hold=() - [ "$open" = true ] && hold=(--hold-if-moved refs/bump/proposed) + [ "$open" = true ] && [ "$seen" = true ] && hold=(--hold-if-moved refs/bump/proposed) rc=0 python3 .github/scripts/bump-mounted-pins.py --write "${hold[@]}" \ --emit-message /tmp/pin-message --emit-body /tmp/pin-body || rc=$? diff --git a/src/km/upgrade.py b/src/km/upgrade.py index 152a51a..cb25a9a 100644 --- a/src/km/upgrade.py +++ b/src/km/upgrade.py @@ -64,8 +64,8 @@ # shipped_hash of every version km shipped, the current one included (a test checks it): a copy that # is still one of them is replaced by the current template, an edited one is left with a note. SHIPPED = { - "team/bump-mounted-pins.yml": {"c72b295b1ba76e65"}, - "team/bump-mounted-pins.py": {"52f798d7ca2f7d32"}, + "team/bump-mounted-pins.yml": {"c72b295b1ba76e65", "bb30b4db64fb033e"}, + "team/bump-mounted-pins.py": {"52f798d7ca2f7d32", "9dc066d86b74a44f"}, } USES = re.compile(r"(?m)^([ \t]*(?:-[ \t]+)?uses:[ \t]*([^@\s]+)@)\S+.*$") # 1: up to the @, 2: the action diff --git a/tests/mounted_pins_smoke.sh b/tests/mounted_pins_smoke.sh index 2eb1fd0..3155885 100644 --- a/tests/mounted_pins_smoke.sh +++ b/tests/mounted_pins_smoke.sh @@ -50,8 +50,8 @@ PY # --- the resolve step against a local origin --- # A stand-in for the script: --branch-prs prints $PRS ("open seen"), --write sets the gitlink of -# brains/m to $NEW; $FAILED (paths) could not be resolved, and with --hold-if-moved REF it writes -# nothing when REF's commit moves one of them, as the script does. +# brains/m to $NEW; $FAILED (paths) could not be resolved and stay at main's pin, and with +# --hold-if-moved REF it writes nothing when REF's commit moves one of them, as the script does. if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then echo " skip: the resolve step needs bash 4 or newer (mapfile), this is ${BASH_VERSION}" else @@ -73,7 +73,7 @@ failed = os.environ.get("FAILED", "").split() ls = lambda ref, p: subprocess.run(["git", "ls-tree", ref, "--", p], capture_output=True, text=True, check=True).stdout hold = "--hold-if-moved" in args and any(ls(args[args.index("--hold-if-moved") + 1], p) != ls(args[args.index("--hold-if-moved") + 1] + "^", p) for p in failed) -if os.environ["NEW"] != os.environ["MAIN_PIN"] and not hold: +if os.environ["NEW"] != os.environ["MAIN_PIN"] and "brains/m" not in failed and not hold: subprocess.run(["git", "update-index", "--cacheinfo", f"160000,{os.environ['NEW']},brains/m"], check=True) open(args[args.index("--emit-message") + 1], "w").write("move\n") open(args[args.index("--emit-body") + 1], "w").write("body\n") @@ -82,12 +82,17 @@ PY A="$(printf 'a%.0s' {1..40})"; B="$(printf 'b%.0s' {1..40})"; C="$(printf 'c%.0s' {1..40})" ( cd "$W" && git update-index --add --cacheinfo "160000,$A,brains/m" && git add .gitmodules .github && git "${G[@]}" commit -q -m main \ && git remote add origin "$O" && git push -q origin main ) && git clone -q "$O" "$T/side" - branch_with(){ # the bump branch on origin, proposing pin $1; none: no branch. Made in a clone of its + branch_with(){ # the bump branch on origin, its commit setting pin $1 (of brains/m, or PATH=PIN); + # none: no branch. Each further pin is a commit pushed by hand on top. Made in a clone of its # own, since the step's fetch makes the work tree's repository shallow. git -C "$O" update-ref -d refs/heads/chore/bump-mounted-pins [ "$1" = none ] && return - ( cd "$T/side" && git checkout -q -B tmp origin/main && git update-index --cacheinfo "160000,$1,brains/m" && git "${G[@]}" commit -q -m p \ - && git push -q -f origin HEAD:refs/heads/chore/bump-mounted-pins ) + ( cd "$T/side" && git checkout -q -B tmp origin/main || exit + for p in "$@"; do + [ "$p" = "${p#*=}" ] && p="brains/m=$p" + git update-index --add --cacheinfo "160000,${p#*=},${p%%=*}" && git "${G[@]}" commit -q -m "$p" || exit + done + git push -q -f origin HEAD:refs/heads/chore/bump-mounted-pins ) } step(){ # PRS NEW [FAILED]: run the step, print changed= and problems= ( cd "$W" && git reset -q --hard main && : > "$T/out" && rm -f "$T"/pin-* \ @@ -99,9 +104,11 @@ PY branch_with none; [ "$(r "false false" "$B")" = "changed=true problems=0" ] && ok "no bump branch: the move is proposed" || no "no branch ($(r "false false" "$B"))" branch_with "$B"; [ "$(r "true true" "$B")" = "changed=false problems=0" ] && ok "an open pull request already proposing these pins: nothing is pushed" || no "open PR, same pins ($(r "true true" "$B"))" branch_with "$B"; [ "$(r "true true" "$C")" = "changed=true problems=0" ] && ok "an open pull request with older pins is moved on" || no "open PR, newer pins ($(r "true true" "$C"))" - branch_with "$B"; [ "$(r "true true" "$C" brains/m)" = "changed=false problems=1" ] && ok "a mount that fails while the open pull request moves it: nothing is pushed, the run fails" || no "open PR moving a failing mount ($(r "true true" "$C" brains/m))" + # brains/f fails, brains/m moves on. "true false": --branch-prs rejects the head (a commit pushed onto it). + branch_with "brains/f=$B"; [ "$(r "true true" "$C" brains/f)" = "changed=false problems=1" ] && ok "a mount that fails while the open pull request's own commit moves it: nothing is pushed, the run fails" || no "open PR moving a failing mount ($(r "true true" "$C" brains/f))" + branch_with "brains/f=$B" "brains/f=$C"; [ "$(r "true false" "$C" brains/f)" = "changed=true problems=1" ] && ok "a commit pushed by hand onto the open pull request, a mount fails that it moves: the branch is regenerated, not held" || no "hand-pushed head, failing mount ($(r "true false" "$C" brains/f))" branch_with "$B"; [ "$(r "true true" "$C" brains/x)" = "changed=true problems=1" ] && ok "a mount that fails and the open pull request does not move: the others are still proposed, the run fails" || no "open PR, a failing mount it does not move ($(r "true true" "$C" brains/x))" - branch_with none; [ "$(r "false false" "$B" brains/m)" = "changed=true problems=1" ] && ok "a mount that fails with no pull request open: the others are still proposed, the run fails" || no "no PR, a failing mount ($(r "false false" "$B" brains/m))" + branch_with none; [ "$(r "false false" "$B" brains/f)" = "changed=true problems=1" ] && ok "a mount that fails with no pull request open: the others are still proposed, the run fails" || no "no PR, a failing mount ($(r "false false" "$B" brains/f))" branch_with "$B"; [ "$(r "false false" "$B")" = "changed=true problems=0" ] && ok "a branch whose pull request never opened is pushed again, so the pull request opens" || no "M1 retry ($(r "false false" "$B"))" branch_with "$B"; [ "$(r "false true" "$B")" = "changed=false problems=0" ] && ok "a pull request closed on these pins is not proposed again" || no "closed PR ($(r "false true" "$B"))" branch_with none; [ "$(r "false false" "$A")" = "changed=false problems=0" ] && ok "nothing to move: no change" || no "nothing to move ($(r "false false" "$A"))"