@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.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 (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,157 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { IDENTIFIER } from "./agent-hooks";
4
+ import type { IssueDetail } from "./linear";
5
+
6
+ // The plugin's MCP server for agents started from an issue. It gives them Linear tools for
7
+ // their issue tree and a way to start Paseo agents on its sub-issues. The daemon hosts it on
8
+ // 127.0.0.1, and each agent gets its own bearer token.
9
+
10
+ export const TOOLS_SERVER = "linear";
11
+ /** Children get only the Linear tools, so agents never start agents of their own. */
12
+ export const LINEAR_TOOLS = ["read_issue", "edit_issue"] as const;
13
+ export const AGENT_TOOLS = ["start_agent", "wait_agent"] as const;
14
+
15
+ export const McpHttpServerSchema = z.object({
16
+ type: z.literal("http"),
17
+ url: z.string().min(1),
18
+ headers: z.record(z.string(), z.string()).optional(),
19
+ });
20
+
21
+ export const ToolPolicySchema = z.object({
22
+ preapproved: z.array(z.object({ kind: z.literal("mcp"), server: z.string(), tool: z.string() })),
23
+ });
24
+
25
+ /** The config of an agent that start_agent starts. */
26
+ export const AgentConfigSchema = z.object({
27
+ provider: z.string().min(1),
28
+ thinkingOptionId: z.string().optional(),
29
+ modeId: z.string().optional(),
30
+ });
31
+ export type AgentConfig = z.infer<typeof AgentConfigSchema>;
32
+
33
+ /** The agent the user chose for a sub-issue, and its name for prompts and replies. */
34
+ export const AssignmentSchema = z.object({ config: AgentConfigSchema, label: z.string() });
35
+ export type Assignment = z.infer<typeof AssignmentSchema>;
36
+
37
+ export const ToolScopeSchema = z.object({
38
+ /** The issue the launch started from. The tools reach it and its sub-issues. */
39
+ root: z.string().regex(IDENTIFIER),
40
+ /** The issue this agent works on, the default for read_issue and edit_issue. */
41
+ home: z.string().regex(IDENTIFIER),
42
+ /** The Paseo project whose Linear key loaded the issue. */
43
+ keyScope: z.string().nullable(),
44
+ workspaceId: z.string().min(1),
45
+ /** The Paseo project the agent runs in. Its settings turn the tools on and off. */
46
+ projectId: z.string().nullable().optional(),
47
+ /** The agent config that started agents copy. */
48
+ config: AgentConfigSchema,
49
+ /** Agents the user chose for sub-issues, by issue key. start_agent uses them. */
50
+ assignments: z.record(z.string(), AssignmentSchema).optional(),
51
+ });
52
+ export type ToolScope = z.infer<typeof ToolScopeSchema>;
53
+
54
+ export const agentToolsRpc = defineRpc({
55
+ name: "linear.agent.tools",
56
+ input: ToolScopeSchema,
57
+ output: z.object({
58
+ /** Goes in the agent's labels, so a tool call can find the agent that made it. */
59
+ grantId: z.string(),
60
+ server: McpHttpServerSchema,
61
+ toolPolicy: ToolPolicySchema,
62
+ }),
63
+ });
64
+
65
+ export function toolPolicy(tools: readonly string[]): z.infer<typeof ToolPolicySchema> {
66
+ return { preapproved: tools.map((tool) => ({ kind: "mcp" as const, server: TOOLS_SERVER, tool })) };
67
+ }
68
+
69
+ const IssueKey = z.string().regex(IDENTIFIER);
70
+
71
+ export const ReadIssueInput = z.object({
72
+ issue: IssueKey.optional().describe("Issue key, such as ENG-12. The default is the issue you work on."),
73
+ });
74
+
75
+ export const EditIssueInput = z.object({
76
+ issue: IssueKey.optional().describe("Issue key, such as ENG-12. The default is the issue you work on."),
77
+ old_text: z
78
+ .string()
79
+ .describe("Exact text of the description to replace. It must occur one time. Use an empty string to add new_text at the end."),
80
+ new_text: z.string().describe("The text that replaces old_text."),
81
+ });
82
+
83
+ export const StartAgentInput = z.object({
84
+ issue: IssueKey.describe("Key of the sub-issue the agent works on."),
85
+ title: z.string().min(1).max(120).describe("Short title. Start it with the issue key."),
86
+ prompt: z.string().min(1).describe("The full task. The agent does not see your conversation."),
87
+ thinking: z
88
+ .string()
89
+ .optional()
90
+ .describe("Thinking option of the agent's model, such as low, medium, or high. The default is yours."),
91
+ });
92
+
93
+ export const WaitAgentInput = z.object({
94
+ agent_id: z.string().min(1),
95
+ timeout_seconds: z.number().int().min(1).max(600).default(50),
96
+ });
97
+
98
+ /** Replaces the one place where old text occurs. Empty old text adds the new text at the end. */
99
+ export function editText(body: string, oldText: string, newText: string): { body: string } | { error: string } {
100
+ if (oldText === "") return { body: body.trimEnd() ? `${body.trimEnd()}\n\n${newText}` : newText };
101
+ const first = body.indexOf(oldText);
102
+ if (first < 0) return { error: "old_text is not in the description. Read the issue again, because it can change." };
103
+ if (body.indexOf(oldText, first + 1) >= 0) {
104
+ return { error: "old_text occurs more than one time. Add nearby lines so that it occurs one time." };
105
+ }
106
+ return { body: body.slice(0, first) + newText + body.slice(first + oldText.length) };
107
+ }
108
+
109
+ /** Counts Markdown task list items, so a reply can say how many are done. */
110
+ export function taskCount(body: string): { done: number; total: number } {
111
+ const items = [...body.matchAll(/^\s*[-*+] \[([ xX])\]/gm)];
112
+ return { done: items.filter((item) => item[1] !== " ").length, total: items.length };
113
+ }
114
+
115
+ /** The issue as text for read_issue. */
116
+ export function issueText(issue: IssueDetail): string {
117
+ const tasks = taskCount(issue.description ?? "");
118
+ const lines = [
119
+ `${issue.identifier}: ${issue.title}`,
120
+ `Status: ${issue.state.name}`,
121
+ `URL: ${issue.url}`,
122
+ tasks.total > 0 ? `Task list: ${tasks.done} of ${tasks.total} checked` : null,
123
+ "",
124
+ "Description:",
125
+ issue.description?.trim() || "(empty)",
126
+ ];
127
+ if (issue.children.length > 0) {
128
+ lines.push("", "Sub-issues:");
129
+ for (const child of issue.children) lines.push(`- ${child.identifier} [${child.state.name}]: ${child.title}`);
130
+ }
131
+ return lines.filter((line) => line !== null).join("\n");
132
+ }
133
+
134
+ /** Opens the prompt of an agent that start_agent makes. */
135
+ export function childBrief(key: string, root: string, edits = true): string {
136
+ const parent = key.toUpperCase() === root.toUpperCase() ? "" : `, a sub-issue of ${root}`;
137
+ const lines = [`You work on Linear issue ${key}${parent}. Start your first reply with "${key}:".`];
138
+ if (edits) {
139
+ lines.push(
140
+ `Use the ${TOOLS_SERVER} MCP tools: read_issue shows the issue, and edit_issue changes its description.`,
141
+ "Check off each task list item you finish by replacing `- [ ]` with `- [x]`. Do not post comments.",
142
+ );
143
+ } else {
144
+ lines.push(`Use the ${TOOLS_SERVER} MCP tool read_issue to read the issue. Do not change the issue or post comments.`);
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+
149
+ /** The assignment for a sub-issue key, in any letter case. */
150
+ export function assignmentFor(assignments: ToolScope["assignments"], key: string): Assignment | null {
151
+ if (!assignments) return null;
152
+ const wanted = key.toUpperCase();
153
+ for (const [entry, assignment] of Object.entries(assignments)) {
154
+ if (entry.toUpperCase() === wanted) return assignment;
155
+ }
156
+ return null;
157
+ }
@@ -0,0 +1,188 @@
1
+ import { z } from "zod";
2
+
3
+ // The user's own plugin icon. The web app draws it as an image, which never runs scripts or
4
+ // loads files; the checks below also refuse such content. Paint recolors every visible pixel,
5
+ // and Solid fills shapes that only have an outline.
6
+
7
+ export const ICON_PAINTS = ["original", "theme", "accent", "linear", "custom"] as const;
8
+ export type IconPaint = (typeof ICON_PAINTS)[number];
9
+
10
+ /** Linear's brand indigo, the plugin's own color. */
11
+ export const LINEAR_INDIGO = "#5E6AD2";
12
+ export const MAX_SVG_LENGTH = 64_000;
13
+ export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
+
15
+ /** Paseo draws panel tabs, the new tab menu, and Command Center with Lucide names only. */
16
+ export const BUILT_IN_ICON = "SquareKanban";
17
+ export const MENU_ICONS: readonly { name: string; label: string }[] = [
18
+ { name: BUILT_IN_ICON, label: "Board (default)" },
19
+ ...[
20
+ ["Kanban", "Kanban"],
21
+ ["LayoutGrid", "Grid"],
22
+ ["ListTodo", "To-do list"],
23
+ ["ClipboardList", "Clipboard"],
24
+ ["CircleDot", "Circle dot"],
25
+ ["Target", "Target"],
26
+ ["Zap", "Lightning"],
27
+ ["Rocket", "Rocket"],
28
+ ["Star", "Star"],
29
+ ["Hexagon", "Hexagon"],
30
+ ["Triangle", "Triangle"],
31
+ ["Diamond", "Diamond"],
32
+ ["Box", "Box"],
33
+ ["Layers", "Layers"],
34
+ ["Compass", "Compass"],
35
+ ["Flag", "Flag"],
36
+ ["Bookmark", "Bookmark"],
37
+ ["Ticket", "Ticket"],
38
+ ["Bug", "Bug"],
39
+ ["Sparkles", "Sparkles"],
40
+ ["Bot", "Bot"],
41
+ ["Gem", "Gem"],
42
+ ["Flame", "Flame"],
43
+ ["Shapes", "Shapes"],
44
+ ["Orbit", "Orbit"],
45
+ ["Workflow", "Workflow"],
46
+ ["Puzzle", "Puzzle"],
47
+ ["Atom", "Atom"],
48
+ ["Command", "Command"],
49
+ ].map(([name = "", label = ""]) => ({ name, label })),
50
+ ];
51
+ const LUCIDE_NAME = /^[A-Z][A-Za-z0-9]*$/;
52
+
53
+ export const IconSettingsSchema = z.object({
54
+ /** Checked SVG markup. Empty uses the built-in icon. */
55
+ svg: z.string().max(MAX_SVG_LENGTH).default(""),
56
+ paint: z.enum(ICON_PAINTS).default("original"),
57
+ /** The color of the custom paint. */
58
+ color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
59
+ /** Fill shapes that only have an outline. */
60
+ solid: z.boolean().default(false),
61
+ /** The Lucide icon for panel tabs, the new tab menu, and Command Center. */
62
+ menu: z.string().regex(LUCIDE_NAME).default(BUILT_IN_ICON),
63
+ });
64
+ export type IconSettings = z.infer<typeof IconSettingsSchema>;
65
+
66
+ const BLOCKED: readonly (readonly [RegExp, string])[] = [
67
+ [/<!DOCTYPE|<!ENTITY/i, "The SVG has a DOCTYPE or entities. Export it again without them."],
68
+ [/<script[\s>/]/i, "The SVG has a script. Use an SVG with shapes only."],
69
+ [/<foreignObject[\s>/]/i, "The SVG has HTML content. Use an SVG with shapes only."],
70
+ [/<[^>]*\son[a-z]+\s*=/i, "The SVG has event handlers. Use an SVG with shapes only."],
71
+ [/[\s:]href\s*=\s*["']\s*(?!#)/i, "The SVG links to other files or images. Use an SVG that has all its shapes inside."],
72
+ [/url\(\s*["']?\s*(?!#)/i, "The SVG uses files outside it. Use an SVG that has all its shapes inside."],
73
+ [/@import/i, "The SVG imports styles from another file."],
74
+ ];
75
+
76
+ const ROOT_TAG = /^<svg\b[^>]*>/i;
77
+ const NUMBER = String.raw`(-?[\d.]+(?:e[-+]?\d+)?)`;
78
+ const VIEW_BOX = new RegExp(String.raw`\sviewBox\s*=\s*["']\s*${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}\s*["']`, "i");
79
+
80
+ const attribute = (tag: string, name: string) =>
81
+ new RegExp(String.raw`\s${name}\s*=\s*["']([^"']*)["']`, "i").exec(tag)?.[1]?.trim() ?? null;
82
+ const withoutAttribute = (tag: string, name: string) =>
83
+ tag.replace(new RegExp(String.raw`\s${name}\s*=\s*(?:"[^"]*"|'[^']*')`, "gi"), "");
84
+
85
+ /** The root's viewBox as x, y, width, and height, or null without a usable one. */
86
+ export function viewBoxOf(svg: string): [number, number, number, number] | null {
87
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
88
+ const box = VIEW_BOX.exec(root)?.slice(1, 5).map(Number);
89
+ if (!box || box.some((value) => !Number.isFinite(value))) return null;
90
+ const [x = 0, y = 0, width = 0, height = 0] = box;
91
+ return width > 0 && height > 0 ? [x, y, width, height] : null;
92
+ }
93
+
94
+ /** Checks pasted or uploaded SVG and returns it ready to save, or why it cannot be used. */
95
+ export function parseIconSvg(text: string): { svg: string } | { error: string } {
96
+ const svg = text
97
+ .replace(/^\uFEFF/, "")
98
+ .replace(/<\?[\s\S]*?\?>/g, "")
99
+ .replace(/<!--[\s\S]*?-->/g, "")
100
+ .trim();
101
+ if (!svg) return { error: "Paste SVG markup or choose an SVG file." };
102
+ if (svg.length > MAX_SVG_LENGTH) {
103
+ return { error: `The SVG is too large. Use one under ${Math.round(MAX_SVG_LENGTH / 1000)} KB.` };
104
+ }
105
+ if (!/^<svg[\s>]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
106
+ return { error: "This is not an SVG. It must start with <svg and end with </svg>." };
107
+ }
108
+ for (const [pattern, error] of BLOCKED) if (pattern.test(svg)) return { error };
109
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
110
+ let next = root;
111
+ if (!viewBoxOf(svg)) {
112
+ const width = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "width") ?? "")?.[1]);
113
+ const height = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "height") ?? "")?.[1]);
114
+ if (!(width > 0 && height > 0)) return { error: "The SVG needs a viewBox, or a width and height in pixels." };
115
+ next = withoutAttribute(next, "viewBox").replace(/^<svg/i, `<svg viewBox="0 0 ${width} ${height}"`);
116
+ }
117
+ // The image scales to its box from the viewBox, so a fixed size would only crop it.
118
+ next = withoutAttribute(withoutAttribute(next, "width"), "height");
119
+ if (!/\sxmlns\s*=/.test(next)) next = next.replace(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"');
120
+ return { svg: next + svg.slice(root.length) };
121
+ }
122
+
123
+ /** Keeps a color inside an attribute value; anything else becomes gray. */
124
+ export function safeColor(color: string): string {
125
+ return /^[#a-z0-9(),.%\s-]+$/i.test(color) ? color.trim() : "#888888";
126
+ }
127
+
128
+ const FILL_NONE = /(?<![\w-])(fill\s*:\s*)none\b/gi;
129
+
130
+ /** Fills shapes that only have an outline, with their stroke color or the icon color. */
131
+ export function fillOutlines(svg: string): string {
132
+ const tags = svg.replace(/<([a-z][\w:.-]*)(\s[^>]*?)?(\/?)>/gi, (tag, name: string, attrs: string | undefined, close: string) => {
133
+ if (!attrs) return tag;
134
+ const stroke = attribute(attrs, "stroke") ?? /(?<![\w-])stroke\s*:\s*([^;"']+)/i.exec(attrs)?.[1]?.trim();
135
+ const fill = stroke && stroke !== "none" ? stroke : "currentColor";
136
+ const next = attrs.replace(/(\sfill\s*=\s*["'])\s*none\s*(["'])/gi, `$1${fill}$2`).replace(FILL_NONE, `$1${fill}`);
137
+ return `<${name}${next}${close}>`;
138
+ });
139
+ return tags.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_all, open: string, css: string, end: string) =>
140
+ `${open}${css.replace(FILL_NONE, "$1currentColor")}${end}`,
141
+ );
142
+ }
143
+
144
+ const TINT_ID = "paseo-linear-tint";
145
+
146
+ /** Paints every visible pixel one color and keeps its transparency. */
147
+ export function tint(svg: string, color: string): string {
148
+ const [x, y, width, height] = viewBoxOf(svg) ?? [0, 0, 24, 24];
149
+ const filter =
150
+ `<filter id="${TINT_ID}" filterUnits="userSpaceOnUse" x="${x - width}" y="${y - height}" width="${width * 3}" height="${height * 3}" color-interpolation-filters="sRGB">` +
151
+ `<feFlood flood-color="${safeColor(color)}"/><feComposite in2="SourceAlpha" operator="in"/></filter>`;
152
+ return svg
153
+ .replace(ROOT_TAG, (root) => `${root}<defs>${filter}</defs><g filter="url(#${TINT_ID})">`)
154
+ .replace(/<\/svg>$/i, "</g></svg>");
155
+ }
156
+
157
+ export interface IconLook {
158
+ /** The color `currentColor` takes: the color Paseo gives the icon. */
159
+ ink: string;
160
+ /** One color for the whole icon, or null to keep its colors. */
161
+ paint: string | null;
162
+ solid: boolean;
163
+ }
164
+
165
+ /** The SVG to draw for saved markup, or null when it no longer passes the checks. */
166
+ export function iconMarkup(saved: string, look: IconLook): string | null {
167
+ const parsed = parseIconSvg(saved);
168
+ if ("error" in parsed) return null;
169
+ const filled = look.solid ? fillOutlines(parsed.svg) : parsed.svg;
170
+ const inked = filled.replace(/currentColor/gi, safeColor(look.ink));
171
+ return look.paint ? tint(inked, look.paint) : inked;
172
+ }
173
+
174
+ /** The one color a paint gives, or null for the icon's own colors. */
175
+ export function paintColor(icon: IconSettings, colors: { ink: string; accent: string }): string | null {
176
+ switch (icon.paint) {
177
+ case "original":
178
+ return null;
179
+ case "theme":
180
+ return colors.ink;
181
+ case "accent":
182
+ return colors.accent;
183
+ case "linear":
184
+ return LINEAR_INDIGO;
185
+ case "custom":
186
+ return icon.color;
187
+ }
188
+ }
@@ -0,0 +1,255 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ // Project knowledge: what the daemon learns about a project's files without an agent. It is
5
+ // kept on disk, so explore and setup prompts, ticket-text maps, and Linear Live can use it.
6
+
7
+ export const AREA_KINDS = ["service", "package", "folder"] as const;
8
+ export type AreaKind = (typeof AREA_KINDS)[number];
9
+
10
+ export const AreaSchema = z.object({
11
+ /** Folder with a trailing slash; "" is the repository root. */
12
+ path: z.string(),
13
+ name: z.string(),
14
+ kind: z.enum(AREA_KINDS),
15
+ /** What the folder holds, from its name, such as "backend" or "tests"; "" when unknown. */
16
+ role: z.string(),
17
+ /** Files that define it, such as package.json or Dockerfile. */
18
+ manifests: z.array(z.string()),
19
+ /** Files under the folder, at any depth. */
20
+ files: z.number(),
21
+ languages: z.array(z.string()),
22
+ /** One line from the setup agent; "" until it writes one. */
23
+ summary: z.string(),
24
+ });
25
+ export type Area = z.infer<typeof AreaSchema>;
26
+
27
+ /** The part of an area the app needs to label files. */
28
+ export const AreaRefSchema = AreaSchema.pick({
29
+ path: true,
30
+ name: true,
31
+ kind: true,
32
+ role: true,
33
+ summary: true,
34
+ });
35
+ export type AreaRef = z.infer<typeof AreaRefSchema>;
36
+
37
+ const CountSchema = z.object({ name: z.string(), files: z.number() });
38
+
39
+ export const ProjectKnowledgeSchema = z.object({
40
+ projectId: z.string(),
41
+ rootPath: z.string(),
42
+ inspectedAt: z.string(),
43
+ source: z.enum(["git", "walk"]),
44
+ /** Repo-relative file paths, sorted, at most MAX_KNOWLEDGE_FILES. */
45
+ files: z.array(z.string()),
46
+ fileCount: z.number(),
47
+ truncated: z.boolean(),
48
+ areas: z.array(AreaSchema),
49
+ languages: z.array(CountSchema),
50
+ /** Files that guide agents and contributors, such as AGENTS.md and CI workflows. */
51
+ guides: z.array(z.string()),
52
+ /** Commands the repository defines, such as "npm run check". */
53
+ commands: z.array(z.string()),
54
+ /** One line from the setup agent about the project; "" until it writes one. */
55
+ summary: z.string(),
56
+ summarizedAt: z.string().nullable(),
57
+ });
58
+ export type ProjectKnowledge = z.infer<typeof ProjectKnowledgeSchema>;
59
+
60
+ /** What the app gets: everything but the file list, plus file counts per top folder. */
61
+ export const ProjectBriefSchema = ProjectKnowledgeSchema.omit({ files: true }).extend({
62
+ folders: z.array(CountSchema),
63
+ });
64
+ export type ProjectBrief = z.infer<typeof ProjectBriefSchema>;
65
+
66
+ export const MAX_KNOWLEDGE_FILES = 20_000;
67
+ export const MAX_RESOLVE_PATHS = 200;
68
+
69
+ /** Saved knowledge of a project; the daemon inspects the project first when it has none. */
70
+ export const knowledgeGetRpc = defineRpc({
71
+ name: "linear.knowledge.get",
72
+ input: z.object({ projectId: z.string().min(1) }),
73
+ output: z.object({ knowledge: ProjectBriefSchema }),
74
+ });
75
+
76
+ export const knowledgeInspectRpc = defineRpc({
77
+ name: "linear.knowledge.inspect",
78
+ input: z.object({ projectId: z.string().min(1) }),
79
+ output: z.object({ knowledge: ProjectBriefSchema }),
80
+ });
81
+
82
+ /** The areas of an agent's project, and ticket paths matched to the project's real files. */
83
+ export const liveProjectRpc = defineRpc({
84
+ name: "linear.live.project",
85
+ input: z.object({
86
+ agentId: z.string().min(1),
87
+ paths: z.array(z.string()).max(MAX_RESOLVE_PATHS),
88
+ }),
89
+ output: z.object({
90
+ areas: z.array(AreaRefSchema),
91
+ resolved: z.array(z.object({ from: z.string(), to: z.string() })),
92
+ }),
93
+ });
94
+
95
+ export function toAreaRef(area: Area): AreaRef {
96
+ return { path: area.path, name: area.name, kind: area.kind, role: area.role, summary: area.summary };
97
+ }
98
+
99
+ /** The deepest area whose folder holds the path. */
100
+ export function areaOf<A extends { path: string }>(path: string, areas: readonly A[]): A | null {
101
+ let best: A | null = null;
102
+ for (const area of areas) {
103
+ if (path.startsWith(area.path) && (!best || area.path.length > best.path.length)) best = area;
104
+ }
105
+ return best;
106
+ }
107
+
108
+ /** Such as "billing-api · service", "server · backend", or "docs". */
109
+ export function areaLabel(area: AreaRef): string {
110
+ const what = area.role || (area.kind === "folder" ? "" : area.kind);
111
+ return what && what.toLowerCase() !== area.name.toLowerCase() ? `${area.name} · ${what}` : area.name;
112
+ }
113
+
114
+ function topFolder(path: string): string {
115
+ const index = path.indexOf("/");
116
+ return index < 0 ? "./" : path.slice(0, index + 1);
117
+ }
118
+
119
+ /** File counts per top folder; "./" counts files at the root. */
120
+ export function topFolders(files: readonly string[]): { name: string; files: number }[] {
121
+ const counts = new Map<string, number>();
122
+ for (const file of files) counts.set(topFolder(file), (counts.get(topFolder(file)) ?? 0) + 1);
123
+ return [...counts]
124
+ .map(([name, count]) => ({ name, files: count }))
125
+ .sort((a, b) => a.name.localeCompare(b.name));
126
+ }
127
+
128
+ /** Where an off-map file is: its area, or its top folder when the project has no knowledge. */
129
+ export function whereLabel(path: string, areas: readonly AreaRef[]): string {
130
+ const area = areaOf(path, areas);
131
+ return area && area.path !== "" ? areaLabel(area) : topFolder(path);
132
+ }
133
+
134
+ /** Folders to the given depth with their file counts, indented, for prompts. */
135
+ export function folderLines(files: readonly string[], depth = 2, limit = 60): string[] {
136
+ const counts = new Map<string, number>();
137
+ for (const file of files) {
138
+ const parts = file.split("/").slice(0, -1);
139
+ for (let level = 1; level <= Math.min(depth, parts.length); level += 1) {
140
+ const dir = `${parts.slice(0, level).join("/")}/`;
141
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
142
+ }
143
+ }
144
+ const dirs = [...counts.keys()].sort();
145
+ if (dirs.length > limit && depth > 1) return folderLines(files, depth - 1, limit);
146
+ const lines = dirs.slice(0, limit).map((dir) => {
147
+ const indent = " ".repeat(dir.split("/").length - 2);
148
+ return `${indent}${dir} (${counts.get(dir)})`;
149
+ });
150
+ if (dirs.length > limit) lines.push(`... ${dirs.length - limit} more folders`);
151
+ return lines;
152
+ }
153
+
154
+ function countLine(entries: readonly { name: string; files: number }[]): string {
155
+ return entries.map((entry) => `${entry.name} ${entry.files}`).join(", ");
156
+ }
157
+
158
+ /** The project map an agent prompt starts from. Empty without knowledge. */
159
+ export function knowledgeLines(knowledge: ProjectKnowledge | null): string[] {
160
+ if (!knowledge) return [];
161
+ const source = knowledge.source === "git" ? "git ls-files" : "a folder walk";
162
+ const lines = [
163
+ `Project map from the plugin's inspection (${knowledge.inspectedAt}, by ${source}). Start from`,
164
+ "it instead of listing the whole repository; files may have changed since.",
165
+ ];
166
+ if (knowledge.summary) lines.push(`- Project: ${knowledge.summary}`);
167
+ const truncated = knowledge.truncated ? ", list truncated" : "";
168
+ lines.push(`- ${knowledge.fileCount} files${truncated}. Languages: ${countLine(knowledge.languages) || "unknown"}.`);
169
+ if (knowledge.guides.length > 0) lines.push(`- Guides: ${knowledge.guides.join(", ")}`);
170
+ if (knowledge.commands.length > 0) lines.push(`- Commands: ${knowledge.commands.join(", ")}`);
171
+ if (knowledge.areas.length > 0) {
172
+ lines.push("Areas (services, packages, and main folders):");
173
+ for (const area of knowledge.areas) {
174
+ const manifests = area.manifests.length > 0 ? ` [${area.manifests.join(", ")}]` : "";
175
+ const languages = area.languages.length > 0 ? `, ${area.languages.join("/")}` : "";
176
+ const summary = area.summary ? `: ${area.summary}` : "";
177
+ const files = `${area.files} ${area.files === 1 ? "file" : "files"}`;
178
+ lines.push(`- ${area.path || "./"} ${areaLabel(area)}${manifests}, ${files}${languages}${summary}`);
179
+ }
180
+ }
181
+ lines.push("Folders (file count):", ...folderLines(knowledge.files));
182
+ return lines;
183
+ }
184
+
185
+ function byName(files: readonly string[]): Map<string, string[]> {
186
+ const index = new Map<string, string[]>();
187
+ for (const file of files) {
188
+ const name = file.slice(file.lastIndexOf("/") + 1);
189
+ const known = index.get(name);
190
+ if (known) known.push(file);
191
+ else index.set(name, [file]);
192
+ }
193
+ return index;
194
+ }
195
+
196
+ function dirsOf(files: readonly string[]): Set<string> {
197
+ const dirs = new Set<string>();
198
+ for (const file of files) {
199
+ let index = file.indexOf("/");
200
+ while (index >= 0) {
201
+ dirs.add(file.slice(0, index + 1));
202
+ index = file.indexOf("/", index + 1);
203
+ }
204
+ }
205
+ return dirs;
206
+ }
207
+
208
+ /** Ticket paths matched to real files: "linear.ts" to "server/linear.ts" when only one fits. */
209
+ export function resolvePaths(paths: readonly string[], files: readonly string[]): Map<string, string> {
210
+ const known = new Set(files);
211
+ const names = byName(files);
212
+ let dirs: Set<string> | null = null;
213
+ const resolved = new Map<string, string>();
214
+ for (const path of paths) {
215
+ let matches: string[];
216
+ if (path.endsWith("/")) {
217
+ dirs ??= dirsOf(files);
218
+ if (dirs.has(path)) continue;
219
+ matches = [...dirs].filter((dir) => dir.endsWith(`/${path}`));
220
+ } else {
221
+ if (known.has(path)) continue;
222
+ const name = path.slice(path.lastIndexOf("/") + 1);
223
+ matches = (names.get(name) ?? []).filter((file) => file === name || file.endsWith(`/${path}`));
224
+ }
225
+ if (matches.length === 1 && matches[0]) resolved.set(path, matches[0]);
226
+ }
227
+ return resolved;
228
+ }
229
+
230
+ const KINDS_SAID = "(?:service|package|app|module|library|lib|worker|folder|directory|dir)";
231
+
232
+ function escapeRegex(text: string): string {
233
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
234
+ }
235
+
236
+ function aliases(area: AreaRef): string[] {
237
+ const folder = area.path.replace(/\/$/, "").split("/").pop() ?? "";
238
+ return [...new Set([area.name, folder])].filter((alias) => alias.length >= 3);
239
+ }
240
+
241
+ // A service or package counts when its name appears; a plain folder only when the text says
242
+ // "the docs folder" or similar, since folder names like "server" are common words.
243
+ function mentions(text: string, alias: string, plain: boolean): boolean {
244
+ const word = `(?:^|[^\\w@/.-])${escapeRegex(alias)}(?![\\w@/-])`;
245
+ if (plain && alias.length >= 4 && new RegExp(word, "i").test(text)) return true;
246
+ return new RegExp(`${word}\\s+${KINDS_SAID}\\b`, "i").test(text);
247
+ }
248
+
249
+ /** Areas the text names, other than the whole repository. */
250
+ export function areasIn(text: string, areas: readonly AreaRef[]): AreaRef[] {
251
+ return areas.filter(
252
+ (area) =>
253
+ area.path !== "" && aliases(area).some((alias) => mentions(text, alias, area.kind !== "folder")),
254
+ );
255
+ }