diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f31306e..c238874 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "km", - "version": "1.9.0", + "version": "1.10.0", "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 d9ace9b..39329ed 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.9.0` shows it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.9.0`, or run it without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.9.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.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`. ## 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.9.0, installs the km CLI at that commit +- uses: pasrom/km@ # v1.10.0, 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.9.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.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. 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. @@ -100,7 +100,7 @@ tests/ smoke tests ## Development -The smoke tests need Python 3.11+, PyYAML and git: `bash tests/gate_smoke.sh`, `index_smoke.sh`, `team_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh`, `list_smoke.sh`, `mounted_pins_smoke.sh` (the mounted-pins workflow and script a team brain gets, against a stand-in GitHub API), `skill_smoke.sh` (the skill's core keeps its size limits, its reference paths resolve, every `/km` verb has a dispatch row) and `hook_smoke.sh` (the plugin's edit guard; the pre-push hook too where pre-commit is installed). CI runs them plus the GitHub Action on every pull request, and `encoding_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh` and `hook_smoke.sh` on Windows (team_smoke runs the encoding test on macOS under non-UTF-8 locales). A release bumps `__version__` in `src/km/__init__.py`, `version` in `.claude-plugin/plugin.json` and the version in this README together; merging that to main is the release, CI tags `vX.Y.Z` once main is green. +The smoke tests need Python 3.11+, PyYAML and git: `bash tests/gate_smoke.sh`, `index_smoke.sh`, `team_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh`, `list_smoke.sh`, `mounted_pins_smoke.sh` (the mounted-pins workflow and script a team brain gets, against a stand-in GitHub API), `skill_smoke.sh` (the skill's core keeps its size limit, no Markdown line under `skills/` is long enough for the Read tool to cut, its reference paths resolve, every `/km` verb has a dispatch row) and `hook_smoke.sh` (the plugin's edit guard; the pre-push hook too where pre-commit is installed). CI runs them plus the GitHub Action on every pull request, and `encoding_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh` and `hook_smoke.sh` on Windows (team_smoke runs the encoding test on macOS under non-UTF-8 locales). A release bumps `__version__` in `src/km/__init__.py`, `version` in `.claude-plugin/plugin.json` and the version in this README together; merging that to main is the release, CI tags `vX.Y.Z` once main is green. km started inside [dotclaude](https://github.com/pasrom/dotclaude) and was moved here with its history. diff --git a/skills/km/reference/setup.md b/skills/km/reference/setup.md index ffa5b85..499a32a 100644 --- a/skills/km/reference/setup.md +++ b/skills/km/reference/setup.md @@ -6,13 +6,14 @@ - Setup check (`/km doctor`, "check my setup") - Init (`/km init`) - Team brain init (`/km init --team`) +- Mounted pins on a team brain (`bump-mounted-pins`) - Upgrade (`/km upgrade`) ## Running km and keeping the copy current **Running km.** `${CLAUDE_SKILL_DIR}/bin/km` (next to SKILL.md) runs km from this installed plugin, written `km` below; `km --help` lists the commands, and all take `--root DIR`. Brains carry no km code: CI and the pre-commit hook install the km release the brain pins, by its commit (`pasrom/km@ # vX.Y.Z`, so a moved tag cannot change what runs), and so does `km` here: in a brain that pins another version it runs that commit through `uvx`, so results match CI (`KM_LOCAL=1` keeps the plugin's; `init`, `upgrade`, `doctor`, `list` and `sync` always run the plugin's km, so `km upgrade` needs no `KM_LOCAL`). Without uv, offline, for a pin to a movable tag, or when the workflows disagree, it runs the plugin's km and warns; `/km upgrade` the brain then. -**Keeping the copy current.** A copy of the brain goes stale: answers then come from old knowledge and new work starts on an old base. `km sync` fetches the upstream brain (the remote `upstream`, else a fork's parent, else origin) and only ever fast-forwards: the checked-out default branch on a clean tree, a fork's default branch on the host, and the mounted brains to the commit the brain pins (a pin moves only through the host's automation and a reviewed PR). Everything else it only reports, with how far behind. The plugin runs it when a session starts in a brain, and its lines are then in your context. Before answering a question from the brain, run `km sync --quiet --max-age 15m` (nothing happens within 15 minutes of the last run; it prints only what changed or needs attention). Before creating a page or a branch, run `km sync`, so the work starts from the current upstream. When it reports `branch is commits behind upstream ; rebase it when you are ready`, tell the user and offer the rebase; run `git rebase upstream/` (`origin/` without a fork) only on their OK and on a clean tree (`git status --porcelain --ignore-submodules=all` prints nothing), never a reset or a force. A default branch with commits of its own, a dirty tree, a mount with changes of its own or a diverged fork are the user's call too: pass the note on and do nothing else. +**Keeping the copy current.** A copy of the brain goes stale: answers then come from old knowledge and new work starts on an old base. `km sync` fetches the upstream brain (the remote `upstream`, else a fork's parent, else origin) and only ever fast-forwards: the checked-out default branch on a clean tree, a fork's default branch on the host, and the mounted brains to the commit the brain pins (a pin moves only through a reviewed PR, on a team brain usually the one its bump-mounted-pins workflow opens). Everything else it only reports, with how far behind. The plugin runs it when a session starts in a brain, and its lines are then in your context. Before answering a question from the brain, run `km sync --quiet --max-age 15m` (nothing happens within 15 minutes of the last run; it prints only what changed or needs attention). Before creating a page or a branch, run `km sync`, so the work starts from the current upstream. When it reports `branch is commits behind upstream ; rebase it when you are ready`, tell the user and offer the rebase; run `git rebase upstream/` (`origin/` without a fork) only on their OK and on a clean tree (`git status --porcelain --ignore-submodules=all` prints nothing), never a reset or a force. A default branch with commits of its own, a dirty tree, a mount with changes of its own or a diverged fork are the user's call too: pass the note on and do nothing else. ## Setup check (`/km doctor`, "check my setup") @@ -20,7 +21,7 @@ For someone who just set up a computer for the brain, often not a developer. Ans 1. Before km: `git --version`. Without git, nothing else works: say so and stop; the brain's own setup guide (README or CLAUDE.md) says how to install it. 2. `km doctor` in the brain (`--offline` when they say they have no network). Where `km` itself cannot start, its message names uv and the one-line command that installs it (no administrator rights needed): offer to run that command for their system, then run `km doctor` again. km finds uv where the installer puts it, so the app need not be restarted first. -3. Each `FAIL` comes with a fix. Offer to run the ones that change only this computer and are safe (`git submodule update --init`, the `km sync` or `git pull --rebase` it names on a clean tree, `git config --global user.name` after asking for the value, `pre-commit install`); hand over the ones they must do themselves (signing in, `gh auth login`, installing a plugin or adding a marketplace with `/plugin`, asking for access). The re-checkout that fixes CRLF files discards uncommitted changes: run it only on a clean tree and after they agree. A `note` needs nothing; mention it only if it explains something they asked about. +3. Each `FAIL` comes with a fix. Offer to run the ones that change only this computer and are safe (`git submodule update --init`, the `km sync` or `git pull --rebase` it names on a clean tree, `git config --global user.name` after asking for the value, `pre-commit install`); hand over the ones they must do themselves (signing in, `gh auth login`, installing a plugin or adding a marketplace with `/plugin`, asking for access). The re-checkout that fixes CRLF files discards uncommitted changes: run it only on a clean tree and after they agree. A `note` needs nothing; mention it only if it explains something they asked about. A note whose fix is in the repository's settings (such as the environment `mounted-pins`) is the maintainer's, nothing to do on this computer. 4. Run `km doctor` again after fixes and end with what still fails, or that the setup is complete. ## Init (`/km init`) @@ -36,16 +37,20 @@ When the user runs `/km init` (or confirms after "CONVENTIONS.md missing" prompt A **team brain** is a shared, served knowledge base: one maintainer merges, the team reads the repo, everyone else contributes by pull request or promotes from a personal brain. On top of a personal brain it adds CI (validation, a generated `_index.md` Documents list, a served bundle, weekly staleness demotion), domain folders instead of `inbox/`, and the one-merger rules. 1. Ask for: the brain's name (e.g. `QA-Brain`), a one-line description of the team (e.g. `the QA team`), the maintainer's initials, and the top-level folders with a one-line purpose each (suggest `process/`, `projects/`, `reference/` plus the team's own domains). Optionally one or two placement rules that decide which folder a doc goes to; without them each folder's purpose becomes its rule. -2. In the empty repo (or an empty clone of a fresh remote): `km init . --team --name --desc "" --initials --folder process="" --folder ... [--rule "" ...]`. It writes the docs, indexes, `schema.local.yaml`, the ci and staleness workflows, the pre-commit hook, a Dependabot config and a `.gitattributes` for LF, all pinned by commit to this km's release (or `--pin `), and refuses to run if any file it would write already exists, such as a README the host created with the repo: delete that first. +2. In the empty repo (or an empty clone of a fresh remote): `km init . --team --name --desc "" --initials --folder process="" --folder ... [--rule "" ...]`. It writes the docs, indexes, `schema.local.yaml`, the ci, staleness, main-guard and bump-mounted-pins workflows (the last with its script `.github/scripts/bump-mounted-pins.py`), the pre-commit hook, a Dependabot config and a `.gitattributes` for LF, all pinned by commit to this km's release (or `--pin `), and refuses to run if any file it would write already exists, such as a README the host created with the repo: delete that first. 3. `git add -A`, then `km gen-index` (it reads tracked files only, so add first; it fills the root and folder lists), `git add -A` and `km validate`: expect **0 errors / 0 warnings**. 4. Commit: `chore: initialize (team served knowledge base)`. -5. Tell the user what km does not do: create the repo (private), push `main` (after `pre-commit install` the pre-push hook refuses a push to `main`: the first push of the fresh brain takes `--no-verify`, once), give the team **read** access and the maintainer write, and ask members to add the brain to their personal brain with `/km brain add `. Branch protection on a private repo needs a paid GitHub plan; without it the one-merger rule is held by the permission model alone. Members with read access contribute by fork PR, and GitHub does not run Actions on fork PRs of a private repo until "Run workflows from fork pull requests" is enabled (repository or organization settings, Actions): without it those PRs get no CI. Dependabot then proposes a PR whenever a newer km is released. +5. Tell the user what km does not do: create the repo (private), push `main` (after `pre-commit install` the pre-push hook refuses a push to `main`: the first push of the fresh brain takes `--no-verify`, once), give the team **read** access and the maintainer write, and ask members to add the brain to their personal brain with `/km brain add `. Branch protection on a private repo needs a paid GitHub plan; without it the one-merger rule is held by the permission model alone. Members with read access contribute by fork PR, and GitHub does not run Actions on fork PRs of a private repo until "Run workflows from fork pull requests" is enabled (repository or organization settings, Actions): without it those PRs get no CI. Dependabot then proposes a PR whenever a newer km or another action is released; mounted brains move through bump-mounted-pins ("Mounted pins on a team brain"). + +## Mounted pins on a team brain (`bump-mounted-pins`) + +The workflow moves the pins of mounted brains (submodules) by pull request: on weekday mornings it opens or updates `chore/bump-mounted-pins` with each mount at the newest commit of its tracked branch whose CI did not fail. Without a mount it stops after reading `.gitmodules`; with one it runs, but moves nothing (and warns) until the maintainer has set it up once, as the header of `.github/workflows/bump-mounted-pins.yml` describes (read it there for the details): a GitHub App with Contents: read and Actions: read on the mounted repositories, the environment `mounted-pins` restricted to `main` with the secrets `CI_APP_CLIENT_ID` and `CI_APP_PRIVATE_KEY`, Actions allowed to create pull requests, and "Require approval of the most recent reviewable push" on `main`; optionally the repository variable `MOUNTED_PINS_RUNNER` for another runner. Create the environment BEFORE the workflow first runs on a brain with mounts: GitHub otherwise creates it on that run, open to every branch. ## Upgrade (`/km upgrade`) Move a brain to the km you run, without touching repo-specific files: -1. `km upgrade` moves the brain's km pins to this km's release commit (`--pin ` for another), turns a pin to a movable tag into a commit pin, and on a brain from the copy-in days drops the copied `scripts/*.py` and `schema.base.yaml` and rewrites its hook and km workflows. It refuses to move a pin to a newer km back (`--force` overrides). +1. `km upgrade` moves the brain's km pins to this km's release commit (`--pin ` for another), turns a pin to a movable tag into a commit pin, and on a brain from the copy-in days drops the copied `scripts/*.py` and `schema.base.yaml` and rewrites its hook and km workflows. On a team brain it adds what is missing of the main-guard and bump-mounted-pins workflows (with the script) and `workflow_dispatch` in the km ci workflow, and replaces a bump-mounted-pins copy that is still a version km shipped; one the brain edited stays, with a note. Pass on its warning when it adds bump-mounted-pins to a brain that already has mounts ("Mounted pins on a team brain"). It refuses to move a pin to a newer km back (`--force` overrides). 2. Show `git diff`. `CONVENTIONS.md`, `CLAUDE.md`, `README.md` and every `_index.md` are repo-owned and stay (in `schema.local.yaml` only an obsolete `team_brain:` line goes); point out prose that still mentions `scripts/`. 3. Run `km validate` (expect **0 errors**); on a team brain also `km gen-index` (a newer km may list more, e.g. subfolder indexes) and stage what it changed. 4. Commit: `chore(km): upgrade to km ` diff --git a/src/km/__init__.py b/src/km/__init__.py index d5a7da6..95afa36 100644 --- a/src/km/__init__.py +++ b/src/km/__init__.py @@ -1,4 +1,4 @@ """km: knowledge management for markdown knowledge bases.""" -__version__ = "1.9.0" +__version__ = "1.10.0" KM_REF = f"v{__version__}" # this km's release tag; brains pin the commit it points at diff --git a/tests/skill_inventory.py b/tests/skill_inventory.py index b6b2821..2f6b598 100644 --- a/tests/skill_inventory.py +++ b/tests/skill_inventory.py @@ -1,9 +1,10 @@ """The skill's core keeps its limits, and its dispatch table and reference files stay consistent. -The core SKILL.md: 500 lines and CORE_BYTES, a description of at most 1024 characters, every -reference file named by its full `${CLAUDE_SKILL_DIR}/reference/` path and every such path -resolving, a contents list in a reference file over 100 lines, and a row in the dispatch table (the verb -opening a code span or a quoted phrase) for every `/km ` the README or the skill's own files mention, `help` included. +The core SKILL.md: CORE_BYTES, a description of at most 1024 characters, every reference file +named by its full `${CLAUDE_SKILL_DIR}/reference/` path and every such path resolving, a contents +list in a reference file over CONTENTS_BYTES, no line over MAX_LINE characters in any Markdown file +under skills/, and a row in the dispatch table (the verb opening a code span or a quoted phrase) for +every `/km ` the README or the skill's own files mention, `help` included. python3 tests/skill_inventory.py [--skill skills/km] """ @@ -21,6 +22,19 @@ # table, so 12,000 bytes is about 4,780 tokens, 4 percent under the budget. Re-measure the ratio if # the file's character changes a lot. CORE_BYTES = 12000 +# The limits below were line counts (500 for the core, a contents list over 100 lines), which meant +# something while the Markdown was hard-wrapped near 100 columns; with one paragraph per line a file +# has as many lines as paragraphs. Measured on the hard-wrapped files (4777eb7), the reference files +# averaged 69 bytes a line and SKILL.md 76, so 500 lines were about 38,000 bytes, three times +# CORE_BYTES: the core's line limit never bound and goes without a replacement. The 100-line rule +# split the reference files into github-reads, pins-and-cites and queue (3,433 to 3,702 bytes, +# under) and setup, validate-and-gate, review-and-pr and brains (8,064 to 13,599 bytes, over); 6,000 +# bytes keeps that split with room on both sides. +CONTENTS_BYTES = 6000 +# Claude's Read tool cuts a line after 2,000 characters, so the rest of a longer paragraph is never +# seen. Characters, not bytes, since that is what it counts; 100 under it, so an edit that nears the +# cut fails here first. All of skills/, not only this skill's files. +MAX_LINE = 1900 def main() -> int: @@ -32,8 +46,6 @@ def main() -> int: files = {p.name if p.parent == skill else f"reference/{p.name}": p.read_text(encoding="utf-8") for p in [skill / "SKILL.md", *refs]} core = files["SKILL.md"] problems = [] - if len(core.splitlines()) > 500: - problems.append(f"SKILL.md has {len(core.splitlines())} lines, limit 500") if (n := len(core.encode("utf-8"))) > CORE_BYTES: problems.append(f"SKILL.md is {n} bytes, limit {CORE_BYTES}: a byte proxy for the 5,000 tokens a compaction keeps of a " f"skill (calibrated 2026-11-07 with a one-turn haiku differential, see CORE_BYTES); move text to a reference file") @@ -42,10 +54,16 @@ def main() -> int: if not desc or len(desc) > 1024: problems.append(f"description is {len(desc)} chars (limit 1024)") for ref in refs: + text = files[f"reference/{ref.name}"] if f"`{REF}/{ref.name}`" not in core: problems.append(f"SKILL.md never names `{REF}/{ref.name}`") - if len(files[f"reference/{ref.name}"].splitlines()) > 100 and "## Contents" not in files[f"reference/{ref.name}"]: - problems.append(f"{ref.name} is over 100 lines and has no '## Contents' list") + if (n := len(text.encode("utf-8"))) > CONTENTS_BYTES and not re.search(r"(?m)^## Contents$", text): + problems.append(f"{ref.name} is {n} bytes, over {CONTENTS_BYTES}, and has no '## Contents' list") + for md in sorted(skill.parent.rglob("*.md")): + for no, line in enumerate(md.read_bytes().decode("utf-8").split("\n"), 1): # at \n only, as the Read tool splits + if len(line) > MAX_LINE: + problems.append(f"{md.relative_to(skill.parent.parent)}:{no} is {len(line)} characters, limit {MAX_LINE}: " + f"the Read tool cuts lines at 2000; split the paragraph") for name, text in files.items(): for hit in re.findall(r"\$\{CLAUDE_SKILL_DIR\}/reference/([\w-]+\.md)", text): if not (skill / "reference" / hit).is_file(): diff --git a/tests/skill_smoke.sh b/tests/skill_smoke.sh index ff896ac..468a58e 100644 --- a/tests/skill_smoke.sh +++ b/tests/skill_smoke.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Smoke test for the skill: the core keeps its limits and its pointers to the reference files resolve -# (tests/skill_inventory.py). +# Smoke test for the skill: the core keeps its limits, no Markdown line is cut by the Read tool, and +# its pointers to the reference files resolve (tests/skill_inventory.py). set -u source "$(dirname "$0")/lib.sh" cd "$REPO" @@ -13,4 +13,16 @@ grep -v '^| `review ' skills/km/SKILL.md > "$C/skills/km/SKILL.md" out="$(kmpy tests/skill_inventory.py --skill "$C/skills/km" 2>&1)" { grep -q "no row for /km approve" <<< "$out" && grep -q "no row for /km review" <<< "$out"; } \ && ok "a verb whose row is gone fails, also one the README never names" || no "missing dispatch row ($out)" +# the size checks bite: a core over CORE_BYTES, a reference file over CONTENTS_BYTES without its +# contents list, and a line over MAX_LINE characters (a multi-byte one, so characters are counted) +cp skills/km/SKILL.md "$C/skills/km/SKILL.md" && printf 'x%.0s' $(seq 12001) >> "$C/skills/km/SKILL.md" +grep -v '^## Contents' skills/km/reference/brains.md > "$C/skills/km/reference/brains.md" +{ printf '\n'; printf 'x%.0s' $(seq 1901); printf '\n'; } >> "$C/skills/km/reference/queue.md" +{ printf '\n'; printf 'ΓΌ%.0s' $(seq 1000); printf '\n'; } >> "$C/skills/km/reference/github-reads.md" +out="$(kmpy tests/skill_inventory.py --skill "$C/skills/km" 2>&1)" +grep -q "SKILL.md is [0-9]* bytes, limit" <<< "$out" && ok "a core over CORE_BYTES fails" || no "core size ($out)" +grep -q "brains.md is [0-9]* bytes, over [0-9]*, and has no '## Contents' list" <<< "$out" \ + && ok "a large reference file without a contents list fails" || no "contents list ($out)" +{ grep -q "queue.md:[0-9]* is 1901 characters" <<< "$out" && ! grep -q "github-reads.md:[0-9]* is" <<< "$out"; } \ + && ok "a line over the limit fails, counted in characters" || no "line length ($out)" summary skill_smoke