@nemus-cli/nemus 0.3.2 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-01
11
+
12
+ ### Changed
13
+
14
+ - **First-time setup now runs right after install.** On a fresh `npm install -g`
15
+ in an interactive terminal, the postinstall step launches `nemus configure`
16
+ automatically (it installs the shell integration + optional MCP server and
17
+ prints the activation reminder) instead of only suggesting you run it. It runs
18
+ **only on a first install** (skipped once `~/.nemus/config.json` exists, so
19
+ upgrades never re-prompt) and **only when a controlling terminal is reachable**
20
+ (via `/dev/tty`, since npm pipes postinstall stdio) — piped/scripted/CI installs,
21
+ or `NEMUS_SKIP_CONFIGURE=1`, fall back to the previous non-interactive path
22
+ (shell integration + a printed tip), so an install can never hang.
23
+
24
+ ## [0.3.3] - 2026-08-30
25
+
26
+ ### Added
27
+
28
+ - **`reflect --workspace <name>`** — analyze a single workspace by name (ignores
29
+ `--limit`); a clean error if that workspace has no recent Claude/pi session.
30
+ - **`reflect` now saves each report** as timestamped JSON under
31
+ `~/.nemus/reflect/` (scope suffix for single-workspace runs), so runs can be
32
+ revisited or diffed over time. Best-effort (never fails the run); disable with
33
+ **`--no-save`**. The path is printed after a run (and included as `savedTo` in
34
+ `--json`).
35
+
10
36
  ## [0.3.2] - 2026-08-30
11
37
 
12
38
  ### 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.4.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -12,8 +12,9 @@
12
12
 
13
13
  'use strict';
14
14
 
15
- const { execSync } = require('child_process');
15
+ const { execSync, execFileSync } = require('child_process');
16
16
  const fs = require('fs');
17
+ const os = require('os');
17
18
  const path = require('path');
18
19
 
19
20
  // Skip in CI environments
@@ -21,6 +22,72 @@ if (process.env.CI) process.exit(0);
21
22
 
22
23
  const PKG_ROOT = path.join(__dirname, '..');
23
24
  const SHELL_SCRIPT = path.join(PKG_ROOT, 'install-shell-integration.sh');
25
+ const CLI_BIN = path.join(PKG_ROOT, 'bin', 'workspace.js');
26
+
27
+ // ── First-time interactive setup ─────────────────────────────────────────────
28
+ // On a FRESH install, run `nemus configure` right away so setup happens after
29
+ // install instead of the user having to remember to. `configure` is a superset
30
+ // of this script's work — it installs the shell integration + (optionally) the
31
+ // MCP server and prints the source reminder — so on success we exit here.
32
+ //
33
+ // npm runs postinstall with stdio PIPED (not a TTY) unless `--foreground-scripts`
34
+ // is set, so we can't gate on process.std*.isTTY (it's falsy during a normal
35
+ // `npm i -g`, so the prompt would never fire). Instead we reach the controlling
36
+ // terminal directly via /dev/tty — openable during an interactive install even
37
+ // though npm piped our own stdio — and wire configure's stdio to it. With no
38
+ // controlling terminal (piped/scripted installs) the open fails and we fall
39
+ // through to the non-interactive tip path, so an install can never hang.
40
+ function cacheDir() {
41
+ return (
42
+ process.env.NEMUS_CACHE_DIR ||
43
+ process.env.WORKSPACE_MANAGER_CACHE_DIR ||
44
+ path.join(os.homedir(), '.nemus')
45
+ );
46
+ }
47
+
48
+ // NEMUS_SKIP_CONFIGURE opts out — but only for real values: `0`/`false`/empty do
49
+ // NOT skip (a user setting `=0` means "don't skip").
50
+ function optedOut() {
51
+ const v = (process.env.NEMUS_SKIP_CONFIGURE || '').trim().toLowerCase();
52
+ return v !== '' && v !== '0' && v !== 'false';
53
+ }
54
+
55
+ // The config marker `configure` writes (utils/config.ts: saveUserConfig ->
56
+ // <NEMUS_CACHE_DIR|WORKSPACE_MANAGER_CACHE_DIR|~/.nemus>/config.json). Same
57
+ // precedence as here, so its presence reliably means "already configured".
58
+ function alreadyConfigured() {
59
+ return fs.existsSync(path.join(cacheDir(), 'config.json'));
60
+ }
61
+
62
+ // A read/write fd on the controlling terminal, or null if there isn't one.
63
+ function openControllingTty() {
64
+ if (process.platform === 'win32') return null; // no /dev/tty; shell integration is bash/zsh anyway
65
+ try {
66
+ return fs.openSync('/dev/tty', 'r+');
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ if (!optedOut() && fs.existsSync(CLI_BIN) && !alreadyConfigured()) {
73
+ const tty = openControllingTty();
74
+ if (tty !== null) {
75
+ try {
76
+ // argv form (no shell) so an odd package path with quotes/$/backticks is
77
+ // safe; stdio wired to the real terminal so inquirer can prompt.
78
+ // process.execPath (not 'node') — node may not be on PATH during a
79
+ // lifecycle script (nvm/Windows), and an ENOENT here would be swallowed by
80
+ // the catch, silently skipping configure in the very case this targets.
81
+ execFileSync(process.execPath, [CLI_BIN, 'configure'], { stdio: [tty, tty, tty] });
82
+ fs.closeSync(tty);
83
+ process.exit(0); // configure handled shell integration + reminder
84
+ } catch {
85
+ try { fs.closeSync(tty); } catch { /* ignore */ }
86
+ // Canceled / failed — fall through to the standard setup + tip below so the
87
+ // install still leaves things in a working state.
88
+ }
89
+ }
90
+ }
24
91
 
25
92
  const shell = process.env.SHELL || '';
26
93
  const shellType = shell.endsWith('/zsh') ? 'zsh'
@@ -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
+ }