Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
56fee40
Merge main back into staging
github-actions[bot] Sep 23, 2026
8174ae8
add research: Sharing one user-level AGENTS.md across harnesses
ftschindler Sep 23, 2026
3d62b5f
rewrite the user-level AGENTS.md research around the generator-tool l…
ftschindler Sep 23, 2026
5f1251e
Merge main back into staging
github-actions[bot] Sep 23, 2026
a265581
correct the tool survey after a proper enumeration
ftschindler Sep 23, 2026
4227938
Add research on spell-checking prose in a pre-commit hook
ftschindler Sep 28, 2026
a78f012
Add a hardware section, and the two Bluetooth findings behind it
ftschindler Sep 28, 2026
b503c1d
Correct the discrete graphics section of the Precision 5470
ftschindler Sep 28, 2026
9fc4524
add finding, "Git LFS fails when the remote URL has a trailing slash"
ftschindler Sep 28, 2026
11b0edf
rephrase Dell entry
ftschindler Sep 28, 2026
3a31624
Add tool stubs for Voxtype and Vocalinux
ftschindler Sep 28, 2026
83eab1d
Record that opencode's transcripts land in the Claude config directory
ftschindler Sep 28, 2026
426821f
Add finding: pymarkdown's fix mode dedents YAML frontmatter
ftschindler Sep 28, 2026
830bc99
Record that pymarkdown does not discover its own config file
ftschindler Sep 28, 2026
dfd4a04
Add liseur and liseur-sync: EPUB reader and sync server by Chmouel
ftschindler Sep 28, 2026
aa553ab
Merge branch 'staging' of https://github.com/ftschindler/knowledge
ftschindler Sep 28, 2026
278762a
add finding, Chromium maximises to the wrong size after an undock
ftschindler Sep 30, 2026
e37605d
Add two findings from an opencode config audit
ftschindler Oct 1, 2026
b5111f8
add finding: a config symlink makes every omo migration fail on start
ftschindler Oct 1, 2026
e22d510
Add tool page, "OpenChamber"
ftschindler Oct 7, 2026
70dc992
Rename the research section to investigations
ftschindler Oct 7, 2026
a70eb2e
Merge main back into staging
github-actions[bot] Oct 7, 2026
f3728b6
Conform the Liseur tool page to the tools genre
ftschindler Oct 7, 2026
6c210c0
Cite the Liseur sources the frontmatter declares
ftschindler Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
Expand Up @@ -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> <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> <Rene.Fritze@arup.com>
Expand Down
6 changes: 4 additions & 2 deletions about/editing_conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` for a person,
`<harness>/<model>` for an agent such as `opencode/claude-opus-5`, `process:<id>` for a
Expand Down
3 changes: 2 additions & 1 deletion docs/.pages
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ nav:
- blueprints
- guides
- knowledge_management
- research
- investigations
- tools
- hardware
- findings
- log.md
- ...
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/explorations/running_this_knowledge_base_on_awiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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: <WpLogind> 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.
Loading
Loading