diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index d1dbe7e..610e850 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -50,7 +50,15 @@ jobs: - name: Cache prek environments uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ~/.cache/prek + # Both directories, because the linkspector hook's environment is split + # across them. `prek` holds the node_modules; puppeteer's browser lands + # in its own cache from a postinstall script. Restoring only the first + # gives a hook environment that looks complete and has no browser: the + # install step never re-runs, so nothing fetches Chrome. They are cached + # under one key so a restore cannot bring back half the pair. + path: | + ~/.cache/prek + ~/.cache/puppeteer key: prek|${{ steps.setup-python.outputs.python-version }}|${{ hashFiles('.pre-commit-config.yaml') }} restore-keys: | prek|${{ steps.setup-python.outputs.python-version }} diff --git a/.github/workflows/merge-back.yml b/.github/workflows/merge-back.yml index 6436bdd..c8f8cbf 100644 --- a/.github/workflows/merge-back.yml +++ b/.github/workflows/merge-back.yml @@ -23,7 +23,7 @@ permissions: jobs: merge_back: name: 🔁 (merge main back into staging) - uses: ftschindler/federated-knowledge-skills/.github/workflows/merge-back.yml@a56c34298a91ded8f88519a027d38ff930ea9ae1 + uses: ftschindler/federated-knowledge-skills/.github/workflows/merge-back.yml@5b0771e12f55d1ce5f598f4e42e788bcac4c4447 # v0.2.2 with: branch: staging permissions: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9908c99..f405896 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,11 +29,22 @@ repos: hooks: - id: linkspector name: Check broken links (linkspector) - # The chrome version here ... - entry: bash -c 'npx --yes puppeteer browsers install chrome@148.0.7778.97 && linkspector check -c .linkspector.yml' + # `browsers install chrome` names no version on purpose: the pinned puppeteer + # below resolves its own, so this cannot drift from the pin the way a literal + # `chrome@...` does. It is a cheap no-op once the browser is in the cache, and + # it is what makes the check independent of whether anything downloaded a + # browser earlier - a restored hook environment, or an npm that declines to + # run puppeteer's postinstall, otherwise leaves it with no browser at all. + entry: bash -c 'puppeteer browsers install chrome && linkspector check -c .linkspector.yml' language: node - # ... needs to match the linkspector version here. - additional_dependencies: ['@umbrelladocs/linkspector@0.5.3', 'puppeteer'] + # puppeteer is pinned exactly, not left to linkspector's own `^24.37.5`. + # It is what decides which Chrome launches: each release downloads and runs + # the revision compiled into it, so a floating range here means the browser + # resolving a link changes without anything in this repository changing. + # 24.39.1 -> Chrome 146.0.7680.76. How to choose the pair, and how to read + # the mapping out of a release: + # https://raw.githubusercontent.com/ftschindler/knowledge/staging/docs/guides/pin_the_browser_behind_a_pre_commit_link_checker.md + additional_dependencies: ['@umbrelladocs/linkspector@0.5.3', 'puppeteer@24.39.1'] pass_filenames: false files: \.md$ # -- Python formatting + linting -- @@ -130,7 +141,7 @@ repos: # use here; pinning it by revision is what keeps this bundle standalone without # carrying a copy that could drift. - repo: https://github.com/ftschindler/federated-knowledge-skills - rev: a56c34298a91ded8f88519a027d38ff930ea9ae1 # frozen: v0.2.1 + rev: 5b0771e12f55d1ce5f598f4e42e788bcac4c4447 # frozen: v0.2.2 hooks: - id: okf-concepts args: [--bundle-root, docs, --floor, docs/fkb.yaml] diff --git a/docs/blueprints/index.md b/docs/blueprints/index.md index f052969..ba3909e 100644 --- a/docs/blueprints/index.md +++ b/docs/blueprints/index.md @@ -3,3 +3,4 @@ The concrete, copyable artefact a decision produced. - [MkDocs Material PKB publishing stack](mkdocs_material_pkb_publishing_stack.md) - the stack this site runs on, component by component, with the reasons attached +- [Sharing one knowledge bundle on two branches](sharing_one_knowledge_bundle_on_two_branches.md) - what a bundle looks like once several people file into it, and the published branch takes no direct pushes diff --git a/docs/blueprints/sharing_one_knowledge_bundle_on_two_branches.md b/docs/blueprints/sharing_one_knowledge_bundle_on_two_branches.md new file mode 100644 index 0000000..f26b97b --- /dev/null +++ b/docs/blueprints/sharing_one_knowledge_bundle_on_two_branches.md @@ -0,0 +1,104 @@ +--- +type: Blueprint +title: Sharing one knowledge bundle on two branches +description: The branch shape, manifest field, workflow and command that let several people file + into one knowledge bundle whose published branch takes no direct pushes. +tags: +- blueprint +- okf +- federation +- knowledge-management +- ci-cd +- collaboration +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:40:00Z' +--- +The shape a knowledge bundle takes once more than one person files into it, produced by +[Federating my knowledge base as privacy-tiered OKF bundles](../decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md) +and shipped in +[federated-knowledge-skills](../tools/federated_knowledge_skills_schindler.md) v0.2.0. The +bundle you are reading runs it, and so does one shared with an employer's team. + +The constraint it answers: a bundle anyone publishes from has a default branch that takes no +direct pushes, so a change reaches it through a pull request and a review. That gate is +measured in days. The knowledge it gates is written in minutes. + +## Manifest - what is assembled + +**Two branches, each with one job.** + +| | the default branch | the shared branch | +| --- | --- | --- | +| Holds | what has been reviewed, and what the site is built from | everything reviewed, plus what is not ready | +| Protected | yes: pull request and review | no: open to anyone with write access | +| Who works here | nobody directly | every checkout, every agent | +| Reaches the other by | a release pull request, on a cadence | a merge, after every change to the default | + +- **Merge commits only**, on both routes. Squash and rebase merging write a new commit with + the same content and no shared ancestry, so the two branches stop knowing they ever met. + The next release then re-displays everything already released. +- **One manifest field**, `sync: staging`, naming the branch this machine's checkout belongs + on. It says three things at once: check this branch out, commits here may leave the + machine, and somebody else is writing here so read it fresh. +- **A reusable merge-back workflow**, called by the bundle on every push to the default + branch and pinned by full commit SHA: + + ```yaml + on: + push: + branches: [main] + + jobs: + merge_back: + uses: ftschindler/federated-knowledge-skills/.github/workflows/merge-back.yml@ + with: + branch: staging + permissions: + contents: write + pull-requests: write + ``` + +- **The governance checks run on the shared branch too**, not only on pull requests. A page + edited in the forge's web editor has no working copy, so the bundle's own pre-commit hooks + never ran on it, and the shared branch is where such a change lands first. + +## Principles it instantiates + +[Pin GitHub Actions to full commit SHAs](../principles/pin_github_actions_to_full_commit_shas.md), +which matters more here than usual: this workflow holds write access to the shared branch. +[Grant least-privilege CI permissions at both workflow and job level](../principles/grant_least_privilege_ci_permissions_at_both_workflow_and_job_level.md), +[Guard invariants at commit-time, not review-time](../principles/guard_invariants_at_commit_time_not_review_time.md) +and [Mirror every local guard in CI](../principles/mirror_every_local_guard_in_ci.md), which is +what the web-editor case above is. The branch shape itself is +[Give a shared repository two clocks when review is slower than the work](../principles/give_a_shared_repository_two_clocks_when_review_is_slower_than_the_work.md). + +One principle this departs from on purpose: +[Keep a linear history](../principles/keep_a_linear_history_block_merge_fixup_and_squash_commits.md) +cannot hold where two long-lived branches have to keep meeting. Shared ancestry is the thing +being protected, and a merge commit is what records it. + +## Operating manual + +- **Before reading the bundle**, `fkb sync `. This is what stops an answer coming from + a page a teammate corrected this morning. +- **After filing**, commit through the bundle's hooks, then `fkb sync ` again. Pushing + is part of filing here in the way committing is. +- **The command refuses rather than resolves.** It does not stash, switch branches or finish a + rebase. Uncommitted work in the tree, a checkout on another branch, a conflict: the commit + stands, nothing is pushed, and the reason is one line of output. An unpushed commit costs a + teammate an hour's delay; a guess about whose work was in the tree costs the work. +- **Cutting a release is `gh pr create`** from the shared branch to the default one, on + whatever cadence suits. Nothing automates it, because a command that did would put a forge + inside the CLI. + +## What it costs + +**A concept is shared with the team before it is reachable by URL.** It sits on the shared +branch, where every checkout sees it, whilst the site still carries the last release. A +cross-bundle citation written today therefore points at a page the world cannot open yet, and +nothing detects this: the link checker asks whether the file exists on this disk, and it does. + +**A conflict stops the machine every time.** Two people writing the same concept is a question +about what is true, which is the last thing to hand to an agent working quickly. diff --git a/docs/decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md b/docs/decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md index 0040e24..04db263 100644 --- a/docs/decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md +++ b/docs/decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md @@ -89,10 +89,14 @@ The bundle you are reading, as the `public` tier: an OKF bundle over the same MkDocs stack, with the hooks doing what the engine used to. The federation layer around it is -[federated-knowledge-skills](https://github.com/ftschindler/federated-knowledge-skills), -currently at design stage. Its blueprint is not written here yet, deliberately, -for the same reason the previous decision deferred one: it should be documented -from a working setup rather than guessed at. This decision points forward to it. +[federated-knowledge-skills](../tools/federated_knowledge_skills_schindler.md), which has been +released and in daily use since September. There are now four bundles at three tiers: this +public one, a sealed private one, one shared with an employer's team, and a read-only upstream. + +The blueprint this decision deferred is written, now that there is a working setup to write it +from rather than a design to guess at: +[Sharing one knowledge bundle on two branches](../blueprints/sharing_one_knowledge_bundle_on_two_branches.md) +is the shape a bundle takes once more than one person files into it. ## What I gave up, and would reconsider diff --git a/docs/explorations/index.md b/docs/explorations/index.md index 6488b15..7d117a9 100644 --- a/docs/explorations/index.md +++ b/docs/explorations/index.md @@ -9,4 +9,5 @@ date, the work simply ended. The argument for keeping them as their own genre is [Record an abandoned exploration as an exploration, not a superseded decision](../knowledge_management/record_an_abandoned_exploration_as_an_exploration_not_a_superseded_decision.md). - [Running this knowledge base on awiki](running_this_knowledge_base_on_awiki.md) - my trial of agent-first authoring on a dedicated wiki engine, the four things that ended it, and the much longer list of what survived -- [Wrapping the kb skills in a federation layer](wrapping_the_kb_skills_in_a_federation_layer.md) - a stub: the federation layer that wrapped another skill set, reached a green test suite, and was retired +- [Wrapping the kb skills in a federation layer](wrapping_the_kb_skills_in_a_federation_layer.md) - the federation layer that wrapped another skill set, reached a green test suite, and was retired for making prose the control flow +- [Tracking AI authorship with git-ai](tracking_ai_authorship_with_git_ai.md) - three and a half months of recording which lines an agent wrote, never reading any of it, and what that says about collecting data because collecting is cheap diff --git a/docs/explorations/tracking_ai_authorship_with_git_ai.md b/docs/explorations/tracking_ai_authorship_with_git_ai.md new file mode 100644 index 0000000..4c6d128 --- /dev/null +++ b/docs/explorations/tracking_ai_authorship_with_git_ai.md @@ -0,0 +1,95 @@ +--- +type: Exploration +title: Tracking AI authorship with git-ai +description: Three and a half months of recording which lines an agent wrote, ended because the data + was never read and the notes it wrote cluttered every history view that shows all refs. +tags: +- exploration +- git-ai +- git +- provenance +- agents +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T14:00:00+02:00' +sources: +- id: felix-git-ai-opencode + resource: https://ftschindler.github.io/running-linux/done/20260624-git-ai-integration-with-opencode/ + title: Git AI Integration with OpenCode + author: human:felix_schindler + last_modified: '2026-06-24' +--- +[git-ai](../tools/git_ai.md) was installed on 24 June 2026 and removed on 9 October. In between +it did exactly what it promised, without once being asked for anything. + +## What it was for + +Most of the code in these repositories is written by an agent, and the question of which lines +those are has no answer in git itself. `git blame` names whoever ran the commit. A tool that +records the agent, the model and the prompt behind each line promises to turn that into +something answerable, and `git ai blame` renders it beside the ordinary blame. + +The setup was done carefully and written up at the time[^felix-git-ai-opencode]: telemetry off, +version checks off, auto-updates off, so the tool ran entirely locally. `prompt_storage` was set +to `notes` deliberately, so attribution and prompts travelled with the repository rather than +sitting in a database on one machine. Each repository was configured to fetch and push +`refs/notes/ai` alongside its branches. + +## What actually happened + +Nothing, which is the finding. + +The collection worked. Three and a half months later, 125 repositories held a `.git/ai` +directory and 19 carried notes. All of it was recorded passively, pushed with the code, and +**never read once**. Not a single `git ai blame`, `git ai stats` or `git ai log` was run against +a real question. + +That is not a failure of the tool, and it is worth separating from one. The attribution data was +accurate and present the whole time. What was missing was a question to put to it. + +## Why it stopped + +**The value is in the analysis, and the analysis is the commercial product.** What the open +source CLI gives one person is a per-file, per-commit view, answered one command at a time. The +things the data would actually be interesting for - how much agent-written code survives review, +which models produce work that needs reworking, what a change really cost - are aggregations +across many commits and many repositories, and those live in the Teams product. Collecting the +data locally without that is keeping records for an audit nobody is going to run. + +**The notes clutter every history view that shows all refs.** `refs/notes/ai` is a ref like any +other, so anything drawing `--all` draws it, and the note commits carry no subject line: + +```text +15ee53d (HEAD -> redesign-as-skill) design the redesign: this config as an installable skill +abd9bf4 +1171be4 (origin/main, main) add a getting-started guide for fresh machines +1414586 +69660c8 update my AGENTS.md +``` + +Two of those five rows are notes. In `gitk`, which is where a branch view gets read rather than +grepped, they interleave with the real history as blank entries and have to be looked past every +time. A background cost, paid at every glance, against a benefit taken zero times. + +## What it cost to leave + +More than it cost to adopt, which is the asymmetry worth recording. The installer wrote into +four unrelated places and the removal had to reach all four, including per-repository state in +125 repositories and notes already pushed to remotes. The procedure is +[Uninstall git-ai and the state it left in every repository](../guides/uninstall_git_ai.md), and +it is longer than the install instructions by a wide margin. + +**A tool that needs no per-repository setup leaves per-repository state anyway.** That is the +general shape, and it is not specific to this tool: the property that makes something frictionless +to adopt is usually the same property that spreads it somewhere nothing is tracking. + +## What would bring it back + +A question worth asking of the data. If the aggregate view ever matters here - measuring what +agent-written code costs over its life rather than at the moment it is written - then this is the +tool that already has the standard, the agent coverage and the git-native storage to answer it. +Nothing about the implementation was the problem. + +Until then the honest position is that the data was collected because collecting it was easy, not +because anyone intended to use it. diff --git a/docs/explorations/wrapping_the_kb_skills_in_a_federation_layer.md b/docs/explorations/wrapping_the_kb_skills_in_a_federation_layer.md index 4fb3914..558b781 100644 --- a/docs/explorations/wrapping_the_kb_skills_in_a_federation_layer.md +++ b/docs/explorations/wrapping_the_kb_skills_in_a_federation_layer.md @@ -10,32 +10,53 @@ tags: - okf - federation - knowledge-management -status: draft +status: stable generated: by: opencode/claude-opus-5 - at: '2026-09-14T00:00:00Z' + at: '2026-10-14T10:30:00Z' --- -**A stub.** The work happened and was retired; this page is the account of it, and is not -written yet. What it needs to cover: - -- **What I wanted.** A federation layer over - [the `kb-*` skills](../tools/agent_knowledge_stjbrown.md) that reimplemented nothing: the upstream - skills vendored unmodified as the single-bundle mechanic, a thin layer above them owning - only the manifest-aware concerns. The architecture is - [Federated OKF knowledge bases](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md), - whose skill-layer section this design originally filled. -- **What I built.** Roughly twenty commits and fifty-six passing tests, so this was not - abandoned at the sketch stage. Worth saying what actually worked. -- **Why I stopped.** A federated skill invoked its single-bundle counterpart and then - executed the prose that came back, which is control flow through prompt obedience rather - than through a call. The `SKILL.md` accumulated three shouted warnings against an - indirection the design had introduced itself, which is the tell worth recording: a - document arguing with its own architecture. -- **The second reason, which is structural.** Skills have no dependency resolution. - `npx skills add` copies a directory and verifies nothing, so a layer that requires another - skill cannot state that requirement anywhere a machine will check. The `kb-*` skills were - not installed on the machine whilst the global `AGENTS.md` advertised the layer above them. -- **What I kept.** The manifest, the `referenceable_by` rule, fail-closed defaults, the - commit-time leak guard, and the rule that replaced the delegation: a skill may run a - command; a skill never invokes another skill. Also the upstream bundle itself, which is - now a read-only source here rather than a dependency. +The first attempt at a federation layer was built on top of +[the `kb-*` skills](../tools/agent_knowledge_stjbrown.md) rather than beside them. It worked, +in the sense that the tests passed, and it was retired anyway. What replaced it is +[federated-knowledge-skills](../tools/federated_knowledge_skills_schindler.md). + +## What it was meant to be + +A layer that reimplemented nothing. The upstream skills would be vendored unmodified as the +single-bundle mechanic, and a thin layer above them would own only the concerns that need the +manifest: which bundles exist, which may be written to, which may cite which. The architecture +it was filling in is +[Federated OKF knowledge bases: a workspace-manifest architecture](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md), +whose skill-layer section this design originally was. + +Roughly twenty commits and fifty-six passing tests went into it, so what follows is not an +account of a sketch. + +## Why it was retired + +**A skill invoked another skill.** The federated skill told the agent to load its +single-bundle counterpart, and then to carry out the prose that came back. That is control +flow through prompt obedience: nothing calls anything, and whether the step happens depends on +how persuasive the paragraph was. + +The tell was in the document rather than in the tests. `SKILL.md` had accumulated three +shouted warnings against an indirection the design itself had introduced, which is a document +arguing with its own architecture. + +**The second reason is structural, and nothing in the design could have fixed it.** Skills +have no dependency resolution. A skill installer copies a directory and verifies nothing, so a +layer that requires another skill cannot state that requirement anywhere a machine will check. +The failure duly happened on a real machine: the `kb-*` skills were not installed whilst the +user-level instructions advertised the layer above them, and nothing reported it. +[akit](../tools/federated_agent_kits_schindler.md) later fixed how a skill and its rule travel +together, and still does not resolve a dependency between two skills, because there is nowhere +to declare one. + +## What survived + +The manifest, the `referenceable_by` rule, the fail-closed defaults and the commit-time leak +guard all went into the rewrite unchanged. So did the upstream bundle itself, which is now a +read-only source read here rather than a dependency anything runs. + +And one rule, which is what the episode cost and what it bought: **a skill may run a command; +a skill never invokes another skill.** diff --git a/docs/findings/20261009_a_ruleset_bypass_applies_to_every_rule_in_that_ruleset.md b/docs/findings/20261009_a_ruleset_bypass_applies_to_every_rule_in_that_ruleset.md new file mode 100644 index 0000000..4b0abc3 --- /dev/null +++ b/docs/findings/20261009_a_ruleset_bypass_applies_to_every_rule_in_that_ruleset.md @@ -0,0 +1,75 @@ +--- +type: Finding +title: A ruleset bypass applies to every rule in that ruleset +description: Bypass is granted per ruleset rather than per rule, so overriding a red status check + also hands over whatever else that ruleset enforces unless the rules are split across rulesets. +tags: +- finding +- github +- repo-hygiene +- permissions +- security +status: stable +stale_after: '2027-04-09' +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T18:30:00+02:00' +--- +A repository ruleset carries a list of rules and a list of `bypass_actors`. The two are not +paired: **an actor on the bypass list is exempt from everything in that ruleset**, not from one +rule in it. + +The practical shape of the problem is a reasonable request that cannot be expressed. "Let me +merge when a required check is stuck, but still make me open a pull request" is two different +answers to two rules that had been written into one ruleset: + +```text +main-requires-checks-and-prs + required_status_checks build, governance + pull_request 1 approval, thread resolution +``` + +Adding a repository-admin bypass there grants the override on the check *and* the right to push +straight to `main`. There is no field that narrows it, because the rule entries have no bypass of +their own. + +## The repair is layout, not configuration + +Split by what you might one day want to override, one rule family per ruleset: + +```text +main-cant-be-overwritten deletion, non_fast_forward bypass: none +main-requires-prs pull_request bypass: none +main-requires-checks required_status_checks bypass: repository admin +``` + +The effective rules on the branch are identical, since GitHub unions every ruleset that matches +a ref. What changes is that the bypass now has somewhere to attach that does not drag the rest +with it. + +**This is worth deciding before the rulesets are written rather than after.** Splitting later +means deleting a ruleset and recreating two, and for the window between those two acts the +branch is enforcing less than it should. + +## Migrating from classic branch protection is not a translation + +Classic protection is one object per branch with `enforce_admins` as a single boolean over the +whole of it. Copying that arrangement into one ruleset reproduces the limitation that made it +blunt. The migration is the moment the granularity becomes available, and taking it literally is +how a repository ends up with either an admin who can delete `main` or a maintainer who cannot +merge past a runner outage. + +Two things to check rather than assume whilst doing it. `enforce_admins: false` was permission to +bypass *everything*, so a faithful-looking translation almost certainly over-grants. And +`RepositoryRole 5` is every repository admin, which on a personal repository is one person and on +a team repository is not. + +Confirm the result by reading the effective rules back, which is the only view that accounts for +several rulesets and classic protection applying at once: + +```bash +gh api repos///rules/branches/main --jq '[.[].type] | unique' +``` + +Create the rulesets before deleting the classic protection, and check that command in between. +The two coexist, so there is no moment where the branch is unguarded. diff --git a/docs/findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md b/docs/findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md new file mode 100644 index 0000000..100acff --- /dev/null +++ b/docs/findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md @@ -0,0 +1,90 @@ +--- +type: Finding +title: An unpinned puppeteer replaces the Chrome pinned beside it +description: Pinning a browser version next to an unpinned puppeteer pins nothing, because the library + launches the revision compiled into its own release rather than the one just installed. +tags: +- finding +- puppeteer +- linkspector +- pre-commit +- supply-chain +- ci-cd +status: stable +stale_after: '2027-04-09' +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T13:20:00+02:00' +--- +A pre-commit hook that had passed for months started failing on every pull request, with two +different Chrome versions in one error: + +```text +Check broken links (linkspector).....Failed + chrome@152.0.7977.64 .../chrome/linux-152.0.7977.64/chrome-linux64/chrome + 💥 Main error: Failed to launch the browser process + #0 ... /chrome/linux-155.0.8059.39/chrome-linux64/chrome +``` + +The first line is the install step reporting success. The stack trace is a different browser +entirely, three major versions ahead, and that is the one that crashed. + +## The pin that was not a pin + +The hook installed an exact Chrome and declared its dependencies like this: + +```yaml +additional_dependencies: ["@umbrelladocs/linkspector@0.5.6", "puppeteer"] +``` + +Two versions were pinned and the third was not. [puppeteer](../tools/puppeteer.md) resolves to +whatever is current at install time, and **each puppeteer release launches the Chrome revision +compiled into it**, not the one sitting in the cache from an earlier command. So the installed +browser was never the browser under test. The arrangement worked only because the current +puppeteer happened to default to a 152 build, close enough to the pinned 152 to be +indistinguishable from the thing actually working. + +That held until upstream moved: 25.11.0 defaults to Chrome 153, 25.12.0 to 154, and 25.13.0 to +155, which does not start at all on a GitHub Actions `ubuntu-latest` runner. Nothing in the +repository changed on the day it broke. + +The pinned value was `chrome@152.0.7977.64`, and no puppeteer release has ever defaulted to that +build. The two had never been in step. They had only ever been adjacent. + +## Why it was not noticed for a fortnight + +The hook is one of several in a job that gates every pull request, so the breakage was loud in +the sense of being red and silent in the sense of saying anything about its cause. The job had +last run green on 25 September. Everything opened in between failed on something that had +nothing to do with it, which is the expensive shape: the failure arrives attached to whatever +change happens to be in flight, and reads as that change's fault. + +What the outage cost separately was link checking itself. With the browser failing to launch, +nothing was being checked, and a genuinely broken link went in and sat there until the hook ran +again. + +## The repair + +Pin the library and derive the browser from it, rather than pinning the browser and hoping: + +```yaml +additional_dependencies: ["@umbrelladocs/linkspector@0.5.6", "puppeteer@25.10.0"] +``` + +```javascript +const CHROME = "chrome@152.0.7977.75"; +``` + +The two values are now one fact stated twice, and the mapping is checkable: unpack the published +tarball and read `package/src/revisions.ts`. + +## What the principle was missing + +[Pin transitive runtime dependencies, not just the tool](../principles/pin_transitive_runtime_dependencies_not_just_the_tool.md) +already said to pin the browser, and the snippet it carried was the configuration that broke. +The claim was right and the worked example was wrong in exactly the way that is hardest to see: +it pinned the runtime, and left unpinned the library that chooses the runtime. + +**A runtime is pinned by whatever decides which runtime starts.** Naming a version somewhere in +the file is not the same thing, and an explicit version string beside an implicit one reads as +rigour whilst providing none. diff --git a/docs/findings/20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md b/docs/findings/20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md new file mode 100644 index 0000000..3a5b01c --- /dev/null +++ b/docs/findings/20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md @@ -0,0 +1,55 @@ +--- +type: Finding +title: git-ai uninstall-hooks has no help flag and uninstalls when asked for one +description: Appending --help to the subcommand does not describe it, it runs it, so a command + issued to find out what a command would do removes the agent hooks from every installed agent. +tags: +- finding +- git-ai +- git +- agents +- dx +status: stable +stale_after: '2027-04-09' +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T10:50:00+02:00' +--- +`git-ai uninstall-hooks --help` does not print usage. It uninstalls the hooks, and reports what +it removed: + +```text +Coding Agents +✓ Claude Code: Hooks removed +✓ VS Code: No hooks to remove +⚠ VS Code: Extension must be uninstalled manually through the editor +✓ OpenCode: Hooks removed +``` + +The flag is not rejected and it is not reported as unknown. It is read as an argument to a +subcommand that takes none, ignored, and the subcommand proceeds. + +## What it cost + +One session, planning how to remove [git-ai](../tools/git_ai.md) from a machine, asked the +command what it would do before deciding whether to run it. The hooks came off Claude Code, +GitHub Copilot and opencode, and the opencode plugin file was deleted, at the point where the +question was still being researched rather than answered. + +Nothing was lost: the removal is reversible with `git-ai install-hooks`, which regenerates the +plugin with the binary's absolute path in it. What it changed was the order of the work, and that +is the shape of the risk here rather than the data loss there was not. + +## Why it is worth a page + +`--help` is the one flag it is safe to try on an unfamiliar command, and a CLI that treats it as +noise rather than as a request turns reconnaissance into execution. The ordinary habit for +reading a new tool, run the subcommand with `--help` and see what comes back, is unsafe against +this one. + +The flag that works is at the top level. **Run the bare command and read its own listing, rather +than asking a subcommand to describe itself**, whenever the subcommand's name suggests it +changes something. + +This is a claim about version 1.5.13, which is the release the shell installer put down in June +2026 and which `disable_auto_updates` held in place. diff --git a/docs/findings/20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md b/docs/findings/20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md new file mode 100644 index 0000000..fd75b65 --- /dev/null +++ b/docs/findings/20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md @@ -0,0 +1,100 @@ +--- +type: Finding +title: A hard freeze leaves no journal, and the last entry is not where it hung +description: A frozen boot's journal stops on an arbitrary driver line, because journald syncs every + five minutes and a boot that froze after twelve seconds never reached a sync. +tags: +- finding +- linux +- systemd +- journald +- debugging +status: stable +stale_after: '2027-04-14' +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:35:00+02:00' +--- +A machine that froze and had to be held down leaves a journal for that boot which simply stops. +Whatever driver logged the last line looks like the thing that hung. It usually is not: the cut +is where the journal last reached disk, and that has nothing to do with where the kernel +stopped. + +Observed on systemd 261 with default journald settings. + +## Symptom + +A boot that froze, ending mid-enumeration on a line that reads like a culprit: + +```text +09:10:13 kernel: intel_ipu6_psys.psys intel_ipu6.psys.40: psys probe minor: 0 +-- no further entries -- +``` + +The camera stack is a plausible thing to hang on, and that is the trap. The same boot had no +error, no warning and no trace before it, so the last line is the only thing on offer. + +## Why the journal stops there + +journald does not write each entry to disk as it arrives. `SyncIntervalSec` defaults to five +minutes, and the manual page is explicit that the only thing bypassing it is priority: + +> Note that syncing is unconditionally done immediately after a log message of priority CRIT, +> ALERT or EMERG has been logged. This setting hence applies only to messages of the levels +> ERR, WARNING, NOTICE, INFO, DEBUG. + +A boot that froze twelve seconds in never came close to a scheduled sync. What survived is +whatever the kernel's own writeback happened to flush before power was cut, so the final entry +marks an arbitrary moment and not an interesting one. + +## Telling it from a real hang + +Compare the dead boot against a healthy one at the same point. If the next boot carries straight +on past that line, it is a flush boundary: + +```bash +journalctl -b -1 -k | tail -5 +journalctl -b 0 -k | grep -A10 'psys probe minor' +``` + +Here the healthy boot continued into `sof-audio`, `iwlwifi` and Bluetooth setup within the same +second, which rules the camera out. The entry counts say the same thing more bluntly: 1351 lines +against 3396 for a boot that reached a desktop. + +**The absence of a high-priority message is itself evidence.** A panic, an oops or a hard lockup +logs at CRIT or above, which journald syncs immediately, so it survives a power cut that loses +everything around it. A frozen boot with nothing at priority 2 or below did not get as far as +logging a kernel failure: + +```bash +journalctl -b -1 -p 2 +``` + +`-- No entries --` means the freeze was never diagnosed by the kernel, not that the evidence was +lost. + +## Keeping the next one + +Two changes, both worth reverting once the problem is found. Shorten the sync interval so a +short boot still lands on disk: + +```text +# /etc/systemd/journald.conf.d/debug.conf +[Journal] +SyncIntervalSec=5s +``` + +Then enable magic SysRq, so a frozen machine can be made to log something rather than be held +down: + +```bash +echo 'kernel.sysrq = 1' | sudo tee /etc/sysctl.d/99-sysrq.conf +``` + +`Alt+SysRq+w` dumps blocked tasks and `Alt+SysRq+l` a backtrace from every CPU, both at a +priority that syncs. A machine too far gone for SysRq is itself a finding, since it separates a +userspace hang from one that has taken the kernel with it. + +The cost of the first is an fsync every five seconds for as long as it is left in place. The cost +of the second is that the key combination also reboots the machine, which is the reason it ships +disabled. diff --git a/docs/findings/20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md b/docs/findings/20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md new file mode 100644 index 0000000..13c817a --- /dev/null +++ b/docs/findings/20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md @@ -0,0 +1,83 @@ +--- +type: Finding +title: ahci probe fails with error -12 on an Intel VMD machine +description: A kernel line that reads as a storage controller running out of memory, logged at every + boot since the machine was installed, because ahci matches Intel RAID-class devices generically and + Volume Management Device is one. +tags: +- finding +- linux +- kernel +- storage +- nvme +- hardware +status: stable +stale_after: '2027-04-14' +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:30:00+02:00' +--- +A machine with Intel Volume Management Device enabled logs an `ahci` probe failure on every +boot. Nothing is wrong with the drive, and the message is not new, which is the part worth +knowing before it is read as evidence of anything. + +Observed on kernel 6.18.49, with an SK hynix PC801 NVMe behind VMD on a +[Dell Precision 5470](../hardware/dell_precision_5470.md). + +## Symptom + +```text +kernel: ahci 0000:00:0e.0: probe with driver ahci failed with error -12 +``` + +`-12` is `-ENOMEM`. A storage controller that cannot be brought up for want of memory is an +alarming thing to find in a journal, and it sits near the top of every boot where it is easy to +reach for. + +## What the device is + +`00:0e.0` is not a SATA controller. It is the Volume Management Device, which presents itself in +the RAID class and hides the NVMe drive behind a PCI domain of its own: + +```text +0000:00:0e.0 RAID bus controller [0104]: Intel Corporation Volume Management Device NVMe RAID Controller [8086:467f] + Kernel driver in use: vmd +10000:e1:00.0 Non-Volatile memory controller [0108]: SK hynix Platinum P41/PC801 NVMe Solid State Drive + Kernel driver in use: nvme +``` + +The domain number is the tell. `10000` is synthetic, created by `vmd` for the devices it carries, +and a drive addressed there is a drive reached through VMD rather than off the root complex. + +`ahci` carries a catch-all match for Intel storage devices in the RAID class, so it attempts this +one and gets nowhere. Its own device table never names the part: + +```bash +$ modinfo ahci | grep -c 467f +0 +``` + +The probe failing is the two drivers sorting out which owns the device, and `vmd` is the one that +ends up bound. + +## Confirming it is old news + +Count the line across every boot the journal still holds, rather than reading it in the one boot +being investigated: + +```bash +for b in $(journalctl --list-boots -q | awk '{print $1}'); do + printf '%s: %s\n' "$b" "$(journalctl -b "$b" -k 2>/dev/null | grep -c 'ahci.*error -12')" +done +``` + +A constant `1` is the answer that settles it. On this machine it was 1 in all 72 recorded boots, +including every boot that worked. + +## The general case + +A kernel error found whilst investigating a fresh failure is worth counting before it is +believed. The journal holds every boot, so the question "was this here last week, when everything +worked?" is one command, and it is the cheapest way to tell a cause from a constant. It is also +the step most easily skipped, because a message that looks like the answer discourages looking +for another one. diff --git a/docs/findings/index.md b/docs/findings/index.md index c8dcdf6..46ed1ce 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,11 @@ And the subject lives in the tags rather than in a directory, per ### October +- 2026-10-14: [A hard freeze leaves no journal, and the last entry is not where it hung](20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md) - the driver line a frozen boot stops on, which marks where the journal reached disk and nothing else +- 2026-10-14: [ahci probe fails with error -12 on an Intel VMD machine](20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md) - a storage controller apparently out of memory, in all 72 boots including the ones that worked +- 2026-10-09: [A ruleset bypass applies to every rule in that ruleset](20261009_a_ruleset_bypass_applies_to_every_rule_in_that_ruleset.md) - why "let me override a red check, but still make me open a pull request" needs two rulesets rather than one setting +- 2026-10-09: [An unpinned puppeteer replaces the Chrome pinned beside it](20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md) - a version string that read as rigour, next to the implicit one that actually chose the browser +- 2026-10-09: [git-ai uninstall-hooks has no help flag and uninstalls when asked for one](20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md) - the one flag it is safe to try on an unfamiliar command, read as noise and the subcommand run anyway - 2026-10-01: [A config symlink makes every omo migration fail on start](20261001_a_config_symlink_makes_every_omo_migration_fail_on_start.md) - a path check that follows the link before it judges the name, and a journal that would have overwritten a fortnight of edits had it succeeded - 2026-10-01: [A plugin upgrade renamed a routing category and breached provider isolation](20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md) - a pin that stayed valid whilst the key it named stopped existing, and the three guards that all declined to notice - 2026-10-01: [zsh does not expand a tilde in env VAR=~/path, and opencode creates the literal directory](20261001_zsh_does_not_expand_a_tilde_in_env_var_assignments.md) - a shell that leaves the tilde alone, a tool that makes the directory anyway, and an error about neither diff --git a/docs/guides/decide_whether_a_linux_freeze_is_failing_hardware.md b/docs/guides/decide_whether_a_linux_freeze_is_failing_hardware.md new file mode 100644 index 0000000..7358602 --- /dev/null +++ b/docs/guides/decide_whether_a_linux_freeze_is_failing_hardware.md @@ -0,0 +1,131 @@ +--- +type: Guide +title: Decide whether a Linux freeze is failing hardware +description: Six checks that separate a one-off freeze from a machine on its way out, each asking a + component to report its own error counters rather than inferring from symptoms. +tags: +- guide +- linux +- hardware +- debugging +- storage +- systemd +status: stable +stale_after: '2027-04-14' +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:40:00+02:00' +--- +A machine that froze two or three times in a morning raises one question before any other: +is this hardware on its way out? The checks below answer it in a few minutes, and they are +worth running in order, because the cheapest one is also the most decisive. + +Every component here keeps its own error counters. The point of the sequence is to read +those counters rather than reason from symptoms, which is what makes the answer defensible +either way. + +Commands are as of smartmontools 7.5 and systemd 261. + +## 1. Count the short boots + +```bash +journalctl --list-boots -q -o json | python3 -c ' +import sys, json, datetime +for b in json.load(sys.stdin): + d = (b["last_entry"] - b["first_entry"]) / 1e6 + t = datetime.datetime.fromtimestamp(b["first_entry"] / 1e6) + mark = " <-- short" if d < 300 else "" + print("%5d %s %8.1fs%s" % (b["index"], t.strftime("%Y-%m-%d %H:%M"), d, mark)) +' +``` + +**This is the check that usually settles it.** Hardware degrades, so a failing machine leaves +a trail: boots that get shorter, or a scattering of short ones over weeks. One isolated +cluster of short boots in a journal going back months is an event, not a trend. + +Run this first. If it shows a trend, the rest of the sequence tells you which component; if +it shows one cluster, the rest is confirmation and goes quickly. + +## 2. Ask the drive + +```bash +sudo smartctl -a /dev/nvme0n1 +``` + +Four fields carry the answer, and the overall assessment is not among them: + +| Field | A healthy drive | +| --- | --- | +| `Media and Data Integrity Errors` | `0` | +| `Error Information Log Entries` | `0` | +| `Available Spare` | at or near `100%`, and above the threshold below it | +| `Percentage Used` | well under `100` | + +`SMART overall-health self-assessment test result: PASSED` is a summary that only fails once +a drive has crossed a threshold it sets itself, so it passes on drives with a real problem. +Read the counters. + +`Unsafe Shutdowns` counts up every time the machine is held down, so it rises during an +investigation like this one and says nothing about the drive. + +## 3. Ask the memory controller + +```bash +journalctl -k | grep -iE 'edac|machine check|hardware error' +``` + +On Intel client parts the `igen6_edac` module reports memory errors, and the useful output is +the module announcing itself and nothing further. Any line counting corrected errors means +memory worth replacing, whether or not it has caused a freeze yet. + +Memory with no error-correcting code reports nothing at all, in which case this check is +silent for a different reason and `memtest86+` is the only answer. + +## 4. Ask the CPU + +A machine check exception is the kernel being told by the processor that something failed in +hardware. The grep above catches them. They are rare, specific and unambiguous: a machine +logging MCEs has a hardware fault and the investigation is over. + +## 5. Rule out heat + +```bash +sudo smartctl -a /dev/nvme0n1 | grep -i 'Temperature Time' +sensors +``` + +`Warning Comp. Temperature Time` and `Critical Comp. Temperature Time` are lifetime counters +in minutes. Both at `0` means the drive has never once been hot, across its whole service +life, which is a stronger statement than any instantaneous reading. + +`sensors` gives the present picture. Compare the package temperature against the `crit` value +beside it rather than against a number you remember. + +## 6. Discount the constants + +```bash +journalctl -b 0 -p err | sort -u -k5 | head -40 +``` + +A fresh look at a boot log turns up alarming lines that have always been there. Count any +candidate across previous boots before treating it as a cause; +[the VMD one](../findings/20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md) +is the worked example, and it appeared in all 72 boots on the machine this guide came from. + +## Reading the frozen boot itself + +The boot that froze is the least informative thing available, and +[its last entry is not where it hung](../findings/20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md). +Check it for a message at priority CRIT or above, which would have been synced, and otherwise +spend the time on the six checks instead. + +## What the answer looks like + +All six quiet, against one isolated cluster of freezes, is a software or firmware fault and +not a dying machine. That conclusion is worth stating with the counters behind it, because +the honest version of it is narrow: the components that keep error counters are reporting +none, which is not the same as a guarantee. + +What it does buy is the right next step. A machine with no hardware evidence against it gets +its firmware updated, its recent package upgrades examined and its instrumentation improved +for the next occurrence, rather than being replaced on a suspicion. diff --git a/docs/guides/index.md b/docs/guides/index.md index 44efd11..f20821c 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -12,3 +12,6 @@ Conventions: [Date a page whose claim is about a version](../knowledge_management/date_a_page_whose_claim_is_about_a_version.md). - [Let CI push to a protected branch with a GitHub App](let_ci_push_to_a_protected_branch_with_a_github_app.md) - an App installed on one repository, minting an hour-long token, instead of a maintainer's personal token in a secret +- [Uninstall git-ai and the state it left in every repository](uninstall_git_ai.md) - four removals in a fixed order, because the daemon reinstalls the hooks it just lost and the data outlives the binary +- [Pin the browser behind a pre-commit link checker](pin_the_browser_behind_a_pre_commit_link_checker.md) - which puppeteer version to choose, how to read the Chrome it carries, and why a separate browser install line is a liability +- [Decide whether a Linux freeze is failing hardware](decide_whether_a_linux_freeze_is_failing_hardware.md) - six checks that read a component's own error counters, ordered so the one that usually settles it runs first diff --git a/docs/guides/pin_the_browser_behind_a_pre_commit_link_checker.md b/docs/guides/pin_the_browser_behind_a_pre_commit_link_checker.md new file mode 100644 index 0000000..3629ad3 --- /dev/null +++ b/docs/guides/pin_the_browser_behind_a_pre_commit_link_checker.md @@ -0,0 +1,114 @@ +--- +type: Guide +title: Pin the browser behind a pre-commit link checker +description: How to pin puppeteer so a link checker's headless Chrome is reproducible, which version + to choose, and why a separate chrome install line is a liability rather than a safeguard. +tags: +- guide +- puppeteer +- linkspector +- pre-commit +- supply-chain +- links +status: stable +stale_after: '2027-04-09' +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T17:30:00+02:00' +--- +[linkspector](../tools/linkspector.md) settles whatever an HTTP request cannot by loading the +page in headless Chrome, so the browser is part of what decides whether a link is reported as +broken. [Pin transitive runtime dependencies, not just the tool](../principles/pin_transitive_runtime_dependencies_not_just_the_tool.md) +says that browser has to be pinned. This is how, and the order matters because the obvious way +round is the wrong one. + +**Pin the library, then read the browser off it.** [puppeteer](../tools/puppeteer.md) launches +the Chrome revision compiled into its own release. Pinning a browser version without pinning +puppeteer pins nothing, which is +[a finding that cost a fortnight of red pull requests](../findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md). + +## 1. Find the range the checker allows + +linkspector depends on puppeteer itself, with a caret range that differs by release, so the +version to pin is constrained by the linkspector version already pinned beside it: + +```bash +npm view @umbrelladocs/linkspector@0.5.3 dependencies.puppeteer # ^24.37.5 +npm view @umbrelladocs/linkspector@0.5.6 dependencies.puppeteer # ^25.2.1 +``` + +Pinning outside that range installs two copies of puppeteer and leaves which one runs to npm's +resolution, which is the opposite of the point. + +## 2. Pick the version the range resolves to today + +Take the newest release inside the range. Pinning it freezes what would have been installed +anyway, so nothing changes on the day of the pin and nothing changes on any day after it: + +```bash +npm view puppeteer-core versions --json +``` + +## 3. Read the Chrome it carries, for the comment + +The mapping is not published as a table anywhere; it is a constant in the package, and the only +reliable way to get it for a release that is not current is to unpack that release: + +```bash +npm pack puppeteer-core@24.39.1 +tar -xzOf puppeteer-core-24.39.1.tgz package/src/revisions.ts | grep chrome +# chrome: '146.0.7680.76', +``` + +## 4. Write the pin with the mapping beside it + +```yaml +- id: linkspector + name: Check broken links (linkspector) + entry: linkspector check -c .linkspector.yml + language: node + # puppeteer is pinned exactly, not left to linkspector's own `^24.37.5`. + # It is what decides which Chrome launches. 24.39.1 -> Chrome 146.0.7680.76. + additional_dependencies: ['@umbrelladocs/linkspector@0.5.3', 'puppeteer@24.39.1'] + pass_filenames: false + files: \.md$ +``` + +The comment is doing real work: the version pair cannot be checked against anything in the +repository, so without it the next reader has only two opaque numbers and no way to tell whether +they still agree. + +## 5. Do not add a separate browser install step + +The tempting shape is an extra command that installs an exact Chrome before the check: + +```yaml +entry: bash -c 'npx --yes puppeteer browsers install chrome@148.0.7778.97 && linkspector check -c .linkspector.yml' +``` + +**This does not do what it looks like.** It populates the puppeteer cache; it does not change +which revision puppeteer launches. If the number differs from the pinned release's own default, +the install is wasted work and the browser under test is not the one named in the file. If it +matches, it is a second copy of a fact that is already stated by the pin, free to drift from it. + +Delete it, and let `puppeteer` download its own default on install. One version, stated once. + +## 6. Check it actually launches + +A browser that fails to start reports as a broken link, so a green run is the only confirmation +that the pin works: + +```bash +prek run linkspector --all-files +``` + +Worth doing on the machine *and* letting CI do it, because the two have different caches and a +Chrome that starts on a developer's desktop does not always start on a runner. + +## Keeping it moving + +Dependabot's `pre-commit` ecosystem bumps `additional_dependencies`, so the pin does not rot +quietly. A bump to linkspector that moves its puppeteer range will want step 1 again, and the +comment from step 4 is what tells a reviewer that the two numbers in the diff belong together. + +Versions read: linkspector 0.5.3 and 0.5.6, puppeteer 24.39.1 and 25.10.0, on 9 October 2026. diff --git a/docs/guides/uninstall_git_ai.md b/docs/guides/uninstall_git_ai.md new file mode 100644 index 0000000..5cf52e8 --- /dev/null +++ b/docs/guides/uninstall_git_ai.md @@ -0,0 +1,143 @@ +--- +type: Guide +title: Uninstall git-ai and the state it left in every repository +description: Removing git-ai means four separate removals in a fixed order, because its daemon + reinstalls agent hooks daily and its per-repository data outlives the binary by a long way. +tags: +- guide +- git-ai +- git +- agents +- provenance +- linux +status: stable +stale_after: '2027-04-09' +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T10:40:00+02:00' +sources: +- id: git-ai-uninstall-section + resource: https://ftschindler.github.io/running-linux/done/20260624-git-ai-integration-with-opencode/ + title: Git AI Integration with OpenCode + author: human:felix_schindler + last_modified: '2026-06-24' +--- +[git-ai](../tools/git_ai.md) installs itself into four places that have nothing to do with each +other, and the removal instructions in general circulation cover two of them. The write-up this +machine's own installation followed[^git-ai-uninstall-section] names the home directory, the +`PATH` line and the editor extension, and stops there. What it leaves out is everything the tool +wrote whilst it was running, which on one machine after three and a half months was 125 `.git/ai` +directories and 19 repositories carrying a `refs/notes/ai` ref. + +The steps below are in the order they have to run in. The first two are the ones whose order is +not arbitrary. + +## 1. Take the agent hooks off first, whilst the binary still exists + +```bash +git-ai uninstall-hooks +``` + +It reports per agent, and it removed hooks from Claude Code, GitHub Copilot and opencode here. +Running it before deleting `~/.git-ai` is what makes it available at all, and it is also what +stops the hooks being orphaned: each one is a file naming an absolute path to the binary, so a +binary deleted first leaves hooks that fail on every tool call rather than hooks that are gone. + +Two things it does not do. It does not touch the VS Code extension, and says so in its own +output. And it is not a way of switching the tool off on its own, because **the daemon reinstalls +the hooks daily**: the removal holds only until step 2. + +Read [the finding about this command](../findings/20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md) +before running anything exploratory near it. + +## 2. Stop the daemon and remove the home directory + +```bash +pkill -f 'git-ai bg' +rm -rf ~/.git-ai +``` + +`~/.git-ai` holds the binary, `config.json`, and SQLite databases of metrics and session +transcripts. It had reached 365 MB. Nothing else on the system owns it: the installer is a shell +script, so there is no package to remove and `pacman -Qo` on the binary reports no owner. + +## 3. Take the `PATH` line out of every shell rc file + +The installer appends to each rc file it recognises, and dates what it wrote: + +```text +# Added by git-ai installer on Wed 24 Jun 14:03:34 CEST 2026 +export PATH="$HOME/.git-ai/bin:$PATH" +``` + +It had written that into both `~/.zshrc` and `~/.bashrc` here. Some installations get a symlink +at `~/.local/bin/git-ai` instead, so check for that too. + +**Read what you are about to delete rather than grepping for the name and removing every hit.** +`git-ai` is an unremarkable thing to call a shell function, and this machine had an unrelated one +of exactly that name, switching git identities for agent work. A sweep by name would have taken +it. + +## 4. Remove the editor extension + +```bash +code --uninstall-extension git-ai.git-ai-vscode +``` + +The uninstaller declines this deliberately and prints a line saying so, which is easy to miss +amongst the successes above it. The same extension id serves Cursor, Windsurf and Antigravity, so +remove it from each editor that has it. + +## 5. Sweep the per-repository data + +This is the step with no command in any official instruction, and the one that accounts for +almost all of what is left. Two kinds of state, in two passes. + +The checkpoint logs, one directory per repository the tool ever saw: + +```bash +find ~ -type d -name ai -path '*/.git/*' -not -path '*/.git/ai/*' -prune -exec rm -rf {} + +``` + +Then the notes, which are refs rather than files and have to be deleted through git so the +repository stays consistent: + +```bash +find ~ -type f -path '*/.git/refs/notes/ai' -printf '%h/../../..\n' | while read -r repo; do + git -C "$repo" update-ref -d refs/notes/ai + git -C "$repo" for-each-ref --format='%(refname)' refs/notes/ai-remote \ + | xargs -r -n1 git -C "$repo" update-ref -d +done +``` + +List before deleting. Most of the hits are throwaway, in package manager caches and docker +volumes, and the handful that are real work are worth looking at rather than sweeping, because a +note is the only copy of what it records. + +## 6. Decide about the notes that are already on a remote + +A repository with a `refs/notes/ai-remote/origin` ref has pushed its notes, and steps 5 deletes +the local copy only. The remote keeps them, a fetch brings them back, and anyone who has cloned +already has them: + +```bash +git push origin :refs/notes/ai +``` + +Run that per repository, and only where the notes are actually unwanted. This is the one step +that is visible to other people, and the one that cannot be undone from the machine you are +standing at. + +## 7. Undo the per-repository git config + +The June setup added two settings to each repository that used the tool, which now refer to a ref +that no longer exists: + +```bash +git config --unset-all remote.origin.fetch '\+refs/notes/ai:refs/notes/ai' +git config --unset push.defaultNotesRef +``` + +Harmless if left: a fetch refspec for a ref the remote does not have is not an error. Worth +removing anyway, because the next person to read that config will spend a minute working out what +put it there. diff --git a/docs/hardware/dell_precision_5470.md b/docs/hardware/dell_precision_5470.md index 02c9a37..a2f576b 100644 --- a/docs/hardware/dell_precision_5470.md +++ b/docs/hardware/dell_precision_5470.md @@ -26,6 +26,7 @@ The Precision 5470 is Dell's 14-inch mobile workstation on Intel's Alder Lake-P | Form factor | Notebook, 14-inch, DMI chassis type 10 | | Processor | Intel Core i7-12800H, Alder Lake-P, 14-core hybrid | | Memory | 32 GB, of which 30 GiB is visible to the kernel | +| Storage | A 2 TB SK hynix PC801 NVMe, reached through Intel Volume Management Device at `00:0e.0` rather than off the root complex, carrying LUKS and LVM | | Graphics | Intel Iris Xe, Alder Lake-P GT2, at `00:02.0`, alongside a discrete NVIDIA GPU at `01:00.0` currently switched off | | Display | eDP-1, 2560x1600, 301 x 188 mm | | Audio | Alder Lake PCH-P High Definition Audio controller at `00:1f.3`, driving a SoundWire codec through SOF | @@ -113,3 +114,5 @@ something else, and on this machine it is noise. - [Bluetooth A2DP fails with Protocol not available after resume](../findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md), which is a logind bookkeeping problem rather than a Bluetooth one, and survives a hibernate cycle on this machine specifically because hibernation works - [A Bluetooth headset reconnects in A2DP instead of the saved profile](../findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md), met with [the OpenMove](openmove_by_shokz.md) +- [ahci probe fails with error -12 on an Intel VMD machine](../findings/20261014_ahci_probe_fails_with_error_12_on_an_intel_vmd_machine.md), a line this machine has logged at every boot since it was installed, and which reads as a storage fault +- [A hard freeze leaves no journal, and the last entry is not where it hung](../findings/20261014_a_hard_freeze_leaves_no_journal_and_the_last_entry_is_not_where_it_hung.md), met after three failed boots here, and general to any machine running journald diff --git a/docs/investigations/index.md b/docs/investigations/index.md index 1337d3f..bce3f29 100644 --- a/docs/investigations/index.md +++ b/docs/investigations/index.md @@ -18,3 +18,4 @@ date. Where an investigation deliberately stops short of choosing, it says so. - [skills.sh: repository layout, selective install and telemetry](skills_sh_repository_layout_and_telemetry.md) - where the skills installer looks, how a user takes one skill rather than all, and what it reports back - [Sharing one user-level AGENTS.md across harnesses](sharing_one_user_level_agents_md_across_harnesses.md) - a crowded tool category that almost entirely refuses to write to the home directory, and why - [Spell-checking prose in a pre-commit hook](spell_checking_prose_in_a_pre_commit_hook.md) - why two spell checkers catch different mistakes, and the one word that shows it +- [What an MCP server can be, and who can reach one](mcp_server_entry_points_and_transports.md) - two transports, five shapes and six callers, and the two variables that decide which are reachable diff --git a/docs/investigations/mcp_server_entry_points_and_transports.md b/docs/investigations/mcp_server_entry_points_and_transports.md new file mode 100644 index 0000000..be540a9 --- /dev/null +++ b/docs/investigations/mcp_server_entry_points_and_transports.md @@ -0,0 +1,168 @@ +--- +type: Investigation +title: What an MCP server can be, and who can reach one +description: The two standard transports, the five shapes a server takes, and the six places a call + can come from, read against the two variables that decide which of them are available. +tags: +- ai-agents +- agents +- mcp +- tooling +- architecture +status: draft +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T12:00:00Z' +stale_after: '2027-04-30' +sources: +- id: mcp-transports + resource: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports + title: 'Model Context Protocol: Transports' + last_modified: '2026-10-14' +- id: mcp-architecture + resource: https://modelcontextprotocol.io/docs/2026-07-28/learn/architecture + title: 'Model Context Protocol: Architecture' + last_modified: '2026-10-14' +- id: mcp-server-concepts + resource: https://modelcontextprotocol.io/docs/2026-07-28/learn/server-concepts + title: 'Model Context Protocol: Server concepts' + last_modified: '2026-10-14' +- id: mcp-authorization + resource: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + title: 'Model Context Protocol: Authorization' + last_modified: '2026-10-14' +--- +**Context** - the word "server" suggests something running somewhere, with a port and an +uptime. Most MCP servers are not that. The question was what an MCP server actually is as an +artefact, and which consumers can reach one, before committing to where any particular one +should live. + +- **Read on**: 2026-10-14 +- **Method**: the specification read directly at revision 2026-07-28; the entry points + enumerated from the harnesses in use here and from the hosted products that consume MCP; + [markitdown](../tools/markitdown.md) read as the worked example of a server that is also a + command. + +The survey stops short of recommending a transport for any particular server. What it does +settle is which question decides that, and the answer is not a technical preference. + +## A server is a program, not a service + +The specification defines two standard transports, and calls a transport a binding: it decides +how messages are framed and delivered, not what they mean[^mcp-transports]. + +**stdio is a subprocess the client launches**, speaking newline-delimited JSON-RPC over its +standard streams[^mcp-transports]. No port, no daemon, no network. It starts when the harness +starts and dies with it. This is most of what exists today. + +**Streamable HTTP is an endpoint**, where each message is a POST and the reply arrives as JSON +or as a stream scoped to that request[^mcp-transports]. The hosted servers are this, and so is +anything that has to be reached from a machine that is not yours. + +So a stdio server's configuration is a command line: + +```json +{ "command": "uvx", "args": ["markitdown-mcp"] } +``` + +That is not a service you run. It is a program the harness launches on demand, exactly like a +command-line tool, because it is one. It happens to write JSON-RPC on stdout instead of text a +person reads. + +## What the protocol adds over a command line + +[markitdown](../tools/markitdown.md) ships both ways, which makes the comparison concrete. +`markitdown report.pdf` and `convert_to_markdown(uri=...)` do the same work. + +Three things change in the wrapping. + +**The model is told the tool exists.** It asks for the server's tool list and gets names, +descriptions and JSON schemas back[^mcp-server-concepts]. With a command, somebody has to have +put `--help` into the context, or the model guesses the flags. + +**The call and the result are typed.** Arguments are a validated object rather than a string +to shell-quote, and the result comes back structured rather than as stdout to pattern-match. + +**The process stays alive between calls.** State lives for as long as the process does, so a +server can hold an open browser, a warm database connection or a loaded index. A command pays +its startup cost every invocation. + +The cost is a configuration entry, a process to supervise, and a tool definition occupying +context on every turn whether it gets used or not. + +## Five shapes, from a script to a tenancy + +The category is much wider than "wrapper around an API", and the shapes have little in common +beyond the protocol. + +- **A pure function**, holding nothing: a converter, a formatter, a unit calculation. Honestly + close to a command with a schema bolted on. +- **Scoped access to a local resource**: a filesystem, a SQLite file, a git repository. The + value over a shell is that the server decides which paths are reachable. +- **A stateful machine**: a browser, a REPL, a long-lived kernel. The surviving process is the + whole point, and a command cannot do this at all. +- **A facade over a remote API**: an issue tracker, a chat platform. It holds the credential so + the model never sees it. +- **A provider of readable material rather than callable tools.** The protocol carries + resources and prompt templates alongside tools[^mcp-server-concepts], and almost nobody uses + them. The most interesting unexplored corner. + +## Six places a call comes from + +A harness with MCP enabled is the obvious consumer and about one sixth of the picture. + +| Entry point | Example | Can spawn a subprocess? | Credential comes from | +| --- | --- | --- | --- | +| Interactive coding harness | opencode, VS Code, Cursor | Yes | your environment | +| Desktop chat application | Claude Desktop | Yes | your environment | +| Headless agent on someone's CI | a coding agent in a build container | In principle, but unattended | a provisioned secret | +| Hosted web product | a connector in a browser-based assistant | No | OAuth, per user | +| Your own code as the client | an SDK in a script or an application | Your choice | your choice | +| A person, by hand | an inspector or a test harness | Yes | yours | + +The last row is worth keeping in view. A human calling the tool list and then a tool proves the +protocol does not require a model to be present, which is the cheapest way to test a server. + +The desktop chat application is separated from the coding harness deliberately. It has the same +power to launch a subprocess and no repository, no working directory and no project +conventions, so anything assuming "there is a project here" breaks on it. + +## Two variables decide which of those are available + +Every row above is a position on two axes, and neither is about the protocol. + +**Who launches the process.** A consumer running on a machine you control, with permission to +fork children, can use stdio. One that is not cannot, and needs the server to already exist +somewhere as an endpoint. + +**Who holds the credential.** A local stdio server reads your environment variables and your +configuration files, because it runs as you. A remote server has no "you" to borrow from, so it +needs its own authorisation story, which the specification builds on OAuth[^mcp-authorization]. + +Those two make three tiers rather than two, and the middle one is easy to miss. A build +container and a hosted web product are both "not my machine", but the container can carry a +secret handed to it in advance whilst the web product needs a user to complete an interactive +flow. Designing for "local or remote" puts those two in one bucket and they do not behave +alike. + +## Two shapes that sit outside the grid + +**The gateway** is an MCP server to its clients and an MCP client to many servers upstream. It +fronts twenty of them as one endpoint, and adds authorisation, auditing, rate limits and tool +filtering on the way through. This is why client and server are roles held per connection +rather than properties of a program[^mcp-architecture]. + +**An agent exposed as a server** lets another agent call it as a tool. That is composition +rather than capability, and it is the point at which "what is a tool" and "what is an agent" +stop being answerable separately. + +## Bottom line + +**An MCP server is a calling convention for handing a model a capability**, uniform across +everything from a forty-line script to a multi-tenant service. Asking whether something "could +just be a command-line tool" usually has the answer that it already is one, and the protocol is +the adapter that makes it legible to a model rather than to a person at a prompt. + +Which transport a given server speaks is therefore decided by its furthest intended consumer +rather than by its own convenience, and that choice is expensive to revisit: +[give an MCP server an HTTP surface before anything needs one](../principles/give_an_mcp_server_an_http_surface_before_anything_needs_one.md). diff --git a/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md b/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md index 147d8a1..db0275d 100644 --- a/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md +++ b/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md @@ -24,7 +24,7 @@ sources: title: Rules - opencode docs last_modified: '2026-09-22' - id: opencode-src - resource: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/instruction.ts + resource: https://github.com/anomalyco/opencode/blob/fc7e4cf93e660ad5ddabb0c48ecc99e934ebfe95/packages/opencode/src/session/instruction.ts title: 'opencode: session/instruction.ts' last_modified: '2026-09-23' - id: claude-memory diff --git a/docs/log.md b/docs/log.md index 5971e10..d43dce5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,34 @@ # Log +## 2026-10-14 + +- write out "Wrapping the kb skills in a federation layer", which had stood as a stub, and take it out of draft +- extend the federated-knowledge-skills tool page with sharing, migration and the subscribed rule, and bring its status and sources current +- correct "Federating my knowledge base as privacy-tiered OKF bundles", which said the federation layer was at design stage, and point it at the blueprint it deferred +- add principle, "A version stamp is bookkeeping about capabilities, not a lock" +- add tool, "federated-agent-kits (Felix Schindler)", and link it from the person page +- add principle, "Subscribe to shared instruction text rather than pasting a copy" +- add blueprint, "Sharing one knowledge bundle on two branches" +- add principle, "Give a shared repository two clocks when review is slower than the work" +- add finding, "A hard freeze leaves no journal, and the last entry is not where it hung" +- add guide, "Decide whether a Linux freeze is failing hardware" +- add a storage row to "Dell Precision 5470", and link the two new findings from it +- add finding, "ahci probe fails with error -12 on an Intel VMD machine" + +## 2026-10-09 + +- add finding, "A ruleset bypass applies to every rule in that ruleset" +- point the linkspector hook comment at the browser-pinning guide +- add guide, "Pin the browser behind a pre-commit link checker", and link it from the puppeteer tool page +- extend "Pin GitHub Actions to full commit SHAs" to name reusable workflows, with the permissions argument and a snippet line +- add exploration, "Tracking AI authorship with git-ai", and link it from the git-ai tool page +- add tool, "puppeteer" +- add finding, "An unpinned puppeteer replaces the Chrome pinned beside it" +- correct the snippet in "Pin transitive runtime dependencies, not just the tool", which pinned the browser and left the library choosing it unpinned +- add tool, "git-ai" +- add guide, "Uninstall git-ai and the state it left in every repository" +- add finding, "git-ai uninstall-hooks has no help flag and uninstalls when asked for one" + ## 2026-10-07 - rename the research section to investigations, moving all eight of its pages, its genre note and its nav entry, and dropping the now-redundant research tag from each diff --git a/docs/people/felix_schindler.md b/docs/people/felix_schindler.md index 28cd23e..1741e33 100644 --- a/docs/people/felix_schindler.md +++ b/docs/people/felix_schindler.md @@ -23,6 +23,7 @@ generated: { by: human:felix_schindler, at: 2026-09-07T13:00:00Z } Things of mine that have a page here. Tagged `self-authored`, wherever their nature puts them. - [federated-knowledge-skills (Felix Schindler)](../tools/federated_knowledge_skills_schindler.md) - the skill, CLI and pre-commit hooks this knowledge base is written through +- [federated-agent-kits (Felix Schindler)](../tools/federated_agent_kits_schindler.md) - the CLI that subscribes to skills and rules and renders them where each harness looks ## Find me on diff --git a/docs/principles/a_version_stamp_is_bookkeeping_about_capabilities_not_a_lock.md b/docs/principles/a_version_stamp_is_bookkeeping_about_capabilities_not_a_lock.md new file mode 100644 index 0000000..3224214 --- /dev/null +++ b/docs/principles/a_version_stamp_is_bookkeeping_about_capabilities_not_a_lock.md @@ -0,0 +1,58 @@ +--- +type: Principle +title: A version stamp is bookkeeping about capabilities, not a lock +description: Where a tool is installed by copying and keeps state the user owns, record which release + that state has been brought up to, and use it to raise questions rather than to block work. +tags: +- principle +- dx +- agents +- versioning +- knowledge-management +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T10:15:00Z' +--- +**Claim.** Software installed by copying arrives without announcement: files are replaced, and +nothing on the machine notices. Where that software also keeps state the user owns, a release +that wants something of that state has no way to ask for it. Recording which release the state +has been brought up to gives it one, and the stamp earns its place only if it never blocks +anything. + +**When to apply.** A skill, a dotfile set, a template or a scaffold that is copied into place, +alongside a workspace, vault or configuration the person edits themselves. Not a package with +a resolver, which already knows what it replaced. + +**The rule.** + +1. **Stamp the state, not the install.** The file that records the version is the one the user + owns, and the number in it means "this setup has been brought up to here", which is a + different fact from which copy of the tool is on disk. +2. **Notice at a command they already run.** A separate check is one nobody runs. The ordinary + listing command compares the two numbers and prints a notice when they differ. +3. **Name the guide; do not perform the change.** The notice points at prose, oldest guide + first, because most of what a release wants is a decision rather than an edit. +4. **Put each decision to the person, and take no for an answer.** These are usually new + capabilities: "there is now a way to share a bundle with your team, do you want that here" + is the shape of it. +5. **Stamp a declined change as migrated.** The question was asked. Leaving it unrecorded asks + it again next week, which teaches the person to ignore the notice. +6. **Never stamp a guide nobody read**, and never edit the number by hand. The field's only + job is to be trustworthy about what has actually been put to this person. +7. **Block nothing.** A setup that ignores the notice keeps working, and the notice comes back. + +**Why.** The alternative is a tool refusing to run against state it did not create and cannot +see the history of, over a release the user never asked for. That spends their attention on +the author's cadence. A stamp that only ever raises a question costs one line of output and +keeps the state theirs. + +**What it costs.** Every code path has to tolerate the old shape, for as long as the guide +remains optional, which is indefinitely. And an upgrade replaces the files a release changed +without removing the ones it deleted, so a guide has to name any stale file explicitly; the +leftover is often a second copy of the very text the release removed. + +**How enforced.** One field in the manifest, a comparison in the listing command, and a +migrate command that lists outstanding guides and records them as read. Earned in +[federated-knowledge-skills](../tools/federated_knowledge_skills_schindler.md), where each +release since the first has asked something of setups already on disk. diff --git a/docs/principles/give_a_shared_repository_two_clocks_when_review_is_slower_than_the_work.md b/docs/principles/give_a_shared_repository_two_clocks_when_review_is_slower_than_the_work.md new file mode 100644 index 0000000..7af5031 --- /dev/null +++ b/docs/principles/give_a_shared_repository_two_clocks_when_review_is_slower_than_the_work.md @@ -0,0 +1,60 @@ +--- +type: Principle +title: Give a shared repository two clocks when review is slower than the work +description: Where a protected branch gates output produced faster than it can be reviewed, a second + long-lived branch carries the work and a merge back keeps the two from drifting. +tags: +- principle +- ci-cd +- collaboration +- knowledge-management +- git +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:45:00Z' +--- +**Claim.** A repository whose default branch is protected has one clock: the review cadence. +Where the work arrives faster than that, the usual answers are to loosen the gate or to let +people sit on local commits, and both give up something that was wanted. A second long-lived +branch gives the repository a second clock, and an automatic merge back is what stops the two +telling different times. + +**When to apply.** The work is produced in minutes and reviewed in days, and both of those are +correct. A knowledge base several people file into is the case this came from. Generated +documentation, a shared vocabulary file and a datasets registry have the same shape. + +**The rule.** + +1. **The default branch keeps the review gate, and nobody works on it.** It holds what was + reviewed and it is what anything downstream is built from. +2. **One long-lived shared branch takes every push.** Every checkout sits on it, so a clone is + ready to work without anyone choosing a branch. +3. **Merge in both directions, never squash or rebase.** A squashed merge writes the same + content as a commit with no shared ancestry, after which the two branches no longer know + they met: the next release re-displays everything already released, and the merge back + conflicts the content against a copy of itself. +4. **Automate the merge back, on every change to the default branch.** Not on the release + alone: work reaches the default branch by other routes, such as a web edit or a pull + request from somebody's own checkout. +5. **Run the same checks on the shared branch as on a pull request.** A change made in the + forge's web editor had no working copy, so no commit hook ran on it. + +**Why.** The failure the merge back prevents is the only one here nobody sees. Branch +protection left off means unreviewed material in public, and a person notices. Squash merging +left on means a confusing release, and a person notices. The merge back missing means the two +branches drift, every working copy goes quietly stale, and the first symptom is a duplicate +written by somebody who could not see the original. That asymmetry is the argument for +shipping the merge back as a pinned reusable workflow rather than as four lines each +repository is trusted to copy correctly. + +**What it costs.** Linear history, which +[the usual principle](keep_a_linear_history_block_merge_fixup_and_squash_commits.md) asks for +and this shape cannot have: shared ancestry is the thing being protected and a merge commit is +what records it. And a lag between what the team can see and what the world can, which is the +review gate working rather than a defect, but it does mean a link written today may not +resolve in public until the next release. + +**How enforced.** Branch protection on the default branch, the merge method fixed in the +repository settings, and the merge-back workflow pinned by commit SHA. The worked example is +[Sharing one knowledge bundle on two branches](../blueprints/sharing_one_knowledge_bundle_on_two_branches.md). diff --git a/docs/principles/give_an_mcp_server_an_http_surface_before_anything_needs_one.md b/docs/principles/give_an_mcp_server_an_http_surface_before_anything_needs_one.md new file mode 100644 index 0000000..26b46a7 --- /dev/null +++ b/docs/principles/give_an_mcp_server_an_http_surface_before_anything_needs_one.md @@ -0,0 +1,43 @@ +--- +type: Principle +title: Give an MCP server an HTTP surface before anything needs one +description: Speak streamable HTTP from the first version, because the consumer that cannot launch a + subprocess turns every later move into a rewrite rather than a deployment. +tags: +- principle +- mcp +- ai-agents +- agents +- architecture +status: draft +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T12:00:00Z' +--- +**Claim.** Build an MCP server to speak streamable HTTP from its first version, even whilst the +only thing calling it is a harness on the same machine that would be happy with stdio. + +**Why.** A stdio server is a subprocess its client launches, so it can only be reached by a +consumer that runs on a machine you control and may fork children. A build container, a hosted +assistant and a colleague's laptop are all outside that. The moment one of them wants the +capability, a stdio-only server has to grow an endpoint, an identity it never had and a +concept of which user a call acts as, which is a rewrite dressed as a configuration change. +Binding the same handlers to an HTTP listener whilst the server is small costs a dependency and +an afternoon, and it converts every later move into a deployment question. + +**Keep stdio as well.** This is not a choice between the two. Local development stays faster +over stdio, an inspector attaches to it more easily, and a harness on the same machine has no +reason to pay for HTTP. Ship both bindings over one set of handlers and let the consumer pick. + +**The authorisation work is the part worth starting early**, and it is the part a local server +never forces. Running as the logged-in user means reading their environment and acting as them, +which is an answer you get for free and lose the moment the process moves. Writing the server +so a caller's identity arrives as an argument rather than as ambient state keeps that answer +replaceable. + +**What this is not.** It is not an argument for hosting the server. Where it runs is a separate +decision with its own costs, and most servers should stay on the machine that needs them. This +principle only says that the decision should remain open. + +The survey this rests on is +[What an MCP server can be, and who can reach one](../investigations/mcp_server_entry_points_and_transports.md). diff --git a/docs/principles/index.md b/docs/principles/index.md index 4961e66..b1272d2 100644 --- a/docs/principles/index.md +++ b/docs/principles/index.md @@ -20,6 +20,7 @@ most general. - [Batch dependency updates with a cooldown, not a firehose](batch_dependency_updates_with_a_cooldown_not_a_firehose.md) - how to keep an update bot from becoming noise you learn to ignore - [Keep transitive dependencies in the regular update cycle](keep_transitive_dependencies_in_the_update_cycle.md) - the packages nobody chose, which is why nobody is watching them - [Keep declared toolchain versions in sync, and guard it](keep_declared_toolchain_versions_in_sync_and_guard_it.md) - what to do when the same fact has to live in two files +- [A version stamp is bookkeeping about capabilities, not a lock](a_version_stamp_is_bookkeeping_about_capabilities_not_a_lock.md) - how a tool copied into place asks something of state it does not own - [Validate config files against their published schema](validate_config_files_against_their_published_schema.md) - linting the configuration, not just the content - [Document a rationale for every disabled lint rule](document_a_rationale_for_every_disabled_lint_rule.md) - why a bare suppression is indistinguishable from an accident - [Grant least-privilege CI permissions at both workflow and job level](grant_least_privilege_ci_permissions_at_both_workflow_and_job_level.md) - scoping a CI token twice, so a job holds only what it uses @@ -39,6 +40,7 @@ most general. - [Track every committed binary type in .gitattributes](track_every_committed_binary_type_in_gitattributes.md) - not trusting Git to guess which of your files are opaque - [Enforce a canonical author identity via .mailmap](enforce_a_canonical_author_identity_via_mailmap.md) - one person, one identity in the history, checked mechanically - [Keep a linear history: block merge, fixup and squash commits](keep_a_linear_history_block_merge_fixup_and_squash_commits.md) - what it takes to actually get the linear history you asked for +- [Give a shared repository two clocks when review is slower than the work](give_a_shared_repository_two_clocks_when_review_is_slower_than_the_work.md) - the second long-lived branch, and the merge back that keeps it honest - [Make the build interface a self-documenting Makefile](make_the_build_interface_a_self_documenting_makefile.md) - one entry point whose help text cannot drift from its targets - [Fail early on a missing tool with a message that names it and points at the fix](fail_early_on_a_missing_tool_with_a_message_that_names_it_and_points_at_the_fix.md) - the difference between a guard and a bare command not found - [Do not make a tool a prerequisite for work it is not needed for](do_not_make_a_tool_a_prerequisite_for_work_it_is_not_needed_for.md) - checking that a listed requirement is on a path anyone walks @@ -53,3 +55,5 @@ most general. - [Write in a calm, quantified, settled-fact voice - not a promotional one](write_in_a_calm_quantified_settled_fact_voice_not_a_promotional_one.md) - the voice these pages are written in, and its tells on both sides - [Give every cross-cutting concept one definitional home](give_every_cross_cutting_concept_one_definitional_home.md) - single source of truth, applied to prose instead of code - [Route a rule to the layer that reaches whoever must obey it](route_a_rule_to_the_layer_that_reaches_whoever_must_obey_it.md) - a page, a hook, a skill or an always-on instruction, chosen by who has to obey +- [Subscribe to shared instruction text rather than pasting a copy](subscribe_to_shared_instruction_text_rather_than_pasting_a_copy.md) - what happens to a block of prose that no later version can reach +- [Give an MCP server an HTTP surface before anything needs one](give_an_mcp_server_an_http_surface_before_anything_needs_one.md) - the consumer that cannot launch a subprocess, and the rewrite it causes if you wait for it diff --git a/docs/principles/pin_github_actions_to_full_commit_shas.md b/docs/principles/pin_github_actions_to_full_commit_shas.md index 6f9ad17..78c3bba 100644 --- a/docs/principles/pin_github_actions_to_full_commit_shas.md +++ b/docs/principles/pin_github_actions_to_full_commit_shas.md @@ -23,11 +23,19 @@ secrets and write permissions - executes it silently. A SHA is immutable, so you run exactly the code you reviewed. Keep the human-readable version in a trailing comment so upgrades stay legible; let Dependabot bump both together. +**A reusable workflow is the stronger case, not an exception.** `uses: +owner/repo/.github/workflows/thing.yml@` is pinned by the same rule, and it +matters more: a called workflow runs with the permissions its caller grants it, so +one that merges into a shared branch holds write access to that branch. A tag there +hands branch-write to whatever the tag points at today. + **Snippet.** ```yaml - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 +# a called workflow, pinned the same way and for stronger reasons +uses: ftschindler/federated-knowledge-skills/.github/workflows/merge-back.yml@5b0771e12f55d1ce5f598f4e42e788bcac4c4447 # v0.2.2 ``` **How enforced.** Dependabot (`package-ecosystem: github-actions`) proposes SHA diff --git a/docs/principles/pin_transitive_runtime_dependencies_not_just_the_tool.md b/docs/principles/pin_transitive_runtime_dependencies_not_just_the_tool.md index ba54937..4289dad 100644 --- a/docs/principles/pin_transitive_runtime_dependencies_not_just_the_tool.md +++ b/docs/principles/pin_transitive_runtime_dependencies_not_just_the_tool.md @@ -30,16 +30,23 @@ it to the tool version with a comment so upgrades move together. **Snippet.** ```yaml -# linkspector pinned together with the exact Chrome it drives -entry: bash -c 'npx --yes puppeteer browsers install chrome@148.0.7778.97 +# linkspector, the puppeteer that chooses the browser, and the exact Chrome +# that puppeteer release defaults to. All three move together. +entry: bash -c 'npx --yes puppeteer browsers install chrome@152.0.7977.75 && linkspector check -c .linkspector.yml' -additional_dependencies: ['@umbrelladocs/linkspector@0.5.3', 'puppeteer'] +additional_dependencies: ['@umbrelladocs/linkspector@0.5.3', 'puppeteer@25.10.0'] ``` The runtime here is not incidental: [linkspector](../tools/linkspector.md) resolves a link it cannot settle with an HTTP request by loading the page in headless Chrome, so the browser is part of what decides the answer. +**Pin whatever decides which runtime starts, not only the runtime.** An earlier version of this +snippet pinned Chrome and left `puppeteer` floating, which pins nothing, because +[puppeteer](../tools/puppeteer.md) launches the revision compiled into its own release rather +than the one just installed. That is +[a finding that cost a fortnight of red pull requests](../findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md). + **How enforced.** Explicit version pins for the runtime alongside the tool, with a paired comment. Extends the SHA-pinning family: [Pin GitHub Actions to full commit SHAs](pin_github_actions_to_full_commit_shas.md), [Pin pre-commit hooks to frozen revisions](pin_pre_commit_hooks_to_frozen_revisions.md). diff --git a/docs/principles/subscribe_to_shared_instruction_text_rather_than_pasting_a_copy.md b/docs/principles/subscribe_to_shared_instruction_text_rather_than_pasting_a_copy.md new file mode 100644 index 0000000..9476204 --- /dev/null +++ b/docs/principles/subscribe_to_shared_instruction_text_rather_than_pasting_a_copy.md @@ -0,0 +1,60 @@ +--- +type: Principle +title: Subscribe to shared instruction text rather than pasting a copy +description: Text maintained in one place and needed in many should be rendered into them by a + subscription, because a copy pasted by hand is one no later version can reach. +tags: +- principle +- agents +- documentation +- knowledge-management +- dx +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T09:55:00Z' +--- +**Claim.** Where the same block of text has to sit in files no release touches, pasting it is +a one-way copy: the authoritative wording moves on, the copy stays as it was, and neither half +knows about the other. A subscription renders the block instead, between markers, and an +upgrade carries it. + +This is the companion to +[Route a rule to the layer that reaches whoever must obey it](route_a_rule_to_the_layer_that_reaches_whoever_must_obey_it.md), +which decides *which* layer a copy belongs in. This one decides how that copy stays current +once it is there. + +**When to apply.** The text lives outside any repository that ships it: the instructions a +coding agent loads before every session, a shared editor configuration, a block of CI any +repository is told to copy. The tell is a document that says "paste the following into your +`X`". + +**The rule.** + +1. **One authoritative copy, in a repository, versioned with whatever it describes.** A skill + and the rule that makes an agent reach for it ship under one name, so an upgrade cannot + bring one without the other. +2. **A tool renders it into place and owns the region it wrote**, delimited by markers it + recognises. +3. **Anything of the reader's own goes outside the markers.** A render rewrites the region + whole, so a line left inside survives until the next upgrade and then is gone with no + message. This is where a migration loses something: the personal line is exactly the one + nobody else would have written. +4. **Ask before subscribing on someone's behalf.** A file loaded at the start of every + session, for every project, is a larger step than writing a file they asked for. +5. **Delete the old pasted copy once the rendered one is in place**, and not before. Two + sections saying the same thing leave a reader nothing to choose between. + +**Why.** A stale copy fails silently and indefinitely. Nothing reads both halves, so no check +can compare them, and the behaviour it produces is plausible rather than broken. Pinning does +not help: this is not a dependency anybody resolved, it is prose somebody transcribed. + +**What it costs.** A tool in the path where there was none, and a manifest of subscriptions to +keep. Where the tool is absent the hand-pasted copy still works, which is what makes this +worth offering rather than requiring. + +**How enforced.** Convention, plus whatever the subscribing tool reports about where each +block landed. An upgrade removes nothing it did not write, so a file a release deleted stays +on disk as a second copy of the text just removed, and a migration guide has to name it +explicitly. Earned subscribing to the `fkb` rule through +[federated-agent-kits](../tools/federated_agent_kits_schindler.md). diff --git a/docs/tools/federated_agent_kits_schindler.md b/docs/tools/federated_agent_kits_schindler.md new file mode 100644 index 0000000..21115d4 --- /dev/null +++ b/docs/tools/federated_agent_kits_schindler.md @@ -0,0 +1,75 @@ +--- +type: Tool +title: federated-agent-kits (Felix Schindler) +description: The CLI that subscribes to skills and rules from ordinary git repositories and renders + them into whatever file each agent harness reads. +tags: +- tools +- self-authored +- agents +- agent-skills +- dx +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-14T10:05:00Z' +sources: +- id: akit-readme + resource: https://github.com/ftschindler/federated-agent-kits + title: 'ftschindler/federated-agent-kits: README' + author: human:felix_schindler + last_modified: '2026-10-14' +--- +`akit`[^akit-readme] fetches a skill or a rule from the git repository it lives in, pins the +commit, and writes the copy each harness expects. One line in a manifest replaces the several +hand-made copies that would otherwise sit in several files and drift apart. It is +[Felix](../people/felix_schindler.md)'s, and it is how the +[`fkb`](federated_knowledge_skills_schindler.md) skill and its rule reach this machine. + +| | | +| --- | --- | +| Author | [Felix Schindler](../people/felix_schindler.md) | +| Licence | MIT | +| Language | Python 3.11+, published to PyPI and run without installing through [uv](uv.md) | +| Version | 0.12.1, alpha: the shape holds, the edges move | +| Platforms | Linux and Windows, both running the full suite in CI | +| Source | [github.com/ftschindler/federated-agent-kits](https://github.com/ftschindler/federated-agent-kits) | + +## What it does + +**A source is any git repository that keeps skills or rules in it.** It needs no manifest, no +registration and no permission, and it never learns that anyone subscribed. `akit` looks in +the places each ecosystem keeps these things, so a repository laid out normally needs nothing +doing to it. + +**A kit is a name, and it may carry several kinds under it.** Subscribing to `fkb` takes both +the skill an agent opens and the rule that makes it reach for the skill at all, which is the +pairing that gets broken when the two are installed separately. + +**A render writes into the region it owns**, between markers, in whichever shape each detected +harness wants: a skill directory for one, a block in an instructions file for another. What +the reader wrote themselves sits outside those markers and is left alone. `akit list` says +what is subscribed, which harnesses are on the machine, where each part landed, and whether +two kits collide on a name. It writes nothing. + +Two manifests, so the same mechanism serves both audiences: `--global` for the subscriptions +that apply to every session, and a per-repository one for what only that project needs. + +## Why it exists + +The argument is +[Subscribe to shared instruction text rather than pasting a copy](../principles/subscribe_to_shared_instruction_text_rather_than_pasting_a_copy.md). +A block pasted by hand is one no later version can reach: the wording in the repository moves +on, the copy does not, and nothing reads both halves to notice. + +`fkb` is the case that produced it. Its rule used to ship as a page saying "paste this into +your instructions file", which made every installed copy a fork. The rule now lives at +`rules/fkb.md` in that project and `akit` carries it, under the same name as the skill, so an +upgrade brings both. + +## What it does not do + +It installs nothing beyond what it writes, and it resolves no dependencies between kits: a +skill that needs another cannot say so anywhere a machine will check. Agent definitions, the +third kind, are still being built, and a subscription to one says which task it is waiting +for. diff --git a/docs/tools/federated_knowledge_skills_schindler.md b/docs/tools/federated_knowledge_skills_schindler.md index 9063528..4382dcf 100644 --- a/docs/tools/federated_knowledge_skills_schindler.md +++ b/docs/tools/federated_knowledge_skills_schindler.md @@ -20,12 +20,12 @@ sources: resource: https://github.com/ftschindler/federated-knowledge-skills title: 'ftschindler/federated-knowledge-skills: README' author: human:felix_schindler - last_modified: '2026-09-16' + last_modified: '2026-10-14' - id: fks-design resource: https://github.com/ftschindler/federated-knowledge-skills/blob/main/DESIGN.md title: 'federated-knowledge-skills: DESIGN.md' author: human:felix_schindler - last_modified: '2026-09-16' + last_modified: '2026-10-14' --- federated-knowledge-skills[^fks-readme] is the layer that turns several independent [OKF](../investigations/open_knowledge_format_okf_findings.md) bundles into one federation an agent @@ -39,9 +39,9 @@ and the thing producing the pages. | Author | [Felix Schindler](../people/felix_schindler.md) | | Licence | MIT, with the vendored OKF validator under its own | | Language | Python 3.11+, run through [uv](uv.md) as PEP 723 scripts with no install step | -| Distribution | A skill directory to copy, and two pre-commit hook ids a bundle pins by revision | +| Distribution | A kit `akit` subscribes to, or a skill directory to copy; plus two pre-commit hook ids a bundle pins by revision | | Source | [github.com/ftschindler/federated-knowledge-skills](https://github.com/ftschindler/federated-knowledge-skills) | -| Status | The skill, the CLI and the hooks are shipped and in use here; a second tier and the retirement of the old architecture are not done | +| Status | Released at v0.3.0 and in daily use since September, across four bundles at three privacy tiers | ## What it is @@ -58,8 +58,8 @@ Four things, and the split between them is the whole design. and the content" true. - **A CLI**, also `fkb`, owns the handful of questions that need a deterministic answer: `list` for what exists and what each allows, `resolve` for one bundle as JSON, `lint` for - whether it holds up, `url` for a link into another bundle, and `init` and `add` for setting - one up. + whether it holds up, `url` for a link into another bundle, `sync` for moving a shared + bundle's commits, and `init`, `add` and `migrate` for setting one up and keeping it current. Underneath, `okf-concepts` and `okf-bundle` are the same checker under two blocking policies, shipped as pre-commit hooks a bundle pins by revision. They enforce the format's hard rules @@ -89,6 +89,32 @@ bypasses the federation layer entirely. The reasoning is taken to its conclusion: if the audience boundary is the repository, then the repository is where it has to be enforced. +## A bundle several people file into + +A bundle names the branch it is shared on, and that one field means three things: this +checkout belongs there, commits here may leave the machine, and somebody else is writing here +so it has to be read fresh. `fkb sync` fetches, rebases and pushes, and refuses rather than +resolving: uncommitted work in the tree, a checkout on another branch or a conflict all leave +the commit standing and nothing pushed. + +The branch shape around it is +[Sharing one knowledge bundle on two branches](../blueprints/sharing_one_knowledge_bundle_on_two_branches.md), +which this bundle and one shared with an employer's team both run. The one piece the project +ships rather than documents is the merge back, as a reusable workflow, because it is the only +part of the shape whose absence nobody sees. + +## How an upgrade reaches a setup + +The skill is installed by copying, so a new release arrives without announcement. `fkb list` +compares the copy on disk against the version recorded in the workspace manifest and says when +something since then wants a decision; `fkb migrate` names the guides and `fkb migrate --done` +records them. Nothing is blocked in the meantime, which is +[the point of the stamp](../principles/a_version_stamp_is_bookkeeping_about_capabilities_not_a_lock.md). + +The rule that makes a session reach for the skill at all ships beside it rather than inside +it, and [akit](federated_agent_kits_schindler.md) carries both under one name. A skill nothing +points at is never loaded, and nothing appears to go wrong when that happens. + ## Where it came from [Federating my knowledge base as privacy-tiered OKF bundles](../decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md) diff --git a/docs/tools/git_ai.md b/docs/tools/git_ai.md new file mode 100644 index 0000000..2fb5279 --- /dev/null +++ b/docs/tools/git_ai.md @@ -0,0 +1,97 @@ +--- +type: Tool +title: git-ai +description: A git extension that records which lines an agent wrote, as git notes under refs/notes/ai, + driven by a background daemon that installs its own hooks into every coding agent it finds. +tags: +- tools +- git-ai +- git +- agents +- provenance +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T10:30:00+02:00' +sources: +- id: git-ai-repo + resource: https://github.com/git-ai-project/git-ai + title: 'git-ai-project/git-ai: A Git extension for tracking the AI-generated code in your repos' + last_modified: '2026-10-09' +- id: felix-git-ai-opencode + resource: https://ftschindler.github.io/running-linux/done/20260624-git-ai-integration-with-opencode/ + title: Git AI Integration with OpenCode + author: human:felix_schindler + last_modified: '2026-06-24' +--- +git-ai[^git-ai-repo] answers "which of these lines did an agent write". It does not guess: +the agent itself reports what it wrote, through a hook the tool installs into each agent it +finds, and the answer is stored as a git note against the commit. + +| | | +| --- | --- | +| Authors | Aidan Cunniffe and Sasha Varlamov, trading as Git AI at [usegitai.com](https://usegitai.com) | +| Licence | Apache 2.0 for the CLI; the Teams product around it is commercial | +| Language | Rust | +| Distribution | A shell installer, `curl -sSL https://usegitai.com/install.sh \| bash`, into `~/.git-ai` | +| Source | [github.com/git-ai-project/git-ai](https://github.com/git-ai-project/git-ai) | +| Version read | 1.5.13, installed 24 June 2026 and pinned there by `disable_auto_updates` | + +## What it records + +Attribution is explicit rather than inferred. An agent calls `git-ai checkpoint ` before +and after each edit, naming itself: the presets shipped in 1.5.13 cover Claude Code, Codex, +Cursor, Gemini, GitHub Copilot, Amp, Windsurf, opencode, Pi, Firebender and Continue, plus a +`human` preset for everything else. On commit the line ranges are written into a note under +`refs/notes/ai`, and `git ai blame` renders them beside the ordinary blame. + +Two consequences follow from the storage choice, and both matter more than the feature list. +**The notes travel with the repository**, so attribution survives a clone and can be pushed to a +remote like any other ref. And **the notes are rewritten by the daemon** whenever history moves, +which is what keeps a rebase or a squash from discarding them. + +Prompts are stored too, where `prompt_storage` is set to `notes`. That is a decision about what +leaves the machine in a push, not only about disk, because it puts the text of every session +into a ref that a remote can hold. + +## Where it puts itself + +The installer is the part worth knowing before running it, because it writes into four places +that have nothing to do with each other: + +| Layer | What lands there | +| --- | --- | +| `~/.git-ai` | The binary, `config.json`, a long-running daemon, and SQLite databases of metrics and session transcripts. Around 365 MB after a few months | +| Shell startup | A `PATH` line appended to every rc file it recognises, each one dated and marked as the installer's | +| Coding agents | Hooks written into Claude Code, GitHub Copilot and opencode, plus a VS Code extension installed into the editor | +| Every repository touched | A `.git/ai` directory of checkpoint logs, and the `refs/notes/ai` ref once anything is committed | + +The last row is the one that grows without being asked. The tool needs no per-repository setup, +which is its selling point, and the consequence is that it leaves state in every repository an +agent ever ran in, including throwaway clones in package manager caches. + +**The daemon reinstalls the agent hooks daily.** That is documented as a convenience, so a newly +installed agent is picked up without anyone remembering, and it means removing a hook is not a +way of switching the tool off: the removal lasts until the next check. Hooks come off when the +binary does, and in that order. + +## Removing it + +Four layers means four separate removals, and the project's own instructions cover two of them. +[Uninstall git-ai and the state it left in every repository](../guides/uninstall_git_ai.md) is the +full procedure, including the per-repository sweep and the notes that may already be on a remote. + +The uninstaller itself has one trap worth knowing before running anything: +[git-ai uninstall-hooks has no help flag and uninstalls when asked for one](../findings/20261009_git_ai_uninstall_hooks_has_no_help_flag_and_uninstalls_when_asked_for_one.md). + +## How it was used here + +Installed on 24 June 2026 alongside [opencode](opencode.md), configured for local-only operation +with telemetry and version checks off, and written up at the +time[^felix-git-ai-opencode]. Removed on 9 October; what the three and a half months in between +taught is [the exploration](../explorations/tracking_ai_authorship_with_git_ai.md). + +The opencode side of it was a plugin file at +`plugins/git-ai.ts`, generated by `git-ai install-hooks` with the absolute path to the binary +baked in, which is why it was gitignored rather than committed with the rest of that +configuration. diff --git a/docs/tools/index.md b/docs/tools/index.md index 7871f00..90e5165 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -17,13 +17,16 @@ behind them: they keep their plain name, because `uv` is Astral's and will not b - [agent-wiki (TacoTakumi)](agent_wiki_tacotakumi.md) - `awiki`: a CLI-driven markdown vault agents search before the web, and what this knowledge base ran on before it was an OKF bundle - [agent-knowledge (stjbrown)](agent_knowledge_stjbrown.md) - the `kb-*` skills rather than an engine for maintaining OKF bundles, and the upstream whose own bundle is cited here - [federated-knowledge-skills (Felix Schindler)](federated_knowledge_skills_schindler.md) - mine: the `fkb` skill, CLI and hooks that bind privacy-tiered bundles into a federation, and what this bundle is now written through +- [federated-agent-kits (Felix Schindler)](federated_agent_kits_schindler.md) - also mine: one manifest line instead of a skill copied by hand into every harness that wants it - [project-wiki (giodra96)](project_wiki_giodra96.md) - the LLM wiki pattern aimed at a repository rather than a person, with a closed taxonomy of requirements, decisions and traceability - [uv](uv.md) - the Python manager under everything here, and the manifest-versus-lockfile split several principles turn on - [Dependabot](dependabot.md) - the update bot, and the three different things that share its name - [opencode](opencode.md) - the terminal agent that writes most of these pages, and the one-directory-per-provider config my own setup is built from - [OpenChamber](openchamber.md) - the desktop, web and mobile workspace that drives an opencode server rather than replacing it, and what adopting it would and would not change here - [markitdown](markitdown.md) - Microsoft's PDF-and-Office-to-Markdown converter, as a command and as the MCP server an agent calls +- [git-ai](git_ai.md) - the git extension that records which lines an agent wrote, where it puts itself, and what that leaves behind - [linkspector](linkspector.md) - the link checker this bundle commits through, the two passes it resolves every link with, and the schema that refuses to be annotated +- [puppeteer](puppeteer.md) - the browser automation library under linkspector, and the hardcoded Chrome revision that decides what actually launches - [skills (Vercel Labs)](skills_vercel_labs.md) - `npx skills add`: the installer that discovers a skill by finding its `SKILL.md`, and the leaderboard it reports installs to - [caveman (Julius Brussee)](caveman_brussee.md) - the terseness skill, the four commercial layers sold under the same name, and what survives once the token-saving argument is dropped - [ponytail (Dietrich Gebert)](ponytail_gebert.md) - the seven-rung ladder an agent climbs before writing code, the benchmark its author retracted, and why I took the review commands and left the ruleset diff --git a/docs/tools/markitdown.md b/docs/tools/markitdown.md index 3c993a3..2a15841 100644 --- a/docs/tools/markitdown.md +++ b/docs/tools/markitdown.md @@ -76,7 +76,9 @@ and a wiped cache makes the next start slow again rather than broken. An agent that cannot read a PDF cannot file what is in one, and this bundle is written by agents. Declaring it as an MCP server rather than leaving it to the shell means the capability is present without anyone remembering the command, which matters for exactly the tool you reach -for twice a month. +for twice a month. It is the plainest example of the shape described in +[what an MCP server can be, and who can reach one](../investigations/mcp_server_entry_points_and_transports.md): +a subprocess the harness launches, holding nothing between calls. It is declared in the base layer of [my opencode configuration](opencode.md), because which model is answering has no bearing on how a DOCX is parsed. That is the general shape: diff --git a/docs/tools/puppeteer.md b/docs/tools/puppeteer.md new file mode 100644 index 0000000..2706f88 --- /dev/null +++ b/docs/tools/puppeteer.md @@ -0,0 +1,76 @@ +--- +type: Tool +title: puppeteer +description: Google's browser automation library, and the reason a project that pins a browser version + can still launch a different one, because each release hardcodes a default Chrome revision of its own. +tags: +- tools +- puppeteer +- supply-chain +- pre-commit +- testing +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-09T13:10:00+02:00' +sources: +- id: puppeteer-repo + resource: https://github.com/puppeteer/puppeteer + title: 'puppeteer/puppeteer: JavaScript API for Chrome and Firefox' + last_modified: '2026-10-09' +- id: puppeteer-revisions + resource: https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/revisions.ts + title: 'puppeteer-core: PUPPETEER_REVISIONS' + last_modified: '2026-10-09' +--- +puppeteer[^puppeteer-repo] drives a browser from JavaScript. It is rarely a direct dependency +here: it arrives underneath something else, which is what makes the one fact on this page worth +writing down. + +| | | +| --- | --- | +| Author | Google, from the Chrome DevTools team | +| Licence | Apache-2.0 | +| Language | TypeScript | +| Distribution | npm, as `puppeteer` and as `puppeteer-core` | +| Source | [github.com/puppeteer/puppeteer](https://github.com/puppeteer/puppeteer) | +| Version read | 25.13.0, the latest release | + +## Every release carries its own browser version + +`PUPPETEER_REVISIONS` in `puppeteer-core` names one exact Chrome build per +release[^puppeteer-revisions]. `puppeteer` downloads that build on install and launches it by +default; `puppeteer-core` ships the same constant but downloads nothing, leaving the caller to +say which browser to use. + +The constant moves roughly every two releases, and quickly: + +| Release | Default Chrome | +| --- | --- | +| 25.4.0 | 151.0.7922.47 | +| 25.6.0 | 151.0.7922.77 | +| 25.8.0 | 152.0.7977.42 | +| 25.9.0 | 152.0.7977.54 | +| 25.10.0 | 152.0.7977.75 | +| 25.11.0 | 153.0.8010.36 | +| 25.12.0 | 154.0.8037.57 | +| 25.13.0 | 155.0.8059.39 | + +Those were read out of the published tarballs, from `package/src/revisions.ts`, which is the way +to answer the question for a release that is not the current one. + +**`puppeteer browsers install chrome@` adds a browser to the cache. It does not change +which one gets launched.** The two look interchangeable and are not: one populates a directory, +the other is a constant compiled into the library. A project that installs a pinned Chrome and +leaves the library version floating has pinned nothing, which is +[the finding this page exists for](../findings/20261009_an_unpinned_puppeteer_replaces_the_chrome_pinned_beside_it.md). +The procedure that follows from it is +[Pin the browser behind a pre-commit link checker](../guides/pin_the_browser_behind_a_pre_commit_link_checker.md). + +## Where it turns up here + +Underneath [linkspector](linkspector.md), whose second checking pass loads a page in headless +Chrome for any link an HTTP request could not settle. The browser is part of what decides +whether a link is reported as broken, which is why +[Pin transitive runtime dependencies, not just the tool](../principles/pin_transitive_runtime_dependencies_not_just_the_tool.md) +names it rather than treating it as an implementation detail of the link checker.