@nemus-cli/nemus 0.2.13 → 0.3.1
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/CHANGELOG.md +31 -0
- package/README.md +17 -0
- package/dist/cli/ai-prompt.js +13 -25
- package/dist/commands/reflect.js +160 -0
- package/dist/program.js +2 -0
- package/dist/utils/agent-judge.js +137 -0
- package/dist/utils/reflect.js +353 -0
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +12 -22
- package/src/commands/reflect.ts +172 -0
- package/src/program.ts +2 -0
- package/src/utils/agent-judge.test.ts +88 -0
- package/src/utils/agent-judge.ts +168 -0
- package/src/utils/reflect.test.ts +103 -0
- package/src/utils/reflect.ts +405 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { listWorkspaces } from './workspace-meta';
|
|
4
|
+
import { getAgentPaths, getSkillsTargetDirs, getAllKnownContextFileNames, ConcreteAgentType } from './agent-config';
|
|
5
|
+
import { pathToProjectDirName, getWorkspaceSessions, WorkspaceSession } from './claude-sessions';
|
|
6
|
+
|
|
7
|
+
// ------------------------------------------------------------------ types
|
|
8
|
+
|
|
9
|
+
export interface SessionDigest {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
agentType: string;
|
|
12
|
+
/** Number of assistant turns (a rough measure of how much back-and-forth). */
|
|
13
|
+
turns: number;
|
|
14
|
+
/** Human prompts the user sent (the raw material for judging prompt quality). */
|
|
15
|
+
userPrompts: string[];
|
|
16
|
+
/** Error/failure snippets from tool results (missing skills/tests show up here). */
|
|
17
|
+
errors: string[];
|
|
18
|
+
/** Distinct tool names the agent used. */
|
|
19
|
+
tools: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkspaceDigest {
|
|
23
|
+
name: string;
|
|
24
|
+
repoCount: number;
|
|
25
|
+
repos: string[];
|
|
26
|
+
/** Context files present at the workspace root, e.g. ['AGENTS.md']. */
|
|
27
|
+
contextFiles: string[];
|
|
28
|
+
session: SessionDigest | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReflectionCorpus {
|
|
32
|
+
generatedAt: string;
|
|
33
|
+
/** Skills already installed globally (so the judge suggests real gaps). */
|
|
34
|
+
availableSkills: string[];
|
|
35
|
+
workspaces: WorkspaceDigest[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type RecommendationKind =
|
|
39
|
+
| 'skill' | 'context' | 'test' | 'prompt' | 'connectivity' | 'workflow' | 'other';
|
|
40
|
+
|
|
41
|
+
export interface Recommendation {
|
|
42
|
+
kind: RecommendationKind;
|
|
43
|
+
title: string;
|
|
44
|
+
detail: string;
|
|
45
|
+
/** Where it applies — a workspace, repo, or path (optional). */
|
|
46
|
+
target?: string;
|
|
47
|
+
priority: 'high' | 'medium' | 'low';
|
|
48
|
+
/** A concrete snippet (skill stub, AGENTS.md rule, test idea). */
|
|
49
|
+
example?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReflectionReport {
|
|
53
|
+
summary: string;
|
|
54
|
+
recommendations: Recommendation[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --------------------------------------------------------- transcript distill
|
|
58
|
+
|
|
59
|
+
// Kept deliberately lean: the judge runs on the user's own (often local, slow)
|
|
60
|
+
// agent, and a 10-workspace corpus at full verbosity produced a ~200KB prompt
|
|
61
|
+
// that timed pi out. These bounds capture the pattern of a session at a
|
|
62
|
+
// fraction of the tokens (~halving the prompt), so the judge actually finishes.
|
|
63
|
+
const MAX_PROMPTS = 12;
|
|
64
|
+
const MAX_ERRORS = 15;
|
|
65
|
+
const PROMPT_CHARS = 400;
|
|
66
|
+
const ERROR_CHARS = 200;
|
|
67
|
+
|
|
68
|
+
/** Flatten a message `content` (string or content-block array) to plain text. */
|
|
69
|
+
function contentToText(content: unknown): string {
|
|
70
|
+
if (typeof content === 'string') return content;
|
|
71
|
+
if (Array.isArray(content)) {
|
|
72
|
+
return content
|
|
73
|
+
.map((b: any) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join('\n');
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Only used as a FALLBACK when a tool result carries no explicit error flag.
|
|
81
|
+
// Kept to strong failure signals so a successful grep/log line for the word
|
|
82
|
+
// "error", or a passing test named “…error…”, isn't mistaken for a failure.
|
|
83
|
+
const ERROR_RE = /\b(fatal|failed|failure|exception|traceback|denied|not a git repository|timed out|exit code\s+[1-9])\b/i;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decide whether a tool result is a failure: trust the explicit `isError` flag
|
|
87
|
+
* when present (true => failure, false => success), and only guess from the
|
|
88
|
+
* text when there's no flag at all. This keeps the judge's “evidence” to real
|
|
89
|
+
* failures instead of any output that happens to contain the word “error”.
|
|
90
|
+
*/
|
|
91
|
+
function isToolFailure(flag: unknown, text: string): boolean {
|
|
92
|
+
if (flag === true) return true;
|
|
93
|
+
if (flag === false) return false;
|
|
94
|
+
return ERROR_RE.test(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Distill a raw `.jsonl` transcript into the signals a judge needs: the human
|
|
99
|
+
* prompts, tool failures, and which tools ran. Pure (operates on file content),
|
|
100
|
+
* defensive about the several line shapes Claude/pi emit, and bounded so a huge
|
|
101
|
+
* transcript can't blow the prompt budget.
|
|
102
|
+
*/
|
|
103
|
+
export function distillTranscript(
|
|
104
|
+
raw: string,
|
|
105
|
+
meta: { sessionId: string; agentType: string },
|
|
106
|
+
): SessionDigest {
|
|
107
|
+
const userPrompts: string[] = [];
|
|
108
|
+
const errors: string[] = [];
|
|
109
|
+
const tools = new Set<string>();
|
|
110
|
+
let turns = 0;
|
|
111
|
+
|
|
112
|
+
for (const line of raw.split('\n')) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (!trimmed) continue;
|
|
115
|
+
let obj: any;
|
|
116
|
+
try {
|
|
117
|
+
obj = JSON.parse(trimmed);
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const msg = obj.message ?? obj;
|
|
123
|
+
const role = msg?.role ?? obj?.type;
|
|
124
|
+
const content = msg?.content;
|
|
125
|
+
|
|
126
|
+
// Assistant turn + tool uses. Claude uses `tool_use` blocks; pi uses `toolCall`.
|
|
127
|
+
if (role === 'assistant') {
|
|
128
|
+
turns++;
|
|
129
|
+
if (Array.isArray(content)) {
|
|
130
|
+
for (const b of content) {
|
|
131
|
+
if (b && (b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string') tools.add(b.name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Tool results, two shapes:
|
|
137
|
+
// - pi: a top-level message with role 'toolResult' (+ toolName, content).
|
|
138
|
+
// - Claude: a `tool_result` block inside a user message's content array.
|
|
139
|
+
if (role === 'toolResult') {
|
|
140
|
+
if (typeof msg.toolName === 'string') tools.add(msg.toolName);
|
|
141
|
+
const text = contentToText(content);
|
|
142
|
+
if (isToolFailure(msg.isError ?? msg.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
|
|
143
|
+
errors.push(text.trim().slice(0, ERROR_CHARS));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(content)) {
|
|
147
|
+
for (const b of content) {
|
|
148
|
+
if (b && b.type === 'tool_result') {
|
|
149
|
+
const text = contentToText(b.content);
|
|
150
|
+
if (isToolFailure(b.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
|
|
151
|
+
errors.push(text.trim().slice(0, ERROR_CHARS));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Human prompts: a user message that carries actual text (not a tool_result echo).
|
|
158
|
+
if (role === 'user') {
|
|
159
|
+
const isToolResultOnly =
|
|
160
|
+
Array.isArray(content) && content.length > 0 && content.every((b: any) => b?.type === 'tool_result');
|
|
161
|
+
if (!isToolResultOnly) {
|
|
162
|
+
const text = contentToText(content).trim();
|
|
163
|
+
if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
|
|
164
|
+
userPrompts.push(text.slice(0, PROMPT_CHARS));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --------------------------------------------------------- corpus gathering
|
|
174
|
+
|
|
175
|
+
/** Locate the most recent `.jsonl` transcript for a workspace under an agent. */
|
|
176
|
+
export async function findLatestTranscriptFile(
|
|
177
|
+
sessionProjectsDir: string,
|
|
178
|
+
workspacePath: string,
|
|
179
|
+
agentType: ConcreteAgentType,
|
|
180
|
+
): Promise<string | null> {
|
|
181
|
+
if (agentType !== 'claude' && agentType !== 'pi') return null;
|
|
182
|
+
const projDir = path.join(sessionProjectsDir, pathToProjectDirName(workspacePath, agentType));
|
|
183
|
+
let entries: string[];
|
|
184
|
+
try {
|
|
185
|
+
entries = await fs.readdir(projDir);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const jsonl = entries.filter((f) => f.endsWith('.jsonl'));
|
|
190
|
+
if (jsonl.length === 0) return null;
|
|
191
|
+
const stats = await Promise.all(
|
|
192
|
+
jsonl.map(async (f) => {
|
|
193
|
+
try {
|
|
194
|
+
return { f, mtime: (await fs.stat(path.join(projDir, f))).mtime.getTime() };
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const best = stats.filter((s): s is { f: string; mtime: number } => !!s).sort((a, b) => b.mtime - a.mtime)[0];
|
|
201
|
+
return best ? path.join(projDir, best.f) : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
|
|
205
|
+
|
|
206
|
+
/** Read + distill the transcript for a specific discovered session. Prefers the
|
|
207
|
+
* exact `<sessionId>.jsonl`; falls back to the latest transcript in that
|
|
208
|
+
* project dir if the exact file has been rotated away. */
|
|
209
|
+
async function readDigestForSession(s: WorkspaceSession): Promise<SessionDigest | null> {
|
|
210
|
+
if (s.agentType !== 'claude' && s.agentType !== 'pi') return null;
|
|
211
|
+
const projectsDir = getAgentPaths(s.agentType).sessionProjectsDir;
|
|
212
|
+
const exact = path.join(projectsDir, pathToProjectDirName(s.workspacePath, s.agentType), `${s.sessionId}.jsonl`);
|
|
213
|
+
let file: string | null = exact;
|
|
214
|
+
try {
|
|
215
|
+
await fs.access(exact);
|
|
216
|
+
} catch {
|
|
217
|
+
file = await findLatestTranscriptFile(projectsDir, s.workspacePath, s.agentType);
|
|
218
|
+
}
|
|
219
|
+
if (!file) return null;
|
|
220
|
+
let raw: string;
|
|
221
|
+
try {
|
|
222
|
+
raw = await fs.readFile(file, 'utf-8');
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (raw.length > MAX_TRANSCRIPT_BYTES) raw = raw.slice(raw.length - MAX_TRANSCRIPT_BYTES); // keep the tail (most recent)
|
|
227
|
+
return distillTranscript(raw, { sessionId: s.sessionId, agentType: s.agentType });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function listAvailableSkills(): Promise<string[]> {
|
|
231
|
+
const names = new Set<string>();
|
|
232
|
+
for (const dir of getSkillsTargetDirs()) {
|
|
233
|
+
try {
|
|
234
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
235
|
+
if (entry.isDirectory()) names.add(entry.name);
|
|
236
|
+
else if (entry.name.endsWith('.md')) names.add(entry.name.replace(/\.md$/, ''));
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
/* dir may not exist */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...names].sort();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function contextFilesFor(workspacePath: string): Promise<string[]> {
|
|
246
|
+
const present: string[] = [];
|
|
247
|
+
for (const name of getAllKnownContextFileNames()) {
|
|
248
|
+
try {
|
|
249
|
+
await fs.access(path.join(workspacePath, name));
|
|
250
|
+
present.push(name);
|
|
251
|
+
} catch {
|
|
252
|
+
/* not present */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return present;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Fired as each workspace is read + distilled, so the CLI can show live,
|
|
259
|
+
* per-workspace progress during the (I/O-bound) gather phase. */
|
|
260
|
+
export interface ReflectProgress {
|
|
261
|
+
index: number;
|
|
262
|
+
total: number;
|
|
263
|
+
digest: WorkspaceDigest;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the corpus the judge reasons over: the `limit` most **recently active**
|
|
268
|
+
* workspaces (by their latest agent session, not creation date — a retrospective
|
|
269
|
+
* is about recent *work*), each with its repos, context files, and distilled
|
|
270
|
+
* session, plus the globally-available skills. `onProgress` (optional) fires
|
|
271
|
+
* once per workspace as it finishes, for a live progress display.
|
|
272
|
+
*/
|
|
273
|
+
export async function gatherReflectionCorpus(
|
|
274
|
+
limit: number,
|
|
275
|
+
onProgress?: (p: ReflectProgress) => void,
|
|
276
|
+
): Promise<ReflectionCorpus> {
|
|
277
|
+
const [sessions, workspaces, availableSkills] = await Promise.all([
|
|
278
|
+
getWorkspaceSessions(), // already sorted by last-active, one per workspace
|
|
279
|
+
listWorkspaces(false),
|
|
280
|
+
listAvailableSkills(),
|
|
281
|
+
]);
|
|
282
|
+
const metaByName = new Map(workspaces.map((w) => [w.name, w]));
|
|
283
|
+
const recent = sessions.slice(0, limit);
|
|
284
|
+
|
|
285
|
+
const digests: WorkspaceDigest[] = [];
|
|
286
|
+
for (let index = 0; index < recent.length; index++) {
|
|
287
|
+
const s = recent[index];
|
|
288
|
+
const meta = metaByName.get(s.workspaceName);
|
|
289
|
+
const digest: WorkspaceDigest = {
|
|
290
|
+
name: s.workspaceName,
|
|
291
|
+
repoCount: meta?.metadata?.repositories?.length ?? 0,
|
|
292
|
+
repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
|
|
293
|
+
contextFiles: await contextFilesFor(s.workspacePath),
|
|
294
|
+
session: await readDigestForSession(s),
|
|
295
|
+
};
|
|
296
|
+
digests.push(digest);
|
|
297
|
+
onProgress?.({ index, total: recent.length, digest });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ------------------------------------------------------------- judge prompt
|
|
304
|
+
|
|
305
|
+
/** JSON schema for `claude --json-schema` (best-effort; other agents ignore it). */
|
|
306
|
+
export const REFLECT_SCHEMA = JSON.stringify({
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {
|
|
309
|
+
summary: { type: 'string' },
|
|
310
|
+
recommendations: {
|
|
311
|
+
type: 'array',
|
|
312
|
+
items: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
kind: { type: 'string', enum: ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'] },
|
|
316
|
+
title: { type: 'string' },
|
|
317
|
+
detail: { type: 'string' },
|
|
318
|
+
target: { type: 'string' },
|
|
319
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
320
|
+
example: { type: 'string' },
|
|
321
|
+
},
|
|
322
|
+
required: ['kind', 'title', 'detail', 'priority'],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
required: ['summary', 'recommendations'],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
|
|
331
|
+
* is asked to recommend concrete improvements to the user's SETUP (skills,
|
|
332
|
+
* AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
|
|
333
|
+
* redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
|
|
334
|
+
*/
|
|
335
|
+
export function buildJudgePrompt(corpus: ReflectionCorpus): string {
|
|
336
|
+
const lines: string[] = [];
|
|
337
|
+
lines.push(
|
|
338
|
+
'You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.',
|
|
339
|
+
'Goal: recommend concrete improvements to their SETUP so the agent works better next time —',
|
|
340
|
+
'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/',
|
|
341
|
+
'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.',
|
|
342
|
+
'',
|
|
343
|
+
'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague',
|
|
344
|
+
'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.',
|
|
345
|
+
'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.',
|
|
346
|
+
'',
|
|
347
|
+
`Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`,
|
|
348
|
+
'',
|
|
349
|
+
`Recent workspaces (${corpus.workspaces.length}):`,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
for (const ws of corpus.workspaces) {
|
|
353
|
+
lines.push(`\n## ${ws.name}`);
|
|
354
|
+
lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
|
|
355
|
+
if (!ws.session) {
|
|
356
|
+
lines.push('session: (no recent agent session found)');
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
|
|
360
|
+
if (ws.session.userPrompts.length) {
|
|
361
|
+
lines.push('user prompts:');
|
|
362
|
+
for (const p of ws.session.userPrompts) lines.push(` - ${p.replace(/\n/g, ' ')}`);
|
|
363
|
+
}
|
|
364
|
+
if (ws.session.errors.length) {
|
|
365
|
+
lines.push('errors/failures observed:');
|
|
366
|
+
for (const e of ws.session.errors) lines.push(` - ${e.replace(/\n/g, ' ')}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
lines.push(
|
|
371
|
+
'',
|
|
372
|
+
'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):',
|
|
373
|
+
'{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",',
|
|
374
|
+
'"title": string, "detail": string, "target": string(optional workspace/repo/path),',
|
|
375
|
+
'"priority":"high|medium|low", "example": string(optional snippet)}]}',
|
|
376
|
+
);
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ----------------------------------------------------------- response parse
|
|
381
|
+
|
|
382
|
+
const KINDS: RecommendationKind[] = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
|
|
383
|
+
const PRIORITIES = ['high', 'medium', 'low'] as const;
|
|
384
|
+
|
|
385
|
+
/** Validate + normalize the judge's parsed JSON into a ReflectionReport. */
|
|
386
|
+
export function parseReflectionReport(parsed: unknown): ReflectionReport {
|
|
387
|
+
const obj = (parsed ?? {}) as any;
|
|
388
|
+
const summary = typeof obj.summary === 'string' ? obj.summary : '';
|
|
389
|
+
const rawRecs = Array.isArray(obj.recommendations) ? obj.recommendations : [];
|
|
390
|
+
const recommendations: Recommendation[] = rawRecs
|
|
391
|
+
.map((r: any): Recommendation | null => {
|
|
392
|
+
if (!r || typeof r !== 'object') return null;
|
|
393
|
+
const title = typeof r.title === 'string' ? r.title : '';
|
|
394
|
+
const detail = typeof r.detail === 'string' ? r.detail : '';
|
|
395
|
+
if (!title && !detail) return null;
|
|
396
|
+
const kind: RecommendationKind = KINDS.includes(r.kind) ? r.kind : 'other';
|
|
397
|
+
const priority = PRIORITIES.includes(r.priority) ? r.priority : 'medium';
|
|
398
|
+
const rec: Recommendation = { kind, title, detail, priority };
|
|
399
|
+
if (typeof r.target === 'string' && r.target.trim()) rec.target = r.target.trim();
|
|
400
|
+
if (typeof r.example === 'string' && r.example.trim()) rec.example = r.example.trim();
|
|
401
|
+
return rec;
|
|
402
|
+
})
|
|
403
|
+
.filter((r: Recommendation | null): r is Recommendation => r !== null);
|
|
404
|
+
return { summary, recommendations };
|
|
405
|
+
}
|