buhtig 0.1.0-alpha.4 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,8 +93,15 @@ proxy in front of it before doing that.
93
93
  ### 📝 Review without leaving the keyboard
94
94
  - **Inline comments** authored by clicking the diff gutter land in a **local change-request** — a
95
95
  draft you can edit, retype, delete, reclassify (plain comment ↔ change-request), or **send now**.
96
+ - **Draft comments survive the author pushing.** Each one stores the same content fingerprint a
97
+ bookmark or a review finding does, so when the diff on screen moves on it resolves to `active`,
98
+ `moved` (re-anchored, and published at the line it moved to) or `stale` — greyed, with its
99
+ captured snippet, and never dropped from the diff. A lost comment is re-anchored by clicking
100
+ **Re-anchor** and then the gutter line it belongs on.
96
101
  - **Publish** the whole batch as a single GitHub review with a verdict (**Approve / Request changes /
97
102
  Comment**), or one-click **Approve** from the header (guarded against approving a stale head).
103
+ A comment whose code the head still has publishes silently; only one nothing can place stops to
104
+ ask.
98
105
  - **Every comment surface has the same powers.** A comment is the same object whether you meet it
99
106
  inline in the diff, in a Conversation thread, or in the timeline, so all three render one shared
100
107
  card: **edit**, **delete** (always behind a confirmation — GitHub has no undelete), **deal to
@@ -107,6 +114,13 @@ proxy in front of it before doing that.
107
114
  - **Conversation tab** hoists the top blocking items (change requests + unresolved threads) into a
108
115
  sticky rail so you can clear the path to merge fast; reply to threads, resolve/unresolve, post PR
109
116
  comments, and edit the description — all with a **side-by-side markdown editor**.
117
+ - **`@` completes the people already on the PR.** Typing `@` in any composer — a thread reply, a PR
118
+ comment, a review body, the description, a finding — opens a menu of the PR's participants first
119
+ and the repository's assignable users after them, under the caret rather than under the box.
120
+ Boundaries are GitHub's, so `alice@example.test` and a handle in a URL open nothing. Everywhere
121
+ markdown is *rendered*, a mention is a link to the person or team — and a mention of **you** is
122
+ drawn as a marked chip, which is what makes the one line in a long thread that is asking you
123
+ something findable.
110
124
  - **Open in `$VISUAL`** per file (at the changed line) or for the whole PR worktree, plus a
111
125
  **worktree** affordance — collapsed to one chip in the header, expanding on hover, and reachable as
112
126
  `Alt+W` → `t`/`a`/`s` — for a **terminal**, a **coding agent**, or an explicit **worktree setup**
@@ -198,6 +212,47 @@ page. **An edit goes back to wherever the action was declared**: a file-declared
198
212
  in its own file, comments and untouched fields left alone, so the file you keep in the repo stays the
199
213
  thing that decides. Saving it as this install's override instead is a separate, named button.
200
214
 
215
+ ### Your own quick prompts
216
+
217
+ Every ✦ offers a row of chips — the sentences nobody wants to type for the fortieth failing job. They
218
+ are buhtig's to begin with and yours to change, keyed by the surface they appear on:
219
+
220
+ ```yaml
221
+ quickPrompts:
222
+ pr.thread:
223
+ # naming a built-in's id PATCHES it — reword it, or switch it off
224
+ - id: draft-reply
225
+ text: 'Draft a reply in my voice: short, no apologies. Do not post it.'
226
+ - id: address
227
+ enabled: false
228
+
229
+ # anything else is a new chip, and needs both a label and a text
230
+ - id: ask-for-tests
231
+ label: ask for tests
232
+ text: Draft a polite reply asking the author to add a regression test.
233
+
234
+ selection:
235
+ builtins: off # drop buhtig's own for this surface entirely
236
+ prompts:
237
+ - id: whose-is-it
238
+ label: whose is it?
239
+ text: Who last touched the selected lines, and why? Change nothing.
240
+ ```
241
+
242
+ The contexts are `pr.chat`, `pr.description`, `pr.blockers`, `pr.thread`, `pr.commit`, `pr.job`,
243
+ `pr.todo`, `changes.diff`, `review.run`, `review.summary`, `review.finding`, `review.title`,
244
+ `review.body-finding`, `review.body-why`, `review.body-fix`, `review.inventory`, `selection`, `toast.error` and
245
+ `toast.warning`.
246
+
247
+ Add, reword, hide and un-hide them from the ✦ itself — the sliders chip beside the row — or keep what
248
+ you just typed as a chip with the bookmark button beside Send. An id that matches nothing buhtig ships is reported
249
+ as a warning rather than failing the file, so a release that renames a preset cannot break a config
250
+ that switched the old name off.
251
+
252
+ Unlike `customActions`, this one **is** allowed in `.buhtig/config.yaml`: a prompt is prose an agent
253
+ is handed, like a facet brief, and it is gated by the same trust — the weakest layer, which any of
254
+ your own can reword or switch off.
255
+
201
256
  ---
202
257
 
203
258
  ## Architecture
@@ -249,18 +304,54 @@ pnpm install --frozen-lockfile
249
304
  # run in dev (web + server, hot reload)
250
305
  pnpm dev
251
306
 
252
- # or build + start the production server
253
- pnpm build
307
+ # or start the production server (builds first)
254
308
  pnpm start
255
309
  ```
256
310
 
257
- The server listens on **port 10003** by default (override with `BUHTIG_PORT`). Open the web app,
258
- pick a repo you have access to, and start reviewing.
311
+ The server listens on **port 10003** by default. Open the web app, pick a repo you have access to,
312
+ and start reviewing.
313
+
314
+ `BUHTIG_LISTEN` moves it, in any of the forms `--listen` accepts, and the Vite dev proxy follows it
315
+ to the same address:
316
+
317
+ ```bash
318
+ BUHTIG_LISTEN=4321 pnpm dev # a port
319
+ BUHTIG_LISTEN=127.0.0.1:4321 pnpm dev # host:port
320
+ BUHTIG_WEB_PORT=4322 pnpm dev # move the dev web server too, for a second checkout
321
+ ```
259
322
 
260
323
  `buhtig doctor` reports whether this machine is set up — Node, `git`, the config file, and each of
261
324
  the three separable auth questions (is the token source reachable, does it yield a token, does
262
325
  GitHub accept it). `buhtig doctor --json` is the thing to paste into a bug report.
263
326
 
327
+ ### Trying it without connecting a repository
328
+
329
+ `/demo` is a guided tour of the product against a fictional repository. Nothing is sent to GitHub,
330
+ and no repository of yours is touched. From the tour, **Open in the full app** leaves it for the real
331
+ pull-request pages — list, conversation, changes, checks, dashboard, stack — still on the fixture.
332
+
333
+ That works two ways, and which one you get depends only on what is behind the page:
334
+
335
+ - **With a buhtig running**, a *throwaway second instance* is launched (`--demo`): its own config,
336
+ database, clones and fake-GitHub state in a tmp dir, never touching your install. Real git, real
337
+ diffs, real worktrees.
338
+ - **With nothing running** — the app served as static files — the browser answers `/api` itself from
339
+ the same fixture, and the diffs are computed in the tab. What you change (viewed marks, drafts,
340
+ triaged findings) is kept in that browser until you press **Reset demo data**.
341
+
342
+ So the built web assets are a complete, hostable demo site with no server at all:
343
+
344
+ ```bash
345
+ pnpm --filter @buhtig/web build # → packages/web/dist
346
+ node scripts/serve-static.mjs # serve it locally on :10005 to check
347
+ ```
348
+
349
+ Upload `packages/web/dist` anywhere that serves static files with an SPA fallback (unknown paths →
350
+ `index.html`). The app probes `/api/health` once at boot: an answer means a real install and nothing
351
+ changes; HTML or a 404 means a file host, and it switches to the browser-local fixture. A *failed*
352
+ connection is read as "your server is down", not as a demo — a hosted demo must never be what you
353
+ see when your own instance restarts. `?demo=local` forces the browser-only mode anywhere.
354
+
264
355
  ---
265
356
 
266
357
  ## Running the server
@@ -278,6 +369,15 @@ buhtig serve --listen http://0.0.0.0:80 --allow-remote # URL form, same thing
278
369
  `BUHTIG_LISTEN` is the same string. `BUHTIG_PORT` still works and still means a port; where both are
279
370
  set, `BUHTIG_LISTEN` wins.
280
371
 
372
+ From a source checkout, `pnpm cli` is this same CLI — it builds what the CLI depends on, then runs
373
+ `packages/cli/src` under `tsx`, so every flag and subcommand works without a global install:
374
+
375
+ ```bash
376
+ pnpm cli serve --listen 127.0.0.1:10004
377
+ pnpm cli doctor --json
378
+ pnpm cli service status
379
+ ```
380
+
281
381
  Two things are refused by name rather than left to fail later. **`https://`** — buhtig does not
282
382
  terminate TLS, and accepting it would listen in plaintext on an address someone believes is
283
383
  encrypted. **Unix sockets** — a socket has no address to hand the agents buhtig launches, and every
@@ -315,8 +415,8 @@ Two platform differences the CLI states rather than hides:
315
415
  system-domain answer is a LaunchDaemon, which runs as root — wrong for a tool authenticating as
316
416
  you). It also appears under System Settings → Login Items, where switching it off stops it.
317
417
 
318
- Installing refuses to overwrite a `buhtig.service` this CLI did not write `install.sh` creates one
319
- — unless you pass `--force`. Set `BUHTIG_SERVICE_UNIT` to run a second instance under its own name.
418
+ Installing refuses to overwrite a `buhtig.service` this CLI did not write unless you pass `--force`.
419
+ Set `BUHTIG_SERVICE_UNIT` to run a second instance under its own name.
320
420
 
321
421
  ---
322
422
 
@@ -397,13 +497,84 @@ window at a time). Where the CLI supports it that briefing goes in as an extra s
397
497
 
398
498
  ### Settings, and where a value comes from
399
499
 
400
- Every option lives in up to three layers, and the settings pages show all of them with the one in
401
- force marked: buhtig's own store (this install), the repo's `.git/buhtig.yaml` (travels with the
402
- clone), and `config.yml`. You pick which layer a save lands in. Writes to a YAML file keep its
403
- comments and key order.
500
+ Every option lives in up to four layers, and the settings pages show all of them with the one in
501
+ force marked. They are named by **reach**, not by filename, because reach is what decides which one
502
+ wins:
503
+
504
+ | badge | file | reach |
505
+ | --- | --- | --- |
506
+ | `this install` | buhtig's own store | this machine only |
507
+ | `this clone` | `.git/buhtig.yaml` | this clone; not versioned, unreachable by any branch |
508
+ | `your config.yml` | `~/.config/buhtig/config.yml` | this machine, every repo |
509
+ | `in the repo` | `.buhtig/config.yaml` | **committed** — everyone who clones it |
510
+
511
+ Precedence runs top to bottom. Everything above `in the repo` is something *you* wrote; the committed
512
+ layer is what the project ships, so it is a default you can always override. The inverse ordering
513
+ would let a repository you merely read pull requests from configure your machine.
514
+
515
+ You pick which layer a save lands in. Writes to a YAML file keep its comments and key order.
404
516
 
405
517
  `/settings` holds the install-wide settings; each repo's gear holds its own and links there.
406
518
 
519
+ ### The repo's own committed config
520
+
521
+ `.buhtig/` at the repo root is the layer that travels:
522
+
523
+ ```
524
+ .buhtig/
525
+ config.yaml # the review block, ticket links, the preferred agent
526
+ review-facets/ # the facet taxonomy
527
+ ```
528
+
529
+ Its schema is the same one the layers above it use, **minus everything that would let a pull request
530
+ reach off its own tree**. `hooks`, `customActions`, `worktreePathTemplate` and `trust` are *rejected*
531
+ rather than dropped — the first two are commands this machine would run, the third is a location on
532
+ it, and the fourth would let a repository declare itself trusted. A key buhtig has no meaning for is
533
+ still stripped rather than fatal, exactly as in the other layers.
534
+
535
+ #### Trust
536
+
537
+ A committed config is content a pull request can propose changes to, and facet prose is the brief a
538
+ review subagent is handed. So buhtig reads `.buhtig/` only when the content is attributable to
539
+ somebody this install trusts, at a level you set per repo:
540
+
541
+ ```yaml
542
+ # config.yml — the default for every repo
543
+ trust:
544
+ repoConfig: approved
545
+
546
+ acme/webapp:
547
+ trust:
548
+ repoConfig: trunk
549
+ ```
550
+
551
+ | level | what it means |
552
+ | --- | --- |
553
+ | `none` | `.buhtig/` is never read for this repo |
554
+ | `approved` *(default)* | nothing is used until you have seen it; a change comes back as a diff to approve |
555
+ | `trunk` | trunk needs no approval — but anyone who can push to trunk can change how your reviews run |
556
+ | `any-branch` | no prompts at all; only for repos where every branch is yours |
557
+
558
+ Content is read **at trunk** while your checkout has no uncommitted changes under `.buhtig/`, which
559
+ keeps the answer the same whatever branch you happen to have out. A dirty root switches the read to
560
+ your working tree, because that is the only reason to prefer it: you are authoring the taxonomy, and
561
+ showing you trunk's copy while you type would be useless. **A pull request's own worktree is never a
562
+ source.**
563
+
564
+ Beyond the level, two things count as your own work without being approved: **uncommitted local
565
+ edits** (no pull request can produce those), and commits **signed by your own key** — matched on the
566
+ `user.signingkey` fingerprint, never on an author address, since a signature proves a key and not a
567
+ claimed identity. Attribution is per file, so one local edit never vouches for a committed rewrite
568
+ beside it.
569
+
570
+ Three refusals hold at every level, including `any-branch`: a **committed symlink or submodule** under
571
+ `.buhtig/` (a read-anywhere primitive nobody spots in a markdown diff), two paths differing only by
572
+ case, and a range git cannot walk. A deletion is attributed like an edit — removing the facet that
573
+ would have caught the bug is the quietest way to suppress a review.
574
+
575
+ Every run records what judged it: the commit, the `.buhtig` tree OID, the layer, and the trust
576
+ verdict that admitted it.
577
+
407
578
  ### Structured review
408
579
 
409
580
  An agent-authored review is a **resource**, not a directory of markdown: a run over a PR at a head
@@ -413,9 +584,15 @@ The facet taxonomy is **per-repo configuration**, resolved from a tree of facet
413
584
  review skill already reads — so one project has a `TENANCY` facet and another has none, and adding a
414
585
  facet is a markdown file rather than a code change.
415
586
 
416
- The taxonomy lives in `.git/buhtig/review-facets/` inside the git directory rather than the tree,
417
- so no branch, merge or pull request can rewrite the rules of its own review, and every linked
418
- worktree sees one taxonomy. There is nothing to configure to place it.
587
+ The taxonomy lives in one of two places. `.buhtig/review-facets/` is **committed**: it travels with
588
+ the repository, so everyone who clones it reviews against the same dimensions and because a pull
589
+ request can propose changes to it, it is read only when [trust](#trust) says the content is
590
+ attributable. `.git/buhtig/review-facets/` is the older home: unreachable by any branch and therefore
591
+ unconditionally trusted, but local to one clone.
592
+
593
+ The committed one wins **once it is trusted**. Until then this clone's copy keeps serving, so a
594
+ colleague landing a `.buhtig/` cannot take a working taxonomy dark. Whichever is not in force is
595
+ reported as present and unused rather than dropped from the answer.
419
596
 
420
597
  ```yaml
421
598
  acme/webapp:
@@ -448,22 +625,42 @@ inheriting its parent's priority and slug, and `slug` is a *namespace* several f
448
625
  identity of a unit of review work is its **file path**.
449
626
 
450
627
  Reviews are driven by the `buhtig-review` skill, which buhtig **serves** rather than ships into your
451
- repositories — `curl localhost:10003/api/skills/buhtig-review`. What you install is a ~25-line
452
- bootstrap that knows how to fetch the rest, once, at user level:
628
+ repositories — `curl localhost:10003/api/skills/buhtig-review`. An agent holding the **MCP server**
629
+ asks for it by name (`skill({ skill: "buhtig-review", doc: "SKILL" })`) and reads the instructions
630
+ rendered as tool calls; an agent with only a shell reads the same instructions rendered as `curl`,
631
+ and neither is shown the other's version. The tool set is generated from one declared catalogue
632
+ (`AGENT_CALLS` in `@buhtig/shared`) with schemas derived from the OpenAPI document. It is curated:
633
+ there is no tool to publish a review, merge or close a PR, or write settings. Dismissing a finding
634
+ and staging one for posting *are* there, because you will want to ask an agent to do them — they
635
+ carry a destructive hint so your CLI stops and asks, and the instructions are blunt that those marks
636
+ answer your instruction and never the agent's own opinion of a finding.
637
+
638
+ The MCP server is a subcommand of the CLI, and `install` registers it with the agent hosts on the
639
+ machine — through each host's own CLI, never by rewriting a config file someone maintains by hand:
640
+
641
+ ```bash
642
+ buhtig mcp install # asks which of the hosts it found
643
+ buhtig mcp install --all # all of them, without asking
644
+ buhtig mcp install --host claude --dry-run
645
+ buhtig mcp install --scope project # for the hosts that offer the choice
646
+ ```
647
+
648
+ The checklist opens with the hosts whose config already names buhtig ticked, so re-running after an
649
+ install moves is one keypress; where nothing is configured yet it suggests everything it found.
650
+ `--host` and `--all` answer it outright, and a run with no terminal takes every host found.
651
+
652
+ OpenCode and Cursor have no CLI to register through, so they get the block to paste. Re-running is
653
+ safe — each entry is removed and re-added, which is also how a moved install gets re-pointed.
654
+
655
+ Running it directly is the same server:
453
656
 
454
657
  ```bash
455
- ./scripts/install-agent-integration.sh # also run by install.sh
658
+ buhtig mcp # stdio, what a host launches
659
+ buhtig mcp --http # streamable HTTP on 10005, for a host that takes a URL
660
+ buhtig mcp --url http://localhost:10004 # drive a different install
456
661
  ```
457
662
 
458
- That script registers buhtig's **MCP server** with your agent CLI as well, and the two work together:
459
- an agent holding the MCP tools reads the instructions rendered as tool calls, an agent with only a
460
- shell reads the same instructions rendered as `curl`, and neither is shown the other's version. The
461
- tool set is generated from one declared catalogue (`AGENT_CALLS` in `@buhtig/shared`) with schemas
462
- derived from the OpenAPI document. It is curated: there is no tool to publish a review, merge or
463
- close a PR, or write settings. Dismissing a finding and staging one for posting *are* there, because
464
- you will want to ask an agent to do them — they carry a destructive hint so your CLI stops and asks,
465
- and the instructions are blunt that those marks answer your instruction and never the agent's own
466
- opinion of a finding.
663
+ `buhtig-mcp` remains as its own binary for anything registered before the subcommand existed.
467
664
 
468
665
  The division of labour is deliberate: the agent supplies prose and `(path, line)`; buhtig derives anchor fingerprints,
469
666
  staleness, whether a line is postable to GitHub, and `F<N>` numbering; **the human** dismisses, posts,
@@ -474,18 +671,19 @@ finding on code the PR never touched stays `active` — and a later push re-reso
474
671
  `active` / `moved` / `stale`. Nothing is ever auto-deleted.
475
672
 
476
673
  ```bash
477
- pnpm server # rebuilds @buhtig/shared, then boots on $BUHTIG_PORT (10003)
674
+ pnpm dev:server # server only, watched, on $BUHTIG_LISTEN (default 10003)
478
675
  curl -s localhost:10003/api/repos/acme/webapp/review/config | jq '{state, facets: (.facets|length)}'
479
676
  ```
480
677
 
481
678
  > A bare `tsx packages/server/src/index.ts` resolves `@buhtig/shared` to its **build output**. If
482
- > `dist/` is stale you get `does not provide an export named …` at import. `pnpm server` and
483
- > `pnpm dev` both handle this; a raw `tsx` invocation does not.
679
+ > `dist/` is stale you get `does not provide an export named …` at import. Every `pnpm` script here
680
+ > builds what it depends on first; a raw `tsx` invocation does not.
484
681
 
485
682
  ### Repo-local config & hooks
486
683
 
487
- A repo can carry its own config at `.git/buhtig.yaml` (unkeyed — it's already repo-scoped). Today it
488
- supports lifecycle **hooks**:
684
+ A repo can carry its own config at `.git/buhtig.yaml` (unkeyed — it's already repo-scoped), local to
685
+ this clone and unreachable by any branch. For settings that should travel with the repository, use
686
+ the committed `.buhtig/config.yaml` layer instead. Hooks are the one thing that can *only* live here:
489
687
 
490
688
  ```yaml
491
689
  hooks:
@@ -520,12 +718,17 @@ The testing seam fakes **GitHub's egress only** — never buhtig's own API or `g
520
718
  source of truth for SHAs, so headline flows like "viewed → stale on advance" stay honest.
521
719
  - **End-to-end (Playwright):** a real backend booted against an isolated sandbox (tmp XDG dirs,
522
720
  fixture `config.yml`, ephemeral git repos, fake `GitHubClient`).
721
+ - **The hosted demo (Playwright, `static-demo` project):** the built assets on a file server with no
722
+ `/api` behind them at all. Its own project because the condition under test is the *absence* of a
723
+ backend, which the shared server cannot provide.
523
724
 
524
725
  ```bash
525
726
  pnpm test # unit + integration (vitest)
526
727
  pnpm test:watch
527
- pnpm test:e2e # playwright
728
+ pnpm test:e2e # playwright (both projects)
729
+ pnpm test:e2e --project static-demo # just the browser-only demo
528
730
  pnpm test:e2e:ui # playwright UI mode
731
+ pnpm --filter buhtig test:setup:local # packed CLI + setup + foreground server, isolated under .tmp
529
732
  pnpm typecheck
530
733
  pnpm lint # biome
531
734
  ```
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "buhtig",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "description": "Local-first, keyboard-driven pull-request review cockpit",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
8
- "buhtig": "./packages/cli/dist/index.js",
9
- "buhtig-mcp": "./packages/cli/dist/mcp.js"
8
+ "buhtig": "./release/index.js",
9
+ "buhtig-mcp": "./release/mcp.js"
10
10
  },
11
- "files": ["LICENSE", "README.md", "packages/cli/dist"],
11
+ "files": ["LICENSE", "README.md", "release"],
12
12
  "repository": {
13
13
  "type": "git",
14
14
  "url": "git+https://github.com/veracioux/buhtig.git"
@@ -28,25 +28,33 @@
28
28
  "pnpm": ">=11"
29
29
  },
30
30
  "scripts": {
31
+ "cli": "turbo run build --filter='@buhtig/cli^...' && tsx packages/cli/src/index.ts",
32
+ "dev": "turbo run dev",
33
+ "dev:server": "turbo run dev --filter=@buhtig/server",
34
+ "dev:web": "turbo run dev --filter=@buhtig/web",
35
+ "start": "turbo run build && node packages/server/dist/index.js",
31
36
  "build": "turbo run build",
37
+ "sdk:generate": "turbo run generate --filter=@buhtig/sdk",
32
38
  "package:build": "BUHTIG_RELEASE=1 pnpm build && node scripts/build-package.mjs",
33
39
  "package:check": "node scripts/check-package.mjs",
34
40
  "prepack": "pnpm package:build && pnpm package:check",
35
- "dev": "turbo run dev",
36
- "format": "biome format --write .",
41
+ "check": "pnpm lint && pnpm typecheck && pnpm test",
37
42
  "lint": "biome check .",
38
- "mobile:sync": "pnpm --filter @buhtig/mobile sync:android",
39
- "mobile:apk:debug": "pnpm --filter @buhtig/mobile apk:debug",
40
- "mobile:apk:release": "pnpm --filter @buhtig/mobile apk:release",
41
- "server": "pnpm --filter @buhtig/shared build && pnpm --filter @buhtig/github build && BUHTIG_PORT=${BUHTIG_PORT:-10003} tsx packages/server/src/index.ts",
42
- "start": "pnpm --filter @buhtig/cli build && pnpm --filter @buhtig/server start",
43
+ "format": "biome check --write .",
44
+ "typecheck": "tsc -p tsconfig.typecheck.json",
43
45
  "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "test:mobile": "pnpm --filter @buhtig/web test:mobile",
44
48
  "test:e2e": "playwright test",
49
+ "test:e2e:ui": "playwright test --ui",
45
50
  "test:e2e:debug": "playwright test --debug",
46
51
  "test:e2e:report": "playwright show-report",
47
- "test:e2e:ui": "playwright test --ui",
48
- "test:watch": "vitest",
49
- "typecheck": "tsc -p tsconfig.typecheck.json"
52
+ "test:setup:local": "node test/setup-smoke/local.mjs",
53
+ "mobile:sync": "pnpm --filter @buhtig/mobile sync:android",
54
+ "mobile:open": "pnpm --filter @buhtig/mobile open:android",
55
+ "mobile:apk:debug": "pnpm --filter @buhtig/mobile apk:debug",
56
+ "mobile:apk:release": "pnpm --filter @buhtig/mobile apk:release",
57
+ "prepare": "husky"
50
58
  },
51
59
  "dependencies": {
52
60
  "better-sqlite3": "^13.0.3"
@@ -56,9 +64,14 @@
56
64
  "@playwright/test": "^1.50.1",
57
65
  "@types/node": "^22.13.1",
58
66
  "esbuild": "^0.25.0",
67
+ "husky": "^9.1.7",
68
+ "lint-staged": "^17.3.0",
59
69
  "tsx": "^4.19.2",
60
70
  "turbo": "^2.9.18",
61
71
  "typescript": "^5.7.3",
62
72
  "vitest": "^4.1.0"
73
+ },
74
+ "lint-staged": {
75
+ "*.{ts,tsx,js,jsx,mjs,cjs,json,astro,vue,svelte}": "biome format --write --no-errors-on-unmatched"
63
76
  }
64
77
  }
@@ -0,0 +1,151 @@
1
+ # Annotating a resource
2
+
3
+ Attach a small typed mark to something in buhtig, saying what you observed about its state.
4
+
5
+ Standalone. It needs no review run, and nothing here is review input: an annotation is not a finding,
6
+ carries no score, and is counted toward no verdict.
7
+
8
+ {{preamble}}
9
+
10
+ ## What an annotation is, and what it is not
11
+
12
+ An annotation is an **observation**. It changes nothing about the thing it describes — it resolves no
13
+ thread, posts no comment, and is never seen on GitHub.
14
+
15
+ That is the whole reason you may set one on your own reading, unlike a disposition, which is the
16
+ human's decision and only ever carried out on their instruction. It is also why an annotation has to
17
+ be honest. A human acts on these: a `resolvable` you were not sure about costs them exactly the trip
18
+ it was meant to save. **Where you are unsure, reply instead.**
19
+
20
+ | | Who owns it | May you act on your own judgement | Reaches GitHub |
21
+ |---|---|---|---|
22
+ | A disposition (`review_dispositions`) | the human's decision | no — on instruction only | no |
23
+ | A note (`note_file`) | your answer to a question | yes | not until they promote it |
24
+ | An annotation | your observation | **yes** | **never** |
25
+
26
+ ## Read the vocabulary first
27
+
28
+ Which marks exist, and **when each applies**, is per-repo configuration. Never work from a kind list
29
+ you remember, and never from a `when` you remember: a repo may have rewritten the instruction,
30
+ dropped the kind, or added one of its own.
31
+
32
+ ```call annotation.vocabulary
33
+ {
34
+ "owner": "acme",
35
+ "repo": "widgets",
36
+ "targetType": "thread"
37
+ }
38
+ ```
39
+
40
+ Each kind comes back with:
41
+
42
+ - **`when`** — the condition you judge against. This is the instruction; there is no other.
43
+ - **`stance`** — whose side of the PR the claim speaks from, `reviewer` or `author`. You do not choose
44
+ a stance and you do not record one. Choosing the kind is what states it.
45
+ - **`soWhat`** — what the human is expected to do about the mark. Useful for deciding whether your
46
+ reason gives them enough to do it.
47
+ - **`expiresOn`** — what voids the mark later. See below.
48
+ - **`settableByHand`** — whether a *person* can also set this one from the UI. Not your concern for
49
+ deciding anything; it explains why a thread may carry a mark you did not make.
50
+
51
+ An empty `kinds` means this repo does not annotate this target type. Say so and stop; do not pick a
52
+ kind anyway. `issues` names anything the repo tried to declare and may not — worth relaying, because
53
+ the human cannot see it from the thread.
54
+
55
+ The kinds buhtig ships for a `thread`, as a repo that has changed nothing gets them:
56
+
57
+ | Kind | Stance | The claim |
58
+ |---|---|---|
59
+ | `resolvable` | reviewer | Nothing is left in the complaint this thread raised — the reviewer who wrote it would have nothing more to ask. |
60
+ | `i_addressed` | author | The code now does what the thread asked, and you can name the commit and lines that do it. |
61
+ | `wont-do` | author | The author has said the change is not going to be made — out of scope, a deliberate choice, or deferred elsewhere. |
62
+
63
+ `i_addressed` and `resolvable` are not alternatives. `i_addressed` is *"I fixed it"*; `resolvable` is
64
+ *"there is nothing left to ask"*. Both on one thread is the strongest signal the pair can carry.
65
+
66
+ `wont-do` is the one that contradicts `i_addressed`, and nothing enforces that — set one or the other,
67
+ never both. It is also the kind you are least often entitled to: it records a **decision the author
68
+ already stated**, in the thread or elsewhere on the PR. You never take that decision. A request
69
+ nobody has answered is unanswered, not declined, and reading silence as a refusal is how a thread
70
+ the author meant to come back to gets closed.
71
+
72
+ ## Read what is already there
73
+
74
+ ```call annotation.list
75
+ {
76
+ "owner": "acme",
77
+ "repo": "widgets",
78
+ "number": 42
79
+ }
80
+ ```
81
+
82
+ Read before writing, so you replace your own earlier reading rather than restating it.
83
+
84
+ **A mark with `author.kind: "human"` is not yours.** A person set it themselves and is standing
85
+ behind it — do not clear it, do not overwrite it with the same kind, and do not treat it as something
86
+ to verify. If you think a person's `i_addressed` is wrong, say so in a reply, which is where a
87
+ disagreement belongs. Same rule as a disposition: never write over a decision you did not make.
88
+
89
+ ## Write the mark
90
+
91
+ ```call annotation.set
92
+ {
93
+ "owner": "acme",
94
+ "repo": "widgets",
95
+ "number": 42,
96
+ "targetType": "thread",
97
+ "targetId": "<thread-id>",
98
+ "kind": "resolvable",
99
+ "reason": "the reviewer asked for a nil guard; src/parse.ts:88 has one and parse.test.ts:41 covers it",
100
+ "observed": {
101
+ "commentCount": 3,
102
+ "lastCommentId": "<id of the last comment you read>",
103
+ "headSha": "<the head you read the code at>"
104
+ }
105
+ }
106
+ ```
107
+
108
+ `reason` is required **of you**, and it is read **instead of** the thread. Make it the finding — what
109
+ you checked and where — not a restatement of the kind. "This is resolvable" tells the human nothing
110
+ they did not already see on the chip. (A person marking a thread themselves may omit it; they are
111
+ the claimant, and there is nothing for them to check.)
112
+
113
+ **Never send `author`.** It defaults to naming you an agent, and the UI draws an agent's mark
114
+ differently from a person's — ✦ against a person glyph. Claiming to be a human would make the chip
115
+ lie about who assessed the thread, which is the one thing the reader is using it to find out.
116
+
117
+ `observed` must be the state you **actually read**. It is not bookkeeping: it is what lets buhtig tell
118
+ a standing mark from one the conversation has moved past.
119
+
120
+ Writing the same kind on the same target again replaces it. That is how a mark is renewed after a
121
+ reply, not a second claim.
122
+
123
+ ## Expiry
124
+
125
+ A mark carries the target's state at the moment you made it. When the live target has moved past
126
+ that, buhtig stops counting the mark — it renders it struck through, as the record that somebody
127
+ looked, and drops it from the filters.
128
+
129
+ For a thread, the default `expiresOn: ["reply"]` means any new comment voids it. A repo may add
130
+ `"head"`, which voids it when the PR's head moves.
131
+
132
+ So: **a `resolvable` set before a reply is not wrong, it is spent.** If you are asked to re-check a
133
+ thread that has since gained a reply, judge it again and set the mark again with the new `observed`.
134
+ Do not clear the old one first.
135
+
136
+ ## Withdrawing one
137
+
138
+ ```call annotation.clear
139
+ {
140
+ "owner": "acme",
141
+ "repo": "widgets",
142
+ "number": 42,
143
+ "targetType": "thread",
144
+ "targetId": "<thread-id>",
145
+ "kind": "resolvable"
146
+ }
147
+ ```
148
+
149
+ Only for a claim you no longer stand behind. A mark that has merely gone out of date needs nothing
150
+ from you — buhtig already shows it as superseded, and clearing it destroys the evidence that the
151
+ thread was assessed at all.