@yannelli/paseo-linear-plugin 1.0.1 → 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 (63) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +134 -8
  3. package/client/access.tsx +67 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/compat.ts +23 -5
  6. package/client/connect.tsx +3 -2
  7. package/client/launch-assignments.tsx +182 -0
  8. package/client/launch-choices.ts +21 -6
  9. package/client/launch-guidance.tsx +103 -0
  10. package/client/launch-plan.ts +114 -17
  11. package/client/launch.tsx +55 -9
  12. package/client/live-agents.tsx +446 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-header.tsx +310 -0
  15. package/client/live-issues.tsx +345 -0
  16. package/client/live-map.tsx +351 -0
  17. package/client/live-panel.tsx +392 -0
  18. package/client/live-timeline.ts +379 -0
  19. package/client/pill.tsx +394 -0
  20. package/client/plugin-icon.tsx +75 -0
  21. package/client/project-init.tsx +192 -0
  22. package/client/project-knowledge.tsx +87 -0
  23. package/client/settings-access.tsx +56 -0
  24. package/client/settings-agents.tsx +113 -0
  25. package/client/settings-fields.tsx +11 -3
  26. package/client/settings-icon.tsx +277 -0
  27. package/client/settings-live.tsx +128 -0
  28. package/client/settings-overrides.tsx +148 -0
  29. package/client/settings-projects.tsx +26 -4
  30. package/client/settings-prompts.tsx +3 -3
  31. package/client/timeline-card.tsx +2 -1
  32. package/client/tool-buttons.tsx +68 -0
  33. package/client/ui.tsx +16 -7
  34. package/client/web.ts +81 -0
  35. package/index.client.tsx +160 -51
  36. package/index.server.ts +32 -5
  37. package/package.json +1 -1
  38. package/server/agent-hooks.ts +37 -0
  39. package/server/agent-runs.ts +312 -0
  40. package/server/agent-tools.ts +276 -0
  41. package/server/handlers.ts +12 -1
  42. package/server/init.ts +89 -0
  43. package/server/inspect.ts +392 -0
  44. package/server/knowledge.ts +157 -0
  45. package/server/live.ts +371 -0
  46. package/server/mcp-http.ts +156 -0
  47. package/server/stores.ts +126 -0
  48. package/server/subagent-logs.ts +146 -0
  49. package/server/sync.ts +133 -0
  50. package/shared/activity.ts +268 -0
  51. package/shared/agent-hooks.ts +68 -0
  52. package/shared/agent-tools.ts +157 -0
  53. package/shared/custom-icon.ts +188 -0
  54. package/shared/knowledge.ts +255 -0
  55. package/shared/live.ts +248 -0
  56. package/shared/map-model.ts +240 -0
  57. package/shared/project-setup.ts +56 -0
  58. package/shared/prompts.ts +109 -2
  59. package/shared/settings.ts +145 -3
  60. package/shared/shell-activity.ts +309 -0
  61. package/shared/shell-lexer.ts +180 -0
  62. package/shared/subagents.ts +106 -0
  63. package/shared/todo-sync.ts +158 -0
package/shared/live.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { McpHttpServerSchema, ToolPolicySchema } from "./agent-tools";
4
+ import { type AreaRef, areasIn } from "./knowledge";
5
+ import { IssueCardSchema, WorkflowStateSchema } from "./linear";
6
+
7
+ // Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
8
+ // carry their own labels so they get no pill, no sync, and no place in the linked agent list.
9
+
10
+ export const MapFileSchema = z.object({
11
+ path: z.string().min(1),
12
+ /** Issue key the file belongs to: the parent or one of its sub-issues. */
13
+ issue: z.string().min(1),
14
+ });
15
+ export type MapFile = z.infer<typeof MapFileSchema>;
16
+
17
+ export const IssueMapSchema = z.object({
18
+ identifier: z.string(),
19
+ source: z.enum(["semantic", "explore"]),
20
+ files: z.array(MapFileSchema),
21
+ createdAt: z.string(),
22
+ });
23
+ export type IssueMap = z.infer<typeof IssueMapSchema>;
24
+
25
+ export const JobSchema = z.object({
26
+ id: z.string(),
27
+ status: z.enum(["running", "done", "failed"]),
28
+ agentId: z.string().nullable(),
29
+ error: z.string().nullable(),
30
+ startedAt: z.string(),
31
+ });
32
+ export type Job = z.infer<typeof JobSchema>;
33
+
34
+ /** What the explore agent must return. */
35
+ export const ExploreResultSchema = z.object({
36
+ files: z.array(z.object({ path: z.string().min(1), issue: z.string().min(1) })).max(200),
37
+ });
38
+
39
+ export const InitProposalSchema = z.object({
40
+ instructions: z.string().default(""),
41
+ steps: z.array(z.string()).default([]),
42
+ implement: z.string().default(""),
43
+ review: z.string().default(""),
44
+ /** Saved to the project knowledge at once; not part of the prompt proposal. */
45
+ summary: z.string().default(""),
46
+ areas: z.array(z.object({ path: z.string(), summary: z.string() })).max(80).default([]),
47
+ });
48
+ export type InitProposal = z.infer<typeof InitProposalSchema>;
49
+
50
+ /** Files an explore run may list; the map shows this many and more. */
51
+ export const MAX_EXPLORE_FILES = 150;
52
+ export const MAX_LIST_DIRS = 40;
53
+ export const MAX_DIR_ENTRIES = 60;
54
+
55
+ export const listFilesRpc = defineRpc({
56
+ name: "linear.live.files",
57
+ input: z.object({ agentId: z.string().min(1), dirs: z.array(z.string()).max(MAX_LIST_DIRS) }),
58
+ output: z.object({
59
+ dirs: z.array(z.object({ dir: z.string(), files: z.array(z.string()), truncated: z.boolean() })),
60
+ }),
61
+ });
62
+
63
+ const SubagentLogSchema = z.object({
64
+ id: z.string(),
65
+ type: z.string(),
66
+ description: z.string(),
67
+ /** The parent's call that started it, when Claude recorded one. */
68
+ toolUseId: z.string().nullable(),
69
+ updatedAt: z.number(),
70
+ /** Its last reply ended the run. */
71
+ finished: z.boolean(),
72
+ items: z.array(
73
+ z.object({
74
+ type: z.literal("tool_call"),
75
+ callId: z.string(),
76
+ name: z.string(),
77
+ status: z.enum(["running", "completed", "failed"]),
78
+ detail: z.record(z.string(), z.unknown()),
79
+ }),
80
+ ),
81
+ });
82
+ export type SubagentLog = z.infer<typeof SubagentLogSchema>;
83
+
84
+ /** Tool calls of the subagents an agent started inside its provider. */
85
+ export const subagentLogsRpc = defineRpc({
86
+ name: "linear.live.subagents",
87
+ input: z.object({ agentId: z.string().min(1) }),
88
+ output: z.object({ runs: z.array(SubagentLogSchema) }),
89
+ });
90
+
91
+ /** Forgets the saved explore map of the agent's issue, so the map falls back to ticket text. */
92
+ export const clearMapRpc = defineRpc({
93
+ name: "linear.live.map.clear",
94
+ input: z.object({ agentId: z.string().min(1) }),
95
+ output: z.object({ cleared: z.boolean() }),
96
+ });
97
+
98
+ export const liveMapRpc = defineRpc({
99
+ name: "linear.live.map",
100
+ input: z.object({ identifier: z.string().min(1) }),
101
+ output: z.object({ map: IssueMapSchema.nullable(), job: JobSchema.nullable() }),
102
+ });
103
+
104
+ export const exploreRpc = defineRpc({
105
+ name: "linear.live.explore",
106
+ input: z.object({ agentId: z.string().min(1), force: z.boolean().default(false) }),
107
+ output: z.object({ job: JobSchema }),
108
+ });
109
+
110
+ export const LaunchConfigSchema = z.object({
111
+ provider: z.string().min(1),
112
+ thinkingOptionId: z.string().optional(),
113
+ modeId: z.string().optional(),
114
+ /** Only the folder of the issue's Claude Code hooks; see agent-hooks.ts. */
115
+ providerOptions: z.object({ extraArgs: z.object({ "plugin-dir": z.string().min(1) }) }).optional(),
116
+ /** Only the plugin's own MCP server; see agent-tools.ts. */
117
+ mcpServers: z.object({ linear: McpHttpServerSchema }).optional(),
118
+ toolPolicy: ToolPolicySchema.optional(),
119
+ });
120
+
121
+ // The daemon maps the files first and then starts the agent, so closing the app mid-way
122
+ // still starts it. With a saved map, or when exploring cannot start, the agent starts now.
123
+ export const LaunchInputSchema = z.object({
124
+ workspaceId: z.string().min(1),
125
+ identifier: z.string().min(1),
126
+ keyScope: z.string().nullable(),
127
+ agent: z.object({
128
+ config: LaunchConfigSchema,
129
+ title: z.string(),
130
+ prompt: z.string(),
131
+ labels: z.record(z.string(), z.string()),
132
+ }),
133
+ card: IssueCardSchema,
134
+ });
135
+ export type LaunchInput = z.infer<typeof LaunchInputSchema>;
136
+
137
+ export const launchAfterExploreRpc = defineRpc({
138
+ name: "linear.live.launch",
139
+ input: LaunchInputSchema,
140
+ output: z.object({ agentId: z.string().nullable(), job: JobSchema }),
141
+ });
142
+
143
+ export const SyncMoveSchema = z.object({
144
+ identifier: z.string(),
145
+ from: WorkflowStateSchema,
146
+ to: WorkflowStateSchema,
147
+ });
148
+
149
+ export const syncNowRpc = defineRpc({
150
+ name: "linear.sync.now",
151
+ input: z.object({ agentId: z.string().min(1) }),
152
+ output: z.object({ moves: z.array(SyncMoveSchema), skipped: z.string().nullable() }),
153
+ });
154
+
155
+ export const initStartRpc = defineRpc({
156
+ name: "linear.init.start",
157
+ input: z.object({ projectId: z.string().min(1), rootPath: z.string().min(1) }),
158
+ output: z.object({ job: JobSchema }),
159
+ });
160
+
161
+ export const initStatusRpc = defineRpc({
162
+ name: "linear.init.status",
163
+ input: z.object({ jobId: z.string().min(1) }),
164
+ output: z.object({ job: JobSchema.nullable(), proposal: InitProposalSchema.nullable() }),
165
+ });
166
+
167
+ const EXTENSIONS =
168
+ "ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|vue|svelte|css|scss|html|md|mdx|json|ya?ml|toml|sql|sh|prisma|graphql";
169
+ const PATH = new RegExp(
170
+ `(?:^|[\\s\`'"(\\[])((?:[\\w@.-]+/)*[\\w@.-]+\\.(?:${EXTENSIONS}))(?=$|[\\s\`'")\\],.:;!?])`,
171
+ "g",
172
+ );
173
+ const DIRECTORY = /`((?:[\w@.-]+\/)+)`/g;
174
+ const ISSUE_KEY = /\b[A-Z][A-Z0-9]*-\d+\b/g;
175
+
176
+ export function pathsIn(text: string): string[] {
177
+ const found = new Set<string>();
178
+ for (const match of text.matchAll(PATH)) if (match[1]) found.add(match[1].replace(/^\.\//, ""));
179
+ for (const match of text.matchAll(DIRECTORY)) if (match[1]) found.add(match[1]);
180
+ return [...found].filter((path) => !/^https?:/.test(path) && !path.includes("//"));
181
+ }
182
+
183
+ export interface SemanticIssue {
184
+ identifier: string;
185
+ title: string;
186
+ description: string | null;
187
+ children: readonly { identifier: string; title: string }[];
188
+ }
189
+
190
+ /** What project knowledge adds to a ticket-text map. */
191
+ export interface MapHints {
192
+ /** Ticket paths matched to real files, such as "linear.ts" to "server/linear.ts". */
193
+ resolved?: ReadonlyMap<string, string>;
194
+ /** A service or package the text names puts its folder on the map. */
195
+ areas?: readonly AreaRef[];
196
+ }
197
+
198
+ // Linear sends sub-issue titles but not their descriptions, so a path in the parent's text
199
+ // belongs to the sub-issue its line names and otherwise to the parent.
200
+ export function semanticMap(issue: SemanticIssue, now = new Date(), hints: MapHints = {}): IssueMap {
201
+ const owners = new Map<string, string>();
202
+ const childKeys = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
203
+ const found = (text: string) => [
204
+ ...pathsIn(text).map((path) => hints.resolved?.get(path) ?? path),
205
+ ...areasIn(text, hints.areas ?? []).map((area) => area.path),
206
+ ];
207
+ for (const child of issue.children) {
208
+ for (const path of found(child.title)) owners.set(path, child.identifier);
209
+ }
210
+ const lines = `${issue.title}\n${issue.description ?? ""}`.split("\n");
211
+ for (const line of lines) {
212
+ const named = (line.match(ISSUE_KEY) ?? []).find((key) => childKeys.has(key));
213
+ for (const path of found(line)) {
214
+ if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
215
+ }
216
+ }
217
+ return {
218
+ identifier: issue.identifier,
219
+ source: "semantic",
220
+ files: [...owners].map(([path, owner]) => ({ path, issue: owner })),
221
+ createdAt: now.toISOString(),
222
+ };
223
+ }
224
+
225
+ /** Parses the last JSON object in an agent reply: a fenced block, the whole text, or braces. */
226
+ export function lastJsonObject(reply: string): unknown {
227
+ const fences = [...reply.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)].reverse();
228
+ const candidates = [...fences.map((match) => match[1] ?? ""), reply.trim()];
229
+ const start = reply.indexOf("{");
230
+ const end = reply.lastIndexOf("}");
231
+ if (start >= 0 && end > start) candidates.push(reply.slice(start, end + 1));
232
+ for (const candidate of candidates) {
233
+ try {
234
+ const value: unknown = JSON.parse(candidate);
235
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
236
+ } catch {
237
+ // Not JSON; try the next candidate.
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** Directory of a repo path, with a trailing slash, or "" for the root. */
244
+ export function dirOf(path: string): string {
245
+ if (path.endsWith("/")) return path;
246
+ const index = path.lastIndexOf("/");
247
+ return index < 0 ? "" : path.slice(0, index + 1);
248
+ }
@@ -0,0 +1,240 @@
1
+ import type { DirTouch, FileTouch } from "./activity";
2
+ import { type AreaRef, areaLabel, areaOf, whereLabel } from "./knowledge";
3
+ import { dirOf, type MapFile } from "./live";
4
+
5
+ // The Linear Live map: predicted files and touched files grouped into folder zones, and the
6
+ // column layout for those zones. Pure, so it runs in tests without React Native.
7
+
8
+ const MAX_GHOSTS = 4;
9
+ /** Listing chips are hidden once a zone has this many real tiles. */
10
+ const GHOSTS_UNTIL = 6;
11
+
12
+ export interface MapTile {
13
+ path: string;
14
+ name: string;
15
+ owner: string | null;
16
+ ghost: boolean;
17
+ onMap: boolean;
18
+ touch: "none" | "read" | "edited" | "created";
19
+ added: number;
20
+ removed: number;
21
+ }
22
+
23
+ export type ZoneExplored = "none" | "listed" | "searched";
24
+
25
+ export interface MapZone {
26
+ dir: string;
27
+ tiles: MapTile[];
28
+ /** Files in the zone that are on the map. */
29
+ predicted: number;
30
+ /** Files in the zone the agent read or changed. */
31
+ touched: number;
32
+ /** Touched files that are on the map. */
33
+ hits: number;
34
+ explored: ZoneExplored;
35
+ /** The service, package, or main folder the zone is in, from the project knowledge. */
36
+ area: AreaRef | null;
37
+ }
38
+
39
+ /** Touched files the map did not predict, grouped by where they are. */
40
+ export interface OffMapGroup {
41
+ where: string;
42
+ files: number;
43
+ }
44
+
45
+ export interface MapModel {
46
+ zones: MapZone[];
47
+ touched: number;
48
+ hits: number;
49
+ offMap: OffMapGroup[];
50
+ }
51
+
52
+ // Search hits alone do not count: one broad search would flood the map with off-map tiles.
53
+ function isTouched(file: FileTouch): boolean {
54
+ return file.reads + file.edits > 0;
55
+ }
56
+
57
+ function touchState(file: FileTouch | undefined): MapTile["touch"] {
58
+ if (!file) return "none";
59
+ if (file.created) return "created";
60
+ return file.edits > 0 ? "edited" : "read";
61
+ }
62
+
63
+ interface Predictions {
64
+ files: ReadonlyMap<string, string>;
65
+ dirs: ReadonlyMap<string, string>;
66
+ }
67
+
68
+ // A map entry ending in "/" names a directory: it seeds a zone and owns the files inside.
69
+ function predictions(files: readonly MapFile[]): Predictions {
70
+ const paths = new Map<string, string>();
71
+ const dirs = new Map<string, string>();
72
+ for (const file of files) {
73
+ const target = file.path.endsWith("/") ? dirs : paths;
74
+ if (!target.has(file.path)) target.set(file.path, file.issue.toUpperCase());
75
+ }
76
+ return { files: paths, dirs };
77
+ }
78
+
79
+ function ownerOf(path: string, predicted: Predictions): string | null {
80
+ const direct = predicted.files.get(path);
81
+ if (direct) return direct;
82
+ for (const [dir, owner] of predicted.dirs) if (path.startsWith(dir)) return owner;
83
+ return null;
84
+ }
85
+
86
+ /** Zone directories: predicted ones in map order, then touched ones by name. */
87
+ export function zoneDirs(files: readonly MapFile[], touches: readonly FileTouch[]): string[] {
88
+ const dirs = new Set(files.map((file) => dirOf(file.path)));
89
+ const extra = touches.filter(isTouched).map((file) => dirOf(file.path));
90
+ for (const dir of [...new Set(extra)].sort()) dirs.add(dir);
91
+ return [...dirs];
92
+ }
93
+
94
+ // A listed folder counts for itself; a recursive search counts for every folder under it,
95
+ // except a search of the whole repository, which would mark every zone.
96
+ function exploredState(dir: string, dirs: readonly DirTouch[]): ZoneExplored {
97
+ let state: ZoneExplored = "none";
98
+ for (const touch of dirs) {
99
+ const covers = touch.path === dir || (touch.deep && touch.path !== "" && dir.startsWith(touch.path));
100
+ if (covers && touch.deep) return "searched";
101
+ if (covers) state = "listed";
102
+ }
103
+ return state;
104
+ }
105
+
106
+ export function buildMapModel(
107
+ files: readonly MapFile[],
108
+ touches: readonly FileTouch[],
109
+ listing: ReadonlyMap<string, readonly string[]>,
110
+ dirs: readonly DirTouch[] = [],
111
+ areas: readonly AreaRef[] = [],
112
+ ): MapModel {
113
+ const predicted = predictions(files);
114
+ const touched = new Map(touches.filter(isTouched).map((file) => [file.path, file]));
115
+ const zones = zoneDirs(files, touches).map((dir): MapZone => {
116
+ const inZone = (path: string) => dirOf(path) === dir;
117
+ const real = new Set([...predicted.files.keys(), ...touched.keys()].filter(inZone));
118
+ const tiles = [...real].sort().map((path): MapTile => {
119
+ const touch = touched.get(path);
120
+ const owner = ownerOf(path, predicted);
121
+ return {
122
+ path,
123
+ name: path.slice(dir.length),
124
+ owner,
125
+ ghost: false,
126
+ onMap: owner !== null,
127
+ touch: touchState(touch),
128
+ added: touch?.added ?? 0,
129
+ removed: touch?.removed ?? 0,
130
+ };
131
+ });
132
+ const ghosts = (tiles.length >= GHOSTS_UNTIL ? [] : (listing.get(dir) ?? []))
133
+ .filter((name) => !real.has(`${dir}${name}`))
134
+ .slice(0, MAX_GHOSTS)
135
+ .map((name): MapTile => {
136
+ const path = `${dir}${name}`;
137
+ const owner = ownerOf(path, predicted);
138
+ const onMap = owner !== null;
139
+ return { path, name, owner, ghost: true, onMap, touch: "none", added: 0, removed: 0 };
140
+ });
141
+ return {
142
+ dir,
143
+ tiles: [...tiles, ...ghosts],
144
+ predicted: tiles.filter((tile) => tile.onMap).length,
145
+ touched: tiles.filter((tile) => tile.touch !== "none").length,
146
+ hits: tiles.filter((tile) => tile.touch !== "none" && tile.onMap).length,
147
+ explored: exploredState(dir, dirs),
148
+ area: areaOf(dir, areas),
149
+ };
150
+ });
151
+ let hits = 0;
152
+ const off = new Map<string, number>();
153
+ for (const path of touched.keys()) {
154
+ if (ownerOf(path, predicted)) hits += 1;
155
+ else off.set(whereLabel(path, areas), (off.get(whereLabel(path, areas)) ?? 0) + 1);
156
+ }
157
+ const offMap = [...off]
158
+ .map(([where, count]) => ({ where, files: count }))
159
+ .sort((a, b) => b.files - a.files || a.where.localeCompare(b.where));
160
+ return { zones, touched: touched.size, hits, offMap };
161
+ }
162
+
163
+ export function hitRateLine(model: MapModel): string {
164
+ if (model.touched === 0) return "No files touched yet";
165
+ const noun = model.touched === 1 ? "file" : "files";
166
+ return `${model.touched} ${noun} touched, ${model.hits} on the map`;
167
+ }
168
+
169
+ const OFF_MAP_SHOWN = 4;
170
+
171
+ /** Such as "Off the map: billing-api · service (3), docs (1)"; null when all are on it. */
172
+ export function offMapLine(model: MapModel): string | null {
173
+ if (model.offMap.length === 0) return null;
174
+ const shown = model.offMap.slice(0, OFF_MAP_SHOWN).map((group) => `${group.where} (${group.files})`);
175
+ const more = model.offMap.length - OFF_MAP_SHOWN;
176
+ return `Off the map: ${shown.join(", ")}${more > 0 ? `, ${more} more` : ""}`;
177
+ }
178
+
179
+ /** Where the file is when the agent touched it off the map; null on the map or untouched. */
180
+ export function offMapWhere(model: MapModel, path: string | null, areas: readonly AreaRef[]): string | null {
181
+ if (!path) return null;
182
+ for (const zone of model.zones) {
183
+ const tile = zone.tiles.find((entry) => entry.path === path && !entry.ghost);
184
+ if (tile) return tile.touch !== "none" && !tile.onMap ? whereLabel(path, areas) : null;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /** The area a zone header names; only the part its folder name does not already say. */
190
+ export function zoneAreaLabel(zone: MapZone): string | null {
191
+ const area = zone.area;
192
+ if (!area || area.path === "") return null;
193
+ const folder = area.path.replace(/\/$/, "").split("/").pop();
194
+ if (zone.dir !== area.path || area.name !== folder) return areaLabel(area);
195
+ return area.role || (area.kind === "folder" ? null : area.kind);
196
+ }
197
+
198
+ export const ZONE_GAP = 10;
199
+
200
+ // Rough zone height from its tiles, so zones can go to the shortest column. Tiles wrap, so
201
+ // count how many fit per row by name length.
202
+ function zoneHeight(zone: MapZone, width: number): number {
203
+ const inner = Math.max(120, width - 26);
204
+ let rows = 1;
205
+ let used = 0;
206
+ for (const tile of zone.tiles) {
207
+ const tileWidth = Math.min(inner, 32 + tile.name.length * 7);
208
+ if (used > 0 && used + tileWidth > inner) {
209
+ rows += 1;
210
+ used = 0;
211
+ }
212
+ used += tileWidth + 6;
213
+ }
214
+ return 46 + rows * 32;
215
+ }
216
+
217
+ /**
218
+ * Splits zones into columns in map order. A zone in `placed` keeps its column; a new zone
219
+ * goes to the shortest column and is added to `placed`.
220
+ */
221
+ export function masonry(
222
+ zones: readonly MapZone[],
223
+ columns: number,
224
+ width: number,
225
+ placed: Map<string, number>,
226
+ ): MapZone[][] {
227
+ const result: MapZone[][] = Array.from({ length: columns }, () => []);
228
+ const heights: number[] = Array.from({ length: columns }, () => 0);
229
+ const columnWidth = (width - ZONE_GAP * (columns - 1)) / columns;
230
+ for (const zone of zones) {
231
+ let column = placed.get(zone.dir);
232
+ if (column === undefined || column >= columns) {
233
+ column = heights.indexOf(Math.min(...heights));
234
+ placed.set(zone.dir, column);
235
+ }
236
+ result[column]?.push(zone);
237
+ heights[column] = (heights[column] ?? 0) + zoneHeight(zone, columnWidth) + ZONE_GAP;
238
+ }
239
+ return result;
240
+ }
@@ -0,0 +1,56 @@
1
+ import type { InitProposal } from "./live";
2
+ import type { ProjectConfig } from "./settings";
3
+
4
+ // A setup proposal shows only fields that change; accepted fields append to the prompts.
5
+ export type ProposalField = "instructions" | "steps" | "implement" | "review";
6
+ type PromptProposal = Pick<InitProposal, ProposalField>;
7
+
8
+ export interface FieldDiff {
9
+ field: ProposalField;
10
+ label: string;
11
+ current: string;
12
+ proposed: string;
13
+ }
14
+
15
+ export function proposalDiffs(config: ProjectConfig, proposal: PromptProposal): FieldDiff[] {
16
+ const steps = proposal.steps.map((step) => step.trim()).filter(Boolean);
17
+ const rows: FieldDiff[] = [
18
+ {
19
+ field: "instructions",
20
+ label: "Project instructions",
21
+ current: config.instructions.trim(),
22
+ proposed: proposal.instructions.trim(),
23
+ },
24
+ {
25
+ field: "steps",
26
+ label: "Project steps",
27
+ current: config.steps.join("\n"),
28
+ proposed: steps.join("\n"),
29
+ },
30
+ {
31
+ field: "implement",
32
+ label: "Implement: append to the prompt",
33
+ current: config.implement.mode === "default" ? "" : config.implement.text.trim(),
34
+ proposed: proposal.implement.trim(),
35
+ },
36
+ {
37
+ field: "review",
38
+ label: "Review: append to the prompt",
39
+ current: config.review.mode === "default" ? "" : config.review.text.trim(),
40
+ proposed: proposal.review.trim(),
41
+ },
42
+ ];
43
+ return rows.filter((row) => row.proposed && row.proposed !== row.current);
44
+ }
45
+
46
+ export function applyProposal(
47
+ proposal: PromptProposal,
48
+ fields: ReadonlySet<ProposalField>,
49
+ ): Partial<ProjectConfig> {
50
+ const patch: Partial<ProjectConfig> = {};
51
+ if (fields.has("instructions")) patch.instructions = proposal.instructions.trim();
52
+ if (fields.has("steps")) patch.steps = proposal.steps.map((s) => s.trim()).filter(Boolean);
53
+ if (fields.has("implement")) patch.implement = { mode: "append", text: proposal.implement.trim() };
54
+ if (fields.has("review")) patch.review = { mode: "append", text: proposal.review.trim() };
55
+ return patch;
56
+ }
package/shared/prompts.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { TOOLS_SERVER } from "./agent-tools";
1
2
  import type { IssueDetail } from "./linear";
2
- import type { AgentAction, LinearSettings, ProjectConfig } from "./settings";
3
+ import type { AgentAction, Guidance, LinearSettings, ProjectConfig } from "./settings";
4
+ import { todoConventionLine } from "./todo-sync";
3
5
 
4
6
  export const DEFAULT_TEMPLATES: Record<AgentAction, string> = {
5
7
  implement: [
@@ -111,6 +113,91 @@ export interface ComposePromptInput {
111
113
  project: ProjectConfig | null;
112
114
  includeComments: boolean;
113
115
  extraInstructions: string;
116
+ /** Launch options that add instructions; the project's saved ones when omitted. */
117
+ guidance?: Guidance;
118
+ /** The Linear tools the agent gets; all of them when omitted. */
119
+ tools?: PromptTools;
120
+ }
121
+
122
+ /** What the prompt may tell the agent about the plugin's linear MCP server. */
123
+ export interface PromptTools {
124
+ /** The agent gets the server. */
125
+ enabled: boolean;
126
+ /** The server has edit_issue. */
127
+ allowEdits: boolean;
128
+ /** Agents that start_agent may run at the same time; 0 has no limit. */
129
+ maxAgents: number;
130
+ /** Sub-issues that the user gave an agent, with the agent's name. */
131
+ assignments: readonly { identifier: string; label: string }[];
132
+ }
133
+
134
+ export const ALL_TOOLS: PromptTools = { enabled: true, allowEdits: true, maxAgents: 0, assignments: [] };
135
+
136
+ const limitLine = (max: number) =>
137
+ `- Run at most ${max} of these agents at the same time. start_agent refuses more until one of them finishes.`;
138
+
139
+ /** The instructions each guidance option adds, in prompt order. */
140
+ export function guidanceSections(issue: IssueDetail, guidance: Guidance, tools: PromptTools = ALL_TOOLS): string[] {
141
+ const sections: string[] = [];
142
+ const example = issue.children[0]?.identifier ?? issue.identifier;
143
+ const edits = tools.enabled && tools.allowEdits;
144
+ if (guidance.updateLinear) {
145
+ const lines = [
146
+ "Keep Linear current while you work:",
147
+ `- Start each todo with the issue key it is for, such as "${example}: add the form".`,
148
+ "- Mark a todo in progress when you start it and completed when it is done. Linear Live moves the issue from these todos.",
149
+ ];
150
+ if (edits) {
151
+ lines.push(
152
+ `- Use the ${TOOLS_SERVER} MCP tools: read_issue shows an issue, and edit_issue changes its description.`,
153
+ "- When you finish a task list item in a description, check it off: replace `- [ ] item` with `- [x] item`.",
154
+ "- When the plan changes, update the description. Do not post comments.",
155
+ );
156
+ } else if (tools.enabled) {
157
+ lines.push(`- Use the ${TOOLS_SERVER} MCP tool read_issue to read an issue. Do not change descriptions or post comments.`);
158
+ } else lines.push("- Do not change the issue or post comments.");
159
+ sections.push(lines.join("\n"));
160
+ }
161
+ if (guidance.subagentKeys) {
162
+ sections.push(
163
+ [
164
+ "When you start a subagent:",
165
+ `- Begin its description and its prompt with the Linear issue key it works on, such as "${example}: review the form".`,
166
+ "- Tell it to put that key in the first line of its reply, and to state the new key if its work moves to another issue.",
167
+ ].join("\n"),
168
+ );
169
+ }
170
+ // Handing off work needs start_agent, so without the tools these sections are left out.
171
+ const handOff = tools.enabled && guidance.paseoSubagents;
172
+ const assigned = tools.enabled ? tools.assignments : [];
173
+ if (handOff) {
174
+ sections.push(
175
+ [
176
+ "Hand off work to Paseo agents, not to your built-in subagent tool:",
177
+ `- Start each one with the ${TOOLS_SERVER} start_agent tool, one agent for each sub-issue. It runs on your provider and model${assigned.length > 0 ? ", or on the agent the user chose for the sub-issue" : ""}.`,
178
+ "- Set its thinking to fit the task: low for search and reading, medium for routine edits, high for design, debugging, and review.",
179
+ `- Start its title with the issue key, such as "${example}: write the tests". Give the full task in its prompt, because it does not see your conversation.`,
180
+ "- Call wait_agent for each agent and read its result before you continue.",
181
+ ...(tools.maxAgents > 0 ? [limitLine(tools.maxAgents)] : []),
182
+ ].join("\n"),
183
+ );
184
+ }
185
+ if (assigned.length > 0) {
186
+ const lines = [
187
+ `The user chose an agent for these sub-issues. Start one agent for each of them with the ${TOOLS_SERVER} start_agent tool. The plugin runs it on the chosen agent. Do not do this work yourself.`,
188
+ ...assigned.map((entry) => `- ${entry.identifier}: ${entry.label}`),
189
+ ];
190
+ if (!handOff) {
191
+ lines.push(
192
+ "For each of these agents:",
193
+ `- Start its title with the issue key, such as "${assigned[0]?.identifier}: write the tests". Give the full task in its prompt, because it does not see your conversation.`,
194
+ "- Call wait_agent for each agent and read its result before you continue.",
195
+ ...(tools.maxAgents > 0 ? [limitLine(tools.maxAgents)] : []),
196
+ );
197
+ }
198
+ sections.push(lines.join("\n"));
199
+ }
200
+ return sections;
114
201
  }
115
202
 
116
203
  export function composePrompt(input: ComposePromptInput): string {
@@ -128,6 +215,8 @@ export function composePrompt(input: ComposePromptInput): string {
128
215
  ];
129
216
  // A template that drops {{issue}} still gets the snapshot so the agent never works blind.
130
217
  if (!/\{\{\s*issue\s*\}\}/.test(template)) sections.push(snapshot);
218
+ const convention = input.action === "implement" ? todoConventionLine(issue.children) : null;
219
+ if (convention) sections.push(convention);
131
220
  const instructions = project?.instructions.trim();
132
221
  if (instructions) sections.push(`Project instructions:\n${instructions}`);
133
222
  const steps = project?.steps.map((step) => step.trim()).filter(Boolean) ?? [];
@@ -136,6 +225,8 @@ export function composePrompt(input: ComposePromptInput): string {
136
225
  `Project steps:\n${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`,
137
226
  );
138
227
  }
228
+ const guidance = input.guidance ?? project?.guidance;
229
+ if (guidance) sections.push(...guidanceSections(issue, guidance, input.tools));
139
230
  const extra = input.extraInstructions.trim();
140
231
  if (extra) sections.push(`Additional instructions:\n${extra}`);
141
232
  return sections.join("\n\n");
@@ -147,4 +238,20 @@ export function agentTitle(action: AgentAction, issue: { identifier: string; tit
147
238
  return `${prefix}: ${title}`.slice(0, 120);
148
239
  }
149
240
 
150
- export const AGENT_LABELS = { issue: "linear.issue", action: "linear.action" } as const;
241
+ export const AGENT_LABELS = {
242
+ issue: "linear.issue",
243
+ action: "linear.action",
244
+ /** The Paseo project whose Linear key loaded the issue. */
245
+ project: "linear.project",
246
+ explore: "linear.explore",
247
+ init: "linear.init",
248
+ /** The grant of the agent's Linear MCP tools, so a tool call can find its agent. */
249
+ tools: "linear.tools",
250
+ /** The grant whose start_agent call made this agent. */
251
+ startedBy: "linear.started-by",
252
+ } as const;
253
+
254
+ /** Agents the plugin starts for itself: explore and project setup. */
255
+ export function isInternalAgent(labels: Readonly<Record<string, string>> | undefined): boolean {
256
+ return Boolean(labels?.[AGENT_LABELS.explore] || labels?.[AGENT_LABELS.init]);
257
+ }