@a-t-h-i/bot-lobby 0.6.24 → 0.6.26
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/README.md +11 -3
- package/package.json +1 -5
- package/prompts/designer.md +14 -0
- package/prompts/master.md +30 -7
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +22 -3
- package/prompts/qa.md +23 -3
- package/prompts/quickfix.md +4 -0
- package/prompts/reviewer.md +17 -0
- package/prompts/worker.md +21 -6
- package/src/ask/tool.ts +3 -3
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/delivery/review.ts +1 -1
- package/src/execution/agent-runner.ts +0 -18
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +4 -17
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +4 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +20 -3
- package/src/lobby/service.ts +5 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/master/research.ts +0 -16
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +12 -4
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +51 -3
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/budget.ts +49 -22
- package/src/state/changes.ts +8 -2
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +2 -6
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/api/tasks.ts +27 -6
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +7 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/protocol.ts +30 -4
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +105 -21
- package/webui/dist/assets/index-C1IwecNS.css +2 -0
- package/webui/dist/assets/index-C4Ww36TW.js +105 -0
- package/webui/dist/build.json +58 -62
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BSewK1QO.css +0 -2
- package/webui/dist/assets/index-CHQYSZXA.js +0 -105
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lint gate's runner: the project's own linter, run by the engine (never
|
|
3
|
+
* by an agent) on the files a task's agents touched, and only those. Each
|
|
4
|
+
* touched file goes to every linter whose config sits nearest above it
|
|
5
|
+
* (ESLint, Biome, Oxlint, Ruff), run from that config's folder with the
|
|
6
|
+
* binary the project installed, so the packages of a monorepo each lint by
|
|
7
|
+
* their own rules; or, when Settings name a command, every touched file goes
|
|
8
|
+
* to that. A linter that is configured but not installed, times out or cannot
|
|
9
|
+
* start reads as "could not run", never as findings: lint never holds a task
|
|
10
|
+
* on a failure of its own. No shell is involved, so a file name is only ever
|
|
11
|
+
* an argument.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
|
+
import type { LintConfig } from "../schemas/configuration.ts";
|
|
18
|
+
import type { LintFinding, LintRun } from "../schemas/lint.ts";
|
|
19
|
+
import { tail } from "../text.ts";
|
|
20
|
+
|
|
21
|
+
export type { LintFinding, LintReport, LintRun, LintState } from "../schemas/lint.ts";
|
|
22
|
+
|
|
23
|
+
/** What one linter call gave back: an exit code (null when it never ran to the end), its output, why it failed to run. */
|
|
24
|
+
export interface LintExecResult {
|
|
25
|
+
code: number | null;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
/** missing (not found), timeout, cancelled, or the spawn error. */
|
|
29
|
+
failure?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type LintExec = (command: string, args: readonly string[], options: { cwd: string; timeoutMs: number; signal?: AbortSignal }) => Promise<LintExecResult>;
|
|
33
|
+
|
|
34
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
35
|
+
/** Findings kept per run; the counts stay whole. */
|
|
36
|
+
export const MAX_FINDINGS = 60;
|
|
37
|
+
const OUTPUT_TAIL = 3000;
|
|
38
|
+
|
|
39
|
+
export const execLint: LintExec = (command, args, options) => new Promise((done) => {
|
|
40
|
+
execFile(command, [...args], { cwd: options.cwd, timeout: options.timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, ...(options.signal ? { signal: options.signal } : {}) }, (error, stdout, stderr) => {
|
|
41
|
+
const out = String(stdout ?? "");
|
|
42
|
+
const err = String(stderr ?? "");
|
|
43
|
+
if (!error) return done({ code: 0, stdout: out, stderr: err });
|
|
44
|
+
const failure = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; code?: unknown };
|
|
45
|
+
if (failure.name === "AbortError") return done({ code: null, stdout: out, stderr: err, failure: "cancelled" });
|
|
46
|
+
if (failure.code === "ENOENT") return done({ code: null, stdout: out, stderr: err, failure: "missing" });
|
|
47
|
+
if (failure.killed || failure.signal) return done({ code: null, stdout: out, stderr: err, failure: "timeout" });
|
|
48
|
+
if (typeof failure.code === "number") return done({ code: failure.code, stdout: out, stderr: err });
|
|
49
|
+
done({ code: null, stdout: out, stderr: err, failure: failure.message });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** What a linter's output says, or undefined when it does not read as a lint result (a crash, a config error). */
|
|
54
|
+
interface Parsed {
|
|
55
|
+
findings: LintFinding[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Linter {
|
|
59
|
+
name: string;
|
|
60
|
+
/** Files whose presence in a folder makes it this linter's: the nearest such folder above a file is where it runs. */
|
|
61
|
+
configs: readonly string[];
|
|
62
|
+
/** A folder whose package.json or pyproject.toml configures it counts too. */
|
|
63
|
+
configured?: (folder: string) => boolean;
|
|
64
|
+
extensions: readonly string[];
|
|
65
|
+
/** The installed binary as `[command, ...leading args]`, looked for from `folder` up to the repository's top. */
|
|
66
|
+
binary(folder: string, top: string): string[] | undefined;
|
|
67
|
+
args(files: readonly string[]): string[];
|
|
68
|
+
read(result: LintExecResult, folder: string, top: string): Parsed | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const JS = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte", ".astro"];
|
|
72
|
+
|
|
73
|
+
function readText(path: string): string | undefined {
|
|
74
|
+
try {
|
|
75
|
+
return readFileSync(path, "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function json(text: string): unknown {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Folders from `start` up to `top`, nearest first. */
|
|
90
|
+
function upward(start: string, top: string): string[] {
|
|
91
|
+
const folders: string[] = [];
|
|
92
|
+
let folder = start;
|
|
93
|
+
for (;;) {
|
|
94
|
+
folders.push(folder);
|
|
95
|
+
if (folder === top) break;
|
|
96
|
+
const parent = dirname(folder);
|
|
97
|
+
const rel = relative(top, parent);
|
|
98
|
+
if (parent === folder || rel.startsWith("..") || isAbsolute(rel)) break;
|
|
99
|
+
folder = parent;
|
|
100
|
+
}
|
|
101
|
+
return folders;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A package's bin script, run with this Node, found in `node_modules` from `folder` up to `top`. */
|
|
105
|
+
function nodeBin(pkg: string, bin: string): (folder: string, top: string) => string[] | undefined {
|
|
106
|
+
return (folder, top) => {
|
|
107
|
+
for (const at of upward(folder, top)) {
|
|
108
|
+
const dir = join(at, "node_modules", pkg);
|
|
109
|
+
const manifest = json(readText(join(dir, "package.json")) ?? "") as { bin?: string | Record<string, string> } | undefined;
|
|
110
|
+
if (!manifest) continue;
|
|
111
|
+
const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
|
|
112
|
+
if (entry && existsSync(join(dir, entry))) return [process.execPath, join(dir, entry)];
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Ruff from a virtualenv in the project, else from PATH (where a missing one reads as not installed). */
|
|
119
|
+
function ruffBin(folder: string, top: string): string[] {
|
|
120
|
+
const names = process.platform === "win32" ? [join(".venv", "Scripts", "ruff.exe"), join("venv", "Scripts", "ruff.exe")] : [join(".venv", "bin", "ruff"), join("venv", "bin", "ruff")];
|
|
121
|
+
for (const at of upward(folder, top)) for (const name of names) if (existsSync(join(at, name))) return [join(at, name)];
|
|
122
|
+
return ["ruff"];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A path a linter printed (absolute, or relative to where it ran) as the repository names it. */
|
|
126
|
+
const repoPath = (top: string, file: string) => relative(top, resolve(top, file)).split("\\").join("/");
|
|
127
|
+
|
|
128
|
+
function eslintRead(result: LintExecResult, _folder: string, top: string): Parsed | undefined {
|
|
129
|
+
// 2 is ESLint's own failure (a config error, a crash), not findings.
|
|
130
|
+
if (result.code !== 0 && result.code !== 1) return undefined;
|
|
131
|
+
const reports = json(result.stdout);
|
|
132
|
+
if (!Array.isArray(reports)) return undefined;
|
|
133
|
+
const findings: LintFinding[] = [];
|
|
134
|
+
for (const report of reports as Array<{ filePath?: string; messages?: Array<{ ruleId?: string | null; severity?: number; message?: string; line?: number; column?: number }> }>) {
|
|
135
|
+
for (const message of report.messages ?? []) {
|
|
136
|
+
// A file the config does not cover is skipped, not a finding.
|
|
137
|
+
if (!message.ruleId && /^File ignored\b/.test(message.message ?? "")) continue;
|
|
138
|
+
findings.push({
|
|
139
|
+
file: repoPath(top, report.filePath ?? ""),
|
|
140
|
+
...(message.line ? { line: message.line } : {}),
|
|
141
|
+
...(message.column ? { column: message.column } : {}),
|
|
142
|
+
...(message.ruleId ? { rule: message.ruleId } : {}),
|
|
143
|
+
message: message.message ?? "",
|
|
144
|
+
severity: message.severity === 2 ? "error" : "warning",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { findings };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function oxlintRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
|
|
152
|
+
if (result.code !== 0 && result.code !== 1) return undefined;
|
|
153
|
+
const raw = json(result.stdout) as { diagnostics?: unknown[] } | unknown[] | undefined;
|
|
154
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.diagnostics) ? raw.diagnostics : undefined;
|
|
155
|
+
if (!list) return undefined;
|
|
156
|
+
return {
|
|
157
|
+
findings: (list as Array<{ message?: string; code?: string; severity?: string; filename?: string; labels?: Array<{ span?: { line?: number; column?: number } }> }>).map((entry) => {
|
|
158
|
+
const span = entry.labels?.[0]?.span;
|
|
159
|
+
return {
|
|
160
|
+
file: repoPath(top, resolve(folder, entry.filename ?? "")),
|
|
161
|
+
...(span?.line ? { line: span.line } : {}),
|
|
162
|
+
...(span?.column ? { column: span.column } : {}),
|
|
163
|
+
...(entry.code ? { rule: entry.code } : {}),
|
|
164
|
+
message: entry.message ?? "",
|
|
165
|
+
severity: entry.severity === "error" ? "error" : "warning",
|
|
166
|
+
} satisfies LintFinding;
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Biome's GitHub reporter: `::error title=lint/x/y,file=a.ts,line=1,endLine=1,col=1,endColumn=9::message`. */
|
|
172
|
+
function biomeRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
|
|
173
|
+
if (result.code !== 0 && result.code !== 1) return undefined;
|
|
174
|
+
const findings: LintFinding[] = [];
|
|
175
|
+
for (const line of `${result.stdout}\n${result.stderr}`.split("\n")) {
|
|
176
|
+
const match = /^::(error|warning|notice)\s+([^:]*)::(.*)$/.exec(line.trim());
|
|
177
|
+
if (!match) continue;
|
|
178
|
+
const fields = Object.fromEntries(match[2]!.split(",").map((pair) => { const at = pair.indexOf("="); return [pair.slice(0, at), pair.slice(at + 1)]; }));
|
|
179
|
+
if (!fields.file) continue;
|
|
180
|
+
findings.push({
|
|
181
|
+
file: repoPath(top, resolve(folder, fields.file)),
|
|
182
|
+
...(Number(fields.line) ? { line: Number(fields.line) } : {}),
|
|
183
|
+
...(Number(fields.col) ? { column: Number(fields.col) } : {}),
|
|
184
|
+
...(fields.title ? { rule: fields.title } : {}),
|
|
185
|
+
message: match[3]!.trim(),
|
|
186
|
+
severity: match[1] === "error" ? "error" : "warning",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Exit 1 with nothing read is Biome failing on its own (a broken config), not a clean run.
|
|
190
|
+
if (result.code === 1 && findings.length === 0) return undefined;
|
|
191
|
+
return { findings };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function ruffRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
|
|
195
|
+
if (result.code !== 0 && result.code !== 1) return undefined;
|
|
196
|
+
const list = json(result.stdout);
|
|
197
|
+
if (!Array.isArray(list)) return undefined;
|
|
198
|
+
return {
|
|
199
|
+
findings: (list as Array<{ code?: string | null; message?: string; filename?: string; location?: { row?: number; column?: number } }>).map((entry) => ({
|
|
200
|
+
file: repoPath(top, resolve(folder, entry.filename ?? "")),
|
|
201
|
+
...(entry.location?.row ? { line: entry.location.row } : {}),
|
|
202
|
+
...(entry.location?.column ? { column: entry.location.column } : {}),
|
|
203
|
+
...(entry.code ? { rule: entry.code } : {}),
|
|
204
|
+
message: entry.message ?? "",
|
|
205
|
+
severity: "error" as const,
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function packageHas(folder: string, key: string): boolean {
|
|
211
|
+
const manifest = json(readText(join(folder, "package.json")) ?? "") as Record<string, unknown> | undefined;
|
|
212
|
+
return Boolean(manifest && typeof manifest === "object" && key in manifest);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const LINTERS: readonly Linter[] = [
|
|
216
|
+
{
|
|
217
|
+
name: "ESLint",
|
|
218
|
+
configs: ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml"],
|
|
219
|
+
configured: (folder) => packageHas(folder, "eslintConfig"),
|
|
220
|
+
extensions: JS,
|
|
221
|
+
binary: nodeBin("eslint", "eslint"),
|
|
222
|
+
args: (files) => ["--format", "json", ...files],
|
|
223
|
+
read: eslintRead,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "Biome",
|
|
227
|
+
configs: ["biome.json", "biome.jsonc"],
|
|
228
|
+
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", ".jsonc", ".css", ".graphql"],
|
|
229
|
+
binary: nodeBin("@biomejs/biome", "biome"),
|
|
230
|
+
args: (files) => ["lint", "--reporter=github", "--colors=off", "--no-errors-on-unmatched", ...files],
|
|
231
|
+
read: biomeRead,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "Oxlint",
|
|
235
|
+
configs: [".oxlintrc.json", "oxlint.config.ts"],
|
|
236
|
+
extensions: JS,
|
|
237
|
+
binary: nodeBin("oxlint", "oxlint"),
|
|
238
|
+
args: (files) => ["--format", "json", ...files],
|
|
239
|
+
read: oxlintRead,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "Ruff",
|
|
243
|
+
configs: ["ruff.toml", ".ruff.toml"],
|
|
244
|
+
configured: (folder) => /^\[tool\.ruff[\].]/m.test(readText(join(folder, "pyproject.toml")) ?? ""),
|
|
245
|
+
extensions: [".py", ".pyi"],
|
|
246
|
+
binary: ruffBin,
|
|
247
|
+
args: (files) => ["check", "--output-format=json", "--no-fix", "--force-exclude", ...files],
|
|
248
|
+
read: ruffRead,
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
function configuredIn(linter: Linter, folder: string): boolean {
|
|
253
|
+
return linter.configs.some((name) => existsSync(join(folder, name))) || Boolean(linter.configured?.(folder));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A file as an argument: relative to where the linter runs, never mistakable for an option. */
|
|
257
|
+
function argument(folder: string, top: string, file: string): string {
|
|
258
|
+
const rel = relative(folder, join(top, file)).split("\\").join("/");
|
|
259
|
+
return rel.startsWith("-") ? `./${rel}` : rel;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Split a command line into words, with quotes ('…', "…") holding spaces; no shell ever reads it. */
|
|
263
|
+
export function commandWords(command: string): string[] {
|
|
264
|
+
const words: string[] = [];
|
|
265
|
+
let word = "";
|
|
266
|
+
let quote: string | undefined;
|
|
267
|
+
let started = false;
|
|
268
|
+
for (const char of command) {
|
|
269
|
+
if (quote) {
|
|
270
|
+
if (char === quote) quote = undefined;
|
|
271
|
+
else word += char;
|
|
272
|
+
} else if (char === "'" || char === "\"") {
|
|
273
|
+
quote = char;
|
|
274
|
+
started = true;
|
|
275
|
+
} else if (/\s/.test(char)) {
|
|
276
|
+
if (started || word) words.push(word);
|
|
277
|
+
word = "";
|
|
278
|
+
started = false;
|
|
279
|
+
} else word += char;
|
|
280
|
+
}
|
|
281
|
+
if (started || word) words.push(word);
|
|
282
|
+
return words;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `src/a.ts:12:5: message`, `src/a.ts:12: message` or `src/a.ts(12,5): message` lines that name a touched file. */
|
|
286
|
+
function genericFindings(text: string, files: readonly string[]): LintFinding[] {
|
|
287
|
+
const findings: LintFinding[] = [];
|
|
288
|
+
for (const line of text.split("\n")) {
|
|
289
|
+
const match = /^\s*(?:\.\/)?([^\s:(][^:(]*?)(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))[:\s-]+(.+)$/.exec(line);
|
|
290
|
+
if (!match) continue;
|
|
291
|
+
const file = files.find((entry) => entry === match[1] || entry.endsWith(`/${match[1]}`));
|
|
292
|
+
if (!file) continue;
|
|
293
|
+
const lineNo = Number(match[2] ?? match[4]);
|
|
294
|
+
const column = Number(match[3] ?? match[5]);
|
|
295
|
+
findings.push({ file, ...(lineNo ? { line: lineNo } : {}), ...(column ? { column } : {}), message: match[6]!.trim(), severity: /\bwarn(ing)?\b/i.test(match[6]!) && !/\berror\b/i.test(match[6]!) ? "warning" : "error" });
|
|
296
|
+
}
|
|
297
|
+
return findings;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function unavailable(tool: string, folder: string, files: string[], output: string, ms = 0): LintRun {
|
|
301
|
+
return { tool, folder, files, state: "unavailable", errors: 0, warnings: 0, findings: [], output, ms };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function why(result: LintExecResult, tool: string, timeoutMs: number): string {
|
|
305
|
+
if (result.failure === "missing") return `${tool} is not installed here.`;
|
|
306
|
+
if (result.failure === "timeout") return `${tool} took longer than its ${Math.round(timeoutMs / 1000)}s limit.`;
|
|
307
|
+
if (result.failure === "cancelled") return `${tool} was stopped.`;
|
|
308
|
+
const said = tail(`${result.stderr}\n${result.stdout}`.trim(), OUTPUT_TAIL);
|
|
309
|
+
return `${tool} failed to run${result.code !== null ? ` (exit ${result.code})` : ""}${result.failure ? `: ${result.failure}` : ""}${said ? `\n${said}` : ""}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function counted(tool: string, folder: string, files: string[], findings: LintFinding[], ms: number, output?: string): LintRun {
|
|
313
|
+
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
314
|
+
const warnings = findings.length - errors;
|
|
315
|
+
return { tool, folder, files, state: errors > 0 ? "failing" : "passing", errors, warnings, findings, ...(output ? { output } : {}), ms };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface LintPlan {
|
|
319
|
+
tool: string;
|
|
320
|
+
/** Absolute. */
|
|
321
|
+
folder: string;
|
|
322
|
+
files: string[];
|
|
323
|
+
linter?: Linter;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Which linter gets which touched file: each file goes to every built-in
|
|
328
|
+
* linter that handles its extension and is configured in a folder above it
|
|
329
|
+
* (the nearest such folder is where that linter runs). With a command in
|
|
330
|
+
* Settings, the files it is given by extension (all of them by default) go to
|
|
331
|
+
* that alone, from the repository's top.
|
|
332
|
+
*/
|
|
333
|
+
export function planLint(top: string, files: readonly string[], config: Pick<LintConfig, "command" | "extensions">): LintPlan[] {
|
|
334
|
+
if (config.command.trim()) {
|
|
335
|
+
const wanted = config.extensions.length ? files.filter((file) => config.extensions.includes(extname(file).toLowerCase())) : [...files];
|
|
336
|
+
return wanted.length ? [{ tool: commandWords(config.command)[0] ?? "lint", folder: top, files: wanted }] : [];
|
|
337
|
+
}
|
|
338
|
+
const groups = new Map<string, LintPlan>();
|
|
339
|
+
for (const file of files) {
|
|
340
|
+
const ext = extname(file).toLowerCase();
|
|
341
|
+
for (const linter of LINTERS) {
|
|
342
|
+
if (!linter.extensions.includes(ext)) continue;
|
|
343
|
+
const folder = upward(dirname(join(top, file)), top).find((at) => configuredIn(linter, at));
|
|
344
|
+
if (!folder) continue;
|
|
345
|
+
const key = `${linter.name}\0${folder}`;
|
|
346
|
+
const group = groups.get(key) ?? { tool: linter.name, folder, files: [], linter };
|
|
347
|
+
group.files.push(file);
|
|
348
|
+
groups.set(key, group);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return [...groups.values()];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Run one planned linter over its files. */
|
|
355
|
+
export async function runPlan(plan: LintPlan, top: string, config: Pick<LintConfig, "command" | "timeoutMs">, exec: LintExec = execLint, signal?: AbortSignal): Promise<LintRun> {
|
|
356
|
+
const started = Date.now();
|
|
357
|
+
const folder = repoPath(top, plan.folder);
|
|
358
|
+
const files = plan.files;
|
|
359
|
+
if (!plan.linter) {
|
|
360
|
+
const words = commandWords(config.command);
|
|
361
|
+
const holder = words.indexOf("{files}");
|
|
362
|
+
const given = files.map((file) => argument(top, top, file));
|
|
363
|
+
const args = holder >= 0 ? [...words.slice(1, holder), ...given, ...words.slice(holder + 1)] : [...words.slice(1), ...given];
|
|
364
|
+
const result = await exec(words[0]!, args, { cwd: top, timeoutMs: config.timeoutMs, ...(signal ? { signal } : {}) });
|
|
365
|
+
const ms = Date.now() - started;
|
|
366
|
+
if (result.code === null) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
|
|
367
|
+
if (result.code === 0) return counted(plan.tool, folder, files, [], ms);
|
|
368
|
+
const said = `${result.stdout}\n${result.stderr}`;
|
|
369
|
+
const findings = genericFindings(said, files);
|
|
370
|
+
// A failing command whose lines name no touched file still failed: one finding stands for it.
|
|
371
|
+
const read = findings.length ? findings : [{ file: files[0]!, message: `${plan.tool} exited ${result.code}`, severity: "error" as const }];
|
|
372
|
+
return counted(plan.tool, folder, files, read, ms, tail(said.trim(), OUTPUT_TAIL));
|
|
373
|
+
}
|
|
374
|
+
const binary = plan.linter.binary(plan.folder, top);
|
|
375
|
+
if (!binary) return unavailable(plan.tool, folder, files, `${plan.tool} is configured${folder ? ` in ${folder}/` : ""} but not installed: install the project's dependencies there.`);
|
|
376
|
+
const result = await exec(binary[0]!, [...binary.slice(1), ...plan.linter.args(files.map((file) => argument(plan.folder, top, file)))], { cwd: plan.folder, timeoutMs: config.timeoutMs, ...(signal ? { signal } : {}) });
|
|
377
|
+
const ms = Date.now() - started;
|
|
378
|
+
if (result.code === null) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
|
|
379
|
+
const parsed = plan.linter.read(result, plan.folder, top);
|
|
380
|
+
if (!parsed) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
|
|
381
|
+
return counted(plan.tool, folder, files, parsed.findings, ms);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A key for the touched files as they stand and the settings that lint them: equal keys, equal results. */
|
|
385
|
+
export function lintFingerprint(top: string, files: readonly string[], config: Pick<LintConfig, "command" | "extensions">): string {
|
|
386
|
+
const hash = createHash("sha256");
|
|
387
|
+
hash.update(JSON.stringify([config.command, config.extensions]));
|
|
388
|
+
for (const file of [...files].sort()) {
|
|
389
|
+
let stamp = "gone";
|
|
390
|
+
try {
|
|
391
|
+
const stat = statSync(join(top, file));
|
|
392
|
+
stamp = `${stat.size}:${stat.mtimeMs}`;
|
|
393
|
+
} catch {
|
|
394
|
+
// A deleted file keys as gone.
|
|
395
|
+
}
|
|
396
|
+
hash.update(`\0${file}\0${stamp}`);
|
|
397
|
+
}
|
|
398
|
+
return hash.digest("hex").slice(0, 24);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A linter the project configures, where, and whether its binary is there to run. */
|
|
402
|
+
export interface FoundLinter {
|
|
403
|
+
tool: string;
|
|
404
|
+
/** Repository-relative ("" at the top). */
|
|
405
|
+
folder: string;
|
|
406
|
+
installed: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "out", "coverage", ".venv", "venv", "target", ".next", ".turbo", "vendor"]);
|
|
410
|
+
|
|
411
|
+
/** The linters configured in the project (its top and folders up to `depth` below, skipping dependencies and builds), for Settings. */
|
|
412
|
+
export function findLinters(top: string, depth = 3, limit = 40): FoundLinter[] {
|
|
413
|
+
const found: FoundLinter[] = [];
|
|
414
|
+
const visit = (folder: string, level: number) => {
|
|
415
|
+
if (found.length >= limit) return;
|
|
416
|
+
for (const linter of LINTERS) {
|
|
417
|
+
if (configuredIn(linter, folder)) found.push({ tool: linter.name, folder: repoPath(top, folder), installed: linter.name === "Ruff" ? ruffBin(folder, top)[0] !== "ruff" || onPath("ruff") : Boolean(linter.binary(folder, top)) });
|
|
418
|
+
}
|
|
419
|
+
if (level >= depth) return;
|
|
420
|
+
let entries: string[] = [];
|
|
421
|
+
try {
|
|
422
|
+
entries = readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !SKIP.has(entry.name)).map((entry) => entry.name).sort();
|
|
423
|
+
} catch {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
for (const name of entries) visit(join(folder, name), level + 1);
|
|
427
|
+
};
|
|
428
|
+
visit(top, 0);
|
|
429
|
+
return found;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function onPath(command: string): boolean {
|
|
433
|
+
const extensions = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
|
|
434
|
+
return (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":").some((dir) => dir && extensions.some((ext) => existsSync(join(dir, `${command}${ext}`))));
|
|
435
|
+
}
|
|
@@ -4,76 +4,6 @@ import type { KnowledgeAgent } from "./paths.ts";
|
|
|
4
4
|
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir } from "./paths.ts";
|
|
5
5
|
import { readFirstExisting, readFileOr, writeFileEnsured } from "./store.ts";
|
|
6
6
|
|
|
7
|
-
export interface KnowledgeAnalysis {
|
|
8
|
-
/** Lines that say the same thing twice. */
|
|
9
|
-
duplicates: string[];
|
|
10
|
-
/** Lines with hedging or unresolved wording. */
|
|
11
|
-
ambiguity: string[];
|
|
12
|
-
/** Different statements sharing a subject: candidates for conflict review. */
|
|
13
|
-
conflictCandidates: { subject: string; statements: string[] }[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const AMBIGUITY = /\bmaybe|possibly|probably|tbd|todo|unclear|not sure|some kind|etc\.|approximately\b/i;
|
|
17
|
-
const HEDGE_STOPWORDS = new Set(["this", "that", "with", "from", "into", "when", "they", "have", "will", "should", "must"]);
|
|
18
|
-
|
|
19
|
-
function significantLines(content: string, minLength = 12): string[] {
|
|
20
|
-
return content
|
|
21
|
-
.split("\n")
|
|
22
|
-
.map((line) => line.replace(/^[-*#>\s]+/, "").trim())
|
|
23
|
-
.filter((line) => line.length >= minLength);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function normalize(line: string): string {
|
|
27
|
-
return line
|
|
28
|
-
.toLowerCase()
|
|
29
|
-
.replace(/[^\w\s]/g, "")
|
|
30
|
-
.replace(/\s+/g, " ")
|
|
31
|
-
.trim();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function subjectKey(line: string): string {
|
|
35
|
-
return normalize(line)
|
|
36
|
-
.split(" ")
|
|
37
|
-
.filter((word) => word.length > 3 && !HEDGE_STOPWORDS.has(word))
|
|
38
|
-
.slice(0, 2)
|
|
39
|
-
.join(" ");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function findDuplicates(lines: string[]): string[] {
|
|
43
|
-
const seen = new Map<string, string>();
|
|
44
|
-
const duplicates: string[] = [];
|
|
45
|
-
for (const line of lines) {
|
|
46
|
-
const key = normalize(line);
|
|
47
|
-
if (seen.has(key)) duplicates.push(line);
|
|
48
|
-
else seen.set(key, line);
|
|
49
|
-
}
|
|
50
|
-
return duplicates;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function findConflicts(lines: string[]): { subject: string; statements: string[] }[] {
|
|
54
|
-
const groups = new Map<string, Set<string>>();
|
|
55
|
-
for (const line of lines) {
|
|
56
|
-
const key = subjectKey(line);
|
|
57
|
-
if (key.split(" ").length < 2) continue;
|
|
58
|
-
const statements = groups.get(key) ?? new Set<string>();
|
|
59
|
-
statements.add(line);
|
|
60
|
-
groups.set(key, statements);
|
|
61
|
-
}
|
|
62
|
-
return [...groups.entries()]
|
|
63
|
-
.filter(([, statements]) => statements.size > 1)
|
|
64
|
-
.map(([subject, statements]) => ({ subject, statements: [...statements] }));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Mechanical candidates that help the Master decide what to compact (§24). */
|
|
68
|
-
export function analyzeKnowledge(content: string): KnowledgeAnalysis {
|
|
69
|
-
const lines = significantLines(content);
|
|
70
|
-
return {
|
|
71
|
-
duplicates: findDuplicates(lines),
|
|
72
|
-
ambiguity: lines.filter((line) => AMBIGUITY.test(line)),
|
|
73
|
-
conflictCandidates: findConflicts(lines),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
7
|
export interface OversizedFile {
|
|
78
8
|
agent: KnowledgeAgent;
|
|
79
9
|
file: string;
|
package/src/lobby/ask.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The oracle puts the planning panel's questions to the user through the
|
|
3
3
|
* questionnaire (`../ask`): a card per question with each seat's options (the
|
|
4
|
-
* recommended one first when the answer is obvious
|
|
4
|
+
* recommended one first when the answer is obvious, with the mockup a seat drew
|
|
5
|
+
* for it), and a field for an answer in the user's own words.
|
|
5
6
|
* The panel's questions become questionnaires of at most four, and the
|
|
6
7
|
* answers become the user's turn for the next round.
|
|
7
8
|
*/
|
|
8
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { PanelQuestion, SettledQuestion } from "./planner.ts";
|
|
10
10
|
import { MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type Asker } from "../ask/types.ts";
|
|
11
11
|
|
|
@@ -32,7 +32,8 @@ export function toAskQuestion(question: PanelQuestion): AskQuestion {
|
|
|
32
32
|
if (!label || RESERVED.has(label.toLowerCase())) continue;
|
|
33
33
|
for (let n = 2; seen.has(label.toLowerCase()); n++) label = clip(`${option.label} (${n})`, MAX_LABEL);
|
|
34
34
|
seen.add(label.toLowerCase());
|
|
35
|
-
|
|
35
|
+
// A seat's mockup goes with its option, so the user sees it (and expands it) in the questionnaire.
|
|
36
|
+
options.push({ label, description: option.description.trim() || label, ...(option.mockup ? { htmlPreview: option.mockup } : {}) });
|
|
36
37
|
if (options.length === MAX_OPTIONS) break;
|
|
37
38
|
}
|
|
38
39
|
for (const filler of DEFAULT_OPTIONS) {
|
|
@@ -120,17 +121,3 @@ export function answerMessage(questions: readonly PanelQuestion[], results: read
|
|
|
120
121
|
...(skipped.length > 0 ? ["", "Not answered this round:", ...skipped] : []),
|
|
121
122
|
].join("\n");
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Put every question to the user, one questionnaire after another. Stopping
|
|
126
|
-
* a questionnaire (esc) stops the rest; what was answered before it is kept.
|
|
127
|
-
*/
|
|
128
|
-
export async function askPanel(questions: readonly PanelQuestion[], ask: Asker, ctx: ExtensionContext): Promise<{ results: AskResult[]; stopped: boolean }> {
|
|
129
|
-
const results: AskResult[] = [];
|
|
130
|
-
for (const chunk of questionnaires(questions)) {
|
|
131
|
-
const result = await ask(chunk, ctx);
|
|
132
|
-
if (result.cancelled) return { results, stopped: true };
|
|
133
|
-
results.push(result);
|
|
134
|
-
}
|
|
135
|
-
return { results, stopped: false };
|
|
136
|
-
}
|
package/src/lobby/feed.ts
CHANGED
|
@@ -69,6 +69,8 @@ export class LobbyFeed {
|
|
|
69
69
|
/** Last step reported per run, so repeated run updates log each step once. */
|
|
70
70
|
private readonly runSteps = new Map<string, string>();
|
|
71
71
|
private readonly runThoughts = new Map<string, string>();
|
|
72
|
+
/** The live whole thought of each piece of work still under way (a run, a quick fix, a review), by its key. */
|
|
73
|
+
private readonly liveThoughts = new Map<string, number>();
|
|
72
74
|
private readonly runStatus = new Map<string, AgentRun["status"]>();
|
|
73
75
|
/** Runs already reported as switched to their fallback model. */
|
|
74
76
|
|
|
@@ -126,8 +128,10 @@ export class LobbyFeed {
|
|
|
126
128
|
|
|
127
129
|
/** Stream the Master's (or any source's) live thought. */
|
|
128
130
|
thinkDelta(source: string, delta: string, at = Date.now()): void {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
// The source's own streaming entry, even when another agent's thought landed after it.
|
|
132
|
+
const keyed = new Set(this.liveThoughts.values());
|
|
133
|
+
const last = [...this.thoughts].reverse().find((entry) => entry.live && entry.source === source && !keyed.has(entry.id));
|
|
134
|
+
if (last) {
|
|
131
135
|
const text = last.text + delta;
|
|
132
136
|
last.text = text.length > MAX_THOUGHT_TEXT ? trimThought(text) : text;
|
|
133
137
|
} else {
|
|
@@ -138,7 +142,8 @@ export class LobbyFeed {
|
|
|
138
142
|
|
|
139
143
|
/** Close the live thought of `source`, replacing it with the final text when given. */
|
|
140
144
|
thinkEnd(source: string, text?: string): void {
|
|
141
|
-
const
|
|
145
|
+
const keyed = new Set(this.liveThoughts.values());
|
|
146
|
+
const last = [...this.thoughts].reverse().find((entry) => entry.source === source && entry.live && !keyed.has(entry.id));
|
|
142
147
|
if (!last) {
|
|
143
148
|
if (text?.trim()) this.thought(source, text);
|
|
144
149
|
return;
|
|
@@ -157,6 +162,32 @@ export class LobbyFeed {
|
|
|
157
162
|
this.touch();
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
/**
|
|
166
|
+
* A whole thought from work still under way (`key`: its run, quick fix or
|
|
167
|
+
* review): live, so the Thinking orb takes its agent's colour, until the
|
|
168
|
+
* same work's next thought or `settleThought(key)` when it ends.
|
|
169
|
+
*/
|
|
170
|
+
liveThought(source: string, text: string, key: string, at = Date.now()): void {
|
|
171
|
+
const body = text.trim();
|
|
172
|
+
if (!body) return;
|
|
173
|
+
this.settleThought(key, false);
|
|
174
|
+
const entry: ThoughtEntry = { id: this.nextId++, at, source, text: body.slice(-MAX_THOUGHT_TEXT), live: true };
|
|
175
|
+
this.thoughts = bounded([...this.thoughts, entry], MAX_THOUGHTS);
|
|
176
|
+
this.liveThoughts.set(key, entry.id);
|
|
177
|
+
this.touch();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The work behind `key` has ended: its last thought is no longer live. */
|
|
181
|
+
settleThought(key: string, touch = true): void {
|
|
182
|
+
const id = this.liveThoughts.get(key);
|
|
183
|
+
if (id === undefined) return;
|
|
184
|
+
this.liveThoughts.delete(key);
|
|
185
|
+
const entry = this.thoughts.find((thought) => thought.id === id);
|
|
186
|
+
if (!entry?.live) return;
|
|
187
|
+
entry.live = false;
|
|
188
|
+
if (touch) this.touch();
|
|
189
|
+
}
|
|
190
|
+
|
|
160
191
|
/** Stream the oracle's reply text as it arrives. */
|
|
161
192
|
replyDelta(delta: string): void {
|
|
162
193
|
this.reply = trimReply(this.reply + delta);
|
|
@@ -207,8 +238,11 @@ export class LobbyFeed {
|
|
|
207
238
|
}
|
|
208
239
|
if (run.thought && this.runThoughts.get(key) !== run.thought) {
|
|
209
240
|
this.runThoughts.set(key, run.thought);
|
|
210
|
-
|
|
241
|
+
// Live while the run goes on: the orb shows who is thinking, not only the oracle.
|
|
242
|
+
if (run.status === "running") this.liveThought(source, run.thought, key, at);
|
|
243
|
+
else this.thought(source, run.thought, at);
|
|
211
244
|
}
|
|
245
|
+
if (run.status !== "running") this.settleThought(key);
|
|
212
246
|
if (run.status !== "running" && before !== run.status) {
|
|
213
247
|
this.settle(undefined, key);
|
|
214
248
|
const kind: ActivityKind = run.status === "success" ? (run.wrappedUp ? "warning" : "success") : run.status === "cancelled" ? "warning" : "error";
|
|
@@ -225,6 +259,7 @@ export class LobbyFeed {
|
|
|
225
259
|
this.reply = "";
|
|
226
260
|
this.runSteps.clear();
|
|
227
261
|
this.runThoughts.clear();
|
|
262
|
+
this.liveThoughts.clear();
|
|
228
263
|
this.runStatus.clear();
|
|
229
264
|
this.touch();
|
|
230
265
|
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -87,12 +87,16 @@ export interface ModelChoice {
|
|
|
87
87
|
/** Everything the web server needs from pi and bot-lobby. */
|
|
88
88
|
export interface LobbyService {
|
|
89
89
|
projectRoot?(): string;
|
|
90
|
+
/** The linters the project configures, for the Settings page's Linting section. */
|
|
91
|
+
linters?(): Array<{ tool: string; folder: string; installed: boolean }>;
|
|
90
92
|
sessionId(): string | undefined;
|
|
91
93
|
/** This session's active task and its runs, as the lobby reads them. */
|
|
92
94
|
zen(): { task?: Task; runs: readonly AgentRun[] };
|
|
93
95
|
feed: LobbyFeed;
|
|
94
96
|
masterBusy(): boolean;
|
|
95
97
|
tasks(): Task[];
|
|
98
|
+
/** How long a task's agents have worked on it, and the worker runs under way (`work.json`); undefined without one. */
|
|
99
|
+
work?(taskId: string): import("../state/work-time.ts").WorkProjection | undefined;
|
|
96
100
|
deliveryDeliver?(taskId: string, request: import("../delivery/operations.ts").DeliveryRequest): Promise<import("../delivery/types.ts").Delivery>;
|
|
97
101
|
deliveryReview?(taskId: string): Promise<import("../delivery/types.ts").Delivery>;
|
|
98
102
|
deliveryDefer?(taskId: string, reviewId: string): import("../delivery/types.ts").Delivery;
|
package/src/lobby/issues.ts
CHANGED
|
@@ -154,14 +154,6 @@ export async function createIssue(exec: Exec, cwd: string, title: string, body:
|
|
|
154
154
|
return { url, ...(Number.isFinite(number) ? { number } : {}) };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/** The issue as text for the planner: body plus comments. */
|
|
158
|
-
export function issueText(issue: IssueDetail): string {
|
|
159
|
-
const comments = issue.comments
|
|
160
|
-
.filter((comment) => comment.body.trim())
|
|
161
|
-
.map((comment) => `**${comment.author ?? "someone"}** commented:\n${comment.body.trim()}`);
|
|
162
|
-
return [issue.body.trim() || "(no description)", ...comments].join("\n\n");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
157
|
/** Lobby state for the Issues tab: the list, the open issue, and what is loading. */
|
|
166
158
|
export class IssuesState {
|
|
167
159
|
issues: IssueSummary[] = [];
|