M .beads/issues.jsonl => .beads/issues.jsonl +1 -0
@@ 1,5 1,6 @@
{"_type":"issue","id":"spec-zqb","title":"Phase 3: write plane — agents propose","description":"The agent half of the loop, and the service's whole premise: an agent proposes a document change, gets back a link, a human approves in a browser. Until this ships, agents can only read.","design":"Scope: proposals (branch proposals/\u003cid\u003e + Postgres row, state open-\u003emerged|rejected); the If-Match tree-splice merge with the four staleness cases and the ancestry check gitx flagged; the single agent token + mandatory provenance trailers; spec_propose / spec_comment over REST + MCP; every write response returns {proposal, url} so the agent can hand over a link. Prereqs found during Phases 1-2: (1) service.ListProposals(space, state) — graph proposal listing is stubbed and db only has ListProposalsByState; (2) IsAncestor(proposalHead, H) check before Merge so an already-merged proposal reports 'already merged' not a confusing 409; (3) auto-merge policy evaluation (.spec.yml AutoMerges) which the reconciler's 'approval=policy' inference also wants. Dispatch like Phases 1-2: proposal orchestration in service/ first and committed, then surface tools fan out. This is where the merge model and reconciler first run under real proposals rather than on paper.","status":"closed","priority":1,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:08:58Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T05:29:29Z","started_at":"2026-07-23T04:52:59Z","closed_at":"2026-07-23T05:29:29Z","close_reason":"Write plane shipped: service.Propose/Merge/Reject/ListProposals with provenance trailers, auto-merge policy, If-Match staleness + already-merged ancestry check; surfaces graph (Proposals port), mcpsrv spec_propose, and new api/ REST PUT — every write returns {proposal, url}. Validated end-to-end against Postgres. spec_comment deliberately deferred to Phase 5 (spec-by6), which gates the comment schema on the Phase 4 review UI; the approve/reject browser UI is Phase 4 (spec-3vz).","dependencies":[{"issue_id":"spec-zqb","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:24Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"spec-ejq","title":"spec.sr.ht — reviewable document storage for humans and agents","description":"A third custom Go service on the self-hosted SourceHut instance: agents propose documents, a human reviews and curates, agents read the approved text. One loop — bot produces, human curates, bots consume. Runs at https://spec.srht.bigb.es, deployed on phoebe as srht-spec-1. Code: git.sourcecraft.dev/bigbes/sr-ht-spec. Design: docs/DESIGN.md.","design":"DONE and deployed (Phases 0-2): core domain, gitx (bare repos + id-keyed tree-splice merge), db (Postgres schema + global ID registry), authn (unified-login cookie + agent tokens + provenance), the three receive hooks + hook RPC + daemon, service layer + reconciler, doc (warren vault/render on git objects), search (one global bleve index, per-line ru/en routing), projects (saved filter, not container), web read UI, mcpsrv read tools, graph read schema, prosediff (Phase 0 gate PASSED), specsrht space create/list, push-\u003ereindex. Live corpus: ~bigbes/rfcs holds SPEC-0001 and NOTE-0001. Verified end-to-end: validating push path, fail-closed reads, id-addressing, bilingual search, GraphQL. NOT DONE: the agent half of the loop (write plane), review UI, comments.","status":"open","priority":1,"issue_type":"epic","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:08:44Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T04:08:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"spec-ovo","title":"CLI: specsrht doc propose — open a proposal from files on the host","description":"The two agent write surfaces (REST PUT, mcpsrv spec_propose) are remote and need a bearer token. When the operator and the documents are already on the host, that token is ceremony: the process can open Postgres and the bare repos directly. Add 'specsrht doc propose ~owner/space \u003cfile\u003e...' calling service.Propose with a constructed agent principal, so the shared layer still owns If-Match, provenance, the branch cut and the auto-merge gate.","acceptance_criteria":"doc propose opens a proposal from local files and prints proposal id, branch, base and URL; --as overrides the in-space path; --proposal adds to an open proposal; --base defaults to the approved head; provenance (--agent/--session) is recorded like a remote agent's; flags parse before, after and between positionals","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-08-05T03:23:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:23:01Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-rsb","title":"CLI: specsrht token create|list|revoke","description":"db.Store has the whole agent-token lifecycle (CreateAgentToken, ListAgentTokens, RevokeAgentToken, GenerateToken/HashToken) but nothing calls it: no CLI subcommand and no web page. A freshly deployed instance therefore has no way to issue the credential the agent write plane (REST PUT and mcpsrv spec_propose) requires, short of hand-writing an INSERT with a sha256 hash. Add a 'token' admin subcommand next to 'space', in the same shape: load config, open the pool, run, exit.","acceptance_criteria":"specsrht token create \u003cname\u003e mints a token, stores only its hash, and prints the plaintext once; specsrht token list shows id/name/created/revoked; specsrht token revoke \u003cid\u003e stamps it revoked; usage errors are one clear line; unit tests cover argument parsing and row formatting","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-08-05T03:14:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:14:05Z","started_at":"2026-08-05T03:14:05Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-by6.3.5","title":"Port review UI to a line-numbered unified prose diff","description":"Replace the block-card review renderer with a real unified diff: line-numbered gutter, drag-select a line range, comment composer inline. Owner reviewed the card version against a live proposal and rejected it — labels ('ADDED PARAGRAPH') outweighed content on every row, every block carried identical chrome, and on a new file the whole page is green so the cards add noise and no signal. Split view was considered and dropped: prose lines are long and unified reads better for reflowed text. Approved prototype: https://claude.ai/code/artifact/a45e45cb-16b5-42ec-8108-84767ef9f465","design":"SELECTION IS BY LINE, ANCHORING IS BY BLOCK. Lines are what the cursor lands on; block hashes are what survive a reflow. The web layer maps a selected line range to its enclosing prosediff block and stores the existing core.CommentAnchor unchanged. service/, db/ and core/ do not move. The composer states what it will anchor to, so the indirection is visible rather than magic.\n\nTHE BLOCKING PROBLEM — word marks cannot be placed on a line for free.\nprosediff.Span is {Op, Text, Space} with no source offsets, and Tokenize\ndeliberately drops whitespace ('\\n' and ' ' both collapse to Space=true) —\nwhich is exactly what makes a rewrap invisible to the differ. So for a\nChangeModify block there is no stored answer to 'which line did this word\nchange on'.\n\nRecoverable, not free: the span script consumes the old token sequence in order\n(Equal+Delete) and the new one (Equal+Insert). Re-tokenize each source line of\nBlock.Lines, walk the script, and split a span where it crosses a line\nboundary. Needs a mapper of roughly 100 lines plus tests. Equal/Insert/Delete\nblocks need none of this — their lines map 1:1 and carry exact numbers.\n\nFALLBACK IF THE MAPPER IS NOT WANTED: render a modified block as a paired\nold/new region labelled by line RANGE rather than per line, keeping the word\nmarks. Honest and much smaller, but does not match the approved prototype.\n\nGUTTER DETAILS SETTLED IN REVIEW: one shared rail ground for both number\ntracks with a single hairline against the content (a border per cell drew a\ncage); one --row-lh shared by gutter and prose so numerals sit on the text\nbaseline rather than floating; 34px tracks; the add/delete tint starts at the\nsign column so the gutter never looks part of the change.\n\nHEADING PATH: no per-hunk breadcrumb. It restated headings visible a few rows\nabove, always so on a new file. Replaced by one sticky section readout that\nappears only once its heading has scrolled past. The path stays in the composer\nand in each thread's anchor note, where the heading is usually off screen.","acceptance_criteria":"A reviewer drags across line numbers, comments, and the thread stores an anchor identical to the one the block-card UI would have stored. Word-level marks still appear inside a modified paragraph. Unchanged lines render as context and collapse. An outdated comment still appears and is never attached to a neighbouring block.","status":"closed","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-25T11:40:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T01:11:47Z","closed_at":"2026-08-05T01:11:47Z","close_reason":"Closed","dependencies":[{"issue_id":"spec-by6.3.5","depends_on_id":"spec-by6.3","type":"parent-child","created_at":"2026-07-25T14:40:01Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-ejq.2","title":"CI publish task fails: build secret apk-ci-s3 is missing","description":"Every recent builds.sr.ht run fails at the publish task while every other task passes. Confirmed on jobs #244 (e97532c), #245 (2dc6b71) and #246 (c2dd1ef): 'scss keygen version build' all SUCCESS, 'publish' FAILED. Pre-existing — #244 and #245 predate the Phase 5b work, so no code change caused it. Consequence: the apk never reaches the Garage repo bucket, so apk-mirror on phoebe has nothing new to re-index and the deployed service cannot be upgraded from CI output.","design":"EVIDENCE. .build.yml declares 'secrets: [apk-ci-s3]', documented as a File secret installed at ~/.apk-ci.env carrying APK_CI_S3_ACCESS_KEY / APK_CI_S3_SECRET_KEY for the Garage repo bucket. The publish task's first real line is '. ~/.apk-ci.env'. But 'hut builds secret list' returns exactly four secrets and none is apk-ci-s3: agent1-deploy (SSH key), bencher-api-key, s3-cache-key-secret, s3-cache-key-id. So the file the task sources is never installed.\n\nNOT VERIFIED: the exact failure text. The raw log endpoint (https://builds.srht.bigb.es/query/log/246/publish/log) needs a Bearer token, and reading hut's credential file was correctly refused, so the diagnosis rests on the secret list plus the manifest rather than on the log line itself. Read the log to confirm before acting.\n\nTWO POSSIBILITIES, needs the owner to distinguish:\n1. The secret was deleted or never created — fix is to create a File secret named apk-ci-s3 at ~/.apk-ci.env (mode 600) holding the two S3 keys.\n2. builds.sr.ht resolves manifest secrets by UUID, not by name — fix is to replace the name with the secret's UUID in .build.yml.\n\nSAME BUG IN THE SIBLING: ~/data/home/sourcehut-compare/.build.yml carries a byte-identical secrets block, so compare.sr.ht's publish is broken the same way and both fix together.","acceptance_criteria":"A push to master produces a build whose publish task succeeds and uploads the .apk to the Garage repo bucket; apk-mirror on phoebe re-indexes it within 15 minutes.","notes":"RE-INDEX VERIFIED 2026-08-05, the half the close reason left open.\n\nChecked through repo.bigb.es, the public mirror apk-mirror builds from the Garage repo bucket (URL documented in the APKBUILD header; s3.bigb.es itself resolves to 192.168.88.18 and is only reachable from the LAN, which is why the first attempt to verify failed).\n\n spec.sr.ht-0.0.78-r0.apk 200 content-length 16059260 last-modified 03:09:39 GMT\n APKINDEX.tar.gz 200 last-modified 03:15:00 GMT\n index entry: P:spec.sr.ht V:0.0.78-r0 A:x86_64 S:16059260\n\nThe size in the index matches the object byte for byte, and 0.0.78 is 'git rev-list --count cc90b4a' — the commit build #251 ran on. The index was rebuilt 5.5 minutes after the upload, inside the 15-minute window the acceptance criteria name. Both halves of the criteria are therefore met.\n\nCorrection to the close reason: the package is named spec.sr.ht-0.0.78-r0.apk, not specsrht-0.0.78-r0.apk — pkgname in APKBUILD is spec.sr.ht. The specsrht spelling 404s.","status":"closed","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-07-24T21:16:35Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:20:11Z","closed_at":"2026-08-05T03:12:30Z","close_reason":"Owner created the File secret apk-ci-s3 (/home/build/.apk-ci.env, mode 600) on builds.srht.bigb.es; 'hut builds secret list' now shows it. Resubmit of #250 as build #251 on the same commit cc90b4a: SUCCESS, all five tasks green including publish.\n\nVERIFIED: publish runs under 'set -e' and its body is a loop of 'rclone copyto' with an echo per file, so any failed upload would have exited non-zero and failed the task. A green publish is therefore evidence the .apk reached repo/alpine/v3.22/bigbes/x86_64/ — specsrht-0.0.78-r0.apk for this commit.\n\nNOT VERIFIED from here: the apk-mirror re-index on phoebe, the second half of the acceptance criteria. s3.bigb.es is not reachable anonymously from this workstation and phoebe host access is blocked (spec-ar4). If the package turns out not to be installable, that is apk-mirror's problem rather than this repo's and wants its own bead.\n\nThe manifest needed no change: '- apk-ci-s3' resolves by name, as the diagnosis note records. The same account-level secret should also unblock sourcehut-compare, whose .build.yml carries the identical block — its next build will show it.","dependencies":[{"issue_id":"spec-ejq.2","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-25T00:16:35Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
A cmd/specsrht/doc.go => cmd/specsrht/doc.go +266 -0
@@ 0,0 1,266 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+const docUsage = "usage: specsrht doc propose ~owner/space <file>... [flags]"
+
+// runDoc is the document administration command. It has one subcommand:
+//
+// specsrht doc propose ~owner/space <file>... [--as path] [--title t] ...
+//
+// It opens (or extends) a proposal from files on this host, calling
+// [service.Service.Propose] — the same entry point the REST PUT and mcpsrv's
+// spec_propose call after they have authenticated. Nothing about proposing is
+// re-decided here: If-Match, provenance, the branch cut and the auto-merge gate
+// all stay in service/, which is what keeps the three surfaces one
+// implementation.
+//
+// # Why an admin command exists at all
+//
+// The two agent surfaces are remote and therefore need a bearer token; this one
+// is not. It runs on the host, with the repositories and Postgres already in
+// hand, and constructs the agent principal directly rather than resolving one
+// from an agent_token row. That is not a hole: a process that can already open
+// the database and the bare repositories can do anything the token would let it
+// do, and demanding a credential from it would only be ceremony. Provenance is
+// *not* waived, though — --agent and --session are recorded exactly as a remote
+// agent's are, so a `git log` cannot tell a proposal opened here from one opened
+// over HTTP, and neither can a reviewer.
+func runDoc(args []string) error {
+ if len(args) == 0 {
+ return errors.New(docUsage)
+ }
+ if args[0] != "propose" {
+ return fmt.Errorf("unknown subcommand %q: want propose", args[0])
+ }
+
+ opts, err := parseDocPropose(args[1:])
+ if err != nil {
+ return err
+ }
+ writes, err := loadWrites(opts)
+ if err != nil {
+ return err
+ }
+
+ conf := config.LoadConfig()
+ cfg, err := validateConfig(conf)
+ if err != nil {
+ return err
+ }
+ pool, err := openDatabase(cfg.ConnectionString)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+
+ svc, err := service.New(cfg, pool)
+ if err != nil {
+ return err
+ }
+ ctx := context.Background()
+
+ // An unset --base means "the approved head as it stands right now", which is
+ // the base a person editing on this host actually read at. It is resolved
+ // here rather than defaulted to the branch name so the proposal records the
+ // sha it was cut from, the same value a remote agent's If-Match carries.
+ base := opts.base
+ if base == "" {
+ sp, err := svc.OpenSpace(ctx, opts.space)
+ if err != nil {
+ return err
+ }
+ head, err := sp.Repo.ApprovedHead(ctx)
+ if err != nil {
+ return fmt.Errorf("resolve the approved head of %s, which is the base this "+
+ "proposal is cut from — a space with no commits yet has none, so push one "+
+ "first or pass --base: %w", opts.space, err)
+ }
+ base = head.String()
+ }
+
+ res, err := svc.Propose(ctx, service.ProposeRequest{
+ Space: opts.space,
+ Principal: authn.Principal{
+ Kind: authn.KindAgent,
+ Owner: cfg.Instance.OwnerName,
+ Agent: opts.agent,
+ Session: opts.session,
+ },
+ ProposalID: opts.proposalID,
+ Title: opts.title,
+ Rationale: opts.rationale,
+ IfMatch: base,
+ Message: opts.message,
+ Writes: writes,
+ })
+ if err != nil {
+ return err
+ }
+
+ return printProposeResult(os.Stdout, res, writes)
+}
+
+// docProposeOpts is one parsed `doc propose` invocation.
+type docProposeOpts struct {
+ space core.SpaceRef
+ // files are local paths to read; paths inside the space are their base
+ // names unless as overrides a single one.
+ files []string
+ as string
+
+ title string
+ rationale string
+ message string
+ base string
+ proposalID int
+
+ agent string
+ session string
+}
+
+// parseDocPropose parses the arguments of `doc propose` into options, with no
+// side effects: no file is opened, no configuration is read, and no database is
+// touched. Everything that can be wrong about an invocation is therefore
+// reported before this host's Postgres has to be reachable, which is what makes
+// a typo'd command a one-line error instead of a connection failure that hides
+// it.
+func parseDocPropose(args []string) (docProposeOpts, error) {
+ var o docProposeOpts
+
+ fs := flag.NewFlagSet("doc propose", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ fs.StringVar(&o.as, "as", "", "path inside the space (one file only; default: the file's base name)")
+ fs.StringVar(&o.title, "title", "", "proposal title (required when opening a new proposal)")
+ fs.StringVar(&o.rationale, "rationale", "", "why this change is proposed")
+ fs.StringVar(&o.message, "message", "", "commit subject and body (default: the title)")
+ fs.StringVar(&o.base, "base", "", "base revision to propose against (default: the approved head)")
+ fs.IntVar(&o.proposalID, "proposal", 0, "add to this open proposal instead of opening a new one")
+ fs.StringVar(&o.agent, "agent", "specsrht-cli", "agent identity recorded as the commit author")
+ fs.StringVar(&o.session, "session", "", "agent session id (default: a fresh one)")
+
+ positional, err := parseFlagsAnywhere(fs, args)
+ if err != nil {
+ return docProposeOpts{}, fmt.Errorf("%v\n%s", err, docUsage)
+ }
+ if len(positional) < 2 {
+ return docProposeOpts{}, errors.New(docUsage)
+ }
+
+ o.space, err = core.ParseSpaceRef(positional[0])
+ if err != nil {
+ return docProposeOpts{}, fmt.Errorf("parse %q: %w", positional[0], err)
+ }
+ o.files = positional[1:]
+
+ if o.as != "" && len(o.files) != 1 {
+ return docProposeOpts{}, fmt.Errorf("--as names one path but %d files were given; "+
+ "drop --as and each file lands under its own base name", len(o.files))
+ }
+ if o.proposalID < 0 {
+ return docProposeOpts{}, fmt.Errorf("--proposal %d is not a proposal id", o.proposalID)
+ }
+ if o.session == "" {
+ o.session, err = newSessionID()
+ if err != nil {
+ return docProposeOpts{}, err
+ }
+ }
+ return o, nil
+}
+
+// parseFlagsAnywhere parses a flag set that allows flags before, after and
+// between positional arguments, returning the positionals in order.
+//
+// Go's flag package stops at the first non-flag, which would make
+// `doc propose ~bigbes/rfcs spec.md --title x` silently ignore --title — and an
+// ignored --title on an opening proposal is a refusal one layer down whose
+// message would name the missing title rather than the flag that was dropped.
+// Parsing the remainder in a loop is the smallest fix that keeps the natural
+// argument order working.
+func parseFlagsAnywhere(fs *flag.FlagSet, args []string) ([]string, error) {
+ var positional []string
+ rest := args
+ for {
+ if err := fs.Parse(rest); err != nil {
+ return nil, err
+ }
+ rest = fs.Args()
+ if len(rest) == 0 {
+ return positional, nil
+ }
+ positional = append(positional, rest[0])
+ rest = rest[1:]
+ }
+}
+
+// loadWrites reads each local file into the whole-document write the service
+// takes, mapping it to its path inside the space.
+//
+// The in-space path is validated here even though service/ and the push hook
+// validate too: this is the layer that invented the path (from a base name),
+// so a local file called "notes.txt" or "../escape.md" should be refused by
+// name, before a proposal row exists.
+func loadWrites(o docProposeOpts) ([]service.DocumentWrite, error) {
+ writes := make([]service.DocumentWrite, 0, len(o.files))
+ for _, local := range o.files {
+ path := o.as
+ if path == "" {
+ path = filepath.Base(local)
+ }
+ if err := core.ValidateDocPath(path); err != nil {
+ return nil, fmt.Errorf("path %q inside the space: %w", path, err)
+ }
+ content, err := os.ReadFile(local)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", local, err)
+ }
+ writes = append(writes, service.DocumentWrite{Path: path, Content: content})
+ }
+ return writes, nil
+}
+
+// printProposeResult reports what landed. The URL is the point — it is the link
+// a human opens to review — so it is printed last, where a terminal leaves it
+// closest to the prompt.
+func printProposeResult(w io.Writer, res service.ProposeResult, writes []service.DocumentWrite) error {
+ state := string(res.Proposal.State)
+ if res.Merged {
+ state += " (auto-merged by policy)"
+ }
+ fmt.Fprintf(w, "proposal %d — %s\n", res.Proposal.ID, state)
+ for _, wr := range writes {
+ fmt.Fprintf(w, " wrote %s (%d bytes)\n", wr.Path, len(wr.Content))
+ }
+ fmt.Fprintf(w, " branch %s\n base %s\n url %s\n",
+ res.Proposal.Branch, res.Proposal.BaseRev, res.URL)
+ return nil
+}
+
+// newSessionID mints the session id an invocation records when the caller did
+// not supply one. It is prefixed so a `git log` shows at a glance that the
+// proposal came from this command rather than from a remote agent that had a
+// session of its own to report.
+func newSessionID() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("generate a session id: %w", err)
+ }
+ return "cli-" + hex.EncodeToString(b), nil
+}
A cmd/specsrht/doc_test.go => cmd/specsrht/doc_test.go +192 -0
@@ 0,0 1,192 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// TestParseDocProposeTakesFlagsAfterThePositionals is the reason
+// parseFlagsAnywhere exists: the natural way to type this command puts the
+// space and the file first, and Go's flag package would stop there and drop
+// every flag that follows.
+func TestParseDocProposeTakesFlagsAfterThePositionals(t *testing.T) {
+ o, err := parseDocPropose([]string{
+ "~bigbes/rfcs", "rfc-0001.md",
+ "--title", "RFC-0001",
+ "--rationale", "because",
+ "--as", "rfcs/rfc-0001.md",
+ })
+ if err != nil {
+ t.Fatalf("parseDocPropose: %v", err)
+ }
+ if o.space.Owner != "bigbes" || o.space.Name != "rfcs" {
+ t.Errorf("space = %+v want ~bigbes/rfcs", o.space)
+ }
+ if len(o.files) != 1 || o.files[0] != "rfc-0001.md" {
+ t.Errorf("files = %v want [rfc-0001.md]", o.files)
+ }
+ if o.title != "RFC-0001" || o.rationale != "because" || o.as != "rfcs/rfc-0001.md" {
+ t.Errorf("flags after the positionals were dropped: %+v", o)
+ }
+}
+
+func TestParseDocProposeTakesFlagsBetweenPositionals(t *testing.T) {
+ o, err := parseDocPropose([]string{
+ "--proposal", "7", "~bigbes/rfcs", "--message", "add two", "a.md", "b.md",
+ })
+ if err != nil {
+ t.Fatalf("parseDocPropose: %v", err)
+ }
+ if o.proposalID != 7 || o.message != "add two" {
+ t.Errorf("flags lost: %+v", o)
+ }
+ if len(o.files) != 2 || o.files[0] != "a.md" || o.files[1] != "b.md" {
+ t.Errorf("files = %v want [a.md b.md] in order", o.files)
+ }
+}
+
+func TestParseDocProposeMintsASessionWhenNoneIsGiven(t *testing.T) {
+ first, err := parseDocPropose([]string{"~bigbes/rfcs", "a.md"})
+ if err != nil {
+ t.Fatalf("parseDocPropose: %v", err)
+ }
+ second, err := parseDocPropose([]string{"~bigbes/rfcs", "a.md"})
+ if err != nil {
+ t.Fatalf("parseDocPropose: %v", err)
+ }
+ if first.session == "" || second.session == "" {
+ t.Fatal("no session id was minted; the write would be refused for missing provenance")
+ }
+ if first.session == second.session {
+ t.Errorf("two invocations reported the same session %q", first.session)
+ }
+ if !strings.HasPrefix(first.session, "cli-") {
+ t.Errorf("session %q is not marked as coming from this command", first.session)
+ }
+ if first.agent != "specsrht-cli" {
+ t.Errorf("agent = %q want the default specsrht-cli", first.agent)
+ }
+}
+
+func TestParseDocProposeRejectsBadInvocations(t *testing.T) {
+ tests := map[string][]string{
+ "no arguments at all": {},
+ "a space but no file": {"~bigbes/rfcs"},
+ "a space with no name": {"~bigbes", "a.md"},
+ "--as with two files": {"~bigbes/rfcs", "a.md", "b.md", "--as", "one.md"},
+ "a negative proposal id": {"~bigbes/rfcs", "a.md", "--proposal", "-1"},
+ "a flag that is not ours": {"~bigbes/rfcs", "a.md", "--titel", "typo"},
+ }
+ for name, args := range tests {
+ if _, err := parseDocPropose(args); err == nil {
+ t.Errorf("parseDocPropose accepted %s: %v", name, args)
+ }
+ }
+}
+
+func TestLoadWritesDefaultsThePathToTheBaseName(t *testing.T) {
+ dir := t.TempDir()
+ local := filepath.Join(dir, "rfc-0001-spec-sr-ht.md")
+ if err := os.WriteFile(local, []byte("# hello\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ writes, err := loadWrites(docProposeOpts{files: []string{local}})
+ if err != nil {
+ t.Fatalf("loadWrites: %v", err)
+ }
+ if len(writes) != 1 {
+ t.Fatalf("got %d writes want 1", len(writes))
+ }
+ if writes[0].Path != "rfc-0001-spec-sr-ht.md" {
+ t.Errorf("path = %q want the base name, not the local directory", writes[0].Path)
+ }
+ if string(writes[0].Content) != "# hello\n" {
+ t.Errorf("content = %q", writes[0].Content)
+ }
+}
+
+// TestLoadWritesRefusesAPathThisCommandInvented guards the one path this layer
+// makes up rather than receives: a base name that is not a document path must
+// be refused by name, before a proposal row exists to leave behind.
+func TestLoadWritesRefusesAPathThisCommandInvented(t *testing.T) {
+ dir := t.TempDir()
+ local := filepath.Join(dir, "notes.txt")
+ if err := os.WriteFile(local, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadWrites(docProposeOpts{files: []string{local}}); err == nil {
+ t.Error("loadWrites accepted a non-.md base name")
+ }
+ if _, err := loadWrites(docProposeOpts{files: []string{local}, as: "../escape.md"}); err == nil {
+ t.Error("loadWrites accepted a path that escapes the space")
+ }
+}
+
+func TestLoadWritesNamesTheFileItCouldNotRead(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "gone.md")
+ _, err := loadWrites(docProposeOpts{files: []string{missing}})
+ if err == nil {
+ t.Fatal("loadWrites accepted a missing file")
+ }
+ if !strings.Contains(err.Error(), "gone.md") {
+ t.Errorf("the error does not name the file:\n%v", err)
+ }
+}
+
+func TestPrintProposeResultLeadsToTheURL(t *testing.T) {
+ var buf bytes.Buffer
+ res := service.ProposeResult{
+ Proposal: service.Proposal{
+ ID: 7,
+ State: core.StateOpen,
+ Branch: "proposals/7",
+ BaseRev: "0123456789abcdef0123456789abcdef01234567",
+ },
+ URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/7",
+ }
+ writes := []service.DocumentWrite{{Path: "rfc-0001.md", Content: []byte("abc")}}
+ if err := printProposeResult(&buf, res, writes); err != nil {
+ t.Fatalf("printProposeResult: %v", err)
+ }
+ out := buf.String()
+ for _, want := range []string{"proposal 7", "rfc-0001.md (3 bytes)", "proposals/7", res.URL} {
+ if !strings.Contains(out, want) {
+ t.Errorf("output does not mention %q:\n%s", want, out)
+ }
+ }
+ if !strings.HasSuffix(strings.TrimRight(out, "\n"), res.URL) {
+ t.Errorf("the URL is not the last line an operator sees:\n%s", out)
+ }
+}
+
+func TestPrintProposeResultSaysWhenPolicyLandedIt(t *testing.T) {
+ var buf bytes.Buffer
+ res := service.ProposeResult{
+ Proposal: service.Proposal{ID: 8, State: core.StateMerged, Branch: "proposals/8"},
+ URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/8",
+ Merged: true,
+ }
+ if err := printProposeResult(&buf, res, nil); err != nil {
+ t.Fatalf("printProposeResult: %v", err)
+ }
+ if !strings.Contains(buf.String(), "auto-merged") {
+ t.Errorf("a policy-merged write reads as if it is waiting for review:\n%s", buf.String())
+ }
+}
+
+func TestRunDocRejectsAnUnknownSubcommandBeforeTheDatabase(t *testing.T) {
+ if err := runDoc(nil); err == nil || err.Error() != docUsage {
+ t.Errorf("empty invocation did not print the usage line: %v", err)
+ }
+ err := runDoc([]string{"upload", "~bigbes/rfcs", "a.md"})
+ if err == nil || !strings.Contains(err.Error(), "propose") {
+ t.Errorf("unknown subcommand error does not name the real one: %v", err)
+ }
+}
M cmd/specsrht/main.go => cmd/specsrht/main.go +27 -5
@@ 22,6 22,21 @@
// neither the configuration nor the database; see the hooks package. The
// explicit form `specsrht hook <name>` does the same thing by hand.
//
+// # Admin commands
+//
+// Three subcommands run and exit without binding anything, so they are safe to
+// invoke while the daemon is up:
+//
+// specsrht space create ~owner/name | specsrht space list
+// specsrht token create <name> | specsrht token list | specsrht token revoke <id>
+// specsrht doc propose ~owner/space <file>... [--as path] [--title t]
+//
+// The first two are the only entry points spaces and agent tokens have. A
+// deployment without both is inert: nothing to hold documents, and no credential
+// for the remote agent write plane, which refuses an anonymous caller by design.
+// The third opens a proposal from files on this host, for when the documents and
+// the operator are already here and a bearer token would be ceremony.
+//
// # Flags
//
// Parsed by core-go's server.New:
@@ 114,12 129,19 @@ func main() {
// Admin subcommands run and exit without binding anything, so they are safe
// to invoke while the daemon holds the hook socket.
- if len(os.Args) > 1 && os.Args[1] == "space" {
- if err := runSpace(os.Args[2:]); err != nil {
- fmt.Fprintf(os.Stderr, "specsrht space: %v\n", err)
- os.Exit(1)
+ if len(os.Args) > 1 {
+ admin := map[string]func([]string) error{
+ "space": runSpace,
+ "token": runToken,
+ "doc": runDoc,
+ }
+ if cmd := os.Args[1]; admin[cmd] != nil {
+ if err := admin[cmd](os.Args[2:]); err != nil {
+ fmt.Fprintf(os.Stderr, "specsrht %s: %v\n", cmd, err)
+ os.Exit(1)
+ }
+ return
}
- return
}
if err := run(log); err != nil {
A cmd/specsrht/token.go => cmd/specsrht/token.go +133 -0
@@ 0,0 1,133 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "text/tabwriter"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/db"
+)
+
+const tokenUsage = "usage: specsrht token create <name> | specsrht token list | specsrht token revoke <id>"
+
+// runToken is the agent-token administration command:
+//
+// specsrht token create <name>
+// specsrht token list
+// specsrht token revoke <id>
+//
+// Agent tokens have no other entry point, and without one a freshly deployed
+// instance cannot be written to at all: both agent write surfaces — the REST
+// PUT and mcpsrv's spec_propose — refuse an anonymous caller, and the human
+// path (native receive-pack) is approval rather than proposal. The alternative
+// to this command is an operator hand-writing an INSERT with a sha256 hash,
+// which is exactly the shape of mistake that ends with an unusable credential
+// and no way to tell why.
+//
+// It talks to [db.Store] directly rather than going through service.New. Tokens
+// touch neither git nor the index, so pulling in the repo root, the hooks and
+// bleve to mint a row would only add ways for the command to fail on an
+// instance whose daemon is otherwise fine.
+func runToken(args []string) error {
+ if len(args) == 0 {
+ return errors.New(tokenUsage)
+ }
+
+ conf := config.LoadConfig()
+ cfg, err := validateConfig(conf)
+ if err != nil {
+ return err
+ }
+ pool, err := openDatabase(cfg.ConnectionString)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+
+ store := db.NewStore(pool)
+ ctx := context.Background()
+
+ switch args[0] {
+ case "create":
+ if len(args) != 2 {
+ return errors.New("usage: specsrht token create <name>")
+ }
+ name := args[1]
+
+ // The plaintext exists only here: it is generated, hashed, stored as a
+ // hash, and printed once. Nothing writes it to the log, because a token
+ // in a log file is a token in a backup.
+ token, err := db.GenerateToken()
+ if err != nil {
+ return err
+ }
+ row, err := store.CreateAgentToken(ctx, name, db.HashToken(token))
+ if err != nil {
+ return err
+ }
+ fmt.Printf("created agent token %q (id %d)\n\n %s\n\n"+
+ "This is the only time the token is shown — only its hash is stored.\n"+
+ "Agents present it as: Authorization: Bearer <token>\n",
+ row.Name, row.ID, token)
+ return nil
+
+ case "list":
+ tokens, err := store.ListAgentTokens(ctx)
+ if err != nil {
+ return err
+ }
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "ID\tNAME\tCREATED\tSTATE")
+ for _, t := range tokens {
+ fmt.Fprintln(w, formatTokenRow(t))
+ }
+ return w.Flush()
+
+ case "revoke":
+ if len(args) != 2 {
+ return errors.New("usage: specsrht token revoke <id>")
+ }
+ id, err := parseTokenID(args[1])
+ if err != nil {
+ return err
+ }
+ if err := store.RevokeAgentToken(ctx, id); err != nil {
+ return err
+ }
+ fmt.Printf("revoked agent token %d\n", id)
+ return nil
+
+ default:
+ return fmt.Errorf("unknown subcommand %q: want create, list or revoke", args[0])
+ }
+}
+
+// formatTokenRow renders one token as a tab-separated line for `token list`.
+// The hash is deliberately not shown: it identifies nothing an operator acts
+// on, and printing a column of it would only invite treating it as the
+// credential.
+func formatTokenRow(t *db.AgentToken) string {
+ state := "active"
+ if !t.Active() {
+ state = "revoked " + t.Revoked.Format(time.RFC3339)
+ }
+ return fmt.Sprintf("%d\t%s\t%s\t%s", t.ID, t.Name, t.Created.Format(time.RFC3339), state)
+}
+
+// parseTokenID reads the id argument of `token revoke`. It rejects anything
+// that is not a positive integer here rather than letting a typo become an
+// UPDATE that matches no row and reports "not found", which reads like the
+// token is already gone.
+func parseTokenID(s string) (int, error) {
+ id, err := strconv.Atoi(s)
+ if err != nil || id <= 0 {
+ return 0, fmt.Errorf("token id %q is not a positive integer; `specsrht token list` shows the ids", s)
+ }
+ return id, nil
+}
A cmd/specsrht/token_test.go => cmd/specsrht/token_test.go +84 -0
@@ 0,0 1,84 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/db"
+)
+
+func TestParseTokenIDRejectsWhatIsNotAnID(t *testing.T) {
+ for _, in := range []string{"", "0", "-1", "3.0", "abc", " 3", "3 "} {
+ if _, err := parseTokenID(in); err == nil {
+ t.Errorf("parseTokenID(%q) was accepted", in)
+ }
+ }
+ id, err := parseTokenID("42")
+ if err != nil {
+ t.Fatalf("parseTokenID(\"42\"): %v", err)
+ }
+ if id != 42 {
+ t.Errorf("parseTokenID(\"42\") = %d want 42", id)
+ }
+}
+
+// TestParseTokenIDPointsAtTheListing keeps the failure actionable: an operator
+// who mistyped an id needs to be told where the ids come from, not just that
+// this one was wrong.
+func TestParseTokenIDPointsAtTheListing(t *testing.T) {
+ _, err := parseTokenID("nope")
+ if err == nil {
+ t.Fatal("parseTokenID accepted a non-numeric id")
+ }
+ if !strings.Contains(err.Error(), "specsrht token list") {
+ t.Errorf("the error does not say how to find the ids:\n%v", err)
+ }
+}
+
+func TestFormatTokenRow(t *testing.T) {
+ created := time.Date(2026, 8, 5, 9, 30, 0, 0, time.UTC)
+ revoked := created.Add(24 * time.Hour)
+
+ active := formatTokenRow(&db.AgentToken{ID: 1, Name: "claude", Created: created})
+ if want := "1\tclaude\t2026-08-05T09:30:00Z\tactive"; active != want {
+ t.Errorf("active row = %q want %q", active, want)
+ }
+
+ dead := formatTokenRow(&db.AgentToken{ID: 2, Name: "old", Created: created, Revoked: &revoked})
+ if want := "2\told\t2026-08-05T09:30:00Z\trevoked 2026-08-06T09:30:00Z"; dead != want {
+ t.Errorf("revoked row = %q want %q", dead, want)
+ }
+}
+
+// TestFormatTokenRowKeepsTheHashOut guards the one thing this listing must not
+// leak into an operator's terminal or scrollback: the stored hash is not a
+// credential and printing it invites treating it as one.
+func TestFormatTokenRowKeepsTheHashOut(t *testing.T) {
+ row := formatTokenRow(&db.AgentToken{
+ ID: 3,
+ Name: "agent",
+ Hash: db.HashToken("s3cret"),
+ Created: time.Date(2026, 8, 5, 9, 30, 0, 0, time.UTC),
+ })
+ if strings.Contains(row, "[") || strings.ContainsAny(row, "%") {
+ t.Errorf("the row appears to render the hash bytes: %q", row)
+ }
+ if strings.Contains(row, string(db.HashToken("s3cret"))) {
+ t.Errorf("the row carries the stored hash: %q", row)
+ }
+}
+
+// TestRunTokenRejectsBadInvocationsBeforeTheDatabase guards the ordering that
+// makes this command usable on a workstation: a usage error must be reported
+// without a config file or a Postgres connection, which is what an operator
+// typing it blind has.
+func TestRunTokenRejectsBadInvocationsBeforeTheDatabase(t *testing.T) {
+ err := runToken(nil)
+ if err == nil {
+ t.Fatal("runToken accepted an empty argument list")
+ }
+ if err.Error() != tokenUsage {
+ t.Errorf("empty invocation did not print the usage line:\n%v", err)
+ }
+}