@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.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.
- package/OVERVIEW.md +19 -1
- package/README.md +73 -3
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-plan.ts +36 -14
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/shared/live.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { IssueCardSchema, WorkflowStateSchema } from "./linear";
|
|
4
|
+
|
|
5
|
+
// Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
|
|
6
|
+
// carry their own labels so they get no pill, no sync, and no place in the linked agent list.
|
|
7
|
+
|
|
8
|
+
export const MapFileSchema = z.object({
|
|
9
|
+
path: z.string().min(1),
|
|
10
|
+
/** Issue key the file belongs to: the parent or one of its sub-issues. */
|
|
11
|
+
issue: z.string().min(1),
|
|
12
|
+
});
|
|
13
|
+
export type MapFile = z.infer<typeof MapFileSchema>;
|
|
14
|
+
|
|
15
|
+
export const IssueMapSchema = z.object({
|
|
16
|
+
identifier: z.string(),
|
|
17
|
+
source: z.enum(["semantic", "explore"]),
|
|
18
|
+
files: z.array(MapFileSchema),
|
|
19
|
+
createdAt: z.string(),
|
|
20
|
+
});
|
|
21
|
+
export type IssueMap = z.infer<typeof IssueMapSchema>;
|
|
22
|
+
|
|
23
|
+
export const JobSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
status: z.enum(["running", "done", "failed"]),
|
|
26
|
+
agentId: z.string().nullable(),
|
|
27
|
+
error: z.string().nullable(),
|
|
28
|
+
startedAt: z.string(),
|
|
29
|
+
});
|
|
30
|
+
export type Job = z.infer<typeof JobSchema>;
|
|
31
|
+
|
|
32
|
+
/** What the explore agent must return. */
|
|
33
|
+
export const ExploreResultSchema = z.object({
|
|
34
|
+
files: z.array(z.object({ path: z.string().min(1), issue: z.string().min(1) })).max(200),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const InitProposalSchema = z.object({
|
|
38
|
+
instructions: z.string().default(""),
|
|
39
|
+
steps: z.array(z.string()).default([]),
|
|
40
|
+
implement: z.string().default(""),
|
|
41
|
+
review: z.string().default(""),
|
|
42
|
+
});
|
|
43
|
+
export type InitProposal = z.infer<typeof InitProposalSchema>;
|
|
44
|
+
|
|
45
|
+
export const MAX_LIST_DIRS = 40;
|
|
46
|
+
export const MAX_DIR_ENTRIES = 60;
|
|
47
|
+
|
|
48
|
+
export const listFilesRpc = defineRpc({
|
|
49
|
+
name: "linear.live.files",
|
|
50
|
+
input: z.object({ agentId: z.string().min(1), dirs: z.array(z.string()).max(MAX_LIST_DIRS) }),
|
|
51
|
+
output: z.object({
|
|
52
|
+
dirs: z.array(z.object({ dir: z.string(), files: z.array(z.string()), truncated: z.boolean() })),
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const MAX_LINK_FILES = 200;
|
|
57
|
+
|
|
58
|
+
/** Imports and other links between the files on the graph. */
|
|
59
|
+
export const linksRpc = defineRpc({
|
|
60
|
+
name: "linear.live.links",
|
|
61
|
+
input: z.object({ agentId: z.string().min(1), files: z.array(z.string().min(1)).max(MAX_LINK_FILES) }),
|
|
62
|
+
output: z.object({
|
|
63
|
+
links: z.array(z.object({ from: z.string(), to: z.string(), kind: z.enum(["import", "link", "mention"]) })),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const liveMapRpc = defineRpc({
|
|
68
|
+
name: "linear.live.map",
|
|
69
|
+
input: z.object({ identifier: z.string().min(1) }),
|
|
70
|
+
output: z.object({ map: IssueMapSchema.nullable(), job: JobSchema.nullable() }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const exploreRpc = defineRpc({
|
|
74
|
+
name: "linear.live.explore",
|
|
75
|
+
input: z.object({ agentId: z.string().min(1), force: z.boolean().default(false) }),
|
|
76
|
+
output: z.object({ job: JobSchema }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const LaunchConfigSchema = z.object({
|
|
80
|
+
provider: z.string().min(1),
|
|
81
|
+
thinkingOptionId: z.string().optional(),
|
|
82
|
+
modeId: z.string().optional(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The daemon maps the files first and then starts the agent, so closing the app mid-way
|
|
86
|
+
// still starts it. With a saved map, or when exploring cannot start, the agent starts now.
|
|
87
|
+
export const LaunchInputSchema = z.object({
|
|
88
|
+
workspaceId: z.string().min(1),
|
|
89
|
+
identifier: z.string().min(1),
|
|
90
|
+
keyScope: z.string().nullable(),
|
|
91
|
+
agent: z.object({
|
|
92
|
+
config: LaunchConfigSchema,
|
|
93
|
+
title: z.string(),
|
|
94
|
+
prompt: z.string(),
|
|
95
|
+
labels: z.record(z.string(), z.string()),
|
|
96
|
+
}),
|
|
97
|
+
card: IssueCardSchema,
|
|
98
|
+
});
|
|
99
|
+
export type LaunchInput = z.infer<typeof LaunchInputSchema>;
|
|
100
|
+
|
|
101
|
+
export const launchAfterExploreRpc = defineRpc({
|
|
102
|
+
name: "linear.live.launch",
|
|
103
|
+
input: LaunchInputSchema,
|
|
104
|
+
output: z.object({ agentId: z.string().nullable(), job: JobSchema }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const SyncMoveSchema = z.object({
|
|
108
|
+
identifier: z.string(),
|
|
109
|
+
from: WorkflowStateSchema,
|
|
110
|
+
to: WorkflowStateSchema,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const syncNowRpc = defineRpc({
|
|
114
|
+
name: "linear.sync.now",
|
|
115
|
+
input: z.object({ agentId: z.string().min(1) }),
|
|
116
|
+
output: z.object({ moves: z.array(SyncMoveSchema), skipped: z.string().nullable() }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const initStartRpc = defineRpc({
|
|
120
|
+
name: "linear.init.start",
|
|
121
|
+
input: z.object({ projectId: z.string().min(1), rootPath: z.string().min(1) }),
|
|
122
|
+
output: z.object({ job: JobSchema }),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const initStatusRpc = defineRpc({
|
|
126
|
+
name: "linear.init.status",
|
|
127
|
+
input: z.object({ jobId: z.string().min(1) }),
|
|
128
|
+
output: z.object({ job: JobSchema.nullable(), proposal: InitProposalSchema.nullable() }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const EXTENSIONS =
|
|
132
|
+
"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";
|
|
133
|
+
const PATH = new RegExp(
|
|
134
|
+
`(?:^|[\\s\`'"(\\[])((?:[\\w@.-]+/)*[\\w@.-]+\\.(?:${EXTENSIONS}))(?=$|[\\s\`'")\\],.:;!?])`,
|
|
135
|
+
"g",
|
|
136
|
+
);
|
|
137
|
+
const DIRECTORY = /`((?:[\w@.-]+\/)+)`/g;
|
|
138
|
+
const ISSUE_KEY = /\b[A-Z][A-Z0-9]*-\d+\b/g;
|
|
139
|
+
|
|
140
|
+
export function pathsIn(text: string): string[] {
|
|
141
|
+
const found = new Set<string>();
|
|
142
|
+
for (const match of text.matchAll(PATH)) if (match[1]) found.add(match[1].replace(/^\.\//, ""));
|
|
143
|
+
for (const match of text.matchAll(DIRECTORY)) if (match[1]) found.add(match[1]);
|
|
144
|
+
return [...found].filter((path) => !/^https?:/.test(path) && !path.includes("//"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface SemanticIssue {
|
|
148
|
+
identifier: string;
|
|
149
|
+
title: string;
|
|
150
|
+
description: string | null;
|
|
151
|
+
children: readonly { identifier: string; title: string }[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Linear sends sub-issue titles but not their descriptions, so a path in the parent's text
|
|
155
|
+
// belongs to the sub-issue its line names and otherwise to the parent.
|
|
156
|
+
export function semanticMap(issue: SemanticIssue, now = new Date()): IssueMap {
|
|
157
|
+
const owners = new Map<string, string>();
|
|
158
|
+
const childKeys = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
|
|
159
|
+
for (const child of issue.children) {
|
|
160
|
+
for (const path of pathsIn(child.title)) owners.set(path, child.identifier);
|
|
161
|
+
}
|
|
162
|
+
const lines = `${issue.title}\n${issue.description ?? ""}`.split("\n");
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const named = (line.match(ISSUE_KEY) ?? []).find((key) => childKeys.has(key));
|
|
165
|
+
for (const path of pathsIn(line)) {
|
|
166
|
+
if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
identifier: issue.identifier,
|
|
171
|
+
source: "semantic",
|
|
172
|
+
files: [...owners].map(([path, owner]) => ({ path, issue: owner })),
|
|
173
|
+
createdAt: now.toISOString(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Parses the last JSON object in an agent reply: a fenced block, the whole text, or braces. */
|
|
178
|
+
export function lastJsonObject(reply: string): unknown {
|
|
179
|
+
const fences = [...reply.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)].reverse();
|
|
180
|
+
const candidates = [...fences.map((match) => match[1] ?? ""), reply.trim()];
|
|
181
|
+
const start = reply.indexOf("{");
|
|
182
|
+
const end = reply.lastIndexOf("}");
|
|
183
|
+
if (start >= 0 && end > start) candidates.push(reply.slice(start, end + 1));
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
try {
|
|
186
|
+
const value: unknown = JSON.parse(candidate);
|
|
187
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value;
|
|
188
|
+
} catch {
|
|
189
|
+
// Not JSON; try the next candidate.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Directory of a repo path, with a trailing slash, or "" for the root. */
|
|
196
|
+
export function dirOf(path: string): string {
|
|
197
|
+
if (path.endsWith("/")) return path;
|
|
198
|
+
const index = path.lastIndexOf("/");
|
|
199
|
+
return index < 0 ? "" : path.slice(0, index + 1);
|
|
200
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { DirTouch, FileTouch } from "./activity";
|
|
2
|
+
import { dirOf, type MapFile } from "./live";
|
|
3
|
+
|
|
4
|
+
// The Linear Live map: predicted files and touched files grouped into folder zones, and the
|
|
5
|
+
// column layout for those zones. Pure, so it runs in tests without React Native.
|
|
6
|
+
|
|
7
|
+
const MAX_GHOSTS = 4;
|
|
8
|
+
/** Listing chips are hidden once a zone has this many real tiles. */
|
|
9
|
+
const GHOSTS_UNTIL = 6;
|
|
10
|
+
|
|
11
|
+
export interface MapTile {
|
|
12
|
+
path: string;
|
|
13
|
+
name: string;
|
|
14
|
+
owner: string | null;
|
|
15
|
+
ghost: boolean;
|
|
16
|
+
onMap: boolean;
|
|
17
|
+
touch: "none" | "read" | "edited" | "created";
|
|
18
|
+
added: number;
|
|
19
|
+
removed: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ZoneExplored = "none" | "listed" | "searched";
|
|
23
|
+
|
|
24
|
+
export interface MapZone {
|
|
25
|
+
dir: string;
|
|
26
|
+
tiles: MapTile[];
|
|
27
|
+
/** Files in the zone that are on the map. */
|
|
28
|
+
predicted: number;
|
|
29
|
+
/** Files in the zone the agent read or changed. */
|
|
30
|
+
touched: number;
|
|
31
|
+
/** Touched files that are on the map. */
|
|
32
|
+
hits: number;
|
|
33
|
+
explored: ZoneExplored;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MapModel {
|
|
37
|
+
zones: MapZone[];
|
|
38
|
+
touched: number;
|
|
39
|
+
hits: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Search hits alone do not count: one broad search would flood the map with off-map tiles.
|
|
43
|
+
function isTouched(file: FileTouch): boolean {
|
|
44
|
+
return file.reads + file.edits > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function touchState(file: FileTouch | undefined): MapTile["touch"] {
|
|
48
|
+
if (!file) return "none";
|
|
49
|
+
if (file.created) return "created";
|
|
50
|
+
return file.edits > 0 ? "edited" : "read";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Predictions {
|
|
54
|
+
files: ReadonlyMap<string, string>;
|
|
55
|
+
dirs: ReadonlyMap<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A map entry ending in "/" names a directory: it seeds a zone and owns the files inside.
|
|
59
|
+
function predictions(files: readonly MapFile[]): Predictions {
|
|
60
|
+
const paths = new Map<string, string>();
|
|
61
|
+
const dirs = new Map<string, string>();
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const target = file.path.endsWith("/") ? dirs : paths;
|
|
64
|
+
if (!target.has(file.path)) target.set(file.path, file.issue.toUpperCase());
|
|
65
|
+
}
|
|
66
|
+
return { files: paths, dirs };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ownerOf(path: string, predicted: Predictions): string | null {
|
|
70
|
+
const direct = predicted.files.get(path);
|
|
71
|
+
if (direct) return direct;
|
|
72
|
+
for (const [dir, owner] of predicted.dirs) if (path.startsWith(dir)) return owner;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Zone directories: predicted ones in map order, then touched ones by name. */
|
|
77
|
+
export function zoneDirs(files: readonly MapFile[], touches: readonly FileTouch[]): string[] {
|
|
78
|
+
const dirs = new Set(files.map((file) => dirOf(file.path)));
|
|
79
|
+
const extra = touches.filter(isTouched).map((file) => dirOf(file.path));
|
|
80
|
+
for (const dir of [...new Set(extra)].sort()) dirs.add(dir);
|
|
81
|
+
return [...dirs];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A listed folder counts for itself; a recursive search counts for every folder under it,
|
|
85
|
+
// except a search of the whole repository, which would mark every zone.
|
|
86
|
+
function exploredState(dir: string, dirs: readonly DirTouch[]): ZoneExplored {
|
|
87
|
+
let state: ZoneExplored = "none";
|
|
88
|
+
for (const touch of dirs) {
|
|
89
|
+
const covers = touch.path === dir || (touch.deep && touch.path !== "" && dir.startsWith(touch.path));
|
|
90
|
+
if (covers && touch.deep) return "searched";
|
|
91
|
+
if (covers) state = "listed";
|
|
92
|
+
}
|
|
93
|
+
return state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildMapModel(
|
|
97
|
+
files: readonly MapFile[],
|
|
98
|
+
touches: readonly FileTouch[],
|
|
99
|
+
listing: ReadonlyMap<string, readonly string[]>,
|
|
100
|
+
dirs: readonly DirTouch[] = [],
|
|
101
|
+
): MapModel {
|
|
102
|
+
const predicted = predictions(files);
|
|
103
|
+
const touched = new Map(touches.filter(isTouched).map((file) => [file.path, file]));
|
|
104
|
+
const zones = zoneDirs(files, touches).map((dir): MapZone => {
|
|
105
|
+
const inZone = (path: string) => dirOf(path) === dir;
|
|
106
|
+
const real = new Set([...predicted.files.keys(), ...touched.keys()].filter(inZone));
|
|
107
|
+
const tiles = [...real].sort().map((path): MapTile => {
|
|
108
|
+
const touch = touched.get(path);
|
|
109
|
+
const owner = ownerOf(path, predicted);
|
|
110
|
+
return {
|
|
111
|
+
path,
|
|
112
|
+
name: path.slice(dir.length),
|
|
113
|
+
owner,
|
|
114
|
+
ghost: false,
|
|
115
|
+
onMap: owner !== null,
|
|
116
|
+
touch: touchState(touch),
|
|
117
|
+
added: touch?.added ?? 0,
|
|
118
|
+
removed: touch?.removed ?? 0,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
const ghosts = (tiles.length >= GHOSTS_UNTIL ? [] : (listing.get(dir) ?? []))
|
|
122
|
+
.filter((name) => !real.has(`${dir}${name}`))
|
|
123
|
+
.slice(0, MAX_GHOSTS)
|
|
124
|
+
.map((name): MapTile => {
|
|
125
|
+
const path = `${dir}${name}`;
|
|
126
|
+
const owner = ownerOf(path, predicted);
|
|
127
|
+
const onMap = owner !== null;
|
|
128
|
+
return { path, name, owner, ghost: true, onMap, touch: "none", added: 0, removed: 0 };
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
dir,
|
|
132
|
+
tiles: [...tiles, ...ghosts],
|
|
133
|
+
predicted: tiles.filter((tile) => tile.onMap).length,
|
|
134
|
+
touched: tiles.filter((tile) => tile.touch !== "none").length,
|
|
135
|
+
hits: tiles.filter((tile) => tile.touch !== "none" && tile.onMap).length,
|
|
136
|
+
explored: exploredState(dir, dirs),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
let hits = 0;
|
|
140
|
+
for (const path of touched.keys()) if (ownerOf(path, predicted)) hits += 1;
|
|
141
|
+
return { zones, touched: touched.size, hits };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function hitRateLine(model: MapModel): string {
|
|
145
|
+
if (model.touched === 0) return "No files touched yet";
|
|
146
|
+
const noun = model.touched === 1 ? "file" : "files";
|
|
147
|
+
return `${model.touched} ${noun} touched, ${model.hits} on the map`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const ZONE_GAP = 10;
|
|
151
|
+
|
|
152
|
+
// Rough zone height from its tiles, so zones can go to the shortest column. Tiles wrap, so
|
|
153
|
+
// count how many fit per row by name length.
|
|
154
|
+
function zoneHeight(zone: MapZone, width: number): number {
|
|
155
|
+
const inner = Math.max(120, width - 26);
|
|
156
|
+
let rows = 1;
|
|
157
|
+
let used = 0;
|
|
158
|
+
for (const tile of zone.tiles) {
|
|
159
|
+
const tileWidth = Math.min(inner, 32 + tile.name.length * 7);
|
|
160
|
+
if (used > 0 && used + tileWidth > inner) {
|
|
161
|
+
rows += 1;
|
|
162
|
+
used = 0;
|
|
163
|
+
}
|
|
164
|
+
used += tileWidth + 6;
|
|
165
|
+
}
|
|
166
|
+
return 46 + rows * 32;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Splits zones into columns in map order. A zone in `placed` keeps its column; a new zone
|
|
171
|
+
* goes to the shortest column and is added to `placed`.
|
|
172
|
+
*/
|
|
173
|
+
export function masonry(
|
|
174
|
+
zones: readonly MapZone[],
|
|
175
|
+
columns: number,
|
|
176
|
+
width: number,
|
|
177
|
+
placed: Map<string, number>,
|
|
178
|
+
): MapZone[][] {
|
|
179
|
+
const result: MapZone[][] = Array.from({ length: columns }, () => []);
|
|
180
|
+
const heights: number[] = Array.from({ length: columns }, () => 0);
|
|
181
|
+
const columnWidth = (width - ZONE_GAP * (columns - 1)) / columns;
|
|
182
|
+
for (const zone of zones) {
|
|
183
|
+
let column = placed.get(zone.dir);
|
|
184
|
+
if (column === undefined || column >= columns) {
|
|
185
|
+
column = heights.indexOf(Math.min(...heights));
|
|
186
|
+
placed.set(zone.dir, column);
|
|
187
|
+
}
|
|
188
|
+
result[column]?.push(zone);
|
|
189
|
+
heights[column] = (heights[column] ?? 0) + zoneHeight(zone, columnWidth) + ZONE_GAP;
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
7
|
+
export interface FieldDiff {
|
|
8
|
+
field: ProposalField;
|
|
9
|
+
label: string;
|
|
10
|
+
current: string;
|
|
11
|
+
proposed: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function proposalDiffs(config: ProjectConfig, proposal: InitProposal): FieldDiff[] {
|
|
15
|
+
const steps = proposal.steps.map((step) => step.trim()).filter(Boolean);
|
|
16
|
+
const rows: FieldDiff[] = [
|
|
17
|
+
{
|
|
18
|
+
field: "instructions",
|
|
19
|
+
label: "Project instructions",
|
|
20
|
+
current: config.instructions.trim(),
|
|
21
|
+
proposed: proposal.instructions.trim(),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
field: "steps",
|
|
25
|
+
label: "Project steps",
|
|
26
|
+
current: config.steps.join("\n"),
|
|
27
|
+
proposed: steps.join("\n"),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
field: "implement",
|
|
31
|
+
label: "Implement: append to the prompt",
|
|
32
|
+
current: config.implement.mode === "default" ? "" : config.implement.text.trim(),
|
|
33
|
+
proposed: proposal.implement.trim(),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
field: "review",
|
|
37
|
+
label: "Review: append to the prompt",
|
|
38
|
+
current: config.review.mode === "default" ? "" : config.review.text.trim(),
|
|
39
|
+
proposed: proposal.review.trim(),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
return rows.filter((row) => row.proposed && row.proposed !== row.current);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyProposal(
|
|
46
|
+
proposal: InitProposal,
|
|
47
|
+
fields: ReadonlySet<ProposalField>,
|
|
48
|
+
): Partial<ProjectConfig> {
|
|
49
|
+
const patch: Partial<ProjectConfig> = {};
|
|
50
|
+
if (fields.has("instructions")) patch.instructions = proposal.instructions.trim();
|
|
51
|
+
if (fields.has("steps")) patch.steps = proposal.steps.map((s) => s.trim()).filter(Boolean);
|
|
52
|
+
if (fields.has("implement")) patch.implement = { mode: "append", text: proposal.implement.trim() };
|
|
53
|
+
if (fields.has("review")) patch.review = { mode: "append", text: proposal.review.trim() };
|
|
54
|
+
return patch;
|
|
55
|
+
}
|
package/shared/prompts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IssueDetail } from "./linear";
|
|
2
2
|
import type { AgentAction, LinearSettings, ProjectConfig } from "./settings";
|
|
3
|
+
import { todoConventionLine } from "./todo-sync";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_TEMPLATES: Record<AgentAction, string> = {
|
|
5
6
|
implement: [
|
|
@@ -128,6 +129,8 @@ export function composePrompt(input: ComposePromptInput): string {
|
|
|
128
129
|
];
|
|
129
130
|
// A template that drops {{issue}} still gets the snapshot so the agent never works blind.
|
|
130
131
|
if (!/\{\{\s*issue\s*\}\}/.test(template)) sections.push(snapshot);
|
|
132
|
+
const convention = input.action === "implement" ? todoConventionLine(issue.children) : null;
|
|
133
|
+
if (convention) sections.push(convention);
|
|
131
134
|
const instructions = project?.instructions.trim();
|
|
132
135
|
if (instructions) sections.push(`Project instructions:\n${instructions}`);
|
|
133
136
|
const steps = project?.steps.map((step) => step.trim()).filter(Boolean) ?? [];
|
|
@@ -147,4 +150,16 @@ export function agentTitle(action: AgentAction, issue: { identifier: string; tit
|
|
|
147
150
|
return `${prefix}: ${title}`.slice(0, 120);
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
export const AGENT_LABELS = {
|
|
153
|
+
export const AGENT_LABELS = {
|
|
154
|
+
issue: "linear.issue",
|
|
155
|
+
action: "linear.action",
|
|
156
|
+
/** The Paseo project whose Linear key loaded the issue. */
|
|
157
|
+
project: "linear.project",
|
|
158
|
+
explore: "linear.explore",
|
|
159
|
+
init: "linear.init",
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
/** Agents the plugin starts for itself: explore and project setup. */
|
|
163
|
+
export function isInternalAgent(labels: Readonly<Record<string, string>> | undefined): boolean {
|
|
164
|
+
return Boolean(labels?.[AGENT_LABELS.explore] || labels?.[AGENT_LABELS.init]);
|
|
165
|
+
}
|
package/shared/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineSettings } from "@getpaseo/plugin";
|
|
1
|
+
import { defineRpc, defineSettings } from "@getpaseo/plugin";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
4
|
export const AGENT_ACTIONS = ["implement", "review"] as const;
|
|
@@ -27,6 +27,12 @@ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
|
27
27
|
export const ISOLATIONS = ["worktree", "workspace"] as const;
|
|
28
28
|
export type Isolation = (typeof ISOLATIONS)[number];
|
|
29
29
|
|
|
30
|
+
export const MAPPING_MODES = ["semantic", "explore"] as const;
|
|
31
|
+
export type MappingMode = (typeof MAPPING_MODES)[number];
|
|
32
|
+
|
|
33
|
+
export const LIVE_VIEWS = ["map", "graph"] as const;
|
|
34
|
+
export type LiveView = (typeof LIVE_VIEWS)[number];
|
|
35
|
+
|
|
30
36
|
export const linearSettings = defineSettings({
|
|
31
37
|
id: "linear",
|
|
32
38
|
scope: "host",
|
|
@@ -45,6 +51,24 @@ export const linearSettings = defineSettings({
|
|
|
45
51
|
assignToMe: z.boolean().default(true),
|
|
46
52
|
})
|
|
47
53
|
.prefault({}),
|
|
54
|
+
// Explore runs cost provider usage and sync writes to Linear, so both start off.
|
|
55
|
+
live: z
|
|
56
|
+
.object({
|
|
57
|
+
enabled: z.boolean().default(true),
|
|
58
|
+
mapping: z.enum(MAPPING_MODES).default("semantic"),
|
|
59
|
+
exploreProvider: z.string().default(""),
|
|
60
|
+
syncTodos: z.boolean().default(false),
|
|
61
|
+
view: z.enum(LIVE_VIEWS).default("map"),
|
|
62
|
+
issuesCollapsed: z.boolean().default(false),
|
|
63
|
+
})
|
|
64
|
+
.prefault({}),
|
|
65
|
+
// Where the plugin works: every project, minus or plus per-project switches.
|
|
66
|
+
access: z
|
|
67
|
+
.object({
|
|
68
|
+
allProjects: z.boolean().default(true),
|
|
69
|
+
projects: z.record(z.string(), z.boolean()).default({}),
|
|
70
|
+
})
|
|
71
|
+
.prefault({}),
|
|
48
72
|
projects: z.array(ProjectConfigSchema).default([]),
|
|
49
73
|
}),
|
|
50
74
|
});
|
|
@@ -66,3 +90,35 @@ export function upsertProject(
|
|
|
66
90
|
const others = settings.projects.filter((entry) => entry.projectId !== next.projectId);
|
|
67
91
|
return [...others, next];
|
|
68
92
|
}
|
|
93
|
+
|
|
94
|
+
export type ProjectAccess = LinearSettings["access"];
|
|
95
|
+
|
|
96
|
+
/** A project switch wins; projects without one follow the all-projects switch. */
|
|
97
|
+
export function projectEnabled(access: ProjectAccess, projectId: string | null | undefined) {
|
|
98
|
+
if (!projectId) return access.allProjects;
|
|
99
|
+
return access.projects[projectId] ?? access.allProjects;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Sets one project's switch, dropping it when it matches the all-projects switch. */
|
|
103
|
+
export function withProjectAccess(
|
|
104
|
+
access: ProjectAccess,
|
|
105
|
+
projectId: string,
|
|
106
|
+
enabled: boolean,
|
|
107
|
+
): ProjectAccess {
|
|
108
|
+
const { [projectId]: _previous, ...others } = access.projects;
|
|
109
|
+
return {
|
|
110
|
+
...access,
|
|
111
|
+
projects: enabled === access.allProjects ? others : { ...others, [projectId]: enabled },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const AccessSchema = z.object({
|
|
116
|
+
allProjects: z.boolean(),
|
|
117
|
+
projects: z.record(z.string(), z.boolean()),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const accessRpc = defineRpc({
|
|
121
|
+
name: "linear.access.get",
|
|
122
|
+
input: z.object({}),
|
|
123
|
+
output: AccessSchema,
|
|
124
|
+
});
|