@nemus-cli/nemus 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-08-30
11
+
12
+ ### Added
13
+
14
+ - **`reflect --workspace <name>`** — analyze a single workspace by name (ignores
15
+ `--limit`); a clean error if that workspace has no recent Claude/pi session.
16
+ - **`reflect` now saves each report** as timestamped JSON under
17
+ `~/.nemus/reflect/` (scope suffix for single-workspace runs), so runs can be
18
+ revisited or diffed over time. Best-effort (never fails the run); disable with
19
+ **`--no-save`**. The path is printed after a run (and included as `savedTo` in
20
+ `--json`).
21
+
10
22
  ## [0.3.2] - 2026-08-30
11
23
 
12
24
  ### Fixed
@@ -13,9 +13,11 @@ function registerReflectCommand(parent) {
13
13
  .alias('retro')
14
14
  .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
15
15
  .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
16
+ .option('-w, --workspace <name>', 'Analyze a single workspace by name (ignores --limit)')
16
17
  .option('--model <model>', 'Judge model override (agent-native pattern/id)')
17
18
  .option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
18
19
  .option('--json', 'Output the report as JSON')
20
+ .option('--no-save', 'Do not save the report to ~/.nemus/reflect/')
19
21
  .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
20
22
  .action(async (opts) => {
21
23
  await handleReflect(opts);
@@ -26,9 +28,13 @@ async function handleReflect(opts) {
26
28
  try {
27
29
  const showProgress = !opts.json && !opts.dryRun;
28
30
  if (showProgress) {
29
- (0, logger_1.logStep)(`Analyzing your ${(0, colors_1.colorize)(String(limit), 'cyan')} most recent workspaces…`);
31
+ (0, logger_1.logStep)(opts.workspace
32
+ ? `Analyzing workspace ${(0, colors_1.colorize)(opts.workspace, 'cyan')}…`
33
+ : `Analyzing your ${(0, colors_1.colorize)(String(limit), 'cyan')} most recent workspaces…`);
30
34
  }
31
- const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit, showProgress ? printProgress : undefined);
35
+ const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit, showProgress ? printProgress : undefined, {
36
+ workspace: opts.workspace,
37
+ });
32
38
  const withSessions = corpus.workspaces.filter((w) => w.session).length;
33
39
  // A script does the heavy analysis (clustering failures, counting tools,
34
40
  // spotting correction loops); the LLM only ever sees these compact facts,
@@ -45,7 +51,9 @@ async function handleReflect(opts) {
45
51
  return;
46
52
  }
47
53
  if (withSessions === 0) {
48
- const msg = 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
54
+ const msg = opts.workspace
55
+ ? `No recent agent session found for workspace "${opts.workspace}" (need a Claude/pi transcript).`
56
+ : 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
49
57
  if (opts.json)
50
58
  (0, output_1.outputJsonError)(msg);
51
59
  else
@@ -69,11 +77,27 @@ async function handleReflect(opts) {
69
77
  stopSpinner();
70
78
  }
71
79
  const report = (0, reflect_1.parseReflectionReport)(parsed);
80
+ // Persist the report (best-effort; never fails the run) unless --no-save.
81
+ let savedTo;
82
+ if (opts.save !== false) {
83
+ try {
84
+ savedTo = await (0, reflect_1.saveReflectionReport)(report, {
85
+ analyzed: withSessions,
86
+ workspaces: corpus.workspaces.length,
87
+ workspace: opts.workspace,
88
+ });
89
+ }
90
+ catch {
91
+ /* saving is a convenience, not the point */
92
+ }
93
+ }
72
94
  if (opts.json) {
73
- (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
95
+ (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report, savedTo });
74
96
  return;
75
97
  }
76
98
  printReport(report, corpus.workspaces.length, withSessions);
99
+ if (savedTo)
100
+ (0, logger_1.logInfo)(`Saved report to ${(0, colors_1.colorize)(savedTo, 'dim')}`);
77
101
  }
78
102
  catch (error) {
79
103
  const msg = error instanceof Error ? error.message : 'reflect failed';
@@ -33,15 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.REFLECT_SCHEMA = void 0;
36
+ exports.REFLECT_REPORTS_DIR = exports.REFLECT_SCHEMA = void 0;
37
37
  exports.isCorrectionPrompt = isCorrectionPrompt;
38
38
  exports.distillTranscript = distillTranscript;
39
39
  exports.classifyAgentsMd = classifyAgentsMd;
40
40
  exports.findLatestTranscriptFile = findLatestTranscriptFile;
41
41
  exports.gatherReflectionCorpus = gatherReflectionCorpus;
42
42
  exports.parseReflectionReport = parseReflectionReport;
43
+ exports.saveReflectionReport = saveReflectionReport;
43
44
  const fs = __importStar(require("fs/promises"));
44
45
  const path = __importStar(require("path"));
46
+ const config_1 = require("./config");
45
47
  const workspace_meta_1 = require("./workspace-meta");
46
48
  const agent_config_1 = require("./agent-config");
47
49
  const claude_sessions_1 = require("./claude-sessions");
@@ -307,14 +309,17 @@ async function contextFilesFor(workspacePath) {
307
309
  * session, plus the globally-available skills. `onProgress` (optional) fires
308
310
  * once per workspace as it finishes, for a live progress display.
309
311
  */
310
- async function gatherReflectionCorpus(limit, onProgress) {
312
+ async function gatherReflectionCorpus(limit, onProgress, opts = {}) {
311
313
  const [sessions, workspaces, availableSkills] = await Promise.all([
312
314
  (0, claude_sessions_1.getWorkspaceSessions)(), // already sorted by last-active, one per workspace
313
315
  (0, workspace_meta_1.listWorkspaces)(false),
314
316
  listAvailableSkills(),
315
317
  ]);
316
318
  const metaByName = new Map(workspaces.map((w) => [w.name, w]));
317
- const recent = sessions.slice(0, limit);
319
+ // A single named workspace (ignores limit), else the N most recently active.
320
+ const recent = opts.workspace
321
+ ? sessions.filter((s) => s.workspaceName === opts.workspace)
322
+ : sessions.slice(0, limit);
318
323
  const digests = [];
319
324
  for (let index = 0; index < recent.length; index++) {
320
325
  const s = recent[index];
@@ -387,3 +392,19 @@ function parseReflectionReport(parsed) {
387
392
  .filter((r) => r !== null);
388
393
  return { summary, recommendations };
389
394
  }
395
+ // -------------------------------------------------------------- report saving
396
+ /** Where saved reflection reports live: `~/.nemus/reflect/`. */
397
+ exports.REFLECT_REPORTS_DIR = path.join(config_1.CACHE_DIR, 'reflect');
398
+ /**
399
+ * Persist a reflection report as timestamped JSON under `~/.nemus/reflect/`, so
400
+ * a run can be revisited or diffed over time. Returns the written path. Pure
401
+ * side-effect (mkdir -p + write); callers treat failure as non-fatal.
402
+ */
403
+ async function saveReflectionReport(report, meta) {
404
+ await fs.mkdir(exports.REFLECT_REPORTS_DIR, { recursive: true });
405
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
406
+ const scope = meta.workspace ? `-${meta.workspace.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : '';
407
+ const file = path.join(exports.REFLECT_REPORTS_DIR, `${stamp}${scope}.json`);
408
+ await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
409
+ return file;
410
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -5,6 +5,7 @@ import { colorize } from '../utils/colors';
5
5
  import {
6
6
  gatherReflectionCorpus,
7
7
  parseReflectionReport,
8
+ saveReflectionReport,
8
9
  REFLECT_SCHEMA,
9
10
  ReflectionReport,
10
11
  ReflectProgress,
@@ -19,24 +20,40 @@ export function registerReflectCommand(parent: Command) {
19
20
  .alias('retro')
20
21
  .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
21
22
  .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
23
+ .option('-w, --workspace <name>', 'Analyze a single workspace by name (ignores --limit)')
22
24
  .option('--model <model>', 'Judge model override (agent-native pattern/id)')
23
25
  .option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
24
26
  .option('--json', 'Output the report as JSON')
27
+ .option('--no-save', 'Do not save the report to ~/.nemus/reflect/')
25
28
  .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
26
29
  .action(async (opts) => {
27
30
  await handleReflect(opts);
28
31
  });
29
32
  }
30
33
 
31
- async function handleReflect(opts: { limit?: string; model?: string; thinking?: string; json?: boolean; dryRun?: boolean }) {
34
+ async function handleReflect(opts: {
35
+ limit?: string;
36
+ workspace?: string;
37
+ model?: string;
38
+ thinking?: string;
39
+ json?: boolean;
40
+ save?: boolean; // commander sets `save: false` for --no-save
41
+ dryRun?: boolean;
42
+ }) {
32
43
  const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
33
44
  try {
34
45
  const showProgress = !opts.json && !opts.dryRun;
35
46
  if (showProgress) {
36
- logStep(`Analyzing your ${colorize(String(limit), 'cyan')} most recent workspaces…`);
47
+ logStep(
48
+ opts.workspace
49
+ ? `Analyzing workspace ${colorize(opts.workspace, 'cyan')}…`
50
+ : `Analyzing your ${colorize(String(limit), 'cyan')} most recent workspaces…`,
51
+ );
37
52
  }
38
53
 
39
- const corpus = await gatherReflectionCorpus(limit, showProgress ? printProgress : undefined);
54
+ const corpus = await gatherReflectionCorpus(limit, showProgress ? printProgress : undefined, {
55
+ workspace: opts.workspace,
56
+ });
40
57
  const withSessions = corpus.workspaces.filter((w) => w.session).length;
41
58
  // A script does the heavy analysis (clustering failures, counting tools,
42
59
  // spotting correction loops); the LLM only ever sees these compact facts,
@@ -54,7 +71,9 @@ async function handleReflect(opts: { limit?: string; model?: string; thinking?:
54
71
  }
55
72
 
56
73
  if (withSessions === 0) {
57
- const msg = 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
74
+ const msg = opts.workspace
75
+ ? `No recent agent session found for workspace "${opts.workspace}" (need a Claude/pi transcript).`
76
+ : 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
58
77
  if (opts.json) outputJsonError(msg);
59
78
  else logError(msg);
60
79
  process.exit(1);
@@ -77,11 +96,26 @@ async function handleReflect(opts: { limit?: string; model?: string; thinking?:
77
96
  }
78
97
  const report = parseReflectionReport(parsed);
79
98
 
99
+ // Persist the report (best-effort; never fails the run) unless --no-save.
100
+ let savedTo: string | undefined;
101
+ if (opts.save !== false) {
102
+ try {
103
+ savedTo = await saveReflectionReport(report, {
104
+ analyzed: withSessions,
105
+ workspaces: corpus.workspaces.length,
106
+ workspace: opts.workspace,
107
+ });
108
+ } catch {
109
+ /* saving is a convenience, not the point */
110
+ }
111
+ }
112
+
80
113
  if (opts.json) {
81
- outputJson({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
114
+ outputJson({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report, savedTo });
82
115
  return;
83
116
  }
84
117
  printReport(report, corpus.workspaces.length, withSessions);
118
+ if (savedTo) logInfo(`Saved report to ${colorize(savedTo, 'dim')}`);
85
119
  } catch (error) {
86
120
  const msg = error instanceof Error ? error.message : 'reflect failed';
87
121
  if (opts.json) outputJsonError(msg);
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt } from './reflect';
2
+ import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt, saveReflectionReport } from './reflect';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
3
5
 
4
6
  const J = (o: unknown) => JSON.stringify(o);
5
7
 
@@ -53,6 +55,24 @@ describe('distillTranscript', () => {
53
55
  });
54
56
  });
55
57
 
58
+ describe('saveReflectionReport', () => {
59
+ it('writes a timestamped JSON report (sanitized scope) and returns its path', async () => {
60
+ const file = await saveReflectionReport(
61
+ { summary: 'ok', recommendations: [{ kind: 'skill', title: 't', detail: 'd', priority: 'high' }] },
62
+ { analyzed: 2, workspaces: 3, workspace: 'pay/app' },
63
+ );
64
+ try {
65
+ expect(file).toMatch(/\.json$/);
66
+ expect(path.basename(file)).toContain('pay_app'); // scope suffix, path-sanitized
67
+ const written = JSON.parse(await fs.readFile(file, 'utf-8'));
68
+ expect(written).toMatchObject({ analyzed: 2, workspaces: 3, workspace: 'pay/app', summary: 'ok' });
69
+ expect(written.generatedAt).toBeTruthy();
70
+ } finally {
71
+ await fs.rm(file, { force: true });
72
+ }
73
+ });
74
+ });
75
+
56
76
  describe('classifyAgentsMd', () => {
57
77
  it('distinguishes missing / boilerplate / substantive', () => {
58
78
  expect(classifyAgentsMd('')).toBe('missing');
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
+ import { CACHE_DIR } from './config';
3
4
  import { listWorkspaces } from './workspace-meta';
4
5
  import { getAgentPaths, getSkillsTargetDirs, getAllKnownContextFileNames, ConcreteAgentType } from './agent-config';
5
6
  import { pathToProjectDirName, getWorkspaceSessions, WorkspaceSession } from './claude-sessions';
@@ -344,6 +345,7 @@ export interface ReflectProgress {
344
345
  export async function gatherReflectionCorpus(
345
346
  limit: number,
346
347
  onProgress?: (p: ReflectProgress) => void,
348
+ opts: { workspace?: string } = {},
347
349
  ): Promise<ReflectionCorpus> {
348
350
  const [sessions, workspaces, availableSkills] = await Promise.all([
349
351
  getWorkspaceSessions(), // already sorted by last-active, one per workspace
@@ -351,7 +353,10 @@ export async function gatherReflectionCorpus(
351
353
  listAvailableSkills(),
352
354
  ]);
353
355
  const metaByName = new Map(workspaces.map((w) => [w.name, w]));
354
- const recent = sessions.slice(0, limit);
356
+ // A single named workspace (ignores limit), else the N most recently active.
357
+ const recent = opts.workspace
358
+ ? sessions.filter((s) => s.workspaceName === opts.workspace)
359
+ : sessions.slice(0, limit);
355
360
 
356
361
  const digests: WorkspaceDigest[] = [];
357
362
  for (let index = 0; index < recent.length; index++) {
@@ -428,3 +433,25 @@ export function parseReflectionReport(parsed: unknown): ReflectionReport {
428
433
  .filter((r: Recommendation | null): r is Recommendation => r !== null);
429
434
  return { summary, recommendations };
430
435
  }
436
+
437
+ // -------------------------------------------------------------- report saving
438
+
439
+ /** Where saved reflection reports live: `~/.nemus/reflect/`. */
440
+ export const REFLECT_REPORTS_DIR = path.join(CACHE_DIR, 'reflect');
441
+
442
+ /**
443
+ * Persist a reflection report as timestamped JSON under `~/.nemus/reflect/`, so
444
+ * a run can be revisited or diffed over time. Returns the written path. Pure
445
+ * side-effect (mkdir -p + write); callers treat failure as non-fatal.
446
+ */
447
+ export async function saveReflectionReport(
448
+ report: ReflectionReport,
449
+ meta: { analyzed: number; workspaces: number; workspace?: string },
450
+ ): Promise<string> {
451
+ await fs.mkdir(REFLECT_REPORTS_DIR, { recursive: true });
452
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
453
+ const scope = meta.workspace ? `-${meta.workspace.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : '';
454
+ const file = path.join(REFLECT_REPORTS_DIR, `${stamp}${scope}.json`);
455
+ await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
456
+ return file;
457
+ }