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
20 changes: 20 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "protonspy",
"description": "open-wiki: a project's documentation as a wiki the agent already has open, with every write going through a gate that validates it.",
"owner": {
"name": "protonspy",
"url": "https://github.com/protonspy"
},
"plugins": [
{
"name": "open-wiki",
"source": "./plugins/open-wiki",
"description": "The write gate and the scaffolding command for an open-wiki project: PreToolUse validates a page before it exists and PostToolUse records it.",
"version": "0.1.0",
"author": { "name": "protonspy" },
"homepage": "https://github.com/protonspy/open-wiki",
"license": "Apache-2.0",
"keywords": ["wiki", "documentation", "provenance"]
}
]
}
36 changes: 35 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,45 @@ jobs:

- run: cargo test --locked --all-features

# 10.6 — the plugin is a manifest and a hooks file, and a malformed one
# fails at `/plugin install` on somebody else's machine. Ubuntu because it
# only reads JSON.
plugin:
name: plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment on lines +135 to +136

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 | 🟠 Major | ⚡ Quick win

Set persist-credentials: false on this checkout.

This job later runs npm i -g @anthropic-ai/claude-code``, third-party code with a postinstall path. By default, actions/checkout leaves the `GITHUB_TOKEN` credential in `.git/config`. If the installed package or one of its dependencies is compromised, it can read and exfiltrate that token.

Add persist-credentials: false, since this job does not need to push or fetch with the token afterward.

🔒 Proposed fix
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 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
steps:
- uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 136-136: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 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 @.github/workflows/ci.yml around lines 135 - 136, Update the
actions/checkout@v4 step in the CI job to set persist-credentials to false,
ensuring the GitHub token is not retained in the repository configuration while
preserving the checkout behavior.

Source: Linters/SAST tools


- uses: actions/setup-node@v4
with:
node-version: 22

- name: The marketplace and the plugin manifest are well formed
run: node scripts/ci/check-plugin.mjs

# A failed *install* is tolerated — a registry outage is not a finding
# about this repository. A failed *validation* is not: `continue-on-error`
# made the `|| exit 1` inert, so the step reported success whatever the
# tool said — which is exactly what check-plugin.mjs's own header calls
# "a check that silently passes when its tool is missing".
- name: claude plugin validate --strict
run: |
if ! npm i -g @anthropic-ai/claude-code >/dev/null 2>&1; then
echo "could not install the Claude Code CLI — skipped (check-plugin.mjs still ran)"
exit 0
fi
# Both, because they are different manifests: validating the
# marketplace reads `.claude-plugin/marketplace.json` and stops — it
# never opens the plugin's own manifest.
claude plugin validate --strict .
claude plugin validate --strict ./plugins/open-wiki

# The single check to require on the branch. Skipped jobs are fine — an empty
# workspace has nothing to test — but a failed or cancelled one is not.
ci:
name: CI
if: always()
needs: [discover, test, checks, rust]
needs: [discover, test, checks, rust, plugin]
runs-on: ubuntu-latest
steps:
- name: Fail if any job failed or was cancelled
Expand All @@ -144,3 +177,4 @@ jobs:
echo "test: ${{ needs.test.result }}"
echo "checks: ${{ needs.checks.result }}"
echo "rust: ${{ needs.rust.result }}"
echo "plugin: ${{ needs.plugin.result }}"
118 changes: 103 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write
permissions: {}

jobs:
release:
name: Build and publish the installer
runs-on: windows-latest
permissions:
# Least privilege, and granted per job rather than per workflow: the
# release needs to create one, and `id-token` is what npm provenance
# signs with. Nothing else in this repository should inherit either.
contents: write
id-token: write
env:
# Never interpolated into a `run:` body. A tag name may contain `"`, `$`,
# `;` and a backtick — `git check-ref-format` permits all four — and this
# job holds the npm token and the signing certificate.
TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4

Expand All @@ -30,41 +40,73 @@ jobs:
exit 1
}

- name: Check the tag matches the app version
# Both artifacts, one version. The installer and the npm package ship
# from this tag, and `adr:0014` names what a skew costs: it "fails
# looking like corrupted state rather than a bad install".
- name: Check the tag and both artifacts agree
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
run: |
$tag = "${{ github.ref_name }}".TrimStart("v")
$version = (Get-Content "apps/desktop/package.json" -Raw | ConvertFrom-Json).version
if ($tag -ne $version) {
Write-Error "tag v$tag does not match apps/desktop/package.json version $version"
exit 1
}
Write-Host "releasing $version"
run: node scripts/ci/release-version.mjs "$env:TAG"

- name: Refuse to republish an existing release
# Both registries, before anything is built. A GitHub release can be
# deleted; an npm version cannot, so finding out afterwards that one of
# the two is already taken leaves a half-published release with no way
# back except bumping the version — which is what the agreement check
# above exists to make impossible.
- name: Refuse to republish
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
# A published release has been downloaded; deleting it does not undo that.
gh release view "${{ github.ref_name }}" 2>$null
gh release view "$env:TAG" 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Error "release $env:TAG already exists — bump the version and tag again"
exit 1
}
$version = $env:TAG.TrimStart("v")
npm view "open-wiki@$version" version 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Error "release ${{ github.ref_name }} already exists — bump the version and tag again"
Write-Error "open-wiki@$version is already on npm — a version there is permanent; bump and tag again"
exit 1
}
Write-Host "no release for ${{ github.ref_name }} yet"
Write-Host "neither registry has $env:TAG yet"

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
# Without this, `setup-node` writes no .npmrc and NODE_AUTH_TOKEN is
# read by nothing — the publish below would be unauthenticated.
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile

# The installer carries them; `vendor/ffmpeg/` is gitignored and fetched
# with hash verification, and the recorder is the one Rust crate.
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
Comment on lines 76 to +91

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 | 🟠 Major | 🏗️ Heavy lift

Address the cache-poisoning risk flagged on the caching actions.

Static analysis flags both actions/setup-node's cache: pnpm and Swatinem/rust-cache@v2 for cache-poisoning risk. This job runs on a tag push with contents: write and id-token: write permissions and holds the npm token and the signing certificate. If a cache entry with a shared restore key were populated by a lower-trust workflow run (for example, a regular PR-triggered CI run), this privileged release job could restore it and execute code from a poisoned dependency cache.

Consider scoping the cache key to this workflow only (for example a key that includes the workflow name or github.ref), or disabling the built-in cache here and installing dependencies without relying on a cache shared with other workflows.

🧰 Tools
🪛 zizmor (1.28.0)

[error] 78-78: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): this step

(cache-poisoning)


[error] 91-91: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default

(cache-poisoning)

🤖 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 @.github/workflows/release.yml around lines 76 - 91, Restrict caching in the
release job to trusted, workflow-specific entries: update the setup-node pnpm
cache and Swatinem/rust-cache configuration so neither can restore cache data
shared with lower-trust workflows, using workflow/ref-scoped keys where
supported or disabling these caches and retaining dependency installation
correctness.

Source: Linters/SAST tools

- run: cargo build --release --locked

- name: Fetch and verify ffmpeg
shell: pwsh
env:
FFMPEG_URL: ${{ vars.FFMPEG_URL }}
FFMPEG_SHA256: ${{ vars.FFMPEG_SHA256 }}
run: |
# The hash is a repository variable rather than a committed constant
# because the upstream URL is a moving one. An empty value here fails
# inside the fetch script as "no expected hash", which reads like a
# bug in the script rather than a setting nobody set.
if (-not $env:FFMPEG_SHA256) {
Write-Error "vars.FFMPEG_SHA256 is not set — the installer bundles ffmpeg and the download is verified against it. Set it (with vars.FFMPEG_URL) in the repository settings."
exit 1
}
node scripts/fetch-ffmpeg.mjs

- name: Build the installer
shell: pwsh
env:
Expand Down Expand Up @@ -92,18 +134,64 @@ jobs:
Out-File -FilePath "$dir/SHA256SUMS.txt" -Encoding utf8
Get-Content "$dir/SHA256SUMS.txt"

# 10.4 — the manifests, generated from the tag and the hash of the
# installer that is about to be released. A manifest carrying last
# release's hash fails as "the download is corrupt" rather than
# "somebody forgot to update a file".
- name: Generate the winget and Scoop manifests
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
run: |
# By name, not by position: the collect step allows more than one
# `.exe`, and taking the first line would silently publish some other
# file's hash.
$version = $env:TAG.TrimStart("v")
$wanted = "open-wiki-Setup-$version.exe"
$line = Get-Content "apps/desktop/release/SHA256SUMS.txt" |
Where-Object { $_ -match [regex]::Escape($wanted) + '$' } |
Select-Object -First 1
if (-not $line) {
Write-Error "no checksum line for $wanted — the installer is not named what the manifests will point at"
exit 1
}
$sha = $line.Split(" ")[0]
node scripts/ci/package-manifests.mjs "$env:TAG" $sha "dist/manifests"
Copy-Item "dist/manifests/scoop/open-wiki.json" "apps/desktop/release/open-wiki.json"
Compress-Archive -Path "dist/manifests/*" -DestinationPath "apps/desktop/release/manifests.zip"

# The reversible step first. A GitHub release can be deleted and made
# again; an npm version is permanent, so publishing to npm before the
# release existed meant any later failure left `open-wiki@X` on the
# registry with no installer to go with it and no way to re-run the tag.
- name: Publish the release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
apps/desktop/release/*.exe
apps/desktop/release/SHA256SUMS.txt
apps/desktop/release/open-wiki.json
apps/desktop/release/manifests.zip
generate_release_notes: true
# A tag carrying a suffix — v0.1.0-beta.1 — is not a stable release.
prerelease: ${{ contains(github.ref_name, '-') }}
fail_on_unmatched_files: true

# 10.3 — the second artifact, from the same tag. Provenance is on, and
# it is not decoration: a product that verifies the SHA256 of its own
# ffmpeg cannot ship a `npx` entry point that verifies nothing.
- name: Publish the CLI to npm
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# A prerelease must not become what `npx open-wiki` resolves to.
$pre = node scripts/ci/release-version.mjs --prerelease "$env:TAG"
$tag = if ($pre) { "next" } else { "latest" }
Write-Host "publishing to the '$tag' dist-tag"
pnpm --filter open-wiki publish --access public --no-git-checks --tag $tag

# workflow_dispatch builds without a tag: useful for checking the packaging
# still works without publishing anything.
- name: Upload the installer as a build artifact
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,10 @@ vite.config.ts.timestamp-*
vendor/ffmpeg/
# Rust build output (crates/)
/target/

# Bundled output. `build-cli.mjs` and `build-main.mjs` produce these; the
# installer and the npm package carry them, and neither is committed.
packages/cli/build/
apps/desktop/build/
apps/desktop/release/
dist/
117 changes: 117 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# open-wiki

A project's documentation, as a wiki the AI agent already has open.

Today a project's documentation is scattered: an architecture PDF, a
requirements `.docx`, decisions that exist only in a recorded meeting. None of
it answers _what is the current state of project X and how did we get here_, and
none of it is read by an agent without somebody pasting it into a prompt by
hand.

open-wiki does three things and refuses the rest. It **takes in sources** — a
file or a recording — and reduces them to text with provenance anchors. It
**stores the wiki** as validated markdown. And it **lives inside the project
directory**, which is where the harness is already working, so reading it needs
no protocol at all.

**The application calls no LLM.** Reading the source text, applying the
convention and writing the pages is the agent's job. The application does not
write content; it validates what comes in and records everything that changes.

Windows 10/11. Apache-2.0.

## Install

The installer from [Releases](https://github.com/protonspy/open-wiki/releases).
Every release publishes a `SHA256SUMS.txt` beside it — check it.

Or through Scoop, from the manifest that release attaches:

```powershell
scoop install https://github.com/protonspy/open-wiki/releases/latest/download/open-wiki.json
```

A winget manifest is generated and attached to every release as well
(`manifests.zip`), quoting the same hash. **It is not in the winget community
repository yet** — submitting it is a pull request to `microsoft/winget-pkgs`
that nothing here opens for you, so `winget install protonspy.open-wiki` does
not work today.

For the CLI alone, with nothing installed:

```
npx open-wiki init
```
Comment on lines +42 to +44

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

Add a language to the fenced code block.

markdownlint (MD040) flags this fence for missing a language identifier.

📝 Proposed fix
-```
+```bash
 npx open-wiki init
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 42-42: 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 `@README.md` around lines 42 - 44, Update the fenced code block containing “npx
open-wiki init” in README.md to specify the bash language identifier, preserving
the command and surrounding documentation unchanged.

Source: Linters/SAST tools


### The SmartScreen warning

The installer is **not code-signed**, so Windows SmartScreen will say
"Windows protected your PC" and name an unknown publisher. That is accurate:
there is no certificate behind this build. Verify the SHA256 against
`SHA256SUMS.txt` on the release page before choosing **More info → Run anyway**,
and if the hash does not match, do not run it. A certificate costs money and
proves the publisher is _someone_, not that the software is safe; the hash is
the thing that actually tells you the bytes are the ones that were built.

## Using it

`ow` in a project directory opens the application scoped to it, the way `code .`
does. `ow init` scaffolds `raw/`, `wiki/`, `.state/`, the convention as skills,
and a short `CLAUDE.md`. Then you talk to your agent in that same directory.

## Recording a meeting

This application records audio, from your microphone **and** from what your
computer is playing — which in a call is everybody else.

**Telling the other people in the call that you are recording is your
responsibility, and in many places it is the law.** Recording a conversation
without consent is a criminal offence in a number of jurisdictions and grounds
for a civil claim in more; where one-party consent is enough it is still, at
minimum, a thing people are entitled to know. This software will not ask them
for you, and it has no way to tell whether you did.

A recording indicator is in the window whenever capture is running, and it is
deliberately hard to miss — the failure it exists to prevent is somebody
forgetting it is on and ending up with a recording of a conversation the other
people in it believe ended.

## Committing a wiki

The project directory is usually a git repository, and that is your business —
this application neither reads nor writes one. But it is worth saying plainly
what committing a wiki does:

**Everything in `wiki/` goes to everyone with repository access.** So does
everything in `raw/` except what the generated `.gitignore` excludes — recorded
audio and `.state/` are out by default, and committing them is opting in. That
default is not tidiness: `.state/` holds every page as it was before each write,
which is where a redaction survives the redaction.

A meeting transcript is a verbatim record of what people said, including the
part they would not have written down. Read a source before you commit it.

**The transcription credential is never in the project directory.** It lives in
the application's own data directory, keyed by project path, unconditionally —
because `git init` a week later turns a conditional rule into a leak.

## What it does not do

Extraction or page-writing by the application; chat inside the application; a
hosted service, accounts or telemetry; a block editor; real-time collaboration;
embeddings or a vector store; versioning (your git is welcome to it); macOS and
Linux; real-time transcription or a bot that joins the meeting.

The reasoning for each is in [`docs/adr/`](docs/adr/), and the shape of the
whole thing is in [`plans/open-wiki.md`](plans/open-wiki.md).

## Building it

See [`.claude/rules/project.md`](.claude/rules/project.md) for the commands.
It is a pnpm workspace plus one Rust crate — the audio recorder, which is the
only thing here not written in TypeScript
(`adr:0014-typescript-everywhere-except-audio-capture`).

## Licence

Apache-2.0. See [LICENSE](LICENSE).
Loading
Loading