From b4f1ce463fb071bc52a177e0fead10568e0a68a1 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sat, 7 Nov 2026 23:36:48 +0100 Subject: [PATCH 1/2] fix(team): hold the bump branch only while its head is the workflow's own bump-mounted-pins leaves the open pull request as it is when a mount fails that the branch head moves (--hold-if-moved), so the proposed move is not lost over a passing failure. The hold applied to any head, including one --branch-prs rejects: a commit pushed by hand onto the branch then survived under the workflow's pull request on every day a mount failed. The hold now also needs the head that --branch-prs accepted (seen: one commit on main changing gitlinks only), the result the step already has. Any other head is regenerated and the failing mount stays at main's pin until it resolves again; a move of it the workflow had proposed comes back then. The script documents that --hold-if-moved takes such a head, and SHIPPED lists both templates' new hashes next to the old ones. The smoke test's stand-in script no longer moves a mount that fails, as the real one does not, so the hold cases run with a second mount that does move. --- src/km/templates/team/bump-mounted-pins.py | 4 +++- src/km/templates/team/bump-mounted-pins.yml | 8 ++++--- src/km/upgrade.py | 4 ++-- tests/mounted_pins_smoke.sh | 23 ++++++++++++++------- 4 files changed, 25 insertions(+), 14 deletions(-) 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"))" From 50b9b06bee095e9dbde282848e7aa66d5ab59026 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sat, 7 Nov 2026 23:36:48 +0100 Subject: [PATCH 2/2] chore(release): km 1.10.1 A team brain's bump-mounted-pins workflow holds its open pull request over a failing mount only while the branch head is the workflow's own commit; a commit pushed onto the branch by hand is regenerated away instead of staying under the workflow's pull request. km upgrade replaces an unchanged 1.10.0 copy of the workflow and its script. Without the bump no brain or plugin install would see the fix. --- .claude-plugin/plugin.json | 2 +- README.md | 6 +++--- src/km/__init__.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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