Agent Runtime & Harness Invocation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the runtime that lets the Go server invoke Claude Code as the Agent — spawning headless claude -p subprocesses, exposing the work surface through a wb-agent CLI, tracking jobs in an agent_jobs table, and wiring the trigger HTTP API.

Architecture: A new internal/agent package owns the queue and the Runner interface; a new cmd/wb-agent binary opens the existing internal/collab code paths for the agent's writes; a new migration relaxes incorporation_proposals.proposed_by and creates agent_jobs. Two project-local skills (wb-incorporate, wb-perspective) scaffold the agent's task contract with <REWRITE CONTRACT OWNED BY #4/#5> placeholders. wiki-browser validates both binaries, the git-author config, and the queue cap at startup.

Tech Stack: Go 1.26, database/sql with modernc.org/sqlite, os/exec with Setpgid + cmd.Cancel + cmd.WaitDelay, embedded migrations, Claude Code 2.x CLI.

Reference spec: docs/superpowers/specs/2026-05-12-agent-runtime-design.html. Cross-cutting context: docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md. #1 reference: docs/superpowers/specs/2026-05-11-document-model-design.html. #7 (auth) is assumed landed — the same auth.RequireCollaborator + auth.RequireCSRF middleware used by /api/topics wraps the new agent-jobs endpoints.


File Structure

internal/collab/migrations/
└── 003_agent_runtime.sql                   # NEW: -- migrate:no-tx; relax proposed_by + create agent_jobs

internal/collab/
├── migrate.go                              # MODIFIED: recognize -- migrate:no-tx directive
├── migrate_test.go                         # MODIFIED: tests for directive + migration 003
├── mutators.go                             # MODIFIED: NewProposal.ProposedBy → *string
├── mutators_test.go                        # MODIFIED: null ProposedBy test
├── agent_jobs.go                           # NEW: InsertJob, StartJob, CompleteJob, ListJobs*, SweepIncompleteJobs
└── agent_jobs_test.go                      # NEW

internal/config/
├── config.go                               # MODIFIED: Agent struct + defaults + validation
└── config_test.go                          # MODIFIED

internal/agent/
├── runner.go                               # NEW: Job, RunResult, Runner interface
├── runner_test.go                          # NEW
├── fake_runner.go                          # NEW: deterministic Runner for tests
├── claude_cli_runner.go                    # NEW: real subprocess runner
├── claude_cli_runner_test.go               # NEW: uses a fake-claude shell script
├── service.go                              # NEW: queue + lifecycle + agent_jobs writes
└── service_test.go                         # NEW

cmd/wb-agent/
├── main.go                                 # NEW: subcommand dispatcher
├── get_topic.go                            # NEW
├── list_open_topics.go                     # NEW
├── insert_proposal.go                      # NEW
├── stubs.go                                # NEW: get-persona + put-perspective scaffolds
└── main_test.go                            # NEW

internal/server/
├── server.go                               # MODIFIED: Deps gains AgentService; new routes
├── agent_jobs.go                           # NEW: POST + GET handlers
└── agent_jobs_test.go                      # NEW

cmd/wiki-browser/
└── main.go                                 # MODIFIED: validate agent binaries, start service, sweep at boot

.claude/skills/wb-incorporate/SKILL.md      # NEW: scaffold with <REWRITE CONTRACT OWNED BY #4>
.claude/skills/wb-perspective/SKILL.md      # NEW: scaffold with <REWRITE CONTRACT OWNED BY #5>

Makefile                                    # MODIFIED: build wb-agent alongside wiki-browser
wiki-browser.example.yaml                   # MODIFIED: agent: block with all keys

Tasks 1-4 set the foundation (migration runner enhancement, the migration itself, Go mutator changes). Tasks 5-9 build the agent runtime in isolation, testable with FakeRunner. Tasks 10-14 build wb-agent. Tasks 15-17 wire it into the HTTP server and the wiki-browser binary. Task 18 commits the skill scaffolds. Task 19 is a full end-to-end smoke test.


Task 1: Migration runner — -- migrate:no-tx directive

Files:

The existing runner wraps every migration in a transaction. SQLite's PRAGMA foreign_keys is a no-op inside a transaction, so the table rebuild in Task 2 needs a way to opt out. A one-line directive recognized as the first non-blank line of the SQL file makes the runner skip its own BEGIN/COMMIT; the file then owns its own transaction and FK toggling. The schema_migrations insert still happens in a short tx afterward.

Add to internal/collab/migrate_test.go:

func TestMigrate_NoTxDirective(t *testing.T) {
	db := openMemDB(t)
	defer db.Close()

	mfs := fstest.MapFS{
		"001_setup.sql": &fstest.MapFile{Data: []byte(
			`CREATE TABLE t (x INTEGER);`,
		)},
		"002_notx.sql": &fstest.MapFile{Data: []byte(
			"-- migrate:no-tx\n" +
				"BEGIN;\n" +
				"INSERT INTO t(x) VALUES (1);\n" +
				"COMMIT;\n" +
				"INSERT INTO t(x) VALUES (2);\n", // outside the file's own tx; runner must not wrap
		)},
	}
	if err := collab.Migrate(db, mfs); err != nil {
		t.Fatalf("Migrate: %v", err)
	}

	var got int
	if err := db.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&got); err != nil {
		t.Fatalf("scan: %v", err)
	}
	if got != 2 {
		t.Fatalf("want 2 rows, got %d", got)
	}

	var rec string
	if err := db.QueryRow(
		`SELECT filename FROM schema_migrations WHERE filename = '002_notx.sql'`,
	).Scan(&rec); err != nil {
		t.Fatalf("schema_migrations missing 002_notx.sql: %v", err)
	}
}

Add helper if not present:

import (
	"database/sql"
	"testing"
	"testing/fstest"

	_ "modernc.org/sqlite"
)

func openMemDB(t *testing.T) *sql.DB {
	t.Helper()
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	return db
}
go test ./internal/collab/ -run TestMigrate_NoTxDirective -v

Expected: FAIL — current applyOne will wrap the BEGIN/COMMIT-containing file in another transaction, causing a cannot start a transaction within a transaction SQL error or similar.

Replace the body of applyOne and add the directive detector. Replace the existing applyOne function with:

const noTxDirective = "-- migrate:no-tx"

// firstNonBlankLine returns the first non-empty trimmed line of body.
func firstNonBlankLine(body string) string {
	for _, line := range strings.Split(body, "\n") {
		t := strings.TrimSpace(line)
		if t != "" {
			return t
		}
	}
	return ""
}

func applyOne(db *sql.DB, name, body string) error {
	if firstNonBlankLine(body) == noTxDirective {
		// Migration owns its own tx + any PRAGMA toggling. The runner records
		// the schema_migrations row afterward in a separate short transaction.
		// If the migration body commits but bookkeeping fails (full disk, conn
		// eviction), the database can temporarily contain a forward schema with
		// no recorded migration. Recovery is "fix the underlying issue, then
		// rerun migrations", so -- migrate:no-tx migrations must be internally
		// idempotent (use CREATE TABLE IF NOT EXISTS / guard with schema
		// introspection).
		if _, err := db.Exec(body); err != nil {
			return fmt.Errorf("apply %s: %w", name, err)
		}
		tx, err := db.Begin()
		if err != nil {
			return fmt.Errorf("record %s: begin: %w", name, err)
		}
		if _, err := tx.Exec(
			`INSERT INTO schema_migrations(filename) VALUES (?)`, name,
		); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("record %s: %w", name, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("record %s: commit: %w", name, err)
		}
		return nil
	}

	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("begin %s: %w", name, err)
	}
	if _, err := tx.Exec(body); err != nil {
		_ = tx.Rollback()
		return fmt.Errorf("apply %s: %w", name, err)
	}
	if _, err := tx.Exec(
		`INSERT INTO schema_migrations(filename) VALUES (?)`, name,
	); err != nil {
		_ = tx.Rollback()
		return fmt.Errorf("record %s: %w", name, err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit %s: %w", name, err)
	}
	return nil
}
go test ./internal/collab/ -run TestMigrate -v

Expected: PASS (both existing and new tests).

git add internal/collab/migrate.go internal/collab/migrate_test.go
git commit -m "wiki-browser: collab — migrate runner recognizes -- migrate:no-tx directive"

Task 2: Migration 003 — relax proposed_by, create agent_jobs

Files:

The migration runs the SQLite twelve-step table rebuild on incorporation_proposals (to drop NOT NULL on proposed_by) and creates agent_jobs. It must toggle foreign_keys off, which is only possible outside a transaction — so it uses the -- migrate:no-tx directive from Task 1.

Add to internal/collab/migrate_test.go:

func TestMigrate_003_AgentRuntime(t *testing.T) {
	dbPath := filepath.Join(t.TempDir(), "collab.db")
	db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer db.Close()

	if err := collab.Migrate(db, collab.MigrationsFS); err != nil {
		t.Fatalf("Migrate: %v", err)
	}

	// 1. proposed_by is now nullable.
	if _, err := db.Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil {
		t.Fatalf("seed user: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
		 VALUES ('t1','docs/foo.md','{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
	); err != nil {
		t.Fatalf("seed topic: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO incorporation_proposals(
		   id, topic_id, revision_number, proposed_source, base_source_sha,
		   proposed_by, created_at
		 ) VALUES ('p1','t1', 1, 'body', 'deadbeef', NULL, unixepoch())`,
	); err != nil {
		t.Fatalf("insert with null proposed_by: %v", err)
	}

	// 2. Existing non-null rows survive (insert before checking by adding one).
	if _, err := db.Exec(
		`INSERT INTO incorporation_proposals(
		   id, topic_id, revision_number, proposed_source, base_source_sha,
		   proposed_by, created_at
		 ) VALUES ('p2','t1', 2, 'body2', 'cafef00d', 'u1', unixepoch())`,
	); err != nil {
		t.Fatalf("insert with non-null proposed_by: %v", err)
	}

	// 3. agent_jobs CHECK rejects mismatched discriminators.
	if _, err := db.Exec(
		`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
		                       status, created_at)
		 VALUES ('j1','incorporate','docs/foo.md','t1','PERSONA','queued', unixepoch())`,
	); err == nil {
		t.Fatalf("expected CHECK violation for incorporate+persona_name set")
	}
	if _, err := db.Exec(
		`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
		                       status, created_at)
		 VALUES ('j2','incorporate','docs/foo.md','t1', NULL,'queued', unixepoch())`,
	); err != nil {
		t.Fatalf("valid incorporate row rejected: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
		                       status, created_at)
		 VALUES ('j3','perspective','docs/bar.md', NULL, 'CFO','queued', unixepoch())`,
	); err != nil {
		t.Fatalf("valid perspective row rejected: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO agent_jobs(id, kind, source_path, topic_id, persona_name,
		                       status, created_at)
		 VALUES ('j4','incorporate','docs/foo.md','t1', NULL,'queued', unixepoch())`,
	); err == nil {
		t.Fatalf("expected partial unique violation for second queued job on same source")
	}

	// 4. foreign_key_check passes after the rebuild.
	rows, err := db.Query(`PRAGMA foreign_key_check`)
	if err != nil {
		t.Fatalf("foreign_key_check: %v", err)
	}
	if rows.Next() {
		t.Fatalf("foreign_key_check returned violations after migration")
	}
	rows.Close()

	// 5. Composite FK from incorporation_attempts to (id, topic_id) survived
	// the rebuild. The 12-step rebuild has to rewrite FKs in *dependent*
	// tables' schema; if the rename didn't propagate, this FK becomes a
	// no-op and bogus inserts would silently succeed. Verify both
	// directions: a valid attempt referencing p2 inserts cleanly, and a
	// bogus (proposal_id, topic_id) is rejected.
	if _, err := db.Exec(
		`INSERT INTO incorporation_attempts(
		   id, proposal_id, topic_id, source_path, base_source_sha,
		   approved_by, approved_at, created_at
		 ) VALUES ('a1','p2','t1','docs/foo.md','cafef00d',
		           'u1', unixepoch(), unixepoch())`,
	); err != nil {
		t.Fatalf("valid incorporation_attempts insert: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO incorporation_attempts(
		   id, proposal_id, topic_id, source_path, base_source_sha,
		   approved_by, approved_at, created_at
		 ) VALUES ('a2','does-not-exist','t1','docs/foo.md','cafef00d',
		           'u1', unixepoch(), unixepoch())`,
	); err == nil {
		t.Fatalf("expected composite FK violation for bogus proposal_id; the rebuild's ALTER TABLE ... RENAME must rewrite the FK in incorporation_attempts")
	}
	rows2, err := db.Query(`PRAGMA foreign_key_check(incorporation_attempts)`)
	if err != nil {
		t.Fatalf("foreign_key_check incorporation_attempts: %v", err)
	}
	if rows2.Next() {
		t.Fatalf("incorporation_attempts foreign_key_check returned violations")
	}
	rows2.Close()

	// 6. Re-applying the no-tx migration after a bookkeeping failure is
	// idempotent. Simulate "body committed, schema_migrations insert failed"
	// by deleting only the migration record, then run Migrate again against
	// populated tables.
	if _, err := db.Exec(
		`DELETE FROM schema_migrations WHERE filename = '003_agent_runtime.sql'`,
	); err != nil {
		t.Fatalf("delete schema_migrations record: %v", err)
	}
	if err := collab.Migrate(db, collab.MigrationsFS); err != nil {
		t.Fatalf("re-Migrate after bookkeeping loss: %v", err)
	}
	var count int
	if err := db.QueryRow(
		`SELECT COUNT(*) FROM incorporation_proposals WHERE topic_id = 't1'`,
	).Scan(&count); err != nil {
		t.Fatalf("count proposals after reapply: %v", err)
	}
	if count != 2 {
		t.Fatalf("proposal count after reapply = %d, want 2", count)
	}
}
go test ./internal/collab/ -run TestMigrate_003_AgentRuntime -v

Expected: FAIL — migration file does not exist yet.

Create internal/collab/migrations/003_agent_runtime.sql:

-- migrate:no-tx
-- Relax incorporation_proposals.proposed_by from NOT NULL to nullable, and
-- create agent_jobs.
--
-- The proposed_by relaxation requires SQLite's twelve-step table rebuild,
-- which in turn requires PRAGMA foreign_keys = OFF. That pragma is a no-op
-- inside a transaction, so this migration carries the -- migrate:no-tx
-- directive above and manages its own BEGIN/COMMIT.
--
-- This migration must be idempotent. The no-tx runner records
-- schema_migrations in a separate short transaction after the body executes,
-- so a successful body + failed bookkeeping leaves the body in a state where
-- it will be re-applied on next boot. Idempotency requirements:
--   * CREATE TABLE / CREATE INDEX use IF NOT EXISTS for the new objects.
--   * The 12-step rebuild is safe to re-run: after a successful first run,
--     incorporation_proposals already has the relaxed schema; redoing the
--     rebuild copies it through _new and lands at the same shape.
--   * `legacy_alter_table` is forced OFF so the rename rewrites foreign-key
--     references in dependent tables' schema (e.g. incorporation_attempts).

PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = OFF;

BEGIN;

CREATE TABLE incorporation_proposals_new (
  id              TEXT PRIMARY KEY,
  topic_id        TEXT NOT NULL,
  revision_number INTEGER NOT NULL,
  proposed_source TEXT NOT NULL,
  base_source_sha TEXT NOT NULL,
  proposed_by     TEXT,
  created_at      INTEGER NOT NULL,
  FOREIGN KEY (topic_id)    REFERENCES topics(id),
  FOREIGN KEY (proposed_by) REFERENCES users(id)
);
INSERT INTO incorporation_proposals_new
  SELECT id, topic_id, revision_number, proposed_source, base_source_sha,
         proposed_by, created_at
    FROM incorporation_proposals;
DROP TABLE incorporation_proposals;
ALTER TABLE incorporation_proposals_new RENAME TO incorporation_proposals;
CREATE UNIQUE INDEX IF NOT EXISTS incorporation_proposals_topic_rev
  ON incorporation_proposals(topic_id, revision_number);
CREATE UNIQUE INDEX IF NOT EXISTS incorporation_proposals_id_topic
  ON incorporation_proposals(id, topic_id);

-- agent_jobs — single source of truth for what the runtime is doing.
CREATE TABLE IF NOT EXISTS agent_jobs (
  id            TEXT PRIMARY KEY,
  kind          TEXT NOT NULL,
  source_path   TEXT NOT NULL,
  topic_id      TEXT,
  persona_name  TEXT,
  status        TEXT NOT NULL,
  started_at    INTEGER,
  completed_at  INTEGER,
  exit_code     INTEGER,
  error_tail    TEXT,
  created_at    INTEGER NOT NULL,
  CHECK (status IN ('queued','running','succeeded','failed','timed_out')),
  CHECK (
    (kind = 'incorporate' AND topic_id IS NOT NULL AND persona_name IS NULL) OR
    (kind = 'perspective' AND persona_name IS NOT NULL AND topic_id IS NULL)
  ),
  CHECK ((status IN ('queued','running')) = (completed_at IS NULL)),
  FOREIGN KEY (topic_id) REFERENCES topics(id)
);
CREATE INDEX IF NOT EXISTS agent_jobs_status      ON agent_jobs(status);
CREATE INDEX IF NOT EXISTS agent_jobs_source_path ON agent_jobs(source_path, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS agent_jobs_one_inflight_source
  ON agent_jobs(source_path)
  WHERE status IN ('queued','running');

PRAGMA foreign_key_check;

COMMIT;

PRAGMA foreign_keys = ON;
go test ./internal/collab/ -run TestMigrate_003_AgentRuntime -v

Expected: PASS.

go test ./internal/collab/...

Expected: PASS.

git add internal/collab/migrations/003_agent_runtime.sql internal/collab/migrate_test.go
git commit -m "wiki-browser: collab — migration 003 relax proposed_by, create agent_jobs"

Task 3: Go layer — NewProposal.ProposedBy *string

Files:

NewProposal.ProposedBy becomes *string so callers can pass nil to record an Agent-authored proposal. The required-fields check loses that key. Human-driven proposals (when they exist post-#4) pass a non-nil pointer.

Add to internal/collab/mutators_test.go:

func TestInsertProposal_NullProposedBy(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()

	seedUser(t, s, "u1", "U1")
	seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")

	id, err := s.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "new body",
		BaseSourceSHA:  "deadbeef",
		ProposedBy:     nil, // Agent proposal
	})
	if err != nil {
		t.Fatalf("InsertProposal: %v", err)
	}
	if id != "p1" {
		t.Fatalf("id = %s, want p1", id)
	}

	var got sql.NullString
	if err := s.RawDBForTest().QueryRow(
		`SELECT proposed_by FROM incorporation_proposals WHERE id = 'p1'`,
	).Scan(&got); err != nil {
		t.Fatalf("scan: %v", err)
	}
	if got.Valid {
		t.Fatalf("proposed_by valid = true, want null")
	}
}

Assume openTestStore, seedUser, seedTopic exist as test helpers — if they don't, add the obvious helpers using the existing public API.

go test ./internal/collab/ -run TestInsertProposal_NullProposedBy -v

Expected: compile error — ProposedBy is string, not *string.

In internal/collab/mutators.go, change NewProposal:

type NewProposal struct {
	ID             string
	TopicID        string
	RevisionNumber int
	ProposedSource string
	BaseSourceSHA  string
	ProposedBy     *string // nil = Agent-authored
}

Update InsertProposal:

func (s *Store) InsertProposal(p NewProposal) (string, error) {
	if p.ID == "" || p.TopicID == "" || p.RevisionNumber < 1 ||
		p.BaseSourceSHA == "" {
		return "", fmt.Errorf("collab.InsertProposal: required fields missing")
	}
	err := s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO incorporation_proposals(
			   id, topic_id, revision_number, proposed_source,
			   base_source_sha, proposed_by, created_at
			 ) VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
			p.ID, p.TopicID, p.RevisionNumber, p.ProposedSource,
			p.BaseSourceSHA, p.ProposedBy,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return p.ID, nil
}

(*string flows through database/sql as either the underlying value or NULL.)

Search for callers:

git grep -n "InsertProposal\|NewProposal{" -- '*.go'

Update each ProposedBy: "..." to ProposedBy: ptr("...") using a small local helper, or use &someVar. If any test sets a literal string, change to a pointer.

Add this helper to internal/collab/mutators_test.go if multiple call sites need it:

func strPtr(s string) *string { return &s }
go test ./internal/collab/...

Expected: PASS.

git add internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "wiki-browser: collab — NewProposal.ProposedBy is *string (nil = Agent)"

Task 4: agent_jobs mutators and readers

Files:

Adds three lifecycle mutators (InsertJob, StartJob, CompleteJob), a sweep used at startup (SweepIncompleteJobs), and the reads the HTTP layer will need (ListJobsForSource, GetJob, HasInflightForSource). The migration's partial unique index is the DB-level invariant for one queued/running job per source_path; the service's in-memory map is only an optimization.

Create internal/collab/agent_jobs_test.go:

package collab_test

import (
	"database/sql"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func intPtr(i int) *int { return &i }

func TestAgentJobs_HappyPath(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()
	seedUser(t, s, "u1", "U1")
	seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")

	id := "j1"
	if err := s.InsertJob(collab.NewJob{
		ID: id, Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
	}); err != nil {
		t.Fatalf("InsertJob: %v", err)
	}

	if inflight, err := s.HasInflightForSource("docs/foo.md"); err != nil {
		t.Fatalf("HasInflightForSource: %v", err)
	} else if !inflight {
		t.Fatalf("HasInflightForSource = false; want true")
	}

	if err := s.StartJob(id); err != nil {
		t.Fatalf("StartJob: %v", err)
	}
	if err := s.CompleteJob(collab.CompleteJobInput{
		ID: id, Status: "succeeded", ExitCode: intPtr(0), ErrorTail: "",
	}); err != nil {
		t.Fatalf("CompleteJob: %v", err)
	}

	if inflight, err := s.HasInflightForSource("docs/foo.md"); err != nil {
		t.Fatalf("HasInflightForSource after complete: %v", err)
	} else if inflight {
		t.Fatalf("HasInflightForSource still true after complete")
	}

	jobs, err := s.ListJobsForSource("docs/foo.md", 10)
	if err != nil {
		t.Fatalf("ListJobsForSource: %v", err)
	}
	if len(jobs) != 1 {
		t.Fatalf("len(jobs) = %d, want 1", len(jobs))
	}
	if jobs[0].Status != "succeeded" || jobs[0].ExitCode == nil || *jobs[0].ExitCode != 0 {
		t.Fatalf("job: %+v", jobs[0])
	}
}

func TestAgentJobs_PerspectiveKind(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()
	if err := s.InsertJob(collab.NewJob{
		ID: "j2", Kind: "perspective", SourcePath: "docs/foo.md",
		PersonaName: strPtr("CFO"),
	}); err != nil {
		t.Fatalf("InsertJob: %v", err)
	}
}

func TestAgentJobs_InvalidKindOrMissingDiscriminator(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()

	if err := s.InsertJob(collab.NewJob{
		ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md",
	}); err == nil {
		t.Fatalf("expected error: incorporate without topic_id")
	}
	if err := s.InsertJob(collab.NewJob{
		ID: "j4", Kind: "perspective", SourcePath: "docs/foo.md",
	}); err == nil {
		t.Fatalf("expected error: perspective without persona_name")
	}
	if err := s.InsertJob(collab.NewJob{
		ID: "j5", Kind: "other", SourcePath: "docs/foo.md",
	}); err == nil {
		t.Fatalf("expected error: unsupported kind")
	}
}

func TestAgentJobs_RejectsSecondInflightForSameSource(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()
	seedUser(t, s, "u1", "U1")
	seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")

	if err := s.InsertJob(collab.NewJob{
		ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
	}); err != nil {
		t.Fatalf("InsertJob first: %v", err)
	}
	if err := s.InsertJob(collab.NewJob{
		ID: "j2", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
	}); err == nil {
		t.Fatalf("expected partial unique violation for second queued job on same source")
	}
	if err := s.CompleteJob(collab.CompleteJobInput{
		ID: "j1", Status: "failed", ErrorTail: "done",
	}); err != nil {
		t.Fatalf("CompleteJob first: %v", err)
	}
	if err := s.InsertJob(collab.NewJob{
		ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
	}); err != nil {
		t.Fatalf("InsertJob after terminal state: %v", err)
	}
}

func TestAgentJobs_SweepIncomplete(t *testing.T) {
	s := openTestStore(t)
	defer s.Close()
	seedUser(t, s, "u1", "U1")
	seedTopic(t, s, "t1", "docs/foo.md", `{"kind":"global"}`, "u1")
	seedTopic(t, s, "t2", "docs/bar.md", `{"kind":"global"}`, "u1")

	if err := s.InsertJob(collab.NewJob{
		ID: "queued-job", Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: strPtr("t1"),
	}); err != nil {
		t.Fatalf("InsertJob queued: %v", err)
	}
	if err := s.InsertJob(collab.NewJob{
		ID: "running-job", Kind: "incorporate", SourcePath: "docs/bar.md", TopicID: strPtr("t2"),
	}); err != nil {
		t.Fatalf("InsertJob running: %v", err)
	}
	if err := s.StartJob("running-job"); err != nil {
		t.Fatalf("StartJob: %v", err)
	}

	n, err := s.SweepIncompleteJobs()
	if err != nil {
		t.Fatalf("SweepIncompleteJobs: %v", err)
	}
	if n != 2 {
		t.Fatalf("swept %d, want 2", n)
	}

	// Both should now be 'failed' with the expected error_tail.
	rows, err := s.RawDBForTest().Query(
		`SELECT id, status, error_tail FROM agent_jobs ORDER BY id`,
	)
	if err != nil {
		t.Fatalf("query: %v", err)
	}
	defer rows.Close()
	type row struct {
		id, status string
		errTail    sql.NullString
	}
	var got []row
	for rows.Next() {
		var r row
		if err := rows.Scan(&r.id, &r.status, &r.errTail); err != nil {
			t.Fatalf("scan: %v", err)
		}
		got = append(got, r)
	}
	for _, r := range got {
		if r.status != "failed" {
			t.Fatalf("%s status = %s, want failed", r.id, r.status)
		}
		if !r.errTail.Valid || r.errTail.String != "server restarted while job in flight" {
			t.Fatalf("%s error_tail = %#v", r.id, r.errTail)
		}
	}
}
go test ./internal/collab/ -run TestAgentJobs -v

Expected: compile error — NewJob, InsertJob, etc. do not exist.

Production code (the new wb-agent CLI and internal/agent.Service) needs to issue read-only queries the typed mutators don't expose. The existing RawDBForTest() exists but its name advertises "test-only." Add an aliased method without the ForTest suffix and keep the existing method for the test code that already uses it. In internal/collab/collab.go:

// RawDB returns the underlying *sql.DB. Callers may use it for read-only
// queries not covered by the typed APIs. Writes should always go through
// the typed mutators so they flow through the write funnel.
func (s *Store) RawDB() *sql.DB { return s.db }

(No test required — it's a one-line accessor.)

package collab

import (
	"database/sql"
	"fmt"
)

// NewJob is the input for InsertJob.
type NewJob struct {
	ID          string
	Kind        string  // "incorporate" | "perspective"
	SourcePath  string
	TopicID     *string // non-nil iff Kind == "incorporate"
	PersonaName *string // non-nil iff Kind == "perspective"
}

// AgentJob is the read shape returned by ListJobsForSource / GetJob.
type AgentJob struct {
	ID          string  `json:"id"`
	Kind        string  `json:"kind"`
	SourcePath  string  `json:"source_path"`
	TopicID     *string `json:"topic_id,omitempty"`
	PersonaName *string `json:"persona_name,omitempty"`
	Status      string  `json:"status"`
	StartedAt   *int64  `json:"started_at,omitempty"`
	CompletedAt *int64  `json:"completed_at,omitempty"`
	ExitCode    *int    `json:"exit_code,omitempty"`
	ErrorTail   *string `json:"error_tail,omitempty"`
	CreatedAt   int64   `json:"created_at"`
}

// CompleteJobInput is the terminal-state write.
type CompleteJobInput struct {
	ID        string
	Status    string // "succeeded" | "failed" | "timed_out"
	ExitCode  *int   // nil when no process exit code exists (spawn error/timeout)
	ErrorTail string // ""=null in the column
}

func validateNewJob(j NewJob) error {
	if j.ID == "" || j.SourcePath == "" {
		return fmt.Errorf("collab.InsertJob: id/source_path required")
	}
	if _, err := ValidateSourcePath(j.SourcePath); err != nil {
		return fmt.Errorf("collab.InsertJob: %w", err)
	}
	switch j.Kind {
	case "incorporate":
		if j.TopicID == nil || *j.TopicID == "" || j.PersonaName != nil {
			return fmt.Errorf("collab.InsertJob: incorporate requires topic_id and no persona_name")
		}
	case "perspective":
		if j.PersonaName == nil || *j.PersonaName == "" || j.TopicID != nil {
			return fmt.Errorf("collab.InsertJob: perspective requires persona_name and no topic_id")
		}
	default:
		return fmt.Errorf("collab.InsertJob: unsupported kind %q", j.Kind)
	}
	return nil
}

func (s *Store) InsertJob(j NewJob) error {
	if err := validateNewJob(j); err != nil {
		return err
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO agent_jobs(
			   id, kind, source_path, topic_id, persona_name,
			   status, created_at
			 ) VALUES (?, ?, ?, ?, ?, 'queued', unixepoch())`,
			j.ID, j.Kind, j.SourcePath, j.TopicID, j.PersonaName,
		)
		return err
	})
}

func (s *Store) StartJob(id string) error {
	if id == "" {
		return fmt.Errorf("collab.StartJob: id required")
	}
	return s.send(func(db *sql.DB) error {
		res, err := db.Exec(
			`UPDATE agent_jobs
			   SET status = 'running', started_at = unixepoch()
			 WHERE id = ? AND status = 'queued'`,
			id,
		)
		if err != nil {
			return err
		}
		n, err := res.RowsAffected()
		if err != nil {
			return err
		}
		if n != 1 {
			return fmt.Errorf("collab.StartJob: %s not in queued state", id)
		}
		return nil
	})
}

func (s *Store) CompleteJob(in CompleteJobInput) error {
	if in.ID == "" {
		return fmt.Errorf("collab.CompleteJob: id required")
	}
	switch in.Status {
	case "succeeded", "failed", "timed_out":
	default:
		return fmt.Errorf("collab.CompleteJob: bad status %q", in.Status)
	}
	var errTail any
	if in.ErrorTail != "" {
		errTail = in.ErrorTail
	}
	return s.send(func(db *sql.DB) error {
		res, err := db.Exec(
			`UPDATE agent_jobs
			   SET status = ?, exit_code = ?, error_tail = ?,
			       completed_at = unixepoch()
			 WHERE id = ?
			   AND status IN ('queued','running')`,
			in.Status, in.ExitCode, errTail, in.ID,
		)
		if err != nil {
			return err
		}
		n, err := res.RowsAffected()
		if err != nil {
			return err
		}
		if n != 1 {
			return fmt.Errorf("collab.CompleteJob: %s not terminable", in.ID)
		}
		return nil
	})
}

// SweepIncompleteJobs marks every queued or running job as failed. Returns
// the number of rows updated. Invoked at startup, before collab.Recover.
func (s *Store) SweepIncompleteJobs() (int, error) {
	var n int64
	err := s.send(func(db *sql.DB) error {
		res, err := db.Exec(
			`UPDATE agent_jobs
			   SET status = 'failed',
			       completed_at = unixepoch(),
			       error_tail = 'server restarted while job in flight'
			 WHERE status IN ('queued','running')`,
		)
		if err != nil {
			return err
		}
		n, err = res.RowsAffected()
		return err
	})
	return int(n), err
}

func (s *Store) HasInflightForSource(sourcePath string) (bool, error) {
	if _, err := ValidateSourcePath(sourcePath); err != nil {
		return false, err
	}
	var n int
	err := s.db.QueryRow(
		`SELECT COUNT(*) FROM agent_jobs
		  WHERE source_path = ? AND status IN ('queued','running')`,
		sourcePath,
	).Scan(&n)
	return n > 0, err
}

func (s *Store) ListJobsForSource(sourcePath string, limit int) ([]AgentJob, error) {
	if _, err := ValidateSourcePath(sourcePath); err != nil {
		return nil, err
	}
	if limit <= 0 {
		limit = 20
	}
	rows, err := s.db.Query(
		`SELECT id, kind, source_path, topic_id, persona_name, status,
		        started_at, completed_at, exit_code, error_tail, created_at
		   FROM agent_jobs
		  WHERE source_path = ?
		  ORDER BY created_at DESC
		  LIMIT ?`,
		sourcePath, limit,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []AgentJob{}
	for rows.Next() {
		var j AgentJob
		var topic, persona, errTail sql.NullString
		var started, completed sql.NullInt64
		var exitCode sql.NullInt64
		if err := rows.Scan(
			&j.ID, &j.Kind, &j.SourcePath, &topic, &persona, &j.Status,
			&started, &completed, &exitCode, &errTail, &j.CreatedAt,
		); err != nil {
			return nil, err
		}
		if topic.Valid {
			s := topic.String
			j.TopicID = &s
		}
		if persona.Valid {
			s := persona.String
			j.PersonaName = &s
		}
		if started.Valid {
			n := started.Int64
			j.StartedAt = &n
		}
		if completed.Valid {
			n := completed.Int64
			j.CompletedAt = &n
		}
		if exitCode.Valid {
			n := int(exitCode.Int64)
			j.ExitCode = &n
		}
		if errTail.Valid {
			s := errTail.String
			j.ErrorTail = &s
		}
		out = append(out, j)
	}
	return out, rows.Err()
}

func (s *Store) GetJob(id string) (AgentJob, error) {
	jobs, err := s.getJobByID(id)
	if err != nil {
		return AgentJob{}, err
	}
	return jobs, nil
}

// HasProposalForTopicSince reports whether any incorporation_proposals row
// exists for the given topic with created_at >= since. Used by agent.Service
// for the post-exit invariant check on incorporate jobs.
func (s *Store) HasProposalForTopicSince(topicID string, since int64) (bool, error) {
	if topicID == "" {
		return false, fmt.Errorf("collab.HasProposalForTopicSince: topic id required")
	}
	var n int
	err := s.db.QueryRow(
		`SELECT COUNT(*) FROM incorporation_proposals
		  WHERE topic_id = ? AND created_at >= ?`,
		topicID, since,
	).Scan(&n)
	return n > 0, err
}

func (s *Store) getJobByID(id string) (AgentJob, error) {
	var j AgentJob
	var topic, persona, errTail sql.NullString
	var started, completed sql.NullInt64
	var exitCode sql.NullInt64
	err := s.db.QueryRow(
		`SELECT id, kind, source_path, topic_id, persona_name, status,
		        started_at, completed_at, exit_code, error_tail, created_at
		   FROM agent_jobs WHERE id = ?`,
		id,
	).Scan(
		&j.ID, &j.Kind, &j.SourcePath, &topic, &persona, &j.Status,
		&started, &completed, &exitCode, &errTail, &j.CreatedAt,
	)
	if err != nil {
		return AgentJob{}, err
	}
	if topic.Valid {
		s := topic.String
		j.TopicID = &s
	}
	if persona.Valid {
		s := persona.String
		j.PersonaName = &s
	}
	if started.Valid {
		n := started.Int64
		j.StartedAt = &n
	}
	if completed.Valid {
		n := completed.Int64
		j.CompletedAt = &n
	}
	if exitCode.Valid {
		n := int(exitCode.Int64)
		j.ExitCode = &n
	}
	if errTail.Valid {
		s := errTail.String
		j.ErrorTail = &s
	}
	return j, nil
}
go test ./internal/collab/ -run TestAgentJobs -v

Expected: PASS.

go test ./internal/collab/...

Expected: PASS.

git add internal/collab/collab.go internal/collab/agent_jobs.go internal/collab/agent_jobs_test.go
git commit -m "wiki-browser: collab — agent_jobs mutators, readers, RawDB, startup sweep"

Task 5: Config — Agent block

Files:

Adds the new agent: block with defaults and startup validation. The validation checks that both binaries are resolvable; this turns deploy-time misconfiguration into a clear startup error.

Ordering note: wb-agent is not built until Task 17 (make build). Until that lands, the integration path (running wiki-browser against a config that uses the default wb_agent_bin) is broken — make run between Tasks 5 and 17 will fail-fast on missing-binary validation. Within this task and the next several, tests stub wb_agent_bin to a sibling fake executable (via writeExecutable) or stub the executablePath seam, so config tests remain green without the real binary.

Add to internal/config/config_test.go:

func TestConfig_AgentDefaults(t *testing.T) {
	root := t.TempDir()
	secret := writeTempFile(t, "client-secret\n")
	wbAgentPath := writeExecutable(t, "fake-wb-agent")
	claudePath := writeExecutable(t, "fake-claude")
	yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "123.apps.googleusercontent.com"
  google_client_secret_file: %q
  allowed_emails: ["daniel@getorcha.com"]
agent:
  author_name:  "Orcha Agent"
  author_email: "agent@orcha.local"
  claude_bin:   %q
  wb_agent_bin: %q
`, root, secret, claudePath, wbAgentPath))

	cfg, err := config.Load(yamlPath)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.Agent.MaxConcurrentJobs != 1 {
		t.Fatalf("MaxConcurrentJobs default = %d, want 1", cfg.Agent.MaxConcurrentJobs)
	}
	if cfg.Agent.IncorporateTimeout != 5*time.Minute {
		t.Fatalf("IncorporateTimeout default = %v, want 5m", cfg.Agent.IncorporateTimeout)
	}
	if cfg.Agent.PerspectiveTimeout != 3*time.Minute {
		t.Fatalf("PerspectiveTimeout default = %v, want 3m", cfg.Agent.PerspectiveTimeout)
	}
	if cfg.Agent.WBAgentBin != wbAgentPath {
		t.Fatalf("WBAgentBin = %q, want %q", cfg.Agent.WBAgentBin, wbAgentPath)
	}
}

func TestConfig_AgentMissingBinariesFail(t *testing.T) {
	root := t.TempDir()
	secret := writeTempFile(t, "client-secret\n")
	yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "123.apps.googleusercontent.com"
  google_client_secret_file: %q
  allowed_emails: ["daniel@getorcha.com"]
agent:
  author_name:  "Orcha Agent"
  author_email: "agent@orcha.local"
  wb_agent_bin: "/does/not/exist/wb-agent"
`, root, secret))

	if _, err := config.Load(yamlPath); err == nil {
		t.Fatalf("expected error for missing wb_agent_bin")
	}
}

func TestConfig_AgentRequiresAuthorFields(t *testing.T) {
	root := t.TempDir()
	secret := writeTempFile(t, "client-secret\n")
	yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "123.apps.googleusercontent.com"
  google_client_secret_file: %q
  allowed_emails: ["daniel@getorcha.com"]
agent: {}
`, root, secret))

	if _, err := config.Load(yamlPath); err == nil {
		t.Fatalf("expected error for missing author_name/author_email")
	}
}

// TestConfig_AgentDefaultWBAgentBin exercises the actual default branch for
// WBAgentBin — sibling-of-wiki-browser-binary resolution. Stubs the
// executablePath seam to point at a fake "wiki-browser" inside t.TempDir(),
// places a sibling "wb-agent" next to it, and asserts the default lands there.
// Without this test, the default branch is never exercised — the other tests
// pass wb_agent_bin explicitly because os.Executable() in a test binary
// resolves to /tmp/.../config.test, which has no sibling wb-agent.
func TestConfig_AgentDefaultWBAgentBin(t *testing.T) {
	dir := t.TempDir()
	wikiBrowserPath := filepath.Join(dir, "wiki-browser")
	if err := os.WriteFile(wikiBrowserPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatalf("write fake wiki-browser: %v", err)
	}
	wbAgentPath := filepath.Join(dir, "wb-agent")
	if err := os.WriteFile(wbAgentPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatalf("write sibling wb-agent: %v", err)
	}

	prev := config.SetExecutablePathForTest(func() (string, error) { return wikiBrowserPath, nil })
	defer config.SetExecutablePathForTest(prev)

	root := t.TempDir()
	secret := writeTempFile(t, "client-secret\n")
	claudePath := writeExecutable(t, "fake-claude")
	yamlPath := writeTempFile(t, fmt.Sprintf(`
root: %q
auth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "123.apps.googleusercontent.com"
  google_client_secret_file: %q
  allowed_emails: ["daniel@getorcha.com"]
agent:
  author_name:  "Orcha Agent"
  author_email: "agent@orcha.local"
  claude_bin:   %q
`, root, secret, claudePath))

	cfg, err := config.Load(yamlPath)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.Agent.WBAgentBin != wbAgentPath {
		t.Fatalf("default WBAgentBin = %q, want sibling %q", cfg.Agent.WBAgentBin, wbAgentPath)
	}
}

Expose the seam from the config package — append to internal/config/config.go:

// SetExecutablePathForTest swaps the os.Executable resolver used by
// applyDefaults to compute the WBAgentBin default. Returns the previous value
// so tests can restore it via defer. Test-only; production code uses the
// package default initialized to os.Executable.
func SetExecutablePathForTest(fn func() (string, error)) func() (string, error) {
	prev := executablePath
	executablePath = fn
	return prev
}

Test helpers — add to internal/config/config_test.go if missing:

func writeTempFile(t *testing.T, content string) string {
	t.Helper()
	f, err := os.CreateTemp(t.TempDir(), "config-test-*")
	if err != nil { t.Fatalf("temp file: %v", err) }
	if _, err := f.WriteString(content); err != nil { t.Fatalf("write: %v", err) }
	_ = f.Close()
	return f.Name()
}

func writeExecutable(t *testing.T, name string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name)
	if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatalf("write exec: %v", err)
	}
	return path
}

Update the existing config fixtures and tests so the new required agent: block does not break unrelated config coverage:

agent:
  author_name: "Orcha Agent"
  author_email: "agent@orcha.local"
  claude_bin: "/bin/sh"
  wb_agent_bin: "/bin/sh"
Agent: config.Agent{
	AuthorName:         "Orcha Agent",
	AuthorEmail:        "agent@orcha.local",
	ClaudeBin:          "/bin/sh",
	WBAgentBin:         "/bin/sh",
	MaxConcurrentJobs:  1,
	IncorporateTimeout: 5 * time.Minute,
	PerspectiveTimeout: 3 * time.Minute,
},
go test ./internal/config/ -run TestConfig_Agent -v

Expected: compile error — cfg.Agent does not exist.

Add the Agent struct and field on Config:

type Config struct {
	Listen     string   `yaml:"listen"`
	Title      string   `yaml:"title"`
	Root       string   `yaml:"root"`
	Extensions []string `yaml:"extensions"`
	IndexDB    string   `yaml:"index_db"`
	CollabDB   string   `yaml:"collab_db"`
	Exclude    []string `yaml:"exclude"`
	Auth       Auth     `yaml:"auth"`
	Agent      Agent    `yaml:"agent"`
}

// Agent configures the Claude Code subprocess runtime. AuthorName/AuthorEmail
// are required (they appear on every Source-rewrite commit as the git author);
// the rest defaults to reasonable values for a single-Pi deployment.
type Agent struct {
	AuthorName         string        `yaml:"author_name"`
	AuthorEmail        string        `yaml:"author_email"`
	ClaudeBin          string        `yaml:"claude_bin"`
	WBAgentBin         string        `yaml:"wb_agent_bin"`
	MaxConcurrentJobs  int           `yaml:"max_concurrent_jobs"`
	IncorporateTimeout time.Duration `yaml:"incorporate_timeout"`
	PerspectiveTimeout time.Duration `yaml:"perspective_timeout"`
	LogDir             string        `yaml:"log_dir"`
}

Add time import. Extend applyDefaults:

// executablePath is a package-level seam over os.Executable so tests can stub
// the resolver used to compute the default WBAgentBin location. Tests that want
// to exercise the real default (sibling-of-wiki-browser-binary) replace this
// with a function that returns a path under their t.TempDir().
var executablePath = os.Executable

func (c *Config) applyDefaults() {
	// ... existing defaults ...
	if c.Agent.ClaudeBin == "" {
		c.Agent.ClaudeBin = "claude"
	}
	if c.Agent.WBAgentBin == "" {
		self, err := executablePath()
		if err == nil {
			c.Agent.WBAgentBin = filepath.Join(filepath.Dir(self), "wb-agent")
		}
	}
	if c.Agent.MaxConcurrentJobs == 0 {
		c.Agent.MaxConcurrentJobs = 1
	}
	if c.Agent.IncorporateTimeout == 0 {
		c.Agent.IncorporateTimeout = 5 * time.Minute
	}
	if c.Agent.PerspectiveTimeout == 0 {
		c.Agent.PerspectiveTimeout = 3 * time.Minute
	}
}

Without the executablePath seam, os.Executable() in a go test process returns the test binary path (e.g. /tmp/go-build…/config.test), so the default points at a non-existent /tmp/.../wb-agent and validation always falls over. Tests would then sidestep by passing wb_agent_bin explicitly — at which point the default branch is never exercised. The seam lets one test exercise the real default; other tests can keep passing the path explicitly.

Extend validate — add after the existing auth validation:

if c.Agent.AuthorName == "" {
	return fmt.Errorf("agent.author_name is required")
}
if c.Agent.AuthorEmail == "" {
	return fmt.Errorf("agent.author_email is required")
}
if c.Agent.MaxConcurrentJobs < 1 {
	return fmt.Errorf("agent.max_concurrent_jobs must be >= 1")
}
// Verify claude_bin resolves via PATH (or is an absolute path that exists).
if _, err := exec.LookPath(c.Agent.ClaudeBin); err != nil {
	return fmt.Errorf("agent.claude_bin %q: %w", c.Agent.ClaudeBin, err)
}
// Verify wb_agent_bin exists at the specified path.
if c.Agent.WBAgentBin == "" {
	return fmt.Errorf("agent.wb_agent_bin: cannot determine default; specify explicitly")
}
if _, err := os.Stat(c.Agent.WBAgentBin); err != nil {
	return fmt.Errorf("agent.wb_agent_bin %q: %w", c.Agent.WBAgentBin, err)
}
if c.Agent.LogDir != "" {
	if err := os.MkdirAll(c.Agent.LogDir, 0o755); err != nil {
		return fmt.Errorf("agent.log_dir %q: %w", c.Agent.LogDir, err)
	}
}

Add os/exec to imports.

go test ./internal/config/...

Expected: PASS.

git add internal/config/config.go internal/config/config_test.go
git commit -m "wiki-browser: config — Agent block with defaults + binary validation"

Task 6: internal/agentJob, RunResult, Runner interface, FakeRunner

Files:

The substitutable boundary that lets the rest of the system test without spawning processes. Implementation comes in Task 7.

Create internal/agent/runner_test.go:

package agent_test

import (
	"context"
	"errors"
	"testing"

	"github.com/getorcha/wiki-browser/internal/agent"
)

func TestFakeRunner_CallsBackWithJob(t *testing.T) {
	var seen agent.Job
	r := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		seen = j
		return agent.RunResult{ExitCode: 0}
	})

	job := agent.Job{
		ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md",
		TopicID: "t1", BaseSHA: "sha", RepoRoot: "/r", WBAgentPath: "/w",
	}
	res := r.Run(context.Background(), job)

	if res.ExitCode != 0 {
		t.Fatalf("exit = %d, want 0", res.ExitCode)
	}
	if seen.ID != "j1" {
		t.Fatalf("seen.ID = %q, want j1", seen.ID)
	}
}

func TestFakeRunner_PropagatesError(t *testing.T) {
	want := errors.New("boom")
	r := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		return agent.RunResult{Err: want}
	})
	res := r.Run(context.Background(), agent.Job{ID: "j2"})
	if !errors.Is(res.Err, want) {
		t.Fatalf("Err = %v, want %v", res.Err, want)
	}
}
go test ./internal/agent/ -v

Expected: compile error — package does not exist.

// Package agent owns the Claude Code subprocess runtime: the queue, the
// Runner interface, and the agent_jobs lifecycle. The HTTP handlers and
// the main binary depend on this package; tests substitute FakeRunner so
// they never spawn a real claude.
package agent

import "context"

// Job is everything the runner needs to invoke an agent task. Note that
// timeout is NOT a field here — Runner.Run receives a context whose deadline
// is set by the caller (Service.run derives it from the per-kind config).
// This keeps the timeout discipline in one place and lets future Runners
// (e.g. v2 channels) honor it via the standard context contract.
type Job struct {
	ID          string
	Kind        string // "incorporate" | "perspective"
	SourcePath  string
	TopicID     string // populated when Kind == "incorporate"
	BaseSHA     string // populated when Kind == "incorporate"
	PersonaName string // populated when Kind == "perspective"
	SourceSHA   string // populated when Kind == "perspective"
	PersonaSHA  string // populated when Kind == "perspective"
	RepoRoot    string // cfg.Root
	WBAgentPath string // absolute path to the wb-agent binary
	WikiRoot    string // cwd for the subprocess (wiki-browser/)
	LogPath     string // when non-empty, runner streams full stderr to this file
}

// RunResult is what the runner returns. The service maps it onto an
// agent_jobs row in CompleteJob.
type RunResult struct {
	ExitCode  int    // 0 on success; ignored when Err != nil
	ErrorTail string // last 4 KiB of stderr
	Err       error  // non-nil for spawn errors / timeouts
	TimedOut  bool   // true when Err is a deadline-exceeded
}

// Runner is the substitution point. ClaudeCLIRunner is the production
// implementation; FakeRunner is for tests.
type Runner interface {
	Run(ctx context.Context, j Job) RunResult
}
package agent

import "context"

// FakeRunner runs a user-supplied function inline. Tests use it to simulate
// agent work (e.g. inserting an incorporation_proposals row directly via the
// collab store) without spawning a subprocess.
type FakeRunner struct {
	fn func(context.Context, Job) RunResult
}

func NewFakeRunner(fn func(context.Context, Job) RunResult) *FakeRunner {
	return &FakeRunner{fn: fn}
}

func (f *FakeRunner) Run(ctx context.Context, j Job) RunResult {
	return f.fn(ctx, j)
}
go test ./internal/agent/ -v

Expected: PASS.

git add internal/agent/runner.go internal/agent/runner_test.go internal/agent/fake_runner.go
git commit -m "wiki-browser: agent — Runner interface and FakeRunner"

Task 7: internal/agentClaudeCLIRunner

Files:

The production runner. Spawns claude -p, sets Setpgid, installs a cmd.Cancel that signals the process group, and captures the last 4 KiB of stderr. Tests use a shell-script substitute so they exercise real subprocess semantics — including the process-group kill path — without depending on Claude Code being installed in CI.

Create internal/agent/claude_cli_runner_test.go:

package agent_test

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/agent"
)

func writeFakeClaude(t *testing.T, body string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), "fake-claude")
	if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
		t.Fatalf("write fake-claude: %v", err)
	}
	return path
}

func writeFakeWBAgent(t *testing.T) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), "wb-agent")
	if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatalf("write fake wb-agent: %v", err)
	}
	return path
}

// runCtx returns a context with `d` deadline tied to the test. ClaudeCLIRunner
// no longer carries an internal context.WithTimeout — the caller (Service.run
// in production, the test here) sets the deadline via the context itself.
func runCtx(t *testing.T, d time.Duration) context.Context {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), d)
	t.Cleanup(cancel)
	return ctx
}

func TestClaudeCLIRunner_Success(t *testing.T) {
	claudeBin := writeFakeClaude(t, `exit 0`)
	r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})

	res := r.Run(runCtx(t, 5*time.Second), agent.Job{
		ID: "j1", Kind: "incorporate", SourcePath: "docs/foo.md",
		WikiRoot:    t.TempDir(),
		WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
	})

	if res.Err != nil {
		t.Fatalf("Err = %v", res.Err)
	}
	if res.ExitCode != 0 {
		t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
	}
}

func TestClaudeCLIRunner_NonZeroExit(t *testing.T) {
	claudeBin := writeFakeClaude(t, `echo "boom" >&2; exit 7`)
	r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})

	res := r.Run(runCtx(t, 5*time.Second), agent.Job{
		ID: "j2", Kind: "incorporate", SourcePath: "docs/foo.md",
		WikiRoot:    t.TempDir(),
		WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
	})

	if res.Err != nil {
		t.Fatalf("Err = %v", res.Err)
	}
	if res.ExitCode != 7 {
		t.Fatalf("ExitCode = %d, want 7", res.ExitCode)
	}
	if !strings.Contains(res.ErrorTail, "boom") {
		t.Fatalf("ErrorTail = %q, want to contain boom", res.ErrorTail)
	}
}

func TestClaudeCLIRunner_Timeout(t *testing.T) {
	// Spawn a child sleep so the timeout test exercises process-group kill.
	claudeBin := writeFakeClaude(t, `
		sh -c 'sleep 30' &
		CHILD=$!
		sleep 30
		kill $CHILD 2>/dev/null
	`)

	r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})

	start := time.Now()
	res := r.Run(runCtx(t, 500*time.Millisecond), agent.Job{
		ID: "j3", Kind: "incorporate", SourcePath: "docs/foo.md",
		WikiRoot:    t.TempDir(),
		WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
	})
	elapsed := time.Since(start)

	if !res.TimedOut {
		t.Fatalf("TimedOut = false; want true (Err = %v)", res.Err)
	}
	// Generous upper bound: timeout (500ms) + WaitDelay (5s) + slack.
	if elapsed > 8*time.Second {
		t.Fatalf("Run took %v, expected <8s (WaitDelay should have triggered)", elapsed)
	}
}

func TestClaudeCLIRunner_ClaudeMissing(t *testing.T) {
	r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{
		ClaudeBin: "/does/not/exist/claude",
	})
	res := r.Run(runCtx(t, 5*time.Second), agent.Job{
		ID: "j4", Kind: "incorporate", SourcePath: "docs/foo.md",
		WikiRoot:    t.TempDir(),
		WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
	})

	if res.Err == nil {
		t.Fatalf("Err = nil; want non-nil (binary missing)")
	}
	if !errors.Is(res.Err, syscall.ENOENT) && !strings.Contains(res.Err.Error(), "no such file") {
		t.Fatalf("Err = %v, want ENOENT-like", res.Err)
	}
}

// TestClaudeCLIRunner_LogPathCapturesFullStderr verifies that when LogPath is
// set, the full stderr stream is captured to the file (not just the 4 KiB
// ErrorTail). Generates >stderrTailBytes of output so the tail truncates,
// then asserts the file holds the full content.
func TestClaudeCLIRunner_LogPathCapturesFullStderr(t *testing.T) {
	// Emit ~8 KiB of stderr so the tail (4 KiB) is a strict subset.
	claudeBin := writeFakeClaude(t, `
		for i in $(seq 1 200); do
			printf 'line-%04d %s\n' "$i" "padding-padding-padding-padding-padding" 1>&2
		done
		exit 0
	`)
	r := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{ClaudeBin: claudeBin})

	logPath := filepath.Join(t.TempDir(), "job.log")
	res := r.Run(runCtx(t, 5*time.Second), agent.Job{
		ID: "j5", Kind: "incorporate", SourcePath: "docs/foo.md",
		WikiRoot:    t.TempDir(),
		WBAgentPath: writeFakeWBAgent(t), RepoRoot: t.TempDir(),
		LogPath:     logPath,
	})

	if res.Err != nil {
		t.Fatalf("Err = %v", res.Err)
	}
	body, err := os.ReadFile(logPath)
	if err != nil {
		t.Fatalf("read log: %v", err)
	}
	if !strings.Contains(string(body), "line-0001 ") {
		t.Fatalf("log file is missing early lines (truncation upstream of file)")
	}
	if !strings.Contains(string(body), "line-0200 ") {
		t.Fatalf("log file is missing final lines")
	}
	if !strings.Contains(res.ErrorTail, "line-0200 ") {
		t.Fatalf("ErrorTail should still contain the final lines")
	}
	if strings.Contains(res.ErrorTail, "line-0001 ") {
		t.Fatalf("ErrorTail unexpectedly contained the first line (tail should have truncated)")
	}
}
go test ./internal/agent/ -run TestClaudeCLI -v

Expected: compile error — ClaudeCLIRunner does not exist.

package agent

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"syscall"
	"time"
)

const stderrTailBytes = 4 << 10 // 4 KiB

type ClaudeCLIRunnerOptions struct {
	ClaudeBin string // resolved path or PATH-resolvable name
}

type ClaudeCLIRunner struct {
	bin string
}

func NewClaudeCLIRunner(opts ClaudeCLIRunnerOptions) *ClaudeCLIRunner {
	return &ClaudeCLIRunner{bin: opts.ClaudeBin}
}

func (r *ClaudeCLIRunner) Run(ctx context.Context, j Job) RunResult {
	prompt := buildPrompt(j)

	// The caller owns the deadline. Service.run wraps the per-kind timeout
	// into ctx via context.WithTimeout before calling Run, so we use ctx
	// directly. Future Runners (v2 channels) honor the timeout the same way
	// without needing a Job-level field.
	cmd := exec.CommandContext(ctx, r.bin,
		"-p", prompt,
		"--dangerously-skip-permissions",
	)
	cmd.Dir = j.WikiRoot
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	cmd.Cancel = func() error {
		if cmd.Process == nil {
			return os.ErrProcessDone
		}
		// Signal the whole process group. The leading negative sign means
		// "process group id" per kill(2). If the group is already dead
		// (race between deadline firing and signal delivery), syscall.Kill
		// returns ESRCH; return os.ErrProcessDone so Go treats the cancel
		// as a no-op rather than surfacing ESRCH as the cmd.Run error
		// (which would mis-classify a timeout as a spawn failure).
		if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil {
			if errors.Is(err, syscall.ESRCH) {
				return os.ErrProcessDone
			}
			return err
		}
		return nil
	}
	cmd.WaitDelay = 5 * time.Second

	// Open the log file (if any) before invoking cmd.Run so a panic in the
	// writer pipeline still triggers the deferred Close. stderrTail is the
	// in-memory 4 KiB tail; stderrSink writes to both when LogPath is set.
	var stderrTail bytes.Buffer
	var stderrSink io.Writer = &stderrTail
	if j.LogPath != "" {
		f, err := os.OpenFile(j.LogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
		if err != nil {
			return RunResult{Err: fmt.Errorf("open log file %s: %w", j.LogPath, err)}
		}
		defer f.Close()
		stderrSink = io.MultiWriter(f, &stderrTail)
	}
	cmd.Stderr = stderrSink
	// Stdout is captured but not used; default human-readable output.
	cmd.Stdout = nil

	err := cmd.Run()

	tail := tailBytes(stderrTail.Bytes(), stderrTailBytes)
	res := RunResult{ErrorTail: tail}

	// Timeout check FIRST: when the context deadline fires, cmd.Cancel
	// SIGTERMs the group and cmd.Wait may return an ExitError reflecting
	// the kill, or — on the ESRCH race — the cancel error itself. In every
	// timeout case ctx.Err() is DeadlineExceeded, so we can disambiguate
	// before falling through to exit-vs-spawn classification.
	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
		res.TimedOut = true
		res.Err = fmt.Errorf("context deadline exceeded")
		return res
	}

	if err == nil {
		res.ExitCode = 0
		return res
	}

	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) {
		res.ExitCode = exitErr.ExitCode()
		return res
	}

	// Spawn errors (binary missing, permission denied, etc.).
	res.Err = err
	return res
}

func tailBytes(b []byte, n int) string {
	if len(b) <= n {
		return strings.TrimSpace(string(b))
	}
	return strings.TrimSpace(string(b[len(b)-n:]))
}

// buildPrompt formats the prompt body the agent sees. The skill's first
// paragraph teaches it how to read this block.
func buildPrompt(j Job) string {
	var b strings.Builder
	switch j.Kind {
	case "incorporate":
		fmt.Fprintf(&b, "Use the wb-incorporate skill.\n\n")
		fmt.Fprintf(&b, "Job ID:          %s\n", j.ID)
		fmt.Fprintf(&b, "Topic ID:        %s\n", j.TopicID)
		fmt.Fprintf(&b, "Source path:     %s\n", j.SourcePath)
		fmt.Fprintf(&b, "Base source SHA: %s\n", j.BaseSHA)
		fmt.Fprintf(&b, "Repo root:       %s\n", j.RepoRoot)
		fmt.Fprintf(&b, "wb-agent path:   %s\n", j.WBAgentPath)
	case "perspective":
		fmt.Fprintf(&b, "Use the wb-perspective skill.\n\n")
		fmt.Fprintf(&b, "Job ID:        %s\n", j.ID)
		fmt.Fprintf(&b, "Source path:   %s\n", j.SourcePath)
		fmt.Fprintf(&b, "Persona name:  %s\n", j.PersonaName)
		fmt.Fprintf(&b, "Source SHA:    %s\n", j.SourceSHA)
		fmt.Fprintf(&b, "Persona SHA:   %s\n", j.PersonaSHA)
		fmt.Fprintf(&b, "Repo root:     %s\n", j.RepoRoot)
		fmt.Fprintf(&b, "wb-agent path: %s\n", j.WBAgentPath)
	}
	fmt.Fprintf(&b, "\nWhen done, exit 0. On any unrecoverable error, exit non-zero — the wiki-browser server will surface stderr to the operator.\n")
	return b.String()
}
go test ./internal/agent/ -run TestClaudeCLI -v -timeout=60s

Expected: PASS for all four cases. The timeout case takes ~5.5s.

git add internal/agent/claude_cli_runner.go internal/agent/claude_cli_runner_test.go
git commit -m "wiki-browser: agent — ClaudeCLIRunner with process-group teardown"

Task 8: internal/agentService (queue + lifecycle)

Files:

The piece that callers actually invoke. Owns the in-memory queue (per-Source serialization + global cap), writes agent_jobs rows on each lifecycle transition, validates inputs, and rejects duplicates with a typed error. Uses FakeRunner for tests.

Create internal/agent/service_test.go:

package agent_test

import (
	"context"
	"errors"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/agent"
	"github.com/getorcha/wiki-browser/internal/collab"
)

// openTestStoreAgent returns a fresh collab.Store with a seeded user + topic.
// Test helper kept local to avoid pulling collab's internal test helpers.
func openTestStoreAgent(t *testing.T) (*collab.Store, string) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "collab.db")
	s, err := collab.Open(collab.Config{Path: path})
	if err != nil {
		t.Fatalf("collab.Open: %v", err)
	}
	t.Cleanup(func() { _ = s.Close() })

	if _, err := s.RawDBForTest().Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil {
		t.Fatalf("seed user: %v", err)
	}
	topicID := "t1"
	if _, err := s.RawDBForTest().Exec(
		`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
		 VALUES (?, 'docs/foo.md', '{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
		topicID,
	); err != nil {
		t.Fatalf("seed topic: %v", err)
	}
	return s, topicID
}

func TestService_Submit_Succeeds(t *testing.T) {
	store, topicID := openTestStoreAgent(t)

	done := make(chan struct{})
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		// Simulate the agent inserting a proposal row.
		if _, err := store.InsertProposal(collab.NewProposal{
			ID: "p1", TopicID: topicID, RevisionNumber: 1,
			ProposedSource: "new body", BaseSourceSHA: "sha", ProposedBy: nil,
		}); err != nil {
			t.Errorf("InsertProposal: %v", err)
		}
		close(done)
		return agent.RunResult{ExitCode: 0}
	})

	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
		RepoRoot: t.TempDir(), WikiRoot: t.TempDir(), WBAgentPath: "/wb-agent",
	})
	t.Cleanup(svc.Stop)

	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if err != nil {
		t.Fatalf("Submit: %v", err)
	}

	select {
	case <-done:
	case <-time.After(3 * time.Second):
		t.Fatalf("runner never invoked")
	}

	// Allow CompleteJob to land.
	require_succeeded := func() bool {
		job, err := store.GetJob(jobID)
		if err != nil { t.Fatalf("GetJob: %v", err) }
		return job.Status == "succeeded"
	}
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) && !require_succeeded() {
		time.Sleep(20 * time.Millisecond)
	}
	if !require_succeeded() {
		t.Fatalf("status not succeeded")
	}
}

func TestService_Submit_409OnConcurrentSameSource(t *testing.T) {
	store, topicID := openTestStoreAgent(t)

	gate := make(chan struct{})
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		<-gate
		return agent.RunResult{ExitCode: 0}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 5,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(func() { close(gate); svc.Stop() })

	if _, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	}); err != nil {
		t.Fatalf("first Submit: %v", err)
	}
	// Allow the first job to enter 'running'.
	time.Sleep(50 * time.Millisecond)

	_, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if !errors.Is(err, agent.ErrInflight) {
		t.Fatalf("second Submit err = %v, want ErrInflight", err)
	}
}

func TestService_RespectsGlobalCap(t *testing.T) {
	store, topicID := openTestStoreAgent(t)

	// Seed a second topic on a different source so per-source serialization
	// is not what gates us.
	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
		 VALUES ('t2','docs/bar.md','{"kind":"global"}', unixepoch(), 'u1', unixepoch())`,
	); err != nil {
		t.Fatalf("seed t2: %v", err)
	}

	var mu sync.Mutex
	var concurrent, peak int
	gate := make(chan struct{})
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		mu.Lock(); concurrent++; if concurrent > peak { peak = concurrent }; mu.Unlock()
		<-gate
		mu.Lock(); concurrent--; mu.Unlock()
		return agent.RunResult{ExitCode: 0}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(func() { close(gate); svc.Stop() })

	if _, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	}); err != nil {
		t.Fatalf("Submit 1: %v", err)
	}
	if _, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/bar.md", TopicID: "t2", BaseSHA: "sha",
	}); err != nil {
		t.Fatalf("Submit 2: %v", err)
	}
	time.Sleep(150 * time.Millisecond)
	mu.Lock()
	defer mu.Unlock()
	if peak > 1 {
		t.Fatalf("peak concurrency = %d, want <= 1", peak)
	}
}

func TestService_FailureRecordsErrorTail(t *testing.T) {
	store, topicID := openTestStoreAgent(t)
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		return agent.RunResult{ExitCode: 9, ErrorTail: "boom"}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(svc.Stop)

	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if err != nil {
		t.Fatalf("Submit: %v", err)
	}

	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		j, err := store.GetJob(jobID)
		if err == nil && j.Status == "failed" && j.ErrorTail != nil && *j.ErrorTail == "boom" {
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("job did not reach failed state with expected error_tail")
}

func TestService_TimeoutLeavesExitCodeNull(t *testing.T) {
	store, topicID := openTestStoreAgent(t)
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		return agent.RunResult{TimedOut: true, ErrorTail: "deadline"}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(svc.Stop)

	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if err != nil {
		t.Fatalf("Submit: %v", err)
	}

	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		j, err := store.GetJob(jobID)
		if err == nil && j.Status == "timed_out" {
			if j.ExitCode != nil {
				t.Fatalf("ExitCode = %v, want nil for timeout", *j.ExitCode)
			}
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("job did not reach timed_out state")
}

func TestService_PostExitNoProposalIsFailure(t *testing.T) {
	store, topicID := openTestStoreAgent(t)
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		// exit 0 but write no proposal
		return agent.RunResult{ExitCode: 0}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(svc.Stop)

	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if err != nil {
		t.Fatalf("Submit: %v", err)
	}

	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		j, err := store.GetJob(jobID)
		if err == nil && j.Status == "failed" {
			if j.ErrorTail == nil || *j.ErrorTail == "" {
				t.Fatalf("error_tail empty; expected post-exit-no-proposal message")
			}
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("job did not reach failed state")
}

func TestService_PreStartProposalDoesNotSatisfyPostExitCheck(t *testing.T) {
	store, topicID := openTestStoreAgent(t)

	gate := make(chan struct{})
	var closeGate sync.Once
	var calls int
	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		calls++
		if calls == 1 {
			<-gate // hold the global slot so the incorporate job stays queued
		}
		return agent.RunResult{ExitCode: 0}
	})
	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: time.Second, PerspectiveTimeout: time.Second,
	})
	t.Cleanup(func() { closeGate.Do(func() { close(gate) }); svc.Stop() })

	if _, err := svc.Submit(agent.SubmitInput{
		Kind: "perspective", SourcePath: "docs/block.md",
		PersonaName: "CFO", SourceSHA: "source-sha", PersonaSHA: "persona-sha",
	}); err != nil {
		t.Fatalf("Submit blocker: %v", err)
	}
	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: topicID, BaseSHA: "sha",
	})
	if err != nil {
		t.Fatalf("Submit incorporate: %v", err)
	}

	// This row exists before StartJob runs for jobID. The post-exit check
	// must use agent_jobs.started_at and reject crediting this old proposal.
	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO incorporation_proposals(
		   id, topic_id, revision_number, proposed_source, base_source_sha,
		   proposed_by, created_at
		 ) VALUES ('prestart', ?, 1, 'body', 'sha', NULL, 1)`,
		topicID,
	); err != nil {
		t.Fatalf("insert pre-start proposal: %v", err)
	}
	closeGate.Do(func() { close(gate) })

	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		j, err := store.GetJob(jobID)
		if err == nil && j.Status == "failed" {
			if j.ErrorTail == nil || *j.ErrorTail == "" {
				t.Fatalf("error_tail empty; expected no post-start proposal message")
			}
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("job credited a pre-start proposal or did not finish")
}
go test ./internal/agent/ -run TestService -v

Expected: compile error — agent.Service does not exist.

package agent

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"path/filepath"
	"sync"
	"time"

	"github.com/google/uuid"

	"github.com/getorcha/wiki-browser/internal/collab"
)

var (
	ErrInflight                 = errors.New("agent: another job is already in flight for this source")
	ErrMissingSourcePath        = errors.New("agent: source_path is required")
	ErrUnsupportedKind          = errors.New("agent: unsupported kind")
	ErrMissingIncorporateFields = errors.New("agent: incorporate requires topic_id and base_sha")
	ErrMissingPerspectiveFields = errors.New("agent: perspective requires persona_name, source_sha, persona_sha")
)

type ServiceConfig struct {
	Store              *collab.Store
	Runner             Runner
	MaxConcurrentJobs  int
	IncorporateTimeout time.Duration
	PerspectiveTimeout time.Duration
	RepoRoot           string // cfg.Root
	WikiRoot           string // wiki-browser project dir
	WBAgentPath        string // resolved wb-agent binary
	LogDir             string // optional; per-job stderr files go here when set

	// AuthorName/AuthorEmail are the git identity used on Source-rewrite
	// commits. They live on the Service (not as loose config) so the future
	// #4 approval handler reads them through agent.Service rather than
	// re-reading config, which keeps #4's seam stable across config shape
	// changes. Required per the decisions log — the validate step in
	// internal/config enforces this.
	AuthorName  string
	AuthorEmail string
}

type SubmitInput struct {
	Kind        string // "incorporate" | "perspective"
	SourcePath  string
	TopicID     string // required when Kind == "incorporate"
	BaseSHA     string // required when Kind == "incorporate"
	PersonaName string // required when Kind == "perspective"
	SourceSHA   string // required when Kind == "perspective"
	PersonaSHA  string // required when Kind == "perspective"
}

type Service struct {
	cfg ServiceConfig

	mu         sync.Mutex
	inflight   map[string]struct{} // source_path → presence
	globalSem  chan struct{}
	wg         sync.WaitGroup
	stopCtx    context.Context
	stopCancel context.CancelFunc
}

func NewService(cfg ServiceConfig) *Service {
	if cfg.MaxConcurrentJobs < 1 {
		cfg.MaxConcurrentJobs = 1
	}
	stopCtx, stopCancel := context.WithCancel(context.Background())
	return &Service{
		cfg:        cfg,
		inflight:   map[string]struct{}{},
		globalSem:  make(chan struct{}, cfg.MaxConcurrentJobs),
		stopCtx:    stopCtx,
		stopCancel: stopCancel,
	}
}

// Stop cancels all in-flight jobs and waits for them. Idempotent.
func (s *Service) Stop() {
	s.stopCancel()
	s.wg.Wait()
}

// AuthorIdentity returns the git author identity to stamp on Source-rewrite
// commits. Consumed by #4's proposal-approval handler when it constructs an
// IncorporateInput; centralized here so a future config-shape change is
// invisible to callers.
func (s *Service) AuthorIdentity() (name, email string) {
	return s.cfg.AuthorName, s.cfg.AuthorEmail
}

func (s *Service) Submit(in SubmitInput) (string, error) {
	if err := s.validate(in); err != nil {
		return "", err
	}

	// Two-layer in-flight gate. The in-memory map covers the common case
	// fast (no DB hit inside one process). The partial unique index on
	// agent_jobs(source_path) WHERE status IN ('queued','running') is the
	// hard invariant across processes and bypassing code paths; InsertJob
	// errors are mapped back to ErrInflight below when that index fires.
	s.mu.Lock()
	if _, ok := s.inflight[in.SourcePath]; ok {
		s.mu.Unlock()
		return "", ErrInflight
	}
	jobID := uuid.NewString()
	s.inflight[in.SourcePath] = struct{}{}
	s.mu.Unlock()

	if inflight, err := s.cfg.Store.HasInflightForSource(in.SourcePath); err != nil {
		s.mu.Lock()
		delete(s.inflight, in.SourcePath)
		s.mu.Unlock()
		return "", fmt.Errorf("agent.Service: HasInflightForSource: %w", err)
	} else if inflight {
		s.mu.Lock()
		delete(s.inflight, in.SourcePath)
		s.mu.Unlock()
		return "", ErrInflight
	}

	job := collab.NewJob{
		ID: jobID, Kind: in.Kind, SourcePath: in.SourcePath,
	}
	switch in.Kind {
	case "incorporate":
		t := in.TopicID
		job.TopicID = &t
	case "perspective":
		p := in.PersonaName
		job.PersonaName = &p
	}
	if err := s.cfg.Store.InsertJob(job); err != nil {
		s.mu.Lock(); delete(s.inflight, in.SourcePath); s.mu.Unlock()
		if inflight, checkErr := s.cfg.Store.HasInflightForSource(in.SourcePath); checkErr == nil && inflight {
			return "", ErrInflight
		}
		return "", fmt.Errorf("agent.Service: InsertJob: %w", err)
	}

	s.wg.Add(1)
	go s.run(jobID, in)
	return jobID, nil
}

func (s *Service) validate(in SubmitInput) error {
	if in.SourcePath == "" {
		return ErrMissingSourcePath
	}
	switch in.Kind {
	case "incorporate":
		if in.TopicID == "" || in.BaseSHA == "" {
			return ErrMissingIncorporateFields
		}
	case "perspective":
		if in.PersonaName == "" || in.SourceSHA == "" || in.PersonaSHA == "" {
			return ErrMissingPerspectiveFields
		}
	default:
		return fmt.Errorf("%w: %q", ErrUnsupportedKind, in.Kind)
	}
	return nil
}

func (s *Service) run(jobID string, in SubmitInput) {
	defer s.wg.Done()
	defer func() {
		s.mu.Lock()
		delete(s.inflight, in.SourcePath)
		s.mu.Unlock()
	}()

	// Global concurrency gate. Sleep until a slot is free or the service stops.
	select {
	case s.globalSem <- struct{}{}:
	case <-s.stopCtx.Done():
		_ = s.cfg.Store.CompleteJob(collab.CompleteJobInput{
			ID: jobID, Status: "failed", ErrorTail: "service shutting down",
		})
		return
	}
	defer func() { <-s.globalSem }()

	if err := s.cfg.Store.StartJob(jobID); err != nil {
		slog.Warn("agent: StartJob failed", "job_id", jobID, "err", err)
		// Try to mark failed so the row reaches a terminal state.
		_ = s.cfg.Store.CompleteJob(collab.CompleteJobInput{
			ID: jobID, Status: "failed", ErrorTail: "StartJob: " + err.Error(),
		})
		return
	}

	timeout := s.cfg.IncorporateTimeout
	if in.Kind == "perspective" {
		timeout = s.cfg.PerspectiveTimeout
	}

	job := Job{
		ID: jobID, Kind: in.Kind, SourcePath: in.SourcePath,
		TopicID: in.TopicID, BaseSHA: in.BaseSHA,
		PersonaName: in.PersonaName, SourceSHA: in.SourceSHA, PersonaSHA: in.PersonaSHA,
		RepoRoot: s.cfg.RepoRoot, WikiRoot: s.cfg.WikiRoot, WBAgentPath: s.cfg.WBAgentPath,
	}
	if s.cfg.LogDir != "" {
		job.LogPath = filepath.Join(s.cfg.LogDir, jobID+".log")
	}

	// Per-kind timeout is enforced via the context passed to Runner.Run.
	// Service owns this here rather than the Runner so future Runner
	// implementations (v2 channels, mock servers) honor the timeout
	// uniformly via the standard context contract.
	jobCtx, jobCancel := context.WithTimeout(s.stopCtx, timeout)
	defer jobCancel()

	start := time.Now()
	res := s.cfg.Runner.Run(jobCtx, job)
	dur := time.Since(start)

	status, errTail := s.classify(res, in, jobID)
	// Only an actual process exit produces a meaningful exit code. Spawn
	// errors and timeouts leave it nil so the log line mirrors the DB row
	// (CompleteJobInput.ExitCode is *int).
	var exitCode *int
	if res.Err == nil && !res.TimedOut {
		exitCode = &res.ExitCode
	}
	slog.Info("agent: job complete",
		"job_id", jobID, "kind", in.Kind, "source_path", in.SourcePath,
		"duration_ms", dur.Milliseconds(), "status", status,
		"exit_code", exitCode,
	)
	if err := s.cfg.Store.CompleteJob(collab.CompleteJobInput{
		ID: jobID, Status: status, ExitCode: exitCode, ErrorTail: errTail,
	}); err != nil {
		slog.Warn("agent: CompleteJob failed", "job_id", jobID, "err", err)
	}
}

// classify maps a RunResult onto (status, error_tail). It also runs the
// post-exit invariant check for incorporate jobs ("did a proposal row
// appear?"). Per #3's contract, deeper checks live in #4/#5.
func (s *Service) classify(res RunResult, in SubmitInput, jobID string) (string, string) {
	if res.TimedOut {
		return "timed_out", firstNonEmpty(res.ErrorTail, "timed out")
	}
	if res.Err != nil {
		return "failed", "agent unreachable: " + res.Err.Error()
	}
	if res.ExitCode != 0 {
		return "failed", firstNonEmpty(res.ErrorTail, fmt.Sprintf("exit %d", res.ExitCode))
	}
	if in.Kind == "incorporate" {
		if err := s.assertProposalCreated(in.TopicID, jobID); err != nil {
			return "failed", err.Error()
		}
	}
	return "succeeded", ""
}

// assertProposalCreated checks that the agent produced at least one
// incorporation_proposals row for the given topic since the job started.
// Uses the agent_jobs row's started_at (not created_at) as the lower bound,
// per the spec: a job may sit in `queued` for arbitrary time before running,
// during which a prior proposal for the same topic could land via another
// path (e.g. a direct wb-agent invocation). Using created_at would
// erroneously credit those to this job. StartedAt is non-null at this point
// because StartJob always runs before Runner.Run.
func (s *Service) assertProposalCreated(topicID, jobID string) error {
	jobRow, err := s.cfg.Store.GetJob(jobID)
	if err != nil {
		return fmt.Errorf("agent exited 0; post-exit GetJob failed: %w", err)
	}
	if jobRow.StartedAt == nil {
		// Invariant violation: classify() runs after Runner.Run, which runs
		// after StartJob; reaching here means the agent_jobs lifecycle is
		// broken. Treat as failure rather than silently skipping the check.
		return errors.New("agent exited 0; post-exit check found no started_at on the job row")
	}
	has, err := s.cfg.Store.HasProposalForTopicSince(topicID, *jobRow.StartedAt)
	if err != nil {
		return fmt.Errorf("agent exited 0; post-exit query failed: %w", err)
	}
	if !has {
		return errors.New("agent exited 0 but produced no proposal")
	}
	return nil
}

func firstNonEmpty(parts ...string) string {
	for _, p := range parts {
		if p != "" {
			return p
		}
	}
	return ""
}
go test ./internal/agent/ -run TestService -v -timeout=30s

Expected: PASS for all service tests.

git add internal/agent/service.go internal/agent/service_test.go
git commit -m "wiki-browser: agent — Service with per-source queue, global cap, lifecycle"

Task 9: cmd/wb-agent — subcommand dispatcher

Files:

The CLI scaffold. Subcommand handlers come in Tasks 10-13.

Create cmd/wb-agent/main_test.go:

package main

import (
	"bytes"
	"strings"
	"testing"
)

func TestDispatch_UnknownSubcommand(t *testing.T) {
	var stderr bytes.Buffer
	code := dispatch([]string{"wb-agent", "nope"}, &bytes.Buffer{}, &stderr, nil)
	if code == 0 {
		t.Fatalf("exit code = 0; want non-zero for unknown subcommand")
	}
	if !strings.Contains(stderr.String(), "unknown subcommand") {
		t.Fatalf("stderr = %q; want to mention unknown subcommand", stderr.String())
	}
}

func TestDispatch_NoArgsShowsHelp(t *testing.T) {
	var stderr bytes.Buffer
	code := dispatch([]string{"wb-agent"}, &bytes.Buffer{}, &stderr, nil)
	if code == 0 {
		t.Fatalf("exit code = 0; want non-zero with no subcommand")
	}
	if !strings.Contains(stderr.String(), "Usage") {
		t.Fatalf("stderr = %q; want Usage", stderr.String())
	}
}
go test ./cmd/wb-agent/ -v

Expected: compile error — package doesn't exist.

// wb-agent — the CLI surface the Agent uses for collab DB access.
//
// The Agent invokes this via the Bash tool from inside a wb-incorporate or
// wb-perspective skill. The CLI opens the same collab DB the running
// wiki-browser server uses, via the existing internal/collab code paths
// (validation, sequence allocation, etc.). SQLite WAL + busy_timeout handle
// cross-process write contention.
package main

import (
	"fmt"
	"io"
	"os"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func main() {
	os.Exit(dispatch(os.Args, os.Stdout, os.Stderr, openStore))
}

// storeOpener returns an opened *collab.Store. The CALLER is responsible for
// calling Store.Close() — wb-agent is a short-lived process, but a missed
// Close skips the WAL checkpoint and can corrupt the DB under tight loops.
// Every subcommand handler must `defer store.Close()` immediately after a
// successful open.
type storeOpener func(configPath string) (*collab.Store, error)

func openStore(configPath string) (*collab.Store, error) {
	// Real implementation comes in Task 10 once the first subcommand needs it.
	return nil, fmt.Errorf("openStore: not implemented")
}

func dispatch(args []string, stdout, stderr io.Writer, opener storeOpener) int {
	if len(args) < 2 {
		fmt.Fprintln(stderr, usage())
		return 2
	}
	switch args[1] {
	case "get-topic", "list-open-topics", "insert-proposal", "get-persona", "put-perspective":
		fmt.Fprintln(stderr, "subcommand not yet implemented")
		return 2
	default:
		fmt.Fprintf(stderr, "unknown subcommand: %s\n%s\n", args[1], usage())
		return 2
	}
}

func usage() string {
	return `Usage: wb-agent <subcommand> [flags]

Subcommands:
  get-topic         --id=<id>
  list-open-topics  --source-path=<path>
  insert-proposal   --topic-id=<id> --base-sha=<sha>     (proposed source on stdin)
  get-persona       --source-path=<path> --name=<name>   (scaffold; #5)
  put-perspective   --source-path=<path> --persona=<n>
                    --source-sha=<sha> --persona-sha=<sha> (scaffold; #5)
`
}
go test ./cmd/wb-agent/ -v

Expected: PASS.

go build -o /tmp/wb-agent ./cmd/wb-agent && /tmp/wb-agent

Expected: Prints usage on stderr; exits 2.

git add cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — subcommand dispatcher skeleton"

Task 10: wb-agent get-topic

Files:

Loads a Topic by ID with its anchor and full message thread; emits JSON. This is also the first subcommand that needs an opened collab store, so we implement openStore here.

Append to cmd/wb-agent/main_test.go:

import (
	"encoding/json"
	"path/filepath"

	"github.com/getorcha/wiki-browser/internal/collab"
)

// newTestStore returns one store for test setup/assertions plus an opener that
// opens a fresh Store handle on the same DB path for the command under test.
// Command handlers own and close the handle returned by opener; closing it must
// not close the setup/assertion handle.
func newTestStore(t *testing.T) (*collab.Store, storeOpener) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "collab.db")
	s, err := collab.Open(collab.Config{Path: path})
	if err != nil { t.Fatalf("Open: %v", err) }
	t.Cleanup(func() { _ = s.Close() })
	opener := func(string) (*collab.Store, error) {
		return collab.Open(collab.Config{Path: path})
	}
	return s, opener
}

func TestGetTopic_EmitsJSON(t *testing.T) {
	store, opener := newTestStore(t)
	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil { t.Fatalf("seed user: %v", err) }
	if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
		TopicID: "t1", SourcePath: "docs/foo.md",
		Anchor:           []byte(`{"kind":"global"}`),
		CreatedBy:        "u1",
		FirstMessageID:   "m1",
		FirstMessageBody: "hello",
	}); err != nil { t.Fatalf("seed topic: %v", err) }

	var stdout, stderr bytes.Buffer
	code := dispatch(
		[]string{"wb-agent", "get-topic", "--id=t1"},
		&stdout, &stderr, opener,
	)
	if code != 0 {
		t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
	}

	var out struct {
		ID         string          `json:"id"`
		SourcePath string          `json:"source_path"`
		Anchor     json.RawMessage `json:"anchor"`
		Messages   []struct {
			ID, Body string
		} `json:"messages"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
		t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
	}
	if out.ID != "t1" {
		t.Fatalf("ID = %q, want t1", out.ID)
	}
	if len(out.Messages) != 1 || out.Messages[0].Body != "hello" {
		t.Fatalf("messages = %+v", out.Messages)
	}
}
go test ./cmd/wb-agent/ -v

Expected: compile or test failure — handler doesn't exist.

package main

import (
	"database/sql"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"

	"github.com/getorcha/wiki-browser/internal/collab"
)

type getTopicOutput struct {
	ID         string                `json:"id"`
	SourcePath string                `json:"source_path"`
	Anchor     json.RawMessage       `json:"anchor"`
	CreatedBy  string                `json:"created_by"`
	CreatedAt  int64                 `json:"created_at"`
	Messages   []collab.TopicMessage `json:"messages"`
}

func runGetTopic(args []string, stdout, stderr io.Writer, opener storeOpener) int {
	fs := flag.NewFlagSet("get-topic", flag.ContinueOnError)
	fs.SetOutput(stderr)
	configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
	id := fs.String("id", "", "topic id")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *id == "" {
		fmt.Fprintln(stderr, "get-topic: --id is required")
		return 2
	}

	store, err := opener(*configPath)
	if err != nil {
		fmt.Fprintf(stderr, "open store: %v\n", err)
		return 1
	}
	defer store.Close()

	row := store.RawDB().QueryRow(
		`SELECT id, source_path, anchor, created_by, created_at
		   FROM topics WHERE id = ?`, *id,
	)
	var out getTopicOutput
	var anchor string
	if err := row.Scan(&out.ID, &out.SourcePath, &anchor, &out.CreatedBy, &out.CreatedAt); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			fmt.Fprintf(stderr, "topic %s not found\n", *id)
			return 1
		}
		fmt.Fprintf(stderr, "scan: %v\n", err)
		return 1
	}
	out.Anchor = json.RawMessage(anchor)

	msgs, err := store.ListMessages(*id)
	if err != nil {
		fmt.Fprintf(stderr, "list messages: %v\n", err)
		return 1
	}
	out.Messages = msgs

	if err := json.NewEncoder(stdout).Encode(out); err != nil {
		fmt.Fprintf(stderr, "encode: %v\n", err)
		return 1
	}
	return 0
}

In cmd/wb-agent/main.go, replace the dispatcher's switch:

switch args[1] {
case "get-topic":
	return runGetTopic(args[2:], stdout, stderr, opener)
case "list-open-topics", "insert-proposal", "get-persona", "put-perspective":
	fmt.Fprintln(stderr, "subcommand not yet implemented")
	return 2
default:
	fmt.Fprintf(stderr, "unknown subcommand: %s\n%s\n", args[1], usage())
	return 2
}

Also flesh out openStore:

func openStore(configPath string) (*collab.Store, error) {
	cfg, err := config.Load(configPath)
	if err != nil {
		return nil, fmt.Errorf("load %s: %w", configPath, err)
	}
	// collab.Open runs Migrate as part of startup. The server has already
	// migrated by the time wb-agent is invoked, so this is wasted work but
	// not incorrect — Migrate is idempotent and the schema_migrations row
	// short-circuits the apply path. Optimizing to skip Migrate here would
	// require a new collab.OpenReadyMigrated flag; defer until profiling
	// shows it matters.
	return collab.Open(collab.Config{Path: cfg.CollabDB})
}

Add import "github.com/getorcha/wiki-browser/internal/config".

go test ./cmd/wb-agent/ -v

Expected: PASS.

git add cmd/wb-agent/main.go cmd/wb-agent/main_test.go cmd/wb-agent/get_topic.go
git commit -m "wiki-browser: wb-agent — get-topic subcommand"

Task 11: wb-agent list-open-topics

Files:

Append to cmd/wb-agent/main_test.go:

func TestListOpenTopics_EmitsJSONArray(t *testing.T) {
	store, opener := newTestStore(t)
	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil { t.Fatalf("seed user: %v", err) }

	for _, id := range []string{"ta", "tb"} {
		if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
			TopicID: id, SourcePath: "docs/foo.md",
			Anchor:           []byte(`{"kind":"global"}`),
			CreatedBy:        "u1",
			FirstMessageID:   "m-" + id,
			FirstMessageBody: id,
		}); err != nil { t.Fatalf("seed: %v", err) }
	}

	var stdout, stderr bytes.Buffer
	code := dispatch(
		[]string{"wb-agent", "list-open-topics", "--source-path=docs/foo.md"},
		&stdout, &stderr, opener,
	)
	if code != 0 {
		t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
	}

	var rows []map[string]any
	if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
		t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
	}
	if len(rows) != 2 {
		t.Fatalf("len(rows) = %d, want 2", len(rows))
	}
}
go test ./cmd/wb-agent/ -run TestListOpen -v

Expected: FAIL — handler not yet implemented (dispatcher emits "not yet implemented").

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
)

func runListOpenTopics(args []string, stdout, stderr io.Writer, opener storeOpener) int {
	fs := flag.NewFlagSet("list-open-topics", flag.ContinueOnError)
	fs.SetOutput(stderr)
	configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
	sourcePath := fs.String("source-path", "", "repo-relative source path")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *sourcePath == "" {
		fmt.Fprintln(stderr, "list-open-topics: --source-path is required")
		return 2
	}

	store, err := opener(*configPath)
	if err != nil {
		fmt.Fprintf(stderr, "open store: %v\n", err)
		return 1
	}
	defer store.Close()

	topics, err := store.ListOpenTopicsForSource(*sourcePath)
	if err != nil {
		fmt.Fprintf(stderr, "list: %v\n", err)
		return 1
	}
	if err := json.NewEncoder(stdout).Encode(topics); err != nil {
		fmt.Fprintf(stderr, "encode: %v\n", err)
		return 1
	}
	return 0
}

In cmd/wb-agent/main.go:

case "list-open-topics":
	return runListOpenTopics(args[2:], stdout, stderr, opener)
go test ./cmd/wb-agent/ -v

Expected: PASS.

git add cmd/wb-agent/list_open_topics.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — list-open-topics subcommand"

Task 12: wb-agent insert-proposal

Files:

Reads proposed Source from stdin. Allocates the next revision_number for the topic. Inserts with proposed_by = NULL.

Append to cmd/wb-agent/main_test.go:

func TestInsertProposal_AllocatesRevisionAndUsesNullProposedBy(t *testing.T) {
	store, opener := newTestStore(t)
	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil { t.Fatalf("seed user: %v", err) }
	if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
		TopicID: "t1", SourcePath: "docs/foo.md",
		Anchor: []byte(`{"kind":"global"}`),
		CreatedBy: "u1", FirstMessageID: "m1", FirstMessageBody: "hi",
	}); err != nil { t.Fatalf("seed: %v", err) }

	args := []string{
		"wb-agent", "insert-proposal",
		"--topic-id=t1", "--base-sha=deadbeef",
	}

	var stdout, stderr bytes.Buffer
	code := runInsertProposalWithStdin(t, args, &stdout, &stderr,
		strings.NewReader("new body bytes"),
		opener,
	)
	if code != 0 {
		t.Fatalf("exit = %d; stderr = %s", code, stderr.String())
	}

	var resp struct{ ID string `json:"id"` }
	if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil || resp.ID == "" {
		t.Fatalf("unmarshal: %v; stdout=%s", err, stdout.String())
	}

	// Verify row exists with null proposed_by.
	var by sql.NullString
	var rev int
	if err := store.RawDBForTest().QueryRow(
		`SELECT revision_number, proposed_by FROM incorporation_proposals WHERE id = ?`,
		resp.ID,
	).Scan(&rev, &by); err != nil {
		t.Fatalf("scan: %v", err)
	}
	if rev != 1 || by.Valid {
		t.Fatalf("rev=%d, proposed_by.Valid=%v; want rev=1, null", rev, by.Valid)
	}
}

// runInsertProposalWithStdin is a small wrapper that exposes the stdin
// reader to the subcommand, since dispatch hard-codes os.Stdin in main.
func runInsertProposalWithStdin(t *testing.T, args []string, stdout, stderr io.Writer, stdin io.Reader, opener storeOpener) int {
	t.Helper()
	if len(args) < 2 || args[1] != "insert-proposal" {
		t.Fatalf("test misuse: %v", args)
	}
	return runInsertProposal(args[2:], stdout, stderr, stdin, opener)
}

Add the needed imports: "database/sql", "io", "strings".

go test ./cmd/wb-agent/ -run TestInsertProposal -v

Expected: compile error — runInsertProposal not defined.

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"

	"github.com/google/uuid"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func runInsertProposal(args []string, stdout, stderr io.Writer, stdin io.Reader, opener storeOpener) int {
	fs := flag.NewFlagSet("insert-proposal", flag.ContinueOnError)
	fs.SetOutput(stderr)
	configPath := fs.String("config", "wiki-browser.yaml", "path to config file")
	topicID := fs.String("topic-id", "", "topic id")
	baseSHA := fs.String("base-sha", "", "base source SHA (git blob)")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *topicID == "" || *baseSHA == "" {
		fmt.Fprintln(stderr, "insert-proposal: --topic-id and --base-sha are required")
		return 2
	}

	body, err := io.ReadAll(stdin)
	if err != nil {
		fmt.Fprintf(stderr, "read stdin: %v\n", err)
		return 1
	}
	if len(body) == 0 {
		fmt.Fprintln(stderr, "insert-proposal: stdin is empty")
		return 2
	}

	store, err := opener(*configPath)
	if err != nil {
		fmt.Fprintf(stderr, "open store: %v\n", err)
		return 1
	}
	defer store.Close()

	// Allocate next revision number. This runs as a raw read because the
	// write happens through the funnel via InsertProposal below; concurrent
	// writers for the same topic would race here, but per the spec at most
	// one agent job per source is in flight at a time, so collisions are
	// impossible in practice.
	var nextRev int
	if err := store.RawDB().QueryRow(
		`SELECT COALESCE(MAX(revision_number), 0) + 1
		   FROM incorporation_proposals WHERE topic_id = ?`,
		*topicID,
	).Scan(&nextRev); err != nil {
		fmt.Fprintf(stderr, "next revision: %v\n", err)
		return 1
	}

	id := uuid.NewString()
	if _, err := store.InsertProposal(collab.NewProposal{
		ID: id, TopicID: *topicID, RevisionNumber: nextRev,
		ProposedSource: string(body), BaseSourceSHA: *baseSHA,
		ProposedBy: nil,
	}); err != nil {
		fmt.Fprintf(stderr, "insert proposal: %v\n", err)
		return 1
	}

	_ = json.NewEncoder(stdout).Encode(struct {
		ID string `json:"id"`
	}{ID: id})
	return 0
}

In cmd/wb-agent/main.go:

case "insert-proposal":
	return runInsertProposal(args[2:], stdout, stderr, os.Stdin, opener)

Update dispatch's signature to take stdin — or keep os.Stdin hardcoded since this is the only stdin-consuming subcommand. The test wraps runInsertProposal directly to inject stdin.

go test ./cmd/wb-agent/ -v

Expected: PASS.

git add cmd/wb-agent/insert_proposal.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — insert-proposal subcommand (null proposed_by)"

Task 13: wb-agentget-persona and put-perspective stubs

Files:

These are scaffolds — they accept the documented flags, exit 0, and print a placeholder so the runtime is testable. #5 implements the real behavior.

Append to cmd/wb-agent/main_test.go:

func TestStubs_ExitZero(t *testing.T) {
	cases := [][]string{
		{"wb-agent", "get-persona", "--source-path=docs/foo.md", "--name=CFO"},
		{"wb-agent", "put-perspective", "--source-path=docs/foo.md",
			"--persona=CFO", "--source-sha=sha", "--persona-sha=psha"},
	}
	for _, args := range cases {
		_, opener := newTestStore(t)
		var stdout, stderr bytes.Buffer
		code := dispatch(args, &stdout, &stderr, opener)
		if code != 0 {
			t.Fatalf("args=%v: exit=%d stderr=%s", args, code, stderr.String())
		}
		if !strings.Contains(stdout.String(), "scaffold") {
			t.Fatalf("args=%v: stdout=%q; want scaffold marker", args, stdout.String())
		}
	}
}
go test ./cmd/wb-agent/ -run TestStubs -v

Expected: FAIL — dispatcher still says "not yet implemented".

package main

import (
	"flag"
	"fmt"
	"io"
)

// runGetPersona is a #5-owned scaffold. #3 ships it so the runtime is
// end-to-end testable; the response shape will be replaced by #5.
func runGetPersona(args []string, stdout, stderr io.Writer, opener storeOpener) int {
	fs := flag.NewFlagSet("get-persona", flag.ContinueOnError)
	fs.SetOutput(stderr)
	_ = fs.String("config", "wiki-browser.yaml", "")
	_ = fs.String("source-path", "", "")
	_ = fs.String("name", "", "")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	fmt.Fprintln(stdout, `{"scaffold":"get-persona; #5 will replace this"}`)
	return 0
}

// runPutPerspective is a #5-owned scaffold.
func runPutPerspective(args []string, stdout, stderr io.Writer, opener storeOpener) int {
	fs := flag.NewFlagSet("put-perspective", flag.ContinueOnError)
	fs.SetOutput(stderr)
	_ = fs.String("config", "wiki-browser.yaml", "")
	_ = fs.String("source-path", "", "")
	_ = fs.String("persona", "", "")
	_ = fs.String("source-sha", "", "")
	_ = fs.String("persona-sha", "", "")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	fmt.Fprintln(stdout, `{"scaffold":"put-perspective; #5 will replace this"}`)
	return 0
}

In cmd/wb-agent/main.go:

case "get-persona":
	return runGetPersona(args[2:], stdout, stderr, opener)
case "put-perspective":
	return runPutPerspective(args[2:], stdout, stderr, opener)

Remove the "not yet implemented" arm.

go test ./cmd/wb-agent/ -v

Expected: PASS.

git add cmd/wb-agent/stubs.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — get-persona and put-perspective scaffold stubs"

Task 14: HTTP — agent-jobs handlers

Files:

Three endpoints. All wrapped with auth.RequireCollaborator; POST also with auth.RequireCSRF. The handler depends on a new AgentService field on server.Deps.

First, the existing newTestServerWithSession helper does not let callers inject an AgentService. The cleanest fix is to extend the helper to accept an optional AgentService parameter (default nil); existing call sites remain unchanged because Go passes nil for unspecified variadic-like configuration. Concretely, change the helper from a fixed signature to take a small testServerOptions struct and keep the existing entry points for backward compatibility.

In internal/server/handler_doc_test.go, replace newTestServerWithSession with a variant that exposes an option struct, but keep the previous signature working:

type testServerOptions struct {
	SessionMiddleware func(http.Handler) http.Handler
	AgentService      AgentService
}

func newTestServerWithSession(t *testing.T, mw func(http.Handler) http.Handler) (*httptest.Server, string, *collab.Store) {
	t.Helper()
	return newTestServerWithOptions(t, testServerOptions{SessionMiddleware: mw})
}

func newTestServerWithOptions(t *testing.T, opts testServerOptions) (*httptest.Server, string, *collab.Store) {
	t.Helper()
	root := t.TempDir()
	files := map[string]string{
		"a.md":     "# A\n\nalpha bravo charlie",
		"raw.html": "<!doctype html><html><body><h1>hi</h1></body></html>",
	}
	for name, body := range files {
		if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	if err != nil { t.Fatal(err) }
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil { t.Fatal(err) }
	idx.SetRoot(root)
	cache := render.NewCache(4 << 20)
	idx.SetCache(cache)
	t.Cleanup(func() { idx.Close() })
	for name := range files {
		if err := idx.Reindex(filepath.Join(root, name)); err != nil { t.Fatal(err) }
	}
	store, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "collab.db")})
	if err != nil { t.Fatal(err) }
	t.Cleanup(func() { _ = store.Close() })
	if err := store.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	mux := server.Mux(server.Deps{
		Title:             "Test",
		Root:              root,
		Walker:            w,
		Index:             idx,
		Cache:             cache,
		Collab:            store,
		SessionMiddleware: opts.SessionMiddleware,
		AgentService:      opts.AgentService,
	})
	ts := httptest.NewServer(mux)
	t.Cleanup(ts.Close)
	return ts, root, store
}

Create internal/server/agent_jobs_test.go:

package server_test

import (
	"encoding/json"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/agent"
	"github.com/getorcha/wiki-browser/internal/server"
)

type fakeAgentService struct {
	submitted []agent.SubmitInput
	id        string
	err       error
}

func (f *fakeAgentService) Submit(in agent.SubmitInput) (string, error) {
	f.submitted = append(f.submitted, in)
	return f.id, f.err
}

func (f *fakeAgentService) AuthorIdentity() (string, string) {
	return "Orcha Agent", "agent@orcha.local"
}

// Compile-time assertion: fakeAgentService satisfies the server interface.
var _ server.AgentService = (*fakeAgentService)(nil)

func TestPostAgentJob_CreatesAndReturnsID(t *testing.T) {
	fake := &fakeAgentService{id: "job-1"}
	ts, _, _ := newTestServerWithOptions(t, testServerOptions{
		SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
		AgentService:      fake,
	})

	body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
	req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-CSRF-Token", "csrf")
	resp, err := http.DefaultClient.Do(req)
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusAccepted {
		b, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, body = %s", resp.StatusCode, b)
	}
	var out struct{ JobID string `json:"job_id"` }
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) }
	if out.JobID != "job-1" {
		t.Fatalf("job_id = %q, want job-1", out.JobID)
	}
	if len(fake.submitted) != 1 || fake.submitted[0].Kind != "incorporate" {
		t.Fatalf("submitted = %+v", fake.submitted)
	}
}

func TestPostAgentJob_409OnInflight(t *testing.T) {
	fake := &fakeAgentService{err: agent.ErrInflight}
	ts, _, _ := newTestServerWithOptions(t, testServerOptions{
		SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
		AgentService:      fake,
	})

	body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
	req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-CSRF-Token", "csrf")
	resp, err := http.DefaultClient.Do(req)
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusConflict {
		b, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, body = %s", resp.StatusCode, b)
	}
}

func TestGetAgentJobs_RequiresAuth(t *testing.T) {
	// newTestServerWithOptions wires a real Collab store internally, so the
	// 503 "agent_unavailable" branch in handleListAgentJobs doesn't pre-empt
	// the auth check. We want auth (RequireCollaborator) to fire BEFORE the
	// handler runs, returning 401. Pass a non-nil AgentService for the same
	// reason — POST validation needs it, and GET handlers fall through to
	// the auth middleware first.
	fake := &fakeAgentService{}
	ts, _, _ := newTestServerWithOptions(t, testServerOptions{
		// Nil session middleware: requests carry no session principal, so
		// RequireCollaborator should reject with 401.
		SessionMiddleware: nil,
		AgentService:      fake,
	})

	resp, err := http.Get(ts.URL + "/api/agent/jobs?source_path=docs/foo.md")
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", resp.StatusCode)
	}
}

func TestPostAgentJob_RequiresCSRF(t *testing.T) {
	// Authenticated request that omits X-CSRF-Token must return 403, not 401.
	// Per #7: missing session → 401, authenticated-without-CSRF → 403.
	fake := &fakeAgentService{id: "job-1"}
	ts, _, _ := newTestServerWithOptions(t, testServerOptions{
		SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
		AgentService:      fake,
	})

	body := strings.NewReader(`{"kind":"incorporate","source_path":"docs/foo.md","topic_id":"t1","base_sha":"sha"}`)
	req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
	req.Header.Set("Content-Type", "application/json")
	// Deliberately omit X-CSRF-Token.
	resp, err := http.DefaultClient.Do(req)
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("status = %d, want 403 (missing CSRF on authenticated request)", resp.StatusCode)
	}
	if len(fake.submitted) != 0 {
		t.Fatalf("submitted = %+v; CSRF should have blocked before the service was called", fake.submitted)
	}
}

func TestPostAgentJob_ValidationErrorCodes(t *testing.T) {
	// The handler should differentiate validation failures from the catch-all
	// "bad_request" so the client can route on the error code. agent.Submit
	// returns typed errors; the handler maps them to specific JSON codes.
	fake := &fakeAgentService{err: agent.ErrUnsupportedKind}
	ts, _, _ := newTestServerWithOptions(t, testServerOptions{
		SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
		AgentService:      fake,
	})

	body := strings.NewReader(`{"kind":"nope","source_path":"docs/foo.md"}`)
	req, _ := http.NewRequest("POST", ts.URL+"/api/agent/jobs", body)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-CSRF-Token", "csrf")
	resp, err := http.DefaultClient.Do(req)
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
	var out struct{ Error string `json:"error"` }
	_ = json.NewDecoder(resp.Body).Decode(&out)
	if out.Error != "unsupported_kind" {
		t.Fatalf("error code = %q, want unsupported_kind", out.Error)
	}
}
go test ./internal/server/ -run TestPostAgentJob -v

Expected: compile error — Deps.AgentService doesn't exist, AgentService interface doesn't exist.

package server

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"

	"github.com/getorcha/wiki-browser/internal/agent"
	"github.com/getorcha/wiki-browser/internal/collab"
)

// AgentService is the subset of *agent.Service the HTTP layer needs. Lets
// tests inject a fake without instantiating a real queue. AuthorIdentity is
// exposed so #4's proposal-approval handler can read the git author from the
// same seam, rather than re-reading config.
type AgentService interface {
	Submit(in agent.SubmitInput) (string, error)
	AuthorIdentity() (name, email string)
}

type agentJobCreateRequest struct {
	Kind        string `json:"kind"`
	SourcePath  string `json:"source_path"`
	TopicID     string `json:"topic_id,omitempty"`
	BaseSHA     string `json:"base_sha,omitempty"`
	PersonaName string `json:"persona_name,omitempty"`
	SourceSHA   string `json:"source_sha,omitempty"`
	PersonaSHA  string `json:"persona_sha,omitempty"`
}

func (d Deps) handleCreateAgentJob(w http.ResponseWriter, r *http.Request) {
	if d.AgentService == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
		return
	}
	var req agentJobCreateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, http.StatusBadRequest, "bad_json")
		return
	}
	if _, err := collab.ValidateSourcePath(req.SourcePath); err != nil {
		writeJSONError(w, http.StatusBadRequest, "bad_source_path")
		return
	}

	id, err := d.AgentService.Submit(agent.SubmitInput{
		Kind: req.Kind, SourcePath: req.SourcePath,
		TopicID: req.TopicID, BaseSHA: req.BaseSHA,
		PersonaName: req.PersonaName, SourceSHA: req.SourceSHA, PersonaSHA: req.PersonaSHA,
	})
	switch {
	case errors.Is(err, agent.ErrInflight):
		writeJSONError(w, http.StatusConflict, "inflight")
		return
	case errors.Is(err, agent.ErrUnsupportedKind):
		writeJSONError(w, http.StatusBadRequest, "unsupported_kind")
		return
	case errors.Is(err, agent.ErrMissingIncorporateFields):
		writeJSONError(w, http.StatusBadRequest, "missing_incorporate_fields")
		return
	case errors.Is(err, agent.ErrMissingPerspectiveFields):
		writeJSONError(w, http.StatusBadRequest, "missing_perspective_fields")
		return
	case errors.Is(err, agent.ErrMissingSourcePath):
		writeJSONError(w, http.StatusBadRequest, "missing_source_path")
		return
	case err != nil:
		// Catch-all for unexpected service errors (DB failures during the
		// HasInflightForSource check, InsertJob failures, etc.). These
		// represent server-side problems, not client validation issues.
		writeJSONError(w, http.StatusInternalServerError, "submit_failed")
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"job_id": id})
}

func (d Deps) handleListAgentJobs(w http.ResponseWriter, r *http.Request) {
	if d.Collab == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
		return
	}
	sourcePath := r.URL.Query().Get("source_path")
	limit := 20
	if s := r.URL.Query().Get("limit"); s != "" {
		if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
			limit = n
		}
	}
	jobs, err := d.Collab.ListJobsForSource(sourcePath, limit)
	if err != nil {
		writeJSONError(w, http.StatusBadRequest, "bad_source_path")
		return
	}
	writeJSON(w, http.StatusOK, jobs)
}

func (d Deps) handleGetAgentJob(w http.ResponseWriter, r *http.Request) {
	if d.Collab == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
		return
	}
	job, err := d.Collab.GetJob(r.PathValue("id"))
	if err != nil {
		writeJSONError(w, http.StatusNotFound, "unknown_job")
		return
	}
	writeJSON(w, http.StatusOK, job)
}

Edit internal/server/server.go. Add to Deps:

type Deps struct {
	// ... existing fields ...
	AgentService AgentService
}

Add inside Mux:

mux.Handle("POST /api/agent/jobs",
	d.withSession(auth.RequireCollaborator(auth.RequireCSRF(http.HandlerFunc(d.handleCreateAgentJob)))))
mux.Handle("GET /api/agent/jobs",
	d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleListAgentJobs))))
mux.Handle("GET /api/agent/jobs/{id}",
	d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleGetAgentJob))))
go test ./internal/server/ -run TestPostAgentJob -v
go test ./internal/server/ -run TestGetAgentJobs -v

Expected: PASS for both.

git add internal/server/agent_jobs.go internal/server/agent_jobs_test.go internal/server/server.go
git commit -m "wiki-browser: server — /api/agent/jobs endpoints"

Task 15: Wire agent.Service and startup sweep into main.go

Files:

Construct the service after opening the collab store, run the startup sweep before collab.Recover, defer svc.Stop(), and pass it into server.Deps.

In run, just after collabStore is opened (and before collab.Recover), insert:

// Sweep any agent_jobs left running by a previous boot. Run before
// collab.Recover so the reconciliation logic sees consistent state.
if swept, err := collabStore.SweepIncompleteJobs(); err != nil {
	return fmt.Errorf("sweep agent jobs: %w", err)
} else if swept > 0 {
	slog.Info("agent_jobs swept on startup", "count", swept)
}

Add internal/agent to imports. Construct the runner + service after the auth setup block, before mux := server.Mux(...):

runner := agent.NewClaudeCLIRunner(agent.ClaudeCLIRunnerOptions{
	ClaudeBin: cfg.Agent.ClaudeBin,
})
agentSvc := agent.NewService(agent.ServiceConfig{
	Store:              collabStore,
	Runner:             runner,
	MaxConcurrentJobs:  cfg.Agent.MaxConcurrentJobs,
	IncorporateTimeout: cfg.Agent.IncorporateTimeout,
	PerspectiveTimeout: cfg.Agent.PerspectiveTimeout,
	RepoRoot:           cfg.Root,
	WikiRoot:           wikiBrowserRoot(),
	WBAgentPath:        cfg.Agent.WBAgentBin,
	LogDir:             cfg.Agent.LogDir,
	AuthorName:         cfg.Agent.AuthorName,
	AuthorEmail:        cfg.Agent.AuthorEmail,
})
defer agentSvc.Stop()

Add a tiny helper at the bottom of the file:

// wikiBrowserRoot returns the directory of the running wiki-browser binary,
// which is also where .claude/skills/ lives in the deployed layout.
func wikiBrowserRoot() string {
	self, err := os.Executable()
	if err != nil {
		// Fall back to cwd; the harness will fail-fast in validation
		// elsewhere if this turns out wrong.
		cwd, _ := os.Getwd()
		return cwd
	}
	return filepath.Dir(self)
}

Pass into Deps:

mux := server.Mux(server.Deps{
	Title:             cfg.Title,
	Root:              cfg.Root,
	Walker:            w,
	Index:             idx,
	Cache:             renderCache,
	Collab:            collabStore,
	Auth:              authHandlers,
	SessionMiddleware: sessionMiddleware,
	AuthDevMode:       cfg.Auth.DevMode,
	AgentService:      agentSvc,
})

AuthorName/AuthorEmail reach agentSvc via ServiceConfig above. #4's proposal-approval handler will read them through Deps.AgentService.AuthorIdentity() rather than cfg.Agent.*, which keeps the seam stable across config-shape changes. Until #4 lands, the values are validated at startup but the approval call site doesn't exist yet — that's fine, the seam is real and AuthorIdentity() is testable on agent.Service directly.

go build ./...

Expected: success.

go test ./...

Expected: PASS.

git add cmd/wiki-browser/main.go
git commit -m "wiki-browser: main — wire agent.Service, startup sweep, agent_jobs routes"

Task 16: Skill scaffolds — wb-incorporate and wb-perspective

Files:

These are intentional scaffolds: they lock the parameter-block schema, the sequence of wb-agent calls, and the on-disk file layout. #4 and #5 fill the <REWRITE CONTRACT OWNED BY #N> placeholders with their domain content.

---
name: wb-incorporate
description: Produce a proposed Source rewrite for an open Topic, re-anchoring
  every other open non-global Topic on the same Source. Used by wiki-browser
  during Topic incorporation.
---

# wb-incorporate

Parse the job parameters from the prompt body. They will look like:

    Job ID:          <uuid>
    Topic ID:        <topic-id>
    Source path:     <repo-relative>
    Base source SHA: <git blob SHA>
    Repo root:       <absolute path to the orcha monorepo root>
    wb-agent path:   <absolute path to the wb-agent binary>

The Repo root + Source path concatenation gives you the absolute Source
file path. Always use the absolute path the harness gave you — never
rely on the current working directory for resolving Source files.
Always invoke wb-agent via the absolute path the harness gave you —
never rely on PATH lookup.

Then:

1. Read the Source file at `<Repo root>/<Source path>`.
2. Run `<wb-agent path> get-topic --id=<topic-id>` to load the topic,
   anchor, and full message thread.
3. Run `<wb-agent path> list-open-topics --source-path=<Source path>`
   to load every other open Topic on this Source, with anchors.
4. <REWRITE CONTRACT OWNED BY #4 — re-anchor + Source rewrite contract goes here.>
5. Pipe the proposed Source on stdin to:
     `<wb-agent path> insert-proposal --topic-id=<topic-id> --base-sha=<Base source SHA>`
6. Exit 0 on success. Exit non-zero on any unrecoverable error; the wiki-browser
   server will surface stderr to the operator.
---
name: wb-perspective
description: Generate or refresh a Perspective rendering of a Source for a
  named persona. Used by wiki-browser when a stale or missing Perspective
  is requested.
---

# wb-perspective

Parse the job parameters from the prompt body. They will look like:

    Job ID:        <uuid>
    Source path:   <repo-relative>
    Persona name:  <persona>
    Source SHA:    <git blob SHA>
    Persona SHA:   <sha256 of the persona prompt>
    Repo root:     <absolute path to the orcha monorepo root>
    wb-agent path: <absolute path to the wb-agent binary>

The Repo root + Source path concatenation gives you the absolute Source
file path. Always use the absolute path the harness gave you — never
rely on the current working directory for resolving Source files.
Always invoke wb-agent via the absolute path the harness gave you —
never rely on PATH lookup.

Then:

1. Read the Source file at `<Repo root>/<Source path>`.
2. Run `<wb-agent path> get-persona --source-path=<Source path> --name=<Persona name>`
   to load the persona prompt text.
3. <REWRITE CONTRACT OWNED BY #5 — Perspective generation contract goes here.>
4. Pipe the generated Perspective on stdin to:
     `<wb-agent path> put-perspective --source-path=<Source path> --persona=<Persona name> --source-sha=<Source SHA> --persona-sha=<Persona SHA>`
5. Exit 0 on success. Exit non-zero on any unrecoverable error; the wiki-browser
   server will surface stderr to the operator.
git add .claude/skills/wb-incorporate/SKILL.md .claude/skills/wb-perspective/SKILL.md
git commit -m "wiki-browser: skills — wb-incorporate and wb-perspective scaffolds"

Task 17: Makefile and example config

Files:

Replace the build and build-arm64 targets:

# Makefile — wiki-browser
.PHONY: build build-arm64 test run lint clean

build:
	go build -trimpath -ldflags="-s -w" -o dist/wiki-browser ./cmd/wiki-browser
	go build -trimpath -ldflags="-s -w" -o dist/wb-agent ./cmd/wb-agent

# Cross-compile both binaries for a 64-bit Pi.
build-arm64:
	GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
	  go build -trimpath -ldflags="-s -w" -o dist/wiki-browser-arm64 ./cmd/wiki-browser
	GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
	  go build -trimpath -ldflags="-s -w" -o dist/wb-agent-arm64 ./cmd/wb-agent

test:
	go test ./...

run:
	go run ./cmd/wiki-browser -config=wiki-browser.yaml

lint:
	go vet ./...

clean:
	rm -rf dist

Add the agent: block at the bottom:

agent:
  author_name:         "Orcha Agent"
  author_email:        "agent@orcha.local"
  claude_bin:          ""            # optional; default "claude" in PATH
  wb_agent_bin:        ""            # optional; default: sibling of wiki-browser binary
  max_concurrent_jobs: 1
  incorporate_timeout: "5m"
  perspective_timeout: "3m"
  log_dir:             "./agent-logs" # optional; empty disables file logging
make build
ls dist/

Expected: dist/wiki-browser and dist/wb-agent both present.

git add Makefile wiki-browser.example.yaml
git commit -m "wiki-browser: build wb-agent and document agent: config"

Task 18: End-to-end smoke test

Files:

A higher-level test that uses FakeRunner to simulate the agent invoking wb-agent insert-proposal (via Go calls, not a subprocess). Validates that submit → run → complete → row-readable works without touching claude. This is the regression net for the whole runtime.

Create internal/agent/e2e_test.go:

package agent_test

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/agent"
	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestEndToEnd_IncorporateProducesProposal(t *testing.T) {
	store, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "collab.db")})
	if err != nil { t.Fatalf("Open: %v", err) }
	t.Cleanup(func() { _ = store.Close() })

	if _, err := store.RawDBForTest().Exec(
		`INSERT INTO users(id, display_name, created_at) VALUES ('u1','U1', unixepoch())`,
	); err != nil { t.Fatalf("seed user: %v", err) }
	if err := store.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
		TopicID: "t1", SourcePath: "docs/foo.md",
		Anchor: []byte(`{"kind":"global"}`),
		CreatedBy: "u1", FirstMessageID: "m1", FirstMessageBody: "rewrite foo",
	}); err != nil { t.Fatalf("seed topic: %v", err) }

	runner := agent.NewFakeRunner(func(ctx context.Context, j agent.Job) agent.RunResult {
		// Simulate the agent calling wb-agent insert-proposal.
		if _, err := store.InsertProposal(collab.NewProposal{
			ID: "p1", TopicID: j.TopicID, RevisionNumber: 1,
			ProposedSource: "fake new source", BaseSourceSHA: j.BaseSHA, ProposedBy: nil,
		}); err != nil {
			return agent.RunResult{ExitCode: 1, ErrorTail: err.Error()}
		}
		return agent.RunResult{ExitCode: 0}
	})

	svc := agent.NewService(agent.ServiceConfig{
		Store: store, Runner: runner, MaxConcurrentJobs: 1,
		IncorporateTimeout: 2 * time.Second, PerspectiveTimeout: 2 * time.Second,
		RepoRoot: t.TempDir(), WikiRoot: t.TempDir(), WBAgentPath: "/wb-agent",
	})
	t.Cleanup(svc.Stop)

	jobID, err := svc.Submit(agent.SubmitInput{
		Kind: "incorporate", SourcePath: "docs/foo.md", TopicID: "t1", BaseSHA: "sha",
	})
	if err != nil { t.Fatalf("Submit: %v", err) }

	// Poll until terminal.
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		j, err := store.GetJob(jobID)
		if err == nil && j.Status == "succeeded" {
			// And the proposal row exists.
			var n int
			if err := store.RawDBForTest().QueryRow(
				`SELECT COUNT(*) FROM incorporation_proposals WHERE topic_id = 't1'`,
			).Scan(&n); err != nil { t.Fatalf("scan: %v", err) }
			if n != 1 { t.Fatalf("proposal rows = %d, want 1", n) }
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("job did not reach succeeded state")
}
go test ./internal/agent/ -run TestEndToEnd -v -timeout=30s

Expected: PASS.

go test ./...

Expected: PASS.

git add internal/agent/e2e_test.go
git commit -m "wiki-browser: agent — end-to-end smoke test (FakeRunner)"

Done

All 18 tasks committed in order produce:

Subprojects #4 and #5 can now produce their prompt content without any further runtime work.