@xpufx/paseo-top 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/OVERVIEW.md +17 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +124 -7
  6. package/client/pill.tsx +724 -669
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +33 -0
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +310 -274
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +21 -61
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +118 -338
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/lifecycle/surface.tsx +140 -0
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +55 -47
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -227
  48. package/package.json +14 -2
  49. package/paseo-plugin.json +2 -0
  50. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  51. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  52. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  53. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  54. package/screenshots/paseo-top-dashboard.jpg +0 -0
  55. package/screenshots/paseo-top-general-view.jpg +0 -0
  56. package/server/resources.ts +98 -0
  57. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  58. package/server/vendor/paseo-plugin-helper/index.ts +4 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  61. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  62. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  63. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  64. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  65. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  66. package/server/vendor/paseo-plugin-helper/tickets.ts +1232 -0
  67. package/server/vendor/permission-audit/index.ts +1 -0
  68. package/server/vendor/permission-audit/server.ts +839 -0
  69. package/shared/helper-version.ts +20 -0
  70. package/shared/multi-host.ts +156 -155
  71. package/shared/resources.ts +146 -1
  72. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  73. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  74. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  75. package/shared/vendor/paseo-plugin-helper/index.ts +2 -0
  76. package/shared/vendor/paseo-plugin-helper/tickets.ts +1104 -0
  77. package/shared/vendor/permission-audit/filter.ts +61 -0
  78. package/shared/vendor/permission-audit/index.ts +2 -0
  79. package/shared/vendor/permission-audit/shared.ts +119 -0
  80. package/shared/version.ts +1 -1
  81. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  82. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  83. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -156
  84. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -184
  85. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -225
  86. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  87. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  88. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  89. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  90. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  91. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  92. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -75
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -118
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -80
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -215
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -25
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -224
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -29
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -378
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/surface.tsx +0 -80
  121. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  122. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  123. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  124. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  125. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  126. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  127. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -0,0 +1,1104 @@
1
+ import { z } from "zod";
2
+ import { defineContract } from "./rpc";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Label taxonomy & definitions (issues #122, #189, #200)
6
+ // ---------------------------------------------------------------------------
7
+
8
+ // Canonical taxonomy is numberless (platform#247). Readers accept the legacy
9
+ // numeric spellings too (dual-read) while the board migrates; `LEGACY_*` arrays
10
+ // and `LABEL_ALIASES` keep the two spellings interchangeable.
11
+ export const STATE_ORDER = [
12
+ "state/triage",
13
+ "state/wip",
14
+ "state/review",
15
+ "state/verify",
16
+ "state/done",
17
+ ] as const;
18
+ export type StateLabel = (typeof STATE_ORDER)[number];
19
+
20
+ export const LEGACY_STATE_ORDER = [
21
+ "state/0-triage",
22
+ "state/1-wip",
23
+ "state/2-review",
24
+ "state/3-verify",
25
+ "state/4-done",
26
+ ] as const;
27
+
28
+ export const PRIORITY_ORDER = [
29
+ "priority/sos",
30
+ "priority/high",
31
+ "priority/normal",
32
+ "priority/low",
33
+ "priority/backburner",
34
+ ] as const;
35
+ export type PriorityLabel = (typeof PRIORITY_ORDER)[number];
36
+
37
+ export const LEGACY_PRIORITY_ORDER = [
38
+ "priority/0-SOS",
39
+ "priority/1-high",
40
+ "priority/2-normal",
41
+ "priority/3-low",
42
+ "priority/4-backburner",
43
+ ] as const;
44
+
45
+ export const ATTENTION_LABELS = [
46
+ "attention/orchestrator",
47
+ "attention/agent",
48
+ "attention/user",
49
+ "attention/ignore",
50
+ ] as const;
51
+ export type AttentionLabel = (typeof ATTENTION_LABELS)[number];
52
+
53
+ export const LEGACY_ATTENTION_LABELS = [
54
+ "attention/0-orchestrator",
55
+ "attention/1-agent",
56
+ "attention/2-user",
57
+ "attention/3-ignore",
58
+ ] as const;
59
+
60
+ export const SPEC_LABELS = [
61
+ "spec/needed",
62
+ "spec/checklist",
63
+ "spec/approved",
64
+ ] as const;
65
+ export type SpecLabel = (typeof SPEC_LABELS)[number];
66
+
67
+ export const LEGACY_SPEC_LABELS = [
68
+ "spec/0-needed",
69
+ "spec/1-checklist",
70
+ "spec/2-approved",
71
+ ] as const;
72
+
73
+ function aliasPairs(
74
+ canonical: readonly string[],
75
+ legacy: readonly string[],
76
+ ): Record<string, string> {
77
+ const map: Record<string, string> = {};
78
+ canonical.forEach((name, i) => {
79
+ const legacyName = legacy[i];
80
+ if (legacyName) map[legacyName] = name;
81
+ });
82
+ return map;
83
+ }
84
+
85
+ /** Legacy numeric label -> canonical numberless label (all scopes). */
86
+ export const LABEL_ALIASES: Record<string, string> = {
87
+ ...aliasPairs(STATE_ORDER, LEGACY_STATE_ORDER),
88
+ ...aliasPairs(PRIORITY_ORDER, LEGACY_PRIORITY_ORDER),
89
+ ...aliasPairs(ATTENTION_LABELS, LEGACY_ATTENTION_LABELS),
90
+ ...aliasPairs(SPEC_LABELS, LEGACY_SPEC_LABELS),
91
+ "review/0-needed": "review/needed",
92
+ "review/1-changes-requested": "review/changes-requested",
93
+ "review/2-approved": "review/approved",
94
+ "format/0-needed": "format/needed",
95
+ "format/1-ok": "format/ok",
96
+ "size/0-cheap": "size/cheap",
97
+ "size/1-medium": "size/medium",
98
+ "size/2-expensive": "size/expensive",
99
+ "size/3-chunk": "size/chunk",
100
+ "linked/0-needs-split": "linked/needs-split",
101
+ "linked/1-peer": "linked/peer",
102
+ "linked/2-done": "linked/done",
103
+ "upstream/0-explore": "upstream/explore",
104
+ "upstream/1-blocked": "upstream/blocked",
105
+ "upstream/2-aligned": "upstream/aligned",
106
+ };
107
+
108
+ /** Canonical numberless spelling of a label; unknown labels pass through. */
109
+ export function canonicalLabel(label: string): string {
110
+ return LABEL_ALIASES[label] ?? label;
111
+ }
112
+
113
+ /** True when two spellings name the same scoped label under the alias map. */
114
+ export function sameLabel(a: string, b: string): boolean {
115
+ return canonicalLabel(a) === canonicalLabel(b);
116
+ }
117
+
118
+ const CANONICAL_SHORT: Record<string, string> = {
119
+ "state/triage": "Triage",
120
+ "state/wip": "WIP",
121
+ "state/review": "Review",
122
+ "state/verify": "Verify",
123
+ "state/done": "Done",
124
+ "priority/sos": "SOS",
125
+ "priority/high": "High",
126
+ "priority/normal": "Normal",
127
+ "priority/low": "Low",
128
+ "priority/backburner": "Parked",
129
+ };
130
+
131
+ /** Compact display alias for a scoped label ("state/1-wip" -> "WIP"). */
132
+ export function shortLabelName(label: string): string {
133
+ return CANONICAL_SHORT[canonicalLabel(label)] ?? label;
134
+ }
135
+
136
+ /** The issue's current `state/*` label (canonical), or null when it carries none. */
137
+ export function currentStateLabel(labels: string[]): string | null {
138
+ for (const label of labels) {
139
+ const canonical = canonicalLabel(label);
140
+ if ((STATE_ORDER as readonly string[]).includes(canonical)) return canonical;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** The issue's current `priority/*` label, defaulting to normal per spec §4.2. */
146
+ export function currentPriorityLabel(labels: string[]): string {
147
+ for (const label of labels) {
148
+ const canonical = canonicalLabel(label);
149
+ if ((PRIORITY_ORDER as readonly string[]).includes(canonical)) return canonical;
150
+ }
151
+ return "priority/normal";
152
+ }
153
+
154
+ /** Next `state/*` promotion step, or null when already done. */
155
+ export function nextStateLabel(labels: string[]): string | null {
156
+ const current = currentStateLabel(labels);
157
+ if (!current) return "state/wip";
158
+ const idx = (STATE_ORDER as readonly string[]).indexOf(current);
159
+ if (idx < 0 || idx + 1 >= STATE_ORDER.length) return null;
160
+ return STATE_ORDER[idx + 1];
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Schemas & Types
165
+ // ---------------------------------------------------------------------------
166
+
167
+ export const ForgeLabelSchema = z.object({
168
+ name: z.string(),
169
+ color: z.string().optional(),
170
+ description: z.string().optional(),
171
+ });
172
+ export type ForgeLabel = z.infer<typeof ForgeLabelSchema>;
173
+
174
+ export const ForgeIssueSchema = z.object({
175
+ number: z.number(),
176
+ title: z.string(),
177
+ state: z.string(),
178
+ labels: z.array(z.string()),
179
+ labelDetails: z.array(ForgeLabelSchema).default([]),
180
+ comments: z.number().int().nonnegative().default(0),
181
+ updatedAt: z.string().optional(),
182
+ createdAt: z.string().optional(),
183
+ author: z.string().optional(),
184
+ url: z.string().optional(),
185
+ body: z.string().optional(),
186
+ remoteUrl: z.string().optional(),
187
+ repo: z.string().optional(),
188
+ branch: z.string().optional(),
189
+ });
190
+ export type ForgeIssue = z.infer<typeof ForgeIssueSchema>;
191
+
192
+ export const AgentEnvelopeSchema = z.object({
193
+ commentId: z.number(),
194
+ sessionTitle: z.string(),
195
+ agentShortId: z.string(),
196
+ model: z.string().nullable().default(null),
197
+ repo: z.string().nullable().default(null),
198
+ branch: z.string().nullable().default(null),
199
+ postedAt: z.string().nullable().default(null),
200
+ commitShas: z.array(z.string().regex(/^[0-9a-f]{7,40}$/)).default([]),
201
+ paseoLinks: z.array(z.string()).default([]),
202
+ serverId: z.string().nullable().default(null),
203
+ });
204
+ export type AgentEnvelope = z.infer<typeof AgentEnvelopeSchema>;
205
+
206
+ export const IssueCommentSchema = z.object({
207
+ id: z.number(),
208
+ author: z.string(),
209
+ createdAt: z.string(),
210
+ updatedAt: z.string(),
211
+ body: z.string(),
212
+ url: z.string(),
213
+ envelope: AgentEnvelopeSchema.nullable().default(null),
214
+ });
215
+ export type IssueComment = z.infer<typeof IssueCommentSchema>;
216
+
217
+ export const IssueDetailSchema = z.object({
218
+ number: z.number(),
219
+ title: z.string(),
220
+ state: z.string(),
221
+ labels: z.array(z.string()),
222
+ labelDetails: z.array(ForgeLabelSchema).default([]),
223
+ body: z.string(),
224
+ author: z.string(),
225
+ createdAt: z.string(),
226
+ updatedAt: z.string(),
227
+ webUrl: z.string(),
228
+ comments: z.array(IssueCommentSchema),
229
+ envelopes: z.array(AgentEnvelopeSchema),
230
+ });
231
+ export type IssueDetail = z.infer<typeof IssueDetailSchema>;
232
+
233
+ export interface ForgeRepoIdentity {
234
+ host: string;
235
+ owner: string;
236
+ repo: string;
237
+ }
238
+
239
+ export type ForgeVisibility = "public" | "private" | "unknown";
240
+ export type ForgeAuthState =
241
+ | "authenticated"
242
+ | "lacks-write-scope"
243
+ | "invalid-token"
244
+ | "anonymous"
245
+ | "unknown";
246
+
247
+ export interface ForgeAccessState {
248
+ visibility: ForgeVisibility;
249
+ auth: ForgeAuthState;
250
+ canEdit: boolean;
251
+ visibilityLabel: string | null;
252
+ authLabel: string;
253
+ authIcon: string;
254
+ authVariant: "success" | "warning" | "danger" | "neutral";
255
+ summary: string;
256
+ requiredScopes: string;
257
+ }
258
+
259
+ export interface ForgeAccessInput {
260
+ repoPublic?: boolean | null;
261
+ tokenPresent?: boolean | null;
262
+ tokenValid?: boolean | null;
263
+ repoWritePermission?: boolean | null;
264
+ }
265
+
266
+ export function forgeWriteScopeList(): string {
267
+ return "repo, write:issue (or issues:write)";
268
+ }
269
+
270
+ export function deriveForgeAccess(input: ForgeAccessInput = {}): ForgeAccessState {
271
+ const visibility: ForgeVisibility =
272
+ input.repoPublic === true
273
+ ? "public"
274
+ : input.repoPublic === false
275
+ ? "private"
276
+ : "unknown";
277
+
278
+ let auth: ForgeAuthState;
279
+ if (input.tokenValid === true) {
280
+ auth = input.repoWritePermission === false ? "lacks-write-scope" : "authenticated";
281
+ } else if (input.tokenPresent !== true) auth = "anonymous";
282
+ else if (input.tokenValid === false) auth = "invalid-token";
283
+ else auth = "unknown";
284
+
285
+ const canEdit = auth === "authenticated";
286
+ const visibilityLabel = visibility === "unknown" ? null : visibility;
287
+
288
+ let authLabel: string;
289
+ let authIcon: string;
290
+ let authVariant: ForgeAccessState["authVariant"];
291
+ if (auth === "authenticated") {
292
+ authLabel = "Authenticated";
293
+ authIcon = "KeyRound";
294
+ authVariant = "success";
295
+ } else if (auth === "lacks-write-scope") {
296
+ authLabel = "Token lacks write scope";
297
+ authIcon = "ShieldAlert";
298
+ authVariant = "warning";
299
+ } else if (auth === "invalid-token") {
300
+ authLabel = "Token rejected";
301
+ authIcon = "AlertTriangle";
302
+ authVariant = "danger";
303
+ } else if (auth === "anonymous") {
304
+ authLabel = "No token";
305
+ authIcon = "User";
306
+ authVariant = "neutral";
307
+ } else {
308
+ authLabel = "Token unverified";
309
+ authIcon = "AlertCircle";
310
+ authVariant = "warning";
311
+ }
312
+
313
+ const scopeHint = `required scopes: ${forgeWriteScopeList()}.`;
314
+ const authClause =
315
+ auth === "authenticated"
316
+ ? "Token accepted with write scope — reads and edits enabled."
317
+ : auth === "lacks-write-scope"
318
+ ? `Token accepted but it cannot push — edits disabled; ${scopeHint}`
319
+ : auth === "invalid-token"
320
+ ? "Saved token was rejected — edits disabled."
321
+ : auth === "anonymous"
322
+ ? "No token saved — edits disabled."
323
+ : "Token state unverified — edits disabled.";
324
+
325
+ const summary =
326
+ visibility === "public"
327
+ ? `Public repo — anonymous reads work. ${authClause}`
328
+ : visibility === "private"
329
+ ? `Private repo — a valid token is required for reads. ${authClause}`
330
+ : `Repo visibility unknown (could not reach host). ${authClause}`;
331
+
332
+ return {
333
+ visibility,
334
+ auth,
335
+ canEdit,
336
+ visibilityLabel,
337
+ authLabel,
338
+ authIcon,
339
+ authVariant,
340
+ summary,
341
+ requiredScopes: forgeWriteScopeList(),
342
+ };
343
+ }
344
+
345
+ export function writeGateNotice(access: ForgeAccessState, capability: string): string {
346
+ if (access.auth === "lacks-write-scope") {
347
+ return `Read-only — this token cannot ${capability}; it lacks write scope. Add a token with ${access.requiredScopes}.`;
348
+ }
349
+ return `Read-only — ${capability} needs a valid token. ${access.summary}`;
350
+ }
351
+
352
+ const ENVELOPE_FOOTER_PATTERN =
353
+ /<sub>\s*🤖\s*\*\*(.+?)\*\*\s*\(`([^`)]+)`\)\s*·\s*`([^`]+)`\s*·\s*`([^`]+)`\s*·\s*_([^_]+)_\s*<\/sub>/;
354
+
355
+ export function stripAgentEnvelopeFooter(body: string | undefined | null): string {
356
+ if (!body || typeof body !== "string") return "";
357
+ return body.replace(ENVELOPE_FOOTER_PATTERN, "").replace(/---\s*$/, "").trim();
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Markdown-lite Parser
362
+ // ---------------------------------------------------------------------------
363
+
364
+ export type MarkdownLiteSpan =
365
+ | { kind: "text"; text: string }
366
+ | { kind: "bold"; text: string }
367
+ | { kind: "italic"; text: string }
368
+ | { kind: "code"; text: string }
369
+ | { kind: "link"; text: string; url: string };
370
+
371
+ export type MarkdownLiteBlock =
372
+ | { kind: "paragraph"; spans: MarkdownLiteSpan[] }
373
+ | { kind: "heading"; level: 1 | 2 | 3; spans: MarkdownLiteSpan[] }
374
+ | { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }
375
+ | { kind: "code"; text: string; language?: string };
376
+
377
+ const INLINE_PATTERN =
378
+ /(`[^`]+`)|(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))|(\*\*([^*]+)\*\*)|(__([^_]+)__)|(\*([^*]+)\*)|(_([^_]+)_)/g;
379
+
380
+ export function parseMarkdownLiteInline(text: string): MarkdownLiteSpan[] {
381
+ if (!text) return [];
382
+ const spans: MarkdownLiteSpan[] = [];
383
+ let cursor = 0;
384
+ INLINE_PATTERN.lastIndex = 0;
385
+ let match: RegExpExecArray | null;
386
+ while ((match = INLINE_PATTERN.exec(text)) !== null) {
387
+ if (match.index > cursor) {
388
+ spans.push({ kind: "text", text: text.slice(cursor, match.index) });
389
+ }
390
+ if (match[1]) {
391
+ spans.push({ kind: "code", text: match[1].slice(1, -1) });
392
+ } else if (match[2]) {
393
+ spans.push({ kind: "link", text: match[3], url: match[4] });
394
+ } else if (match[5]) {
395
+ spans.push({ kind: "bold", text: match[6] });
396
+ } else if (match[7]) {
397
+ spans.push({ kind: "bold", text: match[8] });
398
+ } else if (match[9]) {
399
+ spans.push({ kind: "italic", text: match[10] });
400
+ } else if (match[11]) {
401
+ spans.push({ kind: "italic", text: match[12] });
402
+ }
403
+ cursor = match.index + match[0].length;
404
+ }
405
+ if (cursor < text.length) {
406
+ spans.push({ kind: "text", text: text.slice(cursor) });
407
+ }
408
+ return spans.filter((span) => {
409
+ if (span.kind === "link") return span.text.length > 0 && span.url.length > 0;
410
+ return span.text.length > 0;
411
+ });
412
+ }
413
+
414
+ const HEADING_PATTERN = /^(#{1,3})\s+(.+?)\s*$/;
415
+ const UNORDERED_PATTERN = /^\s*[-*]\s+(.+)$/;
416
+ const ORDERED_PATTERN = /^\s*\d+[.)]\s+(.+)$/;
417
+ const FENCE_PATTERN = /^\s*```\s*([A-Za-z0-9_+-]*)\s*$/;
418
+
419
+ export function parseMarkdownLite(body: string | undefined | null): MarkdownLiteBlock[] {
420
+ if (!body || typeof body !== "string") return [];
421
+ const blocks: MarkdownLiteBlock[] = [];
422
+ const paragraph: string[] = [];
423
+ const flushParagraph = () => {
424
+ if (paragraph.length === 0) return;
425
+ const text = paragraph.join("\n").trim();
426
+ paragraph.length = 0;
427
+ if (!text) return;
428
+ blocks.push({ kind: "paragraph", spans: parseMarkdownLiteInline(text) });
429
+ };
430
+ const closeList = (list: MarkdownLiteBlock | null) => {
431
+ if (list && list.kind === "list" && list.items.length > 0) blocks.push(list);
432
+ };
433
+ let openList: MarkdownLiteBlock | null = null;
434
+ let fenceLanguage: string | undefined;
435
+ let fenceLines: string[] | null = null;
436
+ for (const rawLine of body.split(/\r?\n/)) {
437
+ const fence = FENCE_PATTERN.exec(rawLine);
438
+ if (fence) {
439
+ if (fenceLines == null) {
440
+ flushParagraph();
441
+ closeList(openList);
442
+ openList = null;
443
+ fenceLanguage = fence[1] || undefined;
444
+ fenceLines = [];
445
+ } else {
446
+ blocks.push({
447
+ kind: "code",
448
+ text: fenceLines.join("\n").replace(/\n$/, ""),
449
+ ...(fenceLanguage ? { language: fenceLanguage } : {}),
450
+ });
451
+ fenceLanguage = undefined;
452
+ fenceLines = null;
453
+ }
454
+ continue;
455
+ }
456
+ if (fenceLines != null) {
457
+ fenceLines.push(rawLine);
458
+ continue;
459
+ }
460
+ if (!rawLine.trim()) {
461
+ flushParagraph();
462
+ closeList(openList);
463
+ openList = null;
464
+ continue;
465
+ }
466
+ const heading = HEADING_PATTERN.exec(rawLine);
467
+ if (heading) {
468
+ flushParagraph();
469
+ closeList(openList);
470
+ openList = null;
471
+ blocks.push({
472
+ kind: "heading",
473
+ level: heading[1].length as 1 | 2 | 3,
474
+ spans: parseMarkdownLiteInline(heading[2]),
475
+ });
476
+ continue;
477
+ }
478
+ const unordered = UNORDERED_PATTERN.exec(rawLine);
479
+ const ordered = unordered ? null : ORDERED_PATTERN.exec(rawLine);
480
+ if (unordered || ordered) {
481
+ flushParagraph();
482
+ const isOrdered = !unordered;
483
+ const content = (unordered?.[1] ?? ordered?.[1] ?? "").trim();
484
+ if (!content) continue;
485
+ if (!openList || openList.kind !== "list" || openList.ordered !== isOrdered) {
486
+ closeList(openList);
487
+ openList = { kind: "list", ordered: isOrdered, items: [] };
488
+ }
489
+ (openList as { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }).items.push(
490
+ parseMarkdownLiteInline(content),
491
+ );
492
+ continue;
493
+ }
494
+ closeList(openList);
495
+ openList = null;
496
+ paragraph.push(rawLine);
497
+ }
498
+ if (fenceLines != null) {
499
+ blocks.push({
500
+ kind: "code",
501
+ text: fenceLines.join("\n").replace(/\n$/, ""),
502
+ ...(fenceLanguage ? { language: fenceLanguage } : {}),
503
+ });
504
+ }
505
+ flushParagraph();
506
+ closeList(openList);
507
+ return blocks;
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // RPC Contracts
512
+ // ---------------------------------------------------------------------------
513
+
514
+ export const IssueNumberInput = z
515
+ .object({
516
+ directory: z.string().optional(),
517
+ remoteUrl: z.string().optional(),
518
+ issueNumber: z.number().int().positive().optional(),
519
+ number: z.number().int().positive().optional(),
520
+ })
521
+ .superRefine((value, ctx) => {
522
+ if (value.issueNumber == null && value.number == null) {
523
+ ctx.addIssue({
524
+ code: z.ZodIssueCode.custom,
525
+ message: "issueNumber (or number) is required",
526
+ });
527
+ }
528
+ });
529
+ export type IssueNumberInput = z.infer<typeof IssueNumberInput>;
530
+
531
+ export const IssueDetailInputSchema = IssueNumberInput;
532
+ export type IssueDetailInput = z.infer<typeof IssueDetailInputSchema>;
533
+
534
+ export const IssueDetailOutputSchema = z.object({
535
+ repo: z.string().nullable(),
536
+ issue: IssueDetailSchema.nullable(),
537
+ fetchedAt: z.string().datetime(),
538
+ repoPublic: z.boolean().nullable().default(null),
539
+ tokenPresent: z.boolean().default(false),
540
+ tokenValid: z.boolean().nullable().default(null),
541
+ repoWritePermission: z.boolean().nullable().default(null),
542
+ error: z.string().optional(),
543
+ });
544
+ export type IssueDetailOutput = z.infer<typeof IssueDetailOutputSchema>;
545
+
546
+ export const issueDetailContract = defineContract({
547
+ name: "forge.issue-detail",
548
+ description: "Full body, comments, and parsed Agent Envelopes for one issue",
549
+ input: IssueDetailInputSchema,
550
+ output: IssueDetailOutputSchema,
551
+ });
552
+
553
+ export const SetLabelInputSchema = IssueNumberInput.extend({
554
+ label: z.string().min(1).max(100),
555
+ });
556
+ export type SetLabelInput = z.infer<typeof SetLabelInputSchema>;
557
+
558
+ export const SetLabelOutputSchema = z.object({
559
+ number: z.number(),
560
+ labels: z.array(z.string()),
561
+ error: z.string().optional(),
562
+ });
563
+ export type SetLabelOutput = z.infer<typeof SetLabelOutputSchema>;
564
+
565
+ export const setLabelContract = defineContract({
566
+ name: "forge.set-label",
567
+ description: "Apply one scoped label; an exclusive scope evicts the rest",
568
+ input: SetLabelInputSchema,
569
+ output: SetLabelOutputSchema,
570
+ });
571
+
572
+ export const AddCommentInputSchema = IssueNumberInput.extend({
573
+ body: z.string().min(1).max(10000),
574
+ });
575
+ export type AddCommentInput = z.infer<typeof AddCommentInputSchema>;
576
+
577
+ export const AddCommentOutputSchema = z.object({
578
+ number: z.number(),
579
+ commentId: z.number().nullable(),
580
+ error: z.string().optional(),
581
+ });
582
+ export type AddCommentOutput = z.infer<typeof AddCommentOutputSchema>;
583
+
584
+ export const addCommentContract = defineContract({
585
+ name: "forge.add-comment",
586
+ description: "Post a quick comment (or steering note) to the issue thread",
587
+ input: AddCommentInputSchema,
588
+ output: AddCommentOutputSchema,
589
+ });
590
+
591
+ export const CREATE_ISSUE_TITLE_MAX = 200;
592
+ export const CREATE_ISSUE_BODY_MAX = 10000;
593
+ export const CREATE_ISSUE_LABEL_MAX = 50;
594
+
595
+ export const CreateIssueInputSchema = z.object({
596
+ directory: z.string().optional(),
597
+ remoteUrl: z.string().optional(),
598
+ title: z.string(),
599
+ body: z.string().optional(),
600
+ labels: z.array(z.string()).optional(),
601
+ });
602
+ export type CreateIssueInput = z.infer<typeof CreateIssueInputSchema>;
603
+
604
+ export const CreateIssueOutputSchema = z.object({
605
+ repo: z.string().nullable(),
606
+ host: z.string().nullable().default(null),
607
+ number: z.number().int().positive().nullable().default(null),
608
+ error: z.string().optional(),
609
+ });
610
+ export type CreateIssueOutput = z.infer<typeof CreateIssueOutputSchema>;
611
+
612
+ export const createIssueContract = defineContract({
613
+ name: "forge.create-issue",
614
+ description: "Create a new issue on the configured forge repo",
615
+ input: CreateIssueInputSchema,
616
+ output: CreateIssueOutputSchema,
617
+ });
618
+
619
+ export function validateCreateIssueInput(input: {
620
+ title?: unknown;
621
+ body?: unknown;
622
+ labels?: unknown;
623
+ }): string | null {
624
+ const title = typeof input.title === "string" ? input.title.trim() : "";
625
+ if (!title) return "Issue title must not be empty";
626
+ if (title.length > CREATE_ISSUE_TITLE_MAX) return "Issue title is too long";
627
+ const body = typeof input.body === "string" ? input.body : "";
628
+ if (body.length > CREATE_ISSUE_BODY_MAX) return "Issue description is too long";
629
+ if (input.labels !== undefined) {
630
+ if (!Array.isArray(input.labels)) return "Labels must be a list";
631
+ if (input.labels.length > CREATE_ISSUE_LABEL_MAX) return "Too many labels";
632
+ if (input.labels.some((label) => typeof label !== "string" || !label.trim())) {
633
+ return "Label must not be empty";
634
+ }
635
+ }
636
+ return null;
637
+ }
638
+
639
+ export function parseLabelList(text: string | undefined | null): string[] {
640
+ if (!text || typeof text !== "string") return [];
641
+ const names: string[] = [];
642
+ const seen = new Set<string>();
643
+ for (const part of text.split(",")) {
644
+ const trimmed = part.trim();
645
+ if (!trimmed || seen.has(trimmed)) continue;
646
+ seen.add(trimmed);
647
+ names.push(trimmed);
648
+ }
649
+ return names;
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Open & Search issues RPC contracts
654
+ // ---------------------------------------------------------------------------
655
+
656
+ export const OpenIssuesInputSchema = z.object({
657
+ workspaceId: z.string().optional(),
658
+ directory: z.string().optional(),
659
+ remoteUrl: z.string().optional(),
660
+ page: z.number().int().positive().default(1),
661
+ });
662
+ export type OpenIssuesInput = z.infer<typeof OpenIssuesInputSchema>;
663
+
664
+ export const OpenIssuesOutputSchema = z.object({
665
+ repo: z.string().nullable(),
666
+ host: z.string().nullable().default(null),
667
+ issues: z.array(ForgeIssueSchema),
668
+ openIssueCount: z.number().int().nonnegative().nullable().default(null),
669
+ page: z.number().int().positive().default(1),
670
+ hasMore: z.boolean().default(false),
671
+ derivedRemote: z.string().nullable().default(null),
672
+ remoteSource: z.enum(["explicit", "derived"]).nullable().default(null),
673
+ repoPublic: z.boolean().nullable().default(null),
674
+ tokenPresent: z.boolean().default(false),
675
+ tokenValid: z.boolean().nullable().default(null),
676
+ repoWritePermission: z.boolean().nullable().default(null),
677
+ error: z.string().optional(),
678
+ });
679
+ export type OpenIssuesOutput = z.infer<typeof OpenIssuesOutputSchema>;
680
+
681
+ export const openIssuesContract = defineContract({
682
+ name: "forge.open-issues",
683
+ description: "List open forge issues for the repo backing a workspace directory",
684
+ input: OpenIssuesInputSchema,
685
+ output: OpenIssuesOutputSchema,
686
+ });
687
+
688
+ export const SearchIssuesInputSchema = z.object({
689
+ workspaceId: z.string().optional(),
690
+ directory: z.string().optional(),
691
+ remoteUrl: z.string().optional(),
692
+ query: z.string(),
693
+ page: z.number().int().positive().default(1),
694
+ });
695
+ export type SearchIssuesInput = z.infer<typeof SearchIssuesInputSchema>;
696
+
697
+ export const SearchIssuesOutputSchema = z.object({
698
+ repo: z.string().nullable(),
699
+ host: z.string().nullable().default(null),
700
+ issues: z.array(ForgeIssueSchema),
701
+ page: z.number().int().positive().default(1),
702
+ hasMore: z.boolean().default(false),
703
+ error: z.string().optional(),
704
+ });
705
+ export type SearchIssuesOutput = z.infer<typeof SearchIssuesOutputSchema>;
706
+
707
+ export const searchIssuesContract = defineContract({
708
+ name: "forge.search-issues",
709
+ description: "Keyword-search forge issues (open and closed) for the repo backing a workspace directory",
710
+ input: SearchIssuesInputSchema,
711
+ output: SearchIssuesOutputSchema,
712
+ });
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Forge context RPC contracts
716
+ // ---------------------------------------------------------------------------
717
+
718
+ export const ForgeContextInputSchema = z.object({
719
+ workspaceId: z.string().optional(),
720
+ directory: z.string().optional(),
721
+ });
722
+ export type ForgeContextInput = z.infer<typeof ForgeContextInputSchema>;
723
+
724
+ export const ForgeContextOutputSchema = z.object({
725
+ directory: z.string().nullable().default(null),
726
+ derivedRemote: z.string().nullable().default(null),
727
+ derivedHost: z.string().nullable().default(null),
728
+ derivedRepo: z.string().nullable().default(null),
729
+ });
730
+ export type ForgeContextOutput = z.infer<typeof ForgeContextOutputSchema>;
731
+
732
+ export const forgeContextContract = defineContract({
733
+ name: "forge.context",
734
+ description: "Workspace git-origin forge coordinates",
735
+ input: ForgeContextInputSchema,
736
+ output: ForgeContextOutputSchema,
737
+ });
738
+
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Agent envelope parsing
742
+ // ---------------------------------------------------------------------------
743
+
744
+ export const SHA_PATTERN = /\b[0-9a-f]{7,40}\b/g;
745
+ export const PASEO_LINK_PATTERN = /paseo:\/\/[^\s)>\]]+/g;
746
+ export const PASEO_SERVER_PATTERN = /paseo:\/\/h\/([^/\s]+)\/agent\//;
747
+
748
+ export function parseAgentEnvelope(
749
+ commentId: number,
750
+ body: string | undefined | null,
751
+ ): AgentEnvelope | null {
752
+ if (!body || typeof body !== "string") return null;
753
+ const match = ENVELOPE_FOOTER_PATTERN.exec(body);
754
+ if (!match) return null;
755
+ const sessionTitle = match[1].trim();
756
+ const agentShortId = match[2].trim();
757
+ if (!sessionTitle || !agentShortId) return null;
758
+ const model = match[3].trim() || null;
759
+ const repoBranch = match[4].trim();
760
+ const postedAt = match[5].trim() || null;
761
+ let repo: string | null = null;
762
+ let branch: string | null = null;
763
+ if (repoBranch) {
764
+ const sep = repoBranch.lastIndexOf(":");
765
+ if (sep > 0) {
766
+ repo = repoBranch.slice(0, sep) || null;
767
+ branch = repoBranch.slice(sep + 1) || null;
768
+ } else {
769
+ branch = repoBranch;
770
+ }
771
+ }
772
+ const commitShas = Array.from(
773
+ new Set(
774
+ (body.match(SHA_PATTERN) ?? []).filter(
775
+ (sha) => sha !== agentShortId && /[0-9]/.test(sha) && /[a-f]/.test(sha),
776
+ ),
777
+ ),
778
+ );
779
+ const paseoLinks = Array.from(new Set(body.match(PASEO_LINK_PATTERN) ?? []));
780
+ const serverMatch = PASEO_SERVER_PATTERN.exec(paseoLinks[0] ?? "");
781
+ return {
782
+ commentId,
783
+ sessionTitle,
784
+ agentShortId,
785
+ model,
786
+ repo,
787
+ branch,
788
+ postedAt,
789
+ commitShas,
790
+ paseoLinks,
791
+ serverId: serverMatch ? serverMatch[1] : null,
792
+ };
793
+ }
794
+
795
+ // ---------------------------------------------------------------------------
796
+ // Remote coordinates & resolution
797
+ // ---------------------------------------------------------------------------
798
+
799
+ export interface ForgeRemote {
800
+ host: string;
801
+ owner: string;
802
+ repo: string;
803
+ }
804
+
805
+ export function parseForgeRemote(url: string | undefined | null): ForgeRemote | null {
806
+ if (!url || typeof url !== "string") return null;
807
+ const trimmed = url.trim().replace(/\/+$/, "");
808
+ const scp = trimmed.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
809
+ let host: string | undefined;
810
+ let pathPart: string | undefined;
811
+ if (scp && !trimmed.includes("://")) {
812
+ host = scp[1];
813
+ pathPart = scp[2];
814
+ } else {
815
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
816
+ ? trimmed
817
+ : `ssh://${trimmed}`;
818
+ try {
819
+ const parsed = new URL(withScheme);
820
+ host = parsed.hostname || undefined;
821
+ pathPart = parsed.pathname.replace(/^\/+/, "") || undefined;
822
+ } catch {
823
+ return null;
824
+ }
825
+ }
826
+ if (!host || !pathPart) return null;
827
+ const segments = pathPart.replace(/\.git$/, "").split("/").filter(Boolean);
828
+ if (segments.length < 2) return null;
829
+ const repo = segments.pop() as string;
830
+ const owner = segments.pop() as string;
831
+ return { host, owner, repo };
832
+ }
833
+
834
+ export const BARE_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
835
+
836
+ export type ForgeTargetResolution =
837
+ | { ok: true; host: string; repo: string; source: "explicit" | "derived" }
838
+ | { ok: false; error: string };
839
+
840
+ export function resolveForgeTarget(
841
+ explicitTarget: string | undefined | null,
842
+ gitRemoteUrl: string | undefined | null,
843
+ ): ForgeTargetResolution {
844
+ const explicit = typeof explicitTarget === "string" ? explicitTarget.trim() : "";
845
+ const git = parseForgeRemote(gitRemoteUrl);
846
+ if (explicit) {
847
+ const parsed = parseForgeRemote(explicit);
848
+ if (parsed) {
849
+ return { ok: true, host: parsed.host, repo: `${parsed.owner}/${parsed.repo}`, source: "explicit" };
850
+ }
851
+ if (BARE_REPO_PATTERN.test(explicit)) {
852
+ if (git) return { ok: true, host: git.host, repo: explicit, source: "explicit" };
853
+ return { ok: false, error: `Selected forge "${explicit}" needs a git origin remote to supply its host` };
854
+ }
855
+ return { ok: false, error: `Selected forge "${explicit}" is not a valid forge remote or owner/repo` };
856
+ }
857
+ if (!git) return { ok: false, error: "No forge repo found for this workspace" };
858
+ return { ok: true, host: git.host, repo: `${git.owner}/${git.repo}`, source: "derived" };
859
+ }
860
+
861
+ export function isValidForgeTarget(target: string | undefined | null): boolean {
862
+ const value = typeof target === "string" ? target.trim() : "";
863
+ if (!value) return false;
864
+ return Boolean(parseForgeRemote(value)) || BARE_REPO_PATTERN.test(value);
865
+ }
866
+
867
+ export function forgeCapabilityFromRepo(payload: unknown): boolean | null {
868
+ if (!payload || typeof payload !== "object") return null;
869
+ const record = payload as Record<string, unknown>;
870
+ const permissions = record.permissions;
871
+ if (permissions && typeof permissions === "object") {
872
+ const perms = permissions as Record<string, unknown>;
873
+ if (typeof perms.push === "boolean" || typeof perms.admin === "boolean") {
874
+ return perms.push === true || perms.admin === true;
875
+ }
876
+ }
877
+ const accessLevel = record.access_level ?? record.accessLevel;
878
+ if (typeof accessLevel === "number") return accessLevel >= 30;
879
+ return null;
880
+ }
881
+
882
+ export function activeForgeForDirectory(
883
+ settings:
884
+ | {
885
+ activeForgeByDirectory?: Record<string, string>;
886
+ remotesByDirectory?: Record<string, string>;
887
+ forgesByDirectory?: Record<string, string[]>;
888
+ }
889
+ | undefined
890
+ | null,
891
+ directory: string | undefined | null,
892
+ ): string | null {
893
+ const dir = typeof directory === "string" ? directory.trim() : "";
894
+ if (!dir) return null;
895
+ const activeMap = settings?.activeForgeByDirectory;
896
+ if (activeMap && Object.prototype.hasOwnProperty.call(activeMap, dir)) {
897
+ const active = activeMap[dir];
898
+ return typeof active === "string" && active.trim() ? active.trim() : null;
899
+ }
900
+ const remotesMap = settings?.remotesByDirectory;
901
+ if (remotesMap && Object.prototype.hasOwnProperty.call(remotesMap, dir)) {
902
+ const legacy = remotesMap[dir];
903
+ return typeof legacy === "string" && legacy.trim() ? legacy.trim() : null;
904
+ }
905
+ return null;
906
+ }
907
+
908
+ // ---------------------------------------------------------------------------
909
+ // Issue & label utilities
910
+ // ---------------------------------------------------------------------------
911
+
912
+ export function normalizeIssueNumber(input: {
913
+ issueNumber?: number;
914
+ number?: number;
915
+ }): number | null {
916
+ const candidate = input.issueNumber ?? input.number;
917
+ return typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0
918
+ ? candidate
919
+ : null;
920
+ }
921
+
922
+ export function scopeOfLabel(label: string): string | null {
923
+ const slash = label.indexOf("/");
924
+ if (slash <= 0 || slash + 1 >= label.length) return null;
925
+ const scope = label.slice(0, slash);
926
+ if (!/^[A-Za-z0-9_.-]+$/.test(scope)) return null;
927
+ return scope;
928
+ }
929
+
930
+ export function liveScopesFromLabels(allLabels: string[]): string[] {
931
+ const scopes: string[] = [];
932
+ for (const label of allLabels) {
933
+ const scope = scopeOfLabel(label);
934
+ if (scope && !scopes.includes(scope)) scopes.push(scope);
935
+ }
936
+ return scopes;
937
+ }
938
+
939
+ export function liveScopesFromIssues(issues: Pick<ForgeIssue, "labels">[]): string[] {
940
+ return liveScopesFromLabels(issues.flatMap((issue) => issue.labels));
941
+ }
942
+
943
+ function rankOf(label: string | null, order: readonly string[]): number {
944
+ if (!label) return order.length;
945
+ const idx = (order as readonly string[]).indexOf(label);
946
+ return idx < 0 ? order.length : idx;
947
+ }
948
+
949
+ export function rankIssues<T extends Pick<ForgeIssue, "labels" | "updatedAt">>(issues: T[]): T[] {
950
+ return [...issues].sort((a, b) => {
951
+ const pri =
952
+ rankOf(currentPriorityLabel(a.labels), PRIORITY_ORDER) -
953
+ rankOf(currentPriorityLabel(b.labels), PRIORITY_ORDER);
954
+ if (pri !== 0) return pri;
955
+ const state =
956
+ rankOf(currentStateLabel(a.labels), STATE_ORDER) -
957
+ rankOf(currentStateLabel(b.labels), STATE_ORDER);
958
+ if (state !== 0) return state;
959
+ return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
960
+ });
961
+ }
962
+
963
+ export const PASEO_LABEL_SCOPES = [
964
+ "state",
965
+ "priority",
966
+ "attention",
967
+ "spec",
968
+ "kind",
969
+ "target",
970
+ "format",
971
+ "size",
972
+ "dep",
973
+ "flag",
974
+ ] as const;
975
+
976
+ export const INSTALL_LABEL_MODES = ["merge", "replace"] as const;
977
+ export type InstallLabelMode = (typeof INSTALL_LABEL_MODES)[number];
978
+
979
+ export interface ForgeLabelRef {
980
+ id?: number;
981
+ name: string;
982
+ }
983
+
984
+ export interface LabelDefinition {
985
+ name: string;
986
+ color: string;
987
+ exclusive: boolean;
988
+ description: string;
989
+ }
990
+
991
+ export interface LabelSetPlan {
992
+ mode: InstallLabelMode;
993
+ create: LabelDefinition[];
994
+ remove: ForgeLabelRef[];
995
+ skip: string[];
996
+ }
997
+
998
+ export const InstallLabelsInputSchema = z.object({
999
+ directory: z.string().optional(),
1000
+ remoteUrl: z.string().optional(),
1001
+ mode: z.enum(INSTALL_LABEL_MODES),
1002
+ });
1003
+ export type InstallLabelsInput = z.infer<typeof InstallLabelsInputSchema>;
1004
+
1005
+ export const InstallLabelsOutputSchema = z.object({
1006
+ host: z.string().nullable().default(null),
1007
+ repo: z.string().nullable().default(null),
1008
+ mode: z.enum(INSTALL_LABEL_MODES).nullable().default(null),
1009
+ created: z.array(z.string()).default([]),
1010
+ skipped: z.array(z.string()).default([]),
1011
+ removed: z.array(z.string()).default([]),
1012
+ error: z.string().optional(),
1013
+ });
1014
+ export type InstallLabelsOutput = z.infer<typeof InstallLabelsOutputSchema>;
1015
+
1016
+ const STATE_COLORS = ["#1d76db", "#0e7c6b", "#a6700b", "#6e40c9", "#1a7f37"];
1017
+ const PRIORITY_COLORS = ["#d1242f", "#e85d04", "#1d76db", "#59636e", "#8c959f"];
1018
+
1019
+ const LABEL_DEFS: LabelDefinition[] = [
1020
+ ...STATE_ORDER.map((name, i): LabelDefinition => ({
1021
+ name,
1022
+ color: STATE_COLORS[i] ?? "#59636e",
1023
+ exclusive: true,
1024
+ description: `Workflow state ${i}`,
1025
+ })),
1026
+ ...LEGACY_STATE_ORDER.map((name, i): LabelDefinition => ({
1027
+ name,
1028
+ color: STATE_COLORS[i] ?? "#59636e",
1029
+ exclusive: true,
1030
+ description: `Workflow state ${i} (legacy numeric)`,
1031
+ })),
1032
+ ...PRIORITY_ORDER.map((name, i): LabelDefinition => ({
1033
+ name,
1034
+ color: PRIORITY_COLORS[i] ?? "#59636e",
1035
+ exclusive: true,
1036
+ description: `Priority ${i}`,
1037
+ })),
1038
+ ...LEGACY_PRIORITY_ORDER.map((name, i): LabelDefinition => ({
1039
+ name,
1040
+ color: PRIORITY_COLORS[i] ?? "#59636e",
1041
+ exclusive: true,
1042
+ description: `Priority ${i} (legacy numeric)`,
1043
+ })),
1044
+ ...ATTENTION_LABELS.map((name): LabelDefinition => ({
1045
+ name,
1046
+ color: "#8250df",
1047
+ exclusive: true,
1048
+ description: "Who acts next",
1049
+ })),
1050
+ ...LEGACY_ATTENTION_LABELS.map((name): LabelDefinition => ({
1051
+ name,
1052
+ color: "#8250df",
1053
+ exclusive: true,
1054
+ description: "Who acts next (legacy numeric)",
1055
+ })),
1056
+ ...SPEC_LABELS.map((name): LabelDefinition => ({
1057
+ name,
1058
+ color: "#0e7c6b",
1059
+ exclusive: true,
1060
+ description: "Spec readiness",
1061
+ })),
1062
+ ...LEGACY_SPEC_LABELS.map((name): LabelDefinition => ({
1063
+ name,
1064
+ color: "#0e7c6b",
1065
+ exclusive: true,
1066
+ description: "Spec readiness (legacy numeric)",
1067
+ })),
1068
+ ];
1069
+
1070
+ export function paseoLabelSet(): LabelDefinition[] {
1071
+ return LABEL_DEFS.map((def) => ({ ...def }));
1072
+ }
1073
+
1074
+ export function paseoLabelScopes(): string[] {
1075
+ const scopes: string[] = [];
1076
+ for (const def of LABEL_DEFS) {
1077
+ const scope = scopeOfLabel(def.name);
1078
+ if (scope && !scopes.includes(scope)) scopes.push(scope);
1079
+ }
1080
+ return scopes;
1081
+ }
1082
+
1083
+ export function planLabelSetInstall(
1084
+ existing: ForgeLabelRef[],
1085
+ mode: InstallLabelMode,
1086
+ ): LabelSetPlan {
1087
+ const desired = paseoLabelSet();
1088
+ const desiredNames = new Set(desired.map((def) => def.name));
1089
+ const existingNames = new Set(
1090
+ existing.map((label) => label.name).filter((name): name is string => typeof name === "string"),
1091
+ );
1092
+ const ownScopes = new Set(paseoLabelScopes());
1093
+ const remove =
1094
+ mode === "replace"
1095
+ ? existing.filter((label) => {
1096
+ const scope = scopeOfLabel(label.name);
1097
+ return scope !== null && ownScopes.has(scope) && !desiredNames.has(label.name);
1098
+ })
1099
+ : [];
1100
+ const create = desired.filter((def) => !existingNames.has(def.name));
1101
+ const skip = desired.filter((def) => existingNames.has(def.name)).map((def) => def.name);
1102
+ return { mode, create, remove, skip };
1103
+ }
1104
+