@nemus-cli/nemus 0.5.0 → 0.9.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.
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ /**
4
+ * Resolve the user's preferred editor as an argv array. Honors `$VISUAL` then
5
+ * `$EDITOR` (the long-standing Unix convention — `VISUAL` wins for full-screen
6
+ * editors), falling back to `notepad` on Windows and `vi` elsewhere. The env
7
+ * value may include flags (e.g. `code --wait`, `emacs -nw`), so it's split on
8
+ * whitespace into a command + args. Pure + unit-tested.
9
+ */
10
+ export function resolveEditor(
11
+ env: NodeJS.ProcessEnv = process.env,
12
+ platform: NodeJS.Platform = process.platform,
13
+ ): string[] {
14
+ const raw = (env.VISUAL || env.EDITOR || '').trim();
15
+ if (raw) return raw.split(/\s+/);
16
+ return platform === 'win32' ? ['notepad'] : ['vi'];
17
+ }
18
+
19
+ export interface EditorResult {
20
+ ok: boolean;
21
+ /** Editor argv[0] that was launched. */
22
+ editor: string;
23
+ /** Process exit code, when the editor ran and exited normally. */
24
+ code?: number;
25
+ /** Populated when the editor couldn't be launched at all. */
26
+ error?: string;
27
+ }
28
+
29
+ /**
30
+ * Open `file` in the resolved editor, inheriting the terminal so the editor is
31
+ * interactive. Returns a structured result rather than throwing so the caller
32
+ * controls messaging/exit. `spawn` is injected for tests.
33
+ */
34
+ export function openInEditor(
35
+ file: string,
36
+ deps: { spawn?: typeof spawnSync; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {},
37
+ ): EditorResult {
38
+ const spawn = deps.spawn ?? spawnSync;
39
+ const [cmd, ...args] = resolveEditor(deps.env, deps.platform);
40
+ const res = spawn(cmd, [...args, file], { stdio: 'inherit' });
41
+ if (res.error) {
42
+ const err = res.error as NodeJS.ErrnoException;
43
+ const reason = err.code === 'ENOENT' ? `editor "${cmd}" not found` : err.message;
44
+ return { ok: false, editor: cmd, error: reason };
45
+ }
46
+ const code = typeof res.status === 'number' ? res.status : 1;
47
+ return { ok: code === 0, editor: cmd, code };
48
+ }
@@ -1,6 +1,7 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt, saveReflectionReport } from './reflect';
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt, saveReflectionReport, severityCounts, severitySummary, renderReportMarkdown, groupRecommendations, listSavedReports, loadSavedReport, findSavedMatches, SavedReport, ReflectionReport, Recommendation } from './reflect';
3
3
  import * as fs from 'fs/promises';
4
+ import * as os from 'os';
4
5
  import * as path from 'path';
5
6
 
6
7
  const J = (o: unknown) => JSON.stringify(o);
@@ -124,3 +125,129 @@ describe('parseReflectionReport', () => {
124
125
  expect(parseReflectionReport({ recommendations: 'nope' })).toEqual({ summary: '', recommendations: [] });
125
126
  });
126
127
  });
128
+
129
+ describe('severityCounts / severitySummary', () => {
130
+ const recs = (ps: Array<'high' | 'medium' | 'low'>) =>
131
+ ps.map((priority) => ({ kind: 'other' as const, title: 't', detail: 'd', priority }));
132
+
133
+ it('counts by priority', () => {
134
+ expect(severityCounts(recs(['high', 'high', 'low']))).toEqual({ high: 2, medium: 0, low: 1 });
135
+ });
136
+ it('summary omits empty buckets and is empty for none', () => {
137
+ expect(severitySummary(recs(['high', 'medium', 'medium']))).toBe('1 high · 2 medium');
138
+ expect(severitySummary([])).toBe('');
139
+ });
140
+ });
141
+
142
+ describe('renderReportMarkdown', () => {
143
+ const report: ReflectionReport = {
144
+ summary: 'Overall fine.',
145
+ recommendations: [
146
+ { kind: 'skill', title: 'Add deploy skill', detail: 'manual steps', priority: 'high', target: 'acme/api', example: 'name: deploy' },
147
+ { kind: 'context', title: 'Doc lint', detail: 'guessed', priority: 'medium' },
148
+ ],
149
+ };
150
+
151
+ it('groups by severity with a count line and headings', () => {
152
+ const md = renderReportMarkdown(report, { analyzed: 5, workspaces: 3, generatedAt: '2026-09-01T12:00:00Z' });
153
+ expect(md).toMatch(/^# Reflection/);
154
+ expect(md).toContain('_5 sessions across 3 workspaces · 2026-09-01T12:00:00Z_');
155
+ expect(md).toContain('**1 high · 1 medium**');
156
+ expect(md).toContain('### High priority');
157
+ expect(md).toContain('### Medium priority');
158
+ // high appears before medium
159
+ expect(md.indexOf('### High priority')).toBeLessThan(md.indexOf('### Medium priority'));
160
+ expect(md).toContain('- **[Skill] Add deploy skill** (`acme/api`)');
161
+ expect(md).toContain('- **[Context/AGENTS.md] Doc lint**');
162
+ expect(md.endsWith('\n')).toBe(true);
163
+ });
164
+
165
+ it('uses a single-workspace scope line', () => {
166
+ const md = renderReportMarkdown(report, { analyzed: 1, workspaces: 1, workspace: 'my-ws' });
167
+ expect(md).toContain('_workspace **my-ws**_');
168
+ });
169
+
170
+ it('escapes an example that itself contains a triple-backtick fence', () => {
171
+ const r: ReflectionReport = {
172
+ summary: '',
173
+ recommendations: [{ kind: 'other', title: 'x', detail: '', priority: 'low', example: 'a ```b``` c' }],
174
+ };
175
+ const md = renderReportMarkdown(r, { analyzed: 1, workspaces: 1 });
176
+ expect(md).toContain('````'); // fence longer than the inner run
177
+ expect(md).toContain('a ```b``` c');
178
+ });
179
+
180
+ it('renders a clean empty state', () => {
181
+ const md = renderReportMarkdown({ summary: 'All good.', recommendations: [] }, { analyzed: 2, workspaces: 2 });
182
+ expect(md).toContain('_No specific recommendations — looks solid._');
183
+ expect(md).not.toContain('### High');
184
+ });
185
+ });
186
+
187
+ describe('groupRecommendations', () => {
188
+ const mk = (kind: Recommendation['kind'], priority: Recommendation['priority'], title = 't'): Recommendation =>
189
+ ({ kind, title, detail: 'd', priority });
190
+
191
+ it('groups by priority (high→low), omitting empty groups', () => {
192
+ const groups = groupRecommendations([mk('skill', 'low'), mk('context', 'high')], 'priority');
193
+ expect(groups.map((g) => g.key)).toEqual(['high', 'low']); // no 'medium'
194
+ expect(groups[0].heading).toBe('High priority');
195
+ });
196
+
197
+ it('groups by kind in a fixed order, priority-sorted within a kind', () => {
198
+ const groups = groupRecommendations(
199
+ [mk('context', 'low'), mk('skill', 'low', 'a'), mk('skill', 'high', 'b')],
200
+ 'kind',
201
+ );
202
+ expect(groups.map((g) => g.key)).toEqual(['skill', 'context']); // skill before context
203
+ expect(groups[0].recs.map((r) => r.title)).toEqual(['b', 'a']); // high before low within skill
204
+ });
205
+ });
206
+
207
+ describe('findSavedMatches (pure)', () => {
208
+ const mk = (id: string): SavedReport => ({ id, file: `${id}.json`, analyzed: 0, workspaces: 0, report: { summary: '', recommendations: [] } });
209
+ // newest-first list, as listSavedReports returns it
210
+ const all = [mk('2000-03'), mk('2000-02b'), mk('2000-02a'), mk('2000-01')];
211
+
212
+ it('empty / latest / exact', () => {
213
+ expect(findSavedMatches([], '2000')).toEqual([]);
214
+ expect(findSavedMatches(all)[0].id).toBe('2000-03'); // undefined -> newest
215
+ expect(findSavedMatches(all, 'latest')[0].id).toBe('2000-03');
216
+ expect(findSavedMatches(all, '2000-02a').map((r) => r.id)).toEqual(['2000-02a']); // exact wins
217
+ });
218
+ it('an ambiguous prefix returns every match, newest-first', () => {
219
+ expect(findSavedMatches(all, '2000-02').map((r) => r.id)).toEqual(['2000-02b', '2000-02a']);
220
+ expect(findSavedMatches(all, 'nope')).toEqual([]);
221
+ });
222
+ });
223
+
224
+ describe('listSavedReports / loadSavedReport (isolated temp dir)', () => {
225
+ let dir: string;
226
+ const idA = '2000-01-01T00-00-00-000Z-vitestA';
227
+ const idB = '2000-01-02T00-00-00-000Z-vitestB';
228
+
229
+ beforeAll(async () => {
230
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'nemus-reflect-test-'));
231
+ await fs.writeFile(path.join(dir, `${idA}.json`), JSON.stringify({ generatedAt: '2000-01-01T00:00:00.000Z', analyzed: 4, workspaces: 3, summary: 'old', recommendations: [{ kind: 'skill', title: 'x', detail: 'd', priority: 'high' }] }));
232
+ await fs.writeFile(path.join(dir, `${idB}.json`), JSON.stringify({ generatedAt: '2000-01-02T00:00:00.000Z', analyzed: 1, workspaces: 1, workspace: 'ws', summary: 'new', recommendations: [] }));
233
+ await fs.writeFile(path.join(dir, 'not-json.txt'), 'ignore me');
234
+ await fs.writeFile(path.join(dir, 'corrupt.json'), '{ not valid json');
235
+ });
236
+ afterAll(async () => {
237
+ await fs.rm(dir, { recursive: true, force: true });
238
+ });
239
+
240
+ it('lists newest-first, skipping non-json and corrupt files', async () => {
241
+ const all = await listSavedReports(dir);
242
+ expect(all.map((r) => r.id)).toEqual([idB, idA]); // exactly two, newer first
243
+ expect(all[0].workspace).toBe('ws');
244
+ expect(all[0].report.recommendations).toHaveLength(0);
245
+ });
246
+
247
+ it('loadSavedReport resolves latest, exact id, and prefix', async () => {
248
+ expect((await loadSavedReport('latest', dir))?.id).toBe(idB);
249
+ expect((await loadSavedReport(idA, dir))?.workspaces).toBe(3);
250
+ expect((await loadSavedReport('2000-01-02T00-00-00', dir))?.id).toBe(idB);
251
+ expect(await loadSavedReport('definitely-no-such-id-xyz', dir)).toBeNull();
252
+ });
253
+ });
@@ -63,6 +63,134 @@ export interface ReflectionReport {
63
63
  recommendations: Recommendation[];
64
64
  }
65
65
 
66
+ // ------------------------------------------------------------ report rendering
67
+
68
+ export type Priority = Recommendation['priority'];
69
+
70
+ /** Count recommendations by priority. */
71
+ export function severityCounts(recs: Recommendation[]): Record<Priority, number> {
72
+ const counts: Record<Priority, number> = { high: 0, medium: 0, low: 0 };
73
+ for (const r of recs) counts[r.priority]++;
74
+ return counts;
75
+ }
76
+
77
+ /** "3 high · 2 medium · 1 low", omitting zero buckets; '' when there are none. */
78
+ export function severitySummary(recs: Recommendation[]): string {
79
+ const c = severityCounts(recs);
80
+ return (['high', 'medium', 'low'] as Priority[])
81
+ .filter((p) => c[p] > 0)
82
+ .map((p) => `${c[p]} ${p}`)
83
+ .join(' · ');
84
+ }
85
+
86
+ export const KIND_LABEL: Record<RecommendationKind, string> = {
87
+ skill: 'Skill',
88
+ context: 'Context/AGENTS.md',
89
+ test: 'Test',
90
+ prompt: 'Prompt',
91
+ connectivity: 'Connectivity',
92
+ workflow: 'Workflow',
93
+ other: 'Other',
94
+ };
95
+ // Back-compat alias for existing references.
96
+ const MD_KIND_LABEL = KIND_LABEL;
97
+
98
+ export const PRIORITY_HEADING: Record<Priority, string> = {
99
+ high: 'High priority',
100
+ medium: 'Medium priority',
101
+ low: 'Low priority',
102
+ };
103
+
104
+ export type GroupBy = 'priority' | 'kind';
105
+
106
+ const KIND_ORDER: RecommendationKind[] = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
107
+ const PRIORITY_ORDER: Priority[] = ['high', 'medium', 'low'];
108
+ const PRIORITY_RANK: Record<Priority, number> = { high: 0, medium: 1, low: 2 };
109
+
110
+ export interface RecGroup {
111
+ key: string;
112
+ heading: string;
113
+ recs: Recommendation[];
114
+ }
115
+
116
+ /**
117
+ * Split recommendations into ordered, non-empty groups by either priority
118
+ * (high→low) or kind (a fixed, stable order). When grouping by kind, each
119
+ * group's recs are sorted high-priority first. Pure + unit-tested; shared by the
120
+ * Markdown renderer and the human printer so the two never diverge.
121
+ */
122
+ export function groupRecommendations(recs: Recommendation[], groupBy: GroupBy): RecGroup[] {
123
+ if (groupBy === 'kind') {
124
+ return KIND_ORDER.map((k) => ({
125
+ key: k,
126
+ heading: KIND_LABEL[k],
127
+ recs: recs
128
+ .filter((r) => r.kind === k)
129
+ .sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]),
130
+ })).filter((g) => g.recs.length > 0);
131
+ }
132
+ return PRIORITY_ORDER.map((p) => ({
133
+ key: p,
134
+ heading: PRIORITY_HEADING[p],
135
+ recs: recs.filter((r) => r.priority === p),
136
+ })).filter((g) => g.recs.length > 0);
137
+ }
138
+
139
+ /** A fenced code block whose fence is guaranteed longer than any backtick run
140
+ * inside `body`, so the snippet can't break out of its own fence. */
141
+ function fencedBlock(body: string): string {
142
+ const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((m) => m.length));
143
+ const fence = '`'.repeat(Math.max(3, longest + 1));
144
+ return `${fence}\n${body}\n${fence}`;
145
+ }
146
+
147
+ /**
148
+ * Render a reflection report as clean Markdown — for pasting into an issue/PR or
149
+ * saving alongside the JSON. Pure (no color, no I/O) so it's unit-tested.
150
+ * Recommendations are grouped by severity (high→low); each carries its kind,
151
+ * optional target, detail, and a fenced example.
152
+ */
153
+ export function renderReportMarkdown(
154
+ report: ReflectionReport,
155
+ meta: { analyzed: number; workspaces: number; workspace?: string; generatedAt?: string },
156
+ groupBy: GroupBy = 'priority',
157
+ ): string {
158
+ const lines: string[] = ['# Reflection', ''];
159
+ const scope = meta.workspace
160
+ ? `workspace **${meta.workspace}**`
161
+ : `${meta.analyzed} session${meta.analyzed === 1 ? '' : 's'} across ${meta.workspaces} workspace${meta.workspaces === 1 ? '' : 's'}`;
162
+ const stamp = meta.generatedAt ? ` · ${meta.generatedAt}` : '';
163
+ lines.push(`_${scope}${stamp}_`, '');
164
+
165
+ if (report.summary.trim()) lines.push(report.summary.trim(), '');
166
+
167
+ if (report.recommendations.length === 0) {
168
+ lines.push('## Recommendations', '', '_No specific recommendations — looks solid._', '');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ lines.push('## Recommendations', '', `**${severitySummary(report.recommendations)}**`, '');
173
+
174
+ for (const group of groupRecommendations(report.recommendations, groupBy)) {
175
+ lines.push(`### ${group.heading}`, '');
176
+ for (const r of group.recs) {
177
+ const target = r.target ? ` (\`${r.target}\`)` : '';
178
+ // Under a kind heading the [Kind] prefix is redundant; show a priority tag
179
+ // instead. Under a priority heading, show the kind.
180
+ const label = groupBy === 'kind' ? `_${r.priority}_ — ` : `[${KIND_LABEL[r.kind]}] `;
181
+ lines.push(`- **${label}${r.title}**${target}`);
182
+ if (r.detail.trim()) {
183
+ lines.push(...r.detail.trim().split('\n').map((l) => ` ${l}`));
184
+ }
185
+ if (r.example?.trim()) {
186
+ lines.push('', ...fencedBlock(r.example.trim()).split('\n').map((l) => ` ${l}`));
187
+ }
188
+ lines.push('');
189
+ }
190
+ }
191
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
192
+ }
193
+
66
194
  // --------------------------------------------------------- transcript distill
67
195
 
68
196
  // Kept deliberately lean: the judge runs on the user's own (often local, slow)
@@ -455,3 +583,76 @@ export async function saveReflectionReport(
455
583
  await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
456
584
  return file;
457
585
  }
586
+
587
+ /** A saved report on disk, with its metadata and parsed report. */
588
+ export interface SavedReport {
589
+ /** Basename without .json — the id used by `reflect show <id>`. */
590
+ id: string;
591
+ file: string;
592
+ generatedAt?: string;
593
+ analyzed: number;
594
+ workspaces: number;
595
+ workspace?: string;
596
+ report: ReflectionReport;
597
+ }
598
+
599
+ /**
600
+ * List saved reports under `~/.nemus/reflect/`, newest first (filenames start
601
+ * with an ISO timestamp, so a reverse name sort is chronological). Unreadable /
602
+ * unparseable files are skipped, not fatal. Returns [] if the dir is absent.
603
+ */
604
+ export async function listSavedReports(dir: string = REFLECT_REPORTS_DIR): Promise<SavedReport[]> {
605
+ let names: string[];
606
+ try {
607
+ names = await fs.readdir(dir);
608
+ } catch {
609
+ return [];
610
+ }
611
+ const jsons = names.filter((n) => n.endsWith('.json')).sort().reverse();
612
+ const out: SavedReport[] = [];
613
+ for (const name of jsons) {
614
+ const file = path.join(dir, name);
615
+ try {
616
+ const raw = JSON.parse(await fs.readFile(file, 'utf-8'));
617
+ out.push({
618
+ id: name.replace(/\.json$/, ''),
619
+ file,
620
+ generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : undefined,
621
+ analyzed: typeof raw.analyzed === 'number' ? raw.analyzed : 0,
622
+ workspaces: typeof raw.workspaces === 'number' ? raw.workspaces : 0,
623
+ workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
624
+ report: parseReflectionReport(raw),
625
+ });
626
+ } catch {
627
+ /* skip a corrupt/partial file */
628
+ }
629
+ }
630
+ return out;
631
+ }
632
+
633
+ /**
634
+ * Resolve a report reference against an (already newest-first) list. Returns ALL
635
+ * matches so a caller can detect ambiguity: `undefined`/`'latest'` -> the newest;
636
+ * an exact id -> that one; otherwise every id with the prefix (newest-first). An
637
+ * exact id always wins over prefixes, so an id can't be ambiguous with itself.
638
+ * Pure + unit-tested.
639
+ */
640
+ export function findSavedMatches(all: SavedReport[], ref?: string): SavedReport[] {
641
+ if (all.length === 0) return [];
642
+ if (!ref || ref === 'latest') return [all[0]];
643
+ const exact = all.find((r) => r.id === ref);
644
+ if (exact) return [exact];
645
+ return all.filter((r) => r.id.startsWith(ref));
646
+ }
647
+
648
+ /**
649
+ * Load one saved report by id. `undefined`/`'latest'` returns the newest; an id
650
+ * is matched exactly, then as a prefix (newest match wins). Returns null when
651
+ * nothing matches. For ambiguity-aware callers, use findSavedMatches directly.
652
+ */
653
+ export async function loadSavedReport(
654
+ ref?: string,
655
+ dir: string = REFLECT_REPORTS_DIR,
656
+ ): Promise<SavedReport | null> {
657
+ return findSavedMatches(await listSavedReports(dir), ref)[0] ?? null;
658
+ }