@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 +12 -0
- package/dist/commands/reflect.js +28 -4
- package/dist/utils/reflect.js +24 -3
- package/package.json +1 -1
- package/src/commands/reflect.ts +39 -5
- package/src/utils/reflect.test.ts +21 -1
- package/src/utils/reflect.ts +28 -1
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
|
package/dist/commands/reflect.js
CHANGED
|
@@ -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)(
|
|
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 =
|
|
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';
|
package/dist/utils/reflect.js
CHANGED
|
@@ -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
|
-
|
|
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
package/src/commands/reflect.ts
CHANGED
|
@@ -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: {
|
|
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(
|
|
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 =
|
|
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');
|
package/src/utils/reflect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|