@nemus-cli/nemus 0.5.0 → 0.8.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.
@@ -33,7 +33,11 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.REFLECT_REPORTS_DIR = exports.REFLECT_SCHEMA = void 0;
36
+ exports.REFLECT_REPORTS_DIR = exports.REFLECT_SCHEMA = exports.PRIORITY_HEADING = exports.KIND_LABEL = void 0;
37
+ exports.severityCounts = severityCounts;
38
+ exports.severitySummary = severitySummary;
39
+ exports.groupRecommendations = groupRecommendations;
40
+ exports.renderReportMarkdown = renderReportMarkdown;
37
41
  exports.isCorrectionPrompt = isCorrectionPrompt;
38
42
  exports.distillTranscript = distillTranscript;
39
43
  exports.classifyAgentsMd = classifyAgentsMd;
@@ -41,12 +45,117 @@ exports.findLatestTranscriptFile = findLatestTranscriptFile;
41
45
  exports.gatherReflectionCorpus = gatherReflectionCorpus;
42
46
  exports.parseReflectionReport = parseReflectionReport;
43
47
  exports.saveReflectionReport = saveReflectionReport;
48
+ exports.listSavedReports = listSavedReports;
49
+ exports.findSavedMatches = findSavedMatches;
50
+ exports.loadSavedReport = loadSavedReport;
44
51
  const fs = __importStar(require("fs/promises"));
45
52
  const path = __importStar(require("path"));
46
53
  const config_1 = require("./config");
47
54
  const workspace_meta_1 = require("./workspace-meta");
48
55
  const agent_config_1 = require("./agent-config");
49
56
  const claude_sessions_1 = require("./claude-sessions");
57
+ /** Count recommendations by priority. */
58
+ function severityCounts(recs) {
59
+ const counts = { high: 0, medium: 0, low: 0 };
60
+ for (const r of recs)
61
+ counts[r.priority]++;
62
+ return counts;
63
+ }
64
+ /** "3 high · 2 medium · 1 low", omitting zero buckets; '' when there are none. */
65
+ function severitySummary(recs) {
66
+ const c = severityCounts(recs);
67
+ return ['high', 'medium', 'low']
68
+ .filter((p) => c[p] > 0)
69
+ .map((p) => `${c[p]} ${p}`)
70
+ .join(' · ');
71
+ }
72
+ exports.KIND_LABEL = {
73
+ skill: 'Skill',
74
+ context: 'Context/AGENTS.md',
75
+ test: 'Test',
76
+ prompt: 'Prompt',
77
+ connectivity: 'Connectivity',
78
+ workflow: 'Workflow',
79
+ other: 'Other',
80
+ };
81
+ // Back-compat alias for existing references.
82
+ const MD_KIND_LABEL = exports.KIND_LABEL;
83
+ exports.PRIORITY_HEADING = {
84
+ high: 'High priority',
85
+ medium: 'Medium priority',
86
+ low: 'Low priority',
87
+ };
88
+ const KIND_ORDER = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
89
+ const PRIORITY_ORDER = ['high', 'medium', 'low'];
90
+ const PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
91
+ /**
92
+ * Split recommendations into ordered, non-empty groups by either priority
93
+ * (high→low) or kind (a fixed, stable order). When grouping by kind, each
94
+ * group's recs are sorted high-priority first. Pure + unit-tested; shared by the
95
+ * Markdown renderer and the human printer so the two never diverge.
96
+ */
97
+ function groupRecommendations(recs, groupBy) {
98
+ if (groupBy === 'kind') {
99
+ return KIND_ORDER.map((k) => ({
100
+ key: k,
101
+ heading: exports.KIND_LABEL[k],
102
+ recs: recs
103
+ .filter((r) => r.kind === k)
104
+ .sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]),
105
+ })).filter((g) => g.recs.length > 0);
106
+ }
107
+ return PRIORITY_ORDER.map((p) => ({
108
+ key: p,
109
+ heading: exports.PRIORITY_HEADING[p],
110
+ recs: recs.filter((r) => r.priority === p),
111
+ })).filter((g) => g.recs.length > 0);
112
+ }
113
+ /** A fenced code block whose fence is guaranteed longer than any backtick run
114
+ * inside `body`, so the snippet can't break out of its own fence. */
115
+ function fencedBlock(body) {
116
+ const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((m) => m.length));
117
+ const fence = '`'.repeat(Math.max(3, longest + 1));
118
+ return `${fence}\n${body}\n${fence}`;
119
+ }
120
+ /**
121
+ * Render a reflection report as clean Markdown — for pasting into an issue/PR or
122
+ * saving alongside the JSON. Pure (no color, no I/O) so it's unit-tested.
123
+ * Recommendations are grouped by severity (high→low); each carries its kind,
124
+ * optional target, detail, and a fenced example.
125
+ */
126
+ function renderReportMarkdown(report, meta, groupBy = 'priority') {
127
+ const lines = ['# Reflection', ''];
128
+ const scope = meta.workspace
129
+ ? `workspace **${meta.workspace}**`
130
+ : `${meta.analyzed} session${meta.analyzed === 1 ? '' : 's'} across ${meta.workspaces} workspace${meta.workspaces === 1 ? '' : 's'}`;
131
+ const stamp = meta.generatedAt ? ` · ${meta.generatedAt}` : '';
132
+ lines.push(`_${scope}${stamp}_`, '');
133
+ if (report.summary.trim())
134
+ lines.push(report.summary.trim(), '');
135
+ if (report.recommendations.length === 0) {
136
+ lines.push('## Recommendations', '', '_No specific recommendations — looks solid._', '');
137
+ return lines.join('\n');
138
+ }
139
+ lines.push('## Recommendations', '', `**${severitySummary(report.recommendations)}**`, '');
140
+ for (const group of groupRecommendations(report.recommendations, groupBy)) {
141
+ lines.push(`### ${group.heading}`, '');
142
+ for (const r of group.recs) {
143
+ const target = r.target ? ` (\`${r.target}\`)` : '';
144
+ // Under a kind heading the [Kind] prefix is redundant; show a priority tag
145
+ // instead. Under a priority heading, show the kind.
146
+ const label = groupBy === 'kind' ? `_${r.priority}_ — ` : `[${exports.KIND_LABEL[r.kind]}] `;
147
+ lines.push(`- **${label}${r.title}**${target}`);
148
+ if (r.detail.trim()) {
149
+ lines.push(...r.detail.trim().split('\n').map((l) => ` ${l}`));
150
+ }
151
+ if (r.example?.trim()) {
152
+ lines.push('', ...fencedBlock(r.example.trim()).split('\n').map((l) => ` ${l}`));
153
+ }
154
+ lines.push('');
155
+ }
156
+ }
157
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
158
+ }
50
159
  // --------------------------------------------------------- transcript distill
51
160
  // Kept deliberately lean: the judge runs on the user's own (often local, slow)
52
161
  // agent, and a 10-workspace corpus at full verbosity produced a ~200KB prompt
@@ -408,3 +517,63 @@ async function saveReflectionReport(report, meta) {
408
517
  await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
409
518
  return file;
410
519
  }
520
+ /**
521
+ * List saved reports under `~/.nemus/reflect/`, newest first (filenames start
522
+ * with an ISO timestamp, so a reverse name sort is chronological). Unreadable /
523
+ * unparseable files are skipped, not fatal. Returns [] if the dir is absent.
524
+ */
525
+ async function listSavedReports(dir = exports.REFLECT_REPORTS_DIR) {
526
+ let names;
527
+ try {
528
+ names = await fs.readdir(dir);
529
+ }
530
+ catch {
531
+ return [];
532
+ }
533
+ const jsons = names.filter((n) => n.endsWith('.json')).sort().reverse();
534
+ const out = [];
535
+ for (const name of jsons) {
536
+ const file = path.join(dir, name);
537
+ try {
538
+ const raw = JSON.parse(await fs.readFile(file, 'utf-8'));
539
+ out.push({
540
+ id: name.replace(/\.json$/, ''),
541
+ file,
542
+ generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : undefined,
543
+ analyzed: typeof raw.analyzed === 'number' ? raw.analyzed : 0,
544
+ workspaces: typeof raw.workspaces === 'number' ? raw.workspaces : 0,
545
+ workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
546
+ report: parseReflectionReport(raw),
547
+ });
548
+ }
549
+ catch {
550
+ /* skip a corrupt/partial file */
551
+ }
552
+ }
553
+ return out;
554
+ }
555
+ /**
556
+ * Resolve a report reference against an (already newest-first) list. Returns ALL
557
+ * matches so a caller can detect ambiguity: `undefined`/`'latest'` -> the newest;
558
+ * an exact id -> that one; otherwise every id with the prefix (newest-first). An
559
+ * exact id always wins over prefixes, so an id can't be ambiguous with itself.
560
+ * Pure + unit-tested.
561
+ */
562
+ function findSavedMatches(all, ref) {
563
+ if (all.length === 0)
564
+ return [];
565
+ if (!ref || ref === 'latest')
566
+ return [all[0]];
567
+ const exact = all.find((r) => r.id === ref);
568
+ if (exact)
569
+ return [exact];
570
+ return all.filter((r) => r.id.startsWith(ref));
571
+ }
572
+ /**
573
+ * Load one saved report by id. `undefined`/`'latest'` returns the newest; an id
574
+ * is matched exactly, then as a prefix (newest match wins). Returns null when
575
+ * nothing matches. For ambiguity-aware callers, use findSavedMatches directly.
576
+ */
577
+ async function loadSavedReport(ref, dir = exports.REFLECT_REPORTS_DIR) {
578
+ return findSavedMatches(await listSavedReports(dir), ref)[0] ?? null;
579
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -0,0 +1,112 @@
1
+ import { Command } from 'commander';
2
+ import { getUserConfig, saveUserConfig, CONFIG_PATH } from '../utils/config';
3
+ import {
4
+ CONFIG_KEYS,
5
+ CONFIG_SCHEMA,
6
+ isConfigKey,
7
+ applyConfigSet,
8
+ applyConfigUnset,
9
+ formatConfigValue,
10
+ } from '../utils/config-schema';
11
+ import { outputJson, outputJsonError } from '../utils/output';
12
+ import { logSuccess, logError } from '../utils/logger';
13
+ import { colorize } from '../utils/colors';
14
+
15
+ /**
16
+ * Non-interactive config management: `nemus config get/set/unset/list/path`.
17
+ * Complements the interactive `configure` wizard and is script-friendly —
18
+ * `get`/`list` write DATA to stdout (raw value, or JSON with --json), logs go to
19
+ * stderr. Values are validated/coerced against config-schema.ts.
20
+ */
21
+ export function registerConfigCommand(parent: Command): void {
22
+ const config = parent.command('config').description('Get or set Nemus configuration');
23
+
24
+ config
25
+ .command('get')
26
+ .description('Print a config value (or all values with no key)')
27
+ .argument('[key]', 'Config key')
28
+ .option('--json', 'Output as JSON')
29
+ .action((key: string | undefined, opts: { json?: boolean }) => {
30
+ const cfg = getUserConfig();
31
+ if (key === undefined) {
32
+ printAll(cfg, opts.json);
33
+ return;
34
+ }
35
+ if (!isConfigKey(key)) {
36
+ if (opts.json) outputJsonError(`Unknown config key "${key}"`);
37
+ else logError(`Unknown config key "${key}". Run "nemus config list" to see valid keys.`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const value = cfg[key];
42
+ if (opts.json) outputJson({ key, value });
43
+ else process.stdout.write(formatConfigValue(value) + '\n');
44
+ });
45
+
46
+ config
47
+ .command('set')
48
+ .description('Set a config value')
49
+ .argument('<key>', 'Config key')
50
+ .argument('<value>', 'New value')
51
+ .option('--json', 'Output as JSON')
52
+ .action((key: string, value: string, opts: { json?: boolean }) => {
53
+ const result = applyConfigSet(getUserConfig(), key, value);
54
+ if (!result.ok) {
55
+ if (opts.json) outputJsonError(result.error);
56
+ else logError(result.error);
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ saveUserConfig(result.next);
61
+ if (opts.json) outputJson({ ok: true, key, value: result.value });
62
+ else logSuccess(`Set ${colorize(key, 'cyan')} = ${formatConfigValue(result.value)}`);
63
+ });
64
+
65
+ config
66
+ .command('unset')
67
+ .description('Reset a config value to its default')
68
+ .argument('<key>', 'Config key')
69
+ .option('--json', 'Output as JSON')
70
+ .action((key: string, opts: { json?: boolean }) => {
71
+ const result = applyConfigUnset(getUserConfig(), key);
72
+ if (!result.ok) {
73
+ if (opts.json) outputJsonError(result.error);
74
+ else logError(result.error);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ saveUserConfig(result.next);
79
+ if (opts.json) outputJson({ ok: true, key, value: result.value });
80
+ else logSuccess(`Reset ${colorize(key, 'cyan')} to default (${formatConfigValue(result.value)})`);
81
+ });
82
+
83
+ config
84
+ .command('list')
85
+ .alias('ls')
86
+ .description('List all config keys and current values')
87
+ .option('--json', 'Output as JSON')
88
+ .action((opts: { json?: boolean }) => printAll(getUserConfig(), opts.json));
89
+
90
+ config
91
+ .command('path')
92
+ .description('Print the path to the config file')
93
+ .action(() => {
94
+ process.stdout.write(CONFIG_PATH + '\n');
95
+ });
96
+ }
97
+
98
+ function printAll(cfg: ReturnType<typeof getUserConfig>, json?: boolean): void {
99
+ if (json) {
100
+ const values: Record<string, unknown> = {};
101
+ for (const key of CONFIG_KEYS) values[key] = cfg[key];
102
+ outputJson({ path: CONFIG_PATH, values });
103
+ return;
104
+ }
105
+ const width = Math.max(...CONFIG_KEYS.map((k) => k.length));
106
+ console.log(colorize('Nemus configuration', 'bright') + colorize(` (${CONFIG_PATH})`, 'dim'));
107
+ for (const key of CONFIG_KEYS) {
108
+ const val = formatConfigValue(cfg[key]);
109
+ const shown = val === '' ? colorize('(empty)', 'dim') : val;
110
+ console.log(` ${key.padEnd(width)} ${shown} ${colorize(CONFIG_SCHEMA[key].describe, 'dim')}`);
111
+ }
112
+ }
@@ -1,15 +1,21 @@
1
1
  import { Command } from 'commander';
2
- import { logError, logInfo, logStep } from '../utils/logger';
2
+ import { logError, logInfo, logStep, logWarning } from '../utils/logger';
3
3
  import { outputJson, outputJsonError } from '../utils/output';
4
4
  import { colorize } from '../utils/colors';
5
5
  import {
6
6
  gatherReflectionCorpus,
7
7
  parseReflectionReport,
8
8
  saveReflectionReport,
9
+ renderReportMarkdown,
10
+ severitySummary,
11
+ groupRecommendations,
12
+ listSavedReports,
13
+ findSavedMatches,
9
14
  REFLECT_SCHEMA,
10
15
  ReflectionReport,
11
16
  ReflectProgress,
12
17
  Recommendation,
18
+ GroupBy,
13
19
  } from '../utils/reflect';
14
20
  import { analyzeCorpus, buildAnalysisPrompt } from '../utils/reflect-analyze';
15
21
  import { runAgentJsonAsync } from '../utils/agent-judge';
@@ -18,31 +24,63 @@ export function registerReflectCommand(parent: Command) {
18
24
  parent
19
25
  .command('reflect')
20
26
  .alias('retro')
21
- .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
27
+ .description('Analyze recent sessions for improvements, or review saved reports ("history"/"show")')
28
+ .argument('[subcommand]', '"history" or "show" — omit to run a new analysis')
29
+ .argument('[id]', 'report id when using "show" (default: latest)')
22
30
  .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
23
31
  .option('-w, --workspace <name>', 'Analyze a single workspace by name (ignores --limit)')
24
32
  .option('--model <model>', 'Judge model override (agent-native pattern/id)')
25
33
  .option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
26
34
  .option('--json', 'Output the report as JSON')
35
+ .option('--markdown', 'Output the report as Markdown (paste into an issue/PR)')
36
+ .option('--group-by <how>', 'Group recommendations by: priority | kind', 'priority')
27
37
  .option('--no-save', 'Do not save the report to ~/.nemus/reflect/')
28
38
  .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
29
- .action(async (opts) => {
30
- await handleReflect(opts);
39
+ .addHelpText(
40
+ 'after',
41
+ '\nSaved reports:\n nemus reflect history List saved reports (newest first)\n nemus reflect show [id] Print a saved report (default: latest)\n',
42
+ )
43
+ // history/show are positional (not commander subcommands) on purpose: as
44
+ // subcommands they would share --json/--markdown/--group-by with this parent
45
+ // command, and the only commander fix (enablePositionalOptions on the root)
46
+ // breaks global flags placed after a subcommand (e.g. `nemus list --quiet`).
47
+ .action(async (subcommand: string | undefined, id: string | undefined, opts) => {
48
+ if (subcommand === 'history') return handleHistory(opts);
49
+ if (subcommand === 'show') return handleShow(id, opts);
50
+ if (subcommand !== undefined) {
51
+ const msg = `Unknown reflect subcommand "${subcommand}" (expected "history" or "show").`;
52
+ if (opts.json) outputJsonError(msg);
53
+ else logError(msg);
54
+ process.exit(1);
55
+ }
56
+ return handleReflect(opts);
31
57
  });
32
58
  }
33
59
 
60
+ /** Validate --group-by; returns the value or exits non-zero with a clear error. */
61
+ function resolveGroupBy(raw: string | undefined, json?: boolean): GroupBy {
62
+ if (raw === undefined || raw === 'priority' || raw === 'kind') return (raw ?? 'priority') as GroupBy;
63
+ const msg = `--group-by must be "priority" or "kind"; got "${raw}"`;
64
+ if (json) outputJsonError(msg);
65
+ else logError(msg);
66
+ process.exit(1);
67
+ }
68
+
34
69
  async function handleReflect(opts: {
35
70
  limit?: string;
36
71
  workspace?: string;
37
72
  model?: string;
38
73
  thinking?: string;
39
74
  json?: boolean;
75
+ markdown?: boolean;
76
+ groupBy?: string;
40
77
  save?: boolean; // commander sets `save: false` for --no-save
41
78
  dryRun?: boolean;
42
79
  }) {
43
80
  const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
81
+ const groupBy = resolveGroupBy(opts.groupBy, opts.json);
44
82
  try {
45
- const showProgress = !opts.json && !opts.dryRun;
83
+ const showProgress = !opts.json && !opts.markdown && !opts.dryRun;
46
84
  if (showProgress) {
47
85
  logStep(
48
86
  opts.workspace
@@ -85,7 +123,7 @@ async function handleReflect(opts: {
85
123
  const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
86
124
  const model = opts.model ?? process.env.NEMUS_JUDGE_MODEL ?? undefined;
87
125
  const thinking = opts.thinking ?? process.env.NEMUS_JUDGE_THINKING ?? undefined;
88
- const stopSpinner = opts.json
126
+ const stopSpinner = opts.json || opts.markdown
89
127
  ? () => {}
90
128
  : startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
91
129
  let parsed: unknown;
@@ -114,7 +152,25 @@ async function handleReflect(opts: {
114
152
  outputJson({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report, savedTo });
115
153
  return;
116
154
  }
117
- printReport(report, corpus.workspaces.length, withSessions);
155
+ if (opts.markdown) {
156
+ // DATA channel: markdown to stdout, nothing else (a 'Saved report' note
157
+ // would corrupt a redirected .md file), so surface the path on stderr.
158
+ process.stdout.write(
159
+ renderReportMarkdown(
160
+ report,
161
+ {
162
+ analyzed: withSessions,
163
+ workspaces: corpus.workspaces.length,
164
+ workspace: opts.workspace,
165
+ generatedAt: new Date().toISOString(),
166
+ },
167
+ groupBy,
168
+ ),
169
+ );
170
+ if (savedTo) logInfo(`Saved report to ${colorize(savedTo, 'dim')}`);
171
+ return;
172
+ }
173
+ printReport(report, corpus.workspaces.length, withSessions, groupBy);
118
174
  if (savedTo) logInfo(`Saved report to ${colorize(savedTo, 'dim')}`);
119
175
  } catch (error) {
120
176
  const msg = error instanceof Error ? error.message : 'reflect failed';
@@ -183,7 +239,7 @@ function priorityBadge(p: Recommendation['priority']): string {
183
239
  return colorize('● low', 'gray');
184
240
  }
185
241
 
186
- function printReport(report: ReflectionReport, workspaces: number, analyzed: number) {
242
+ function printReport(report: ReflectionReport, workspaces: number, analyzed: number, groupBy: GroupBy = 'priority') {
187
243
  console.log('');
188
244
  console.log(colorize(' Reflection', 'bright') + colorize(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
189
245
  console.log(colorize(' ' + '─'.repeat(56), 'dim'));
@@ -196,19 +252,99 @@ function printReport(report: ReflectionReport, workspaces: number, analyzed: num
196
252
  return;
197
253
  }
198
254
 
199
- // High priority first.
200
- const order = { high: 0, medium: 1, low: 2 };
201
- const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
255
+ console.log('\n ' + colorize(severitySummary(report.recommendations), 'dim'));
202
256
 
203
- console.log('');
204
- for (const r of recs) {
205
- const target = r.target ? colorize(` [${r.target}]`, 'cyan') : '';
206
- console.log(` ${priorityBadge(r.priority)} ${colorize(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
207
- if (r.detail) console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
208
- if (r.example) {
209
- console.log(colorize(' example:', 'dim'));
210
- console.log(colorize(r.example.replace(/^/gm, ' '), 'dim'));
257
+ for (const group of groupRecommendations(report.recommendations, groupBy)) {
258
+ console.log('\n ' + colorize(group.heading, 'bright'));
259
+ for (const r of group.recs) {
260
+ const target = r.target ? colorize(` [${r.target}]`, 'cyan') : '';
261
+ // Under a kind heading show the priority badge; under a priority heading
262
+ // show the kind label (the heading conveys the other axis).
263
+ const lead = groupBy === 'kind' ? priorityBadge(r.priority) : colorize(KIND_LABEL[r.kind], 'bright');
264
+ console.log(` ${lead} ${r.title}${target}`);
265
+ if (r.detail) console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
266
+ if (r.example) {
267
+ console.log(colorize(' example:', 'dim'));
268
+ console.log(colorize(r.example.replace(/^/gm, ' '), 'dim'));
269
+ }
211
270
  }
212
- console.log('');
213
271
  }
272
+ console.log('');
273
+ }
274
+
275
+ async function handleHistory(opts: { json?: boolean }) {
276
+ const reports = await listSavedReports();
277
+ if (opts.json) {
278
+ outputJson({
279
+ count: reports.length,
280
+ reports: reports.map((r) => ({
281
+ id: r.id,
282
+ generatedAt: r.generatedAt,
283
+ analyzed: r.analyzed,
284
+ workspaces: r.workspaces,
285
+ workspace: r.workspace,
286
+ recommendations: r.report.recommendations.length,
287
+ severity: severitySummary(r.report.recommendations),
288
+ })),
289
+ });
290
+ return;
291
+ }
292
+ if (reports.length === 0) {
293
+ logInfo('No saved reflection reports yet. Run `nemus reflect` to create one.');
294
+ return;
295
+ }
296
+ console.log('');
297
+ console.log(colorize(' Saved reflections', 'bright') + colorize(` (${reports.length})`, 'dim'));
298
+ console.log(colorize(' ' + '─'.repeat(56), 'dim'));
299
+ for (const r of reports) {
300
+ const when = r.generatedAt ? new Date(r.generatedAt).toLocaleString() : r.id;
301
+ const scope = r.workspace ? colorize(` ${r.workspace}`, 'cyan') : colorize(` ${r.analyzed} sessions`, 'dim');
302
+ const sev = r.report.recommendations.length
303
+ ? colorize(` ${severitySummary(r.report.recommendations)}`, 'dim')
304
+ : colorize(' no recs', 'green');
305
+ console.log(` ${colorize(r.id, 'bright')}${scope}${sev}`);
306
+ console.log(colorize(` ${when}`, 'dim'));
307
+ }
308
+ console.log('');
309
+ console.log(colorize(' nemus reflect show <id> (or `latest`)', 'dim'));
310
+ }
311
+
312
+ async function handleShow(
313
+ id: string | undefined,
314
+ opts: { json?: boolean; markdown?: boolean; groupBy?: string },
315
+ ) {
316
+ const groupBy = resolveGroupBy(opts.groupBy, opts.json);
317
+ const matches = findSavedMatches(await listSavedReports(), id);
318
+ const saved = matches[0];
319
+ if (!saved) {
320
+ const msg = id && id !== 'latest'
321
+ ? `No saved report matching "${id}". Run "nemus reflect history" to list them.`
322
+ : 'No saved reflection reports yet. Run "nemus reflect" to create one.';
323
+ if (opts.json) outputJsonError(msg);
324
+ else logError(msg);
325
+ process.exit(1);
326
+ }
327
+ // An id-prefix that matches several reports resolves to the newest — say so
328
+ // (stderr only, so --json/--markdown stdout stays clean) rather than quietly
329
+ // showing a possibly-unintended report. An exact id / "latest" never multi-matches.
330
+ if (matches.length > 1 && !opts.json) {
331
+ logWarning(
332
+ `"${id}" matched ${matches.length} reports; showing the newest (${saved.id}). Use a longer id to disambiguate.`,
333
+ );
334
+ }
335
+ const meta = {
336
+ analyzed: saved.analyzed,
337
+ workspaces: saved.workspaces,
338
+ workspace: saved.workspace,
339
+ generatedAt: saved.generatedAt,
340
+ };
341
+ if (opts.json) {
342
+ outputJson({ id: saved.id, ...meta, ...saved.report });
343
+ return;
344
+ }
345
+ if (opts.markdown) {
346
+ process.stdout.write(renderReportMarkdown(saved.report, meta, groupBy));
347
+ return;
348
+ }
349
+ printReport(saved.report, saved.workspaces, saved.analyzed, groupBy);
214
350
  }
package/src/program.ts CHANGED
@@ -53,6 +53,7 @@ import { registerArchiveCommand } from './commands/archive';
53
53
  import { registerSessionsCommand } from './commands/sessions';
54
54
  import { registerGenerateDocsCommand } from './commands/generate-docs';
55
55
  import { registerConfigureCommand } from './commands/configure';
56
+ import { registerConfigCommand } from './commands/config';
56
57
  import { registerConfigureClaudeCommand } from './commands/configure-claude';
57
58
  import { registerGhqStatusCommand } from './commands/ghq-status';
58
59
  import { registerSaveContextCommand } from './commands/save-context';
@@ -79,6 +80,7 @@ registerArchiveCommand(program);
79
80
  registerSessionsCommand(program);
80
81
  registerGenerateDocsCommand(program);
81
82
  registerConfigureCommand(program);
83
+ registerConfigCommand(program);
82
84
  registerConfigureClaudeCommand(program);
83
85
  registerGhqStatusCommand(program);
84
86
  registerSaveContextCommand(program);