diff --git a/README.md b/README.md index 5ebe604..6c2c16d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# search +# Search W-135 S-3 (CTX-0003) Lua policy package for Bitty scrollback search: bounded snapshot queries over the public history API only. Accepted contracts are @@ -9,6 +9,134 @@ is bitty-plugin-sdk at `3ea8e58` (merged `main`; see the pin note below). Core host evidence is W-143 (bitty `0d50b436`) plus the history-read host (bitty `76fa42d6`). +## Overview + +Search runs bounded, scoped snapshot queries over already-persisted transcript +history, then keeps one result page as presentation state for cursor +navigation. It never mutates Terminal Truth, never streams, and never retries +a denial silently: a failed refresh keeps the previous page intact. Record +bodies are untrusted observation text, displayed verbatim and never executed. + +## Install + +Prerequisites: + +- Bitty `>=0.0.24,<0.1` with Plugin API `^1.0` (the manifest `[compat]` + range). +- Opt-in capture enabled for transcript history. Without it, queries return + `E_HISTORY_CAPTURE_DISABLED`. +- Consent to `history.transcript.read`. The plugin requests no clipboard, + filesystem, process, network, overlay, or terminal authority. + +Install from a local checkout: + +```sh +git clone https://github.com/bitty-terminal/search.git +cd search +bitty plugin install "$PWD" +bitty plugin enable bitty-terminal.search +bitty plugin list +``` + +`bitty plugin install ` stages the package under the XDG data store and +records the capability you consent to; it executes no plugin code (`--yes` +approves non-interactively). Verify activation with `bitty plugin list`: the +entry must show `bitty-terminal.search`, version `0.0.1`, source `local-path`, +enabled, and the `history.transcript.read` grant. +`bitty plugin info bitty-terminal.search` prints the recorded grant set. + +## Commands + +Every command id is qualified with the plugin id. All commands return a table +with `ok` and `code`. None of the commands has a default keybinding. + +| Command | What it does | Arguments | Default keybinding | +| ------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `bitty-terminal.search:search` | Runs one bounded transcript search and replaces the cached page on success. | `panel`, `workspace` (at least one required), `needle` (1–256 bytes), `row_count` (integer 1–16), `max_bytes` (integer 1–4096). | None | +| `bitty-terminal.search:next` | Moves the cursor to the next cached match; no host call. | None | None | +| `bitty-terminal.search:prev` | Moves the cursor to the previous cached match; no host call. | None | None | +| `bitty-terminal.search:clear` | Drops the cached result page. | None | None | +| `bitty-terminal.search:copy` | Exports the current match through the clipboard gate. | None | None | + +`copy` fails closed with `E_CAPABILITY_DENIED` in this release: the manifest +declares no `clipboard.write` grant, and the history grant never implies +export. The command stays declared so a later grant can enable it under its +own review. + +Below the table, `search` replaces the cached page only on success; a denial +keeps the prior page. `next` and `prev` clamp at the ends (`AT_LAST` and +`AT_FIRST`) and report `NO_RESULTS` when no page is cached. A query with no +`panel` or `workspace` is denied by the host with `E_HISTORY_SCOPE_MISMATCH`. + +## Configuration + +Settings live in `$XDG_CONFIG_HOME/bitty/plugins/bitty-terminal.search.lua`. +Values are read at activation and pinned to that plugin generation. + +| Key | Type | Default | Description | Reload | +| ----------- | ----- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `row_count` | `int` | `10` | Rows per command query when the caller omits `row_count`. Accepted 1–16; an out-of-window configured value falls back to `10`. | Restart | +| `max_bytes` | `int` | `4096` | Byte budget per command query when the caller omits `max_bytes`. Accepted 1–4096; an out-of-window configured value falls back to `4096`. | Restart | + +Example: + +```lua +-- $XDG_CONFIG_HOME/bitty/plugins/bitty-terminal.search.lua +return { + row_count = 10, + max_bytes = 4096, +} +``` + +`Restart` means settings are resolved during activation: there is no settings +watcher or hot-reload path, so edit the file and restart Bitty. A denied +settings read does not abort activation; the plugin uses the documented +default and records one bounded `SETTINGS_DEGRADED` diagnostic. Configured +values never widen a query: explicit arguments still travel to the host, +which denies over-bound requests. + +## Keybindings + +Search registers no default keybindings. Bind a chord in +`$XDG_CONFIG_HOME/bitty/init.lua`: + +```lua +return { + keymaps = { + { + chord = "ctrl+n", + action = "bitty-terminal.search:next", + context = "global", + }, + }, +} +``` + +`next`, `prev`, and `clear` are safe to bind bare because they operate on the +cached page. `search` needs a `panel` or `workspace` argument (and a needle), +so invoke it through a caller that supplies them; a bare argument-free binding +denies with `E_HISTORY_SCOPE_MISMATCH`. + +## Troubleshooting + +| Code | Class | Cause | Remedy | +| ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `E_SETTINGS_UNDECLARED` | `validation` | A settings read named a key absent from the manifest `[settings]` declarations. | Install a plugin version whose declarations match the running build. Only `row_count` and `max_bytes` are declared. | +| `E_INCOMPATIBLE` | `validation` | The running host is outside `>=0.0.24,<0.1`, or Plugin API is not `^1.0`. | Upgrade Bitty or install a Search release whose `[compat]` range covers your host. | +| `E_CAPABILITY_DENIED` | `runtime` | The transcript grant is missing, revoked, or expired, or `copy` ran without a `clipboard.write` grant. | Re-grant `history.transcript.read` and restart. For `copy`, this release intentionally has no clipboard grant; use the host's own copying instead. | +| `E_HISTORY_MISSING_GRANT` | `runtime` | No per-plugin transcript read grant is recorded (deny by default). | Approve `history.transcript.read`. | +| `E_HISTORY_REVOKED_GRANT` | `runtime` | The grant existed but was revoked or expired after activation. | Re-grant it and restart Bitty; a live generation keeps the grant bound at activation. | +| `E_HISTORY_SCOPE_MISMATCH` | `runtime` | `search` named no panel/workspace extent, or the grant does not cover the named scope. | Pass a `panel` or `workspace` inside the granted scope. A bare argument-free binding cannot search. | +| `E_HISTORY_OVER_BOUND` | `runtime` | `row_count` exceeds 16, `max_bytes` exceeds 4096, or the needle is empty or longer than 256 bytes. | Stay inside the caps. The plugin never clamps an explicit over-bound request. | +| `E_HISTORY_CAPTURE_DISABLED` | `runtime` | Opt-in transcript capture is off, so nothing was persisted to search. | Enable transcript capture, then query again. | +| `E_HISTORY_SAFE_MODE` | `runtime` | Bitty runs in safe mode, which reads no transcript history. | Restart without safe mode to use Search. | +| `E_HISTORY_TRUST_DENIED` | `runtime` | The caller's trust level or domain admits no standing access. | Query from a trusted local context; unknown trust levels and domains deny rather than default. | +| `E_HISTORY_UNAVAILABLE` | `runtime` | The content was purged or expired, or the host has no transcript namespace. | Treat it as typed unavailability, not an empty result. The previous page is kept. | + +`E_DISABLED` means the activation-time compatibility gate rejected this +generation, and `NO_HOST` means the module was loaded without the host bridge +for tests or headless use; neither state exposes commands. + ## Scope - `bitty-plugin.toml`: plugin id `bitty-terminal.search`, version `0.0.1`, @@ -84,3 +212,16 @@ block and the harness denies undeclared reads with the same `E_SETTINGS_UNDECLARED` code the Core host raises, so the activation regression is reproducible locally. Derivation of every spelling and bound used here is recorded in `docs/lua-defs.md` and `docs/mock-host.md` of the SDK. + +## Development + +Run the repository gates through the justfile: + +```sh +just install +just check +``` + +`just check` includes `just docs-check`, which fails when the README +Configuration table omits a declared `[settings]` key, documents an undeclared +key, or drifts from the declared type or default. diff --git a/justfile b/justfile index a8d072b..5f9f149 100644 --- a/justfile +++ b/justfile @@ -66,6 +66,7 @@ check: just manifest just lua just lua-control + just docs-check just test # Install pinned dev dependencies from bun.lock. This is the only gate step @@ -96,6 +97,10 @@ lua: deps lua-control: deps @! bunx --bun luaparse@{{luaparse_pin}} --quiet --code 'local =' +# Fail when the README Configuration table drifts from the manifest [settings]. +docs-check: + bun tests/check-docs.mjs + # Run the search behavior suite: `lua/search/init.lua` executes in a # pinned Lua VM (wasmoon) against the pinned SDK mock host, which owns every # capability, registration, scope, bound, capture, and denial check. diff --git a/tests/check-docs.mjs b/tests/check-docs.mjs new file mode 100644 index 0000000..f8ea1c8 --- /dev/null +++ b/tests/check-docs.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env bun +// User-docs gate: the README configuration table must describe exactly the +// keys declared under [settings] in bitty-plugin.toml, with the declared type +// and default. Missing keys, extra keys, and value drift fail closed. +// +// The check also pins the user-facing section order: Overview, Install, +// Commands, Configuration, Keybindings, Troubleshooting must be the first six +// H2 sections, before developer/architecture material. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const MANIFEST = join(ROOT, "bitty-plugin.toml"); +const README = join(ROOT, "README.md"); + +const REQUIRED_SECTIONS = [ + "Overview", + "Install", + "Commands", + "Configuration", + "Keybindings", + "Troubleshooting", +]; + +const problems = []; + +function fail(message) { + problems.push(message); +} + +function read(path) { + try { + return readFileSync(path, "utf8"); + } catch (error) { + console.error(`docs-check: cannot read ${path}: ${error.message}`); + process.exit(1); + } +} + +// Split Markdown into H2 sections while ignoring headings inside fenced code. +function h2Sections(text) { + const lines = text.split("\n"); + const sections = []; + let current = null; + let fence = null; + for (const line of lines) { + const fenceMatch = line.match(/^\s*(```+|~~~+)/); + if (fenceMatch) { + if (fence === null) { + fence = fenceMatch[1][0]; + } else if (fenceMatch[1][0] === fence) { + fence = null; + } + } + if (fence === null) { + const heading = line.match(/^##\s+(.+?)\s*$/); + if (heading) { + current = { title: heading[1], lines: [] }; + sections.push(current); + continue; + } + } + if (current) { + current.lines.push(line); + } + } + return sections; +} + +// Parse the single-line `[settings]` declarations used by plugin manifests. +function declaredSettings(text) { + const declared = new Map(); + let inSettings = false; + for (const [index, raw] of text.split("\n").entries()) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) { + continue; + } + const table = line.match(/^\[([^\]]+)\]$/); + if (table) { + inSettings = table[1] === "settings"; + continue; + } + if (!inSettings) { + continue; + } + const match = line.match( + /^([a-z][a-z0-9_]*)\s*=\s*\{\s*type\s*=\s*"([a-z-]+)"\s*,\s*default\s*=\s*(.+?)\s*\}$/, + ); + if (!match) { + fail( + `bitty-plugin.toml:${index + 1}: [settings] entry is not the expected "key = { type = ..., default = ... }" shape: ${line}`, + ); + continue; + } + const [, key, type, defaultLiteral] = match; + if (declared.has(key)) { + fail(`bitty-plugin.toml:${index + 1}: duplicate [settings] key "${key}"`); + continue; + } + declared.set(key, { type, defaultLiteral }); + } + return declared; +} + +function cleanCell(cell) { + return cell.replace(/^`|`$/g, "").trim(); +} + +// Extract documented settings rows. A settings row's first cell is exactly a +// single backticked key; requirement tables (for example `git` on PATH) do not +// match because they carry text outside the code span. +function documentedSettings(sectionLines) { + const documented = new Map(); + for (const [index, line] of sectionLines.entries()) { + const match = line.match(/^\|\s*`([a-z][a-z0-9_]*)`\s*\|(.*)\|\s*$/); + if (!match) { + continue; + } + const [, key, rest] = match; + const cells = rest.split("|").map((cell) => cell.trim()); + if (cells.length !== 4) { + fail( + `README.md Configuration row for "${key}" must have 5 columns (Key, Type, Default, Description, Reload), got ${cells.length + 1}`, + ); + continue; + } + if (documented.has(key)) { + fail(`README.md Configuration table repeats key "${key}"`); + continue; + } + documented.set(key, { + line: index, + type: cleanCell(cells[0]), + defaultLiteral: cleanCell(cells[1]), + description: cells[2], + reload: cells[3], + }); + } + return documented; +} + +const manifestText = read(MANIFEST); +const readmeText = read(README); +const sections = h2Sections(readmeText); +const titles = sections.map((section) => section.title); + +const firstSix = titles.slice(0, REQUIRED_SECTIONS.length); +if (firstSix.join("\u0000") !== REQUIRED_SECTIONS.join("\u0000")) { + fail( + `README.md must start with these H2 sections in order: ${REQUIRED_SECTIONS.join(", ")}; found: ${firstSix.join(", ") || "(none)"}`, + ); +} + +const configuration = sections.find( + (section) => section.title === "Configuration", +); +if (!configuration) { + fail("README.md is missing the ## Configuration section"); +} + +const declared = declaredSettings(manifestText); +const documented = configuration + ? documentedSettings(configuration.lines) + : new Map(); + +for (const [key, entry] of declared) { + const row = documented.get(key); + if (!row) { + fail(`README.md Configuration is missing declared key "${key}"`); + continue; + } + if (row.type !== entry.type) { + fail( + `README.md Configuration type for "${key}" is "${row.type}", manifest declares "${entry.type}"`, + ); + } + if (row.defaultLiteral !== entry.defaultLiteral) { + fail( + `README.md Configuration default for "${key}" is "${row.defaultLiteral}", manifest declares "${entry.defaultLiteral}"`, + ); + } + if (row.description === "") { + fail(`README.md Configuration description for "${key}" is empty`); + } + if (row.reload === "") { + fail(`README.md Configuration reload column for "${key}" is empty`); + } +} + +for (const key of documented.keys()) { + if (!declared.has(key)) { + fail( + `README.md Configuration documents "${key}", which is not declared in bitty-plugin.toml [settings]`, + ); + } +} + +if (declared.size === 0) { + if (!/No declared settings/.test(configuration?.lines.join("\n") ?? "")) { + fail( + 'README.md ## Configuration must state "No declared settings." when the manifest declares none', + ); + } + if (documented.size > 0) { + fail( + "README.md ## Configuration lists settings rows but the manifest declares none", + ); + } +} + +if (problems.length > 0) { + console.error("docs-check: user documentation does not match the manifest"); + for (const problem of problems) { + console.error(` - ${problem}`); + } + process.exit(1); +} + +const summary = + declared.size === 0 + ? "no [settings] keys" + : `${declared.size} [settings] key(s): ${[...declared.keys()].join(", ")}`; +console.log(`docs-check: README configuration matches manifest (${summary})`);