Topic resolution & incorporation — 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: Implement sub-project #4 of the collaborative-annotations initiative: the Resolve flow that turns an open Topic into either an Incorporated Source commit or a Discarded Topic, with an in-process Agent producing diffed Source rewrites for human review.

Architecture: Three layers on the existing #1–#3 substrate. (a) Slim the wb-agent CLI surface and the agent prompt to match a single consumer (the skill). (b) Add agent_job_id on proposals (migration 004), a freshness helper that catches both base-SHA drift and missing topic markers, and a transactional re-anchor step inside collab.CompleteIncorporation. (c) Ship seven HTTP endpoints (propose, list proposals, diff, preview render, approve, discard, plus the existing job-status endpoint) behind collaborator auth.

Tech Stack: Go 1.26, SQLite (modernc.org/sqlite), goldmark renderer, hexops/gotextdiff for unified diff, existing internal/{agent,auth,collab,render,server} packages. Tests use testing + internal/agent.FakeRunner + internal/collab in-memory stores.


Spec & decisions

Read both before starting any task. Every task assumes the spec is the source of truth — if a step here diverges, the spec wins.

File structure

New files:

Path Responsibility
internal/collab/migrations/004_proposal_job_id.sql Adds incorporation_proposals.agent_job_id + index.
internal/collab/freshness.go ProposalFreshness() — computes stale_reasons and missing_topic_ids against the current Source SHA and the current open-Topic set.
internal/collab/freshness_test.go Tests for ProposalFreshness.
internal/server/diff.go Tier 1 unified-diff computation via hexops/gotextdiff.
internal/server/diff_test.go Tests for the diff helper.
internal/server/subject.go DefaultIncorporateSubject() — rune-truncated, Markdown-stripped commit-subject default.
internal/server/subject_test.go Tests for subject default.
internal/server/handler_proposals.go All five proposal/topic HTTP handlers (propose, list, diff, approve, discard).
internal/server/handler_proposals_test.go HTTP integration tests for the handlers.
internal/server/handler_preview.go GET /content/preview/proposals/{id} — Tier 2 right-iframe HTML.
internal/server/handler_preview_test.go Preview route tests.

Modified files:

Path Change
internal/collab/mutators.go NewProposal gains AgentJobID *string; InsertProposal writes it. CompleteIncorporationInput gains ReanchorTopicIDs []string; CompleteIncorporation runs the in-tx UPDATE topics SET anchor='{"kind":"marker"}' ….
internal/collab/incorporate.go IncorporateInput gains ReanchorTopicIDs []string; threaded through to CompleteIncorporation.
internal/collab/reader.go ListOpenTopicsForSource gains an exclude-topic variant (or accept a slice of IDs to exclude); a new ListProposalsForTopic that joins agent_jobs to surface linked job_status.
internal/collab/mutators_test.go / incorporate_test.go Tests for the new fields.
cmd/wb-agent/get_topic.go Drop --id; keep --config + --job-id. Return {id, source_path (absolute), base_source_sha, anchor, created_by, created_at, messages} — no repo_root. agent-proposal messages inline their linked proposed_source so rework attempts can read prior outputs.
cmd/wb-agent/list_open_topics.go Add --exclude-topic; absolute-path input; filepath.Rel(cfg.Root, …) + collab.ValidateSourcePath defence.
cmd/wb-agent/insert_proposal.go Drop --topic-id and --base-sha; add --job-id and --explanation. BEGIN IMMEDIATE transaction inserting proposal row (with agent_job_id) + agent-proposal message in one shot.
cmd/wb-agent/{main,main_test,*_test}.go Update dispatcher table + tests for the new flag shapes.
internal/agent/runner.go / internal/agent/service.go Job struct keeps SourcePath for queue-keying but drops TopicID, BaseSHA, and RepoRoot; SubmitInput drops BaseSHA for incorporate jobs. The job row still carries topic_id; the skill discovers the rest through wb-agent. (See Task 9 notes.)
internal/agent/claude_cli_runner.go Rewrite buildPrompt in user-task voice with three values: Job ID, Config path, wb-agent path.
internal/agent/service.go Add post-job invariants (anchor markers, explanation non-empty, agent_job_id link).
internal/agent/{runner_test,service_test,claude_cli_runner_test,e2e_test}.go Rewrite fixtures to match the slim prompt + new invariants.
internal/server/server.go Wire the new handlers.
.claude/skills/wb-incorporate/SKILL.md Replace scaffold with the rewrite contract from the spec.

Conventions


Phase 1 — Schema + collab additions

Task 1: Migration 004 — incorporation_proposals.agent_job_id

Files:

In internal/collab/migrate_test.go, append:

func TestMigration004_AddsAgentJobID(t *testing.T) {
	store := newTestStore(t)

	// Insert a job + a topic so the FK has a target.
	mustExec(t, store, `INSERT INTO users(id, display_name, created_at)
		VALUES ('u1', 'u1', unixepoch())`)
	mustExec(t, store, `INSERT INTO topics(id, source_path, anchor, created_by, created_at, updated_at)
		VALUES ('t1', 'docs/x.md', '{"kind":"global"}', 'u1', unixepoch(), unixepoch())`)
	mustExec(t, store, `INSERT INTO agent_jobs(id, kind, source_path, topic_id, status, created_at)
		VALUES ('j1', 'incorporate', 'docs/x.md', 't1', 'succeeded', unixepoch())`)

	// Insert a proposal with agent_job_id set.
	mustExec(t, store, `INSERT INTO incorporation_proposals(
		id, topic_id, revision_number, proposed_source, base_source_sha,
		proposed_by, agent_job_id, created_at)
		VALUES ('p1', 't1', 1, 'x', 'sha', NULL, 'j1', unixepoch())`)

	// Insert a legacy NULL row to confirm the column is nullable.
	mustExec(t, store, `INSERT INTO incorporation_proposals(
		id, topic_id, revision_number, proposed_source, base_source_sha,
		proposed_by, agent_job_id, created_at)
		VALUES ('p2', 't1', 2, 'x', 'sha', NULL, NULL, unixepoch())`)

	// Bad FK should fail.
	_, err := store.RawDB().Exec(`INSERT INTO incorporation_proposals(
		id, topic_id, revision_number, proposed_source, base_source_sha,
		proposed_by, agent_job_id, created_at)
		VALUES ('p3', 't1', 3, 'x', 'sha', NULL, 'no-such-job', unixepoch())`)
	if err == nil {
		t.Fatal("expected FK violation, got nil")
	}
}

// mustExec — add this helper next to other test helpers if not present.
func mustExec(t *testing.T, store *Store, sql string, args ...any) {
	t.Helper()
	if _, err := store.RawDB().Exec(sql, args...); err != nil {
		t.Fatalf("exec %q: %v", sql, err)
	}
}
go test ./internal/collab/ -run TestMigration004_AddsAgentJobID -v

Expected: FAIL — column does not exist.

Create internal/collab/migrations/004_proposal_job_id.sql:

ALTER TABLE incorporation_proposals
  ADD COLUMN agent_job_id TEXT
  REFERENCES agent_jobs(id);

CREATE INDEX incorporation_proposals_agent_job
  ON incorporation_proposals(agent_job_id);

No -- migrate:no-tx directive — this is a plain ALTER TABLE ADD COLUMN, which SQLite allows inside the standard migration transaction.

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

Expected: PASS.

go test ./internal/collab/...

Expected: PASS. Look out for migration-runner tests that count migrations; bump them if needed.

git add internal/collab/migrations/004_proposal_job_id.sql internal/collab/migrate_test.go
git commit -m "wiki-browser: collab — migration 004 adds incorporation_proposals.agent_job_id"

Task 1b: Migration 005 — per-topic inflight for incorporate jobs

The spec says "Jobs for different Topics on the same Source are allowed to queue; the per-Source agent queue serializes them" (HTTP surface, POST /api/topics/{id}/proposals row). The current #3 implementation has a partial unique index agent_jobs_one_inflight_source (in migration 003) and an in-memory map keyed on source_path inside agent.Service.Submit, both of which reject the same-Source-different-Topic enqueue the spec wants. Migration 005 + a small agent.Service change fixes this without touching perspective-job behaviour materially.

Files:

In internal/agent/service_test.go, add:

func TestSubmit_AllowsDifferentTopicsOnSameSource(t *testing.T) {
	svc, store := newTestServiceWithBlockingRunner(t)
	createTopic(t, store, "t1", "docs/x.md")
	createTopic(t, store, "t2", "docs/x.md")

	id1, err := svc.Submit(SubmitInput{Kind: "incorporate", SourcePath: "docs/x.md", TopicID: "t1"})
	if err != nil {
		t.Fatalf("first submit: %v", err)
	}
	id2, err := svc.Submit(SubmitInput{Kind: "incorporate", SourcePath: "docs/x.md", TopicID: "t2"})
	if err != nil {
		t.Fatalf("second submit (different topic, same source): %v", err)
	}
	if id1 == id2 {
		t.Fatalf("expected distinct job ids")
	}
}

func TestSubmit_RejectsSameTopicInflight(t *testing.T) {
	svc, store := newTestServiceWithBlockingRunner(t)
	createTopic(t, store, "t1", "docs/x.md")
	if _, err := svc.Submit(SubmitInput{Kind: "incorporate", SourcePath: "docs/x.md", TopicID: "t1"}); err != nil {
		t.Fatal(err)
	}
	if _, err := svc.Submit(SubmitInput{Kind: "incorporate", SourcePath: "docs/x.md", TopicID: "t1"}); !errors.Is(err, ErrInflight) {
		t.Fatalf("expected ErrInflight, got %v", err)
	}
}

newTestServiceWithBlockingRunner is a helper variant (build it from the existing newTestService shape in service_test.go) that uses a BlockingRunner so the first job stays in flight while the second Submit call runs.

go test ./internal/agent/ -run TestSubmit_AllowsDifferentTopicsOnSameSource -v

Expected: FAIL — ErrInflight returned for the second submit on a different topic.

Create internal/collab/migrations/005_inflight_keys.sql:

DROP INDEX IF EXISTS agent_jobs_one_inflight_source;

CREATE UNIQUE INDEX IF NOT EXISTS agent_jobs_one_inflight_incorporate_topic
  ON agent_jobs(topic_id)
  WHERE kind = 'incorporate'
    AND status IN ('queued','running');

CREATE UNIQUE INDEX IF NOT EXISTS agent_jobs_one_inflight_perspective_source
  ON agent_jobs(source_path, persona_name)
  WHERE kind = 'perspective'
    AND status IN ('queued','running');

Replace HasInflightForSource with two kind-specific helpers:

// HasInflightIncorporateForTopic reports whether a queued/running
// incorporate job exists for topicID.
func (s *Store) HasInflightIncorporateForTopic(topicID string) (bool, error) {
	var one int
	err := s.db.QueryRow(
		`SELECT 1 FROM agent_jobs
		  WHERE kind = 'incorporate'
		    AND topic_id = ?
		    AND status IN ('queued','running')
		  LIMIT 1`, topicID,
	).Scan(&one)
	if err == sql.ErrNoRows {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return true, nil
}

// HasInflightPerspectiveForSourcePersona reports whether a queued/running
// perspective job exists for the given (source_path, persona_name).
func (s *Store) HasInflightPerspectiveForSourcePersona(sourcePath, persona string) (bool, error) {
	var one int
	err := s.db.QueryRow(
		`SELECT 1 FROM agent_jobs
		  WHERE kind = 'perspective'
		    AND source_path = ?
		    AND persona_name = ?
		    AND status IN ('queued','running')
		  LIMIT 1`, sourcePath, persona,
	).Scan(&one)
	if err == sql.ErrNoRows {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return true, nil
}

Delete the old HasInflightForSource. grep for callers — there's at least one in internal/agent/service.go — and update each.

Rewrite the inflight section of Submit (currently lines ~106–145) to use a kind-keyed map and the new helpers:

	key := inflightKey(in)

	s.mu.Lock()
	if _, ok := s.inflight[key]; ok {
		s.mu.Unlock()
		return "", ErrInflight
	}
	jobID := uuid.NewString()
	s.inflight[key] = struct{}{}
	s.mu.Unlock()

	if inflight, err := s.checkPersistedInflight(in); err != nil {
		s.releaseInflight(key)
		return "", fmt.Errorf("agent.Service: persisted inflight check: %w", err)
	} else if inflight {
		s.releaseInflight(key)
		return "", ErrInflight
	}

	// ... existing InsertJob block unchanged. In its error branch, replace
	// the HasInflightForSource() call with checkPersistedInflight(in), and
	// the delete(s.inflight, in.SourcePath) call with releaseInflight(key).

Helpers (add at the bottom of service.go):

func inflightKey(in SubmitInput) string {
	switch in.Kind {
	case "incorporate":
		return "incorporate:" + in.TopicID
	case "perspective":
		return "perspective:" + in.SourcePath + ":" + in.PersonaName
	}
	return in.Kind + ":" + in.SourcePath
}

func (s *Service) checkPersistedInflight(in SubmitInput) (bool, error) {
	switch in.Kind {
	case "incorporate":
		return s.cfg.Store.HasInflightIncorporateForTopic(in.TopicID)
	case "perspective":
		return s.cfg.Store.HasInflightPerspectiveForSourcePersona(in.SourcePath, in.PersonaName)
	}
	return false, nil
}

func (s *Service) releaseInflight(key string) {
	s.mu.Lock()
	delete(s.inflight, key)
	s.mu.Unlock()
}

Also update the post-run release path (wherever delete(s.inflight, in.SourcePath) is called when a job finishes) to use inflightKey(in).

The per-Source execution-serialisation queue (max_concurrent_jobs=1 per source) stays unchanged: the inflight gate prevents same-Topic duplication; the execution queue prevents two Source rewrites racing each other.

go test ./internal/collab/... ./internal/agent/...

Expected: PASS. Existing service_test.go tests that asserted same-source rejection of different topics encoded the old behaviour — update or delete them.

git add internal/collab/migrations/005_inflight_keys.sql internal/collab/reader.go internal/agent/service.go internal/agent/service_test.go
git commit -m "wiki-browser: collab+agent — migration 005, per-topic inflight for incorporate jobs"

Task 2: collab.NewProposal.AgentJobID + InsertProposal writes it; extend Proposal struct + GetProposal

The proposal type Proposal and the GetProposal reader (both in mutators.go) don't currently carry agent_job_id. Several later tasks read it back (Tasks 10, 14, 17), so this task also lifts the field into the Go type and adds a job-keyed lookup the invariant check uses.

Files:

In internal/collab/mutators_test.go, append:

func TestInsertProposal_WritesAgentJobID(t *testing.T) {
	store, _ := setupStoreWithTopic(t) // existing helper; if not, see neighbours
	jobID := "job-1"
	mustExec(t, store, `INSERT INTO agent_jobs(id, kind, source_path, topic_id, status, created_at)
		VALUES (?, 'incorporate', 'docs/x.md', ?, 'running', unixepoch())`,
		jobID, "t1") // replace with the topic ID setupStoreWithTopic created

	_, err := store.InsertProposal(NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "hello", BaseSourceSHA: "abcd",
		ProposedBy:  nil,
		AgentJobID: &jobID,
	})
	if err != nil {
		t.Fatalf("InsertProposal: %v", err)
	}

	var got sql.NullString
	if err := store.RawDB().QueryRow(
		`SELECT agent_job_id FROM incorporation_proposals WHERE id = 'p1'`,
	).Scan(&got); err != nil {
		t.Fatalf("scan: %v", err)
	}
	if !got.Valid || got.String != jobID {
		t.Fatalf("agent_job_id: got %#v, want %q", got, jobID)
	}
}

If setupStoreWithTopic doesn't exist with that exact name, adapt to whatever the file uses to seed a Topic.

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

Expected: FAIL — NewProposal has no AgentJobID field.

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

type NewProposal struct {
	ID             string
	TopicID        string
	RevisionNumber int
	ProposedSource string
	BaseSourceSHA  string
	ProposedBy     *string // nil = Agent-authored
	AgentJobID     *string // set by wb-agent insert-proposal; nil for legacy/manual rows
}

And 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, agent_job_id, created_at
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())`,
			p.ID, p.TopicID, p.RevisionNumber, p.ProposedSource,
			p.BaseSourceSHA, p.ProposedBy, p.AgentJobID,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return p.ID, nil
}

Also extend the read shape:

type Proposal struct {
	ID             string
	TopicID        string
	RevisionNumber int
	ProposedSource string
	BaseSourceSHA  string
	AgentJobID     sql.NullString // nil for legacy / non-Agent rows
	CreatedAt      int64
}

func (s *Store) GetProposal(id string) (Proposal, error) {
	var p Proposal
	err := s.db.QueryRow(
		`SELECT id, topic_id, revision_number, proposed_source,
		        base_source_sha, agent_job_id, created_at
		   FROM incorporation_proposals WHERE id = ?`,
		id,
	).Scan(&p.ID, &p.TopicID, &p.RevisionNumber, &p.ProposedSource,
		&p.BaseSourceSHA, &p.AgentJobID, &p.CreatedAt)
	return p, err
}

If CreatedAt wasn't on the old struct, that's fine — adding it is needed by Task 14's list view.

In internal/collab/reader.go, add:

// GetProposalByJobID returns the proposal row inserted by an Agent job, or
// sql.ErrNoRows if none. Used by the post-job invariant check.
func (s *Store) GetProposalByJobID(jobID string) (Proposal, error) {
	var p Proposal
	err := s.db.QueryRow(
		`SELECT id, topic_id, revision_number, proposed_source,
		        base_source_sha, agent_job_id, created_at
		   FROM incorporation_proposals WHERE agent_job_id = ?`,
		jobID,
	).Scan(&p.ID, &p.TopicID, &p.RevisionNumber, &p.ProposedSource,
		&p.BaseSourceSHA, &p.AgentJobID, &p.CreatedAt)
	return p, err
}

(If the schema permits multiple Agent proposals to share one job — it shouldn't, since each job ships at most one proposal — change the query to ORDER BY revision_number DESC LIMIT 1 as a defence in depth.)

go test ./internal/collab/...

Expected: PASS.

git add internal/collab/mutators.go internal/collab/reader.go internal/collab/mutators_test.go
git commit -m "wiki-browser: collab — agent_job_id on Proposal struct, InsertProposal, GetProposal, GetProposalByJobID"

Task 3: collab.ProposalFreshness helper

The freshness helper is the single source of truth for whether a proposal is approvable. Used by both the proposals-list endpoint (advisory stale_reasons) and the approval endpoint (gating).

Files:

Create internal/collab/freshness_test.go:

package collab

import (
	"reflect"
	"sort"
	"testing"
)

func TestProposalFreshness_Fresh(t *testing.T) {
	store, repoRoot := setupStoreWithSourceFile(t, "docs/x.md", "hello world")
	jobID, topicID := "j1", "t1"
	seedJobTopicProposal(t, store, jobID, topicID, "docs/x.md",
		/*baseSHA=*/ blobSHA(t, "hello world"),
		/*proposed=*/ `<span data-orcha-anchor="t2">x</span>`,
		/*agentJobStatus=*/ "succeeded")
	mustInsertOpenTopic(t, store, "t2", "docs/x.md")

	res, err := ProposalFreshness(store, repoRoot, "p1")
	if err != nil {
		t.Fatalf("ProposalFreshness: %v", err)
	}
	if !res.Fresh {
		t.Fatalf("expected Fresh=true, got %+v", res)
	}
	if len(res.StaleReasons) != 0 {
		t.Fatalf("expected no stale_reasons, got %v", res.StaleReasons)
	}
}

func TestProposalFreshness_StaleBaseSourceSHA(t *testing.T) {
	store, repoRoot := setupStoreWithSourceFile(t, "docs/x.md", "DIFFERENT now")
	seedJobTopicProposal(t, store, "j1", "t1", "docs/x.md",
		/*baseSHA stored on proposal=*/ "deadbeef",
		"", "succeeded")

	res, err := ProposalFreshness(store, repoRoot, "p1")
	if err != nil {
		t.Fatalf("ProposalFreshness: %v", err)
	}
	if res.Fresh {
		t.Fatalf("expected Fresh=false")
	}
	if !containsString(res.StaleReasons, "source_sha") {
		t.Fatalf("expected source_sha reason, got %v", res.StaleReasons)
	}
}

func TestProposalFreshness_MissingTopicMarkers(t *testing.T) {
	store, repoRoot := setupStoreWithSourceFile(t, "docs/x.md", "hello")
	seedJobTopicProposal(t, store, "j1", "t1", "docs/x.md",
		blobSHA(t, "hello"),
		/*proposed = no marker for t2*/ "bare proposal body",
		"succeeded")
	mustInsertOpenTopic(t, store, "t2", "docs/x.md")

	res, err := ProposalFreshness(store, repoRoot, "p1")
	if err != nil {
		t.Fatalf("ProposalFreshness: %v", err)
	}
	if res.Fresh {
		t.Fatalf("expected Fresh=false")
	}
	if !containsString(res.StaleReasons, "missing_topic_markers") {
		t.Fatalf("expected missing_topic_markers, got %v", res.StaleReasons)
	}
	sort.Strings(res.MissingTopicIDs)
	if !reflect.DeepEqual(res.MissingTopicIDs, []string{"t2"}) {
		t.Fatalf("MissingTopicIDs: got %v, want [t2]", res.MissingTopicIDs)
	}
}

func TestProposalFreshness_FailedJobIsNotApprovable(t *testing.T) {
	store, repoRoot := setupStoreWithSourceFile(t, "docs/x.md", "hi")
	seedJobTopicProposal(t, store, "j1", "t1", "docs/x.md",
		blobSHA(t, "hi"), "", "failed")

	res, err := ProposalFreshness(store, repoRoot, "p1")
	if err != nil {
		t.Fatalf("ProposalFreshness: %v", err)
	}
	if res.Fresh {
		t.Fatalf("expected Fresh=false for failed linked job")
	}
}

// Helpers — implement next to the existing test helpers in collab. If
// setupStoreWithSourceFile / seedJobTopicProposal / mustInsertOpenTopic /
// blobSHA / containsString don't exist, add them in this file or a shared
// _test_helpers.go.

The helpers (setupStoreWithSourceFile, seedJobTopicProposal, etc.) should be added next to existing test helpers. blobSHA should use SourceSHAOfBytes (already in hashes.go) so the test matches what the code under test will compute.

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

Expected: FAIL — ProposalFreshness undefined.

Create internal/collab/freshness.go:

package collab

import (
	"database/sql"
	"fmt"
	"strings"
)

// Freshness is the result of checking whether a proposal is approvable.
// Fresh = true iff (linked Agent job is succeeded OR there is no linked job)
// AND base_source_sha matches the current Source SHA AND every current open
// non-global Topic on the Source except the proposal's own Topic has at
// least one data-orcha-anchor marker in proposed_source.
type Freshness struct {
	Fresh           bool
	StaleReasons    []string // subset of {"source_sha", "missing_topic_markers"}
	MissingTopicIDs []string // sorted; populated when StaleReasons contains "missing_topic_markers"
	JobStatus       string   // empty if there is no linked job
}

// ProposalFreshness inspects the proposal at proposalID and returns its
// approvability against the current state. Caller passes the repo root so
// the helper can hash the current Source on disk.
func ProposalFreshness(s *Store, repoRoot, proposalID string) (Freshness, error) {
	var (
		topicID, sourcePath, baseSourceSHA, proposed string
		agentJobID                                   sql.NullString
	)
	err := s.db.QueryRow(
		`SELECT p.topic_id, t.source_path, p.base_source_sha,
		        p.proposed_source, p.agent_job_id
		   FROM incorporation_proposals p
		   JOIN topics t ON t.id = p.topic_id
		  WHERE p.id = ?`, proposalID,
	).Scan(&topicID, &sourcePath, &baseSourceSHA, &proposed, &agentJobID)
	if err != nil {
		return Freshness{}, fmt.Errorf("load proposal: %w", err)
	}

	out := Freshness{Fresh: true}

	if agentJobID.Valid {
		var status string
		if err := s.db.QueryRow(
			`SELECT status FROM agent_jobs WHERE id = ?`, agentJobID.String,
		).Scan(&status); err != nil {
			return Freshness{}, fmt.Errorf("load agent_job: %w", err)
		}
		out.JobStatus = status
		if status != "succeeded" {
			out.Fresh = false
			// Not in stale_reasons — distinct concept (job didn't succeed
			// at all vs. proposal's bytes are out of date). UI surfaces via
			// JobStatus separately.
		}
	}

	currentSHA, err := SourceSHA(repoRoot, sourcePath)
	if err != nil {
		return Freshness{}, fmt.Errorf("hash current source: %w", err)
	}
	if currentSHA != baseSourceSHA {
		out.Fresh = false
		out.StaleReasons = append(out.StaleReasons, "source_sha")
	}

	openIDs, err := openNonGlobalTopicIDsExcluding(s, sourcePath, topicID)
	if err != nil {
		return Freshness{}, err
	}
	var missing []string
	for _, id := range openIDs {
		marker := `data-orcha-anchor="` + id + `"`
		if !strings.Contains(proposed, marker) {
			missing = append(missing, id)
		}
	}
	if len(missing) > 0 {
		out.Fresh = false
		out.StaleReasons = append(out.StaleReasons, "missing_topic_markers")
		out.MissingTopicIDs = missing
	}

	return out, nil
}

// openNonGlobalTopicIDsExcluding lists the IDs of open (not incorporated,
// not discarded) non-global Topics on sourcePath, minus excludeID. Sorted
// ascending for determinism.
func openNonGlobalTopicIDsExcluding(s *Store, sourcePath, excludeID string) ([]string, error) {
	rows, err := s.db.Query(
		`SELECT id FROM topics
		  WHERE source_path = ?
		    AND incorporated_at IS NULL
		    AND discarded_at IS NULL
		    AND id != ?
		    AND json_extract(anchor, '$.kind') != 'global'
		  ORDER BY id`, sourcePath, excludeID,
	)
	if err != nil {
		return nil, fmt.Errorf("list open topics: %w", err)
	}
	defer rows.Close()
	var ids []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		ids = append(ids, id)
	}
	return ids, rows.Err()
}
go test ./internal/collab/ -run TestProposalFreshness -v

Expected: PASS.

git add internal/collab/freshness.go internal/collab/freshness_test.go
git commit -m "wiki-browser: collab — ProposalFreshness helper for stale_reasons"

Task 4: collab.CompleteIncorporation re-anchors other open Topics in-tx

Files:

In internal/collab/mutators_test.go, append:

func TestCompleteIncorporation_ReanchorsOtherOpenTopics(t *testing.T) {
	store, _ := setupStoreWithSourceFile(t, "docs/x.md", "hi")
	mustExec(t, store, `INSERT INTO users(id, display_name, created_at)
		VALUES ('u1','u1',unixepoch())`)
	// Incorporated Topic.
	mustExec(t, store, `INSERT INTO topics(id, source_path, anchor, created_by, created_at, updated_at)
		VALUES ('t1','docs/x.md','{"kind":"global"}','u1',unixepoch(),unixepoch())`)
	// Two other open Topics on the same source, currently in pre-marker state.
	pre := `{"kind":"pre-marker","source_sha":"stale","start":0,"end":1,"quote":"h"}`
	mustExec(t, store, `INSERT INTO topics(id, source_path, anchor, created_by, created_at, updated_at)
		VALUES ('t2','docs/x.md',?,'u1',unixepoch(),unixepoch())`, pre)
	mustExec(t, store, `INSERT INTO topics(id, source_path, anchor, created_by, created_at, updated_at)
		VALUES ('t3','docs/x.md',?,'u1',unixepoch(),unixepoch())`, pre)
	// One discarded Topic — must NOT be reanchored.
	mustExec(t, store, `INSERT INTO topics(id, source_path, anchor, created_by, discarded_by, discarded_at, created_at, updated_at)
		VALUES ('t4','docs/x.md',?,'u1','u1',unixepoch(),unixepoch(),unixepoch())`, pre)

	// Seed a proposal + attempt the incorporation transaction normally seeds.
	mustExec(t, store, `INSERT INTO incorporation_proposals(
		id, topic_id, revision_number, proposed_source, base_source_sha,
		proposed_by, created_at) VALUES ('p1','t1',1,'x','sha',NULL,unixepoch())`)
	mustExec(t, store, `INSERT INTO incorporation_attempts(
		id, proposal_id, topic_id, source_path, base_source_sha,
		approved_by, approved_at, created_at)
		VALUES ('a1','p1','t1','docs/x.md','sha','u1',unixepoch(),unixepoch())`)

	if err := store.CompleteIncorporation(CompleteIncorporationInput{
		TopicID:           "t1",
		ProposalID:        "p1",
		AttemptID:         "a1",
		CommitSHA:         "deadbeef",
		IncorporatedBy:    "u1",
		IncorporatedAt:    1700000000,
		ReanchorTopicIDs:  []string{"t2", "t3", "t4"}, // t4 must be filtered out
	}); err != nil {
		t.Fatalf("CompleteIncorporation: %v", err)
	}

	// t2 and t3 flip to marker; t4 stays pre-marker.
	assertAnchorKind(t, store, "t2", "marker")
	assertAnchorKind(t, store, "t3", "marker")
	assertAnchorKind(t, store, "t4", "pre-marker")
}

func assertAnchorKind(t *testing.T, s *Store, id, want string) {
	t.Helper()
	var anchor string
	if err := s.RawDB().QueryRow(
		`SELECT anchor FROM topics WHERE id = ?`, id,
	).Scan(&anchor); err != nil {
		t.Fatal(err)
	}
	a, err := UnmarshalAnchor(json.RawMessage(anchor))
	if err != nil {
		t.Fatalf("unmarshal %s: %v", id, err)
	}
	var kind string
	switch a.(type) {
	case MarkerAnchor:
		kind = "marker"
	case PreMarkerAnchor:
		kind = "pre-marker"
	case GlobalAnchor:
		kind = "global"
	}
	if kind != want {
		t.Fatalf("%s anchor kind = %q, want %q", id, kind, want)
	}
}

Adjust imports (encoding/json) at the top of the test file.

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

Expected: FAIL — ReanchorTopicIDs undefined.

In internal/collab/mutators.go, extend CompleteIncorporationInput:

type CompleteIncorporationInput struct {
	TopicID          string
	ProposalID       string
	AttemptID        string
	CommitSHA        string
	IncorporatedBy   string
	IncorporatedAt   int64 // unix seconds; usually time.Now().Unix()
	ReanchorTopicIDs []string
}

In CompleteIncorporation, after the existing UPDATE incorporation_attempts … and before return tx.Commit(), add:

	if len(in.ReanchorTopicIDs) > 0 {
		// Build placeholders + args. Filter is by id IN (...), and the
		// WHERE clause additionally guards against any concurrent terminal
		// transitions (Discard or another Incorporation) and against
		// re-running the update against rows already at marker kind.
		placeholders := strings.TrimSuffix(strings.Repeat("?,", len(in.ReanchorTopicIDs)), ",")
		args := make([]any, 0, len(in.ReanchorTopicIDs))
		for _, id := range in.ReanchorTopicIDs {
			args = append(args, id)
		}
		query := `UPDATE topics
		             SET anchor = '{"kind":"marker"}',
		                 updated_at = unixepoch()
		           WHERE id IN (` + placeholders + `)
		             AND discarded_at IS NULL
		             AND incorporated_at IS NULL
		             AND json_extract(anchor, '$.kind') = 'pre-marker'`
		if _, err := tx.Exec(query, args...); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("reanchor topics: %w", err)
		}
	}

Add "strings" to the imports if not present.

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

Expected: PASS, plus any pre-existing tests on CompleteIncorporation should still pass.

git add internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "wiki-browser: collab — CompleteIncorporation reanchors other open Topics in-tx"

Task 5: Thread ReanchorTopicIDs through collab.Incorporate

Files:

In internal/collab/incorporate_test.go, append a test that runs Incorporate end-to-end with one other open Topic on the same Source and asserts it flips to marker. Pattern:

func TestIncorporate_ThreadsReanchorTopicIDs(t *testing.T) {
	// setup: a git repo at repoRoot with docs/x.md containing the base bytes,
	// a topic t1 to incorporate, a proposal p1 whose proposed_source replaces
	// the file content, and another open topic t2 with pre-marker anchor.
	// Reuse whatever helpers existing Incorporate tests use.

	got, err := Incorporate(store, IncorporateInput{
		RepoRoot:          repoRoot,
		ProposalID:        "p1",
		ApproverID:        "u1",
		ApproverName:      "U One",
		Subject:           "test",
		AuthorName:        "Orcha Agent",
		AuthorEmail:       "agent@orcha.local",
		ReanchorTopicIDs: []string{"t2"},
	})
	if err != nil {
		t.Fatalf("Incorporate: %v", err)
	}
	_ = got
	assertAnchorKind(t, store, "t2", "marker")
}
go test ./internal/collab/ -run TestIncorporate_ThreadsReanchorTopicIDs -v

Expected: FAIL — ReanchorTopicIDs undefined on IncorporateInput.

In internal/collab/incorporate.go, modify the struct:

type IncorporateInput struct {
	RepoRoot         string
	ProposalID       string
	ApproverID       string // user id
	ApproverName     string // display name
	Subject          string // commit subject line
	Body             string // optional commit body
	AuthorName       string // git author/committer name (Agent identity)
	AuthorEmail      string // git author/committer email
	ReanchorTopicIDs []string
}

And update the call to CompleteIncorporation to pass it through:

	if err := s.CompleteIncorporation(CompleteIncorporationInput{
		TopicID:          prop.TopicID,
		ProposalID:       prop.ID,
		AttemptID:        attemptID,
		CommitSHA:        sha,
		IncorporatedBy:   in.ApproverID,
		IncorporatedAt:   time.Now().Unix(),
		ReanchorTopicIDs: in.ReanchorTopicIDs,
	}); err != nil {
go test ./internal/collab/...

Expected: PASS.

git add internal/collab/incorporate.go internal/collab/incorporate_test.go
git commit -m "wiki-browser: collab — Incorporate threads ReanchorTopicIDs"

Phase 2 — wb-agent CLI simplifications

Task 6: wb-agent get-topic switches to --job-id, returns absolute path

Files:

In cmd/wb-agent/main_test.go, add or update a TestGetTopic_ByJobID that exercises the new shape:

func TestGetTopic_ByJobID(t *testing.T) {
	store, repoRoot, cfgPath := setupStoreWithJob(t, "j1", "t1", "docs/x.md", "hello")
	_ = store

	var stdout, stderr bytes.Buffer
	code := runGetTopic([]string{"--config=" + cfgPath, "--job-id=j1"},
		&stdout, &stderr, openTestStore(t))
	if code != 0 {
		t.Fatalf("exit %d, stderr=%q", code, stderr.String())
	}

	var got struct {
		ID            string `json:"id"`
		SourcePath    string `json:"source_path"`
		BaseSourceSHA string `json:"base_source_sha"`
		// no repo_root
		Messages []struct {
			Kind           string  `json:"kind"`
			ProposalID     *string `json:"proposal_id,omitempty"`
			ProposedSource *string `json:"proposed_source,omitempty"`
		} `json:"messages"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if got.ID != "t1" {
		t.Fatalf("ID = %q, want t1", got.ID)
	}
	if got.SourcePath != filepath.Join(repoRoot, "docs/x.md") {
		t.Fatalf("SourcePath = %q, want absolute %q", got.SourcePath,
			filepath.Join(repoRoot, "docs/x.md"))
	}
	if got.BaseSourceSHA == "" {
		t.Fatalf("BaseSourceSHA empty")
	}
	// If setupStoreWithJob seeds an earlier agent-proposal message, it
	// must inline proposed_source so rework runs can see prior attempts.
	for _, m := range got.Messages {
		if m.Kind == "agent-proposal" && (m.ProposedSource == nil || *m.ProposedSource == "") {
			t.Fatalf("agent-proposal missing proposed_source: %+v", m)
		}
	}
}

Add helper setupStoreWithJob that writes the Source file inside the repo root, inserts a topic + agent_jobs row, and returns repo root + config path. Seed one prior proposal + agent-proposal message in the fixture so the test proves proposed_source is inlined.

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

Expected: FAIL.

Replace the file's body with:

package main

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

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

type getTopicOutput struct {
	ID            string            `json:"id"`
	SourcePath    string            `json:"source_path"` // absolute
	BaseSourceSHA string            `json:"base_source_sha"`
	Anchor        json.RawMessage   `json:"anchor"`
	CreatedBy     string            `json:"created_by"`
	CreatedAt     int64             `json:"created_at"`
	Messages      []getTopicMessage `json:"messages"`
}

type getTopicMessage struct {
	ID             string  `json:"id"`
	TopicID        string  `json:"topic_id"`
	Kind           string  `json:"kind"`
	Body           string  `json:"body"`
	AuthorUserID   *string `json:"author_user_id,omitempty"`
	ProposalID     *string `json:"proposal_id,omitempty"`
	ProposedSource *string `json:"proposed_source,omitempty"`
	Sequence       int     `json:"sequence"`
	CreatedAt      int64   `json:"created_at"`
}

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")
	jobID := fs.String("job-id", "", "agent_jobs.id")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *jobID == "" {
		fmt.Fprintln(stderr, "get-topic: --job-id is required")
		return 2
	}

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

	var (
		topicID, sourcePath string
	)
	if err := store.RawDB().QueryRow(
		`SELECT topic_id, source_path FROM agent_jobs WHERE id = ?`, *jobID,
	).Scan(&topicID, &sourcePath); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			fmt.Fprintf(stderr, "agent job %s not found\n", *jobID)
			return 1
		}
		fmt.Fprintf(stderr, "scan job: %v\n", err)
		return 1
	}
	if topicID == "" {
		fmt.Fprintf(stderr, "agent job %s has no topic_id\n", *jobID)
		return 1
	}

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

	sha, err := collab.SourceSHA(cfg.Root, sourcePath)
	if err != nil {
		fmt.Fprintf(stderr, "source sha: %v\n", err)
		return 1
	}
	out.BaseSourceSHA = sha

	msgs, err := store.ListMessages(topicID)
	if err != nil {
		fmt.Fprintf(stderr, "list messages: %v\n", err)
		return 1
	}
	out.Messages = make([]getTopicMessage, 0, len(msgs))
	for _, m := range msgs {
		gm := getTopicMessage{
			ID: m.ID, TopicID: m.TopicID, Kind: m.Kind, Body: m.Body,
			AuthorUserID: m.AuthorUserID, ProposalID: m.ProposalID,
			Sequence: m.Sequence, CreatedAt: m.CreatedAt,
		}
		if m.Kind == "agent-proposal" && m.ProposalID != nil {
			var proposed string
			if err := store.RawDB().QueryRow(
				`SELECT proposed_source FROM incorporation_proposals WHERE id = ?`,
				*m.ProposalID,
			).Scan(&proposed); err != nil {
				fmt.Fprintf(stderr, "load proposal %s: %v\n", *m.ProposalID, err)
				return 1
			}
			gm.ProposedSource = &proposed
		}
		out.Messages = append(out.Messages, gm)
	}

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

Note the storeOpener signature change: it now returns (store, cfg, error) so subcommands know the repo root. Update storeOpener and its production / test implementations in cmd/wb-agent/main.go and the test helpers accordingly.

In cmd/wb-agent/main.go, change:

type storeOpener func(configPath string) (*collab.Store, *config.Config, error)

The production opener loads config.Config from the YAML and opens the store; tests can mock both.

The signature change cascades. Five subcommands take storeOpener today: runGetTopic (this task), runListOpenTopics, runInsertProposal, runGetPersona (in cmd/wb-agent/stubs.go), and runPutPerspective (also stubs.go). All five must accept the third return value or the package won't compile. The list-open-topics and insert-proposal logic changes land in Tasks 7 and 8; for now make a minimal mechanical edit in each:

store, _, err := opener(*configPath) // cfg unused here yet

Touch the stub files (stubs.go) the same way — they don't use cfg but they must accept the new return shape. Update the test helpers (the test storeOpener factory) to return the three-value shape.

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

Expected: PASS. Existing tests against the old --id flag will fail — delete them; this is the surface change.

git add cmd/wb-agent/get_topic.go cmd/wb-agent/list_open_topics.go cmd/wb-agent/insert_proposal.go cmd/wb-agent/main.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — get-topic keys on --job-id, returns absolute source_path"

Task 7: wb-agent list-open-topics adds --exclude-topic, validates absolute paths

Files:

In cmd/wb-agent/main_test.go, add:

func TestListOpenTopics_ExcludesSelf_ValidatesPath(t *testing.T) {
	store, repoRoot, cfgPath := setupStoreWithTwoOpenTopics(t, "docs/x.md", "t1", "t2")
	_ = store

	abs := filepath.Join(repoRoot, "docs/x.md")
	var stdout, stderr bytes.Buffer
	code := runListOpenTopics(
		[]string{"--config=" + cfgPath, "--source-path=" + abs, "--exclude-topic=t1"},
		&stdout, &stderr, openTestStore(t),
	)
	if code != 0 {
		t.Fatalf("exit %d, stderr=%q", code, stderr.String())
	}
	var got []struct{ ID string }
	if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || got[0].ID != "t2" {
		t.Fatalf("got %v, want [t2]", got)
	}
}

func TestListOpenTopics_RejectsEscapingPath(t *testing.T) {
	_, _, cfgPath := setupStoreWithTwoOpenTopics(t, "docs/x.md", "t1", "t2")
	var stdout, stderr bytes.Buffer
	code := runListOpenTopics(
		[]string{"--config=" + cfgPath, "--source-path=/etc/passwd", "--exclude-topic=t1"},
		&stdout, &stderr, openTestStore(t),
	)
	if code == 0 {
		t.Fatalf("expected non-zero exit, got 0")
	}
	if !strings.Contains(stderr.String(), "source path") {
		t.Fatalf("stderr = %q, want path validation error", stderr.String())
	}
}
go test ./cmd/wb-agent/ -run TestListOpenTopics -v

Expected: FAIL.

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"path/filepath"
	"strings"

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

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", "", "absolute path to the source file")
	excludeTopic := fs.String("exclude-topic", "", "topic id to exclude from the listing")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *sourcePath == "" {
		fmt.Fprintln(stderr, "list-open-topics: --source-path is required")
		return 2
	}

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

	// Normalise absolute path to repo-relative + validate.
	abs := filepath.Clean(*sourcePath)
	rel, err := filepath.Rel(cfg.Root, abs)
	if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
		fmt.Fprintf(stderr, "list-open-topics: source path escapes repo root: %q\n", abs)
		return 1
	}
	if _, err := collab.ValidateSourcePath(rel); err != nil {
		fmt.Fprintf(stderr, "list-open-topics: invalid source path %q: %v\n", rel, err)
		return 1
	}

	topics, err := store.ListOpenTopicsForSourceExcluding(rel, *excludeTopic)
	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 internal/collab/reader.go, add a sibling method to ListOpenTopicsForSource:

// ListOpenTopicsForSourceExcluding is like ListOpenTopicsForSource but skips
// the topic whose id equals excludeID (empty string disables the filter) and
// any global-anchor topics. Use this for re-anchor working sets where the
// caller needs every-other open Topic except the one currently being
// resolved.
func (s *Store) ListOpenTopicsForSourceExcluding(sourcePath, excludeID string) ([]TopicSummary, error) {
	if _, err := ValidateSourcePath(sourcePath); err != nil {
		return nil, fmt.Errorf("collab.ListOpenTopicsForSourceExcluding: %w", err)
	}
	rows, err := s.db.Query(
		`SELECT id, source_path, anchor, created_by, created_at, updated_at
		   FROM topics
		  WHERE source_path = ?
		    AND incorporated_at IS NULL
		    AND discarded_at IS NULL
		    AND id != COALESCE(?, '')
		    AND json_extract(anchor, '$.kind') != 'global'
		  ORDER BY created_at`, sourcePath, excludeID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	// Reuse the same scan logic as ListOpenTopicsForSource. Pull the helper
	// out if it's currently inlined.
	return scanTopicSummaries(rows)
}

If scanTopicSummaries doesn't exist, extract the scan loop from ListOpenTopicsForSource into a helper and reuse it from both methods.

go test ./cmd/wb-agent/... ./internal/collab/...

Expected: PASS.

git add cmd/wb-agent/list_open_topics.go cmd/wb-agent/main_test.go internal/collab/reader.go
git commit -m "wiki-browser: wb-agent — list-open-topics adds --exclude-topic, validates absolute path"

Task 8: wb-agent insert-proposal — slim flags, BEGIN IMMEDIATE, explanation, agent_job_id

Files:

In cmd/wb-agent/main_test.go, add:

func TestInsertProposal_JobID_Explanation_AgentMessage(t *testing.T) {
	store, repoRoot, cfgPath := setupStoreWithJob(t, "j1", "t1", "docs/x.md", "hi")
	_ = repoRoot

	body := bytes.NewBufferString(`<span data-orcha-anchor="t2">x</span>`)
	var stdout, stderr bytes.Buffer
	code := runInsertProposal(
		[]string{
			"--config=" + cfgPath,
			"--job-id=j1",
			"--explanation=I understood the discussion as foo and applied it as bar.",
		},
		&stdout, &stderr, body, openTestStore(t),
	)
	if code != 0 {
		t.Fatalf("exit %d, stderr=%q", code, stderr.String())
	}

	var out struct {
		ID             string `json:"id"`
		RevisionNumber int    `json:"revision_number"`
		MessageID      string `json:"message_id"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	if out.ID == "" || out.MessageID == "" || out.RevisionNumber != 1 {
		t.Fatalf("unexpected output: %+v", out)
	}

	// Verify proposal row.
	var (
		jobID sql.NullString
		bodyGot, kind string
		propID sql.NullString
	)
	if err := store.RawDB().QueryRow(
		`SELECT agent_job_id FROM incorporation_proposals WHERE id = ?`, out.ID,
	).Scan(&jobID); err != nil {
		t.Fatal(err)
	}
	if !jobID.Valid || jobID.String != "j1" {
		t.Fatalf("agent_job_id: %v", jobID)
	}

	// Verify accompanying agent-proposal message.
	if err := store.RawDB().QueryRow(
		`SELECT kind, body, proposal_id FROM topic_messages WHERE id = ?`,
		out.MessageID,
	).Scan(&kind, &bodyGot, &propID); err != nil {
		t.Fatal(err)
	}
	if kind != "agent-proposal" {
		t.Fatalf("message kind = %q, want agent-proposal", kind)
	}
	if !propID.Valid || propID.String != out.ID {
		t.Fatalf("message proposal_id: %v", propID)
	}
	if !strings.Contains(bodyGot, "I understood") {
		t.Fatalf("body: %q", bodyGot)
	}
}

func TestInsertProposal_RejectsEmptyExplanation(t *testing.T) {
	_, _, cfgPath := setupStoreWithJob(t, "j1", "t1", "docs/x.md", "hi")

	body := bytes.NewBufferString("anything")
	var stdout, stderr bytes.Buffer
	code := runInsertProposal(
		[]string{"--config=" + cfgPath, "--job-id=j1", "--explanation="},
		&stdout, &stderr, body, openTestStore(t),
	)
	if code == 0 {
		t.Fatalf("expected non-zero exit on empty explanation")
	}
}
go test ./cmd/wb-agent/ -run TestInsertProposal -v

Expected: FAIL.

package main

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

	"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")
	jobID := fs.String("job-id", "", "agent_jobs.id")
	explanation := fs.String("explanation", "",
		"natural-language explanation of the rewrite; stored as the agent-proposal message body")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *jobID == "" {
		fmt.Fprintln(stderr, "insert-proposal: --job-id is required")
		return 2
	}
	if strings.TrimSpace(*explanation) == "" {
		fmt.Fprintln(stderr, "insert-proposal: --explanation must be non-empty")
		return 2
	}

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

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

	// Look up topic + source_path through the job.
	var topicID, sourcePath string
	if err := store.RawDB().QueryRow(
		`SELECT topic_id, source_path FROM agent_jobs WHERE id = ?`, *jobID,
	).Scan(&topicID, &sourcePath); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			fmt.Fprintf(stderr, "agent job %s not found\n", *jobID)
			return 1
		}
		fmt.Fprintf(stderr, "scan job: %v\n", err)
		return 1
	}
	if topicID == "" {
		fmt.Fprintln(stderr, "insert-proposal: job has no topic_id")
		return 1
	}

	// Hash the current Source at the moment the proposal is recorded.
	baseSHA, err := collab.SourceSHA(cfg.Root, sourcePath)
	if err != nil {
		fmt.Fprintf(stderr, "hash source: %v\n", err)
		return 1
	}

	proposalID := uuid.NewString()
	messageID := uuid.NewString()

	// Single transaction, BEGIN IMMEDIATE so the cross-process write lock
	// acquires up-front. Do this on a dedicated raw connection; calling
	// db.Begin() first would already start a DEFERRED transaction and make
	// BEGIN IMMEDIATE fail as a nested transaction.
	db := store.RawDB()
	ctx := context.Background()
	conn, err := db.Conn(ctx)
	if err != nil {
		fmt.Fprintf(stderr, "conn: %v\n", err)
		return 1
	}
	defer conn.Close()
	rollback := func() { _, _ = conn.ExecContext(ctx, `ROLLBACK`) }

	if _, err := conn.ExecContext(ctx, `BEGIN IMMEDIATE`); err != nil {
		fmt.Fprintf(stderr, "begin immediate: %v\n", err)
		return 1
	}

	// Allocate revision_number.
	var nextRev int
	if err := conn.QueryRowContext(ctx,
		`SELECT COALESCE(MAX(revision_number), 0) + 1
		   FROM incorporation_proposals WHERE topic_id = ?`, topicID,
	).Scan(&nextRev); err != nil {
		rollback()
		fmt.Fprintf(stderr, "next revision: %v\n", err)
		return 1
	}

	// Insert proposal row.
	if _, err := conn.ExecContext(ctx,
		`INSERT INTO incorporation_proposals(
		   id, topic_id, revision_number, proposed_source,
		   base_source_sha, proposed_by, agent_job_id, created_at
		 ) VALUES (?, ?, ?, ?, ?, NULL, ?, unixepoch())`,
		proposalID, topicID, nextRev, string(source), baseSHA, *jobID,
	); err != nil {
		rollback()
		fmt.Fprintf(stderr, "insert proposal: %v\n", err)
		return 1
	}

	// Allocate sequence and insert agent-proposal message.
	var nextSeq int
	if err := conn.QueryRowContext(ctx,
		`SELECT COALESCE(MAX(sequence), 0) + 1
		   FROM topic_messages WHERE topic_id = ?`, topicID,
	).Scan(&nextSeq); err != nil {
		rollback()
		fmt.Fprintf(stderr, "next sequence: %v\n", err)
		return 1
	}
	if _, err := conn.ExecContext(ctx,
		`INSERT INTO topic_messages(
		   id, topic_id, kind, body, author_user_id, proposal_id, sequence, created_at
		 ) VALUES (?, ?, 'agent-proposal', ?, NULL, ?, ?, unixepoch())`,
		messageID, topicID, *explanation, proposalID, nextSeq,
	); err != nil {
		rollback()
		fmt.Fprintf(stderr, "insert message: %v\n", err)
		return 1
	}

	if _, err := conn.ExecContext(ctx, `COMMIT`); err != nil {
		rollback()
		fmt.Fprintf(stderr, "commit: %v\n", err)
		return 1
	}

	_ = json.NewEncoder(stdout).Encode(struct {
		ID             string `json:"id"`
		RevisionNumber int    `json:"revision_number"`
		MessageID      string `json:"message_id"`
	}{ID: proposalID, RevisionNumber: nextRev, MessageID: messageID})
	return 0
}

Either run the test directly (it exercises concurrent contention indirectly by going through SQLite normally) or run a stress test:

go test ./cmd/wb-agent/ -run TestInsertProposal -count=20

Expected: PASS consistently.

git add cmd/wb-agent/insert_proposal.go cmd/wb-agent/main_test.go
git commit -m "wiki-browser: wb-agent — insert-proposal keys on --job-id, adds --explanation, writes agent-proposal msg"

Phase 3 — Agent runtime updates

Task 9: Slim agent.Job and rewrite buildPrompt in user-task voice

Files:

In internal/agent/claude_cli_runner_test.go, replace whatever the current prompt assertion does with:

func TestBuildPrompt_Incorporate_UserTaskVoice(t *testing.T) {
	j := Job{
		ID:          "job-123",
		Kind:        "incorporate",
		ConfigPath:  "/etc/wb/wiki-browser.yaml",
		WBAgentPath: "/opt/wb/dist/wb-agent",
	}
	got := buildPrompt(j)

	wantSubstrings := []string{
		"Please help me incorporate",     // user-task voice
		"Use the wb-incorporate skill",
		"Job ID:        job-123",
		"Config path:   /etc/wb/wiki-browser.yaml",
		"wb-agent path: /opt/wb/dist/wb-agent",
	}
	for _, s := range wantSubstrings {
		if !strings.Contains(got, s) {
			t.Errorf("prompt missing %q\n---prompt---\n%s", s, got)
		}
	}
	// Forbidden substrings — these belonged to the old shape.
	forbidden := []string{"Topic ID:", "Base source SHA:", "Source path:", "Repo root:", "harness"}
	for _, s := range forbidden {
		if strings.Contains(got, s) {
			t.Errorf("prompt should not contain %q\n---prompt---\n%s", s, got)
		}
	}
}
go test ./internal/agent/ -run TestBuildPrompt -v

Expected: FAIL.

In internal/agent/runner.go:

type Job struct {
	ID          string
	Kind        string // "incorporate" | "perspective"
	SourcePath  string // queue key
	PersonaName string // populated when Kind == "perspective"
	SourceSHA   string // populated when Kind == "perspective"
	PersonaSHA  string // populated when Kind == "perspective"
	ConfigPath  string // absolute path to wiki-browser config
	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
}

Drop TopicID, BaseSHA, RepoRoot. Anywhere they were populated in internal/agent/service.go (most likely an Enqueue helper), remove the assignments — the skill discovers them via wb-agent.

Also update SubmitInput and validation in internal/agent/service.go: incorporate jobs still require TopicID and SourcePath so the queue and agent_jobs row can be created, but they no longer require BaseSHA. Remove the BaseSHA field from SubmitInput, remove it from ErrMissingIncorporateFields, and stop passing it into Job. The Agent records the proposal's base SHA at wb-agent insert-proposal time.

Update internal/server/agent_jobs.go at the same time: the create-agent-job request must stop requiring base_sha for incorporate jobs and must not pass BaseSHA into agent.SubmitInput. Keeping base_sha as an ignored optional JSON field is acceptable for backward compatibility, but no validation, prompt generation, or job construction may depend on it.

In internal/agent/claude_cli_runner.go:

func buildPrompt(j Job) string {
	var b strings.Builder
	switch j.Kind {
	case "incorporate":
		fmt.Fprintln(&b, "Please help me incorporate a Topic discussion into a shared document.")
		fmt.Fprintln(&b, "Use the wb-incorporate skill to load the conversation, produce a rewrite,")
		fmt.Fprintln(&b, "and persist it as a proposal for review.")
		fmt.Fprintln(&b)
		fmt.Fprintf(&b, "Job ID:        %s\n", j.ID)
		fmt.Fprintf(&b, "Config path:   %s\n", j.ConfigPath)
		fmt.Fprintf(&b, "wb-agent path: %s\n", j.WBAgentPath)
	case "perspective":
		fmt.Fprintln(&b, "Please help me refresh a Perspective rendering of a document.")
		fmt.Fprintln(&b, "Use the wb-perspective skill.")
		fmt.Fprintln(&b)
		fmt.Fprintf(&b, "Job ID:        %s\n", j.ID)
		fmt.Fprintf(&b, "Config path:   %s\n", j.ConfigPath)
		fmt.Fprintf(&b, "wb-agent path: %s\n", j.WBAgentPath)
	}
	return b.String()
}
go test ./internal/agent/...

Several fixtures will fail because they reference removed fields or assert old prompt content. Walk through each failure and either delete the assertion (if it was testing the dropped surface) or update it to the new shape. Resist the urge to keep "backwards compat" fields — the design point is to remove them.

git add internal/agent/
git commit -m "wiki-browser: agent — slim Job + rewrite buildPrompt in user-task voice"

Task 10: Post-job invariants for incorporate jobs

Files:

In internal/agent/service_test.go, add:

func TestService_PostExit_FailsOnMissingMarker(t *testing.T) {
	// Setup: a Topic t1 to incorporate; another open Topic t2 on the same
	// Source. Proposal inserted by FakeRunner lacks t2's marker.
	svc, store, jobID := startServiceWithFakeProposer(t,
		/*proposal source=*/ "bare content with no markers",
		/*explanation=*/ "did my best")

	_ = svc.WaitFor(jobID)

	var status, errTail string
	if err := store.RawDB().QueryRow(
		`SELECT status, error_tail FROM agent_jobs WHERE id = ?`, jobID,
	).Scan(&status, &errTail); err != nil {
		t.Fatal(err)
	}
	if status != "failed" {
		t.Fatalf("status = %q, want failed", status)
	}
	if !strings.Contains(errTail, "anchor invariant") {
		t.Fatalf("error_tail = %q, want anchor-invariant message", errTail)
	}
}

func TestService_PostExit_FailsOnEmptyExplanation(t *testing.T) {
	svc, store, jobID := startServiceWithFakeProposer(t,
		`<span data-orcha-anchor="t2">x</span>`,
		/*explanation=*/ "  ") // whitespace only

	_ = svc.WaitFor(jobID)

	var status, errTail string
	store.RawDB().QueryRow(
		`SELECT status, error_tail FROM agent_jobs WHERE id = ?`, jobID,
	).Scan(&status, &errTail)
	if status != "failed" || !strings.Contains(errTail, "explanation invariant") {
		t.Fatalf("status=%q tail=%q", status, errTail)
	}
}

func TestService_PostExit_FailsOnLeakedIncorporatedMarker(t *testing.T) {
	svc, store, jobID := startServiceWithFakeProposer(t,
		`<span data-orcha-anchor="t1">x</span>`, // t1 is the incorporated Topic
		"explanation")
	_ = svc.WaitFor(jobID)

	var status, errTail string
	store.RawDB().QueryRow(
		`SELECT status, error_tail FROM agent_jobs WHERE id = ?`, jobID,
	).Scan(&status, &errTail)
	if status != "failed" || !strings.Contains(errTail, "leaked") {
		t.Fatalf("status=%q tail=%q", status, errTail)
	}
}

startServiceWithFakeProposer is a test helper that: stands up a Service with a FakeRunner whose behaviour is "insert a proposal row via collab.InsertProposal then exit 0," seeds a Topic t1 and a sibling Topic t2 on docs/x.md, kicks off an incorporate job, and returns the job id. If the helper doesn't exist, build it from internal/agent/e2e_test.go patterns.

go test ./internal/agent/ -run TestService_PostExit -v

Expected: FAIL.

internal/agent/service.go already has classify(res, in, jobID) (status, errTail string) that runs after Runner.Run and feeds CompleteJob. It calls assertProposalCreated for incorporate jobs today (the #3 baseline: "did a row appear since started_at?"). We replace that timestamp-based check with the explicit agent_job_id lookup and add the three #4 invariants on top, all inside classify. The integration point and return shape stay the same — (status, errTail) — so nothing further upstream changes.

Replace the existing assertProposalCreated-using branch in classify():

	if in.Kind == "incorporate" {
		if status, tail := s.checkIncorporateInvariants(in, jobID); status != "" {
			return status, tail
		}
	}
	return "succeeded", ""

And add checkIncorporateInvariants next to assertProposalCreated (which can be deleted — the new helper supersedes it):

// checkIncorporateInvariants verifies the four post-exit invariants for
// incorporate jobs, in order:
//   1. The job produced a proposal row linked via agent_job_id.
//   2. The accompanying agent-proposal message has a non-empty body.
//   3. Every other open non-global Topic on the Source has at least one
//      data-orcha-anchor="<that-id>" marker in proposed_source.
//   4. The incorporated Topic's own marker is absent from proposed_source.
//
// Returns ("", "") on success, or ("failed", "<descriptive tail>") on the
// first violation. Caller writes that pair into agent_jobs.
func (s *Service) checkIncorporateInvariants(in SubmitInput, jobID string) (status, tail string) {
	prop, err := s.cfg.Store.GetProposalByJobID(jobID)
	if errors.Is(err, sql.ErrNoRows) {
		return "failed", "agent exited 0 but produced no proposal"
	}
	if err != nil {
		return "failed", "agent exited 0; GetProposalByJobID failed: " + err.Error()
	}
	if !prop.AgentJobID.Valid || prop.AgentJobID.String != jobID {
		return "failed", fmt.Sprintf("provenance invariant: proposal %s has agent_job_id=%v, want %s",
			prop.ID, prop.AgentJobID, jobID)
	}

	expl, err := s.cfg.Store.GetAgentProposalMessage(prop.ID)
	if err != nil {
		return "failed", "load agent-proposal message: " + err.Error()
	}
	if strings.TrimSpace(expl.Body) == "" {
		return "failed", "explanation invariant: agent-proposal body is empty"
	}

	others, err := s.cfg.Store.ListOpenTopicsForSourceExcluding(in.SourcePath, prop.TopicID)
	if err != nil {
		return "failed", "list other open topics: " + err.Error()
	}
	for _, t := range others {
		marker := `data-orcha-anchor="` + t.ID + `"`
		if !strings.Contains(prop.ProposedSource, marker) {
			return "failed", "anchor invariant: topic " + t.ID + " not stamped in proposal"
		}
	}

	leaked := `data-orcha-anchor="` + prop.TopicID + `"`
	if strings.Contains(prop.ProposedSource, leaked) {
		return "failed", "anchor invariant: incorporated topic's marker leaked into proposal"
	}
	return "", ""
}

Add the import of database/sql and strings at the top of service.go if not present.

Add GetAgentProposalMessage to collab.reader.go if it doesn't exist:

func (s *Store) GetAgentProposalMessage(proposalID string) (TopicMessage, error) {
	var m TopicMessage
	err := s.db.QueryRow(
		`SELECT id, topic_id, kind, body, author_user_id, proposal_id, sequence, created_at
		   FROM topic_messages
		  WHERE proposal_id = ? AND kind = 'agent-proposal'`, proposalID,
	).Scan(&m.ID, &m.TopicID, &m.Kind, &m.Body, &m.AuthorUserID, &m.ProposalID, &m.Sequence, &m.CreatedAt)
	return m, err
}

HasProposalForTopicSince (the timestamp-based check) can be left as-is for now — it's no longer called by classify, but other callers (e.g. recover.go) may use it. Delete it only if grep -r HasProposalForTopicSince confirms no remaining callers.

go test ./internal/agent/... ./internal/collab/...

Expected: PASS.

git add internal/agent/service.go internal/agent/service_test.go internal/collab/reader.go
git commit -m "wiki-browser: agent — post-exit invariants for incorporate jobs"

Task 10b: Byte-based render entrypoints

Today internal/render's public renderers — renderMarkdown and renderHTML — both take an absolute file path and call os.ReadFile themselves. The Tier 2 preview route (Task 16) needs to render bytes that exist only in memory (the proposal's proposed_source column). Factoring out a byte-level entrypoint is a real chunk of work and gets its own task so Task 16 stays focused on the preview handler.

Files:

In internal/render/markdown_test.go (or the existing markdown-renderer test file), add:

func TestRenderMarkdownBytes_RoundTrip(t *testing.T) {
	bytes := []byte("# Hello\n\nparagraph\n")
	doc, err := RenderMarkdownBytes(bytes, "docs/x.md")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(doc.HTML), "<h1") || !strings.Contains(string(doc.HTML), "Hello") {
		t.Fatalf("rendered HTML: %s", doc.HTML)
	}
}

And mirror it in html_test.go:

func TestRenderHTMLBytes_PassesThrough(t *testing.T) {
	bytes := []byte("<p>hi</p>")
	doc, err := RenderHTMLBytes(bytes, "docs/x.html")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(doc.HTML), "<p>hi</p>") {
		t.Fatalf("rendered HTML: %s", doc.HTML)
	}
}
go test ./internal/render/ -run RenderMarkdownBytes -v

Expected: FAIL — functions undefined.

In internal/render/markdown.go, split the existing private renderMarkdown(absPath string) into a thin file-reading shell + a byte-based implementation. The path argument keeps the relative source path for RenderMap/anchor purposes:

// renderMarkdown reads the file at absPath and renders it as Markdown.
func renderMarkdown(absPath string) (*Document, error) {
	src, err := os.ReadFile(absPath)
	if err != nil {
		return nil, err
	}
	// sourcePath used by the renderer for relative-link resolution and the
	// RenderMap. Strip absPath to a repo-relative form if the renderer
	// requires that; otherwise pass absPath verbatim — match the existing
	// behaviour so callers see no observable change.
	return RenderMarkdownBytes(src, absPath)
}

// RenderMarkdownBytes renders Markdown source bytes as a Document. The
// sourcePath is used for relative-link resolution and RenderMap source
// positions; it doesn't need to exist on disk.
func RenderMarkdownBytes(src []byte, sourcePath string) (*Document, error) {
	// Body of the previous renderMarkdown, minus the os.ReadFile call.
	// ...
}

Do the same split for renderHTML in html.goRenderHTMLBytes.

If the public dispatch (Render(absPath string) or similar) needs a byte variant too, add RenderBytes(src []byte, sourcePath string) (*Document, error) that selects between markdown/html by extension on sourcePath. The preview handler in Task 16 will call this.

go test ./internal/render/...

Expected: PASS. Existing tests that called renderMarkdown(path) should be unchanged (the file-reading wrapper is preserved).

git add internal/render/markdown.go internal/render/html.go internal/render/render.go internal/render/markdown_test.go internal/render/html_test.go
git commit -m "wiki-browser: render — byte-based RenderMarkdownBytes / RenderHTMLBytes entrypoints"

Phase 4 — Server endpoints

Task 11: Tier 1 unified-diff helper

Files:

Create internal/server/diff_test.go:

package server

import (
	"strings"
	"testing"
)

func TestUnifiedDiff_BasicReplacement(t *testing.T) {
	got, err := UnifiedDiff("docs/x.md", "alpha\nbeta\n", "alpha\nGAMMA\n")
	if err != nil {
		t.Fatal(err)
	}
	for _, want := range []string{
		"--- docs/x.md",
		"+++ docs/x.md",
		"-beta",
		"+GAMMA",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("missing %q in:\n%s", want, got)
		}
	}
}

func TestUnifiedDiff_IdenticalIsEmpty(t *testing.T) {
	got, err := UnifiedDiff("docs/x.md", "same\n", "same\n")
	if err != nil {
		t.Fatal(err)
	}
	if strings.TrimSpace(got) != "" {
		t.Fatalf("expected empty diff for identical content, got:\n%s", got)
	}
}
go test ./internal/server/ -run TestUnifiedDiff -v

Expected: FAIL.

Create internal/server/diff.go:

package server

import (
	"fmt"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"
)

// UnifiedDiff returns the unified-diff text between base and proposed,
// labelled with name on both sides. Returns "" if the inputs are identical.
func UnifiedDiff(name, base, proposed string) (string, error) {
	if base == proposed {
		return "", nil
	}
	edits := myers.ComputeEdits(span.URIFromPath(name), base, proposed)
	d := gotextdiff.ToUnified(name, name, base, edits)
	return fmt.Sprintf("%v", d), nil
}

If the dep isn't a direct require yet, run:

go get github.com/hexops/gotextdiff@v1.0.3

(It's already an indirect require — promoting it to direct is a one-line change in go.mod.)

go test ./internal/server/ -run TestUnifiedDiff -v
go mod tidy

Expected: PASS.

git add internal/server/diff.go internal/server/diff_test.go go.mod go.sum
git commit -m "wiki-browser: server — Tier 1 unified-diff helper via gotextdiff"

Task 12: Commit-subject default helper

Files:

package server

import "testing"

func TestDefaultIncorporateSubject(t *testing.T) {
	cases := []struct {
		name, in, want string
	}{
		{"short ascii", "Fix the typo", "Incorporate Topic: Fix the typo"},
		{"strips heading", "# Section title", "Incorporate Topic: Section title"},
		{"strips list bullet", "- bullet point", "Incorporate Topic: bullet point"},
		{"strips blockquote", "> quoted text", "Incorporate Topic: quoted text"},
		{"collapses whitespace", "foo \n\n bar\tbaz", "Incorporate Topic: foo bar baz"},
		{"truncates over 60 runes", strings.Repeat("a", 80),
			"Incorporate Topic: " + strings.Repeat("a", 60) + "…"},
		{"multibyte safe", "日本語テストの長いタイトルですね" + strings.Repeat("あ", 50),
			"Incorporate Topic: " + "日本語テストの長いタイトルですね" + strings.Repeat("あ", 44) + "…"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := DefaultIncorporateSubject(tc.in)
			if got != tc.want {
				t.Errorf("got %q\nwant %q", got, tc.want)
			}
		})
	}
}

Adjust the multibyte expectation if your manual count differs — what matters is rune-based truncation at 60. If the test fails on rune count, fix the test, not the truncation logic.

go test ./internal/server/ -run TestDefaultIncorporateSubject -v

Expected: FAIL.

package server

import (
	"strings"
	"unicode/utf8"
)

const subjectMaxRunes = 60

// DefaultIncorporateSubject builds the commit subject used when the client
// omits one. Strips a single leading Markdown prefix, collapses whitespace,
// truncates to subjectMaxRunes runes with "…" ellipsis when needed.
func DefaultIncorporateSubject(firstMessage string) string {
	s := strings.TrimSpace(firstMessage)
	for _, prefix := range []string{"# ", "## ", "### ", "- ", "* ", "> "} {
		if strings.HasPrefix(s, prefix) {
			s = strings.TrimSpace(s[len(prefix):])
			break
		}
	}
	s = strings.Join(strings.Fields(s), " ")

	if utf8.RuneCountInString(s) > subjectMaxRunes {
		// Slice at the rune boundary.
		count := 0
		end := 0
		for i := range s {
			if count == subjectMaxRunes {
				end = i
				break
			}
			count++
		}
		if end == 0 {
			end = len(s)
		}
		s = s[:end] + "…"
	}
	return "Incorporate Topic: " + s
}
go test ./internal/server/ -run TestDefaultIncorporateSubject -v

Expected: PASS.

git add internal/server/subject.go internal/server/subject_test.go
git commit -m "wiki-browser: server — rune-based DefaultIncorporateSubject helper"

Task 13: POST /api/topics/{id}/proposals — idempotent enqueue

Files:

func TestPostProposals_EnqueuesNew(t *testing.T) {
	h, store := newCollabTestHarness(t)
	createTopic(t, store, "t1", "docs/x.md")

	resp := h.do(t, "POST", "/api/topics/t1/proposals", "", withCSRF())
	if resp.Code != http.StatusAccepted {
		t.Fatalf("status %d body %s", resp.Code, resp.Body.String())
	}
	var got struct{ JobID string `json:"job_id"` }
	mustDecode(t, resp.Body, &got)
	assertAgentJob(t, store, got.JobID, "queued", "t1")
}

func TestPostProposals_IdempotentWhileRunning(t *testing.T) {
	h, store := newCollabTestHarness(t)
	createTopic(t, store, "t1", "docs/x.md")

	first := h.do(t, "POST", "/api/topics/t1/proposals", "", withCSRF())
	var fb struct{ JobID string `json:"job_id"` }
	mustDecode(t, first.Body, &fb)

	// Second call while the job is still queued/running — must return the
	// same job_id and 200, not a new row.
	second := h.do(t, "POST", "/api/topics/t1/proposals", "", withCSRF())
	if second.Code != http.StatusOK {
		t.Fatalf("status %d, want 200", second.Code)
	}
	var sb struct{ JobID string `json:"job_id"` }
	mustDecode(t, second.Body, &sb)
	if sb.JobID != fb.JobID {
		t.Fatalf("job ids: %q vs %q", fb.JobID, sb.JobID)
	}
}

func TestPostProposals_RejectsTerminalTopic(t *testing.T) {
	h, store := newCollabTestHarness(t)
	createDiscardedTopic(t, store, "t1", "docs/x.md")
	resp := h.do(t, "POST", "/api/topics/t1/proposals", "", withCSRF())
	if resp.Code != http.StatusUnprocessableEntity {
		t.Fatalf("status %d, want 422", resp.Code)
	}
}

Use the existing test harness pattern from topics_test.go and auth_integration_test.go. withCSRF() is a helper that attaches a valid X-CSRF-Token; if not present, add one mirroring how topics_test.go constructs authenticated requests.

go test ./internal/server/ -run TestPostProposals -v

Expected: FAIL — route not registered.

Create internal/server/handler_proposals.go (or extend an existing one) with:

func (d Deps) handleProposeRewrite(w http.ResponseWriter, r *http.Request) {
	if d.Collab == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "collab_unavailable")
		return
	}
	topicID := r.PathValue("id")
	if topicID == "" {
		writeJSONError(w, http.StatusBadRequest, "topic_required")
		return
	}

	// Reject terminal topics.
	state, err := d.Collab.GetTopicState(topicID)
	if err != nil { /* 404 if not found, 500 otherwise */ }
	if !state.Open {
		writeJSONError(w, http.StatusUnprocessableEntity, "topic_terminal")
		return
	}

	// Idempotency: latest agent_jobs row for this topic in queued/running?
	if existing, err := d.Collab.LatestActiveJobForTopic(topicID); err != nil {
		writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
		return
	} else if existing != "" {
		writeJSON(w, http.StatusOK, map[string]string{"job_id": existing})
		return
	}

	if d.AgentService == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
		return
	}
	jobID, err := d.AgentService.Submit(agent.SubmitInput{
		Kind:       "incorporate",
		SourcePath: state.SourcePath,
		TopicID:    topicID,
	})
	if err != nil { /* 500 */ }
	writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
}

Wire LatestActiveJobForTopic into internal/collab/reader.go if it doesn't already exist. GetTopicState already returns Open and SourcePath; use that to reject terminal Topics and submit the existing agent.SubmitInput. Add the internal/agent import to the server file that owns this handler.

Register the route in internal/server/server.go alongside the existing /api/topics/... routes, protected by auth.RequireCollaborator and the CSRF middleware that #7 ships.

go test ./internal/server/ -run TestPostProposals -v

Expected: PASS.

git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/server/server.go internal/collab/reader.go internal/agent/service.go
git commit -m "wiki-browser: server — POST /api/topics/{id}/proposals (idempotent enqueue)"

Task 14: GET /api/topics/{id}/proposals — list with freshness

Files:

func TestGetProposals_ListsWithFreshness(t *testing.T) {
	h, store, repoRoot := newCollabTestHarness(t)
	writeFile(t, repoRoot, "docs/x.md", "hello")
	createTopic(t, store, "t1", "docs/x.md")
	seedSucceededProposal(t, store, "p1", "t1", blobSHA(t, "hello"), "<span data-orcha-anchor=\"t2\">x</span>")
	mustInsertOpenTopic(t, store, "t2", "docs/x.md")

	resp := h.do(t, "GET", "/api/topics/t1/proposals", "")
	var got []struct {
		ID              string   `json:"id"`
		RevisionNumber  int      `json:"revision_number"`
		BaseSourceSHA   string   `json:"base_source_sha"`
		AgentJobID      *string  `json:"agent_job_id"`
		JobStatus       string   `json:"job_status"`
		Fresh           bool     `json:"fresh"`
		StaleReasons    []string `json:"stale_reasons"`
		MissingTopicIDs []string `json:"missing_topic_ids"`
	}
	mustDecode(t, resp.Body, &got)
	if len(got) != 1 || !got[0].Fresh {
		t.Fatalf("got %+v", got)
	}

	// Now create another open Topic that's not in the proposal — stale.
	mustInsertOpenTopic(t, store, "t3", "docs/x.md")
	resp = h.do(t, "GET", "/api/topics/t1/proposals", "")
	mustDecode(t, resp.Body, &got)
	if got[0].Fresh ||
		!containsString(got[0].StaleReasons, "missing_topic_markers") ||
		!containsString(got[0].MissingTopicIDs, "t3") {
		t.Fatalf("got %+v", got)
	}
}
func (d Deps) handleListProposals(w http.ResponseWriter, r *http.Request) {
	topicID := r.PathValue("id")
	proposals, err := d.Collab.ListProposalsForTopic(topicID)
	if err != nil { /* 500 */ }

	type row struct {
		ID              string   `json:"id"`
		RevisionNumber  int      `json:"revision_number"`
		BaseSourceSHA   string   `json:"base_source_sha"`
		AgentJobID      *string  `json:"agent_job_id"`
		JobStatus       string   `json:"job_status,omitempty"`
		Fresh           bool     `json:"fresh"`
		StaleReasons    []string `json:"stale_reasons,omitempty"`
		MissingTopicIDs []string `json:"missing_topic_ids,omitempty"`
		CreatedAt       int64    `json:"created_at"`
	}
	out := make([]row, 0, len(proposals))
	for _, p := range proposals {
		fr, err := collab.ProposalFreshness(d.Collab, d.Root, p.ID)
		if err != nil { /* 500 */ }
		r := row{
			ID:              p.ID,
			RevisionNumber:  p.RevisionNumber,
			BaseSourceSHA:   p.BaseSourceSHA,
			Fresh:           fr.Fresh,
			StaleReasons:    fr.StaleReasons,
			MissingTopicIDs: fr.MissingTopicIDs,
			JobStatus:       fr.JobStatus,
			CreatedAt:       p.CreatedAt,
		}
		if p.AgentJobID.Valid {
			s := p.AgentJobID.String
			r.AgentJobID = &s
		}
		out = append(out, r)
	}
	writeJSON(w, http.StatusOK, out)
}

Add ListProposalsForTopic to collab.reader.go if not present (ordered by revision_number DESC, excludes the proposed-source body to keep the response small).

Wire the route.

Expected: PASS.

git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/server/server.go internal/collab/reader.go
git commit -m "wiki-browser: server — GET /api/topics/{id}/proposals with freshness"

Task 15: GET /api/proposals/{id}/diff — Tier 1 response

Files:

func TestGetProposalDiff_ReturnsUnified(t *testing.T) {
	h, store, repoRoot := newCollabTestHarness(t)
	writeFile(t, repoRoot, "docs/x.md", "alpha\nbeta\n")
	createTopic(t, store, "t1", "docs/x.md")
	seedSucceededProposal(t, store, "p1", "t1", blobSHA(t, "alpha\nbeta\n"), "alpha\nGAMMA\n")

	resp := h.do(t, "GET", "/api/proposals/p1/diff", "")
	if resp.Code != http.StatusOK {
		t.Fatalf("status %d", resp.Code)
	}
	var got struct {
		Unified     string   `json:"unified"`
		BaseSHA     string   `json:"base_sha"`
		ProposedSHA string   `json:"proposed_sha"`
		Fresh       bool     `json:"fresh"`
	}
	mustDecode(t, resp.Body, &got)
	if !strings.Contains(got.Unified, "-beta") || !strings.Contains(got.Unified, "+GAMMA") {
		t.Fatalf("diff body: %s", got.Unified)
	}
}
func (d Deps) handleProposalDiff(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	prop, err := d.Collab.GetProposal(id)
	if errors.Is(err, sql.ErrNoRows) {
		writeJSONError(w, http.StatusNotFound, "proposal_not_found")
		return
	}
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "load_proposal_failed")
		return
	}
	topic, err := d.Collab.GetTopic(prop.TopicID)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "load_topic_failed")
		return
	}
	// Spec: "410 if Topic terminal." Once a Topic is incorporated or
	// discarded the diff carries no actionable meaning.
	if topic.IsTerminal() {
		writeJSONError(w, http.StatusGone, "topic_terminal")
		return
	}

	baseBytes, err := os.ReadFile(filepath.Join(d.Root, topic.SourcePath))
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "read_source_failed")
		return
	}

	unified, err := UnifiedDiff(topic.SourcePath, string(baseBytes), prop.ProposedSource)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "diff_failed")
		return
	}

	fr, err := collab.ProposalFreshness(d.Collab, d.Root, id)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "freshness_failed")
		return
	}
	baseSHA, _ := collab.SourceSHAOfBytes(d.Root, topic.SourcePath, baseBytes)
	proposedSHA, _ := collab.SourceSHAOfBytes(d.Root, topic.SourcePath, []byte(prop.ProposedSource))

	writeJSON(w, http.StatusOK, map[string]any{
		"unified":      unified,
		"base_sha":     baseSHA,
		"proposed_sha": proposedSHA,
		"fresh":        fr.Fresh,
	})
}

Add GetProposal/GetTopic to collab readers if not present. Topic.IsTerminal() is a small method: t.IncorporatedAt.Valid || t.DiscardedAt.Valid — add it next to the Topic struct in reader.go. Also add a matching test case TestGetProposalDiff_ReturnsGoneForTerminalTopic exercising the 410 path.

Expected: PASS.

git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go
git commit -m "wiki-browser: server — GET /api/proposals/{id}/diff"

Task 16: GET /content/preview/proposals/{id} — Tier 2 preview with render-mode markers

Files:

func TestPreview_RendersProposedBytes_AndResolvesMarkers(t *testing.T) {
	h, store, _ := newCollabTestHarness(t)
	createTopic(t, store, "t1", "docs/x.md")
	mustInsertOpenTopic(t, store, "t2", "docs/x.md") // remains pre-marker in DB
	seedSucceededProposal(t, store, "p1", "t1", "irrelevant",
		`Some prose <span data-orcha-anchor="t2">live</span> bytes.`)

	resp := h.do(t, "GET", "/content/preview/proposals/p1", "")
	if resp.Code != http.StatusOK {
		t.Fatalf("status %d", resp.Code)
	}
	body := resp.Body.String()
	// The rendered output should include the live marker because the
	// preview pass treats t2 as marker-kind for rendering only.
	if !strings.Contains(body, `data-topic-ids="t2"`) &&
		!strings.Contains(body, `data-orcha-anchor="t2"`) {
		t.Fatalf("expected resolved marker for t2 in body:\n%s", body)
	}
	// And the wb-source-sha meta tag must NOT appear so the composer
	// can't fire against uncommitted bytes.
	if strings.Contains(body, `<meta name="wb-source-sha"`) {
		t.Fatalf("preview must not emit wb-source-sha meta tag")
	}
}

Create internal/server/handler_preview.go:

package server

import (
	"encoding/json"
	"html/template"
	"net/http"
	"strings"

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

func (d Deps) handlePreviewProposal(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	prop, err := d.Collab.GetProposal(id)
	if err != nil { /* 404 / 500 */ }
	topic, err := d.Collab.GetTopic(prop.TopicID)
	if err != nil { /* 500 */ }

	doc, err := render.RenderBytes(topic.SourcePath, []byte(prop.ProposedSource))
	if err != nil { /* 500 */ }

	// Build the open-Topic set for anchor resolution. We want every
	// currently-open non-global Topic on this Source whose marker is in
	// the proposal bytes, *excluding* the incorporated Topic. Each one is
	// passed as a synthetic MarkerAnchor so ResolveAnchors highlights them.
	others, err := d.Collab.ListOpenTopicsForSourceExcluding(topic.SourcePath, topic.ID)
	if err != nil { /* 500 */ }

	markerAnchor, _ := json.Marshal(map[string]string{"kind": "marker"})
	resolveSet := make([]render.OpenTopic, 0, len(others))
	for _, t := range others {
		marker := `data-orcha-anchor="` + t.ID + `"`
		if strings.Contains(prop.ProposedSource, marker) {
			resolveSet = append(resolveSet, render.OpenTopic{
				ID:     t.ID,
				Anchor: markerAnchor, // override stored anchor for rendering
			})
		}
	}

	// "Current source SHA" doesn't matter for marker-kind anchors, but
	// ResolveAnchors needs a value. Pass empty string; resolver will skip
	// any pre-marker anchors (we passed none).
	resolved, err := render.ResolveAnchors(doc, "", resolveSet)
	if err != nil { /* 500 */ }

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Vary", "Cookie")
	// Deliberately omit wb-source-sha by passing SourceSHA="". Keep the
	// same content template as /content/{source_path} so preview pages get
	// prose.css, content.js, mermaid bootstrapping, and identical body chrome.
	_ = mustTemplates().ExecuteTemplate(w, "content_md.html", ContentMDData{
		Title:         resolved.Title,
		BodyHTML:      template.HTML(resolved.HTML),
		HasMermaid:    resolved.HasMermaid,
		SourceSHA:     "",
		Authenticated: true,
	})
}

render.RenderBytes(sourcePath string, src []byte) was added in Task 10b — the preview handler uses it directly. sourcePath supplies the extension and fallback title; the preview handler must not write temp files and must not bypass the normal content_md.html wrapper.

Register the route in internal/server/server.go before the generic /content/{source_path...} route, protected by auth.RequireCollaborator. Preview does not mutate state, so it does not need CSRF middleware.

Expected: PASS.

git add internal/server/handler_preview.go internal/server/handler_preview_test.go internal/server/server.go internal/render/
git commit -m "wiki-browser: server — Tier 2 preview route with render-mode marker anchors"

Task 17: POST /api/proposals/{id}/incorporate — approval with stale guard + reanchor + subject default

Files:

func TestIncorporate_HappyPath_DefaultsSubjectReanchorsAndCommits(t *testing.T) {
	h, store, repoRoot := newCollabTestHarness(t)
	gitInit(t, repoRoot)
	writeFileCommit(t, repoRoot, "docs/x.md", "alpha\n")
	baseSHA := blobSHA(t, "alpha\n")

	createTopicWithFirstMessage(t, store, "t1", "docs/x.md", "Hello: rename foo")
	mustInsertOpenTopicPreMarker(t, store, "t2", "docs/x.md")
	seedSucceededProposal(t, store, "p1", "t1", baseSHA,
		"alpha\n<span data-orcha-anchor=\"t2\">new</span>\n")

	resp := h.do(t, "POST", "/api/proposals/p1/incorporate", `{}`, withCSRF())
	if resp.Code != http.StatusOK {
		t.Fatalf("status %d body %s", resp.Code, resp.Body.String())
	}
	var got struct {
		CommitSHA string `json:"commit_sha"`
		TopicID   string `json:"topic_id"`
	}
	mustDecode(t, resp.Body, &got)
	if got.CommitSHA == "" || got.TopicID != "t1" {
		t.Fatalf("got %+v", got)
	}

	// Topic t1 closed; t2 flipped to marker.
	assertTopicIncorporated(t, store, "t1", got.CommitSHA)
	assertAnchorKind(t, store, "t2", "marker")

	// Commit subject = default.
	subject := lastCommitSubject(t, repoRoot)
	if !strings.HasPrefix(subject, "Incorporate Topic: Hello: rename foo") {
		t.Fatalf("subject = %q", subject)
	}
}

func TestIncorporate_RejectsStaleProposal(t *testing.T) {
	h, store, repoRoot := newCollabTestHarness(t)
	gitInit(t, repoRoot)
	writeFileCommit(t, repoRoot, "docs/x.md", "current bytes\n")
	createTopic(t, store, "t1", "docs/x.md")
	seedSucceededProposal(t, store, "p1", "t1", "deadbeef" /* stale sha */, "anything")

	resp := h.do(t, "POST", "/api/proposals/p1/incorporate", `{}`, withCSRF())
	if resp.Code != http.StatusConflict {
		t.Fatalf("status %d", resp.Code)
	}
	var got struct {
		Code            string   `json:"code"`
		StaleReasons    []string `json:"stale_reasons"`
		MissingTopicIDs []string `json:"missing_topic_ids"`
	}
	mustDecode(t, resp.Body, &got)
	if got.Code != "stale_proposal" || !containsString(got.StaleReasons, "source_sha") {
		t.Fatalf("got %+v", got)
	}
}
type incorporateRequest struct {
	Subject string `json:"subject,omitempty"`
	Body    string `json:"body,omitempty"`
}

func (d Deps) handleIncorporate(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req incorporateRequest
	_ = json.NewDecoder(r.Body).Decode(&req) // empty body is fine

	prop, err := d.Collab.GetProposal(id)
	if errors.Is(err, sql.ErrNoRows) {
		writeJSONError(w, http.StatusNotFound, "proposal_not_found")
		return
	}
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "load_proposal_failed")
		return
	}
	topic, err := d.Collab.GetTopic(prop.TopicID)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "load_topic_failed")
		return
	}
	state, err := d.Collab.GetTopicState(prop.TopicID)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "load_topic_state_failed")
		return
	}
	if !state.Open {
		writeJSONError(w, http.StatusUnprocessableEntity, "topic_terminal")
		return
	}
	// Spec: "422 if its linked Agent job did not succeed." This is a
	// separate failure mode from staleness (the bytes are out of date)
	// — here the bytes never came from a successful run in the first
	// place. The proposal row stays for audit but is not approvable.
	if prop.AgentJobID.Valid {
		jobStatus, err := d.Collab.GetJobStatus(prop.AgentJobID.String)
		if err != nil {
			writeJSONError(w, http.StatusInternalServerError, "load_job_status_failed")
			return
		}
		if jobStatus != "succeeded" {
			writeJSONError(w, http.StatusUnprocessableEntity, "job_did_not_succeed")
			return
		}
	}

	fr, err := collab.ProposalFreshness(d.Collab, d.Root, id)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "freshness_failed")
		return
	}
	if !fr.Fresh {
		writeJSON(w, http.StatusConflict, map[string]any{
			"code":              "stale_proposal",
			"stale_reasons":     fr.StaleReasons,
			"missing_topic_ids": fr.MissingTopicIDs,
		})
		return
	}

	subject := strings.TrimSpace(req.Subject)
	if subject == "" {
		first, err := d.Collab.FirstHumanMessage(topic.ID)
		if err != nil { /* 500 */ }
		subject = DefaultIncorporateSubject(first.Body)
	}

	// Compute the reanchor set: every current open non-global Topic on the
	// source, excluding the incorporated Topic, whose marker is present in
	// proposed_source. Freshness check above guarantees this list matches
	// the open set entirely.
	others, err := d.Collab.ListOpenTopicsForSourceExcluding(topic.SourcePath, topic.ID)
	if err != nil { /* 500 */ }
	reanchor := make([]string, 0, len(others))
	for _, t := range others {
		marker := `data-orcha-anchor="` + t.ID + `"`
		if strings.Contains(prop.ProposedSource, marker) {
			reanchor = append(reanchor, t.ID)
		}
	}

	if d.AgentService == nil {
		writeJSONError(w, http.StatusServiceUnavailable, "agent_unavailable")
		return
	}
	principal, _ := auth.PrincipalFrom(r.Context())
	authorName, authorEmail := d.AgentService.AuthorIdentity()
	commitSHA, err := collab.Incorporate(d.Collab, collab.IncorporateInput{
		RepoRoot:         d.Root,
		ProposalID:       id,
		ApproverID:       principal.UserID,
		ApproverName:     principal.DisplayName,
		Subject:          subject,
		Body:             req.Body,
		AuthorName:       authorName,
		AuthorEmail:      authorEmail,
		ReanchorTopicIDs: reanchor,
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "incorporate_failed")
		return
	}

	writeJSON(w, http.StatusOK, map[string]string{
		"commit_sha": commitSHA,
		"topic_id":   topic.ID,
	})
}

Add FirstHumanMessage, IsTerminal, and GetJobStatus(id) (string, error) helpers to collab. Add a matching test case TestIncorporate_Rejects422OnFailedJob exercising the new branch: seed a proposal whose agent_job_id points at a failed job, fire the approval request, expect 422 with "job_did_not_succeed".

Expected: PASS.

git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/collab/reader.go
git commit -m "wiki-browser: server — POST /api/proposals/{id}/incorporate (stale guard + reanchor + subject default)"

Task 18: POST /api/topics/{id}/discard

Files:

func TestDiscardTopic_TransitionsAndAppendsReason(t *testing.T) {
	h, store := newCollabTestHarness(t)
	createTopic(t, store, "t1", "docs/x.md")

	resp := h.do(t, "POST", "/api/topics/t1/discard",
		`{"reason":"superseded by t2"}`, withCSRF())
	if resp.Code != http.StatusOK {
		t.Fatalf("status %d body %s", resp.Code, resp.Body.String())
	}

	var got struct{ DiscardedAt int64 `json:"discarded_at"` }
	mustDecode(t, resp.Body, &got)
	if got.DiscardedAt == 0 {
		t.Fatalf("discarded_at empty")
	}

	// Final message present.
	last := lastMessage(t, store, "t1")
	if last.Kind != "human" || !strings.Contains(last.Body, "superseded") {
		t.Fatalf("last message: %+v", last)
	}
}

func TestDiscardTopic_NoReasonAppendsNoMessage(t *testing.T) {
	h, store := newCollabTestHarness(t)
	createTopicWithFirstMessage(t, store, "t1", "docs/x.md", "kickoff")

	beforeCount := messageCount(t, store, "t1")

	resp := h.do(t, "POST", "/api/topics/t1/discard", `{}`, withCSRF())
	if resp.Code != http.StatusOK {
		t.Fatalf("status %d", resp.Code)
	}

	afterCount := messageCount(t, store, "t1")
	if afterCount != beforeCount {
		t.Fatalf("message count changed: %d → %d", beforeCount, afterCount)
	}
}
type discardRequest struct {
	Reason string `json:"reason,omitempty"`
}

func (d Deps) handleDiscardTopic(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req discardRequest
	_ = json.NewDecoder(r.Body).Decode(&req)

	state, err := d.Collab.GetTopicState(id)
	if err != nil { /* 404 / 500 */ }
	if !state.Open {
		writeJSONError(w, http.StatusUnprocessableEntity, "topic_terminal")
		return
	}

	principal, _ := auth.PrincipalFrom(r.Context())
	discardedAt, err := d.Collab.DiscardTopic(collab.DiscardTopicInput{
		TopicID: id,
		Reason:  strings.TrimSpace(req.Reason),
		ByUser:  principal.UserID,
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "discard_failed")
		return
	}

	writeJSON(w, http.StatusOK, map[string]int64{"discarded_at": discardedAt})
}

Add collab.DiscardTopic. The two writes (optional message append + topic outcome columns) must be atomic, so they share a single s.send transaction. To avoid bypassing validateMessage, mirror the existing InsertTopicWithFirstMessage pattern: build the NewMessage, run validateMessage outside the funnel, then bind the same value into the raw INSERT inside the transaction. Reuse, don't reimplement:

type DiscardTopicInput struct {
	TopicID string
	Reason  string
	ByUser  string
}

func (s *Store) DiscardTopic(in DiscardTopicInput) (int64, error) {
	if in.TopicID == "" || in.ByUser == "" {
		return 0, fmt.Errorf("DiscardTopic: required fields missing")
	}

	// Build + validate the message before opening the transaction, the
	// same way InsertTopicWithFirstMessage does. The raw INSERT below
	// binds this same NewMessage value so the validator and writer
	// can't drift.
	var msg *NewMessage
	if in.Reason != "" {
		by := in.ByUser
		m := NewMessage{
			ID: uuid.NewString(), TopicID: in.TopicID, Kind: "human",
			Body: in.Reason, AuthorUserID: &by,
		}
		if err := validateMessage(m); err != nil {
			return 0, fmt.Errorf("DiscardTopic: %w", err)
		}
		msg = &m
	}

	now := time.Now().Unix()
	err := s.send(func(db *sql.DB) error {
		tx, err := db.Begin()
		if err != nil { return err }

		if msg != nil {
			var nextSeq int
			if err := tx.QueryRow(
				`SELECT COALESCE(MAX(sequence), 0) + 1
				   FROM topic_messages WHERE topic_id = ?`, msg.TopicID,
			).Scan(&nextSeq); err != nil {
				_ = tx.Rollback(); return err
			}
			if _, err := tx.Exec(
				`INSERT INTO topic_messages(
				   id, topic_id, kind, body, author_user_id,
				   proposal_id, sequence, created_at
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
				msg.ID, msg.TopicID, msg.Kind, msg.Body, msg.AuthorUserID, msg.ProposalID, nextSeq, now,
			); err != nil {
				_ = tx.Rollback(); return err
			}
		}

		if _, err := tx.Exec(
			`UPDATE topics
			    SET discarded_at = ?, discarded_by = ?, updated_at = ?
			  WHERE id = ?
			    AND incorporated_at IS NULL
			    AND discarded_at IS NULL`,
			now, in.ByUser, now, in.TopicID,
		); err != nil {
			_ = tx.Rollback(); return err
		}
		return tx.Commit()
	})
	return now, err
}

Register the route.

Expected: PASS.

git add internal/server/handler_proposals.go internal/server/handler_proposals_test.go internal/server/server.go internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "wiki-browser: server — POST /api/topics/{id}/discard"

Phase 5 — Skill body

Task 19: Rewrite wb-incorporate/SKILL.md with the rewrite contract

Files:

The spec's open-questions section already flags that an Agent-produced explanation containing a literal single quote would be unrepresentable through the single-quoted Bash variable assignment shown below. Don't try to fix that here — it's an acknowledged limitation; the v1 case set (natural-language explanations) doesn't trigger it, and if it bites in practice, the fix is a SKILL.md-only change (switch to length-prefixed stdin framing).

Copy the body from the spec's "wb-incorporate rewrite contract" section (the SKILL.md excerpt). The final file:

---
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.
---

# What you're helping with

You're helping a small group of writers iterate on a shared document. They
hold conversations about specific parts of the document — call those
conversations Topics — and when they reach agreement, they ask you to
translate the outcome into a concrete rewrite. The rewritten document is
then reviewed and (usually) committed as the new version.

Vocabulary you'll see:
  - **Source** — the canonical Markdown/HTML file being edited.
  - **Topic** — a discussion thread attached to a region (or the whole) of
    the Source. Topics are open until they are incorporated or discarded.
  - **Anchor** — an inline marker stamped into the Source as an HTML element
    with a `data-orcha-anchor="<topic-id>"` attribute. Tells the UI which
    region each open Topic is about. You maintain these.
  - **Proposal** — the rewritten Source you produce, plus a short
    explanation of what you understood the Topic to be asking for.

# Inputs you have

The message that invoked this skill carries three values:

    Job ID:        <uuid>
    Config path:   <absolute path to wiki-browser.yaml>
    wb-agent path: <absolute path to the wb-agent binary>

Always invoke wb-agent via the absolute path you were given.
Always pass `--config=<Config path>`.

# Steps

## 1. Load the working set

Run:

    <wb-agent path> get-topic --config=… --job-id=…

It returns the Topic you're working on with its anchor and the full message
thread, the absolute path to the Source file, and the `base_source_sha` of
the Source at this moment. Prior proposals you've made for this Topic
appear inside the message thread as `kind: "agent-proposal"`, with their
full `proposed_source` bodies inlined — read them as your own earlier
thinking.

Then list the other open discussions on the same Source so you can keep
them anchored:

    <wb-agent path> list-open-topics --config=… --source-path=<abs> --exclude-topic=<current-id>

Pass the absolute `source_path` `get-topic` returned, and exclude the Topic
you're working on. The response is every other open non-global Topic on
that Source, each with its anchor and message thread.

Read the current Source via the Read tool at the absolute `source_path`.

## 2. Produce a rewrite

Apply the Topic's discussion to the Source. The discussion is
authoritative — don't invent changes the humans didn't agree to. If the
discussion is ambiguous or unresolved, prefer the most recent human
messages and explicit decision markers ("yes", "approved", "let's go with
X"); if still ambiguous, prefer the smallest change that reflects the
conversation.

## 3. Re-anchor every other open non-global Topic

For every Topic returned by `list-open-topics`, the rewritten Source MUST
contain at least one occurrence of:

    data-orcha-anchor="<that-topic-id>"

How to place markers:

- If the Topic's intent still maps to a region of the new Source, wrap the
  relevant content. Inline anchors use `<span
  data-orcha-anchor="<id>">…</span>`; block-level anchors use `<div
  data-orcha-anchor="<id>"></div>` immediately preceding the block,
  separated by a blank line.
- If a Topic's intent naturally spans multiple regions, use multiple
  markers — one per region is fine.
- If a Topic's intent no longer maps cleanly to anything in the new
  Source, append it to a section titled exactly:

      ## Other ideas (potentially to discard)

  at the very bottom of the Source. Each parked Topic gets at least one
  sub-bullet referencing the discussion, wrapped in a marker. If a parked
  discussion has multiple distinct sub-ideas to preserve, use multiple
  sub-bullets — each with its own marker is fine.
- Do NOT include any marker for the Topic you're incorporating. Its
  discussion outcome lives in the prose now; the marker would be a dead
  UUID in the committed Source.

## 4. Write a short explanation

In 1–3 paragraphs, summarise what you understood the Topic to be asking
for and how your rewrite addresses it. This goes in the UI alongside the
diff so the humans reviewing know what your thinking was. Plain prose; no
Markdown headings.

## 5. Persist

Build your explanation as a single shell variable so multi-line text and
special characters pass safely as one argv value (no temp files):

    EXPLANATION='your explanation here, possibly
    spanning multiple lines'

Then pipe the rewritten Source bytes on stdin to wb-agent:

    <wb-agent path> insert-proposal --config=… --job-id=… --explanation="$EXPLANATION" < rewritten-source

Exit 0 on success. Exit non-zero on any unrecoverable error; the stderr
you produce becomes the error message humans see in the UI.
git add .claude/skills/wb-incorporate/SKILL.md
git commit -m "wiki-browser: skills — wb-incorporate body with rewrite contract"

Phase 6 — End-to-end

Task 20: E2E smoke test through FakeRunner

Files:

func TestE2E_ResolveFlow_HappyPath(t *testing.T) {
	env := newFullStackFake(t)
	defer env.Close()

	// 1. Create a Topic + initial message via the #2 endpoints.
	resp := env.HTTPPost("/api/topics", `{
		"source_path": "docs/x.md",
		"global": true,
		"first_message_body": "Please rename foo to bar in the example."
	}`)
	var topic struct{ ID string `json:"id"` }
	mustDecode(t, resp.Body, &topic)

	// 2. Stand up a FakeRunner that on Run will produce a proposal +
	//    explanation via wb-agent insert-proposal — wired by the FakeRunner
	//    test helper.
	env.SetFakeProposalSource(`some new bytes`)
	env.SetFakeExplanation(`I read this as a rename request and applied it directly.`)

	// 3. Propose a rewrite.
	resp = env.HTTPPost("/api/topics/"+topic.ID+"/proposals", "")
	var enq struct{ JobID string `json:"job_id"` }
	mustDecode(t, resp.Body, &enq)
	env.WaitForJob(enq.JobID)

	// 4. List proposals — should have one fresh proposal.
	resp = env.HTTPGet("/api/topics/" + topic.ID + "/proposals")
	var proposals []struct {
		ID    string `json:"id"`
		Fresh bool   `json:"fresh"`
	}
	mustDecode(t, resp.Body, &proposals)
	if len(proposals) != 1 || !proposals[0].Fresh {
		t.Fatalf("proposals: %+v", proposals)
	}

	// 5. Approve.
	resp = env.HTTPPost("/api/proposals/"+proposals[0].ID+"/incorporate", `{}`)
	var done struct{ CommitSHA string `json:"commit_sha"` }
	mustDecode(t, resp.Body, &done)
	if done.CommitSHA == "" {
		t.Fatal("expected non-empty commit_sha")
	}
}

func TestE2E_ResolveFlow_DiscardPath(t *testing.T) {
	env := newFullStackFake(t)
	defer env.Close()

	resp := env.HTTPPost("/api/topics", `{
		"source_path": "docs/x.md", "global": true,
		"first_message_body": "kickoff"
	}`)
	var topic struct{ ID string `json:"id"` }
	mustDecode(t, resp.Body, &topic)

	resp = env.HTTPPost("/api/topics/"+topic.ID+"/discard", `{"reason":"never mind"}`)
	if resp.Code != http.StatusOK { t.Fatalf("status %d", resp.Code) }
}

The harness newFullStackFake should stand up: real internal/collab against a temp DB, a real git init'd repo at the harness's RepoRoot, the actual internal/server HTTP handlers behind the test auth helper, and an agent.Service with a FakeRunner whose Run simulates the skill (by calling wb-agent insert-proposal directly through the configured paths). Look at internal/agent/e2e_test.go for the existing harness shape.

This helper is non-trivial — expect to spend real time on it before any e2e test passes. Recommended sub-steps if you find yourself stuck:

  1. Build a tempDB(t) helper that opens internal/collab against a fresh on-disk SQLite file (the existing setupStoreWith… helpers from earlier tasks should already give you this — promote it).
  2. Build a tempGitRepo(t) helper that runs git init, commits an initial file, and returns the repo root.
  3. Build a fakeSkillRunner that, on Run, shells out to the wb-agent test binary with the right --config and --job-id, feeding configured proposal bytes on stdin and a configured explanation on argv.
  4. Compose a newFullStackFake(t) that wires (1)+(2)+(3) into the actual internal/server mux with agent.Service and writes a fake auth principal into the request context.

Each sub-step is its own commit if it grew significant.

go test ./internal/agent/ -run TestE2E_ResolveFlow -v

If the test exposes integration gaps (missing Deps fields on the server, routing misses, etc.), fix them in the relevant files. Each fix lands as its own follow-up commit if substantive; small fixes can stay in this commit.

go test ./...

Expected: PASS.

git add internal/agent/e2e_test.go
git commit -m "wiki-browser: e2e — Resolve flow happy + discard paths via FakeRunner"

Definition of done

When all 20 tasks pass:

Move to manual verification once the suite is green: run make build, start wiki-browser against a scratch repo, exercise the Resolve flow by hand with playwright-cli per CLAUDE.md's recipe. The first real run will likely surface UI gaps that #8 owns — capture them as #8 parking-lot items rather than fixing here.