Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,6 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

.claude/
CLAUDE.md
Comment on lines +144 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not ignore the project convention if it must be shared.

These entries exclude .claude/skills/ and CLAUDE.md, but the documented model says project-scaffolded skills reach everyone who clones the project. Until task 1.4 runs, this repository cannot provide reproducible convention or scc validation. Remove these rules in this change, or make task 1.4 a release prerequisite with a CI check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 144 - 146, Remove the .claude/ and CLAUDE.md entries
from the ignore rules so project-scaffolded skills and shared conventions are
committed and available to every clone. Do not add a CI or release prerequisite
unless task 1.4 is explicitly being implemented in this change.

3 changes: 2 additions & 1 deletion docs/adr/0003-mcp-as-the-only-bridge-to-the-llm.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
status: accepted
status: superseded
superseded-by: 0013-the-project-directory-is-the-unit
---

# 0003 · MCP is the only bridge between the wiki and the LLM
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0009-distribution-through-github-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ no server of our own, so there is no update endpoint and no download host to run
distributes the binary is somebody else's infrastructure.

The repository is already on GitHub, the tag is already the thing that says "this is a
version", and the winget and Scoop manifests of task 10.2 both work by pointing at a stable
version", and the winget and Scoop manifests both work by pointing at a stable
download URL with a known hash. GitHub Releases is that URL, produced by the tag we are
already pushing.

Expand All @@ -36,8 +36,8 @@ is a settings change and not a workflow rewrite.
## Consequences

Distribution costs nothing to run and nothing to operate. The download URL is stable and
predictable, which is the only property task 10.2 needs from it, and every release carries
the hash that manifest has to state.
predictable, which is the only property a winget or Scoop manifest needs from it, and every
release carries the hash that manifest has to state.

**An unsigned installer means Microsoft SmartScreen warns on it**, with a dialog whose
default button is "Don't run". This lands worst in exactly the environment this product is
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0010-a-derived-index-engine-behind-a-cli.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
status: proposed
status: superseded
superseded-by: 0014-typescript-everywhere-except-audio-capture
---

# 0010 · A derived-index engine as a second Rust binary, behind a CLI
Expand Down
203 changes: 203 additions & 0 deletions docs/adr/0013-the-project-directory-is-the-unit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
status: accepted
---

# 0013 · The project directory is the unit, and MCP becomes cross-project consultation

## Context

`adr:0002-workspace-as-a-local-markdown-folder` made the workspace a container folder with
one directory per project, and `adr:0003-mcp-as-the-only-bridge-to-the-llm` made MCP the
only way an agent reached any of it. Both were written before anyone checked what the
harness does with a folder that sits outside the project it has open.

Checked against Claude Code v2.1.x, it does badly:

- Reaching the folder at all takes `--add-dir` or an `additionalDirectories` setting.
- A `CLAUDE.md` in such a directory **does not load** by default — it needs an environment
variable, and through the `additionalDirectories` setting it loads no configuration at
all. The plan generated exactly that file.
- A plugin cannot ship permission rules, so a deny rule protecting the folder has to be
written into the user's own settings — which, for a folder outside the project, there is
nothing in the product positioned to do.

Inside the project the harness has the opposite problem, which is to say none. `CLAUDE.md`
and `.claude/rules/*.md` load at session start. `.mcp.json` and `.claude/settings.json` are
picked up from the repository root, so they can be committed and reach everyone who clones.
`Edit(path/**)` deny rules are enforced by the harness rather than requested of the model.
And `Read` and `Grep` already work, over a ripgrep better than any search this product would
ship.

So MCP was carrying reads that the filesystem carries better, and the container folder was
buying a project switcher nobody needed.

## Decision

**A project is a directory.** `ow` invoked inside it opens the application scoped to that
directory, the way `code .` and `idea .` do. There is no workspace container and no
directory of projects owned by the application. A registry of known project paths replaces
it, for the launcher and for name resolution — and it is a cache, never a source of truth:
a moved or deleted directory degrades, it does not corrupt.

**The harness reads the wiki through the filesystem.** No tool, no protocol, no
configuration.

**MCP stops being the bridge and becomes the window into other projects.** It is read-only,
runs over stdio, and is spawned by the harness rather than by the application:

```
ow mcp --project fenix --read-only
```
Comment on lines +48 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the code fence language.

markdownlint reports MD040 for Line 48. Use console or text.

Proposed fix
-```
+```console
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
ow mcp --project fenix --read-only
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 48-48: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0013-the-project-directory-is-the-unit.md` around lines 48 - 50,
Specify the code fence language for the ow mcp command by changing the unlabeled
fence to a console or text fence, preserving the command content.

Source: Linters/SAST tools


Four constraints are the decision:

**One process, one project, chosen by the caller's configuration.** The process is launched
for a single project and cannot reach another. This replaces 0003's "chosen by the
application" with confinement by process. The project is still named — `--project fenix` is
a parameter — but it is fixed for the process's whole life and comes from configuration
rather than from a tool call, so an agent cannot pivot to another base mid-session. That is
the whole of the improvement, and it is smaller than "no parameter at all": the entrypoint
must therefore not import the write path, so that `--read-only` describes what the process
can do rather than what it agrees to do.

**Stdio, so there is no port and no token.** The consulted project's application is almost
never running — nobody has `fenix` open while working in `payments-api` — so a server the
application starts is a server that is not there. The harness spawning the process removes
the port, the loopback exposure, the mandatory token and the `headersHelper` that was to
deliver it, in one move.

That win is only real if nothing puts an unauthenticated local listener back. A CLI that
talks to the running application over a socket to pay down cold start is exactly that
listener, so it is a security boundary and not a performance detail: it carries read and
validate, never write, and where the platform offers a named pipe with a restrictive ACL it
uses one.

**The project is named, not pathed.** A committed `.mcp.json` carrying `C:\dev\fenix` breaks
for everyone who clones it. The argument is the registry name, resolved locally, so the
configuration is both committed and portable.

**Read tools return whole pages.** A wiki page is a few kilobytes; the index says what
exists and the agent reads what it picked. No passage extraction and no ranking — and with
them, this record closes the embeddings question the plan had left ajar: there are none, and
no vector store, no reranking and no inverted index.

**The gate's own configuration is outside what the gate lets an agent write.** `.claude/**`,
`.mcp.json` and `CLAUDE.md` sit inside the project directory, and they are executable
configuration: a command to spawn, permission rules, and prompt text loaded into every
collaborator's agent. A write path that reaches them is a write path that edits away its own
restraint — and it does it through a change that reads as documentation in review. Path
confinement is to the project *minus* those, and the check resolves the real path before
comparing, because on Windows a directory junction needs no privilege and is not a symlink.

A project's `.mcp.json` lists *other* projects, never itself. Locally the filesystem reaches
everything, and structural queries go through the CLI.

## Consequences

Of `adr:0003-mcp-as-the-only-bridge-to-the-llm`, three clauses survive intact and are the
substance of that record: **the application does not call an LLM**, **the agent writes the
pages**, and **write-time validation is what replaces the writer**. Four are void: HTTP on
the loopback started and stopped by the application; MCP exposing ingest and write; the
served project being chosen by the application; and the mandatory token. That is why this
record supersedes it rather than narrowing it.

`adr:0002-workspace-as-a-local-markdown-folder` is narrowed, not superseded. It loses the
container — a workspace with one directory per project — and keeps everything that made it:
files rather than a database, the application never touching git, and atomic writes,
snapshots, the operation log and undo as the net.

Because the token is gone, the application holds **exactly one secret again**, the
transcription credential. That is what 0003 claimed and what
`adr:0007-plaintext-credentials-in-the-config` had to walk back to two.
Comment on lines +109 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use “at most one secret” for the credential claim.

The plan and docs/stack.md allow local whisper.cpp, which requires no credential. State that the transcription credential is the only secret the application may hold when a remote provider is selected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0013-the-project-directory-is-the-unit.md` around lines 109 - 111,
Update the credential claim in the ADR text near “the application holds exactly
one secret again” to say the application holds at most one secret, and qualify
that the transcription credential is held only when a remote transcription
provider is selected; preserve the reference to ADR 0003 and adr:0007.


That same record now carries weight it was not written to carry. Its clause "never inside
the workspace" was mild advice when the workspace was a folder the application owned; with
the project directory likely being a git repository, a secret written there is a secret in
history forever. The configuration therefore splits: project settings — the content
language, the conventions — committed and shared with the team, and every secret in the
application's own data directory, keyed by project path.

Three things about that split are decided here rather than left to taste. **It is
unconditional**: a secret is never written into a project directory, whether or not one is a
repository today, because `git init` a week later turns a conditional rule into a leak.
**The committed half carries no local path**, for the same reason `.mcp.json` names a
project rather than a directory — a path is both a portability bug and someone's username.
And **the committed half is a closed schema**: an unknown key is refused rather than
tolerated, so the first feature that wants a per-project token cannot quietly put one there.

`adr:0007-plaintext-credentials-in-the-config` keeps its decision — plaintext, in the
application's data directory, with DPAPI as the named successor — but the file it sketches
is now historical in two places: there is no `mcp` section, because the port and the token
are gone, and `workspace_path` becomes the registry of
`adr:0013-the-project-directory-is-the-unit`. Its two operational rules gain a reader it did
not have: the CLI's stderr is consumed by an agent and travels to a model provider, so the
entrypoints that run under a hook or as `ow mcp` must not read the credential section at
all.

Where the project is in git, the user's git becomes a recovery path the product does not
have to build, and the application still knows nothing about it. Where it is not, `.state/`
is the whole net, so nothing here may be read as delegating recovery to a tool this project
declares out of scope.

`adr:0008-content-language-is-a-setting-english-by-default` loses one clause and keeps its
decision. The content language was "workspace-wide, not per project", on the reasoning that
a workspace holding projects in different languages was an axis nobody had asked for; with
no workspace, the setting is necessarily per project and lives in the committed half of that
project's configuration. What it reaches is unchanged — the transcription hint, and the
convention text the agent reads.

**The gate moved, and it is rebuildable — for the file tools.** With writes gone from MCP,
the agent writes the wiki with its own tools. A `PreToolUse` hook receives the complete
`tool_input` — for `Write` that includes `content`, for `Edit` the strings — and can answer
`permissionDecision: deny` with a reason the agent reads. It can also return `updatedInput`,
which replaces the arguments before the tool runs. So refusal survives intact — the
malformed page never reaches the disk, and 0002's corollary that the defence has to be at
the entrance still holds — and so does everything the store used to do *for* the agent
rather than *to* it: the fields filled in on its behalf are written into the input, not
requested of it in an error message. The whole service survives, not a weakened half of it.

This is the most load-bearing fact in the record and it was got wrong twice while drafting,
in both directions, each time by reasoning about the hook contract instead of reading it.
Nothing about what a harness can or cannot do belongs in these records unless it was
checked against the reference.

What does *not* survive is the completeness of that gate. A hook matches a tool, so a write
that arrives some other way is not gated: `echo > wiki/page.md` through Bash carries a
command string, not page content, and no content-validating hook can fire on it. Denying
`Edit(wiki/**)` does not constrain Bash either — permission rules are per tool. Whatever
9.5 chooses therefore has to answer for the shell, not only for `Edit` and `Write`, and
`PostToolUse` validation plus group 7's checks are what cover what the entrance misses.

Hooks are also Claude Code's mechanism and nobody else's. In a harness without them the
gate is whatever the CLI verb enforces, and where neither is in place group 7 is the net of
record.

The operation log changes meaning with it. It was *operations we performed*; it becomes
*changes we observed*, and undo covers only what the application's editor or a hook saw. A
page edited in Obsidian falls outside it.

## The two questions this record does not answer

**Which write gate, and how does it cover the shell?** A `PreToolUse` hook that validates
`tool_input` and denies is the strongest of the options and the cheapest, but it is Claude
Code only and it does not see a write made through Bash. A CLI verb that is the only way to
write covers any harness that has a shell, but only if something stops the shell writing the
file directly, which a per-tool deny rule does not. They compose — and the composition, not
either half, is what has to be chosen. It cannot ship open.

**What of `raw/` and `.state/` enters git, and who reads it then?** The cost side is easy:
an hour of Opus is ~11 MB, written once and never modified, and committing `text.md`,
`timeline.*` and `manifest.json` keeps a cited passage readable by anyone who clones, while
refusing the media means a provenance link opens nothing for everyone except the person who
recorded it — a real loss against `adr:0006-opus-as-the-provenance-format`.

The side that matters more is disclosure, and it is the reason this question is the more
irreversible of the two. `raw/` holds recorded meeting audio and the verbatim text rendered
from its timeline;
`.state/` holds a snapshot of every page before every write, which means a redaction lives
on there in its unredacted form. Committing either puts that content in every clone, every
fork and every CI checkout, permanently and beyond recall — and it dissolves the promise
`docs/stack.md` makes for whisper.cpp, that the audio never leaves the machine. The default
therefore has to be deny: `ow init` writes the ignore entries and the user opts *in*, rather
than discovering after a push. What is still open is exactly which paths, and whether
committing the media is offered at all.
78 changes: 78 additions & 0 deletions docs/adr/0014-typescript-everywhere-except-audio-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
status: accepted
---

# 0014 · TypeScript everywhere except audio capture

## Context

`adr:0010-a-derived-index-engine-behind-a-cli` proposed a second Rust binary, `ow.exe`,
owning the derived view of a project behind a command line. It was never accepted, and it
left two questions open.

Meanwhile `adr:0013-the-project-directory-is-the-unit` turned that CLI from an optimisation
into the product's spine: it is the launcher, the project registry, the MCP process, the
integrity checker and — depending on which write gate is chosen — the way a page is written
at all.

The repository is already a pnpm workspace with strict TypeScript, Vitest and a coverage
floor enforced per package.

## Decision

**TypeScript and Node for everything except audio capture.** Rust keeps exactly the
recorder: COM against WASAPI, a clock of its own, no GC pause for an hour.

The CLI is a TypeScript package. It is published to npm, so `npx open-wiki init` works with
nothing installed, and the desktop installer puts an `ow` shim on `PATH` that invokes the
installed application — which is how `code` works, and it ships no second runtime.

## Consequences

This does not overrule 0010's reasoning; it applies it. That record disclaimed its own
performance argument in as many words — *"anyone claiming this engine is needed for speed at
MVP scale is wrong. What justifies it is having one owner for the derived view and a
contract narrow enough to test"* — and that justification is agnostic of language. What it
loses is the second binary, the two artifacts whose versions must agree, and the mismatched
pair that fails looking like corrupted data.

0010 is marked `superseded` rather than `rejected`, which deserves a word because it was
never accepted. `rejected` would say the proposal was refused and nothing came of it; what
happened is that its contract was adopted and its implementation language was not. This
record is where the surviving half now lives, so pointing at it is more useful to a reader
than a status that would send them nowhere. The two questions 0010 left open go with it:
the inverted index is refused outright, and the graph comes before the search because the
search is what a scan already does.

`adr:0005-wasapi-capture-in-a-minimal-sidecar` becomes literally true again. Its sentence
"everything that is not audio capture lives on the JavaScript side" was being narrowed by
0010; it is not any more.

One language means one test runner, one lint, one coverage story, and the core — validation,
frontmatter, wikilinks, markdown — is a package imported by the Electron main process, the
CLI and the MCP process rather than a contract between two languages. The plan's rule that
there is one implementation of project access, used by every surface, stops being a
discipline and becomes a fact of the build. The MCP SDK is TypeScript-first, which the Rust
version would have given up.

**Cold start is the real cost, and it is the one 0010 named.** A CLI carrying a markdown
parser, a YAML parser and the MCP SDK reaches several hundred milliseconds per invocation,
and under a hook that fires on every page write that is a tax on the agent's edit loop.
Two things pay it down: bundling to a single file, which removes module resolution, and
talking to the running application over a local socket when there is one — which there
usually is, because `ow` is what opened it.

That socket is a local listener, and this project has already paid once for treating one as
plumbing. `adr:0013-the-project-directory-is-the-unit` removed an authenticated loopback
port and would be giving the exposure straight back if a cold-start optimisation quietly
reintroduced an unauthenticated one carrying the write path. It carries read and validate
only, and the constraint belongs to that record rather than to this one.

That second path cannot be the only one. `npx open-wiki init` runs where nothing is
installed, so the CLI has to work standalone as well, and both paths have to produce the
same answer.

Publishing to npm is a distribution channel `adr:0009-distribution-through-github-releases`
does not cover. There are now two artifacts with two release paths — an installer from a
`v*` tag and a package on npm — and a version skew between them is a real failure mode, not
a hypothetical one.
Loading
Loading