@yannelli/paseo-linear-plugin 1.1.0-beta.2 → 1.1.0-beta.3

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/server/live.ts CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import type { PluginServerContext } from "@getpaseo/plugin/server";
4
4
  import { z } from "zod";
5
5
  import { relativePath } from "../shared/activity";
6
+ import { knowledgeLines, liveProjectRpc, type ProjectKnowledge, resolvePaths, toAreaRef } from "../shared/knowledge";
6
7
  import { type IssueDetail, ISSUE_CARD_KIND } from "../shared/linear";
7
8
  import {
8
9
  ExploreResultSchema,
@@ -29,6 +30,7 @@ import {
29
30
  startInternalRun,
30
31
  } from "./agent-runs";
31
32
  import type { LinearAccess } from "./handlers";
33
+ import { type KnowledgeService, knowledgeFor } from "./knowledge";
32
34
  import { fileLinks } from "./links";
33
35
  import { subagentLogs } from "./subagent-logs";
34
36
  import type { LaunchQueue, MapStore } from "./stores";
@@ -97,8 +99,9 @@ export async function listDirectories(cwd: string, dirs: readonly string[]) {
97
99
  return results;
98
100
  }
99
101
 
100
- export function explorePrompt(issue: IssueDetail): string {
102
+ export function explorePrompt(issue: IssueDetail, knowledge: ProjectKnowledge | null = null): string {
101
103
  const keys = [issue.identifier, ...issue.children.map((child) => child.identifier)];
104
+ const map = knowledgeLines(knowledge);
102
105
  return [
103
106
  `Map this repository for Linear issue ${issue.identifier}: ${issue.title}`,
104
107
  "",
@@ -106,6 +109,7 @@ export function explorePrompt(issue: IssueDetail): string {
106
109
  "",
107
110
  issueSnapshot(issue, { includeComments: false }),
108
111
  "",
112
+ ...(map.length > 0 ? [...map, ""] : []),
109
113
  "List the files an engineer will most likely read or change for this issue and each sub-issue.",
110
114
  `Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
111
115
  `Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
@@ -138,6 +142,7 @@ export interface LiveDependencies {
138
142
  access: LinearAccess;
139
143
  maps: MapStore;
140
144
  launches: LaunchQueue;
145
+ knowledge: KnowledgeService;
141
146
  readSettings(): Promise<LinearSettings | null>;
142
147
  }
143
148
 
@@ -159,7 +164,7 @@ export async function startLaunch(workspace: WorkspaceHandle, launch: LaunchInpu
159
164
  }
160
165
 
161
166
  export function registerLive(server: PluginServerContext, dependencies: LiveDependencies) {
162
- const { access, maps, launches, readSettings } = dependencies;
167
+ const { access, maps, launches, knowledge, readSettings } = dependencies;
163
168
  const jobs = createJobStore<IssueMap>();
164
169
  const recovering = new Map<string, Promise<Job | null>>();
165
170
  const recoveredAt = new Map<string, number>();
@@ -230,6 +235,14 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
230
235
  return { links: await fileLinks(agent.cwd, files) };
231
236
  });
232
237
 
238
+ server.handle(liveProjectRpc, async ({ agentId, paths }, { paseo }) => {
239
+ const agent = await agentInfo(paseo, agentId);
240
+ const known = await knowledgeFor(paseo, knowledge, agent.projectId);
241
+ if (!known) return { areas: [], resolved: [] };
242
+ const resolved = [...resolvePaths(paths, known.files)].map(([from, to]) => ({ from, to }));
243
+ return { areas: known.areas.map(toAreaRef), resolved };
244
+ });
245
+
233
246
  server.handle(clearMapRpc, async ({ agentId }, { paseo }) => {
234
247
  const agent = await agentInfo(paseo, agentId);
235
248
  const identifier = agent.labels[AGENT_LABELS.issue];
@@ -267,6 +280,8 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
267
280
  interface ExploreTarget {
268
281
  identifier: string;
269
282
  keyScope: string | null;
283
+ /** Paseo project whose saved knowledge goes into the prompt. */
284
+ projectId: string | null;
270
285
  cwd: string;
271
286
  /** Opened only when a new run starts. */
272
287
  workspace: () => Promise<WorkspaceHandle>;
@@ -293,6 +308,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
293
308
  const issue = await linear.getIssue(identifier);
294
309
  const labels: Record<string, string> = { [AGENT_LABELS.explore]: identifier };
295
310
  if (target.keyScope) labels[AGENT_LABELS.project] = target.keyScope;
311
+ const known = await knowledgeFor(paseo, knowledge, target.projectId);
296
312
  const run = await startInternalRun({
297
313
  paseo,
298
314
  workspace: await target.workspace(),
@@ -300,7 +316,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
300
316
  provider: target.provider,
301
317
  effort: target.effort,
302
318
  title: `Explore ${identifier}`,
303
- prompt: explorePrompt(issue),
319
+ prompt: explorePrompt(issue, known),
304
320
  labels,
305
321
  schema: ExploreResultSchema,
306
322
  timeoutMs: EXPLORE_TIMEOUT_MS,
@@ -326,6 +342,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
326
342
  const job = await ensureExplore(paseo, {
327
343
  identifier,
328
344
  keyScope: agent.labels[AGENT_LABELS.project] ?? null,
345
+ projectId: agent.projectId,
329
346
  cwd,
330
347
  workspace: async () =>
331
348
  workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
@@ -344,6 +361,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
344
361
  const job = await ensureExplore(paseo, {
345
362
  identifier: input.identifier,
346
363
  keyScope: input.keyScope,
364
+ projectId: info.projectId,
347
365
  cwd: info.workspaceDirectory ?? info.projectRootPath,
348
366
  workspace: async () => workspace,
349
367
  provider: settings?.live.exploreProvider || input.agent.config.provider,
package/server/stores.ts CHANGED
@@ -7,7 +7,7 @@ const MAX_MAPS = 200;
7
7
  /** A launch still waiting after this long is dropped, so a stale one never starts by surprise. */
8
8
  export const PENDING_LAUNCH_MAX_AGE_MS = 60 * 60_000;
9
9
 
10
- interface JsonFile<T> {
10
+ export interface JsonFile<T> {
11
11
  read(): Promise<T>;
12
12
  /** Changes run one at a time, so a read, change, and write never interleave. */
13
13
  update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
@@ -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
+ }
package/shared/live.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineRpc } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
3
  import { McpHttpServerSchema, ToolPolicySchema } from "./agent-tools";
4
+ import { type AreaRef, areasIn } from "./knowledge";
4
5
  import { IssueCardSchema, WorkflowStateSchema } from "./linear";
5
6
 
6
7
  // Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
@@ -40,6 +41,9 @@ export const InitProposalSchema = z.object({
40
41
  steps: z.array(z.string()).default([]),
41
42
  implement: z.string().default(""),
42
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([]),
43
47
  });
44
48
  export type InitProposal = z.infer<typeof InitProposalSchema>;
45
49
 
@@ -194,18 +198,30 @@ export interface SemanticIssue {
194
198
  children: readonly { identifier: string; title: string }[];
195
199
  }
196
200
 
201
+ /** What project knowledge adds to a ticket-text map. */
202
+ export interface MapHints {
203
+ /** Ticket paths matched to real files, such as "linear.ts" to "server/linear.ts". */
204
+ resolved?: ReadonlyMap<string, string>;
205
+ /** A service or package the text names puts its folder on the map. */
206
+ areas?: readonly AreaRef[];
207
+ }
208
+
197
209
  // Linear sends sub-issue titles but not their descriptions, so a path in the parent's text
198
210
  // belongs to the sub-issue its line names and otherwise to the parent.
199
- export function semanticMap(issue: SemanticIssue, now = new Date()): IssueMap {
211
+ export function semanticMap(issue: SemanticIssue, now = new Date(), hints: MapHints = {}): IssueMap {
200
212
  const owners = new Map<string, string>();
201
213
  const childKeys = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
214
+ const found = (text: string) => [
215
+ ...pathsIn(text).map((path) => hints.resolved?.get(path) ?? path),
216
+ ...areasIn(text, hints.areas ?? []).map((area) => area.path),
217
+ ];
202
218
  for (const child of issue.children) {
203
- for (const path of pathsIn(child.title)) owners.set(path, child.identifier);
219
+ for (const path of found(child.title)) owners.set(path, child.identifier);
204
220
  }
205
221
  const lines = `${issue.title}\n${issue.description ?? ""}`.split("\n");
206
222
  for (const line of lines) {
207
223
  const named = (line.match(ISSUE_KEY) ?? []).find((key) => childKeys.has(key));
208
- for (const path of pathsIn(line)) {
224
+ for (const path of found(line)) {
209
225
  if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
210
226
  }
211
227
  }
@@ -1,4 +1,5 @@
1
1
  import type { DirTouch, FileTouch } from "./activity";
2
+ import { type AreaRef, areaLabel, areaOf, whereLabel } from "./knowledge";
2
3
  import { dirOf, type MapFile } from "./live";
3
4
 
4
5
  // The Linear Live map: predicted files and touched files grouped into folder zones, and the
@@ -31,12 +32,21 @@ export interface MapZone {
31
32
  /** Touched files that are on the map. */
32
33
  hits: number;
33
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;
34
43
  }
35
44
 
36
45
  export interface MapModel {
37
46
  zones: MapZone[];
38
47
  touched: number;
39
48
  hits: number;
49
+ offMap: OffMapGroup[];
40
50
  }
41
51
 
42
52
  // Search hits alone do not count: one broad search would flood the map with off-map tiles.
@@ -98,6 +108,7 @@ export function buildMapModel(
98
108
  touches: readonly FileTouch[],
99
109
  listing: ReadonlyMap<string, readonly string[]>,
100
110
  dirs: readonly DirTouch[] = [],
111
+ areas: readonly AreaRef[] = [],
101
112
  ): MapModel {
102
113
  const predicted = predictions(files);
103
114
  const touched = new Map(touches.filter(isTouched).map((file) => [file.path, file]));
@@ -134,11 +145,19 @@ export function buildMapModel(
134
145
  touched: tiles.filter((tile) => tile.touch !== "none").length,
135
146
  hits: tiles.filter((tile) => tile.touch !== "none" && tile.onMap).length,
136
147
  explored: exploredState(dir, dirs),
148
+ area: areaOf(dir, areas),
137
149
  };
138
150
  });
139
151
  let hits = 0;
140
- for (const path of touched.keys()) if (ownerOf(path, predicted)) hits += 1;
141
- return { zones, touched: touched.size, hits };
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 };
142
161
  }
143
162
 
144
163
  export function hitRateLine(model: MapModel): string {
@@ -147,6 +166,35 @@ export function hitRateLine(model: MapModel): string {
147
166
  return `${model.touched} ${noun} touched, ${model.hits} on the map`;
148
167
  }
149
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
+
150
198
  export const ZONE_GAP = 10;
151
199
 
152
200
  // Rough zone height from its tiles, so zones can go to the shortest column. Tiles wrap, so
@@ -3,6 +3,7 @@ import type { ProjectConfig } from "./settings";
3
3
 
4
4
  // A setup proposal shows only fields that change; accepted fields append to the prompts.
5
5
  export type ProposalField = "instructions" | "steps" | "implement" | "review";
6
+ type PromptProposal = Pick<InitProposal, ProposalField>;
6
7
 
7
8
  export interface FieldDiff {
8
9
  field: ProposalField;
@@ -11,7 +12,7 @@ export interface FieldDiff {
11
12
  proposed: string;
12
13
  }
13
14
 
14
- export function proposalDiffs(config: ProjectConfig, proposal: InitProposal): FieldDiff[] {
15
+ export function proposalDiffs(config: ProjectConfig, proposal: PromptProposal): FieldDiff[] {
15
16
  const steps = proposal.steps.map((step) => step.trim()).filter(Boolean);
16
17
  const rows: FieldDiff[] = [
17
18
  {
@@ -43,7 +44,7 @@ export function proposalDiffs(config: ProjectConfig, proposal: InitProposal): Fi
43
44
  }
44
45
 
45
46
  export function applyProposal(
46
- proposal: InitProposal,
47
+ proposal: PromptProposal,
47
48
  fields: ReadonlySet<ProposalField>,
48
49
  ): Partial<ProjectConfig> {
49
50
  const patch: Partial<ProjectConfig> = {};