@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.
- package/CHANGELOG.md +34 -0
- package/README.md +33 -0
- package/dist/commands/config.js +113 -0
- package/dist/commands/reflect.js +141 -20
- package/dist/program.js +2 -0
- package/dist/utils/config-schema.js +88 -0
- package/dist/utils/config.js +4 -2
- package/dist/utils/reflect.js +170 -1
- package/package.json +1 -1
- package/src/commands/config.ts +112 -0
- package/src/commands/reflect.ts +156 -20
- package/src/program.ts +2 -0
- package/src/utils/config-schema.test.ts +111 -0
- package/src/utils/config-schema.ts +112 -0
- package/src/utils/config.ts +4 -1
- package/src/utils/reflect.test.ts +129 -2
- package/src/utils/reflect.ts +201 -0
package/dist/utils/reflect.js
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/commands/reflect.ts
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
console.log(
|
|
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);
|