@nemus-cli/nemus 0.2.13 → 0.3.1
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 +31 -0
- package/README.md +17 -0
- package/dist/cli/ai-prompt.js +13 -25
- package/dist/commands/reflect.js +160 -0
- package/dist/program.js +2 -0
- package/dist/utils/agent-judge.js +137 -0
- package/dist/utils/reflect.js +353 -0
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +12 -22
- package/src/commands/reflect.ts +172 -0
- package/src/program.ts +2 -0
- package/src/utils/agent-judge.test.ts +88 -0
- package/src/utils/agent-judge.ts +168 -0
- package/src/utils/reflect.test.ts +103 -0
- package/src/utils/reflect.ts +405 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseAgentJson, runAgentRaw, runAgentRawAsync, agentAttempts } from './agent-judge';
|
|
3
|
+
|
|
4
|
+
describe('parseAgentJson', () => {
|
|
5
|
+
it('unwraps the common agent envelopes and shapes', () => {
|
|
6
|
+
expect(parseAgentJson('{"a":1}')).toEqual({ a: 1 }); // bare object
|
|
7
|
+
expect(parseAgentJson(J({ structured_output: { a: 2 } }))).toEqual({ a: 2 }); // claude structured
|
|
8
|
+
expect(parseAgentJson(J({ result: J({ a: 3 }) }))).toEqual({ a: 3 }); // result-as-json-string
|
|
9
|
+
expect(parseAgentJson(J({ result: { a: 4 } }))).toEqual({ a: 4 }); // result-as-object
|
|
10
|
+
expect(parseAgentJson('```json\n{"a":5}\n```')).toEqual({ a: 5 }); // fenced
|
|
11
|
+
expect(parseAgentJson('noise before {"a":6} after')).toEqual({ a: 6 }); // outermost span fallback
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('throws when there is no JSON at all', () => {
|
|
15
|
+
expect(() => parseAgentJson('totally not json')).toThrow(/did not return JSON/);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('agentAttempts', () => {
|
|
20
|
+
it('claude: preferred (schema + lean flags) then a plain fallback', () => {
|
|
21
|
+
const a = agentAttempts('claude', 'P', '{"type":"object"}');
|
|
22
|
+
expect(a[0]).toEqual({ cmd: 'claude', args: expect.arrayContaining(['-p', 'P', '--output-format', 'json', '--json-schema', '{"type":"object"}']) });
|
|
23
|
+
expect(a[1]).toEqual({ cmd: 'claude', args: ['-p', 'P'] });
|
|
24
|
+
});
|
|
25
|
+
it('pi: lean then plain; opencode: single run', () => {
|
|
26
|
+
const pi = agentAttempts('pi', 'P');
|
|
27
|
+
expect(pi[0].args).toEqual(expect.arrayContaining(['--no-tools', '--no-skills', '-p', 'P']));
|
|
28
|
+
expect(pi[1]).toEqual({ cmd: 'pi', args: ['-p', 'P'] });
|
|
29
|
+
expect(agentAttempts('opencode', 'P')).toEqual([{ cmd: 'opencode', args: ['run', 'P'] }]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('runAgentRawAsync', () => {
|
|
34
|
+
it('runs the preferred attempt and returns stdout', async () => {
|
|
35
|
+
const calls: string[][] = [];
|
|
36
|
+
const execAsync = async (cmd: string, args: string[]) => {
|
|
37
|
+
calls.push([cmd, ...args]);
|
|
38
|
+
return '{"ok":true}';
|
|
39
|
+
};
|
|
40
|
+
const out = await runAgentRawAsync('P', { agentType: 'pi', execAsync });
|
|
41
|
+
expect(out).toBe('{"ok":true}');
|
|
42
|
+
expect(calls).toHaveLength(1); // first attempt succeeded, no fallback
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('turns a timeout into an actionable error', async () => {
|
|
46
|
+
const execAsync = async () => {
|
|
47
|
+
throw Object.assign(new Error('spawn pi ETIMEDOUT'), { code: 'ETIMEDOUT', killed: true });
|
|
48
|
+
};
|
|
49
|
+
await expect(runAgentRawAsync('P', { agentType: 'pi', execAsync })).rejects.toThrow(/timed out.*NEMUS_JUDGE_TIMEOUT_MS/s);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('runAgentRaw', () => {
|
|
54
|
+
it('claude: passes the schema + lean flags, falls back on a rejected flag', () => {
|
|
55
|
+
const calls: string[][] = [];
|
|
56
|
+
const exec = (cmd: string, args: string[]) => {
|
|
57
|
+
calls.push([cmd, ...args]);
|
|
58
|
+
if (calls.length === 1) throw new Error('unknown flag --json-schema'); // old claude
|
|
59
|
+
return '{"ok":true}';
|
|
60
|
+
};
|
|
61
|
+
const out = runAgentRaw('PROMPT', { agentType: 'claude', schema: '{"type":"object"}', exec });
|
|
62
|
+
expect(out).toBe('{"ok":true}');
|
|
63
|
+
// first (preferred) attempt carries the schema + structured flags…
|
|
64
|
+
expect(calls[0]).toEqual(expect.arrayContaining(['claude', '-p', 'PROMPT', '--output-format', 'json', '--json-schema', '{"type":"object"}']));
|
|
65
|
+
// …the fallback is the plainest form
|
|
66
|
+
expect(calls[1]).toEqual(['claude', '-p', 'PROMPT']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('pi: runs with lean flags', () => {
|
|
70
|
+
let seen: string[] = [];
|
|
71
|
+
const exec = (cmd: string, args: string[]) => {
|
|
72
|
+
seen = [cmd, ...args];
|
|
73
|
+
return 'ok';
|
|
74
|
+
};
|
|
75
|
+
runAgentRaw('P', { agentType: 'pi', exec });
|
|
76
|
+
expect(seen).toEqual(expect.arrayContaining(['pi', '--no-extensions', '--no-skills', '--no-tools', '-p', 'P']));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('wraps a hard failure in a clear error', () => {
|
|
80
|
+
const exec = () => { throw Object.assign(new Error('boom'), { stderr: 'agent exploded' }); };
|
|
81
|
+
// pi retries once (lean → plain), then throws
|
|
82
|
+
expect(() => runAgentRaw('P', { agentType: 'pi', exec })).toThrow(/agent judge failed \(pi\): agent exploded/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function J(o: unknown) {
|
|
87
|
+
return JSON.stringify(o);
|
|
88
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { getPrimaryAgent } from './agent-config';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run the user's configured coding agent headlessly as an "LLM-as-a-judge":
|
|
9
|
+
* feed it a prompt, get back a parsed JSON object. This reuses whatever agent
|
|
10
|
+
* the user already has authenticated (claude / pi / opencode) — no API keys of
|
|
11
|
+
* our own. It mirrors the lean, structured invocation used for intent
|
|
12
|
+
* extraction, but generalized (any prompt + optional JSON schema).
|
|
13
|
+
*
|
|
14
|
+
* The call is bounded by a timeout and a large maxBuffer; a transcript-analysis
|
|
15
|
+
* judge returns more than intent extraction, so the buffer is generous.
|
|
16
|
+
*/
|
|
17
|
+
export interface JudgeOptions {
|
|
18
|
+
/** JSON schema string passed to `claude --json-schema` (ignored by others). */
|
|
19
|
+
schema?: string;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
maxBuffer?: number;
|
|
22
|
+
/** Injected for tests. Defaults to the real (blocking) child_process runner. */
|
|
23
|
+
exec?: (cmd: string, args: string[], opts: { timeout: number; maxBuffer: number }) => string;
|
|
24
|
+
/** Injected for tests. Async runner used by the non-blocking variants. */
|
|
25
|
+
execAsync?: (cmd: string, args: string[], opts: { timeout: number; maxBuffer: number }) => Promise<string>;
|
|
26
|
+
/** Injected for tests. Defaults to the configured primary agent. */
|
|
27
|
+
agentType?: 'claude' | 'pi' | 'opencode' | 'codex' | 'gemini';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type JudgeAgentType = NonNullable<JudgeOptions['agentType']>;
|
|
31
|
+
|
|
32
|
+
export interface AgentAttempt {
|
|
33
|
+
cmd: string;
|
|
34
|
+
args: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The ordered invocation attempts for an agent (preferred → fallback), as pure
|
|
39
|
+
* data so both the sync and async runners share ONE flag ladder (and it's
|
|
40
|
+
* unit-testable without spawning anything).
|
|
41
|
+
*/
|
|
42
|
+
export function agentAttempts(agentType: JudgeAgentType, prompt: string, schema?: string): AgentAttempt[] {
|
|
43
|
+
if (agentType === 'claude') {
|
|
44
|
+
const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
|
|
45
|
+
if (schema) preferred.push('--json-schema', schema);
|
|
46
|
+
// Older claude may reject the newer flags — fall back to the plainest form.
|
|
47
|
+
return [{ cmd: 'claude', args: preferred }, { cmd: 'claude', args: ['-p', prompt] }];
|
|
48
|
+
}
|
|
49
|
+
if (agentType === 'opencode') {
|
|
50
|
+
return [{ cmd: 'opencode', args: ['run', prompt] }];
|
|
51
|
+
}
|
|
52
|
+
// pi (and any other): run as lean as possible so a bloated env can't hang it.
|
|
53
|
+
const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
|
|
54
|
+
return [{ cmd: 'pi', args: [...piLean, '-p', prompt] }, { cmd: 'pi', args: ['-p', prompt] }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function wrapJudgeError(err: any, agentType: string): Error {
|
|
58
|
+
// A timeout is the common failure (big prompt + slow local model), so make it
|
|
59
|
+
// actionable instead of surfacing a raw `spawn … ETIMEDOUT`.
|
|
60
|
+
if (err?.killed || err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /ETIMEDOUT/.test(String(err?.message ?? ''))) {
|
|
61
|
+
return new Error(
|
|
62
|
+
`agent judge (${agentType}) timed out. Try a smaller --limit, a faster agent, or raise the cap with NEMUS_JUDGE_TIMEOUT_MS.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
|
|
66
|
+
return new Error(`agent judge failed (${agentType}): ${detail}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_TIMEOUT_MS = 300_000; // judging N transcripts is heavier than extraction; a big prompt + slow model can run minutes
|
|
70
|
+
const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
|
|
74
|
+
* on timeout / non-zero exit. Kept separate from parsing so callers can inspect
|
|
75
|
+
* raw output (e.g. `--dry-run`, debugging).
|
|
76
|
+
*/
|
|
77
|
+
export function runAgentRaw(prompt: string, opts: JudgeOptions = {}): string {
|
|
78
|
+
const agentType = opts.agentType ?? getPrimaryAgent().type;
|
|
79
|
+
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
+
const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
81
|
+
const exec =
|
|
82
|
+
opts.exec ??
|
|
83
|
+
((cmd, args, o) => execFileSync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
|
|
84
|
+
|
|
85
|
+
const attempts = agentAttempts(agentType, prompt, opts.schema);
|
|
86
|
+
let lastErr: any;
|
|
87
|
+
for (const a of attempts) {
|
|
88
|
+
try {
|
|
89
|
+
return exec(a.cmd, a.args, { timeout, maxBuffer });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
lastErr = err; // try the next (fallback) form
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw wrapJudgeError(lastErr, agentType);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Non-blocking twin of {@link runAgentRaw}. Uses `execFile` (async) so the
|
|
99
|
+
* caller's event loop stays free — letting a spinner/progress UI animate while
|
|
100
|
+
* the judge (which can take minutes) runs. Prefer this in interactive commands.
|
|
101
|
+
*/
|
|
102
|
+
export async function runAgentRawAsync(prompt: string, opts: JudgeOptions = {}): Promise<string> {
|
|
103
|
+
const agentType = opts.agentType ?? getPrimaryAgent().type;
|
|
104
|
+
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
106
|
+
const exec =
|
|
107
|
+
opts.execAsync ??
|
|
108
|
+
(async (cmd, args, o) =>
|
|
109
|
+
(await execFileAsync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer })).stdout.toString());
|
|
110
|
+
|
|
111
|
+
const attempts = agentAttempts(agentType, prompt, opts.schema);
|
|
112
|
+
let lastErr: any;
|
|
113
|
+
for (const a of attempts) {
|
|
114
|
+
try {
|
|
115
|
+
return await exec(a.cmd, a.args, { timeout, maxBuffer });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
lastErr = err; // try the next (fallback) form
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw wrapJudgeError(lastErr, agentType);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run the agent and parse its reply as JSON, tolerating the shapes different
|
|
125
|
+
* agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
|
|
126
|
+
* or a bare object. Returns `unknown`; callers validate/normalize their shape.
|
|
127
|
+
*/
|
|
128
|
+
export function runAgentJson(prompt: string, opts: JudgeOptions = {}): unknown {
|
|
129
|
+
const raw = runAgentRaw(prompt, opts);
|
|
130
|
+
return parseAgentJson(raw);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Non-blocking twin of {@link runAgentJson}. */
|
|
134
|
+
export async function runAgentJsonAsync(prompt: string, opts: JudgeOptions = {}): Promise<unknown> {
|
|
135
|
+
const raw = await runAgentRawAsync(prompt, opts);
|
|
136
|
+
return parseAgentJson(raw);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Extract a JSON object from an agent's raw stdout. Exported for tests. */
|
|
140
|
+
export function parseAgentJson(raw: string): unknown {
|
|
141
|
+
let text = raw.trim();
|
|
142
|
+
const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
143
|
+
if (fence) text = fence[1].trim();
|
|
144
|
+
|
|
145
|
+
let parsed: any;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(text);
|
|
148
|
+
} catch {
|
|
149
|
+
// Last resort: grab the outermost {...} span.
|
|
150
|
+
const span = text.match(/\{[\s\S]*\}/);
|
|
151
|
+
if (!span) throw new Error('agent did not return JSON');
|
|
152
|
+
parsed = JSON.parse(span[0]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Unwrap the common agent envelopes.
|
|
156
|
+
if (parsed && typeof parsed === 'object') {
|
|
157
|
+
if (parsed.structured_output && typeof parsed.structured_output === 'object') return parsed.structured_output;
|
|
158
|
+
if (typeof parsed.result === 'string') {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(parsed.result);
|
|
161
|
+
} catch {
|
|
162
|
+
/* fall through — result was plain text, return the envelope */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (parsed.result && typeof parsed.result === 'object') return parsed.result;
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { distillTranscript, buildJudgePrompt, parseReflectionReport, ReflectionCorpus } from './reflect';
|
|
3
|
+
|
|
4
|
+
const J = (o: unknown) => JSON.stringify(o);
|
|
5
|
+
|
|
6
|
+
describe('distillTranscript', () => {
|
|
7
|
+
it('extracts prompts, tools, errors, and turns across pi + claude shapes', () => {
|
|
8
|
+
const lines = [
|
|
9
|
+
'not json — skipped',
|
|
10
|
+
// pi: user prompt
|
|
11
|
+
J({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'do the thing' }] } }),
|
|
12
|
+
// pi: assistant with a toolCall
|
|
13
|
+
J({ type: 'message', message: { role: 'assistant', content: [{ type: 'toolCall', name: 'bash' }] } }),
|
|
14
|
+
// pi: tool result flagged as error
|
|
15
|
+
J({ type: 'message', message: { role: 'toolResult', toolName: 'bash', isError: true, content: [{ type: 'text', text: 'command failed: boom' }] } }),
|
|
16
|
+
// claude: user prompt as plain string
|
|
17
|
+
J({ type: 'user', message: { role: 'user', content: 'claude style prompt' } }),
|
|
18
|
+
// claude: assistant tool_use
|
|
19
|
+
J({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Edit' }] } }),
|
|
20
|
+
// claude: tool_result-only user message → an error, NOT a prompt
|
|
21
|
+
J({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', is_error: true, content: 'file not found' }] } }),
|
|
22
|
+
// a system-reminder-style user text → ignored as a prompt
|
|
23
|
+
J({ type: 'message', message: { role: 'user', content: '<system-reminder>ignore me</system-reminder>' } }),
|
|
24
|
+
// UNFLAGGED benign result whose text merely mentions "error" → NOT a failure
|
|
25
|
+
J({ type: 'message', message: { role: 'toolResult', toolName: 'grep', content: [{ type: 'text', text: '0 results for error' }] } }),
|
|
26
|
+
// UNFLAGGED result with a strong failure signal → a failure (regex fallback)
|
|
27
|
+
J({ type: 'message', message: { role: 'toolResult', toolName: 'git', content: [{ type: 'text', text: 'fatal: not a git repository' }] } }),
|
|
28
|
+
// EXPLICITLY not-an-error, despite failing-looking text → trust the flag
|
|
29
|
+
J({ type: 'message', message: { role: 'toolResult', isError: false, content: [{ type: 'text', text: 'fatal: boom (but flagged success)' }] } }),
|
|
30
|
+
].join('\n');
|
|
31
|
+
|
|
32
|
+
const d = distillTranscript(lines, { sessionId: 's1', agentType: 'pi' });
|
|
33
|
+
expect(d.turns).toBe(2);
|
|
34
|
+
expect(d.userPrompts).toEqual(['do the thing', 'claude style prompt']);
|
|
35
|
+
expect(d.tools.sort()).toEqual(['Edit', 'bash', 'git', 'grep']);
|
|
36
|
+
expect(d.errors).toContain('command failed: boom'); // explicit isError:true
|
|
37
|
+
expect(d.errors).toContain('file not found'); // explicit is_error:true
|
|
38
|
+
expect(d.errors).toContain('fatal: not a git repository'); // unflagged + strong signal
|
|
39
|
+
expect(d.errors).not.toContain('0 results for error'); // unflagged benign 'error' mention
|
|
40
|
+
expect(d.errors.some((e) => e.includes('flagged success'))).toBe(false); // isError:false trusted
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is bounded and tolerant of empty input', () => {
|
|
44
|
+
expect(distillTranscript('', { sessionId: 's', agentType: 'pi' })).toMatchObject({
|
|
45
|
+
turns: 0,
|
|
46
|
+
userPrompts: [],
|
|
47
|
+
errors: [],
|
|
48
|
+
tools: [],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const corpus: ReflectionCorpus = {
|
|
54
|
+
generatedAt: '2026-01-01T00:00:00Z',
|
|
55
|
+
availableSkills: ['redash', 'datadog'],
|
|
56
|
+
workspaces: [
|
|
57
|
+
{
|
|
58
|
+
name: 'pay-app',
|
|
59
|
+
repoCount: 1,
|
|
60
|
+
repos: ['api'],
|
|
61
|
+
contextFiles: ['AGENTS.md'],
|
|
62
|
+
session: { sessionId: 's', agentType: 'pi', turns: 12, userPrompts: ['fix the sync bug'], errors: ['gh_pr_create: not a git repository'], tools: ['bash', 'edit'] },
|
|
63
|
+
},
|
|
64
|
+
{ name: 'empty-ws', repoCount: 0, repos: [], contextFiles: [], session: null },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('buildJudgePrompt', () => {
|
|
69
|
+
const p = buildJudgePrompt(corpus);
|
|
70
|
+
it('frames the judge task and includes the evidence + guardrails', () => {
|
|
71
|
+
expect(p).toContain('LLM as a judge');
|
|
72
|
+
expect(p).toContain('redash, datadog'); // available skills (do not re-suggest)
|
|
73
|
+
expect(p).toContain('## pay-app');
|
|
74
|
+
expect(p).toContain('fix the sync bug');
|
|
75
|
+
expect(p).toContain('gh_pr_create: not a git repository');
|
|
76
|
+
expect(p).toContain('no recent agent session found'); // empty-ws
|
|
77
|
+
expect(p).toContain('context files: NONE'); // empty-ws has none
|
|
78
|
+
expect(p).toMatch(/ONLY a JSON object/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('parseReflectionReport', () => {
|
|
83
|
+
it('normalizes, clamps enums, and drops empty recs', () => {
|
|
84
|
+
const report = parseReflectionReport({
|
|
85
|
+
summary: 'ok',
|
|
86
|
+
recommendations: [
|
|
87
|
+
{ kind: 'skill', title: 'Add X', detail: 'because Y', priority: 'high', target: ' api ', example: 'stub' },
|
|
88
|
+
{ kind: 'bogus', title: 'clamp me', detail: 'd', priority: 'urgent' }, // kind→other, priority→medium
|
|
89
|
+
{ title: '', detail: '' }, // dropped
|
|
90
|
+
'garbage', // dropped
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
expect(report.summary).toBe('ok');
|
|
94
|
+
expect(report.recommendations).toHaveLength(2);
|
|
95
|
+
expect(report.recommendations[0]).toEqual({ kind: 'skill', title: 'Add X', detail: 'because Y', priority: 'high', target: 'api', example: 'stub' });
|
|
96
|
+
expect(report.recommendations[1]).toMatchObject({ kind: 'other', priority: 'medium', title: 'clamp me' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('tolerates a malformed top-level object', () => {
|
|
100
|
+
expect(parseReflectionReport(null)).toEqual({ summary: '', recommendations: [] });
|
|
101
|
+
expect(parseReflectionReport({ recommendations: 'nope' })).toEqual({ summary: '', recommendations: [] });
|
|
102
|
+
});
|
|
103
|
+
});
|