@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 +26 -0
- package/dist/commands/reflect.js +28 -4
- package/dist/utils/reflect.js +24 -3
- package/package.json +1 -1
- package/scripts/postinstall.js +68 -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,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
|
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/scripts/postinstall.js
CHANGED
|
@@ -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'
|
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
|
+
}
|