@xpufx/paseo-forges 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/active-forge.ts +47 -0
  4. package/client/board-alert.tsx +236 -0
  5. package/client/foreign-link.tsx +33 -0
  6. package/client/hook-queue-panel.tsx +412 -0
  7. package/client/issues-pill.tsx +1976 -0
  8. package/client/label-chip.tsx +116 -0
  9. package/client/linkifier.tsx +280 -0
  10. package/client/pill-label.ts +183 -0
  11. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  12. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  13. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  14. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  15. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  16. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  17. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  18. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  19. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  20. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  21. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  22. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  23. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  24. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  25. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  26. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  27. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  28. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  29. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  30. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  31. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  32. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  33. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  34. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  35. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  36. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  37. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  38. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  39. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  40. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  41. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  42. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  44. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  51. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  52. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  53. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  54. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  55. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  56. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  57. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  58. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  59. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  60. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  61. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  62. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  63. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  64. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  65. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  66. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  67. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  68. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  69. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  70. package/client/webhook-card.tsx +198 -0
  71. package/docs/specs/forge-workflow-gui.md +820 -0
  72. package/docs/workflow.md +339 -0
  73. package/examples/README.md +108 -0
  74. package/examples/hook-service/README.md +66 -0
  75. package/examples/hook-service/forge-hook.service +28 -0
  76. package/examples/hook-service/hook-server.mjs +265 -0
  77. package/examples/hook-service/hook.env.example +21 -0
  78. package/examples/labels/README.md +64 -0
  79. package/examples/labels/label-base.yaml +117 -0
  80. package/examples/skills/coding-agent/SKILL.md +262 -0
  81. package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
  82. package/examples/skills/orchestrator/SKILL.md +133 -0
  83. package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
  84. package/examples/tools/README.md +68 -0
  85. package/examples/tools/fgjx +464 -0
  86. package/package.json +38 -0
  87. package/paseo-plugin.json +4 -0
  88. package/server/forge-client.ts +484 -0
  89. package/server/forge-guard.ts +70 -0
  90. package/server/git-origin.ts +70 -0
  91. package/server/hook-queue.ts +127 -0
  92. package/server/issues.ts +542 -0
  93. package/server/settings.ts +52 -0
  94. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  95. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  96. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  97. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  98. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  100. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  101. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  102. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  103. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  104. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  105. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  106. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  107. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  108. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  109. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  110. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  111. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  112. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  113. package/shared/hook-queue.ts +140 -0
  114. package/shared/issues.ts +1750 -0
  115. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  116. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  117. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  118. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  119. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  120. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  121. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  122. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  123. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  124. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  125. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  126. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  127. package/shared/version.ts +2 -0
  128. package/shared/webhook.ts +223 -0
@@ -0,0 +1,820 @@
1
+ # Native Forge Workflow GUI Plugin for Paseo
2
+
3
+ **Status:** specification (implements [Issue #56 (forge.example.com)](https://forge.example.com/your-org/your-repo/issues/56));
4
+ the auth/data-path, label, and install sections below track the shipped
5
+ implementation as of the standalone-release pass for [Issue #121 (forge.example.com)](https://forge.example.com/your-org/your-repo/issues/121)
6
+ **Scope:** `plugins/forges` (`paseo-forges`) server + client, built only on
7
+ `paseo-plugin-helper` primitives — no Paseo host/SDK changes
8
+ **Auth model:** daemon-side token in plugin settings; all forge access goes
9
+ through the embedded TypeScript `fetch` client (`server/forge-client.ts`).
10
+ There is no `fgj`/`fgjx` subprocess dependency and no host dotfile coupling.
11
+
12
+ > Historical note: an earlier revision specified a `fgjx`/`fgj` subprocess
13
+ > data path. That design is superseded — see §3.
14
+
15
+ ---
16
+
17
+ ## 1. Problem statement
18
+
19
+ The operator (`@your-org`) and the Orchestrator must leave the Paseo
20
+ desktop/mobile client and open an external web browser to inspect issues,
21
+ review agent deliverables, advance label state, or cross-reference agent IDs
22
+ and commit hashes. Every context switch breaks the orchestration loop:
23
+ triage happens in the browser, steering happens in chat, and neither side sees
24
+ the other.
25
+
26
+ A native GUI plugin keeps the whole loop inside Paseo: the issue queue, the
27
+ issue detail (with parsed Agent Envelope telemetry), one-click scoped label
28
+ toggles, and quick comments all live in plugin surfaces powered by
29
+ `paseo-plugin-helper`.
30
+
31
+ ### What already exists (reuse, do not duplicate)
32
+
33
+ `plugins/forges` (`paseo-forges`) already ships the thin end of this wedge:
34
+
35
+ - Server: `forge.open-issues` contract (`shared/issues.ts`) + `handleOpenIssues`
36
+ (`server/issues.ts`) — resolves owner/repo from the workspace directory's
37
+ git `origin` remote, calls the embedded forge (Gitea-family) fetch client, and never
38
+ throws (failures surface as an `error` field so the pill renders a
39
+ placeholder).
40
+ - Shared: `parseForgeRemote`, `extractForgeIssueUrls` (timeline
41
+ linkifier), `formatIssueCountLabel` (null count renders `"issues --"`
42
+ placeholder, never a false zero).
43
+ - Client: composer pill (`GitPullRequest` icon, 15 s label poll / 30 s query
44
+ poll) + issues modal (`Tabs`, `SearchInput`, `Card`, `EmptyState`, copy the
45
+ `[#N title](url)` markdown ref — push-to-composer is unavailable on the 0.8
46
+ SDK, so copy-and-paste remains the handoff).
47
+
48
+ This spec extends that plugin with four new RPC contracts and three new UI
49
+ surfaces. All shared parsing helpers (`parseForgeRemote`,
50
+ `extractForgeIssueUrls`) are reused as-is.
51
+
52
+ ---
53
+
54
+ ## 2. Goals / non-goals
55
+
56
+ ### Goals
57
+
58
+ 1. **Board overview in-client.** Searchable queue of open issues sorted by the
59
+ deterministic priority tuple (§4.3), with quick-filters for `state/*` and
60
+ `priority/*` buckets — no browser required for triage.
61
+ 2. **Issue detail in-client.** Full markdown body, comment thread, and parsed
62
+ Agent Envelope cards (agent id, provider/model, branch, commit SHAs,
63
+ `paseo://` session link) in one inspection modal.
64
+ 3. **One-click label state toggles.** Scoped `state/`–`priority/`–`attention/`–`spec/`
65
+ selectors that rely on Gitea-family native exclusive auto-eviction (apply the new
66
+ label; the old one in the same scope evicts itself — zero `--remove-label`
67
+ calls).
68
+ 4. **Quick comments from the client.** Operator steering posted straight into
69
+ the issue thread without leaving Paseo.
70
+ 5. **Token-in-settings auth.** The daemon stores a per-host API token in
71
+ plugin settings; the client never sees it and no host CLI/dotfile is
72
+ required.
73
+ 6. **Deep links.** Agent IDs resolve to `paseo://h/<serverId>/agent/<agentId>`
74
+ sessions; commit SHAs resolve to local `git log` / web viewer.
75
+
76
+ ### Non-goals
77
+
78
+ - No new Paseo SDK surface (`initClientHelpers` four-field shape unchanged;
79
+ `registerComposerPill` / `registerSidebarSurface` used as documented).
80
+ - No push-to-composer (0.8 SDK exposes no composer-insert API — same
81
+ limitation the current issues modal already documents).
82
+ - No actionable toasts (see the
83
+ [toast-to-approval spec](./toast-to-approval.md) for the split-surface
84
+ approval pattern if a signoff flow is needed later).
85
+ - No plugin-specific logic in `paseo-plugin-helper` — any generally reusable
86
+ parsing (envelope regex, priority-rank comparator) ships in the helper only
87
+ if a second consumer needs it; until then it lives in `plugins/forges`.
88
+ - No implementation in this spec phase — schemas, method names, and layouts
89
+ only.
90
+
91
+ ---
92
+
93
+ ## 3. Authentication & data path
94
+
95
+ ### 3.1 Token source (daemon-side plugin settings)
96
+
97
+ All forge access goes through the embedded TypeScript client
98
+ (`server/forge-client.ts`), which speaks the Gitea-family `/api/v1` over
99
+ `fetch` with a 15 s abort timeout. There is no subprocess and no host
100
+ CLI/dotfile dependency: the plugin works on a machine that has never had
101
+ `fgj`/`fgjx` or `~/.config/fgj`.
102
+
103
+ The token lives in daemon-side plugin settings, keyed by host
104
+ (`ForgeSettings.tokensByHost`), and is read by `tokenForHost(host)` in
105
+ `server/settings.ts`:
106
+
107
+ 1. Resolve repo coordinates without shelling: read `<directory>/.git/config`
108
+ directly and parse the `origin` URL with `parseForgeRemote` (handles
109
+ `git@host:owner/repo`, `https://host/owner/repo`, `ssh://git@host/...`);
110
+ an explicit per-workspace forge target wins absolutely (issue #109/#137).
111
+ 2. Construct `new ForgeClient({ host, token: await tokenForHost(host) })`.
112
+ The token is attached only as an `Authorization: token <t>` header on
113
+ `fetch` calls that run in the daemon; it is never serialized into an RPC
114
+ payload and never reaches the client.
115
+ 3. Endpoints: `GET /repos/{owner}/{repo}/issues` (list),
116
+ `GET /repos/{owner}/{repo}/issues/{n}` + `/comments` (detail/labels),
117
+ `PATCH /repos/{owner}/{repo}/issues/{n}` (label writes), and
118
+ `POST /repos/{owner}/{repo}/issues/{n}/comments` (comments). Repo labels
119
+ (`GET`/`POST/DELETE /repos/{owner}/{repo}/labels`) back the optional label
120
+ install (§5.5).
121
+ 4. Handlers never throw: failures return a typed `error` field and the client
122
+ renders `EmptyState` + Retry (per `docs/surfaces.md` — data absent with a
123
+ live source renders the empty state, never a crash). Unauthenticated public
124
+ repos stay readable; writes require an accepted, write-scoped token on both
125
+ public and private repos (issues #152, #193). Edit capability is derived
126
+ from the repo response's permission object (`forgeCapabilityFromRepo`:
127
+ Forgejo/GitHub `permissions.push`/`admin`, GitLab `access_level >= 30`),
128
+ with bare token validity as the fallback when a host returns none. The
129
+ minimum Forgejo/Gitea scopes are `read:user`, `read:repository`, and
130
+ `write:issue`; a valid token without write scope surfaces as
131
+ **"token lacks write scope"** (`auth: "lacks-write-scope"`), never as
132
+ edits-enabled.
133
+
134
+ Why embedded fetch over a subprocess: the plugin needs no preinstalled CLI or
135
+ credential store, auth is explicit and daemon-scoped, and the API surface is
136
+ versioned with the plugin instead of tracking a host binary.
137
+
138
+ ### 3.2 Caching & polling
139
+
140
+ - There is no daemon-side board cache: reads are served live from the API.
141
+ - Client-side: `useRpcQuery` for reads with a 30 s `refetchInterval` (the
142
+ issues list), plus a manual Refresh; the settings form queries
143
+ `forge.forge-context` independently so the token/host fields render while
144
+ issues load or fail (regression #152). Writes go through `useRpcMutation`
145
+ and refetch the board/detail on success.
146
+ - Handlers stay cheap because each query is one or two API calls; a failed
147
+ list is logged once per host/repo, then demoted to debug to avoid log spam.
148
+
149
+ ---
150
+
151
+ ## 4. Data models
152
+
153
+ All schemas are Zod, defined in `plugins/forges/shared/` (importable by both
154
+ server and client), built with `defineContract` from
155
+ `paseo-plugin-helper/shared`.
156
+
157
+ ### 4.1 Scoped label vocabularies (fallback + display data)
158
+
159
+ Gitea-family scoped labels are exclusive: applying one label in a scope evicts the
160
+ previous label in that scope at the DB level. The board is the source of truth
161
+ for which scopes exist (decision #121.2): the client derives live scopes from
162
+ the labels actually present on the returned issues (`liveScopesFromIssues`),
163
+ and the canonical lists below are used only as fallback vocabulary and for
164
+ display aliases. On a write, `handleSetLabel` sends the new label plus an
165
+ explicit removal of any existing label in the same scope, so the result is
166
+ correct even for boards whose scope names differ from ours. Canonical scopes
167
+ and ranks:
168
+
169
+ ```ts
170
+ export const StateRank = {
171
+ "state/0-triage": 0,
172
+ "state/1-wip": 1,
173
+ "state/2-review": 2,
174
+ "state/3-verify": 3,
175
+ "state/4-done": 4,
176
+ } as const;
177
+
178
+ export const PriorityRank = {
179
+ "priority/0-SOS": 0,
180
+ "priority/1-high": 1,
181
+ "priority/2-normal": 2,
182
+ "priority/3-low": 3,
183
+ "priority/4-backburner": 4,
184
+ } as const;
185
+
186
+ export const AttentionScope = [
187
+ "attention/0-orchestrator",
188
+ "attention/1-agent",
189
+ "attention/2-user",
190
+ "attention/3-ignore",
191
+ ] as const;
192
+
193
+ export const SpecScope = [
194
+ "spec/0-needed",
195
+ "spec/1-checklist",
196
+ "spec/2-approved",
197
+ ] as const;
198
+ ```
199
+
200
+ > [!NOTE]
201
+ > Label-name discrepancy: the issue body §2 cites shorthand names
202
+ > (`state/wip`, `state/ready-for-review`, `state/verify`,
203
+ > `state/confirmed-done`, `attention/0-agent`, `attention/1-user`). The live
204
+ > board (verified via API at spec time) uses the numbered forms above
205
+ > (`state/1-wip`, `state/2-review`, `state/3-verify`, `state/4-done`;
206
+ > `attention/0-orchestrator` … `attention/3-ignore`). This spec norms on the
207
+ > live names; the client renders short display aliases (`WIP`, `Review`,
208
+ > `Verify`, `Done`) so the UI stays compact.
209
+
210
+ ### 4.2 Board item
211
+
212
+ ```ts
213
+ export const BoardIssueSchema = z.object({
214
+ number: z.number(),
215
+ title: z.string(),
216
+ state: z.string(),
217
+ labels: z.array(z.string()),
218
+ priorityRank: z.number().int().min(0).max(4),
219
+ stateRank: z.number().int().min(0).max(4).nullable(),
220
+ attention: z.string().nullable().default(null),
221
+ updatedAt: z.string().optional(),
222
+ commentCount: z.number().int().nonnegative().default(0),
223
+ });
224
+ export type BoardIssue = z.infer<typeof BoardIssueSchema>;
225
+ ```
226
+
227
+ - Issues carrying **no** `priority/*` label sort as `priority/2-normal`
228
+ (rank 2); issues with no `state/*` label carry `stateRank: null` and sort
229
+ after ranked states within the same priority band.
230
+ - Closed issues are excluded from the overview (the `state/4-done` filter
231
+ shows open issues labeled done; true closed state is a separate query
232
+ flag — see `includeClosed` in §5.1).
233
+
234
+ ### 4.3 Sort tuple
235
+
236
+ The overview is sorted by the deterministic tuple
237
+ `(priorityRank, stateRank ?? 99, updatedAt desc)`:
238
+
239
+ 1. `priorityRank` ascending (`0-SOS` preempts everything).
240
+ 2. `stateRank` ascending within a band — `3-verify` (needs a human) surfaces
241
+ above `1-wip` (already owned), so operator attention lands where it
242
+ unblocks work. `null` ranks last.
243
+ 3. `updatedAt` descending (most recently active first) as the final tiebreak.
244
+
245
+ `size/*` (`0-cheap` … `3-chunk`) and `dep/blocker` are exposed on the card as
246
+ badges but do **not** enter the sort key in v1 (open question §10.3).
247
+
248
+ ### 4.4 Agent Envelope (parsed telemetry)
249
+
250
+ Every agent comment ends with the envelope footer stamped by the team's
251
+ issue-comment tool (see the coding-agent example skill):
252
+
253
+ ```markdown
254
+ ---
255
+ <sub>🤖 **<SessionTitle>** (`<shortId>`) · `<model>` · `<repo>:<branch>` · _<UTC timestamp>_</sub>
256
+ ```
257
+
258
+ Verified live example (`#77`):
259
+
260
+ ```markdown
261
+ <sub>🤖 **Update legacy react-native specifier to client** (`d705b95`) · `muse-spark-1.3-contributor` · `paseo:main` · _2026-09-12 12:13 UTC_</sub>
262
+ ```
263
+
264
+ Parsed schema (server-side, pure function, unit-tested):
265
+
266
+ ```ts
267
+ export const AgentEnvelopeSchema = z.object({
268
+ commentId: z.number(),
269
+ sessionTitle: z.string(),
270
+ agentShortId: z.string(),
271
+ model: z.string().nullable().default(null),
272
+ repo: z.string().nullable().default(null),
273
+ branch: z.string().nullable().default(null),
274
+ postedAt: z.string().nullable().default(null),
275
+ commitShas: z.array(z.string().regex(/^[0-9a-f]{7,40}$/)).default([]),
276
+ paseoLinks: z.array(z.string().url().or(z.string().startsWith("paseo://"))).default([]),
277
+ serverId: z.string().nullable().default(null),
278
+ });
279
+ export type AgentEnvelope = z.infer<typeof AgentEnvelopeSchema>;
280
+ ```
281
+
282
+ - `commitShas`: full 40-char and short 7+ char hex hashes found in the comment
283
+ body (code-fenced blocks included — agents report SHAs in both prose and
284
+ `commit:` lines).
285
+ - `paseoLinks`: `paseo://h/<serverId>/agent/<agentId>` deep links found in
286
+ the body; `serverId` is extracted from the first one when present.
287
+ - Unparseable footers are skipped (the comment still renders as plain
288
+ markdown); envelope parsing never fails the detail RPC.
289
+
290
+ ### 4.5 Issue detail
291
+
292
+ ```ts
293
+ export const IssueCommentSchema = z.object({
294
+ id: z.number(),
295
+ author: z.string(),
296
+ createdAt: z.string(),
297
+ updatedAt: z.string(),
298
+ body: z.string(),
299
+ envelope: AgentEnvelopeSchema.nullable().default(null),
300
+ });
301
+
302
+ export const IssueDetailSchema = z.object({
303
+ number: z.number(),
304
+ title: z.string(),
305
+ state: z.string(),
306
+ labels: z.array(z.string()),
307
+ body: z.string(),
308
+ author: z.string(),
309
+ createdAt: z.string(),
310
+ updatedAt: z.string(),
311
+ webUrl: z.string().url(),
312
+ comments: z.array(IssueCommentSchema),
313
+ envelopes: z.array(AgentEnvelopeSchema),
314
+ });
315
+ ```
316
+
317
+ ---
318
+
319
+ ## 5. Server RPC interface
320
+
321
+ Four contracts, namespaced `forge.*`, registered on the daemon
322
+ `PluginContext` next to the existing `forge.open-issues` handler (which
323
+ stays untouched for backward compatibility — the pill keeps working during
324
+ migration).
325
+
326
+ ```ts
327
+ import { z } from "zod";
328
+ import { defineContract } from "paseo-plugin-helper/shared";
329
+ ```
330
+
331
+ ### 5.1 `forge.board-overview` (read)
332
+
333
+ ```ts
334
+ export const boardOverviewContract = defineContract({
335
+ name: "forge.board-overview",
336
+ description: "Open issues sorted by the (priority, state, recency) tuple",
337
+ input: z.object({
338
+ directory: z.string().optional(),
339
+ stateFilter: z.enum(["all", "state/0-triage", "state/1-wip",
340
+ "state/2-review", "state/3-verify", "state/4-done"]).default("all"),
341
+ priorityFilter: z.enum(["all", "priority/0-SOS", "priority/1-high",
342
+ "priority/2-normal", "priority/3-low",
343
+ "priority/4-backburner"]).default("all"),
344
+ query: z.string().max(200).default(""),
345
+ includeClosed: z.boolean().default(false),
346
+ limit: z.number().int().min(1).max(200).default(100),
347
+ }),
348
+ output: z.object({
349
+ repo: z.string().nullable(),
350
+ issues: z.array(BoardIssueSchema),
351
+ verifyCount: z.number().int().nonnegative(),
352
+ wipCount: z.number().int().nonnegative(),
353
+ fetchedAt: z.string().datetime(),
354
+ error: z.string().optional(),
355
+ }),
356
+ });
357
+ ```
358
+
359
+ - Filtering is server-side (single fetch, cheap in-memory partition) so the
360
+ pill's `verifyCount` and the dashboard always agree — same predicates, same
361
+ data (per `docs/surfaces.md`).
362
+ - `query` matches issue number (`"56"` / `"#56"`), title substring, or label
363
+ substring (case-insensitive) — the same matcher the current modal uses
364
+ client-side, moved server-side so sidebar and modal share it.
365
+ - `verifyCount` = open issues with `state/3-verify`; `wipCount` = open issues
366
+ with `state/1-wip`. Returned on every call so the pill never needs a second
367
+ round-trip.
368
+
369
+ ### 5.2 `forge.issue-detail` (read)
370
+
371
+ ```ts
372
+ export const issueDetailContract = defineContract({
373
+ name: "forge.issue-detail",
374
+ description: "Full body, comments, and parsed Agent Envelopes for one issue",
375
+ input: z.object({
376
+ directory: z.string().optional(),
377
+ number: z.number().int().positive(),
378
+ }),
379
+ output: z.object({
380
+ repo: z.string().nullable(),
381
+ issue: IssueDetailSchema.nullable(),
382
+ fetchedAt: z.string().datetime(),
383
+ error: z.string().optional(),
384
+ }),
385
+ });
386
+ ```
387
+
388
+ - Unknown number → `{ issue: null, error: "Issue #N not found in <repo>" }`
389
+ (typed null, never a throw — the modal renders `EmptyState`).
390
+ - Envelopes are parsed server-side (Node regex) so the client receives
391
+ structured cards with zero parsing logic.
392
+
393
+ ### 5.3 `forge.set-label` (write)
394
+
395
+ ```ts
396
+ export const setLabelContract = defineContract({
397
+ name: "forge.set-label",
398
+ description: "Apply one scoped label; an exclusive scope evicts the rest",
399
+ input: z.object({
400
+ directory: z.string().optional(),
401
+ number: z.number().int().positive(),
402
+ label: z.string().min(1),
403
+ }),
404
+ output: z.object({
405
+ number: z.number(),
406
+ labels: z.array(z.string()),
407
+ error: z.string().optional(),
408
+ }),
409
+ });
410
+ ```
411
+
412
+ - Implemented by fetching the issue, computing the current label list, and
413
+ `PATCH`ing the issue with the new label added plus any same-scope mate
414
+ removed (the embedded client's `setLabels`). Gitea-family exclusivity alone would
415
+ also evict a scope mate, but the explicit remove keeps the write correct on
416
+ boards with non-canonical scope names.
417
+ - `label` is validated against the live board scopes (fallback: the known
418
+ vocabularies in §4.1) before any API call; unknown scopes return `{ error }`
419
+ without touching the API.
420
+ - On success returns the fresh label list and refetches the board/detail
421
+ client-side.
422
+ - Input accepts `issueNumber` (primary) with `number` as a deprecated alias;
423
+ handlers normalize via `normalizeIssueNumber`.
424
+
425
+ ### 5.4 `forge.add-comment` (write)
426
+
427
+ ```ts
428
+ export const addCommentContract = defineContract({
429
+ name: "forge.add-comment",
430
+ description: "Post a quick comment (or steering note) to the issue thread",
431
+ input: z.object({
432
+ directory: z.string().optional(),
433
+ number: z.number().int().positive(),
434
+ body: z.string().min(1).max(10000),
435
+ }),
436
+ output: z.object({
437
+ number: z.number(),
438
+ commentId: z.number().nullable(),
439
+ error: z.string().optional(),
440
+ }),
441
+ });
442
+ ```
443
+
444
+ - Implemented with `POST /repos/{owner}/{repo}/issues/{n}/comments` via the
445
+ embedded client (`addComment`). The plugin does **not** append an agent
446
+ envelope — quick comments are operator steering, stamped with the sender's
447
+ identity by the API.
448
+ - Empty/whitespace-only bodies are rejected client-side (button disabled) and
449
+ server-side (`min(1)` after trim).
450
+ - Input accepts `issueNumber` (primary) with `number` as a deprecated alias.
451
+
452
+ ### 5.5 `forge.install-labels` (operator-only, not shipped)
453
+
454
+ > Operator-gated, not registered: the optional label-set install is kept in the
455
+ > codebase for our own board but excluded from the release surface (issue #163).
456
+ > `index.server.ts` deliberately omits `handleInstallLabels`, so the RPC is not
457
+ > reachable by end users, and the Settings card does not render. The contract,
458
+ > handler, and planner below remain as the internals.
459
+
460
+ ```ts
461
+ export const installLabelsContract = defineContract({
462
+ name: "forge.install-labels",
463
+ description: "Copy the Paseo label taxonomy onto the configured forge repo after an explicit user choice",
464
+ input: z.object({
465
+ directory: z.string().optional(),
466
+ remoteUrl: z.string().optional(),
467
+ mode: z.enum(["merge", "replace"]),
468
+ }),
469
+ output: z.object({
470
+ host: z.string().nullable(),
471
+ repo: z.string().nullable(),
472
+ mode: z.enum(["merge", "replace"]).nullable(),
473
+ created: z.array(z.string()),
474
+ skipped: z.array(z.string()),
475
+ removed: z.array(z.string()),
476
+ error: z.string().optional(),
477
+ }),
478
+ });
479
+ ```
480
+
481
+ - Keeps our taxonomy as installable data (`paseoLabelSet()`): `state/`,
482
+ `priority/`, `attention/`, `spec/`. The install is **never automatic** — the
483
+ pre-gate client required an explicit action plus a keep/replace choice, and
484
+ the server only writes after resolving an explicit forge target.
485
+ - `mode` is required with no default so the choice can never be implicit.
486
+ `merge` only creates missing labels. `replace` additionally deletes the
487
+ target's labels that share a scope with our set but are not in it (e.g. a
488
+ foreign `state/ready-for-review`); labels in other scopes and unscoped
489
+ labels are never touched. See `planLabelSetInstall` in `shared/issues.ts`.
490
+ - Requires a saved daemon-side token; a missing token fails closed
491
+ (`{ error }`) without any write. Results are reported as created/removed/
492
+ skipped name lists.
493
+
494
+ ---
495
+
496
+ ## 6. Client UI surfaces
497
+
498
+ All components use only documented helper primitives. Entry point calls
499
+ `initClientHelpers({ Icon, Modal, useRpc, useToast })` once (unchanged).
500
+
501
+ ### 6.1 Status count pill (composer trackbar)
502
+
503
+ Extends the existing `forges-issues` pill; no second pill:
504
+
505
+ - Wide label: `"3 verify · 12 open"` (verify-first — the operator's most
506
+ valuable glance). Compact label: `"3v"`.
507
+ - Data: `useRpcQuery(boardOverviewContract, { directory })`, reusing the
508
+ existing 15 s label poll / 30 s query poll cadence.
509
+ - Visibility rule (per `docs/surfaces.md`): `verifyCount > 0` → label as
510
+ above; zero verify but live source → `"12 open"`; source dead/unknown →
511
+ existing `"issues --"` placeholder. Never a `0 verify` chip.
512
+ - Tap opens the Queue Dashboard modal (§6.2) pre-filtered to
513
+ `state/3-verify` when `verifyCount > 0`, else unfiltered.
514
+
515
+ ### 6.2 Queue Dashboard (modal + sidebar)
516
+
517
+ Registered **twice** from one shared component tree:
518
+
519
+ - Modal: `registerComposerPill(..., { renderModal: (props) =>
520
+ <ForgeBoardModal {...props} /> })` — replaces the current
521
+ `ForgeIssuesModal` list tab, keeping `SearchInput` + copy-ref behavior.
522
+ - Sidebar: `registerSidebarSurface(plugin, { id: "forge-board",
523
+ title: "Board", icon: "KanbanSquare", Component: ForgeBoardSurface })` —
524
+ full-height surface for sustained triage (desktop split-pane friendly).
525
+
526
+ Layout (shared `<BoardView>` used by both, responsive via `useResponsive()`):
527
+
528
+ ```
529
+ ┌──────────────────────────────────────────────┐
530
+ │ Card.Header: "Board · owner/repo" │
531
+ │ badge: "3 verify" (warning dot) / hidden │
532
+ ├──────────────────────────────────────────────┤
533
+ │ Tabs: [Queue] [Verify n] [WIP n] [Search] │
534
+ │ shortLabels: Queue/Verify/WIP/Search │
535
+ ├──────────────────────────────────────────────┤
536
+ │ (Search tab) SearchInput "Filter #number…" │
537
+ │ Priority filter row: [SOS][High][Normal]… │
538
+ │ (ghost Buttons, single-select, All reset) │
539
+ ├──────────────────────────────────────────────┤
540
+ │ DataTable (desktop) / card list (compact): │
541
+ │ #N │ Title + label Badges │ 💬n │ updated │
542
+ │ via <Responsive desktop card-list> │
543
+ ├──────────────────────────────────────────────┤
544
+ │ ActionBar: [Refresh secondary] [Close ghost] │
545
+ │ (modal only; sidebar omits Close) │
546
+ └──────────────────────────────────────────────┘
547
+ ```
548
+
549
+ - Rows: `DataTable` with `keyExtractor={(i) => String(i.number)}` on desktop;
550
+ automatic card-list reflow on compact (built into `DataTable`). Row tap →
551
+ opens Issue Inspection (§6.3) with `{ number }` payload. Copy-ref button
552
+ per row (existing `issueMarkdownRef` behavior preserved).
553
+ - State/priority badges: `Badge` per label (`priority/0-SOS` → `danger`,
554
+ `state/3-verify` → `warning`, `state/1-wip` → `info`, rest `neutral`).
555
+ - Filters are `Tabs` (state scope) + single-select ghost `Button` row
556
+ (priority scope) + `SearchInput` (query) — all three map 1:1 onto the
557
+ `board-overview` input, so sidebar and modal can never disagree.
558
+ - Empty states: `EmptyState` (`"CheckCircle2"` / `"No open issues"`;
559
+ `"Search"` / `"No matches"` with query echo) — same copy as today.
560
+
561
+ ### 6.3 Issue Inspection modal
562
+
563
+ `renderModal` payload `{ number }` (from board row tap or pill shortcut).
564
+ `useAutoRefreshQuery(issueDetailContract, { directory, number },
565
+ { defaultRate: "30s", isOpen })`.
566
+
567
+ ```
568
+ ┌──────────────────────────────────────────────┐
569
+ │ Card.Header: "#56 Title…" (truncate helper) │
570
+ │ badges: state Badge + priority Badge │
571
+ ├──────────────────────────────────────────────┤
572
+ │ Tabs: [Overview] [Comments n] [Envelopes m] │
573
+ │ [Labels] │
574
+ ├─ Overview ───────────────────────────────────┤
575
+ │ KeyValueGroup(2): Author │ Updated │ State… │
576
+ │ Markdown body (RN text; fenced blocks via │
577
+ │ CodeBlock w/ copy; issue URLs linkified │
578
+ │ via extractForgeIssueUrls) │
579
+ │ Envelopes preview: latest AgentEnvelopeCard │
580
+ ├─ Comments ───────────────────────────────────┤
581
+ │ Comment cards (author, timestamp, body) │
582
+ │ Quick-comment composer: TextInput (multiline)│
583
+ │ + Button "Post" (useRpcMutation add- │
584
+ │ comment; disabled when empty/pending) │
585
+ ├─ Envelopes ──────────────────────────────────┤
586
+ │ AgentEnvelopeCard × m: │
587
+ │ Card + Card.Header (session title, agent │
588
+ │ ShortId Badge mono copyable, model │
589
+ │ Badge) │
590
+ │ KeyValue: Branch (copyable mono) │ Posted │
591
+ │ Commit SHAs: one CommandBox per SHA │
592
+ │ (copy button; tap → §7.2) │
593
+ │ paseo:// link Button "Open agent session" │
594
+ │ (ghost w/ ExternalLink icon; tap → §7.1) │
595
+ │ EmptyState "Cpu"/"No agent activity yet" │
596
+ │ when m = 0 │
597
+ ├─ Labels ─────────────────────────────────────┤
598
+ │ Scoped toggle groups (one row per scope): │
599
+ │ State: [Triage][WIP][Review][Verify]… │
600
+ │ Priority: [SOS][High][Normal][Low][Parked] │
601
+ │ Attention:[Orches.][Agent][User][Ignore] │
602
+ │ Spec: [Needed][Checklist][Approved] │
603
+ │ Active label per scope: primary Button; rest │
604
+ │ ghost. Tap → useRpcMutation set-label; │
605
+ │ isPending disables the group. │
606
+ │ Signoff row: Button "Post verify request" │
607
+ │ (add-comment w/ canned "Ready for human │
608
+ │ verification" template — operator opt-in) │
609
+ └──────────────────────────────────────────────┘
610
+ ```
611
+
612
+ - Markdown rendering: plain React Native `Text` + `CodeBlock` for fenced
613
+ sections + pressable link spans for issue URLs / `paseo://` / SHAs. No new
614
+ markdown dependency (zero native modules).
615
+ - Optimistic label UI: the tapped button shows `loading` until the mutation
616
+ settles, then query invalidation repaints the group from server truth — no
617
+ client-side label prediction (exclusivity edge cases stay server-side).
618
+ - The Labels tab is the Orchestrator Approval Panel's v1: state promotion
619
+ (`1-wip` → `2-review` → `3-verify`) and review-verdict comments cover the
620
+ Agent-vs-Operator handoff the issue asks for, without inventing a new
621
+ approval primitive (defer full signoff flow to the toast-to-approval
622
+ pattern if needed).
623
+
624
+ ---
625
+
626
+ ## 7. Deep linking & telemetry
627
+
628
+ ### 7.1 Agent session links (`paseo://h/<serverId>/agent/<agentId>`)
629
+
630
+ - Tap handler: `Linking.openURL("paseo://h/<serverId>/agent/<agentId>")`
631
+ (same `Linking` mechanism the current row-tap uses for `https://` URLs).
632
+ - Fallback: on failure, `copyToClipboard(link)` + toast (`"Session link
633
+ copied"`) so the operator can paste it into a connected client.
634
+ - Envelopes without a `paseo://` link (older comments, e.g. `#77`) render
635
+ the agent ShortId as a copyable mono `KeyValue` instead of a dead button —
636
+ surfaces rule: absent data with a live source renders the copyable value,
637
+ never a broken action.
638
+
639
+ ### 7.2 Commit SHA links
640
+
641
+ Tap on a SHA `CommandBox`:
642
+
643
+ 1. Preferred: open the repo web commit view
644
+ `https://<host>/<owner>/<repo>/commit/<sha>` via `Linking.openURL`
645
+ (coordinates already known from `resolveRepo`).
646
+ 2. Long-press (or secondary button): copy the full SHA to clipboard.
647
+ 3. v2 (Phase 3): `git -C <directory> show --stat <sha>` via a new
648
+ `forge.commit-stat` read contract — specified but not required for v1.
649
+
650
+ ### 7.3 Worktree / branch display
651
+
652
+ Branch names from envelopes render as copyable mono text (`CommandBox`
653
+ single-line variant). No checkout action in v1 — branch teleportation stays
654
+ with the agent harness (open question §10.4).
655
+
656
+ ---
657
+
658
+ ## 8. Lifecycle state machine (label transitions)
659
+
660
+ ```
661
+ set-label set-label
662
+ 0-triage ───────────────────▶ 1-wip ───────────────────▶ 2-review
663
+ │ │ │
664
+ │ attention/1-agent │ agent posts completion │ orchestrator approves /
665
+ │ (claim) │ envelope + sets │ requests changes
666
+ ▼ ▼ ▼
667
+ (operator triage) 2-review ◀────────────────── 1-wip (rework)
668
+
669
+ │ operator verifies / approves
670
+
671
+ 3-verify ── human closes ──▶ (closed)
672
+
673
+ │ changes requested
674
+
675
+ 1-wip
676
+ ```
677
+
678
+ Invariants:
679
+
680
+ 1. Client sends only `set-label` adds; Gitea-family exclusive scopes guarantee
681
+ single-occupancy per scope — the client never issues removes.
682
+ 2. Agents and the Orchestrator never close issues (coding-agent skill §6):
683
+ `state/4-done` is an open label; closing is the human operator's word.
684
+ 3. `flag/stop-work` short-circuits everything: when present, the Labels tab
685
+ disables all toggle groups and renders a `danger` banner (circuit breaker
686
+ is board-global, not per-transition).
687
+ 4. `attention/3-ignore` suppresses the issue in the default Queue tab
688
+ (server-side exclusion unless `query` matches — deterministic triage
689
+ parity), unless `priority/0-SOS` is also present (SOS outranks ignore).
690
+
691
+ ---
692
+
693
+ ## 9. Error handling matrix
694
+
695
+ | Situation | Behavior |
696
+ |---|---|
697
+ | No git remote / unparseable origin | `{ repo: null, error }` → `EmptyState` "No forge repo for this workspace" (existing behavior, kept) |
698
+ | API unreachable / no token for a private repo | `{ error: "Issue list unavailable" }` → `EmptyState` + Retry; pill falls back to `"issues --"` placeholder |
699
+ | Unknown issue number | `{ issue: null, error }` → `EmptyState` "Issue #N not found in repo" |
700
+ | `set-label` with out-of-vocabulary label | Rejected before spawn; `{ error }` surfaced via mutation `onError`; toggle group re-enables |
701
+ | `set-label` race (two operators, same scope) | Last write wins at the host; query invalidation repaints from server truth — no client prediction to unwind |
702
+ | `add-comment` empty body | Button disabled client-side; `min(1)` server-side rejects as typed error |
703
+ | `add-comment` failure (network/auth) | Mutation `onError` → toast; composer text preserved (never cleared on failure) |
704
+ | Envelope footer unparseable | Comment renders as plain markdown; `envelopes` omits it; detail RPC still succeeds |
705
+ | `paseo://` open fails (no handler) | Copy link to clipboard + toast; never a dead tap |
706
+ | Storage write fails (board cache) | Serve last good cache with stale `fetchedAt`; `Card.Header` subtitle shows "updated Xm ago" via `formatDuration` |
707
+
708
+ ---
709
+
710
+ ## 10. Phased roadmap
711
+
712
+ ### Phase 0 — This spec (done when merged)
713
+
714
+ Spec file + board review. Labels advance `spec/1-checklist` →
715
+ `spec/2-approved`, `state/1-wip` → `state/2-review` on the tracking issue.
716
+ No code touched.
717
+
718
+ ### Phase 1 — Architecture & data path (shipped)
719
+
720
+ - [x] `shared/issues.ts`: label vocabularies + ranks, issue/detail/envelope
721
+ schemas, envelope parser, sort-tuple comparator, contracts (§4–§5).
722
+ - [x] `server/forge-client.ts`: embedded Gitea-family `/api/v1` fetch client
723
+ (list/detail/comments/labels, repo probe, token probe).
724
+ - [x] `server/issues.ts` + `server/settings.ts`: repo resolution (`.git/config`
725
+ parse, explicit-target precedence), host-keyed daemon-side tokens,
726
+ `set-label` / `add-comment` / `install-labels` handlers. No subprocess.
727
+ - [x] Unit tests: envelope parser, sort tuple (SOS-first, verify-before-wip,
728
+ recency tiebreak), scope-vocabulary guard, link classification, label-set
729
+ planning.
730
+ - [x] Keep `forge.open-issues` contract; new contracts register alongside.
731
+
732
+ ### Phase 2 — Client UI surfaces
733
+
734
+ - [ ] `<BoardView>` shared tree (`Card`, `Tabs`, `SearchInput`, `DataTable`,
735
+ `Badge`, `EmptyState`, `ActionBar`) + `ForgeBoardModal` (replaces list
736
+ tab content) + `ForgeBoardSurface` via `registerSidebarSurface`.
737
+ - [ ] Pill upgrade: verify-first label (`"3 verify · 12 open"` / `"3v"`),
738
+ tap-through to pre-filtered dashboard.
739
+ - [ ] `<IssueDetailModal>` with four tabs + `<AgentEnvelopeCard>` +
740
+ `<ScopedLabelGroup>` + quick-comment composer (`TextInput` multiline +
741
+ `useRpcMutation`).
742
+ - [ ] Compact-portrait pass (`useResponsive` / `select`: table → cards,
743
+ full labels → `shortLabel`, `touchTargetMin` 44pt on toggles).
744
+
745
+ ### Phase 3 — Deep linking & telemetry
746
+
747
+ - [ ] `paseo://` tap-through with clipboard fallback (§7.1).
748
+ - [ ] Commit SHA → web commit view + long-press copy (§7.2).
749
+ - [ ] Optional `forge.commit-stat` read contract (`git show --stat`) if
750
+ operator review needs diff summaries in-client.
751
+
752
+ ### Phase 4 — Hardening & parity
753
+
754
+ - [ ] `flag/stop-work` banner + toggle-group disable; `attention/3-ignore`
755
+ exclusion rule (§8.4).
756
+ - [ ] Closed-app honesty: board cache renders instantly with "updated Xm ago"
757
+ subtitle while the 60 s refresh runs (mirrors the toast-to-approval
758
+ durable-queue principle).
759
+ - [ ] Gap suite: every new badge/count renders through shared predicates on
760
+ both pill and dashboard (per `docs/surfaces.md` rules for new metrics).
761
+
762
+ ### Open questions (implementation phase)
763
+
764
+ 1. Should the envelope parser / sort comparator move into
765
+ `paseo-plugin-helper/shared` for reuse by other plugins (e.g. an
766
+ Orchestrator dashboard)? Recommendation: keep in `plugins/forges` until
767
+ a second consumer exists.
768
+ 2. Should `forge.commit-stat` be part of v1? Recommendation: no — web-view
769
+ link + copy covers review; diff-in-client is Phase 3 stretch.
770
+ 3. Should `size/*` (effort) and `dep/blocker` enter the sort key?
771
+ Recommendation: badges only in v1; revisit after operator feedback.
772
+ 4. Branch teleportation (tap branch → open/attach workspace)? Recommendation:
773
+ explicitly out of scope — stays with the agent harness.
774
+
775
+ ---
776
+
777
+ ## 11. Testing plan
778
+
779
+ - **Server:** pure shared functions unit-tested directly (`node --test` via
780
+ `esbuild`); the fetch client is exercised through its parsing helpers.
781
+ Assert board sort order, live scope derivation, scope-vocabulary rejection,
782
+ label-set planning (`merge` vs `replace`), detail null-form for unknown
783
+ numbers, and comment/commentId passthrough.
784
+ - **Parser:** envelope footer fixtures (live `#77` footer, multi-SHA body,
785
+ `paseo://` body, malformed footer → skipped, no footer → `null`).
786
+ - **Client:** mock `useRpc` doubles. Assert pill label variants
787
+ (`"3 verify · 12 open"` / `"12 open"` / `"issues --"`), tab filters map to
788
+ contract input, row tap opens detail payload, label tap fires
789
+ `set-label { number, label }` with group disabled while pending, empty
790
+ comment disables Post, failed comment preserves composer text.
791
+ - **Surfaces:** pill and dashboard fed by the same mocked overview payload;
792
+ assert counts agree (gap-suite style, per `docs/surfaces.md`).
793
+
794
+ ---
795
+
796
+ ## 12. Acceptance criteria
797
+
798
+ - [ ] Board overview renders the open queue sorted by
799
+ `(priorityRank, stateRank, updatedAt desc)` with state/priority filters
800
+ and `#N`/title/label search — no browser needed for triage.
801
+ - [ ] Issue detail shows body, comment thread, and structured Agent Envelope
802
+ cards (agent id, model, branch, SHAs, `paseo://` link) in-client.
803
+ - [ ] Scoped label toggles advance `state/`–`priority/`–`attention/`–`spec/`
804
+ with single taps; the write adds the label and removes any same-scope
805
+ mate, and the UI repaints from server truth after each mutation.
806
+ - [ ] Quick comments post operator steering into the thread; composer text
807
+ survives failures.
808
+ - [ ] Auth uses a daemon-side token saved per host in plugin settings; the
809
+ token never reaches the client and no `fgj` config or host CLI is
810
+ required.
811
+ - [ ] Pill shows verify-first counts (`"3 verify · 12 open"` / `"3v"`) and
812
+ deep-opens the pre-filtered dashboard; zero-verify hides the verify
813
+ chip (never `0 verify`).
814
+ - [ ] Agent ID taps resolve to `paseo://` sessions (clipboard fallback);
815
+ SHA taps open the web commit view (long-press copies).
816
+ - [ ] Built only from documented helper primitives; `initClientHelpers`
817
+ shape unchanged; zero new SDK imports; client uses no Node
818
+ built-ins.
819
+ - [ ] `forge.open-issues` contract and current pill behavior preserved
820
+ throughout migration.