From 8174ae8e4808b007838941a51b8d87e38bab8308 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:51:55 +0200 Subject: [PATCH 01/20] add research: Sharing one user-level AGENTS.md across harnesses --- docs/log.md | 4 + docs/research/index.md | 1 + ...e_user_level_agents_md_across_harnesses.md | 146 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 docs/research/sharing_one_user_level_agents_md_across_harnesses.md diff --git a/docs/log.md b/docs/log.md index c73b2c6..91aaa9f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 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 + ## 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/index.md b/docs/research/index.md index 89633ef..5246f5e 100644 --- a/docs/research/index.md +++ b/docs/research/index.md @@ -14,3 +14,4 @@ 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) - five harnesses, five paths, and why the import beats the symlink diff --git a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md new file mode 100644 index 0000000..b0f1d13 --- /dev/null +++ b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md @@ -0,0 +1,146 @@ +--- +type: Reference +title: Sharing one user-level AGENTS.md across harnesses +description: Where each coding-agent harness looks for a user-level instruction file, why no standard + covers that location, and which of the three adapter mechanisms survives Windows. +tags: +- research +- ai-agents +- agents +- opencode +- config-management +- 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: 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' +--- +**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 currently sits at `~/.config/opencode/AGENTS.md`, which is a path +[opencode](../tools/opencode.md) invented and nothing else reads. The question is what +"portable" can mean for that file across operating systems and across harnesses. + +- **Read on**: 2026-09-23 +- **Method**: the vendor documentation for each harness, read directly rather than + through a summary. Claims that appeared only in a research summary and not in a vendor + page are marked below as unconfirmed. + +## The standard stops at the project root + +`AGENTS.md` is a real convention with real reach: one file, read by Codex, opencode, +Amp, Cursor, Gemini CLI, Jules, Zed, Warp, Factory and others, now stewarded by the +Agentic AI Foundation[^agents-md]. **It specifies nothing about a user-level file.** +The format is "a README for agents" placed at the root of a repository, and every +statement it makes about discovery is about walking a directory tree inside a project. + +So the thing this investigation set out to find does not exist. There is no +`~/.agents/AGENTS.md` that harnesses agree on, and Claude Code goes as far as naming +`.agents/` in its list of paths it deliberately does not read[^claude-memory]. + +What each harness does instead: + +| Harness | User-level file | Set by | +| --- | --- | --- | +| opencode | `~/.config/opencode/AGENTS.md`, falling back to `~/.claude/CLAUDE.md` | fixed path[^opencode-rules] | +| Claude Code | `~/.claude/CLAUDE.md`, plus every `.md` in `~/.claude/rules/` | fixed path[^claude-memory] | +| Codex CLI | `~/.codex/AGENTS.override.md`, else `~/.codex/AGENTS.md` | `CODEX_HOME`[^codex-agents-md] | +| Gemini CLI | `~/.gemini/GEMINI.md` | `context.fileName` in `settings.json`[^agents-md] | +| Aider | none; reads what `read:` names | `~/.aider.conf.yml`[^agents-md] | + +Three different filenames, five different directories, and the one harness that reads a +file called `AGENTS.md` at user level (Codex) reads it from a directory named after +itself. A survey summary also offered `~/.config/amp/AGENTS.md` for Amp and a Settings-UI-only +answer for Cursor CLI; neither was confirmed against a vendor page here. + +## Three mechanisms, and only one of them travels + +Every harness above can be pointed at a file it would not otherwise find, but the three +mechanisms behave very differently. + +**Symlink.** Put the content anywhere, link each harness's expected path at it. Works for +opencode and Codex, whose loaders only open a path. It is the mechanism that fails first: +creating a symlink on Windows needs Administrator rights or Developer Mode, and git checks +a committed symlink out as a one-line text file unless `core.symlinks` is enabled - which +leaves that machine with a `CLAUDE.md` containing the string `AGENTS.md` and no +instructions at all. **Claude Code's own documentation says this, and recommends the import +over the symlink for exactly this reason**[^claude-memory]. + +**Reference.** The harness reads a small file of its own that names the canonical one. +Claude Code expands `@path` imports, four hops deep, and the worked example in its docs is +`@~/.claude/my-project-instructions.md`, so the tilde resolves there. Aider takes +`read:` in `~/.aider.conf.yml`. opencode takes an `instructions` array in `opencode.json`, +which accepts relative paths, globs and even remote URLs fetched with a five-second +timeout; whether it expands `~` is not documented. + +Two properties make this the mechanism to build on. It is ordinary file content, so it +works identically on every operating system. And it composes: harness-specific additions +sit below the import, in the harness's own file, rather than having to be conditionalised +inside a single shared one. + +**Rename.** Gemini CLI takes `{"context": {"fileName": "AGENTS.md"}}` and Codex takes +`project_doc_fallback_filenames`. These change which name is looked for, not which +directory, so they solve half the problem and only the half that was not the obstacle. + +## One free pairing, and why it is still the wrong home + +opencode falls back to `~/.claude/CLAUDE.md` when no `~/.config/opencode/AGENTS.md` +exists[^opencode-rules]. Putting the canonical content there therefore feeds two harnesses +with no adapter, no symlink and no config key - the cheapest working setup in the whole +survey. + +It is still the wrong home. A file every harness shares should not be named after one +vendor, and the fallback is a compatibility shim that opencode can retire or that a user +switches off with `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1`. **A canonical location is +chosen, not discovered**, and `~/.agents/AGENTS.md` is the neutral name - already the +cross-harness home for skills in +[ftschindler/agents-skills](https://github.com/ftschindler/agents-skills) - as long as it +is understood that no harness finds it on its own. + +## The constraint that decides the content + +The file has to stay short, and the limits are not the same. Codex stops adding +instruction files once the combined size reaches `project_doc_max_bytes`, 32 KiB by +default[^codex-agents-md]. Claude Code asks for under 200 lines per file 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 on every turn and never amortised. + +This is the same 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 direction: the always-on layer carries imperatives and refuses to +carry reasoning, because that is what its budget affords. Felix's current file is 64 lines +and sits well inside every limit. + +## Bottom line + +One canonical file at `~/.agents/AGENTS.md`, and a one-line adapter per harness that +references it rather than links to it. Concretely: `@~/.agents/AGENTS.md` in +`~/.claude/CLAUDE.md`, `read: [~/.agents/AGENTS.md]` in `~/.aider.conf.yml`, an +`instructions` entry in opencode's config, `context.fileName` for Gemini. Codex is the one +harness with no reference mechanism, so it takes the symlink and accepts the Windows +caveat, or a copy written by whatever already provisions the machine. + +Two things worth settling before any of this is built. Whether opencode's `instructions` +expands `~` is undocumented and decides whether that entry can be committed to a +repository shared across machines or has to be generated per machine. And the file is +currently version-controlled inside the opencode config repository, so moving it to +`~/.agents` moves it into a different repository with a different publishing posture - +a decision about where the content lives, not a path change. From 3d62b5f2c7e2637cda603a7e4237c0fc1682e8f7 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:10:38 +0200 Subject: [PATCH 02/20] rewrite the user-level AGENTS.md research around the generator-tool landscape Ruler refuses to write outside the project root by design; rulesync's global mode covers three tools, which happen to be the right three. Confirms at source that opencode's instructions key expands a leading tilde, which was the open question the first version left. Also adds the github-actions bot to .mailmap, which a commit from the remote had left unmapped. --- .mailmap | 1 + docs/log.md | 1 + docs/research/index.md | 2 +- ...e_user_level_agents_md_across_harnesses.md | 250 ++++++++++-------- 4 files changed, 145 insertions(+), 109 deletions(-) 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/docs/log.md b/docs/log.md index 91aaa9f..7c83542 100644 --- a/docs/log.md +++ b/docs/log.md @@ -3,6 +3,7 @@ ## 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 ## 2026-09-22 diff --git a/docs/research/index.md b/docs/research/index.md index 5246f5e..9b92607 100644 --- a/docs/research/index.md +++ b/docs/research/index.md @@ -14,4 +14,4 @@ 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) - five harnesses, five paths, and why the import beats the symlink +- [Sharing one user-level AGENTS.md across harnesses](sharing_one_user_level_agents_md_across_harnesses.md) - the rule-file generator category, and why the mature tools in it refuse the home directory diff --git a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md index b0f1d13..902f0de 100644 --- a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md +++ b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md @@ -1,14 +1,16 @@ --- type: Reference title: Sharing one user-level AGENTS.md across harnesses -description: Where each coding-agent harness looks for a user-level instruction file, why no standard - covers that location, and which of the three adapter mechanisms survives Windows. +description: A survey of the rule-file generator category, why every mature tool in it solves the + project problem rather than the user-level one, and the two config lines that make a generator + unnecessary here. tags: - research - ai-agents - agents - opencode - config-management +- tooling - dx status: stable generated: @@ -23,6 +25,10 @@ sources: 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 @@ -31,116 +37,144 @@ sources: 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' --- -**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 currently sits at `~/.config/opencode/AGENTS.md`, which is a path -[opencode](../tools/opencode.md) invented and nothing else reads. The question is what -"portable" can mean for that file across operating systems and across harnesses. +**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**: the vendor documentation for each harness, read directly rather than - through a summary. Claims that appeared only in a research summary and not in a vendor - page are marked below as unconfirmed. - -## The standard stops at the project root - -`AGENTS.md` is a real convention with real reach: one file, read by Codex, opencode, -Amp, Cursor, Gemini CLI, Jules, Zed, Warp, Factory and others, now stewarded by the -Agentic AI Foundation[^agents-md]. **It specifies nothing about a user-level file.** -The format is "a README for agents" placed at the root of a repository, and every -statement it makes about discovery is about walking a directory tree inside a project. - -So the thing this investigation set out to find does not exist. There is no -`~/.agents/AGENTS.md` that harnesses agree on, and Claude Code goes as far as naming -`.agents/` in its list of paths it deliberately does not read[^claude-memory]. - -What each harness does instead: - -| Harness | User-level file | Set by | -| --- | --- | --- | -| opencode | `~/.config/opencode/AGENTS.md`, falling back to `~/.claude/CLAUDE.md` | fixed path[^opencode-rules] | -| Claude Code | `~/.claude/CLAUDE.md`, plus every `.md` in `~/.claude/rules/` | fixed path[^claude-memory] | -| Codex CLI | `~/.codex/AGENTS.override.md`, else `~/.codex/AGENTS.md` | `CODEX_HOME`[^codex-agents-md] | -| Gemini CLI | `~/.gemini/GEMINI.md` | `context.fileName` in `settings.json`[^agents-md] | -| Aider | none; reads what `read:` names | `~/.aider.conf.yml`[^agents-md] | - -Three different filenames, five different directories, and the one harness that reads a -file called `AGENTS.md` at user level (Codex) reads it from a directory named after -itself. A survey summary also offered `~/.config/amp/AGENTS.md` for Amp and a Settings-UI-only -answer for Cursor CLI; neither was confirmed against a vendor page here. - -## Three mechanisms, and only one of them travels - -Every harness above can be pointed at a file it would not otherwise find, but the three -mechanisms behave very differently. - -**Symlink.** Put the content anywhere, link each harness's expected path at it. Works for -opencode and Codex, whose loaders only open a path. It is the mechanism that fails first: -creating a symlink on Windows needs Administrator rights or Developer Mode, and git checks -a committed symlink out as a one-line text file unless `core.symlinks` is enabled - which -leaves that machine with a `CLAUDE.md` containing the string `AGENTS.md` and no -instructions at all. **Claude Code's own documentation says this, and recommends the import -over the symlink for exactly this reason**[^claude-memory]. - -**Reference.** The harness reads a small file of its own that names the canonical one. -Claude Code expands `@path` imports, four hops deep, and the worked example in its docs is -`@~/.claude/my-project-instructions.md`, so the tilde resolves there. Aider takes -`read:` in `~/.aider.conf.yml`. opencode takes an `instructions` array in `opencode.json`, -which accepts relative paths, globs and even remote URLs fetched with a five-second -timeout; whether it expands `~` is not documented. - -Two properties make this the mechanism to build on. It is ordinary file content, so it -works identically on every operating system. And it composes: harness-specific additions -sit below the import, in the harness's own file, rather than having to be conditionalised -inside a single shared one. - -**Rename.** Gemini CLI takes `{"context": {"fileName": "AGENTS.md"}}` and Codex takes -`project_doc_fallback_filenames`. These change which name is looked for, not which -directory, so they solve half the problem and only the half that was not the obstacle. - -## One free pairing, and why it is still the wrong home - -opencode falls back to `~/.claude/CLAUDE.md` when no `~/.config/opencode/AGENTS.md` -exists[^opencode-rules]. Putting the canonical content there therefore feeds two harnesses -with no adapter, no symlink and no config key - the cheapest working setup in the whole -survey. - -It is still the wrong home. A file every harness shares should not be named after one -vendor, and the fallback is a compatibility shim that opencode can retire or that a user -switches off with `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1`. **A canonical location is -chosen, not discovered**, and `~/.agents/AGENTS.md` is the neutral name - already the -cross-harness home for skills in -[ftschindler/agents-skills](https://github.com/ftschindler/agents-skills) - as long as it -is understood that no harness finds it on its own. - -## The constraint that decides the content - -The file has to stay short, and the limits are not the same. Codex stops adding -instruction files once the combined size reaches `project_doc_max_bytes`, 32 KiB by -default[^codex-agents-md]. Claude Code asks for under 200 lines per file 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 on every turn and never amortised. - -This is the same argument +- **Method**: vendor documentation read directly; repository metadata via `gh`; the two leading + tools cloned and their READMEs read; opencode's instruction loader read at source. + +There is a mature tool category for this, and surveying it answers the question by showing +that the question was the wrong size. + +## The generators, and what they are for + +Two tools lead a category of perhaps a dozen. **Ruler** has 2.9k stars and targets around +thirty agents[^ruler]. **Rulesync** has 1.5k stars and targets around fifty, across eight +surfaces - not just rules but ignore files, MCP servers, commands, subagents, skills, hooks +and permissions[^rulesync-tools]. Both are actively developed, both are TypeScript, both take +one source directory and compile it into each tool's native files. + +The category is real, it is maintained, and adopting it would be the ordinary answer. It also +does not solve this problem, and the two tools fail to in different and instructive ways. + +**Ruler writes only into the project.** Its `--global` flag creates a configuration at +`~/.config/ruler` used "when no local `.ruler/` directory is found" - a global *source*, whose +*outputs* still land in the repository[^ruler]. The README states the boundary 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] Writing to `$HOME` is not a missing feature. It is a thing the tool +removed on purpose. + +**Rulesync has a global mode, and it is three tools wide.** The tool matrix marks global +support against most of its fifty targets, but the Global Mode guide is narrower than the +matrix: "Currently, supports rules generation for Claude Code, GitHub Copilot, and +OpenCode."[^rulesync-global] Commands in global mode are Claude Code only. + +So the category is built for a repository handing instructions to whichever agent a +contributor brought. The user-level file - one person, many harnesses, many machines - is the +long tail, and the coverage numbers that make these tools look decisive are project-scope +numbers. + +## The three harnesses that do have global support + +The reframing is that the narrow answer lands exactly on target. Rulesync's global rule +generation covers Claude Code, GitHub Copilot and opencode[^rulesync-global] - which is the +harness in use here, plus the two most likely to be added. Measured against the harnesses that +matter rather than against a catalogue, coverage is complete. + +That makes rulesync a genuine option rather than a near-miss, and the decision becomes a +comparison rather than a search. 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 direction: the always-on layer carries imperatives and refuses to -carry reasoning, because that is what its budget affords. Felix's current file is 64 lines -and sits well inside every limit. +makes from the other side. ## Bottom line -One canonical file at `~/.agents/AGENTS.md`, and a one-line adapter per harness that -references it rather than links to it. Concretely: `@~/.agents/AGENTS.md` in -`~/.claude/CLAUDE.md`, `read: [~/.agents/AGENTS.md]` in `~/.aider.conf.yml`, an -`instructions` entry in opencode's config, `context.fileName` for Gemini. Codex is the one -harness with no reference mechanism, so it takes the symlink and accepts the Windows -caveat, or a copy written by whatever already provisions the machine. - -Two things worth settling before any of this is built. Whether opencode's `instructions` -expands `~` is undocumented and decides whether that entry can be committed to a -repository shared across machines or has to be generated per machine. And the file is -currently version-controlled inside the opencode config repository, so moving it to -`~/.agents` moves it into a different repository with a different publishing posture - -a decision about where the content lives, not a path change. +The generator category is mature and aimed elsewhere. Ruler will not write to `$HOME` by +design; rulesync will, for three tools, which happen to be the right three - so it is a real +alternative, not an absent one. + +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 rulesync 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. + +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. From a265581baad5da11b15cca3a7d540eae6b16d884 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:22:54 +0200 Subject: [PATCH 03/20] correct the tool survey after a proper enumeration The first pass undercounted badly. Searching GitHub and npm properly splits the population in two: dozens of content generators that write an AGENTS.md from a repo scan, and a much smaller set of distributors. Adds vibe-rules, ai-rulez and ai-rules-sync, and records the real finding - writing to the home directory is the rare feature, because shared project rules are a team problem and a user-level file is a dotfiles problem. --- docs/log.md | 1 + docs/research/index.md | 2 +- ...e_user_level_agents_md_across_harnesses.md | 161 +++++++++++------- 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/docs/log.md b/docs/log.md index 7c83542..39119a1 100644 --- a/docs/log.md +++ b/docs/log.md @@ -4,6 +4,7 @@ - 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 diff --git a/docs/research/index.md b/docs/research/index.md index 9b92607..8bfc0dc 100644 --- a/docs/research/index.md +++ b/docs/research/index.md @@ -14,4 +14,4 @@ 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) - the rule-file generator category, and why the mature tools in it refuse the home directory +- [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 diff --git a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md index 902f0de..7a0af6a 100644 --- a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md +++ b/docs/research/sharing_one_user_level_agents_md_across_harnesses.md @@ -1,9 +1,8 @@ --- type: Reference title: Sharing one user-level AGENTS.md across harnesses -description: A survey of the rule-file generator category, why every mature tool in it solves the - project problem rather than the user-level one, and the two config lines that make a generator - unnecessary here. +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: - research - ai-agents @@ -49,6 +48,14 @@ sources: 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 @@ -58,54 +65,84 @@ nothing else reads. The question was how to make that file portable across opera and harnesses. - **Read on**: 2026-09-23 -- **Method**: vendor documentation read directly; repository metadata via `gh`; the two leading - tools cloned and their READMEs read; opencode's instruction loader read at source. +- **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 mature tool category for this, and surveying it answers the question by showing +There is a crowded tool category for this, and surveying it answers the question by showing that the question was the wrong size. -## The generators, and what they are for - -Two tools lead a category of perhaps a dozen. **Ruler** has 2.9k stars and targets around -thirty agents[^ruler]. **Rulesync** has 1.5k stars and targets around fifty, across eight -surfaces - not just rules but ignore files, MCP servers, commands, subagents, skills, hooks -and permissions[^rulesync-tools]. Both are actively developed, both are TypeScript, both take -one source directory and compile it into each tool's native files. - -The category is real, it is maintained, and adopting it would be the ordinary answer. It also -does not solve this problem, and the two tools fail to in different and instructive ways. - -**Ruler writes only into the project.** Its `--global` flag creates a configuration at -`~/.config/ruler` used "when no local `.ruler/` directory is found" - a global *source*, whose -*outputs* still land in the repository[^ruler]. The README states the boundary 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] Writing to `$HOME` is not a missing feature. It is a thing the tool -removed on purpose. - -**Rulesync has a global mode, and it is three tools wide.** The tool matrix marks global -support against most of its fifty targets, but the Global Mode guide is narrower than the -matrix: "Currently, supports rules generation for Claude Code, GitHub Copilot, and -OpenCode."[^rulesync-global] Commands in global mode are Claude Code only. - -So the category is built for a repository handing instructions to whichever agent a -contributor brought. The user-level file - one person, many harnesses, many machines - is the -long tail, and the coverage numbers that make these tools look decisive are project-scope -numbers. - -## The three harnesses that do have global support - -The reframing is that the narrow answer lands exactly on target. Rulesync's global rule -generation covers Claude Code, GitHub Copilot and opencode[^rulesync-global] - which is the -harness in use here, plus the two most likely to be added. Measured against the harnesses that -matter rather than against a catalogue, coverage is complete. - -That makes rulesync a genuine option rather than a near-miss, and the decision becomes a -comparison rather than a search. 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 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 @@ -164,16 +201,24 @@ makes from the other side. ## Bottom line -The generator category is mature and aimed elsewhere. Ruler will not write to `$HOME` by -design; rulesync will, for three tools, which happen to be the right three - so it is a real -alternative, not an absent one. - -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 rulesync 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 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 From 422793821ede7db143f5f4cce1cef00ba4342563 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:57:30 +0200 Subject: [PATCH 04/20] Add research on spell-checking prose in a pre-commit hook --- docs/log.md | 6 + docs/research/index.md | 1 + ...ell_checking_prose_in_a_pre_commit_hook.md | 162 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 docs/research/spell_checking_prose_in_a_pre_commit_hook.md diff --git a/docs/log.md b/docs/log.md index 39119a1..3b003ba 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,11 @@ # Log +## 2026-09-28 + +- 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 + ## 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 diff --git a/docs/research/index.md b/docs/research/index.md index 8bfc0dc..260e15b 100644 --- a/docs/research/index.md +++ b/docs/research/index.md @@ -15,3 +15,4 @@ date. Where an investigation deliberately stops short of choosing, it says so. - [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/spell_checking_prose_in_a_pre_commit_hook.md b/docs/research/spell_checking_prose_in_a_pre_commit_hook.md new file mode 100644 index 0000000..674c164 --- /dev/null +++ b/docs/research/spell_checking_prose_in_a_pre_commit_hook.md @@ -0,0 +1,162 @@ +--- +type: Reference +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: +- research +- 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. From a78f012ae0c74c4bf8878aecf319d4e22bb4c065 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:04:32 +0200 Subject: [PATCH 05/20] Add a hardware section, and the two Bluetooth findings behind it Adds `hardware/` as a genre of device references, holding what a machine or peripheral is and how it presents itself to Linux, with the Precision 5470 and the OpenMove headset as its first two pages. Adds the two findings from getting Bluetooth audio working on them: the BlueZ monitor that never starts because logind's per-user state file disagrees with its own seat and session records, and the profile selection that returns a headset to A2DP whenever the saved profile is absent. Adds a WirePlumber tool page for both to link back to. The five pages reference each other in a cycle, so they land together rather than through intermediate states the site build would reject. --- docs/.pages | 1 + ...ts_in_a2dp_instead_of_the_saved_profile.md | 111 +++++++++++ ...ith_protocol_not_available_after_resume.md | 173 ++++++++++++++++++ docs/findings/index.md | 2 + docs/hardware/.genre.yaml | 11 ++ docs/hardware/.pages | 1 + docs/hardware/dell_precision_5470.md | 87 +++++++++ docs/hardware/index.md | 32 ++++ docs/hardware/openmove_by_shokz.md | 80 ++++++++ docs/index.md | 1 + docs/log.md | 6 + docs/tools/index.md | 1 + docs/tools/wireplumber.md | 73 ++++++++ 13 files changed, 579 insertions(+) create mode 100644 docs/findings/20260928_a_bluetooth_headset_reconnects_in_a2dp_instead_of_the_saved_profile.md create mode 100644 docs/findings/20260928_bluetooth_a2dp_fails_with_protocol_not_available_after_resume.md create mode 100644 docs/hardware/.genre.yaml create mode 100644 docs/hardware/.pages create mode 100644 docs/hardware/dell_precision_5470.md create mode 100644 docs/hardware/index.md create mode 100644 docs/hardware/openmove_by_shokz.md create mode 100644 docs/tools/wireplumber.md diff --git a/docs/.pages b/docs/.pages index ba7bb71..251565a 100644 --- a/docs/.pages +++ b/docs/.pages @@ -18,6 +18,7 @@ nav: - knowledge_management - research - tools +- hardware - findings - log.md - ... 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/index.md b/docs/findings/index.md index 066849b..00ee09a 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,8 @@ And the subject lives in the tags rather than in a directory, per ### September +- 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..d3f656d --- /dev/null +++ b/docs/hardware/dell_precision_5470.md @@ -0,0 +1,87 @@ +--- +type: Device +title: Dell Precision 5470 +description: A 14-inch Alder Lake mobile workstation whose audio path runs over SoundWire rather than + classic HD Audio, and whose Bluetooth radio shares a die with its Wi-Fi. +tags: +- hardware +- dell +- linux +- audio +- bluetooth +status: stable +generated: + by: opencode/claude-opus-5 + at: '2026-09-28T08:58:20Z' +--- +The Precision 5470 is Dell's 14-inch mobile workstation on Intel's Alder Lake-P platform. It is +the machine this bundle is written on, and the one every Linux finding here was found on unless +a page says otherwise. + +| | | +| --- | --- | +| 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` | +| 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. + +## No discrete GPU, despite the drivers + +The 5470 is offered with a discrete workstation GPU. This unit does not have one: `lspci` +enumerates only the integrated Iris Xe, and no NVIDIA module is loaded. The NVIDIA userspace +packages are nonetheless installed, which means `nvidia-hibernate.service` and its siblings +appear in the journal on every suspend, skipped by their own `ExecCondition`: + +```text +systemd[1]: nvidia-hibernate.service: Skipped due to 'exec-condition'. +``` + +That line is noise on this machine rather than a symptom, and it sits close enough to the sleep +transition to look relevant when reading a journal for something else. + +## What has cost time here + +- [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..20e0c58 --- /dev/null +++ b/docs/hardware/index.md @@ -0,0 +1,32 @@ +# Hardware + +A machine or peripheral in daily use: what it is, and how it presents itself to Linux. + +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 that +cost an afternoon 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. + +Three conventions are local to this genre. + +**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, +MAC addresses and hostnames identify one machine and its owner, and this bundle is published. +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**, because what a vendor +lists and what the kernel enumerates routinely differ. A page says which it is when the two +disagree. + +**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) - the notebook these notes are written on: a 14-inch Alder Lake workstation whose audio runs over SoundWire and whose Bluetooth shares a die with its Wi-Fi +- [OpenMove by Shokz](openmove_by_shokz.md) - the open-ear bone-conduction headset, its four Bluetooth profiles, and the reason it is used in the low-quality one 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..26ad7cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,4 +24,5 @@ Each section defines what it holds on its own page, and lists what is in it. The - [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 - [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/log.md b/docs/log.md index 3b003ba..9580577 100644 --- a/docs/log.md +++ b/docs/log.md @@ -5,6 +5,12 @@ - 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" ## 2026-09-23 diff --git a/docs/tools/index.md b/docs/tools/index.md index 80a0005..90900c3 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -26,3 +26,4 @@ behind them: they keep their plain name, because `uv` is Astral's and will not b - [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 +- [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 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. From b503c1d9e9114b1f7e5fbcc4d2c53b3c61cfa0a7 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:17:57 +0200 Subject: [PATCH 06/20] Correct the discrete graphics section of the Precision 5470 The unit does have the discrete NVIDIA GPU. It is held off the bus by EnvyControl's `integrated` mode, which blacklists the modules and removes any 0x10de display controller through a udev rule at enumeration, so `lspci` shows nothing and the earlier page read that as absent hardware. Records the two things that still evidence the card, EnvyControl's cache and the empty secondary bus behind the x16 bridge, and the reason the NVIDIA packages are current rather than leftovers. --- docs/hardware/dell_precision_5470.md | 54 +++++++++++++++++++++------- docs/hardware/index.md | 31 ++++++++-------- docs/log.md | 1 + 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/docs/hardware/dell_precision_5470.md b/docs/hardware/dell_precision_5470.md index d3f656d..956b802 100644 --- a/docs/hardware/dell_precision_5470.md +++ b/docs/hardware/dell_precision_5470.md @@ -1,8 +1,7 @@ --- type: Device title: Dell Precision 5470 -description: A 14-inch Alder Lake mobile workstation whose audio path runs over SoundWire rather than - classic HD Audio, and whose Bluetooth radio shares a die with its Wi-Fi. +description: A 14-inch Alder Lake mobile workstation tags: - hardware - dell @@ -13,10 +12,13 @@ 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. It is -the machine this bundle is written on, and the one every Linux finding here was found on unless -a page says otherwise. +The Precision 5470 is Dell's 14-inch mobile workstation on Intel's Alder Lake-P platform. | | | | --- | --- | @@ -24,7 +26,7 @@ a page says otherwise. | 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` | +| 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 | @@ -67,19 +69,45 @@ four entries rather than two: the two Dell platform switches, `dell-wifi` and `d 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. -## No discrete GPU, despite the drivers +## The discrete GPU is switched off, not absent -The 5470 is offered with a discrete workstation GPU. This unit does not have one: `lspci` -enumerates only the integrated Iris Xe, and no NVIDIA module is loaded. The NVIDIA userspace -packages are nonetheless installed, which means `nvidia-hibernate.service` and its siblings -appear in the journal on every suspend, skipped by their own `ExecCondition`: +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 is noise on this machine rather than a symptom, and it sits close enough to the sleep -transition to look relevant when reading a journal for something else. +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. ## What has cost time here diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 20e0c58..dca595d 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -1,32 +1,29 @@ # Hardware -A machine or peripheral in daily use: what it is, and how it presents itself to Linux. +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 that -cost an afternoon is a [finding](../findings/index.md), the software it was fixed in is a +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. -Three conventions are local to this genre. +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, -MAC addresses and hostnames identify one machine and its owner, and this bundle is published. -Where a command's output is quoted and contained an address, the address is replaced with an -obvious placeholder rather than trimmed silently. +- 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**, because what a vendor -lists and what the kernel enumerates routinely differ. A page says which it is when the two -disagree. +- 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. +- 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) - the notebook these notes are written on: a 14-inch Alder Lake workstation whose audio runs over SoundWire and whose Bluetooth shares a die with its Wi-Fi -- [OpenMove by Shokz](openmove_by_shokz.md) - the open-ear bone-conduction headset, its four Bluetooth profiles, and the reason it is used in the low-quality one +- [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/log.md b/docs/log.md index 9580577..820006f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -11,6 +11,7 @@ - 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 ## 2026-09-23 From 9fc4524e85905688d4e71623aa043a70c100c12c Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:45:44 +0200 Subject: [PATCH 07/20] add finding, "Git LFS fails when the remote URL has a trailing slash" --- ...hen_the_remote_url_has_a_trailing_slash.md | 90 +++++++++++++++++++ docs/findings/index.md | 1 + docs/log.md | 1 + 3 files changed, 92 insertions(+) create mode 100644 docs/findings/20260928_git_lfs_fails_when_the_remote_url_has_a_trailing_slash.md 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/index.md b/docs/findings/index.md index 00ee09a..0add69a 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,7 @@ And the subject lives in the tags rather than in a directory, per ### September +- 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 diff --git a/docs/log.md b/docs/log.md index 820006f..0686392 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-09-28 +- 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 From 11b0edfb9e504b8adffcdc5b92f67d3784f34ea8 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:47:58 +0200 Subject: [PATCH 08/20] rephrase Dell entry --- docs/hardware/dell_precision_5470.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/hardware/dell_precision_5470.md b/docs/hardware/dell_precision_5470.md index 956b802..02c9a37 100644 --- a/docs/hardware/dell_precision_5470.md +++ b/docs/hardware/dell_precision_5470.md @@ -69,7 +69,7 @@ four entries rather than two: the two Dell platform switches, `dell-wifi` and `d 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, not absent +## 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 @@ -109,7 +109,7 @@ 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. -## What has cost time here +## 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) From 3a31624ceeb078bc593358eac14a1445c9131f08 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:15:46 +0200 Subject: [PATCH 09/20] Add tool stubs for Voxtype and Vocalinux --- docs/log.md | 2 ++ docs/tools/index.md | 2 ++ docs/tools/vocalinux_vocahq.md | 44 ++++++++++++++++++++++++++++++++++ docs/tools/voxtype_jackson.md | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 docs/tools/vocalinux_vocahq.md create mode 100644 docs/tools/voxtype_jackson.md diff --git a/docs/log.md b/docs/log.md index 0686392..34e7ff8 100644 --- a/docs/log.md +++ b/docs/log.md @@ -13,6 +13,8 @@ - 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 diff --git a/docs/tools/index.md b/docs/tools/index.md index 90900c3..da026a6 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -27,3 +27,5 @@ behind them: they keep their plain name, because `uv` is Astral's and will not b - [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 - [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/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. From 83eab1d94b356371d5d50327fa1d7516cef06038 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:49:32 +0200 Subject: [PATCH 10/20] Record that opencode's transcripts land in the Claude config directory --- ...cripts_into_the_claude_config_directory.md | 52 +++++++++++++++++++ docs/findings/index.md | 1 + docs/log.md | 1 + 3 files changed, 54 insertions(+) create mode 100644 docs/findings/20260928_opencode_writes_its_session_transcripts_into_the_claude_config_directory.md 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/index.md b/docs/findings/index.md index 0add69a..0255957 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,7 @@ And the subject lives in the tags rather than in a directory, per ### September +- 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 diff --git a/docs/log.md b/docs/log.md index 34e7ff8..a4f80c5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-09-28 +- 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 From 426821fab08065ab462fa8fa3dc07f791af340a8 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:09:25 +0200 Subject: [PATCH 11/20] Add finding: pymarkdown's fix mode dedents YAML frontmatter --- ...downs_fix_mode_dedents_yaml_frontmatter.md | 78 +++++++++++++++++++ docs/findings/index.md | 1 + docs/log.md | 1 + 3 files changed, 80 insertions(+) create mode 100644 docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md 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..7d161e0 --- /dev/null +++ b/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md @@ -0,0 +1,78 @@ +--- +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 + } + } +} +``` + +Two things about that file are worth checking at the same time, because a configuration that has +this bug tends to have them too. + +**`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/index.md b/docs/findings/index.md index 0255957..1b49acc 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,7 @@ And the subject lives in the tags rather than in a directory, per ### September +- 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 diff --git a/docs/log.md b/docs/log.md index a4f80c5..f885655 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 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 From 830bc9903b9d0c328e2a590aff04698e1be15f8d Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:11:45 +0200 Subject: [PATCH 12/20] Record that pymarkdown does not discover its own config file --- ...markdowns_fix_mode_dedents_yaml_frontmatter.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md b/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md index 7d161e0..b361a6f 100644 --- a/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md +++ b/docs/findings/20260928_pymarkdowns_fix_mode_dedents_yaml_frontmatter.md @@ -55,8 +55,19 @@ Enable the extension in `.pymarkdown.json`: } ``` -Two things about that file are worth checking at the same time, because a configuration that has -this bug tends to have them too. +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. From dfd4a04331f7612cfb816e65326ca1b65ed058ba Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:43:14 +0200 Subject: [PATCH 13/20] Add liseur and liseur-sync: EPUB reader and sync server by Chmouel --- docs/tools/index.md | 1 + docs/tools/liseur_chmouel.md | 117 +++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 docs/tools/liseur_chmouel.md diff --git a/docs/tools/index.md b/docs/tools/index.md index 80a0005..e701c15 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -26,3 +26,4 @@ behind them: they keep their plain name, because `uv` is Astral's and will not b - [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 diff --git a/docs/tools/liseur_chmouel.md b/docs/tools/liseur_chmouel.md new file mode 100644 index 0000000..7ff94c8 --- /dev/null +++ b/docs/tools/liseur_chmouel.md @@ -0,0 +1,117 @@ +--- +description: EPUB reader and sync server for self-hosted book libraries with reading-position synchronization across devices +type: concept +tags: + - tools + - reading + - epub + - self-hosted +resource: + liseur: https://github.com/chmouel/liseur + liseur-sync: https://github.com/chmouel/liseur-sync +generated: + by: opencode/qwen3.5-397b-a17b + on: 2026-09-28 +--- + +# Liseur and Liseur-Sync (Chmouel Boudjnah) + +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** is the Android client: an EPUB reader using the Readium engine with support for local files, OPDS catalogs, and sync servers. **Liseur-sync** 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: https://blog.chmouel.com \ No newline at end of file From 278762a505121bed1547e19379a7f6df583f5fa3 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:37:25 +0200 Subject: [PATCH 14/20] add finding, Chromium maximises to the wrong size after an undock --- ...mises_to_the_wrong_size_after_an_undock.md | 125 ++++++++++++++++++ docs/findings/index.md | 1 + docs/log.md | 4 + 3 files changed, 130 insertions(+) create mode 100644 docs/findings/20260930_chromium_maximises_to_the_wrong_size_after_an_undock.md 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/index.md b/docs/findings/index.md index 1b49acc..8f84ea4 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,7 @@ And the subject lives in the tags rather than in a directory, per ### 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 diff --git a/docs/log.md b/docs/log.md index f885655..1163893 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 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" From e37605d92e42fa80cc4d95550041983e46058288 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:33:55 +0200 Subject: [PATCH 15/20] Add two findings from an opencode config audit A plugin upgrade that renamed a routing category and silently sent two slots across the provider isolation boundary, and the unexpanded tilde in env VAR=~/path that opencode turns into a real directory. --- ...ategory_and_breached_provider_isolation.md | 70 +++++++++++++++++++ ...t_expand_a_tilde_in_env_var_assignments.md | 63 +++++++++++++++++ docs/findings/index.md | 5 ++ docs/log.md | 5 ++ 4 files changed, 143 insertions(+) create mode 100644 docs/findings/20261001_a_plugin_upgrade_renamed_a_routing_category_and_breached_provider_isolation.md create mode 100644 docs/findings/20261001_zsh_does_not_expand_a_tilde_in_env_var_assignments.md 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 8f84ea4..df3cb12 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -20,6 +20,11 @@ And the subject lives in the tags rather than in a directory, per ## 2026 +### October + +- 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 diff --git a/docs/log.md b/docs/log.md index 1163893..5bdbde1 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,10 @@ # Log +## 2026-10-01 + +- 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" From b5111f8068434bdba9ebe538a05f15e19d188edb Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:39:55 +0200 Subject: [PATCH 16/20] add finding: a config symlink makes every omo migration fail on start --- ...makes_every_omo_migration_fail_on_start.md | 79 +++++++++++++++++++ docs/findings/index.md | 1 + docs/log.md | 1 + 3 files changed, 81 insertions(+) create mode 100644 docs/findings/20261001_a_config_symlink_makes_every_omo_migration_fail_on_start.md 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/index.md b/docs/findings/index.md index df3cb12..c8dcdf6 100644 --- a/docs/findings/index.md +++ b/docs/findings/index.md @@ -22,6 +22,7 @@ And the subject lives in the tags rather than in a directory, per ### 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 diff --git a/docs/log.md b/docs/log.md index 5bdbde1..aa01549 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 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" From e22d510d0cf96be5fc8e2f654b3e7824396ef3db Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:14:06 +0200 Subject: [PATCH 17/20] Add tool page, "OpenChamber" --- docs/log.md | 4 ++ docs/tools/index.md | 1 + docs/tools/openchamber.md | 116 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 docs/tools/openchamber.md diff --git a/docs/log.md b/docs/log.md index aa01549..7c8afff 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-10-07 + +- add tool, "OpenChamber" + ## 2026-10-01 - add finding, "A config symlink makes every omo migration fail on start" diff --git a/docs/tools/index.md b/docs/tools/index.md index 347e582..7871f00 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -21,6 +21,7 @@ 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 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. From 70dc992de46d79978a5da19f502d3ddb6fd5fb28 Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:52:44 +0200 Subject: [PATCH 18/20] Rename the research section to investigations The old index carried a note that it needed splitting into analyses and findings. Neither half holds: findings already exists as a different genre, cheap and incident-led, and the remaining cut between a source read and a design question is the subject rather than the nature, which the tags already carry. So one section, renamed for what its pages are. The genre type goes from Reference to Investigation, matching every other directory here, and the redundant research tag comes off all eight pages. --- about/editing_conventions.md | 6 ++++-- docs/.pages | 2 +- ...owledge_base_as_privacy_tiered_okf_bundles.md | 6 +++--- .../running_this_knowledge_base_on_awiki.md | 6 +++--- ...apping_the_kb_skills_in_a_federation_layer.md | 2 +- ...ion_breaks_on_frontmatter_led_source_files.md | 2 +- docs/index.md | 2 +- docs/{research => investigations}/.genre.yaml | 6 +++--- docs/investigations/.pages | 1 + ...ault_interaction_for_an_okf_conformant_pkb.md | 3 +-- ...ge_bases_a_workspace_manifest_architecture.md | 3 +-- docs/{research => investigations}/index.md | 8 +++++--- .../open_knowledge_format_okf_findings.md | 3 +-- .../security_analysis_of_agent_wiki_awiki.md | 2 +- ..._one_user_level_agents_md_across_harnesses.md | 3 +-- .../skills_sh_repository_layout_and_telemetry.md | 3 +-- .../spell_checking_prose_in_a_pre_commit_hook.md | 3 +-- ...f_based_agent_first_llm_wiki_investigation.md | 3 +-- ...ize_by_what_content_is_not_why_you_made_it.md | 4 ++-- ...a_values_to_blueprints_derivation_pipeline.md | 8 ++++---- ...s_an_exploration_not_a_superseded_decision.md | 16 ++++++++-------- .../separate_audiences_with_separate_bundles.md | 2 +- docs/log.md | 4 ++++ docs/research/.pages | 1 - docs/tools/agent_knowledge_stjbrown.md | 6 +++--- docs/tools/agent_wiki_tacotakumi.md | 6 +++--- .../federated_knowledge_skills_schindler.md | 4 ++-- docs/tools/project_wiki_giodra96.md | 2 +- docs/tools/skills_vercel_labs.md | 2 +- 29 files changed, 60 insertions(+), 59 deletions(-) rename docs/{research => investigations}/.genre.yaml (89%) create mode 100644 docs/investigations/.pages rename docs/{research => investigations}/agent_integration_layer_and_multi_vault_interaction_for_an_okf_conformant_pkb.md (99%) rename docs/{research => investigations}/federated_okf_knowledge_bases_a_workspace_manifest_architecture.md (99%) rename docs/{research => investigations}/index.md (89%) rename docs/{research => investigations}/open_knowledge_format_okf_findings.md (99%) rename docs/{research => investigations}/security_analysis_of_agent_wiki_awiki.md (99%) rename docs/{research => investigations}/sharing_one_user_level_agents_md_across_harnesses.md (99%) rename docs/{research => investigations}/skills_sh_repository_layout_and_telemetry.md (99%) rename docs/{research => investigations}/spell_checking_prose_in_a_pre_commit_hook.md (99%) rename docs/{research => investigations}/substrate_options_for_an_okf_based_agent_first_llm_wiki_investigation.md (99%) delete mode 100644 docs/research/.pages 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 251565a..1a1f926 100644 --- a/docs/.pages +++ b/docs/.pages @@ -16,7 +16,7 @@ nav: - blueprints - guides - knowledge_management -- research +- investigations - tools - hardware - findings 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/index.md b/docs/index.md index 26ad7cc..b18de58 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +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 89% rename from docs/research/index.md rename to docs/investigations/index.md index 260e15b..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. 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/research/sharing_one_user_level_agents_md_across_harnesses.md b/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md similarity index 99% rename from docs/research/sharing_one_user_level_agents_md_across_harnesses.md rename to docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md index 7a0af6a..147d8a1 100644 --- a/docs/research/sharing_one_user_level_agents_md_across_harnesses.md +++ b/docs/investigations/sharing_one_user_level_agents_md_across_harnesses.md @@ -1,10 +1,9 @@ --- -type: Reference +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: -- research - ai-agents - agents - opencode 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/research/spell_checking_prose_in_a_pre_commit_hook.md b/docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md similarity index 99% rename from docs/research/spell_checking_prose_in_a_pre_commit_hook.md rename to docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md index 674c164..8a55cfa 100644 --- a/docs/research/spell_checking_prose_in_a_pre_commit_hook.md +++ b/docs/investigations/spell_checking_prose_in_a_pre_commit_hook.md @@ -1,12 +1,11 @@ --- -type: Reference +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: -- research - pre-commit - linting - spelling 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 7c8afff..5971e10 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,10 @@ ## 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 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/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` From f3728b6bca0ba22ebc29e300714586909493ed2c Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:05:50 +0200 Subject: [PATCH 19/20] Conform the Liseur tool page to the tools genre Its frontmatter still said type: concept and carried an ad-hoc resource map, so the genre and OKF floor checks both rejected it. Give it the tool frontmatter the sibling pages use, move the two repository URLs into sources, and drop the body H1 now that the title is declared. --- docs/tools/liseur_chmouel.md | 40 +++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/tools/liseur_chmouel.md b/docs/tools/liseur_chmouel.md index 7ff94c8..89f0c0d 100644 --- a/docs/tools/liseur_chmouel.md +++ b/docs/tools/liseur_chmouel.md @@ -1,21 +1,28 @@ --- -description: EPUB reader and sync server for self-hosted book libraries with reading-position synchronization across devices -type: concept +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 -resource: - liseur: https://github.com/chmouel/liseur - liseur-sync: https://github.com/chmouel/liseur-sync +- 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 - on: 2026-09-28 + at: '2026-09-28T00:00:00Z' --- -# Liseur and Liseur-Sync (Chmouel Boudjnah) - 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 @@ -30,11 +37,12 @@ Together they form a self-hosted alternative to commercial ebook ecosystems, wit 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. +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` @@ -66,16 +74,19 @@ curl -fsSL https://raw.githubusercontent.com/chmouel/liseur-sync/main/scripts/in ### 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 ``` @@ -112,6 +123,7 @@ MIT ## Author Chmouel Boudjnah + - Fediverse: [@chmouel@chmouel.com](https://fosstodon.org/@chmouel) - Twitter: [@chmouel](https://twitter.com/chmouel) -- Blog: https://blog.chmouel.com \ No newline at end of file +- Blog: From 6c210c07369bac37ff20e06715bf7001c278e38f Mon Sep 17 00:00:00 2001 From: Felix Schindler <107669806+ftschindler@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:07:41 +0200 Subject: [PATCH 20/20] Cite the Liseur sources the frontmatter declares The build holds declared sources and body citations to the same set, so the two repository entries needed footnote markers where each component is introduced. --- docs/tools/liseur_chmouel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/liseur_chmouel.md b/docs/tools/liseur_chmouel.md index 89f0c0d..77df5e8 100644 --- a/docs/tools/liseur_chmouel.md +++ b/docs/tools/liseur_chmouel.md @@ -27,7 +27,7 @@ An open-source EPUB reader for Android paired with a self-hosted sync server, de ## What it is -**Liseur** is the Android client: an EPUB reader using the Readium engine with support for local files, OPDS catalogs, and sync servers. **Liseur-sync** is the companion server: a single Go binary that provides reading-position synchronization, library management, and statistics. +**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.