diff --git a/.mailmap b/.mailmap index 79f54c2..a68261a 100644 --- a/.mailmap +++ b/.mailmap @@ -10,6 +10,7 @@ copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Felix Schindler <107669806+ftschindler@users.noreply.github.com> +github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> PA to ftschindler <266167831+ftschindler-pa@users.noreply.github.com> René Fritze <47802+renefritze@users.noreply.github.com> René Fritze <47802+renefritze@users.noreply.github.com> diff --git a/about/editing_conventions.md b/about/editing_conventions.md index 091c693..9578a04 100644 --- a/about/editing_conventions.md +++ b/about/editing_conventions.md @@ -37,8 +37,10 @@ generated: { by: human:felix_schindler, at: 2026-09-07T10:00:00Z } ``` - `type` is free text describing what kind of thing this is: `Principle`, `Person`, - `Decision`, `Reference`. It is not free per page, though: one directory holds one genre, so - `type` must be the one its `.genre.yaml` declares, and the `genre-conformance` hook says so. + `Decision`, `Investigation`. It is not free per page, though: one directory holds one genre, + so `type` must be the one its `.genre.yaml` declares, and the `genre-conformance` hook says + so. Every directory names its own genre, so the type and the directory say the same thing + twice on purpose, and a generic type is a sign the directory has not decided what it holds. - `status` is `draft`, `stable` or `deprecated`. Absent means stable. - `generated.by` names whatever did the writing: `human:` for a person, `/` for an agent such as `opencode/claude-opus-5`, `process:` for a diff --git a/docs/.pages b/docs/.pages index ba7bb71..1a1f926 100644 --- a/docs/.pages +++ b/docs/.pages @@ -16,8 +16,9 @@ nav: - blueprints - guides - knowledge_management -- research +- investigations - tools +- hardware - findings - log.md - ... 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 f8dca15..0040e24 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 @@ -56,7 +56,7 @@ exploration produced: ## What that led me to - **A format instead of an engine.** - [Open Knowledge Format](../research/open_knowledge_format_okf_findings.md) + [Open Knowledge Format](../investigations/open_knowledge_format_okf_findings.md) specifies one concept per Markdown document, standard Markdown links, and no required tooling, with conformance judged file-structurally. It grants what the engine was for and imposes none of what the engine cost. `docs/` is an OKF @@ -71,12 +71,12 @@ exploration produced: own git repository, each cloning and publishing standalone and unaware of the others, bound by a single local manifest file that assigns each a role. The architecture is written up in - [Federated OKF knowledge bases: a workspace-manifest architecture](../research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md). + [Federated OKF knowledge bases: a workspace-manifest architecture](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md). - **The leak boundary in each repository, not in the skill layer.** The manifest is a guardrail; the actual boundary is each bundle's own pre-commit hooks and publish gate, so a bundle stays safe even when an agent bypasses the federation layer entirely. This is the direct answer to the aggregation risk the - [security review](../research/security_analysis_of_agent_wiki_awiki.md) found. + [security review](../investigations/security_analysis_of_agent_wiki_awiki.md) found. - **A skill layer, kept thin.** Agents reach the bundles through skills rather than a runtime: single-bundle mechanics wrapped by a federation layer that owns only the manifest-aware decisions. Skills are prose an agent reads, so they are diff --git a/docs/explorations/running_this_knowledge_base_on_awiki.md b/docs/explorations/running_this_knowledge_base_on_awiki.md index ced9347..43cde47 100644 --- a/docs/explorations/running_this_knowledge_base_on_awiki.md +++ b/docs/explorations/running_this_knowledge_base_on_awiki.md @@ -60,7 +60,7 @@ the same way every other invariant here is guarded. Before trusting it with real conversation history and a public repository I read its source, which became -[Security Analysis of Agent Wiki (awiki)](../research/security_analysis_of_agent_wiki_awiki.md). +[Security Analysis of Agent Wiki (awiki)](../investigations/security_analysis_of_agent_wiki_awiki.md). That review found no covert egress and no telemetry of any kind, so nothing below is a complaint about the tool's integrity. It is a good tool that turned out to be the wrong tool for this. @@ -143,7 +143,7 @@ Almost all of it, which is why this page is worth its length: - **The engine belongs in the quality gate.** The instinct to run the knowledge tool's own checks as pre-commit hooks survived; the hooks in this repository are the same idea with the engine replaced by scripts. -- **[Open Knowledge Format](../research/open_knowledge_format_okf_findings.md).** +- **[Open Knowledge Format](../investigations/open_knowledge_format_okf_findings.md).** Reached through awiki's alignment with it, and kept once it became clear the format mandates no tooling, no raw-and-rendered split, and standard Markdown links, meaning it grants everything I wanted from the engine and imposes none @@ -166,7 +166,7 @@ so that if one of them is later missed, it is missed knowingly. ## What it led to The survey of what to use instead is -[Substrate options for an OKF-based agent-first LLM wiki: investigation](../research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md), +[Substrate options for an OKF-based agent-first LLM wiki: investigation](../investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md), which scored candidates against requirements written directly out of the four failures above and deliberately stopped short of choosing. The choice itself is [Federating my knowledge base as privacy-tiered OKF bundles](../decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md). 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 69c0c28..4fb3914 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 @@ -22,7 +22,7 @@ written yet. What it needs to cover: [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](../research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md), + [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. diff --git a/docs/findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md b/docs/findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md index b2a3c16..f58adea 100644 --- a/docs/findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md +++ b/docs/findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md @@ -16,7 +16,7 @@ generated: by: opencode/claude-opus-5 at: '2026-08-27T00:00:00Z' --- -Found while ingesting a page from [Security Analysis of Agent Wiki (awiki)](../research/security_analysis_of_agent_wiki_awiki.md) +Found while ingesting a page from [Security Analysis of Agent Wiki (awiki)](../investigations/security_analysis_of_agent_wiki_awiki.md) into this vault - noted here as a standalone tool gotcha. **Version**: agent-wiki-kb `0.8.1` - verified against the installed source at diff --git a/docs/findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md b/docs/findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md new file mode 100644 index 0000000..370b83b --- /dev/null +++ b/docs/findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md @@ -0,0 +1,111 @@ +--- +type: Finding +title: A Bluetooth headset reconnects in A2DP instead of the saved profile +description: WirePlumber consults a saved profile only in the first of three selection passes, so a + headset deliberately kept in HFP returns to A2DP as soon as that saved state is not there. +tags: +- finding +- linux +- bluetooth +- audio +- wireplumber +- pipewire +status: stable +stale_after: '2027-03-28' +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +--- +A Bluetooth headset switched by hand into the HSP/HFP profile comes back in A2DP after a +reconnect. The saved profile is real and it is honoured, but it is consulted in only the first of +three selection passes, and the pass that runs when it is missing decides by priority, where +A2DP outranks HFP by a factor of twenty-six. + +Observed on WirePlumber 0.5.17 and PipeWire 1.6.8. + +## Symptom + +The card returns in `a2dp-sink`, 48 kHz stereo, after having been left in `headset-head-unit`: + +```bash +$ pactl list short sinks | grep bluez +bluez_output.AA_BB_CC_DD_EE_FF.1 PipeWire s16le 2ch 48000Hz SUSPENDED +``` + +It survives for as long as nothing disturbs the saved state, which is what makes it look +intermittent: the headset behaves for days and then comes back wrong. + +## How the profile is chosen + +Three hooks run in a fixed order, each declaring its own position relative to the others, and +each returning immediately if a profile has already been selected: + +| Order | Hook | Decides from | +| --- | --- | --- | +| 1 | `device/find-stored-profile` | `~/.local/state/wireplumber/default-profile` | +| 2 | `device/find-preferred-profile` | the `device.profile.priority.rules` configuration section | +| 3 | `device/find-best-profile` | the priority number on each profile | + +The saved profile therefore wins whenever it is present, and contributes nothing whenever it is +not. On the [OpenMove](../hardware/openmove_by_shokz.md) the third pass is not close: +`a2dp-sink` carries priority 132 and `headset-head-unit` carries 5. + +**The second pass does not rescue it by default.** With no rule configured, it falls back, for +BlueZ devices only, to the `bluetooth.profile-preference` setting, and both values that setting +accepts name an A2DP pseudo-profile: `quality` resolves to `a2dp-auto-prefer-quality` and +`latency` to `a2dp-auto-prefer-latency`. The setting chooses between A2DP variants. It cannot +express a preference for HFP at all, which is worth knowing before spending time on it. + +## Why the saved state is not enough on its own + +It is rewritten on every profile change, and it is read at exactly one point in the sequence. +Anything that clears or overwrites it drops the decision through to a pass where HFP cannot win: +a re-pair, a state reset, an automatic switch, or a deliberate switch to A2DP for one call that +is then never switched back. + +## Fix + +Give the second pass a rule. In a file under `~/.config/wireplumber/wireplumber.conf.d/`: + +```text +device.profile.priority.rules = [ + { + matches = [ { device.name = "bluez_card.AA_BB_CC_DD_EE_FF" } ] + actions = { update-props = { priorities = [ "headset-head-unit", "headset-head-unit-cvsd" ] } } + } +] +``` + +The card name is derived from the device address and is not portable between machines. Read the +real one with `pactl list short cards`. Listing the CVSD profile second means an absent mSBC +leaves the device in HFP rather than dropping it back to A2DP. + +## Confirming it + +The rule and the saved state both produce the wanted profile, so a test that leaves the saved +state in place proves nothing. Clear it first, and check the negative case as well: + +```bash +printf '[default-profile]\n' > ~/.local/state/wireplumber/default-profile +systemctl --user restart wireplumber +``` + +| Rule | Saved state | Resulting profile | +| --- | --- | --- | +| present | cleared | `headset-head-unit` | +| absent | cleared | `a2dp-sink` | +| present | cleared, then a real disconnect and reconnect | `headset-head-unit`, codec `msbc` | + +The middle row is the one that carries the argument. Without it, a headset that happens to be in +the right profile looks like a working rule. + +## What it costs + +HFP is bidirectional, so the wanted profile is 16 kHz mono with the microphone open, against +48 kHz stereo for A2DP. That is inherent to the profile rather than a side effect of the rule. +A deliberate switch to A2DP still works and is still remembered, by the first pass, until +something clears it and the rule takes over again. + +Met whilst fixing +[Bluetooth A2DP fails with Protocol not available after resume](20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md), +which had to be dealt with before any profile could be selected at all. diff --git a/docs/findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md b/docs/findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md new file mode 100644 index 0000000..99c5d61 --- /dev/null +++ b/docs/findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md @@ -0,0 +1,173 @@ +--- +type: Finding +title: Bluetooth A2DP fails with Protocol not available after resume +description: A headset that will not connect because WirePlumber never started its BlueZ monitor, + because logind's per-user state file says the user is inactive whilst logind's own session and seat + records say otherwise. +tags: +- finding +- linux +- bluetooth +- audio +- wireplumber +- pipewire +- systemd +status: stable +stale_after: '2027-03-28' +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +sources: +- id: wireplumber-bluetooth-config + resource: https://gitlab.freedesktop.org/pipewire/wireplumber/-/blob/251d7424df15b4516ae17b4cc1181f9043150427/docs/rst/daemon/configuration/bluetooth.rst + title: 'WirePlumber: Bluetooth configuration' + last_modified: '2026-09-28' +- id: wireplumber-module-logind + resource: https://gitlab.freedesktop.org/pipewire/wireplumber/-/blob/251d7424df15b4516ae17b4cc1181f9043150427/modules/module-logind.c + title: 'WirePlumber: modules/module-logind.c' + last_modified: '2026-09-28' +--- +A paired Bluetooth headset refuses to connect after a resume, and BlueZ reports a protocol +failure. Neither BlueZ nor the adapter is at fault: WirePlumber never started its BlueZ monitor, +so no media endpoint was registered for BlueZ to offer, and it never started it because logind's +per-user state file claims the user is not active. That file contradicts logind's own seat and +session records, and every command a person reaches for to check reads the records rather than +the file. + +Observed on WirePlumber 0.5.17, PipeWire 1.6.8, BlueZ 5.87 and systemd 261. + +## Symptom + +```text +bluetoothd: src/service.c:btd_service_connect() a2dp-sink profile connect failed +for AA:BB:CC:DD:EE:FF: Protocol not available +``` + +Everything that usually explains this looks correct. The adapter is powered and unblocked, the +device is still paired, `bluetooth.service` is running, and the PipeWire BlueZ plugins are +installed. Restarting `bluetooth.service` changes nothing. + +**The distinguishing tell is that restarting WirePlumber changes nothing either.** A session +manager that has merely lost track of a device recovers when it is restarted. One that is being +prevented from starting the monitor at all comes back in exactly the same state, which is what +separates this from the ordinary post-suspend audio confusion. + +## What the error actually means + +"Protocol not available" here is not the headset refusing anything. BlueZ offers a profile only +if some client has registered a media endpoint advertising a codec for it, and +`btd_service_connect` reports this when none has. The count is visible in the journal: + +```bash +journalctl -b | grep -c "Endpoint registered" +``` + +A working session registers these at WirePlumber startup, around twenty of them on a machine +with the usual codec set. A broken one registers none, and the last thing in the journal about +them is a block of `Endpoint unregistered` lines at the moment the machine went to sleep. + +## Cause + +The BlueZ monitor is deliberately gated on logind. The reasoning upstream gives is that a +graphical login manager runs as its own user with its own PipeWire instance, and would otherwise +grab the Bluetooth audio devices away from whoever logs in, so the monitor creates device and +node objects only for a user on the active logind session.[^wireplumber-bluetooth-config] + +WirePlumber's logind module decides that through `sd_uid_get_state`, and additionally rewrites a +state of `active` to `online` when the user has no seat that is itself active, via +`sd_uid_get_seats` with its `require_active` argument set.[^wireplumber-module-logind] The +monitor runs on `active` and on nothing else. + +After a resume, logind's bookkeeping for the user disagrees with its bookkeeping for the seat +and the session. The user file reports the user as online, with no active seat, and with the +active session given as the seatless manager session rather than the graphical one: + +```text +# /run/systemd/users/1000 +STATE=online +ACTIVE_SESSIONS=1 +ACTIVE_SEATS= +ONLINE_SEATS=seat0 +``` + +Both of the other two files contradict it: + +```text +# /run/systemd/seats/seat0 # /run/systemd/sessions/3 +ACTIVE=3 ACTIVE=1 +ACTIVE_UID=1000 STATE=active +``` + +So the graphical session is active on seat0 and owned by this user, whilst the user is recorded +as having no active seat. Either half of the user file is enough on its own to stop the monitor: +the state is already `online` before WirePlumber's own downgrade is reached, and the empty +`ACTIVE_SEATS` would trigger that downgrade even if it were not. + +## Why every check says the session is active + +`loginctl` answers from logind's live objects over D-Bus. `sd_uid_get_state` reads the file. The +two disagree, and WirePlumber believes the library: + +```text +$ loginctl show-user "$USER" -p State +State=active +``` + +This is the part that costs the time. Session state, seat state and user state all read +correctly, the adapter is fine, the plugins are present, and nothing anywhere reports an error. +WirePlumber logs the decision at debug level only, as a single line naming the state it settled +on: + +```text +s-monitors-bluez: Seat state changed: online +``` + +## Confirming it + +Ask the library directly rather than asking `loginctl`, which is the only check that +distinguishes this from every other cause: + +```python +import ctypes, ctypes.util, os +lib = ctypes.CDLL(ctypes.util.find_library("systemd")) +st = ctypes.c_char_p() +lib.sd_uid_get_state(os.getuid(), ctypes.byref(st)) +print("state:", st.value) +print("active seats:", lib.sd_uid_get_seats(os.getuid(), 1, None)) +``` + +`state: b'online'` with `active seats: 0`, on a machine where `loginctl` says the session is +active, is this finding and not another one. + +## Fix + +Disable the seat gate for the BlueZ monitor. The fragment is the one upstream +documents,[^wireplumber-bluetooth-config] in a file under +`~/.config/wireplumber/wireplumber.conf.d/`: + +```text +wireplumber.profiles = { + main = { + monitor.bluez.seat-monitoring = disabled + } +} +``` + +Restart WirePlumber, and the endpoints register immediately. + +**Repairing the logind state instead does not work.** `loginctl activate` on the graphical +session, which is already the seat's active session, leaves `STATE`, `ACTIVE_SESSIONS` and +`ACTIVE_SEATS` in the user file exactly as they were. Nothing short of ending the session +rebuilds it, so on a machine that is resumed rather than rebooted the configuration change is +the remedy rather than a workaround for one. + +What the gate is given up in exchange is arbitration between simultaneous users: the login +manager's PipeWire instance, or a second logged-in user, may now hold the Bluetooth audio +device whilst inactive. On a single-user machine that is not a cost. On a shared one it is the +behaviour the gate exists to provide. + +## Scope + +The gate is WirePlumber's, but the disagreement underneath it is not. Anything that decides +whether a user is present by calling `sd_uid_get_state` sees `online` here whilst the desktop is +plainly in use, and will behave as though nobody is logged in. diff --git a/docs/findings/20260928_git_lfs_fails_when_the_remote_url_has_a_trailing_slash.md b/docs/findings/20260928_git_lfs_fails_when_the_remote_url_has_a_trailing_slash.md new file mode 100644 index 0000000..997ef31 --- /dev/null +++ b/docs/findings/20260928_git_lfs_fails_when_the_remote_url_has_a_trailing_slash.md @@ -0,0 +1,90 @@ +--- +type: Finding +title: Git LFS fails when the remote URL has a trailing slash +description: A trailing slash on the remote path is tolerated by git and rejected by the LFS + authentication endpoint, which reports it as a permissions error on a repository that cannot be found. +tags: +- finding +- git +- git-lfs +- authentication +- ssh +status: stable +stale_after: '2027-03-28' +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +--- +A remote URL ending in `/` works for every ordinary git operation and breaks Git LFS alone. +Clone, fetch and push are unaffected, because git normalises the path before it asks for +anything; Git LFS passes it through verbatim to `git-lfs-authenticate` over SSH, where GitHub +treats `owner/repo/` as a different repository from `owner/repo.git`, and answers accordingly: + +```console +$ ssh git@HOST git-lfs-authenticate owner/repo/ download +{"auth_status":"bad_permissions","body":"Repository not found."} + +$ ssh git@HOST git-lfs-authenticate owner/repo.git download +{"href": "https://lfs.github.com/owner/repo", "header": {"Authorization": "RemoteAuth ..."}} +``` + +The same credential, the same host, one character apart. The fix is to remove the slash: + +```console +git remote set-url origin git@HOST:owner/repo.git +``` + +## Why it costs time + +The response names the two things that are not wrong. `bad_permissions` points at the token +and `Repository not found` points at the URL's *existence*, so the search goes to scopes, to +single sign-on authorisation, to whether the account still has access, and to the SSH key. +Every one of those checks passes, and `ssh -T git@HOST` greets the right user by name whilst it +does, which is what makes the credential look exonerated rather than untested. Nothing in the +message distinguishes a path the server declines to serve from one it declines to acknowledge, +and that is the distinction the whole finding turns on. + +A host alias from `~/.ssh/config` deepens it. `git lfs env` reports an endpoint built from the +alias rather than from `github.com`: + +```text +Endpoint=https://HOST-ALIAS/owner/repo.git/info/lfs (auth=none) +``` + +That line is a plausible culprit and an innocent one. Git LFS guesses an HTTPS endpoint from +the remote URL and then replaces it with whatever the SSH round trip returns, so the alias +never reaches the network, and `auth=none` describes an empty cache rather than a missing +credential. Reproducing the error with the alias removed is what rules it out, and is worth +doing early, because a wrong hostname printed by the tool's own diagnostic is the most +convincing wrong answer available here. + +## The failure is not confined to the object + +The LFS smudge filter runs during checkout, so its failure aborts the checkout partway. A +`git reset --hard` that hits this leaves the index holding staged deletions for every file it +had already processed, which presents as a repository that has deleted itself: + +```console +$ git reset --hard +... +Error downloading object: docs/diagram.png (0a1b2c3): Smudge error: ... + batch request: {"auth_status":"bad_permissions","body":"Repository not found."} +fatal: docs/diagram.png: smudge filter lfs failed + +$ git status --porcelain | wc -l +147 +``` + +Nothing is lost, and the state is the aborted reset rather than the original problem, but the +volume of it invites a response aimed at the deletions. Repeating the reset once the URL is +corrected restores the tree and empties the index in one step. Running `git lfs ls-files` and +opening one of the files afterwards confirms the objects arrived as content rather than as +pointer text, which a clean `git status` on its own does not. + +## Where the slash comes from + +Nobody types it. It arrives from a clone command copied out of a web interface, an onboarding +document, or a setup script that joins a base URL to a repository name, and it survives +because every check anyone would run on a fresh clone passes. Correcting one working copy +leaves the source of it in place, so the next clone on that machine, or the first clone on a +colleague's, reproduces the whole search from the beginning. diff --git a/docs/findings/20260928_opencode_writes_its_session_transcripts_into_the_claude_config_directory.md b/docs/findings/20260928_opencode_writes_its_session_transcripts_into_the_claude_config_directory.md new file mode 100644 index 0000000..1b1eae6 --- /dev/null +++ b/docs/findings/20260928_opencode_writes_its_session_transcripts_into_the_claude_config_directory.md @@ -0,0 +1,52 @@ +--- +type: Finding +title: opencode writes its session transcripts into the Claude config directory +description: The oh-my-openagent plugin stores transcripts and todos under ~/.claude, so that directory + fills up on a machine where Claude Code has never run. +tags: +- finding +- opencode +- oh-my-openagent +status: stable +stale_after: '2027-03-28' +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +--- +A machine that has never run anything from Anthropic still accumulates hundreds of files in +`~/.claude/transcripts`, one per session, named `ses_.jsonl`. They are opencode sessions. +The oh-my-openagent plugin resolves its own storage against the Claude config directory: + +```js +var TRANSCRIPT_DIR = join75(getClaudeConfigDir(), "transcripts"); +function getClaudeConfigDir() { + const envConfigDir = process.env.CLAUDE_CONFIG_DIR; + if (envConfigDir) return envConfigDir; + return join28(homedir14(), ".claude"); +} +``` + +The same constant appears again in the plugin's `session-manager` tools, alongside a sibling +`todos/` directory, so the tools that list and search sessions read back from the same place. + +Telling the two apart takes one look at the filenames. opencode names a session +`ses_f4b95fe3bffeZm7KqJHiiNpxtH`; Claude Code uses a UUID, and files it under +`~/.claude/projects//` rather than a flat directory. A `~/.claude` holding +`transcripts/` and no `projects/` was written entirely by opencode. + +`CLAUDE_CONFIG_DIR` moves all of it, since the plugin reads that variable before falling back to +the home directory. That is the whole fix, with one consequence worth knowing in advance: it +moves the todos and the session history together, and any real Claude Code install on the same +machine follows it too. + +## Why it surfaced + +The transcripts are worth finding because `session_search` will not find them for you. It scans +`MAX_SESSIONS_TO_SCAN = 50` sessions and stops, so on a machine with 489 transcripts it covers +about the last fortnight. A session from ten days earlier is absent from the results whilst its +file sits on disk, and the search reports no matches rather than reporting a truncated scan. + +`rg` over `~/.claude/transcripts` answers the same question against every session, which is the +workaround for any search that has to reach further back than the cap. + +Observed with opencode 1.18.32 and oh-my-openagent 4.19.4. diff --git a/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md b/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md new file mode 100644 index 0000000..b361a6f --- /dev/null +++ b/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md @@ -0,0 +1,89 @@ +--- +type: Finding +title: pymarkdown's fix mode dedents YAML frontmatter +description: With the front-matter extension disabled, pymarkdown reads a concept's frontmatter as a + thematic break and a paragraph, and fix mode strips the indentation off every nested key. +tags: +- finding +- pymarkdown +- markdown +- frontmatter +- pre-commit +- configuration +status: stable +stale_after: '2027-03-28' +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +--- +A file whose frontmatter carries a nested mapping comes back from a `pymarkdown fix` run with the +nesting gone: + +```yaml +generated: +by: opencode/claude-opus-5 +at: '2026-09-28T00:00:00Z' +``` + +The YAML still parses, which is what makes this expensive. It parses to three sibling keys and a +`generated` of `null`, so whatever consumes the frontmatter sees the field as absent rather than +malformed, and nothing upstream of the renderer complains. + +## What it is + +pymarkdown's front-matter extension is **disabled by default**, and an extension that is off is +off for parsing, not merely for linting. Without it, the opening `---` is an ordinary thematic +break and everything under it is an ordinary paragraph. In CommonMark, leading whitespace on a +paragraph's continuation lines is insignificant, so fix mode is within its rights to normalise it +away, and does. + +markdownlint-cli2 handles frontmatter by default, which is why a repository running both hooks +sees only one of them do this, and why the reflex is to suspect the rule set rather than the +parser. + +## How to get past it + +Enable the extension in `.pymarkdown.json`: + +```json +{ + "extensions": { + "front-matter": { + "enabled": true + } + } +} +``` + +Three things about that file are worth checking at the same time, because every one of them fails +the same way, by leaving the configuration unread and saying nothing. + +**pymarkdown does not look for `.pymarkdown.json` on its own.** The name is conventional, not a +default, so a config nothing points at is a config nothing reads, and editing it changes nothing. +Under pre-commit that means the hook needs the flag: + +```yaml +args: [--strict-config, --config, .pymarkdown.json, --disable-rules, MD046, fix] +``` + +`--strict-config` is what turns the rest of this page into an error instead of a default. Without +it, a configuration pymarkdown cannot use is a configuration pymarkdown ignores in silence. + +**`extensions` and `plugins` are objects, not arrays.** Written as `[]` they are accepted, and +nothing is configured. + +**There is no top-level `config` key.** Rule settings go under `plugins`. A block of rule +configuration parked under `config:` is read by nothing, reported by nothing, and leaves every +rule at its default while looking deliberate. + +The failing and working cases differ by one flag, which makes this quick to confirm on a scratch +file before touching the repository: + +```bash +pymarkdown --set 'extensions.front-matter.enabled=$!True' -c .pymarkdown.json fix a.md +``` + +Enabling the extension changes what the parser sees, not just what it reports: the block that was +a thematic break plus a paragraph becomes frontmatter, so heading rules such as MD001 now measure +the body alone. Expect the first scan after the change to have something to say, and read it as +the rule finally seeing the document rather than as a regression. diff --git a/docs/findings/20260930_chromium_maximises_to_the_wrong_size_after_an_undock.md b/docs/findings/20260930_chromium_maximises_to_the_wrong_size_after_an_undock.md new file mode 100644 index 0000000..8dfbcc3 --- /dev/null +++ b/docs/findings/20260930_chromium_maximises_to_the_wrong_size_after_an_undock.md @@ -0,0 +1,125 @@ +--- +type: Finding +title: Chromium maximises to the wrong size after an undock, and closing every window does not + restart it +description: A browser window that loses its maximised state when the display it was laid out on + disappears, and a PWA window that keeps the process alive so the restart which would clear it + never happens. +tags: +- finding +- linux +- chromium +- kde +- plasma +- x11 +- kwin +status: stable +stale_after: '2027-03-30' +generated: + by: opencode/claude-opus-5 + at: '2026-09-30T12:40:00Z' +--- +Undocking a laptop leaves Chromium windows that maximise to the wrong rectangle, draw no window +decoration, and register clicks several centimetres from where the pointer is. Nothing in the +display configuration is wrong: the process is carrying a model of a screen that no longer +exists, and only a genuine restart clears it. The reason it looks unfixable is separate and is +the part worth keeping, because a PWA window holds the browser process open, so closing every +browser window restarts nothing. + +Observed on Chromium 153.0.8010.47 under Plasma 6 on X11. + +## Symptom + +A Chromium window that was open across an undock behaves as though its geometry and its drawing +disagree. Maximising it fills part of the screen rather than the work area, the title bar +vanishes, and clicks land somewhere other than the element under the pointer. Other windows of +the same process may be perfectly fine, which makes it look like a per-window rendering glitch. + +The configuration all reads correctly, which is what sends the first hour elsewhere. The output +is enabled at its native mode, the X screen dimensions match it, and the scale settings are +whatever they always were. + +## Cause + +Chromium builds its model of the attached displays at startup and does not rebuild it when an +output is destroyed. The damaging case is not a monitor being added or removed alongside others +but the display a window was laid out on ceasing to exist, which is what an undock does when the +external screen was the only enabled output and the internal panel was switched off. + +The visible consequence is in the window's X properties. The affected window has lost its +maximised state entirely: + +```bash +$ xprop -id _NET_WM_STATE +_NET_WM_STATE(ATOM) = +``` + +So the window manager sizes and places it as an ordinary free-floating window whilst Chromium +continues to draw it as a maximised one. The pointer offset follows from that disagreement +rather than from any scaling problem: the two sides are working from different rectangles, and +the gap between them is what a click is wrong by. + +**The drop shadow is not the tell, and reads like one.** A healthy unmaximised Chromium window +carries `_GTK_FRAME_EXTENTS` of `20, 20, 13, 40`, so its raw geometry is larger than the area a +person can see and taller than the screen it fits on. Subtracting the extents gives the visible +rectangle, and on a healthy window that rectangle is flush with the work area. A geometry that +looks impossible is therefore the normal case, and only the missing state atoms distinguish this +finding from a window that is merely not maximised. + +## Why the restart appears not to work + +Closing every Chromium window does not necessarily quit Chromium. A progressive web app opened +from Chromium runs in the same browser process, and its window keeps that process alive after +the last ordinary browser window is gone. Reopening the browser then attaches to the surviving +process and inherits the same stale display model, so the symptom returns immediately and the +restart appears to have been tried and failed. + +This is the whole of the difficulty. A transient fault that a restart would clear is +indistinguishable from a persistent one when the restart silently does not happen, and every +subsequent conclusion is drawn from evidence that says "survives a restart" when nothing of the +sort has been established. + +## Confirming it + +Compare the age of the process owning the window against the moment the display changed. A +process older than the undock is carrying the stale model: + +```bash +pid=$(xprop -id _NET_WM_PID | grep -o '[0-9]*$') +ps -o pid,lstart= -p "$pid" +``` + +The display change itself is dated by the configuration the desktop wrote when it happened, the +newest file under `~/.local/share/kscreen/`, whose contents also record which outputs were +enabled and where they sat. + +## Fix + +Quit Chromium properly, confirm that nothing survives, then relaunch: + +```bash +pkill -x chromium +pgrep -x chromium # must print nothing before relaunching +``` + +The second command is the point of the sequence. A new process rebuilds its display model from +the current outputs, and windows maximise correctly again, including windows restored into the +same session. + +## Recovering one window without restarting + +Where a restart is not wanted, forcing the window through the maximised state repairs that +window, which is enough to make it usable whilst work is finished in it: + +```bash +xdotool windowstate --add MAXIMIZED_HORZ +xdotool windowstate --add MAXIMIZED_VERT +``` + +**The two calls are required, and a combined one does not work.** Passing both flags to a single +invocation applies the horizontal maximise and silently drops the vertical, leaving a window the +full width of the screen and its former height. Splitting them across two calls applies both. + +This repairs geometry only. The process keeps its stale display model, so every other window +already open stays affected and each new window is laid out from the same wrong screen. It is a +way to finish what is in front of you, not a fix. diff --git a/docs/findings/20261001_a_config_symlink_makes_every_omo_migration_fail_on_start.md b/docs/findings/20261001_a_config_symlink_makes_every_omo_migration_fail_on_start.md new file mode 100644 index 0000000..07375fa --- /dev/null +++ b/docs/findings/20261001_a_config_symlink_makes_every_omo_migration_fail_on_start.md @@ -0,0 +1,79 @@ +--- +type: Finding +title: A config symlink makes every omo migration fail on start +description: oh-my-openagent resolves ~/.omo/omo.jsonc through its symlink and then rejects the real + path for not being named omo.jsonc, so a pending migration retries and fails on every start. +tags: +- finding +- opencode +- oh-my-openagent +- config-management +status: stable +stale_after: '2027-04-01' +generated: + by: opencode/claude-opus-5 + at: '2026-10-01T18:10:00+02:00' +--- +Every opencode start printed the same warning, naming a file nobody had asked it to write: + +```text +Migration target is not an omo config path: /home/felix/.config/opencode/omo.jsonc.PROFILES +``` + +The plugin accepts a migration target only when its resolved path is literally +`~/.omo/omo.json`, `~/.omo/omo.jsonc`, or the same two filenames inside a project's `.omo/` +directory. Felix's `~/.omo/omo.jsonc` is a symlink into the opencode config repository, where the +file is called `omo.jsonc.PROFILES` so that a single checkout can carry several profile sets. +The symlink is followed before the check, so the directory is wrong and the basename is wrong, +and the write is refused. + +Reading the config was never affected, which is why the arrangement had worked for a month. The +rejection is in the writer alone. + +## What was trying to write + +A migration whose work had already been done by hand. `~/.omo/.migration-journal.json` held +`2026-09-category-deep-split` with `targetWritten: false`, left behind by +[the upgrade that split the deep category in two](20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md). +The rename had been applied to the config directly, but the `_migrations` array in that file +still listed only the two older markers, so the plugin saw an unapplied migration, tried to +apply it, and hit the symlink. Every start, identically. + +The journal is worth opening rather than deleting on sight. Its stored `targetWrite` payload is +a snapshot of what the config looked like when the migration was first attempted, and in this +case several model pins had been edited since. Had the path problem been fixed and the migration +allowed to run in its `replace-target` mode, it would have written the stale snapshot back over +a fortnight of later edits. A repair that makes the write succeed is the more dangerous of the +two available repairs. + +## The repair that does not touch the symlink + +Both the journal-resume path and the ordinary plan path check for the migration's marker in the +target document *before* anything validates the target path. Recording the migration as applied +therefore stops the attempt early enough that the symlink is never examined: + +```jsonc +"_migrations": [ + "2026-07-opencode-config-unification", + "2026-08-reasoning-unification", + "2026-09-category-deep-split" +] +``` + +Deleting `~/.omo/.migration-journal.json` removes the resume attempt as well. After both, a real +`opencode run` starts clean and the journal does not reappear. + +Reversing the symlink would be the fix that addresses the cause, by putting the real file at +`~/.omo/omo.jsonc` and pointing the repository copy at it. That is not available where the +repository layout requires the differently-named file to be the real one, and the marker repair +costs one line. + +## What it leaves behind + +The next migration oh-my-openagent ships will attempt the same write and fail the same way, with +a different migration id in the message. The repair is the same each time: confirm the change is +already present in the config, add the marker, clear the journal. + +That recurrence is the actual finding. A warning that returns on a schedule set by someone else's +release cadence reads as noise by the third occurrence, and the one time it names a migration +that has *not* been applied by hand is indistinguishable from the times it does not. diff --git a/docs/findings/20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md b/docs/findings/20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md new file mode 100644 index 0000000..8e0ea3a --- /dev/null +++ b/docs/findings/20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md @@ -0,0 +1,70 @@ +--- +type: Finding +title: A plugin upgrade renamed a routing category and silently breached provider isolation +description: oh-my-openagent 5 split its deep category in two, which left the pin naming the old key + inert and sent both new slots to a provider the profile exists to exclude. +tags: +- finding +- opencode +- oh-my-openagent +- config-management +status: stable +stale_after: '2027-04-01' +generated: + by: opencode/claude-opus-5 + at: '2026-10-01T16:35:00+02:00' +--- +Upgrading oh-my-openagent from 4.19.4 to 5.1.7 moved my ovhcloud profile's deep-work slots onto +GitHub Copilot, across the provider boundary the profile exists to enforce. Nothing reported an +error, and the config file that caused it was unchanged and still looked correct. + +The plugin split its `deep` category into `deep-low` and `deep-high`. My profile pinned `deep`, +so after the upgrade that key matched no slot and the two new ones had no pin at all. An +unpinned slot falls through to the plugin's built-in routing table, which encodes a global +preference order over providers rather than "use the one this profile enables" - and its +preferred entries for both named `github-copilot`: + +```text +deep-low STRAY github-copilot/gpt-5.6-sol(medium) (provider is in disabled_providers) +deep-high STRAY github-copilot/gpt-6-astra(xhigh) (provider is in disabled_providers) +``` + +The pin was still in the file, still valid YAML, still naming real ovhcloud models. It had simply +stopped referring to anything. + +## Why nothing caught it + +Three separate guards were in place and none of them fired. + +The profile's `disabled_providers` is read by the plugin, and only governs pins the plugin can +see; a slot resolved by the routing table was never a pin. opencode's own `enabled_providers` is +the stronger gate, but the plugin's provider cache does not consult it, so the routing table +considered an excluded provider reachable. And a dangling pin is not an error condition at all - +the plugin's contract is that an unmatched slot falls back, which is the correct behaviour for a +slot that was never pinned and indistinguishable from one whose key was renamed underneath it. + +A renamed key therefore degrades exactly like a key that was never written, and the only +difference between the two is intent, which no file records. + +## How it was found, and what prevents it + +Only by replaying the new build's routing table against the live catalogue and comparing the +result to what the profile intends - the `check-resolution.sh` in my config's own update skill, +which labels each slot `PINNED`, `ok`, `down`, `NONE` or `STRAY`. Reading the config file could +not have found it, because the file was right. Reading the release notes might have, had the +rename been called out. + +So the habit worth keeping is to **re-run resolution after every plugin upgrade, not only when +something looks wrong**. A routing table is a dependency whose keys are an interface, and nothing +in a config file is version-checked against it. + +This is the mirror image of +[a declared-but-inert config documents intent, not enforcement](../principles/a_declared_but_inert_config_documents_intent_not_enforcement.md): +there an inert entry is legitimately kept as a statement of intent, because the input it waits for +may yet appear. Here the entry was live enforcement until an upgrade made it inert, and the +failure is that the two states are written identically. An inert entry is safe when it never fired; +it is dangerous when it used to. + +The fix was to pin `deep-low` and `deep-high` explicitly, following the old `deep` chain's own +ordering. The same upgrade also retuned the chains for several slots that were resolving fine, +which is the other reason to look rather than assume. diff --git a/docs/findings/20261001_zsh_does_not_expand_a_tilde_in_env_var_assignments.md b/docs/findings/20261001_zsh_does_not_expand_a_tilde_in_env_var_assignments.md new file mode 100644 index 0000000..748a13a --- /dev/null +++ b/docs/findings/20261001_zsh_does_not_expand_a_tilde_in_env_var_assignments.md @@ -0,0 +1,63 @@ +--- +type: Finding +title: zsh does not expand a tilde in env VAR=~/path, and opencode creates the literal directory +description: An unexpanded tilde after env makes OPENCODE_CONFIG_DIR a relative path, which opencode + silently creates rather than rejecting, so commands run against a provider-less config. +tags: +- finding +- opencode +- zsh +- config-management +status: stable +stale_after: '2027-04-01' +generated: + by: opencode/claude-opus-5 + at: '2026-10-01T16:45:00+02:00' +--- +Two innocuous behaviours compose into a confusing one. `opencode run --model ` failed +with `UnknownError: Unexpected server error` for every model I tried, including one I was +sitting in a working session of, which read as a provider outage or a rejected credential. It +was neither. + +The command was: + +```sh +env OPENCODE_CONFIG_DIR=~/.config/opencode/profiles/github-copilot opencode run ... +``` + +zsh expands a tilde after `=` only in a real assignment, or with `MAGIC_EQUAL_SUBST` set. In +`env VAR=~/path` the whole thing is an ordinary command argument, so the tilde survives as a +literal character and the variable holds the relative path `~/.config/opencode/...`. The same +tilde in `export VAR=~/path`, or in the command-prefix form `VAR=~/path opencode ...`, expands +normally - which is why most of my commands that day worked and three did not. + +opencode then **creates a missing config directory rather than failing**, so the relative path +produced a real directory named `~` under the current working directory: + +```text +./~/.config/opencode/profiles/github-copilot/ +``` + +It contained no `opencode.json`, so the session enabled no providers - my base config sets +`enabled_providers: []` deliberately, to fail closed. The session therefore had no model to +reach and reported it as a server error rather than as a missing provider. opencode had also +populated it with a `node_modules` tree, 276 directories, which is what made it visible at all: +it showed up in a recursive diff against a backup taken an hour earlier. + +## How to tell + +Two cheap checks, both faster than debugging the error: + +- `ls -d './~'` in the directory you ran from. A literal tilde directory is never intentional. +- Print the variable as the child sees it: `env VAR=~/x sh -c 'echo "$VAR"'` shows `~/x`, + whilst `VAR=~/x sh -c 'echo "$VAR"'` shows the expanded path. + +Use `"$HOME/..."` rather than a tilde whenever the assignment is an argument to another command. +It expands in every shell and in every position, which a tilde does not. + +The general shape is worth more than the specific bug: a tool that silently creates a missing +config directory converts a typo into a plausible-looking empty configuration, and every error +after that describes the empty configuration rather than the typo. The symptom appears at the +first operation that needed a setting, arbitrarily far from the cause. + +Observed with opencode 1.18.33 under zsh. diff --git a/docs/findings/index.md b/docs/findings/index.md index 066849b..c8dcdf6 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -20,8 +20,20 @@ And the subject lives in the tags rather than in a directory, per ## 2026 +### October + +- 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 + ### September +- 2026-09-30: [Chromium maximises to the wrong size after an undock, and closing every window does not restart it](20260930_chromium_maximises_to_the_wrong_size_after_an_undock.md) - a window that quietly loses its maximised state, and a PWA that keeps the process alive so the restart never happens +- 2026-09-28: [pymarkdown's fix mode dedents YAML frontmatter](20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md) - an extension that is off by default, and a nested key that loses its indentation into YAML that still parses +- 2026-09-28: [opencode writes its session transcripts into the Claude config directory](20260928_opencode_writes_its_session_transcripts_into_the_claude_config_directory.md) - a directory that fills up where Claude Code has never run, and the session search that stops 50 sessions back +- 2026-09-28: [Git LFS fails when the remote URL has a trailing slash](20260928_git_lfs_fails_when_the_remote_url_has_a_trailing_slash.md) - a character git ignores and the LFS authentication endpoint does not, reported as a permissions error +- 2026-09-28: [Bluetooth A2DP fails with Protocol not available after resume](20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md) - a session manager that never starts its Bluetooth monitor, because logind's user file and its seat file stopped agreeing +- 2026-09-28: [A Bluetooth headset reconnects in A2DP instead of the saved profile](20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md) - a saved profile that is consulted in one of three passes, and the priority that decides the other two - 2026-09-21: [neo-tree's hide_hidden does not show dotfiles on Linux](20260921_neo_tree_hide_hidden_does_not_show_dotfiles_on_linux.md) - an option that is Windows-only, in the wrong place, and rejected by nothing - 2026-09-18: [A GitHub token does not authenticate requests to github.com web pages](20260918_a_github_token_does_not_authenticate_github_com_web_pages.md) - a credential the website ignores, and a 404 that cannot be told apart from a deleted repository - 2026-09-18: [linkspector ignores the httpHeaders in its config](20260918_linkspector_ignores_the_httpheaders_in_its_config.md) - a credential that passes validation, is dropped by both checking passes, and leaves a live link reported as broken diff --git a/docs/hardware/.genre.yaml b/docs/hardware/.genre.yaml new file mode 100644 index 0000000..b781fb7 --- /dev/null +++ b/docs/hardware/.genre.yaml @@ -0,0 +1,11 @@ +--- +# What kind of page this directory holds. One directory, one genre, so this is +# declared once here rather than copied into every page's body. The build hook +# `hooks/concept_genre.py` renders the note, and the `genre-conformance` check +# holds every concept's `type` to the one named here. +type: Device +article: a +word: device reference +note: |- + What a machine or peripheral is, and how it presents itself to Linux. Anything + learned whilst getting it to work lives elsewhere and links back here. diff --git a/docs/hardware/.pages b/docs/hardware/.pages new file mode 100644 index 0000000..6a9c95b --- /dev/null +++ b/docs/hardware/.pages @@ -0,0 +1 @@ +title: Hardware diff --git a/docs/hardware/dell_precision_5470.md b/docs/hardware/dell_precision_5470.md new file mode 100644 index 0000000..02c9a37 --- /dev/null +++ b/docs/hardware/dell_precision_5470.md @@ -0,0 +1,115 @@ +--- +type: Device +title: Dell Precision 5470 +description: A 14-inch Alder Lake mobile workstation +tags: +- hardware +- dell +- linux +- audio +- bluetooth +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +sources: +- id: envycontrol + resource: https://github.com/bayasdev/envycontrol + title: 'EnvyControl: a tool to switch between hybrid and dedicated graphics' + last_modified: '2026-09-28' +--- +The Precision 5470 is Dell's 14-inch mobile workstation on Intel's Alder Lake-P platform. + +| | | +| --- | --- | +| Vendor | Dell | +| 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 | +| 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 | +| Wireless | Intel AX211 CNVi, `iwlwifi` for Wi-Fi and `btusb` for Bluetooth | +| Firmware | BIOS 1.31.1, board 08DX30 | +| Read through | Manjaro Linux, kernel 6.18.49 | + +Everything above was read off the running machine rather than from a specification sheet, per +[the convention for this genre](index.md). + +## The processor enumerates without SMT + +The kernel presents 14 logical CPUs, one thread per core, and +`/sys/devices/system/cpu/smt/control` reads `notsupported`. That matches the physical core count +of the part, six performance cores and eight efficiency cores, but not the thread count its +performance cores would normally contribute. Anything that sizes a thread pool from +`nproc` on this machine gets 14, and anything expecting hyperthreading to be togglable at +runtime finds the control unavailable rather than merely off. + +## Audio runs over SoundWire + +The audio controller is enumerated as ordinary Intel HD Audio, but the codec behind it is +reached over SoundWire and driven by the Sound Open Firmware stack, so ALSA presents a single +card named `sof-soundwire` rather than the `HDA Intel PCH` a classic setup would show. The +consequence that surfaces most often is the node names, which carry `platform-sof_sdw` and a +`HiFi__` prefix: + +```text +alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__Speaker__sink +alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__Headphones__sink +``` + +A configuration snippet written against a `HDA Intel PCH` node name will not match anything +here, which is worth knowing before concluding that a rule has been ignored. + +## Bluetooth and Wi-Fi are one part + +The AX211 is a CNVi design, so the Wi-Fi side attaches through the chipset whilst the Bluetooth +side appears on the USB bus as `8087:0033` and is driven by `btusb`. `rfkill` therefore lists +four entries rather than two: the two Dell platform switches, `dell-wifi` and `dell-bluetooth`, +and the two radios themselves, `phy0` and `hci0`. A radio can be blocked at either level, and +the platform switch is the one a function key toggles. + +## The discrete GPU is switched off + +This unit has the discrete NVIDIA GPU, on the x16 bridge at `00:01.0`. It does not appear in +`lspci`, and that absence is the thing to be careful about: the device is removed at boot rather +than missing. + +EnvyControl[^envycontrol] holds the machine in `integrated` mode, which it does with two files. +A modprobe blacklist refuses `nouveau` and every `nvidia` module, and a udev rule removes the +hardware as it is enumerated: + +```text +# /etc/udev/rules.d/50-remove-nvidia.rules +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x03[0-9]*", \ + ATTR{power/control}="auto", ATTR{remove}="1" +``` + +So the rule fires on any NVIDIA display controller, sets it to autosuspend and unbinds it. By the +time anything asks `lspci` what is on the bus, bridge `00:01.0` has an empty secondary bus behind +it. Two things still record that the GPU exists: EnvyControl's own cache names its address, and +the bridge is there with nowhere to lead. + +```bash +$ envycontrol --query +integrated +$ cat /var/cache/envycontrol/cache.json +{ "nvidia_gpu_pci_bus": "PCI:1:0:0" } +``` + +The NVIDIA driver packages are installed and current for the same reason, rather than being +leftovers: `envycontrol -s hybrid` and a reboot bring the card back, and it needs its drivers to +be there when it returns. The visible consequence in the meantime is that `nvidia-suspend`, +`nvidia-resume` and `nvidia-hibernate` are enabled units that skip themselves on every sleep: + +```text +systemd[1]: nvidia-hibernate.service: Skipped due to 'exec-condition'. +``` + +That line sits close enough to the sleep transition to look relevant when reading a journal for +something else, and on this machine it is noise. + +## Findings related to this machine + +- [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) diff --git a/docs/hardware/index.md b/docs/hardware/index.md new file mode 100644 index 0000000..dca595d --- /dev/null +++ b/docs/hardware/index.md @@ -0,0 +1,29 @@ +# Hardware + +A machine or peripheral: what it is, and how it presents itself. + +One page per device, flat. A device page holds the standing facts about a thing, so everything +learned *whilst fixing* one lives under its own nature and links back here: a driver quirk +is a [finding](../findings/index.md), the software it was fixed in is a +[tool reference](../tools/index.md). That split is +[Split orthogonal classification axes across folders and tags](../knowledge_management/split_orthogonal_classification_axes_across_folders_and_tags.md) +applied to physical kit, and it is the reason this directory is a genre rather than a subject: +`hardware` is also a tag, and the tag is what collects the findings. + +Conventions: + +- No page here carries an identifier that singles out a unit. Model names, chipsets, firmware + versions and codec support are what make a page useful to anyone. Serial numbers, service tags, + Where a command's output is quoted and contained an address, the address is replaced with an + obvious placeholder rather than trimmed silently. + +- The facts are read off the running device, not off a spec sheet. + +- A device page is dated by the software it was read through. Kernel and distribution + versions belong in the table, since half of what a page claims is really a claim about a + driver. + +## Devices + +- [Dell Precision 5470](dell_precision_5470.md) - my work machine from 2022 until now: a 14-inch Alder Lake workstation +- [OpenMove by Shokz](openmove_by_shokz.md) - my open-ear bone-conduction headset diff --git a/docs/hardware/openmove_by_shokz.md b/docs/hardware/openmove_by_shokz.md new file mode 100644 index 0000000..c8f6e9a --- /dev/null +++ b/docs/hardware/openmove_by_shokz.md @@ -0,0 +1,80 @@ +--- +type: Device +title: OpenMove by Shokz +description: An open-ear bone-conduction Bluetooth headset that offers SBC for music and mSBC for + voice, advertises no vendor or product ID, and is used here in the lower-quality profile. +tags: +- hardware +- bluetooth +- audio +- shokz +- linux +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +sources: +- id: shokz-openmove + resource: https://shokz.com/products/openmove + title: 'Shokz: OpenMove' + last_modified: '2026-09-28' +--- +The OpenMove is a bone-conduction headset: it sits in front of the ear rather than over or in +it, and conducts through bone, leaving the ear canal open.[^shokz-openmove] That is the whole +reason it is used here, and it is also why the profile it runs in matters more than it would on +a pair of headphones. + +| | | +| --- | --- | +| Vendor | Shokz | +| Type | Open-ear, bone conduction | +| Bluetooth name | `OpenMove by Shokz` | +| Device class | Reported to BlueZ as `audio-headphones` | +| A2DP codecs | SBC, and SBC-XQ | +| HFP codecs | mSBC, and CVSD | +| Identification | Advertises no vendor or product ID | + +## The four profiles it offers + +PipeWire presents these as card profiles, and the priorities are the part worth writing down, +because profile selection falls back to them: + +| Profile | Transport and codec | Format | Priority | +| --- | --- | --- | --- | +| `a2dp-sink` | A2DP, SBC | 48 kHz stereo | 132 | +| `a2dp-sink-sbc_xq` | A2DP, SBC-XQ | 48 kHz stereo | 131 | +| `headset-head-unit` | HSP/HFP, mSBC | 16 kHz mono, with the microphone | 5 | +| `headset-head-unit-cvsd` | HSP/HFP, CVSD | 8 kHz mono, with the microphone | 4 | + +Nothing beyond SBC is on offer for music: no AAC, no aptX, no LDAC. The choice is therefore not +between a good codec and a bad one but between a stereo playback-only profile and a mono +profile that also carries the microphone. + +The two A2DP profiles outrank the two headset profiles by a factor of twenty-six, which is the +mechanism behind +[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). +This device is deliberately used in `headset-head-unit`, so that ordering has to be overridden +rather than accepted. + +## It cannot be matched by vendor or product + +The headset advertises ten service UUIDs, including Handsfree, Advanced Audio Distribution and +both sides of AVRCP, alongside a Serial Port entry and a PnP Information record. The PnP record +is empty in the way that matters: + +```text +bluetooth:v000ApFFFFdFFFF +``` + +Product and device identifiers are `FFFF`, the unset value. A rule that wants to single this +device out therefore cannot key off vendor and product the way a USB rule would, and has to +match on the card name that BlueZ derives from the device address instead. Both configuration +fragments in the findings below do exactly that, which is also why they cannot be copied +between machines unedited. + +## What has cost time here + +- [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), the priority ordering above meeting a saved profile that loses to it +- [Bluetooth A2DP fails with Protocol not available after resume](../findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md), which is not about this device at all, but presents as this device refusing to connect + +Met on [the Precision 5470](dell_precision_5470.md). diff --git a/docs/index.md b/docs/index.md index 8ffafdd..b18de58 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ Each section defines what it holds on its own page, and lists what is in it. The - [Blueprints](blueprints/index.md) - the concrete, copyable artefact a decision produced - [Guides](guides/index.md) - a procedure I have carried out and would carry out again, with the steps that are silent when skipped marked as such - [Knowledge management](knowledge_management/index.md) - how knowledge gets organised, independently of any tool that stores it -- [Research](research/index.md) - longer investigations, recorded as findings rather than conclusions +- [Investigations](investigations/index.md) - longer reading, kept for the evidence rather than the conclusion it reached - [Tools](tools/index.md) - an external tool or project that earns a page of its own +- [Hardware](hardware/index.md) - a machine or peripheral in daily use, and how it presents itself to Linux - [Findings](findings/index.md) - things that cost time once: the symptom, what it was, and how to get past it diff --git a/docs/research/.genre.yaml b/docs/investigations/.genre.yaml similarity index 89% rename from docs/research/.genre.yaml rename to docs/investigations/.genre.yaml index 390c115..c136e87 100644 --- a/docs/research/.genre.yaml +++ b/docs/investigations/.genre.yaml @@ -3,9 +3,9 @@ # declared once here rather than copied into every page's body. The build hook # `hooks/concept_genre.py` renders the note, and the `genre-conformance` check # holds every concept's `type` to the one named here. -type: Reference -article: a -word: piece of research +type: Investigation +article: an +word: investigation note: |- A longer investigation, recorded as findings rather than conclusions. A survey is a claim about what existed when it was written. diff --git a/docs/investigations/.pages b/docs/investigations/.pages new file mode 100644 index 0000000..02639fe --- /dev/null +++ b/docs/investigations/.pages @@ -0,0 +1 @@ +title: Investigations diff --git a/docs/research/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md b/docs/investigations/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md similarity index 99% rename from docs/research/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md rename to docs/investigations/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md index f0bd967..b9b89d9 100644 --- a/docs/research/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md +++ b/docs/investigations/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md @@ -1,5 +1,5 @@ --- -type: Reference +type: Investigation title: Agent-integration layer and multi-vault interaction for an OKF-conformant PKB description: How an agent reaches an OKF-conformant personal knowledge base, and how several vaults at different privacy tiers interact. @@ -10,7 +10,6 @@ tags: - agent-first - knowledge-management - mkdocs -- research status: stable generated: by: opencode/claude-opus-5 diff --git a/docs/research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md b/docs/investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md similarity index 99% rename from docs/research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md rename to docs/investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md index c04f28e..a2a2ef8 100644 --- a/docs/research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md +++ b/docs/investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md @@ -1,5 +1,5 @@ --- -type: Reference +type: Investigation title: 'Federated OKF knowledge bases: a workspace-manifest architecture' description: An implementation-ready architecture binding independent OKF bundles into one privacy-tiered knowledge base through a workspace manifest and a skill layer. @@ -11,7 +11,6 @@ tags: - knowledge-management - federation - mkdocs -- research status: stable generated: by: opencode/claude-opus-5 diff --git a/docs/research/index.md b/docs/investigations/index.md similarity index 72% rename from docs/research/index.md rename to docs/investigations/index.md index 89633ef..1337d3f 100644 --- a/docs/research/index.md +++ b/docs/investigations/index.md @@ -1,10 +1,12 @@ -# Research - -> This needs to be split into analyses and findings! +# Investigations Longer investigations, recorded as findings rather than conclusions. Read these when you want the evidence a [decision](../decisions/index.md) was made on. +A [finding](../findings/index.md) is the cheap neighbour: something that cost time once, +written so the next encounter is short. An investigation costs more, and is read against the +decision it fed rather than on its own. + A survey is a claim about what existed when it was written, so read one against its `generated` date. Where an investigation deliberately stops short of choosing, it says so. @@ -14,3 +16,5 @@ date. Where an investigation deliberately stops short of choosing, it says so. - [Federated OKF knowledge bases: a workspace-manifest architecture](federated_okf_knowledge_bases_a_workspace_manifest_architecture.md) - the architecture that answered the previous two, and that this bundle is now an instance of - [Security Analysis of Agent Wiki (awiki)](security_analysis_of_agent_wiki_awiki.md) - a read of one such tool's source, asking what it does with your data - [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 diff --git a/docs/research/open_knowledge_format_okf_findings.md b/docs/investigations/open_knowledge_format_okf_findings.md similarity index 99% rename from docs/research/open_knowledge_format_okf_findings.md rename to docs/investigations/open_knowledge_format_okf_findings.md index 7bef8ab..9c5e2ac 100644 --- a/docs/research/open_knowledge_format_okf_findings.md +++ b/docs/investigations/open_knowledge_format_okf_findings.md @@ -1,10 +1,9 @@ --- -type: Reference +type: Investigation title: 'Open Knowledge Format (OKF): findings' description: 'Factual notes from reading the Open Knowledge Format v0.2 specification: what it standardises and what it leaves open.' tags: -- research - okf - knowledge-management - agent-wiki diff --git a/docs/research/security_analysis_of_agent_wiki_awiki.md b/docs/investigations/security_analysis_of_agent_wiki_awiki.md similarity index 99% rename from docs/research/security_analysis_of_agent_wiki_awiki.md rename to docs/investigations/security_analysis_of_agent_wiki_awiki.md index 5505322..409a44d 100644 --- a/docs/research/security_analysis_of_agent_wiki_awiki.md +++ b/docs/investigations/security_analysis_of_agent_wiki_awiki.md @@ -1,5 +1,5 @@ --- -type: Reference +type: Investigation title: Security Analysis of Agent Wiki (awiki) description: A security review of the agent-wiki CLI, asking what its potential for data leaks is and whether it phones home. 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 new file mode 100644 index 0000000..147d8a1 --- /dev/null +++ b/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md @@ -0,0 +1,224 @@ +--- +type: Investigation +title: Sharing one user-level AGENTS.md across harnesses +description: A survey of the tools that distribute one ruleset to many coding agents, why almost all + of them refuse the home directory, and the two config lines that make one unnecessary here. +tags: +- ai-agents +- agents +- opencode +- config-management +- tooling +- dx +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-23T00:00:00Z' +sources: +- id: agents-md + resource: https://agents.md/ + title: 'AGENTS.md: a simple, open format for guiding coding agents' + last_modified: '2026-09-23' +- id: opencode-rules + resource: https://opencode.ai/docs/rules/ + 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 + title: 'opencode: session/instruction.ts' + last_modified: '2026-09-23' +- id: claude-memory + resource: https://code.claude.com/docs/en/memory + title: How Claude remembers your project + last_modified: '2026-09-23' +- id: codex-agents-md + resource: https://developers.openai.com/codex/guides/agents-md + title: Custom instructions with AGENTS.md - OpenAI Codex + last_modified: '2026-09-23' +- id: ruler + resource: https://github.com/intellectronica/ruler + title: 'Ruler: apply the same rules to all coding agents' + last_modified: '2026-09-16' +- id: rulesync-global + resource: https://rulesync.dyoshikawa.com/guide/global-mode + title: 'Rulesync: Global Mode' + last_modified: '2026-09-23' +- id: rulesync-tools + resource: https://rulesync.dyoshikawa.com/reference/supported-tools + title: 'Rulesync: Supported Tools and Features' + last_modified: '2026-09-23' +- id: vibe-rules + resource: https://www.npmjs.com/package/vibe-rules + title: 'vibe-rules: a utility for managing Cursor rules, Windsurf rules and other AI prompts' + last_modified: '2025-08-21' +- id: ai-rules-sync + resource: https://www.npmjs.com/package/ai-rules-sync + title: 'ai-rules-sync: synchronize, manage and share your AI rules, skills, commands and subagents' + last_modified: '2026-08-20' +--- +**Context** - Felix keeps one always-on instruction file for every agent session, the digest +described in +[Teaching my agents to write better](../decisions/teaching_my_agents_to_write_better.md). It +sits at `~/.config/opencode/AGENTS.md`, a path [opencode](../tools/opencode.md) invented and +nothing else reads. The question was how to make that file portable across operating systems +and harnesses. + +- **Read on**: 2026-09-23 +- **Method**: vendor documentation read directly; the tool population enumerated by repeated + GitHub repository search and npm registry search, then each candidate's star count and last + push read through `gh` and its README read for the words `global`, `$HOME` and `~/.`; + opencode's instruction loader read at source. + +There is a crowded tool category for this, and surveying it answers the question by showing +that the question was the wrong size. + +## The population, and the two things it splits into + +Searching for these tools returns dozens, which is the expected shape for anything in this +ecosystem. Nearly all of them are answering a different question. + +**The bulk of the count is content generators**: point one at a repository and it inspects the +package manager, scripts and CI and writes an `AGENTS.md` describing what it found. A single +search phrase returns thirty of these, almost all under ten stars. They produce a first draft +of a project file, which is not a distribution problem and has nothing to say about where a +user-level file lives. + +**The distributors are the relevant category**, and there the count is smaller and the +adoption curve brutal: + +| Tool | Stars | Last push | Writes to `$HOME`? | +| --- | --- | --- | --- | +| Ruler | 2.9k | 2026-09 | No, by explicit design[^ruler] | +| Rulesync | 1.5k | 2026-09 | Rules, for three tools[^rulesync-global] | +| vibe-rules | 528 | 2025-08 | Yes, four tools[^vibe-rules] | +| ai-rulez | 143 | 2026-09 | No | +| ai-rules-sync | 38 | 2026-08 | Yes, `--user`[^ai-rules-sync] | +| airul | 34 | 2025-09 | No | +| rulix, airules, dev-spec | 0-4 | 2025-10 to 2026-09 | Not documented | + +Two tools hold the category, a third is an order of magnitude behind, and below that is a tail +of projects with no users. So the explosion is real in count and thin in adoption, which is a +different thing from the category being empty. + +## Writing to the home directory is the rare feature + +**Ruler writes only into the project, and says so as a safety property**: "Ruler never writes +MCP configuration files outside your project root. Any historical references to user home +directories ... have been removed; only project-local paths are targeted."[^ruler] Its +`--global` flag creates a configuration at `~/.config/ruler` used "when no local `.ruler/` +directory is found" - a global *source* feeding project *outputs*[^ruler]. Not a missing +feature; a thing the tool removed on purpose. ai-rulez is project-scoped the same way, where +"global" means the npm install. + +Three tools do write to `$HOME`, and each carries a catch: + +- **Rulesync** has a global mode whose reach is narrower than its fifty-target + matrix[^rulesync-tools] suggests: "Currently, supports rules generation for Claude Code, + GitHub Copilot, and OpenCode."[^rulesync-global] Commands in global mode are Claude Code only. +- **vibe-rules** has the best-shaped mechanism found. `--global` writes `~/.claude/CLAUDE.md`, + `~/.gemini/GEMINI.md`, `~/.codex/AGENTS.md` and `~/.cursor/rules/`, and it edits inside a + delimited `` block rather than owning the + file[^vibe-rules], so hand-written content survives a regeneration. It has had no commit + since 2025-08 and has no opencode target. +- **ai-rules-sync** is the only one built for this case on purpose, with a `--user` flag and an + `ais user install` for a new machine[^ai-rules-sync]. Its mechanism is a git repository + cloned to a cache with symlinks into place - which is a dotfile manager, and inherits the + Windows symlink problem below. + +The asymmetry has an obvious cause once the two categories are separated. Shared rules +committed to a repository are a team problem, with onboarding and drift and a reviewer; one +person's instruction file across their own machines is a dotfiles problem, and dotfiles are +already solved. The tools go where the teams are. + +## What this costs against two lines of config + +Rulesync's three global targets are Claude Code, GitHub Copilot and opencode - the harness in +use here plus the two most likely to be added. Measured against the harnesses that matter +rather than against a catalogue, that is full coverage, so it is a real alternative rather than +an absent one. + +What it costs is a compile step: a source directory, a generate command, generated files in +three home directories that no longer say where they came from, and a fourth tool in the chain +that has to keep tracking fifty moving targets. What it buys, beyond the file itself, is the +other seven surfaces - skills, commands, MCP servers, permissions - kept in step by the same +run. + +## The two lines that make it unnecessary + +opencode's `instructions` config key expands a leading tilde. The loader is one line: + +```ts +const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw +``` + +from `packages/opencode/src/session/instruction.ts`[^opencode-src]. This was the open question +left by the first version of this page, and the answer settles it: `"instructions": +["~/.agents/AGENTS.md"]` is machine-independent, so it can be committed to a config repository +shared across machines. The same file also shows opencode falling back to +`~/.claude/CLAUDE.md` when no global `AGENTS.md` exists[^opencode-src], which the documentation +describes as Claude Code compatibility[^opencode-rules]. + +Claude Code reaches the same file with `@~/.agents/AGENTS.md` in `~/.claude/CLAUDE.md`. Its +`@path` imports go four hops deep, and imports in a user-scope file are trusted without the +approval dialog a project file's external import triggers[^claude-memory]. + +**Two harnesses, two lines of configuration, no build step and nothing generated.** A +generator earns its keep when the ratio is worse than this - more harnesses, or more surfaces +than instructions alone. It is the wrong shape for two lines. + +Codex is the one harness with no reference mechanism at all: it reads +`~/.codex/AGENTS.override.md` or `~/.codex/AGENTS.md` from `CODEX_HOME` and nothing +else[^codex-agents-md]. It takes a symlink, a copy, or rulesync - and it is the case that would +tip the decision if it arrived. + +## Why no standard rescues this + +`AGENTS.md` is a genuine convention, read by Codex, opencode, Amp, Cursor, Gemini CLI, Zed, +Warp, Aider and others, now stewarded by the Agentic AI Foundation[^agents-md]. **Every +statement it makes about discovery is about walking a directory tree inside a repository.** It +specifies nothing at user level, which is why five harnesses invented five paths and three +filenames, and why Claude Code lists `.agents/` among the directories it deliberately does not +read[^claude-memory]. + +`~/.agents/AGENTS.md` is therefore a name chosen rather than a location discovered. It is the +right name here because it is already the cross-harness home for skills in +[ftschindler/agents-skills](https://github.com/ftschindler/agents-skills), and because a file +every harness shares should not be named after one vendor. + +Two constraints hold whatever mechanism is picked. **Symlinks lose on Windows**: creating one +needs Administrator rights or Developer Mode, and git checks a committed symlink out as a +one-line text file unless `core.symlinks` is set, which leaves that machine holding a +`CLAUDE.md` whose entire contents are the string `AGENTS.md`. Claude Code's own documentation +recommends the import over the symlink for exactly this reason[^claude-memory]. And **the file +stays short**: Codex stops adding instruction files at `project_doc_max_bytes`, 32 KiB by +default[^codex-agents-md], and Claude Code asks for under 200 lines and says plainly that +longer files reduce adherence[^claude-memory]. A user-level file is prepended to every session +in every repository, so its cost is paid every turn and never amortised - the same budget +argument +[Route a rule to the layer that reaches whoever must obey it](../principles/route_a_rule_to_the_layer_that_reaches_whoever_must_obey_it.md) +makes from the other side. + +## Bottom line + +The category is crowded and aimed elsewhere. Most of its headcount writes project files from a +repository scan; of the handful that distribute one source to many tools, the two with real +adoption treat the home directory as out of scope or nearly so, and the three that do write +there are narrow, unmaintained or symlink-based. + +Rulesync is the one worth holding onto, because its three global targets happen to be the +right three. It is still the wrong size for two harnesses. One canonical file at +`~/.agents/AGENTS.md`, an `instructions` entry in opencode's config and an `@` import in +`~/.claude/CLAUDE.md` gets the content to both with no compile step and nothing generated. +Revisit it when a third harness arrives without a reference mechanism, or when the thing being +synchronised stops being one markdown file and becomes skills, commands and MCP servers as +well - which is the threshold it was actually built for. + +The more useful thing the survey settles is what kind of problem this is. The tools decline +the home directory because a user-level instruction file is not an agent problem at all: it is +one person's configuration across their own machines, which is a dotfiles problem wearing a +new hat. ai-rules-sync is the tool that noticed, and its answer - a git repository, a cache +and symlinks - is what a dotfile manager already does. + +One thing this does not settle: the file is currently version-controlled inside the opencode +config repository, and `~/.agents` is a different repository with a different publishing +posture. That is a decision about where the content lives, not a path change. diff --git a/docs/research/skills_sh_repository_layout_and_telemetry.md b/docs/investigations/skills_sh_repository_layout_and_telemetry.md similarity index 99% rename from docs/research/skills_sh_repository_layout_and_telemetry.md rename to docs/investigations/skills_sh_repository_layout_and_telemetry.md index 4378257..5b81e8a 100644 --- a/docs/research/skills_sh_repository_layout_and_telemetry.md +++ b/docs/investigations/skills_sh_repository_layout_and_telemetry.md @@ -1,10 +1,9 @@ --- -type: Reference +type: Investigation title: 'skills.sh: repository layout, selective install and telemetry' description: A source read of the skills CLI, answering where it finds skills in a repository, how a user installs one rather than all, and what it reports back about the install. tags: -- research - agent-skills - skills-sh - data-privacy diff --git a/docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md b/docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md new file mode 100644 index 0000000..8a55cfa --- /dev/null +++ b/docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md @@ -0,0 +1,161 @@ +--- +type: Investigation +title: 'Spell-checking prose in a pre-commit hook: corpus checkers, dictionary checkers, and what + each one misses' +description: A measured comparison of codespell, typos and cspell against one real misspelling, showing + that corpus and dictionary checkers fail on disjoint sets and that neither alone enforces a + language variant. +tags: +- pre-commit +- linting +- spelling +- prose +- dx +- tooling +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +sources: +- id: typos-hooks + resource: https://raw.githubusercontent.com/crate-ci/typos/master/.pre-commit-hooks.yaml + title: 'crate-ci/typos: .pre-commit-hooks.yaml' + last_modified: '2026-09-28' +- id: cspell-hooks + resource: https://raw.githubusercontent.com/streetsidesoftware/cspell-cli/main/.pre-commit-hooks.yaml + title: 'streetsidesoftware/cspell-cli: .pre-commit-hooks.yaml' + last_modified: '2026-09-28' +- id: typos-pypi + resource: https://pypi.org/pypi/typos/json + title: 'typos on PyPI: release metadata' + last_modified: '2026-09-28' +--- +**Context** - a documentation bundle whose house rules require British English throughout had no +spell check of any kind, and the thing that prompted one was a single typo in a YAML file: +`revied`, for `reviewed`. The question was which hook to add. The answer turned out to depend on +a distinction that neither tool's description makes obvious, and that only testing against that +exact word revealed. + +Everything below was run on one repository of roughly a hundred markdown files on 2026-09-28, +against `codespell`, `typos` v1.50.3 and `cspell` v10.2.0. + +## Two kinds of checker, and they are not substitutes + +**A corpus checker** carries a list of known misspellings paired with their corrections. +`codespell` and `typos` are both of this kind. They are fast, they suggest a fix, and they +almost never produce a false positive, because a word is only reported if somebody has +previously recorded it as a mistake. + +**A dictionary checker** carries a list of words that exist. `cspell` is of this kind. It reports +anything absent from that list, usually with no suggestion, and every proper noun and domain term +in the repository is absent from it until somebody adds it. + +The consequence is the finding, and it is sharper than "one is stricter": + +```text +$ echo 'teh recieve revied reviewd seperate occured' | checker + +codespell teh, recieve, seperate, occured +typos teh, recieve, seperate, occured, reviewd +cspell recieve, revied, reviewd, seperate, occured +``` + +`revied` passes both corpus checkers. It is not a recorded mistake, so neither examines it. +`reviewd`, one letter away and the same kind of error, is caught by `typos`. A corpus checker's +coverage is not a property of how wrong a word is; it is a property of whether somebody typed +that particular wrong word often enough for it to be collected. + +The inverse holds too. `teh` is in every corpus and, being three letters, is the kind of token a +dictionary checker's own heuristics may pass over. **The two catch overlapping but neither-contains-the-other +sets**, which is the argument for running both rather than choosing. + +## Only one of the three can enforce a language variant + +A written rule of "British English throughout" is unenforced unless the checker knows which +English it wants. + +- `typos` takes `locale = "en-gb"` and then reports `color` as `colour`, `utilizing` as + `utilising`, `capitalize` as `capitalise`. +- `codespell` ships builtin dictionaries named `en-GB_to_en-US` and `en_to_en-OX`, and nothing in + the opposite direction. It can convert British spellings to American, which is the reverse of + what a British-English rule wants, so it cannot do this job at all. +- `cspell` under `--locale en-GB` rejects `color`, but accepts `utilizing`, `capitalize` and + `localized`. That is correct lexicography and useless as a style gate: the `-ize` endings are + valid English, listed in Oxford dictionaries, and a dictionary checker has no grounds to object. + +So the locale rule is `typos`-shaped work. On a repository that had never been spell-checked, it +found `utilizing`, `capitalize` and `localized` sitting in prose whose conventions file demanded +the opposite. + +## Scope is the difference between seven hits and five hundred + +Run unscoped across the repository, `typos` reported 532 occurrences of `Color` and 55 of +`center`. All of them came from Excalidraw diagrams, which are JSON files full of an American +spelling nobody wrote by hand. Scoped to tracked markdown via `git ls-files '*.md'`, the same run +reported seven distinct issues. + +`cspell` over the same markdown reported 87 unknown words, of which roughly 85 were surnames, +product names and jargon. That is the real cost of a dictionary checker, and it is a one-time +cost: those words become a committed project dictionary, after which a new unknown word is either +a typo or a term nobody has written down yet. The corpus checker's equivalent cost was one +ignore entry, for a product name whose capitalisation the corpus read as a misspelling of a +common English word. + +Both numbers argue the same thing from opposite ends. A content checker is pointed at the prose a +person wrote, never at a whole tree, and the file filter is doing as much work as the tool. + +## The upstream hooks, and their defaults + +Both projects ship a `.pre-commit-hooks.yaml`, so both pin to a SHA the way +[Pin pre-commit hooks to frozen revisions](../principles/pin_pre_commit_hooks_to_frozen_revisions.md) +asks. Neither needs a local reimplementation, and writing one would trade a commit pin for a +package-version pin, which is strictly worse. + +One default is worth knowing before adopting. The `typos` hook ships +`args: [--write-changes, --force-exclude]`[^typos-hooks], so taking it as documented means a +tool that **rewrites prose in place on every commit**. For code that is usually welcome, and +[Autofix in the hook, don't just flag](../principles/autofix_in_the_hook_dont_just_flag.md) +argues for exactly that where a transform is deterministic and safe. A spelling correction in +prose is neither: the corpus offers several candidates for some words, and in a bundle whose +subject is its own wording, a silent edit is a change of meaning. Overriding `args` wholesale is +the only way to drop the flag, since pre-commit replaces the list rather than appending to it. + +The `cspell` hook ends its `args` with `--files`[^cspell-hooks], which must stay last so the +paths are appended correctly, and any override has to preserve that. + +Both were run under `prek` in a scratch repository before being recommended, which is how the +`--write-changes` default and the config-file pickup were established rather than assumed. That +habit generalises: see +[Verify a pre-commit hook's file-type filter actually matches your file](../principles/verify_a_pre_commit_hooks_file_type_filter_actually_matches_your_file.md), +where a hook reported success whilst looking at nothing. + +## Portability + +`typos` installs as `language: python` from a prebuilt wheel, and v1.50.3 publishes wheels for +macOS x86-64 and arm64, manylinux x86-64, aarch64 and i686, musllinux, and Windows 32 and +64-bit[^typos-pypi]. No Rust toolchain is involved anywhere, so it is as portable as any Python +hook. + +`cspell` is `language: node`, so the hook runner provisions Node for it. That is not a new risk in +a repository that already runs a node hook, but it is not free either: a node hook's environment +and the runner's cache can drift apart, and the failure shows up as a hook that installed without +its dependencies. + +Measured on the same markdown: `typos` 0.2s, `cspell` 1.9s. Neither is a reason to choose. + +## Bottom line + +Run both. The pair costs two seconds and one dictionary, and the case for it is a single word: +`revied` passes both corpus checkers, `teh` is what corpus checkers exist for, and no single tool +here covers both. If only one is possible, take `cspell` for the dictionary class and accept that +the `-ize` endings go unchecked, because a variant rule nobody enforces was already the status +quo. + +Two things the investigation does not settle. Whether a dictionary of 85 proper nouns stays +maintained, or silently becomes a place where genuine typos get added to make a commit pass, +which is the same failure mode as an unexplained lint suppression and wants the same answer. +And what to do about mirrored documents: a bundle that reproduces somebody else's writing must +not correct its typos, since a corrected mirror is a paraphrase still claiming provenance. Four +of the seven remaining `typos` hits were inside such a mirror. Excluding the directory is the +honest fix and an allow list is not, but that is a claim about mirrors rather than about +spelling, and it belongs on a page of its own. diff --git a/docs/research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md b/docs/investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md similarity index 99% rename from docs/research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md rename to docs/investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md index 2952c92..87012fa 100644 --- a/docs/research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md +++ b/docs/investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md @@ -1,5 +1,5 @@ --- -type: Reference +type: Investigation title: 'Substrate options for an OKF-based agent-first LLM wiki: investigation' description: What tool or substrate to adopt for an agent-first LLM wiki that is also a human PKB, given an already-adopted OKF target. @@ -10,7 +10,6 @@ tags: - agent-wiki - knowledge-management - agent-first -- research status: stable generated: by: opencode/claude-opus-5 diff --git a/docs/knowledge_management/categorize_by_what_content_is_not_why_you_made_it.md b/docs/knowledge_management/categorize_by_what_content_is_not_why_you_made_it.md index 30dcfe7..639def8 100644 --- a/docs/knowledge_management/categorize_by_what_content_is_not_why_you_made_it.md +++ b/docs/knowledge_management/categorize_by_what_content_is_not_why_you_made_it.md @@ -19,8 +19,8 @@ knowledge. They landed there because **that is what I happened to be doing** when I found them, not because of what they actually are. Corrected: -[Security Analysis of Agent Wiki (awiki)](../research/security_analysis_of_agent_wiki_awiki.md) -is a security review, so it belongs in `research`. +[Security Analysis of Agent Wiki (awiki)](../investigations/security_analysis_of_agent_wiki_awiki.md) +is a security review, so it belongs in `investigations`. [awiki title extraction breaks on frontmatter-led source files](../findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md) is a finding, so it belongs in `findings`. Both stand on their own merits as durable, hard-won knowledge. Neither needed the knowledge-management framing to diff --git a/docs/knowledge_management/layer_build_knowledge_as_a_values_to_blueprints_derivation_pipeline.md b/docs/knowledge_management/layer_build_knowledge_as_a_values_to_blueprints_derivation_pipeline.md index dc46cfe..d5b16c6 100644 --- a/docs/knowledge_management/layer_build_knowledge_as_a_values_to_blueprints_derivation_pipeline.md +++ b/docs/knowledge_management/layer_build_knowledge_as_a_values_to_blueprints_derivation_pipeline.md @@ -39,11 +39,11 @@ sit beside the pipeline and supply the hinge with evidence: | Layer | Question it answers | Nature | | --- | --- | --- | -| **Research** | What is actually the case, before I commit to anything? | evidence gathered, conclusion deferred | +| **Investigations** | What is actually the case, before I commit to anything? | evidence gathered, conclusion deferred | | **Explorations** | What did I try, what did it teach me, and why did I stop? | a commitment made and then withdrawn | They differ by commitment, and that difference is what tells a reader how much -weight the findings carry: research is reading, an exploration is building. +weight the findings carry: an investigation is reading, an exploration is building. Somebody who read the documentation and somebody who ran the thing for a month know different amounts. @@ -88,5 +88,5 @@ compliance artefact. Because the knowledge is *yours*, the record can be persona Applying this model means creating a directory per layer, which is itself an instance of [Split orthogonal classification axes across folders and tags](split_orthogonal_classification_axes_across_folders_and_tags.md) (nature is the folder axis). This page is the general technique; standing up the actual -`values` / `wishes` / `decisions` / `blueprints` directories, alongside `research` -and `explorations`, is its application. +`values` / `wishes` / `decisions` / `blueprints` directories, alongside +`investigations` and `explorations`, is its application. diff --git a/docs/knowledge_management/record_an_abandoned_exploration_as_an_exploration_not_a_superseded_decision.md b/docs/knowledge_management/record_an_abandoned_exploration_as_an_exploration_not_a_superseded_decision.md index 4936915..a89fa2a 100644 --- a/docs/knowledge_management/record_an_abandoned_exploration_as_an_exploration_not_a_superseded_decision.md +++ b/docs/knowledge_management/record_an_abandoned_exploration_as_an_exploration_not_a_superseded_decision.md @@ -56,14 +56,14 @@ claims about the world, and a status field on a page whose header says **Decision** cannot carry the difference: the reader has already accepted the frame by the time they reach it. -Research is the nearer neighbour, and still not the same thing. Research is -evidence gathered without committing; an exploration is a commitment made and -then withdrawn. The distinction is worth keeping because it tells the reader how -much weight the findings carry. Somebody who read the documentation and somebody -who ran the thing for a month know different amounts. +An investigation is the nearer neighbour, and still not the same thing. An +investigation is evidence gathered without committing; an exploration is a +commitment made and then withdrawn. The distinction is worth keeping because it +tells the reader how much weight the findings carry. Somebody who read the +documentation and somebody who ran the thing for a month know different amounts. ## The tell that you have one -You are hesitating over whether a page is a decision or research, and the reason -is that you *did* decide, and it *is* no longer true. That hesitation is the -exploration layer asking to exist. +You are hesitating over whether a page is a decision or an investigation, and +the reason is that you *did* decide, and it *is* no longer true. That hesitation +is the exploration layer asking to exist. diff --git a/docs/knowledge_management/separate_audiences_with_separate_bundles.md b/docs/knowledge_management/separate_audiences_with_separate_bundles.md index 4eaa144..5dde705 100644 --- a/docs/knowledge_management/separate_audiences_with_separate_bundles.md +++ b/docs/knowledge_management/separate_audiences_with_separate_bundles.md @@ -52,7 +52,7 @@ reorganising an existing one. - **Cross-bundle links become their own problem**, since a link from a public page into a private one leaks the private page's existence and often its title. That needs a rule of its own; in this knowledge base it is the `referenceable_by` allow-list in - [Federated OKF knowledge bases](../research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md). + [Federated OKF knowledge bases](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md). - **Moving a page to a wider audience is irreversible**, because the target's history keeps it. That makes promotion a deliberate, gated act rather than a drag-and-drop, which is why [Federating my knowledge base as privacy-tiered OKF bundles](../decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md) diff --git a/docs/log.md b/docs/log.md index c73b2c6..5971e10 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,47 @@ # Log +## 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 +- change the investigations genre type from Reference to Investigation, on all eight pages and in the directory's genre note +- rewrite the investigations index to define the genre against findings and decisions +- retitle the research layer as investigations in "Layer build-knowledge as a values-to-blueprints derivation pipeline" and "Record an abandoned exploration as an exploration, not a superseded decision" +- add tool, "OpenChamber" + +## 2026-10-01 + +- add finding, "A config symlink makes every omo migration fail on start" +- add finding, "A plugin upgrade renamed a routing category and breached provider isolation" +- add finding, "zsh does not expand a tilde in env VAR=~/path, and opencode creates the literal directory" + +## 2026-09-30 + +- add finding, "Chromium maximises to the wrong size after an undock, and closing every window does not restart it" + +## 2026-09-28 + +- add finding, "pymarkdown's fix mode dedents YAML frontmatter" +- add finding, "opencode writes its session transcripts into the Claude config directory" +- add finding, "Git LFS fails when the remote URL has a trailing slash" +- add research, "Spell-checking prose in a pre-commit hook", comparing codespell, typos and cspell against one real misspelling and finding that corpus and dictionary checkers fail on sets neither contains +- record that only a locale-aware checker can enforce a British-English rule, codespell shipping the conversion in the wrong direction and cspell having no grounds to reject a valid -ize ending +- keep the measurements that argue for scope, five hundred hits from diagram JSON against seven from the markdown a person wrote +- add a hardware section, holding device references, with its genre declaration and its place in the root index and the nav +- add "Dell Precision 5470", the notebook +- add "OpenMove by Shokz", the headset +- add tool page, "WirePlumber" +- add finding, "Bluetooth A2DP fails with Protocol not available after resume" +- add finding, "A Bluetooth headset reconnects in A2DP instead of the saved profile" +- correct the discrete graphics section of "Dell Precision 5470", which read a device removed by EnvyControl as a device the machine does not have +- add tool stub, "Voxtype (Peter Jackson)" +- add tool stub, "Vocalinux (VocaHQ)" + +## 2026-09-23 + +- add research, "Sharing one user-level AGENTS.md across harnesses", surveying where each harness looks for a user-level instruction file and which adapter mechanism survives Windows +- rewrite it around the rule-file generator category, Ruler and rulesync, after confirming opencode's `instructions` key expands a leading tilde at source +- correct that survey after a proper enumeration: split the population into content generators and distributors, add vibe-rules, ai-rulez and ai-rules-sync, and record that writing to the home directory is the rare feature + ## 2026-09-22 - move the "What I took" section off the caveman tool page into a decision, "Adopting caveman at lite, without its init block", and leave the tool page pointing at it diff --git a/docs/research/.pages b/docs/research/.pages deleted file mode 100644 index 8acebaf..0000000 --- a/docs/research/.pages +++ /dev/null @@ -1 +0,0 @@ -title: Research diff --git a/docs/tools/agent_knowledge_stjbrown.md b/docs/tools/agent_knowledge_stjbrown.md index da34f06..d688f06 100644 --- a/docs/tools/agent_knowledge_stjbrown.md +++ b/docs/tools/agent_knowledge_stjbrown.md @@ -28,7 +28,7 @@ sources: --- agent-knowledge[^ak-readme] is a set of portable agent skills for building and maintaining knowledge bundles in plain Markdown, conformant to -[Open Knowledge Format v0.2](../research/open_knowledge_format_okf_findings.md). It is the +[Open Knowledge Format v0.2](../investigations/open_knowledge_format_okf_findings.md). It is the closest published thing to what this knowledge base is, and it appears here in two roles at once: as a tool that was evaluated, and as a source that is cited. @@ -69,10 +69,10 @@ work without it. ## Why it matters here **It ranked first as an architectural match.** -[Substrate options for an OKF-based agent-first LLM wiki: investigation](../research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md) +[Substrate options for an OKF-based agent-first LLM wiki: investigation](../investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md) scored it as the best fit for "skill plus contract, CLI optional", and its index is the worked example behind the conclusion in -[Federated OKF knowledge bases](../research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md) +[Federated OKF knowledge bases](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md) that an index should be authored rather than generated: it truncates, compresses or rewrites its own concept descriptions rather than copying them, which is what a generator cannot do. diff --git a/docs/tools/agent_wiki_tacotakumi.md b/docs/tools/agent_wiki_tacotakumi.md index f5aa9f3..034ba1e 100644 --- a/docs/tools/agent_wiki_tacotakumi.md +++ b/docs/tools/agent_wiki_tacotakumi.md @@ -78,7 +78,7 @@ was already git and pre-commit. That argument, and three others, are in ## On its OKF claim The README describes the vault as roughly 80% conformant with the -[Open Knowledge Format](../research/open_knowledge_format_okf_findings.md) *by convergent +[Open Knowledge Format](../investigations/open_knowledge_format_okf_findings.md) *by convergent design* rather than by implementing the spec: markdown with frontmatter, generated `index.md` and `log.md`, no prescribed taxonomy, arbitrary frontmatter keys preserved. That is an honest framing, and the missing fraction is where it matters. OKF §6.1 specifies standard markdown @@ -89,7 +89,7 @@ concept, whilst awiki splits each one into a raw source and a rendered page. - [awiki tracks backlinks via wikilinks only, not Markdown links](../findings/20260827_awiki_tracks_backlinks_via_wikilinks_only_not_markdown_links.md) - [awiki title extraction breaks on frontmatter-led source files](../findings/20260827_awiki_title_extraction_breaks_on_frontmatter_led_source_files.md) -- [Security Analysis of Agent Wiki (awiki)](../research/security_analysis_of_agent_wiki_awiki.md) - a full source read, asking what it does with your data +- [Security Analysis of Agent Wiki (awiki)](../investigations/security_analysis_of_agent_wiki_awiki.md) - a full source read, asking what it does with your data - [Running this knowledge base on awiki](../explorations/running_this_knowledge_base_on_awiki.md) - the phase spent running on it, and why it ended ## Where it sits among the alternatives @@ -102,4 +102,4 @@ of the tools implementing the pattern and a of the OKF-authoring ones. It is a bundle in this workspace, so it is worth reading there rather than restating here. The narrower question of what to use *instead*, scored against requirements, is -[Substrate options for an OKF-based agent-first LLM wiki: investigation](../research/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md). +[Substrate options for an OKF-based agent-first LLM wiki: investigation](../investigations/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md). diff --git a/docs/tools/federated_knowledge_skills_schindler.md b/docs/tools/federated_knowledge_skills_schindler.md index 4d50051..9063528 100644 --- a/docs/tools/federated_knowledge_skills_schindler.md +++ b/docs/tools/federated_knowledge_skills_schindler.md @@ -28,7 +28,7 @@ sources: last_modified: '2026-09-16' --- federated-knowledge-skills[^fks-readme] is the layer that turns several independent -[OKF](../research/open_knowledge_format_okf_findings.md) bundles into one federation an agent +[OKF](../investigations/open_knowledge_format_okf_findings.md) bundles into one federation an agent can use: a skill it reads, a CLI it runs, and two pre-commit hooks each bundle pins for itself. It is mine, and it is the tooling this bundle is now written through, so it appears here in the same two roles that [agent-knowledge](agent_knowledge_stjbrown.md) does: a tool with a page, @@ -93,7 +93,7 @@ where it has to be enforced. [Federating my knowledge base as privacy-tiered OKF bundles](../decisions/federating_my_knowledge_base_as_privacy_tiered_okf_bundles.md) is the decision, and -[Federated OKF knowledge bases: a workspace-manifest architecture](../research/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md) +[Federated OKF knowledge bases: a workspace-manifest architecture](../investigations/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md) is the architecture it settled on. The first implementation wrapped the `kb-*` skills from [agent-knowledge](agent_knowledge_stjbrown.md) and was retired at twenty commits and a green test suite; the format discipline and that project's own bundle survived, and are still read here as an diff --git a/docs/tools/index.md b/docs/tools/index.md index 80a0005..7871f00 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -21,8 +21,13 @@ behind them: they keep their plain name, because `uv` is Astral's and will not b - [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 - [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 - [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 +- [liseur and liseur-sync (Chmouel Boudjnah)](liseur_chmouel.md) - EPUB reader for Android with self-hosted sync server, reading-position synchronization across devices, KOReader compatibility, and offline PWA web reader +- [WirePlumber](wireplumber.md) - the session manager that decides what reaches a PipeWire graph, and the half of the audio stack that owns the decision when a device never appears +- [Voxtype (Peter Jackson)](voxtype_jackson.md) - stub: push-to-talk dictation as a single Rust binary, with eight local engines behind one config line +- [Vocalinux (VocaHQ)](vocalinux_vocahq.md) - stub: the AGPL Python alternative, dictating from a tray icon on X11 and Wayland alike diff --git a/docs/tools/liseur_chmouel.md b/docs/tools/liseur_chmouel.md new file mode 100644 index 0000000..77df5e8 --- /dev/null +++ b/docs/tools/liseur_chmouel.md @@ -0,0 +1,129 @@ +--- +type: Tool +title: Liseur and Liseur-Sync +description: An open-source Android EPUB reader paired with a self-hosted Go sync server, for keeping + reading positions in step across devices and serving a personal book library. +tags: +- tools +- reading +- epub +- self-hosted +status: stable +sources: +- id: liseur-repo + resource: https://github.com/chmouel/liseur + title: 'chmouel/liseur: EPUB reader for Android' + last_modified: '2026-09-28' +- id: liseur-sync-repo + resource: https://github.com/chmouel/liseur-sync + title: 'chmouel/liseur-sync: sync server for Liseur' + last_modified: '2026-09-28' +generated: + by: opencode/qwen3.5-397b-a17b + at: '2026-09-28T00:00:00Z' +--- + +An open-source EPUB reader for Android paired with a self-hosted sync server, designed for reading-position synchronization across devices and integration with personal book libraries. + +## What it is + +**Liseur**[^liseur-repo] is the Android client: an EPUB reader using the Readium engine with support for local files, OPDS catalogs, and sync servers. **Liseur-sync**[^liseur-sync-repo] is the companion server: a single Go binary that provides reading-position synchronization, library management, and statistics. + +Together they form a self-hosted alternative to commercial ebook ecosystems, with explicit support for KOReader synchronization and Calibre library integration. + +## Key capabilities + +### Reading synchronization + +Reading positions are stored as an append-only log rather than replacing the previous state. This allows the server to resolve updates from multiple devices without older clients blindly overwriting newer state. The same history derives reading sessions and statistics. + +Book identity is independent of filesystem path - clients resolve books using content and metadata identifiers before exchanging reading state. Reader preferences (typeface, theme, margins) sync as a small map of opaque strings per account, resolving by last-writer-wins. + +### Library management + +The server can index either: + +- A directory containing EPUB files +- A Calibre library using `metadata.db` + +Folders are granted to accounts individually and are read-only by default. Uploads and deletions require explicit configuration via `liseur-sync admin folder-uploads on`. + +### Web reader with offline PWA + +A browser-based EPUB reader is included, using the same synchronization API as native clients. When another device has read further, the reader offers to continue there, naming both pages and how long ago the other device was there. + +The web UI ships as a PWA: choose "Save offline" for each book, install via "Add to Home Screen", and read without a network connection. Positions, highlights, notes, and reading sessions are stored locally until reconnection. + +### Interfaces + +- Native REST API +- KOReader-compatible kosync API +- OPDS 1.2 catalog +- Per-user reading statistics and sessions +- Per-user series claims +- Browser-based administration interface + +## Installation + +### Quick install + +```bash +curl -fsSL https://raw.githubusercontent.com/chmouel/liseur-sync/main/scripts/install.sh | bash +``` + +### Docker Compose + +SQLite (simplest): + +```bash +docker compose --profile sqlite up -d +``` + +Bundled PostgreSQL: + +```bash +docker compose --profile postgres up -d +``` + +External PostgreSQL: + +```bash +docker compose --profile external up -d +``` + +### Build from source + +```bash +go build ./cmd/liseur-sync +./liseur-sync serve +``` + +## Initial setup + +The web interface is at `/ui/`. When the database contains no accounts, the setup page creates the initial administrator account and can watch a first folder of books in the same step. + +Users, folders, API tokens, folder grants, and reader pairing are managed from the administration interface or with the `admin` CLI. + +## Client integration + +Liseur uses the native API. Protocol and synchronization details are documented in [`docs/integrating.md`](https://github.com/chmouel/liseur-sync/blob/main/docs/integrating.md) in the server repository. + +OpenAPI specification: [`docs/openapi.yaml`](https://github.com/chmouel/liseur-sync/blob/main/docs/openapi.yaml) + +## Related projects + +- **liseur-desktop**: Desktop version (abandoned) +- **KOReader**: Supported via kosync-compatible API +- **Calibre**: Integration via `metadata.db` catalog + +## License + +MIT + +## Author + +Chmouel Boudjnah + +- Fediverse: [@chmouel@chmouel.com](https://fosstodon.org/@chmouel) +- Twitter: [@chmouel](https://twitter.com/chmouel) +- Blog: diff --git a/docs/tools/openchamber.md b/docs/tools/openchamber.md new file mode 100644 index 0000000..5e73360 --- /dev/null +++ b/docs/tools/openchamber.md @@ -0,0 +1,116 @@ +--- +type: Tool +title: OpenChamber +description: A desktop, web and mobile workspace that drives an opencode server rather than replacing + it, and what it adds and gives up against running the opencode terminal interface directly. +tags: +- tools +- opencode +- agents +- openchamber +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-10-07T12:00:00+02:00' +sources: +- id: openchamber-repo + resource: https://github.com/openchamber/openchamber + title: 'openchamber/openchamber: Agentic Development Environment based on OpenCode AI agent' + last_modified: '2026-10-07' +- id: openchamber-docs-server + resource: https://docs.openchamber.dev/opencode-server/ + title: 'OpenChamber: OpenCode Server' + last_modified: '2026-10-07' +- id: openchamber-docs-providers + resource: https://docs.openchamber.dev/providers/ + title: 'OpenChamber: Providers, Models and Agents' + last_modified: '2026-10-07' +- id: openchamber-docs-environment + resource: https://docs.openchamber.dev/environment/ + title: 'OpenChamber: Environment Variables' + last_modified: '2026-10-07' +- id: openchamber-docs-repository-config + resource: https://docs.openchamber.dev/repository-config/ + title: 'OpenChamber: Repository Configuration' + last_modified: '2026-10-07' +--- +OpenChamber[^openchamber-repo] is a workspace around [opencode](opencode.md) rather than a +second agent. It starts an opencode server and talks to its API[^openchamber-docs-server], so +the agent loop, the providers and the configuration all stay opencode's, and what OpenChamber +owns is everything around a session: a chat window with a diff viewer, review and release +workflow, and the same sessions reachable from another device. + +| | | +| --- | --- | +| Author | The `openchamber` organisation, with the project's own site at [openchamber.dev](https://openchamber.dev/). Independent of the opencode team | +| Licence | MIT | +| Language | TypeScript, built with bun; the desktop application is Electron | +| Distribution | `@openchamber/web` from npm for the CLI and server, an installer script, Linux AppImages for x86\_64 and ARM64, a VS Code extension, iOS and Android clients | +| Source | [github.com/openchamber/openchamber](https://github.com/openchamber/openchamber) | +| Version read | 2.1.1, released 2026-10-04 | + +## How it reaches opencode + +The server process spawns `opencode serve` and makes its calls through opencode's own client +library. It will also attach to a server somebody else started, through `OPENCODE_HOST` and +`OPENCODE_SKIP_START`[^openchamber-docs-server]. + +Which opencode binary that is depends on the surface. The CLI, the web interface and the VS Code +extension use whatever `opencode` is on the machine. The desktop application ships its own, +pinned to the version it was built against[^openchamber-repo], so installing the desktop app on a +machine that already runs opencode gives that machine two of them. + +The configuration is read, not replaced. `OPENCODE_CONFIG_DIR` is honoured, falling back to +`$XDG_CONFIG_HOME/opencode` and then `~/.config/opencode`[^openchamber-docs-environment], and +agents, plugins, skills and MCP servers come from the config layers opencode already resolves. +OpenChamber puts a settings page in front of several of those: providers, agents, skills, MCP +servers, and the `AGENTS.md` sitting at the config directory. A provider added through that page +is written into opencode's config and its key into opencode's auth store, which the documentation +is explicit about: credentials are "stored by OpenCode, not +OpenChamber"[^openchamber-docs-providers]. + +It keeps a little state of its own, beside opencode's rather than inside it: +`~/.config/openchamber` for its data directory, and `.openchamber/project.json` in a repository +for per-project settings[^openchamber-docs-repository-config]. + +## What it adds + +The features are about the work around a session rather than about the model answering it: + +| | | +| --- | --- | +| **Session goals** | A finish line per session. The result is checked after each turn and the agent keeps going until the goal is met, it is blocked, or a limit is reached, including after the app is closed | +| **Multi-run and fusion** | The same task given to up to five models, each in its own session and optionally its own worktree, then one result chosen or the strongest parts combined | +| **Changes walkthrough** | A large diff turned into an ordered, grouped tour of the change | +| **Preview** | The application under development opened beside the conversation; pointing at an element sends the agent its screenshot, styles, position and browser errors | +| **Scheduled work** | A prompt run once, daily, weekly or on a cron schedule, optionally carrying a session goal | +| **Device reach** | Desktop, browser or PWA, VS Code, iOS and Android against the same sessions, paired by QR code over an end-to-end encrypted relay, with tunnels, LAN and SSH as alternatives | + +## Where it sits against a terminal opencode setup + +The comparison worth making is against opencode driven from a terminal with a configuration like +[the one this bundle's opencode page describes](opencode.md#how-i-configure-it), because the two +are not alternatives in the usual sense: one is running inside the other. + +| | opencode in a terminal | OpenChamber | +| --- | --- | --- | +| **Invocation** | A binary in a shell, in whatever directory and multiplexer you were already in | A server plus a client. `openchamber --ui-password ...` binds to localhost, and a browser, the desktop app or a phone connects to it | +| **Portability** | One binary; wherever a terminal reaches, including a plain `ssh` one-liner | Node 22 or newer for the CLI and server, Electron for the desktop app, FUSE for the AppImage. The server runs headless with `--api-only`, which is the shape that travels | +| **Configuration** | Files you edit and commit; `OPENCODE_CONFIG_DIR` picks which set | The same files, plus settings pages that write them, plus `.openchamber/` for its own | +| **Agents, skills, rules** | opencode's, resolved from the config layers | opencode's, resolved the same way. Nothing to port, and nothing it gives that a terminal session does not already have | +| **Credentials** | opencode's `auth.json` | The same file, written through a different front end | +| **What it is for** | Running the agent | Watching several of them, from somewhere that is not this machine | + +The thing to take from that table is that **everything provider-side and agent-side is inherited, +so adopting it is not a migration.** A profile-per-provider layout keeps working, because the +variable that selects the profile is the variable OpenChamber reads. What is genuinely new is the +remote access, the parallel runs and the review surface, and the cost of those is an Electron +application or a long-lived server process where there used to be a binary and a shell. + +Two caveats sit on the GitHub side rather than the agent side. The issue and pull request +integration authenticates through its own device flow against `github.com`, falling back to a +token from the `gh` CLI; a personal access token is refused outright, and no Enterprise host is +offered. And whilst opencode's `github-copilot` provider is reachable through OpenChamber like any +other, nothing in the documentation says anything about which kind of GitHub account that provider +will sign in, so a tenancy that constrains the device flow constrains OpenChamber exactly as much +as it constrains opencode, and no more. diff --git a/docs/tools/project_wiki_giodra96.md b/docs/tools/project_wiki_giodra96.md index de51940..c2e53e8 100644 --- a/docs/tools/project_wiki_giodra96.md +++ b/docs/tools/project_wiki_giodra96.md @@ -104,7 +104,7 @@ the set of kinds is closed, and named in a schema file rather than chosen as the Against [agent-knowledge (stjbrown)](agent_knowledge_stjbrown.md) the contrast is sharper, because the two are the same shape - skills plus deterministic scripts, no runtime, no database - pointed at different targets. agent-knowledge builds -[OKF](../research/open_knowledge_format_okf_findings.md) bundles, and OKF explicitly declines to +[OKF](../investigations/open_knowledge_format_okf_findings.md) bundles, and OKF explicitly declines to fix a taxonomy of concept types. project-wiki fixes one, completely, in `schema/project-wiki.yml`, and gains from it what OKF gives up: `validate_wiki.py` can check that an ADR exists for a decision a requirement references, because it knows what an ADR is. The diff --git a/docs/tools/skills_vercel_labs.md b/docs/tools/skills_vercel_labs.md index 5b86222..bd658c4 100644 --- a/docs/tools/skills_vercel_labs.md +++ b/docs/tools/skills_vercel_labs.md @@ -48,7 +48,7 @@ first is a local tool; the second is a website the first feeds. An install event repository and the skills taken from it; `DO_NOT_TRACK=1` switches the reporting off, at the cost of the pre-install security audit lookup, which is guarded by the same flag. What the event carries in full, and where the built-in private-repository exclusion does not hold, is -[the research page](../research/skills_sh_repository_layout_and_telemetry.md). +[the investigation](../investigations/skills_sh_repository_layout_and_telemetry.md). A skill is discovered, never declared. There is no manifest, no registry entry and no packaging step: a directory containing a `SKILL.md` whose YAML frontmatter carries a `name` diff --git a/docs/tools/vocalinux_vocahq.md b/docs/tools/vocalinux_vocahq.md new file mode 100644 index 0000000..1306f32 --- /dev/null +++ b/docs/tools/vocalinux_vocahq.md @@ -0,0 +1,44 @@ +--- +type: Tool +title: Vocalinux (VocaHQ) +description: Voice dictation for X11 and Wayland, a Python desktop app running whisper.cpp and four + other local engines behind a tray icon and a settings dialog. +tags: +- tools +- vocalinux +- dictation +- speech-to-text +- linux +- wayland +status: draft +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +sources: +- id: vocalinux-repo + resource: https://github.com/VocaHQ/vocalinux + title: 'VocaHQ/vocalinux: voice dictation for Linux' + last_modified: '2026-09-28' +--- +Vocalinux[^vocalinux-repo] turns speech into typed text in whatever application has focus, with +transcription on the machine once a model has been downloaded. It is the Linux entry in VocaHQ's +one-app-per-platform set, and it is driven from a tray icon and a settings dialog rather than +from a configuration file. + +| | | +| --- | --- | +| Author | VocaHQ | +| Licence | AGPL-3.0 | +| Language | Python | +| Distribution | An install script, PyPI, AUR, AppImage, Snap on edge, Flatpak bundles off the releases page | +| Source | [github.com/VocaHQ/vocalinux](https://github.com/VocaHQ/vocalinux) | +| Version read | 0.17.0 | + +## To fill in + +A stub, written so the page exists and can be linked at. What belongs here once it has been +used: which of the five engines is worth running, how the text reaches the focused window on +Wayland through the `ydotool` and clipboard fallbacks, and what the AGPL and the optional remote +API mean for using it at work. It is the direct alternative to +[Voxtype (Peter Jackson)](voxtype_jackson.md), and the pair is worth one comparison rather than +two separate verdicts. diff --git a/docs/tools/voxtype_jackson.md b/docs/tools/voxtype_jackson.md new file mode 100644 index 0000000..a4d1d91 --- /dev/null +++ b/docs/tools/voxtype_jackson.md @@ -0,0 +1,43 @@ +--- +type: Tool +title: Voxtype (Peter Jackson) +description: Push-to-talk voice dictation for Linux and macOS, a single Rust binary running local + speech-to-text engines and typing the result into whatever has focus. +tags: +- tools +- voxtype +- dictation +- speech-to-text +- linux +- wayland +status: draft +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T00:00:00Z' +sources: +- id: voxtype-site + resource: https://voxtype.io/ + title: 'Voxtype: Push-to-Talk Voice-to-Text for Linux and macOS' + last_modified: '2026-09-28' +--- +Voxtype[^voxtype-site] records whilst a key is held, transcribes locally, and types the text into +the focused window. It is written in Rust and ships as one binary with a systemd user service, +rather than as a Python tree with an activation script. + +| | | +| --- | --- | +| Author | Peter Jackson, at Faster Agile | +| Licence | MIT | +| Language | Rust | +| Distribution | AUR, `.deb`, `.rpm`, AppImage, a Nix flake, Homebrew on macOS | +| Source | [github.com/peteonrails/voxtype](https://github.com/peteonrails/voxtype) | +| Version read | 1.1.0 | + +## To fill in + +A stub, written so the page exists and can be linked at. What belongs here once it has been +used: which engine of the eight is worth configuring and at what accuracy, how the hotkey is +bound under a compositor as against the evdev fallback, and how the typing path behaves when +`wtype` is unavailable. The comparison against +[Vocalinux (VocaHQ)](vocalinux_vocahq.md), which solves the same problem in Python under +AGPL-3.0, is the other half of it. diff --git a/docs/tools/wireplumber.md b/docs/tools/wireplumber.md new file mode 100644 index 0000000..4472312 --- /dev/null +++ b/docs/tools/wireplumber.md @@ -0,0 +1,73 @@ +--- +type: Tool +title: WirePlumber +description: The session manager that decides what exists in a PipeWire graph and how it is linked, + configured by composable profiles and implemented as Lua hooks over a C core. +tags: +- tools +- wireplumber +- pipewire +- audio +- linux +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +sources: +- id: wireplumber-repo + resource: https://gitlab.freedesktop.org/pipewire/wireplumber + title: 'PipeWire: WirePlumber' + last_modified: '2026-09-28' +--- +WirePlumber[^wireplumber-repo] is the session manager for PipeWire. PipeWire provides the media +graph and the plugins that talk to hardware; WirePlumber decides which devices become nodes in +that graph, what those nodes are called, which profile a device sits in, and what gets linked to +what. On a desktop it is the component that turns a working sound server into working sound. + +| | | +| --- | --- | +| Author | Collabora, under the PipeWire project | +| Licence | MIT | +| Language | C, with policy written in Lua | +| Distribution | Distribution packages, alongside PipeWire itself | +| Source | [gitlab.freedesktop.org/pipewire/wireplumber](https://gitlab.freedesktop.org/pipewire/wireplumber) | +| Version read | 0.5.17 | + +## What it is + +The division of labour with PipeWire is the thing worth holding onto, because it decides where +to look when something is missing. The device monitors are SPA plugins and belong to PipeWire. +WirePlumber loads them and lets them work. So a missing Bluetooth device is rarely a missing +plugin and is usually WirePlumber having declined to load one, which is a different file to read +and a different thing to fix. + +Policy is Lua. Each decision is a hook registered against an event, declaring where it runs +relative to its siblings, and returning early if an earlier hook already settled the question. +Profile selection is three such hooks in a row, which is the whole mechanism behind +[a headset reconnecting in the wrong profile](../findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md). + +## How it is configured + +`wireplumber.conf` declares **components**, each naming what it `provides` and what it +`requires` or `wants`, and **profiles**, which switch named features to `required`, `optional` or +`disabled`. The default profile is `main`. A feature that is merely wanted, and is unavailable, +leaves its component running with less behaviour rather than failing to load, which is why a +disabled feature is usually silent. + +Configuration is not edited in place. Fragments in `wireplumber.conf.d/`, under `/etc` or under +`~/.config/wireplumber/`, merge over the shipped file, so an override is a small file of its own +and survives a package upgrade. Both fixes recorded here are fragments of that kind. + +Almost nothing is logged at the default level. `WIREPLUMBER_DEBUG=3` on the service, through a +systemd drop-in, is what makes the load decisions visible, and it is the difference between +guessing at why a monitor is absent and reading the line where it was skipped. + +## Why it matters here + +It is the component that failed in both Bluetooth findings on +[the Precision 5470](../hardware/dell_precision_5470.md), and in neither case was the visible +error its own. A gate it consults reported the desktop inactive, so +[no Bluetooth endpoint was ever registered](../findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md) +and BlueZ reported the protocol as unavailable. That pattern, where the component at fault is +upstream of the one producing the message, is the reason it is worth knowing which half of the +PipeWire stack owns which decision.