buhtig 0.1.0-alpha.3 → 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 +235 -32
- package/package.json +27 -14
- package/release/docs/buhtig-review/ANNOTATIONS.md +151 -0
- package/{packages/cli/dist → release}/docs/buhtig-review/API.md +131 -21
- package/{packages/cli/dist → release}/docs/buhtig-review/FACET_REVIEWER.md +75 -12
- package/{packages/cli/dist → release}/docs/buhtig-review/ORCHESTRATOR.md +38 -13
- package/{packages/cli/dist → release}/docs/buhtig-review/SKILL.md +28 -28
- package/release/index.js +92403 -0
- package/{packages/cli/dist → release}/mcp.js +5458 -17429
- package/{packages/cli/dist → release}/openapi.json +22325 -8438
- package/release/web/assets/GalleryPage--tnuAGmn.js +97 -0
- package/release/web/assets/index-BM6t4Pp0.css +1 -0
- package/release/web/assets/index-TSKDa5O3.js +1946 -0
- package/release/web/assets/web-Bpd8NlUv.js +1 -0
- package/release/web/assets/web-CPCLSvxo.js +1 -0
- package/{packages/cli/dist → release}/web/index.html +2 -2
- package/packages/cli/dist/index.js +0 -60292
- package/packages/cli/dist/web/assets/index-DYQXToGQ.css +0 -1
- package/packages/cli/dist/web/assets/index-V4uvKNIY.js +0 -890
- package/packages/cli/dist/web/assets/web-66b0rS20.js +0 -1
- package/packages/cli/dist/web/assets/web-BdI4d3MI.js +0 -1
- /package/{packages/cli/dist → release}/docs/buhtig-review/BOOTSTRAP.md +0 -0
- /package/{packages/cli/dist → release}/docs/buhtig-review/PROVENANCE.md +0 -0
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
|
|
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
|
|
258
|
-
|
|
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
|
|
319
|
-
|
|
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
|
|
|
@@ -387,7 +487,7 @@ A repo can override the *choice* (`agent: codex` in its own entry, or in `.git/b
|
|
|
387
487
|
Until something chooses, the Alt+W → Agent action reads "configure…" and takes you to the settings
|
|
388
488
|
page rather than launching whatever buhtig happened to be written against.
|
|
389
489
|
|
|
390
|
-
A launched agent is **briefed on the way in**: it is told that you can send it a one-line
|
|
490
|
+
A launched agent is **briefed on the way in**: it is told that you can send it a one-line message
|
|
391
491
|
from the UI, and given one exact command to watch for one — `buhtig agent listen`, pointed at
|
|
392
492
|
`$BUHTIG_STREAM` and chosen for the agent being launched (Claude Code sets it up as a persistent
|
|
393
493
|
monitor that never returns and wakes it on each message; Codex and opencode run it for one bounded
|
|
@@ -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
|
|
401
|
-
force marked
|
|
402
|
-
|
|
403
|
-
|
|
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 `.
|
|
417
|
-
|
|
418
|
-
|
|
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`.
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
483
|
-
>
|
|
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)
|
|
488
|
-
|
|
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.
|
|
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": "./
|
|
9
|
-
"buhtig-mcp": "./
|
|
8
|
+
"buhtig": "./release/index.js",
|
|
9
|
+
"buhtig-mcp": "./release/mcp.js"
|
|
10
10
|
},
|
|
11
|
-
"files": ["LICENSE", "README.md", "
|
|
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
|
-
"
|
|
36
|
-
"format": "biome format --write .",
|
|
41
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test",
|
|
37
42
|
"lint": "biome check .",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
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:
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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.
|