@nemus-cli/nemus 0.4.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 +44 -0
- package/README.md +55 -0
- package/dist/commands/config.js +113 -0
- package/dist/commands/reflect.js +141 -20
- package/dist/program.js +20 -17
- package/dist/utils/banner.js +33 -12
- package/dist/utils/colors.js +43 -2
- package/dist/utils/config-schema.js +88 -0
- package/dist/utils/config.js +4 -2
- package/dist/utils/global-flags.js +21 -0
- package/dist/utils/logger.js +16 -1
- 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 +21 -19
- package/src/utils/banner.ts +32 -13
- package/src/utils/colors.test.ts +51 -0
- package/src/utils/colors.ts +50 -3
- 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/global-flags.test.ts +44 -0
- package/src/utils/global-flags.ts +28 -0
- package/src/utils/logger.test.ts +36 -0
- package/src/utils/logger.ts +11 -0
- package/src/utils/reflect.test.ts +129 -2
- package/src/utils/reflect.ts +201 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { UserConfig, CONFIG_DEFAULTS } from './config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declarative schema for the non-interactive `nemus config get/set` command.
|
|
5
|
+
* Every writable UserConfig field is described here with its type + allowed
|
|
6
|
+
* values, so `set` can validate/coerce a string argument and `get`/`list` can
|
|
7
|
+
* enumerate keys. Kept as pure data + pure functions so it's fully unit-tested
|
|
8
|
+
* without touching disk. Keep this in sync with the UserConfig interface — the
|
|
9
|
+
* `satisfies` check below fails the build if a key is misspelled.
|
|
10
|
+
*/
|
|
11
|
+
export type ConfigKey = keyof UserConfig;
|
|
12
|
+
|
|
13
|
+
type FieldSpec =
|
|
14
|
+
| { type: 'string'; allowEmpty?: boolean; describe: string }
|
|
15
|
+
| { type: 'boolean'; describe: string }
|
|
16
|
+
| { type: 'enum'; values: readonly string[]; describe: string };
|
|
17
|
+
|
|
18
|
+
const AGENT_VALUES = ['claude', 'pi', 'opencode', 'codex', 'gemini'] as const;
|
|
19
|
+
|
|
20
|
+
export const CONFIG_SCHEMA = {
|
|
21
|
+
workspacesDir: { type: 'string', describe: 'Directory where workspaces are created' },
|
|
22
|
+
githubOrg: { type: 'string', allowEmpty: true, describe: 'Default GitHub org for repo lookups' },
|
|
23
|
+
cloneProtocol: { type: 'enum', values: ['ssh', 'https'], describe: 'Protocol used to clone repos' },
|
|
24
|
+
aiAgent: {
|
|
25
|
+
type: 'enum',
|
|
26
|
+
values: [...AGENT_VALUES, 'both', 'auto'],
|
|
27
|
+
describe: 'AI agent(s) to integrate with',
|
|
28
|
+
},
|
|
29
|
+
primaryAgent: {
|
|
30
|
+
type: 'enum',
|
|
31
|
+
values: [...AGENT_VALUES, 'auto'],
|
|
32
|
+
describe: 'Agent launched when opening a workspace',
|
|
33
|
+
},
|
|
34
|
+
autoLaunchClaude: { type: 'boolean', describe: 'Auto-launch the agent after creating a workspace' },
|
|
35
|
+
generateClaudeContext: { type: 'boolean', describe: 'Generate agent context files (AGENTS.md)' },
|
|
36
|
+
installMcp: { type: 'boolean', describe: 'Install the MCP server during configure' },
|
|
37
|
+
piWorkspaceInputStatus: { type: 'boolean', describe: "Show workspace status in Pi's input area" },
|
|
38
|
+
claudeWorkspaceStatusLine: { type: 'boolean', describe: "Show workspace table in Claude's status line" },
|
|
39
|
+
autoReportBugs: { type: 'boolean', describe: 'Auto-file a GitHub issue when a command crashes' },
|
|
40
|
+
} satisfies Record<ConfigKey, FieldSpec>;
|
|
41
|
+
|
|
42
|
+
export const CONFIG_KEYS = Object.keys(CONFIG_SCHEMA).sort() as ConfigKey[];
|
|
43
|
+
|
|
44
|
+
const TRUE_WORDS = new Set(['true', '1', 'yes', 'on', 'y']);
|
|
45
|
+
const FALSE_WORDS = new Set(['false', '0', 'no', 'off', 'n']);
|
|
46
|
+
|
|
47
|
+
/** True if `key` is a writable config key. */
|
|
48
|
+
export function isConfigKey(key: string): key is ConfigKey {
|
|
49
|
+
return Object.prototype.hasOwnProperty.call(CONFIG_SCHEMA, key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ParseResult =
|
|
53
|
+
| { ok: true; value: UserConfig[ConfigKey] }
|
|
54
|
+
| { ok: false; error: string };
|
|
55
|
+
|
|
56
|
+
/** Validate + coerce a raw string for `key` into the field's typed value. */
|
|
57
|
+
export function parseConfigValue(key: ConfigKey, raw: string): ParseResult {
|
|
58
|
+
const spec: FieldSpec = CONFIG_SCHEMA[key];
|
|
59
|
+
if (spec.type === 'boolean') {
|
|
60
|
+
const v = raw.trim().toLowerCase();
|
|
61
|
+
if (TRUE_WORDS.has(v)) return { ok: true, value: true };
|
|
62
|
+
if (FALSE_WORDS.has(v)) return { ok: true, value: false };
|
|
63
|
+
return { ok: false, error: `${key} expects a boolean (true/false); got "${raw}"` };
|
|
64
|
+
}
|
|
65
|
+
if (spec.type === 'enum') {
|
|
66
|
+
// Enum values are all lowercase, so normalize input like booleans do —
|
|
67
|
+
// `HTTPS` / ` https ` should resolve to the canonical value, not fail.
|
|
68
|
+
const v = raw.trim().toLowerCase();
|
|
69
|
+
if ((spec.values as readonly string[]).includes(v)) {
|
|
70
|
+
return { ok: true, value: v as UserConfig[ConfigKey] };
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}; got "${raw}"` };
|
|
73
|
+
}
|
|
74
|
+
// string: trim surrounding whitespace (a stray space in a path/org is almost
|
|
75
|
+
// always a mistake), but preserve case.
|
|
76
|
+
const trimmed = raw.trim();
|
|
77
|
+
if (!spec.allowEmpty && trimmed === '') {
|
|
78
|
+
return { ok: false, error: `${key} cannot be empty` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, value: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
84
|
+
export function applyConfigSet(
|
|
85
|
+
current: UserConfig,
|
|
86
|
+
key: string,
|
|
87
|
+
raw: string,
|
|
88
|
+
): { ok: true; next: UserConfig; value: UserConfig[ConfigKey] } | { ok: false; error: string } {
|
|
89
|
+
if (!isConfigKey(key)) {
|
|
90
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
91
|
+
}
|
|
92
|
+
const parsed = parseConfigValue(key, raw);
|
|
93
|
+
if (!parsed.ok) return parsed;
|
|
94
|
+
return { ok: true, next: { ...current, [key]: parsed.value }, value: parsed.value };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reset a key to its default value, returning a NEW config or an error. Pure. */
|
|
98
|
+
export function applyConfigUnset(
|
|
99
|
+
current: UserConfig,
|
|
100
|
+
key: string,
|
|
101
|
+
): { ok: true; next: UserConfig; value: UserConfig[ConfigKey] } | { ok: false; error: string } {
|
|
102
|
+
if (!isConfigKey(key)) {
|
|
103
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
104
|
+
}
|
|
105
|
+
const value = CONFIG_DEFAULTS[key];
|
|
106
|
+
return { ok: true, next: { ...current, [key]: value }, value };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Render a config value for plain (scriptable) stdout output. */
|
|
110
|
+
export function formatConfigValue(value: unknown): string {
|
|
111
|
+
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
112
|
+
}
|
package/src/utils/config.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface UserConfig {
|
|
|
67
67
|
autoReportBugs: boolean;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const
|
|
70
|
+
export const CONFIG_DEFAULTS: UserConfig = {
|
|
71
71
|
workspacesDir: path.join(HOME_DIR, 'workspaces'),
|
|
72
72
|
githubOrg: '',
|
|
73
73
|
autoLaunchClaude: true,
|
|
@@ -81,6 +81,9 @@ const DEFAULTS: UserConfig = {
|
|
|
81
81
|
autoReportBugs: false,
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
// Internal alias retained for the many references below.
|
|
85
|
+
const DEFAULTS = CONFIG_DEFAULTS;
|
|
86
|
+
|
|
84
87
|
function loadConfigFileSync(): Partial<UserConfig> {
|
|
85
88
|
try {
|
|
86
89
|
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { applyGlobalFlags } from './global-flags';
|
|
4
|
+
|
|
5
|
+
const spies = () => ({ setColorEnabled: vi.fn(), setQuiet: vi.fn() });
|
|
6
|
+
|
|
7
|
+
// Mirror the real root-program global options so we test what commander actually
|
|
8
|
+
// parses (esp. bundled short flags), not a hand-built opts object.
|
|
9
|
+
const rootOpts = (argv: string[]) => {
|
|
10
|
+
const p = new Command();
|
|
11
|
+
p.option('-y, --yes', '').option('-q, --quiet', '').option('--no-color', '');
|
|
12
|
+
p.command('list').action(() => {});
|
|
13
|
+
p.parse(['node', 'nemus', 'list', ...argv]);
|
|
14
|
+
return p.opts();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('applyGlobalFlags', () => {
|
|
18
|
+
it('does nothing by default (color on, not quiet)', () => {
|
|
19
|
+
const d = spies();
|
|
20
|
+
applyGlobalFlags(rootOpts([]), d);
|
|
21
|
+
expect(d.setColorEnabled).not.toHaveBeenCalled();
|
|
22
|
+
expect(d.setQuiet).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('--no-color disables color', () => {
|
|
26
|
+
const d = spies();
|
|
27
|
+
applyGlobalFlags(rootOpts(['--no-color']), d);
|
|
28
|
+
expect(d.setColorEnabled).toHaveBeenCalledWith(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('--quiet enables quiet', () => {
|
|
32
|
+
const d = spies();
|
|
33
|
+
applyGlobalFlags(rootOpts(['--quiet']), d);
|
|
34
|
+
expect(d.setQuiet).toHaveBeenCalledWith(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('bundled short flags (-yq) still enable quiet', () => {
|
|
38
|
+
const d = spies();
|
|
39
|
+
const opts = rootOpts(['-yq']);
|
|
40
|
+
expect(opts.quiet).toBe(true); // commander expands the bundle
|
|
41
|
+
applyGlobalFlags(opts, d);
|
|
42
|
+
expect(d.setQuiet).toHaveBeenCalledWith(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { setColorEnabled } from './colors';
|
|
2
|
+
import { setQuiet } from './logger';
|
|
3
|
+
|
|
4
|
+
/** Global options commander parses off the root program. */
|
|
5
|
+
export interface GlobalFlagOpts {
|
|
6
|
+
/** commander's negatable `--no-color` yields `color: false` when passed. */
|
|
7
|
+
color?: boolean;
|
|
8
|
+
/** `-q`/`--quiet`, including bundled short forms like `-yq`. */
|
|
9
|
+
quiet?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply parsed global flags to process-wide state. Kept pure over injected
|
|
14
|
+
* setters so it's testable without commander — the `preAction` hook in
|
|
15
|
+
* program.ts is a one-line call into this. Only ever turns features OFF here:
|
|
16
|
+
* color defaults on (and env/TTY detection already ran at import), so we act
|
|
17
|
+
* solely on an explicit `--no-color` (`color === false`).
|
|
18
|
+
*/
|
|
19
|
+
export function applyGlobalFlags(
|
|
20
|
+
opts: GlobalFlagOpts,
|
|
21
|
+
deps: { setColorEnabled: (on: boolean) => void; setQuiet: (on: boolean) => void } = {
|
|
22
|
+
setColorEnabled,
|
|
23
|
+
setQuiet,
|
|
24
|
+
},
|
|
25
|
+
): void {
|
|
26
|
+
if (opts.color === false) deps.setColorEnabled(false);
|
|
27
|
+
if (opts.quiet) deps.setQuiet(true);
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { logInfo, logSuccess, logStep, logWarning, logError, setQuiet, isQuiet } from './logger';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
setQuiet(false);
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('--quiet (setQuiet)', () => {
|
|
10
|
+
it('suppresses info/success/step but keeps warnings + errors', () => {
|
|
11
|
+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
12
|
+
setQuiet(true);
|
|
13
|
+
expect(isQuiet()).toBe(true);
|
|
14
|
+
|
|
15
|
+
logInfo('i');
|
|
16
|
+
logSuccess('s');
|
|
17
|
+
logStep('st');
|
|
18
|
+
expect(err).not.toHaveBeenCalled(); // routine progress silenced
|
|
19
|
+
|
|
20
|
+
logWarning('w');
|
|
21
|
+
logError('e');
|
|
22
|
+
expect(err).toHaveBeenCalledTimes(2); // warnings + errors still shown
|
|
23
|
+
expect(err.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/w[\s\S]*e|e[\s\S]*w/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('emits everything when not quiet', () => {
|
|
27
|
+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
28
|
+
setQuiet(false);
|
|
29
|
+
logInfo('i');
|
|
30
|
+
logSuccess('s');
|
|
31
|
+
logStep('st');
|
|
32
|
+
logWarning('w');
|
|
33
|
+
logError('e');
|
|
34
|
+
expect(err).toHaveBeenCalledTimes(5);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/utils/logger.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { colors, colorize } from './colors';
|
|
|
5
5
|
// --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
|
|
6
6
|
const logStream = (line: string): void => console.error(line);
|
|
7
7
|
|
|
8
|
+
// `--quiet` silences routine progress (info/success/step) while KEEPING warnings
|
|
9
|
+
// and errors, which a script or human still needs to see.
|
|
10
|
+
let quiet = false;
|
|
11
|
+
export const setQuiet = (on: boolean): void => {
|
|
12
|
+
quiet = on;
|
|
13
|
+
};
|
|
14
|
+
export const isQuiet = (): boolean => quiet;
|
|
15
|
+
|
|
8
16
|
const getTimestamp = (): string => {
|
|
9
17
|
const now = new Date();
|
|
10
18
|
return now.toLocaleTimeString('en-US', {
|
|
@@ -16,10 +24,12 @@ const getTimestamp = (): string => {
|
|
|
16
24
|
};
|
|
17
25
|
|
|
18
26
|
export const logInfo = (message: string): void => {
|
|
27
|
+
if (quiet) return;
|
|
19
28
|
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
|
|
20
29
|
};
|
|
21
30
|
|
|
22
31
|
export const logSuccess = (message: string): void => {
|
|
32
|
+
if (quiet) return;
|
|
23
33
|
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
|
|
24
34
|
};
|
|
25
35
|
|
|
@@ -32,6 +42,7 @@ export const logWarning = (message: string): void => {
|
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
export const logStep = (stepOrMessage: number | string, total?: number, message?: string): void => {
|
|
45
|
+
if (quiet) return;
|
|
35
46
|
if (typeof stepOrMessage === 'string') {
|
|
36
47
|
// Single parameter version: just a message
|
|
37
48
|
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt, saveReflectionReport } from './reflect';
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt, saveReflectionReport, severityCounts, severitySummary, renderReportMarkdown, groupRecommendations, listSavedReports, loadSavedReport, findSavedMatches, SavedReport, ReflectionReport, Recommendation } from './reflect';
|
|
3
3
|
import * as fs from 'fs/promises';
|
|
4
|
+
import * as os from 'os';
|
|
4
5
|
import * as path from 'path';
|
|
5
6
|
|
|
6
7
|
const J = (o: unknown) => JSON.stringify(o);
|
|
@@ -124,3 +125,129 @@ describe('parseReflectionReport', () => {
|
|
|
124
125
|
expect(parseReflectionReport({ recommendations: 'nope' })).toEqual({ summary: '', recommendations: [] });
|
|
125
126
|
});
|
|
126
127
|
});
|
|
128
|
+
|
|
129
|
+
describe('severityCounts / severitySummary', () => {
|
|
130
|
+
const recs = (ps: Array<'high' | 'medium' | 'low'>) =>
|
|
131
|
+
ps.map((priority) => ({ kind: 'other' as const, title: 't', detail: 'd', priority }));
|
|
132
|
+
|
|
133
|
+
it('counts by priority', () => {
|
|
134
|
+
expect(severityCounts(recs(['high', 'high', 'low']))).toEqual({ high: 2, medium: 0, low: 1 });
|
|
135
|
+
});
|
|
136
|
+
it('summary omits empty buckets and is empty for none', () => {
|
|
137
|
+
expect(severitySummary(recs(['high', 'medium', 'medium']))).toBe('1 high · 2 medium');
|
|
138
|
+
expect(severitySummary([])).toBe('');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('renderReportMarkdown', () => {
|
|
143
|
+
const report: ReflectionReport = {
|
|
144
|
+
summary: 'Overall fine.',
|
|
145
|
+
recommendations: [
|
|
146
|
+
{ kind: 'skill', title: 'Add deploy skill', detail: 'manual steps', priority: 'high', target: 'acme/api', example: 'name: deploy' },
|
|
147
|
+
{ kind: 'context', title: 'Doc lint', detail: 'guessed', priority: 'medium' },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
it('groups by severity with a count line and headings', () => {
|
|
152
|
+
const md = renderReportMarkdown(report, { analyzed: 5, workspaces: 3, generatedAt: '2026-09-01T12:00:00Z' });
|
|
153
|
+
expect(md).toMatch(/^# Reflection/);
|
|
154
|
+
expect(md).toContain('_5 sessions across 3 workspaces · 2026-09-01T12:00:00Z_');
|
|
155
|
+
expect(md).toContain('**1 high · 1 medium**');
|
|
156
|
+
expect(md).toContain('### High priority');
|
|
157
|
+
expect(md).toContain('### Medium priority');
|
|
158
|
+
// high appears before medium
|
|
159
|
+
expect(md.indexOf('### High priority')).toBeLessThan(md.indexOf('### Medium priority'));
|
|
160
|
+
expect(md).toContain('- **[Skill] Add deploy skill** (`acme/api`)');
|
|
161
|
+
expect(md).toContain('- **[Context/AGENTS.md] Doc lint**');
|
|
162
|
+
expect(md.endsWith('\n')).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('uses a single-workspace scope line', () => {
|
|
166
|
+
const md = renderReportMarkdown(report, { analyzed: 1, workspaces: 1, workspace: 'my-ws' });
|
|
167
|
+
expect(md).toContain('_workspace **my-ws**_');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('escapes an example that itself contains a triple-backtick fence', () => {
|
|
171
|
+
const r: ReflectionReport = {
|
|
172
|
+
summary: '',
|
|
173
|
+
recommendations: [{ kind: 'other', title: 'x', detail: '', priority: 'low', example: 'a ```b``` c' }],
|
|
174
|
+
};
|
|
175
|
+
const md = renderReportMarkdown(r, { analyzed: 1, workspaces: 1 });
|
|
176
|
+
expect(md).toContain('````'); // fence longer than the inner run
|
|
177
|
+
expect(md).toContain('a ```b``` c');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('renders a clean empty state', () => {
|
|
181
|
+
const md = renderReportMarkdown({ summary: 'All good.', recommendations: [] }, { analyzed: 2, workspaces: 2 });
|
|
182
|
+
expect(md).toContain('_No specific recommendations — looks solid._');
|
|
183
|
+
expect(md).not.toContain('### High');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('groupRecommendations', () => {
|
|
188
|
+
const mk = (kind: Recommendation['kind'], priority: Recommendation['priority'], title = 't'): Recommendation =>
|
|
189
|
+
({ kind, title, detail: 'd', priority });
|
|
190
|
+
|
|
191
|
+
it('groups by priority (high→low), omitting empty groups', () => {
|
|
192
|
+
const groups = groupRecommendations([mk('skill', 'low'), mk('context', 'high')], 'priority');
|
|
193
|
+
expect(groups.map((g) => g.key)).toEqual(['high', 'low']); // no 'medium'
|
|
194
|
+
expect(groups[0].heading).toBe('High priority');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('groups by kind in a fixed order, priority-sorted within a kind', () => {
|
|
198
|
+
const groups = groupRecommendations(
|
|
199
|
+
[mk('context', 'low'), mk('skill', 'low', 'a'), mk('skill', 'high', 'b')],
|
|
200
|
+
'kind',
|
|
201
|
+
);
|
|
202
|
+
expect(groups.map((g) => g.key)).toEqual(['skill', 'context']); // skill before context
|
|
203
|
+
expect(groups[0].recs.map((r) => r.title)).toEqual(['b', 'a']); // high before low within skill
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('findSavedMatches (pure)', () => {
|
|
208
|
+
const mk = (id: string): SavedReport => ({ id, file: `${id}.json`, analyzed: 0, workspaces: 0, report: { summary: '', recommendations: [] } });
|
|
209
|
+
// newest-first list, as listSavedReports returns it
|
|
210
|
+
const all = [mk('2000-03'), mk('2000-02b'), mk('2000-02a'), mk('2000-01')];
|
|
211
|
+
|
|
212
|
+
it('empty / latest / exact', () => {
|
|
213
|
+
expect(findSavedMatches([], '2000')).toEqual([]);
|
|
214
|
+
expect(findSavedMatches(all)[0].id).toBe('2000-03'); // undefined -> newest
|
|
215
|
+
expect(findSavedMatches(all, 'latest')[0].id).toBe('2000-03');
|
|
216
|
+
expect(findSavedMatches(all, '2000-02a').map((r) => r.id)).toEqual(['2000-02a']); // exact wins
|
|
217
|
+
});
|
|
218
|
+
it('an ambiguous prefix returns every match, newest-first', () => {
|
|
219
|
+
expect(findSavedMatches(all, '2000-02').map((r) => r.id)).toEqual(['2000-02b', '2000-02a']);
|
|
220
|
+
expect(findSavedMatches(all, 'nope')).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('listSavedReports / loadSavedReport (isolated temp dir)', () => {
|
|
225
|
+
let dir: string;
|
|
226
|
+
const idA = '2000-01-01T00-00-00-000Z-vitestA';
|
|
227
|
+
const idB = '2000-01-02T00-00-00-000Z-vitestB';
|
|
228
|
+
|
|
229
|
+
beforeAll(async () => {
|
|
230
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'nemus-reflect-test-'));
|
|
231
|
+
await fs.writeFile(path.join(dir, `${idA}.json`), JSON.stringify({ generatedAt: '2000-01-01T00:00:00.000Z', analyzed: 4, workspaces: 3, summary: 'old', recommendations: [{ kind: 'skill', title: 'x', detail: 'd', priority: 'high' }] }));
|
|
232
|
+
await fs.writeFile(path.join(dir, `${idB}.json`), JSON.stringify({ generatedAt: '2000-01-02T00:00:00.000Z', analyzed: 1, workspaces: 1, workspace: 'ws', summary: 'new', recommendations: [] }));
|
|
233
|
+
await fs.writeFile(path.join(dir, 'not-json.txt'), 'ignore me');
|
|
234
|
+
await fs.writeFile(path.join(dir, 'corrupt.json'), '{ not valid json');
|
|
235
|
+
});
|
|
236
|
+
afterAll(async () => {
|
|
237
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('lists newest-first, skipping non-json and corrupt files', async () => {
|
|
241
|
+
const all = await listSavedReports(dir);
|
|
242
|
+
expect(all.map((r) => r.id)).toEqual([idB, idA]); // exactly two, newer first
|
|
243
|
+
expect(all[0].workspace).toBe('ws');
|
|
244
|
+
expect(all[0].report.recommendations).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('loadSavedReport resolves latest, exact id, and prefix', async () => {
|
|
248
|
+
expect((await loadSavedReport('latest', dir))?.id).toBe(idB);
|
|
249
|
+
expect((await loadSavedReport(idA, dir))?.workspaces).toBe(3);
|
|
250
|
+
expect((await loadSavedReport('2000-01-02T00-00-00', dir))?.id).toBe(idB);
|
|
251
|
+
expect(await loadSavedReport('definitely-no-such-id-xyz', dir)).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/utils/reflect.ts
CHANGED
|
@@ -63,6 +63,134 @@ export interface ReflectionReport {
|
|
|
63
63
|
recommendations: Recommendation[];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// ------------------------------------------------------------ report rendering
|
|
67
|
+
|
|
68
|
+
export type Priority = Recommendation['priority'];
|
|
69
|
+
|
|
70
|
+
/** Count recommendations by priority. */
|
|
71
|
+
export function severityCounts(recs: Recommendation[]): Record<Priority, number> {
|
|
72
|
+
const counts: Record<Priority, number> = { high: 0, medium: 0, low: 0 };
|
|
73
|
+
for (const r of recs) counts[r.priority]++;
|
|
74
|
+
return counts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** "3 high · 2 medium · 1 low", omitting zero buckets; '' when there are none. */
|
|
78
|
+
export function severitySummary(recs: Recommendation[]): string {
|
|
79
|
+
const c = severityCounts(recs);
|
|
80
|
+
return (['high', 'medium', 'low'] as Priority[])
|
|
81
|
+
.filter((p) => c[p] > 0)
|
|
82
|
+
.map((p) => `${c[p]} ${p}`)
|
|
83
|
+
.join(' · ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const KIND_LABEL: Record<RecommendationKind, string> = {
|
|
87
|
+
skill: 'Skill',
|
|
88
|
+
context: 'Context/AGENTS.md',
|
|
89
|
+
test: 'Test',
|
|
90
|
+
prompt: 'Prompt',
|
|
91
|
+
connectivity: 'Connectivity',
|
|
92
|
+
workflow: 'Workflow',
|
|
93
|
+
other: 'Other',
|
|
94
|
+
};
|
|
95
|
+
// Back-compat alias for existing references.
|
|
96
|
+
const MD_KIND_LABEL = KIND_LABEL;
|
|
97
|
+
|
|
98
|
+
export const PRIORITY_HEADING: Record<Priority, string> = {
|
|
99
|
+
high: 'High priority',
|
|
100
|
+
medium: 'Medium priority',
|
|
101
|
+
low: 'Low priority',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type GroupBy = 'priority' | 'kind';
|
|
105
|
+
|
|
106
|
+
const KIND_ORDER: RecommendationKind[] = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
|
|
107
|
+
const PRIORITY_ORDER: Priority[] = ['high', 'medium', 'low'];
|
|
108
|
+
const PRIORITY_RANK: Record<Priority, number> = { high: 0, medium: 1, low: 2 };
|
|
109
|
+
|
|
110
|
+
export interface RecGroup {
|
|
111
|
+
key: string;
|
|
112
|
+
heading: string;
|
|
113
|
+
recs: Recommendation[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Split recommendations into ordered, non-empty groups by either priority
|
|
118
|
+
* (high→low) or kind (a fixed, stable order). When grouping by kind, each
|
|
119
|
+
* group's recs are sorted high-priority first. Pure + unit-tested; shared by the
|
|
120
|
+
* Markdown renderer and the human printer so the two never diverge.
|
|
121
|
+
*/
|
|
122
|
+
export function groupRecommendations(recs: Recommendation[], groupBy: GroupBy): RecGroup[] {
|
|
123
|
+
if (groupBy === 'kind') {
|
|
124
|
+
return KIND_ORDER.map((k) => ({
|
|
125
|
+
key: k,
|
|
126
|
+
heading: KIND_LABEL[k],
|
|
127
|
+
recs: recs
|
|
128
|
+
.filter((r) => r.kind === k)
|
|
129
|
+
.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]),
|
|
130
|
+
})).filter((g) => g.recs.length > 0);
|
|
131
|
+
}
|
|
132
|
+
return PRIORITY_ORDER.map((p) => ({
|
|
133
|
+
key: p,
|
|
134
|
+
heading: PRIORITY_HEADING[p],
|
|
135
|
+
recs: recs.filter((r) => r.priority === p),
|
|
136
|
+
})).filter((g) => g.recs.length > 0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A fenced code block whose fence is guaranteed longer than any backtick run
|
|
140
|
+
* inside `body`, so the snippet can't break out of its own fence. */
|
|
141
|
+
function fencedBlock(body: string): string {
|
|
142
|
+
const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((m) => m.length));
|
|
143
|
+
const fence = '`'.repeat(Math.max(3, longest + 1));
|
|
144
|
+
return `${fence}\n${body}\n${fence}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render a reflection report as clean Markdown — for pasting into an issue/PR or
|
|
149
|
+
* saving alongside the JSON. Pure (no color, no I/O) so it's unit-tested.
|
|
150
|
+
* Recommendations are grouped by severity (high→low); each carries its kind,
|
|
151
|
+
* optional target, detail, and a fenced example.
|
|
152
|
+
*/
|
|
153
|
+
export function renderReportMarkdown(
|
|
154
|
+
report: ReflectionReport,
|
|
155
|
+
meta: { analyzed: number; workspaces: number; workspace?: string; generatedAt?: string },
|
|
156
|
+
groupBy: GroupBy = 'priority',
|
|
157
|
+
): string {
|
|
158
|
+
const lines: string[] = ['# Reflection', ''];
|
|
159
|
+
const scope = meta.workspace
|
|
160
|
+
? `workspace **${meta.workspace}**`
|
|
161
|
+
: `${meta.analyzed} session${meta.analyzed === 1 ? '' : 's'} across ${meta.workspaces} workspace${meta.workspaces === 1 ? '' : 's'}`;
|
|
162
|
+
const stamp = meta.generatedAt ? ` · ${meta.generatedAt}` : '';
|
|
163
|
+
lines.push(`_${scope}${stamp}_`, '');
|
|
164
|
+
|
|
165
|
+
if (report.summary.trim()) lines.push(report.summary.trim(), '');
|
|
166
|
+
|
|
167
|
+
if (report.recommendations.length === 0) {
|
|
168
|
+
lines.push('## Recommendations', '', '_No specific recommendations — looks solid._', '');
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
lines.push('## Recommendations', '', `**${severitySummary(report.recommendations)}**`, '');
|
|
173
|
+
|
|
174
|
+
for (const group of groupRecommendations(report.recommendations, groupBy)) {
|
|
175
|
+
lines.push(`### ${group.heading}`, '');
|
|
176
|
+
for (const r of group.recs) {
|
|
177
|
+
const target = r.target ? ` (\`${r.target}\`)` : '';
|
|
178
|
+
// Under a kind heading the [Kind] prefix is redundant; show a priority tag
|
|
179
|
+
// instead. Under a priority heading, show the kind.
|
|
180
|
+
const label = groupBy === 'kind' ? `_${r.priority}_ — ` : `[${KIND_LABEL[r.kind]}] `;
|
|
181
|
+
lines.push(`- **${label}${r.title}**${target}`);
|
|
182
|
+
if (r.detail.trim()) {
|
|
183
|
+
lines.push(...r.detail.trim().split('\n').map((l) => ` ${l}`));
|
|
184
|
+
}
|
|
185
|
+
if (r.example?.trim()) {
|
|
186
|
+
lines.push('', ...fencedBlock(r.example.trim()).split('\n').map((l) => ` ${l}`));
|
|
187
|
+
}
|
|
188
|
+
lines.push('');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
192
|
+
}
|
|
193
|
+
|
|
66
194
|
// --------------------------------------------------------- transcript distill
|
|
67
195
|
|
|
68
196
|
// Kept deliberately lean: the judge runs on the user's own (often local, slow)
|
|
@@ -455,3 +583,76 @@ export async function saveReflectionReport(
|
|
|
455
583
|
await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
|
|
456
584
|
return file;
|
|
457
585
|
}
|
|
586
|
+
|
|
587
|
+
/** A saved report on disk, with its metadata and parsed report. */
|
|
588
|
+
export interface SavedReport {
|
|
589
|
+
/** Basename without .json — the id used by `reflect show <id>`. */
|
|
590
|
+
id: string;
|
|
591
|
+
file: string;
|
|
592
|
+
generatedAt?: string;
|
|
593
|
+
analyzed: number;
|
|
594
|
+
workspaces: number;
|
|
595
|
+
workspace?: string;
|
|
596
|
+
report: ReflectionReport;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* List saved reports under `~/.nemus/reflect/`, newest first (filenames start
|
|
601
|
+
* with an ISO timestamp, so a reverse name sort is chronological). Unreadable /
|
|
602
|
+
* unparseable files are skipped, not fatal. Returns [] if the dir is absent.
|
|
603
|
+
*/
|
|
604
|
+
export async function listSavedReports(dir: string = REFLECT_REPORTS_DIR): Promise<SavedReport[]> {
|
|
605
|
+
let names: string[];
|
|
606
|
+
try {
|
|
607
|
+
names = await fs.readdir(dir);
|
|
608
|
+
} catch {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
const jsons = names.filter((n) => n.endsWith('.json')).sort().reverse();
|
|
612
|
+
const out: SavedReport[] = [];
|
|
613
|
+
for (const name of jsons) {
|
|
614
|
+
const file = path.join(dir, name);
|
|
615
|
+
try {
|
|
616
|
+
const raw = JSON.parse(await fs.readFile(file, 'utf-8'));
|
|
617
|
+
out.push({
|
|
618
|
+
id: name.replace(/\.json$/, ''),
|
|
619
|
+
file,
|
|
620
|
+
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : undefined,
|
|
621
|
+
analyzed: typeof raw.analyzed === 'number' ? raw.analyzed : 0,
|
|
622
|
+
workspaces: typeof raw.workspaces === 'number' ? raw.workspaces : 0,
|
|
623
|
+
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
|
624
|
+
report: parseReflectionReport(raw),
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
/* skip a corrupt/partial file */
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve a report reference against an (already newest-first) list. Returns ALL
|
|
635
|
+
* matches so a caller can detect ambiguity: `undefined`/`'latest'` -> the newest;
|
|
636
|
+
* an exact id -> that one; otherwise every id with the prefix (newest-first). An
|
|
637
|
+
* exact id always wins over prefixes, so an id can't be ambiguous with itself.
|
|
638
|
+
* Pure + unit-tested.
|
|
639
|
+
*/
|
|
640
|
+
export function findSavedMatches(all: SavedReport[], ref?: string): SavedReport[] {
|
|
641
|
+
if (all.length === 0) return [];
|
|
642
|
+
if (!ref || ref === 'latest') return [all[0]];
|
|
643
|
+
const exact = all.find((r) => r.id === ref);
|
|
644
|
+
if (exact) return [exact];
|
|
645
|
+
return all.filter((r) => r.id.startsWith(ref));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Load one saved report by id. `undefined`/`'latest'` returns the newest; an id
|
|
650
|
+
* is matched exactly, then as a prefix (newest match wins). Returns null when
|
|
651
|
+
* nothing matches. For ambiguity-aware callers, use findSavedMatches directly.
|
|
652
|
+
*/
|
|
653
|
+
export async function loadSavedReport(
|
|
654
|
+
ref?: string,
|
|
655
|
+
dir: string = REFLECT_REPORTS_DIR,
|
|
656
|
+
): Promise<SavedReport | null> {
|
|
657
|
+
return findSavedMatches(await listSavedReports(dir), ref)[0] ?? null;
|
|
658
|
+
}
|