@nemus-cli/nemus 0.3.0 → 0.3.2
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/dist/cli/ai-prompt.js +4 -0
- package/dist/commands/reflect.js +67 -9
- package/dist/utils/agent-judge.js +146 -26
- package/dist/utils/reflect-analyze.js +136 -0
- package/dist/utils/reflect.js +92 -48
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +4 -0
- package/src/commands/reflect.ts +71 -11
- package/src/utils/agent-judge.test.ts +70 -1
- package/src/utils/agent-judge.ts +169 -27
- package/src/utils/reflect-analyze.test.ts +126 -0
- package/src/utils/reflect-analyze.ts +246 -0
- package/src/utils/reflect.test.ts +30 -27
- package/src/utils/reflect.ts +111 -67
package/dist/utils/reflect.js
CHANGED
|
@@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.REFLECT_SCHEMA = void 0;
|
|
37
|
+
exports.isCorrectionPrompt = isCorrectionPrompt;
|
|
37
38
|
exports.distillTranscript = distillTranscript;
|
|
39
|
+
exports.classifyAgentsMd = classifyAgentsMd;
|
|
38
40
|
exports.findLatestTranscriptFile = findLatestTranscriptFile;
|
|
39
41
|
exports.gatherReflectionCorpus = gatherReflectionCorpus;
|
|
40
|
-
exports.buildJudgePrompt = buildJudgePrompt;
|
|
41
42
|
exports.parseReflectionReport = parseReflectionReport;
|
|
42
43
|
const fs = __importStar(require("fs/promises"));
|
|
43
44
|
const path = __importStar(require("path"));
|
|
@@ -45,10 +46,24 @@ const workspace_meta_1 = require("./workspace-meta");
|
|
|
45
46
|
const agent_config_1 = require("./agent-config");
|
|
46
47
|
const claude_sessions_1 = require("./claude-sessions");
|
|
47
48
|
// --------------------------------------------------------- transcript distill
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
// Kept deliberately lean: the judge runs on the user's own (often local, slow)
|
|
50
|
+
// agent, and a 10-workspace corpus at full verbosity produced a ~200KB prompt
|
|
51
|
+
// that timed pi out. These bounds capture the pattern of a session at a
|
|
52
|
+
// fraction of the tokens (~halving the prompt), so the judge actually finishes.
|
|
53
|
+
const MAX_PROMPTS = 12;
|
|
54
|
+
const MAX_ERRORS = 15;
|
|
55
|
+
const MAX_RESTEER = 6;
|
|
56
|
+
const PROMPT_CHARS = 400;
|
|
57
|
+
const ERROR_CHARS = 200;
|
|
58
|
+
const RESTEER_CHARS = 240;
|
|
59
|
+
// A conservative “the user corrected / redirected the agent” cue. Soft signal:
|
|
60
|
+
// used to count re-steers and to capture the verbatim message for the judge.
|
|
61
|
+
const CORRECTION_RE = /\b(no|nope|wrong|incorrect|revert|undo|instead|actually|you (missed|forgot|broke|didn'?t)|that'?s? (wrong|not right|incorrect)|not what|don'?t)\b/i;
|
|
62
|
+
/** Whether a single user prompt reads like a correction/redirect. Exported +
|
|
63
|
+
* shared with the analyzer so the two never diverge. */
|
|
64
|
+
function isCorrectionPrompt(prompt) {
|
|
65
|
+
return CORRECTION_RE.test(prompt ?? '');
|
|
66
|
+
}
|
|
52
67
|
/** Flatten a message `content` (string or content-block array) to plain text. */
|
|
53
68
|
function contentToText(content) {
|
|
54
69
|
if (typeof content === 'string')
|
|
@@ -86,6 +101,7 @@ function isToolFailure(flag, text) {
|
|
|
86
101
|
*/
|
|
87
102
|
function distillTranscript(raw, meta) {
|
|
88
103
|
const userPrompts = [];
|
|
104
|
+
const reSteerSamples = [];
|
|
89
105
|
const errors = [];
|
|
90
106
|
const tools = new Set();
|
|
91
107
|
let turns = 0;
|
|
@@ -139,13 +155,57 @@ function distillTranscript(raw, meta) {
|
|
|
139
155
|
const isToolResultOnly = Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === 'tool_result');
|
|
140
156
|
if (!isToolResultOnly) {
|
|
141
157
|
const text = contentToText(content).trim();
|
|
142
|
-
if (text && !text.startsWith('<')
|
|
143
|
-
userPrompts.
|
|
158
|
+
if (text && !text.startsWith('<')) {
|
|
159
|
+
if (userPrompts.length < MAX_PROMPTS)
|
|
160
|
+
userPrompts.push(text.slice(0, PROMPT_CHARS));
|
|
161
|
+
// Capture corrections verbatim (bounded) — the sharpest coaching signal.
|
|
162
|
+
if (reSteerSamples.length < MAX_RESTEER && isCorrectionPrompt(text)) {
|
|
163
|
+
reSteerSamples.push(text.slice(0, RESTEER_CHARS));
|
|
164
|
+
}
|
|
144
165
|
}
|
|
145
166
|
}
|
|
146
167
|
}
|
|
147
168
|
}
|
|
148
|
-
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
|
|
169
|
+
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, reSteerSamples, errors, tools: [...tools] };
|
|
170
|
+
}
|
|
171
|
+
// ------------------------------------------------------- context classification
|
|
172
|
+
// Lines that are structural/boilerplate rather than real, custom guidance.
|
|
173
|
+
const BOILERPLATE_MARKERS = [
|
|
174
|
+
'ws-rules:',
|
|
175
|
+
'this workspace was created with',
|
|
176
|
+
'workspace manager',
|
|
177
|
+
'saved context',
|
|
178
|
+
'add your own notes here',
|
|
179
|
+
'common workflows',
|
|
180
|
+
];
|
|
181
|
+
/**
|
|
182
|
+
* Classify an AGENTS.md/CLAUDE.md by how much *real* guidance it carries, so the
|
|
183
|
+
* judge can tell “no context” from “has a file but it's the generated template.”
|
|
184
|
+
* Heuristic + pure.
|
|
185
|
+
*
|
|
186
|
+
* Deliberately **newline-independent**: it measures the volume of non-boilerplate
|
|
187
|
+
* prose (word count) plus heading count via a whitespace-tolerant regex, rather
|
|
188
|
+
* than splitting on lines. A line-anchored version would misclassify any excerpt
|
|
189
|
+
* whose newlines were collapsed to spaces upstream (a real bug class caught in a
|
|
190
|
+
* sibling implementation) — here even a fully single-lined file classifies the
|
|
191
|
+
* same as its multi-line original.
|
|
192
|
+
*/
|
|
193
|
+
function classifyAgentsMd(content) {
|
|
194
|
+
if (!content || !content.trim())
|
|
195
|
+
return 'missing';
|
|
196
|
+
let s = content.replace(/\r\n/g, '\n').toLowerCase();
|
|
197
|
+
s = s.replace(/```[\s\S]*?```/g, ' '); // drop fenced code
|
|
198
|
+
s = s.replace(/<!--[\s\S]*?-->/g, ' '); // drop HTML comments
|
|
199
|
+
for (const m of BOILERPLATE_MARKERS)
|
|
200
|
+
s = s.split(m).join(' '); // drop generated boilerplate (markers are lowercase)
|
|
201
|
+
// Headings: a `#` run at start OR after any whitespace (so a collapsed,
|
|
202
|
+
// single-line excerpt still counts them), followed by a space.
|
|
203
|
+
const headings = (s.match(/(?:^|\s)#{1,6}\s/g) || []).length;
|
|
204
|
+
// Remaining non-boilerplate words (markdown punctuation stripped).
|
|
205
|
+
const words = s.replace(/[#|>*_`~-]/g, ' ').split(/\s+/).filter((w) => w.length > 1).length;
|
|
206
|
+
if (words < 40)
|
|
207
|
+
return 'boilerplate';
|
|
208
|
+
return headings >= 2 || words >= 60 ? 'substantive' : 'boilerplate';
|
|
149
209
|
}
|
|
150
210
|
// --------------------------------------------------------- corpus gathering
|
|
151
211
|
/** Locate the most recent `.jsonl` transcript for a workspace under an agent. */
|
|
@@ -220,26 +280,34 @@ async function listAvailableSkills() {
|
|
|
220
280
|
}
|
|
221
281
|
return [...names].sort();
|
|
222
282
|
}
|
|
283
|
+
/** Which context files exist at the workspace root, plus how substantive the
|
|
284
|
+
* primary one is (missing/boilerplate/substantive). One read per present file. */
|
|
223
285
|
async function contextFilesFor(workspacePath) {
|
|
224
286
|
const present = [];
|
|
287
|
+
let quality = 'missing';
|
|
225
288
|
for (const name of (0, agent_config_1.getAllKnownContextFileNames)()) {
|
|
226
289
|
try {
|
|
227
|
-
await fs.
|
|
290
|
+
const content = await fs.readFile(path.join(workspacePath, name), 'utf-8');
|
|
228
291
|
present.push(name);
|
|
292
|
+
// Classify the first present file, then keep the best classification seen.
|
|
293
|
+
const c = classifyAgentsMd(content);
|
|
294
|
+
if (quality === 'missing' || (quality === 'boilerplate' && c === 'substantive'))
|
|
295
|
+
quality = c;
|
|
229
296
|
}
|
|
230
297
|
catch {
|
|
231
|
-
/* not present */
|
|
298
|
+
/* not present / unreadable */
|
|
232
299
|
}
|
|
233
300
|
}
|
|
234
|
-
return present;
|
|
301
|
+
return { files: present, quality };
|
|
235
302
|
}
|
|
236
303
|
/**
|
|
237
304
|
* Build the corpus the judge reasons over: the `limit` most **recently active**
|
|
238
305
|
* workspaces (by their latest agent session, not creation date — a retrospective
|
|
239
306
|
* is about recent *work*), each with its repos, context files, and distilled
|
|
240
|
-
* session, plus the globally-available skills.
|
|
307
|
+
* session, plus the globally-available skills. `onProgress` (optional) fires
|
|
308
|
+
* once per workspace as it finishes, for a live progress display.
|
|
241
309
|
*/
|
|
242
|
-
async function gatherReflectionCorpus(limit) {
|
|
310
|
+
async function gatherReflectionCorpus(limit, onProgress) {
|
|
243
311
|
const [sessions, workspaces, availableSkills] = await Promise.all([
|
|
244
312
|
(0, claude_sessions_1.getWorkspaceSessions)(), // already sorted by last-active, one per workspace
|
|
245
313
|
(0, workspace_meta_1.listWorkspaces)(false),
|
|
@@ -248,15 +316,20 @@ async function gatherReflectionCorpus(limit) {
|
|
|
248
316
|
const metaByName = new Map(workspaces.map((w) => [w.name, w]));
|
|
249
317
|
const recent = sessions.slice(0, limit);
|
|
250
318
|
const digests = [];
|
|
251
|
-
for (
|
|
319
|
+
for (let index = 0; index < recent.length; index++) {
|
|
320
|
+
const s = recent[index];
|
|
252
321
|
const meta = metaByName.get(s.workspaceName);
|
|
253
|
-
|
|
322
|
+
const context = await contextFilesFor(s.workspacePath);
|
|
323
|
+
const digest = {
|
|
254
324
|
name: s.workspaceName,
|
|
255
325
|
repoCount: meta?.metadata?.repositories?.length ?? 0,
|
|
256
326
|
repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
|
|
257
|
-
contextFiles:
|
|
327
|
+
contextFiles: context.files,
|
|
328
|
+
contextQuality: context.quality,
|
|
258
329
|
session: await readDigestForSession(s),
|
|
259
|
-
}
|
|
330
|
+
};
|
|
331
|
+
digests.push(digest);
|
|
332
|
+
onProgress?.({ index, total: recent.length, digest });
|
|
260
333
|
}
|
|
261
334
|
return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
|
|
262
335
|
}
|
|
@@ -284,37 +357,8 @@ exports.REFLECT_SCHEMA = JSON.stringify({
|
|
|
284
357
|
},
|
|
285
358
|
required: ['summary', 'recommendations'],
|
|
286
359
|
});
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* is asked to recommend concrete improvements to the user's SETUP (skills,
|
|
290
|
-
* AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
|
|
291
|
-
* redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
|
|
292
|
-
*/
|
|
293
|
-
function buildJudgePrompt(corpus) {
|
|
294
|
-
const lines = [];
|
|
295
|
-
lines.push('You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.', 'Goal: recommend concrete improvements to their SETUP so the agent works better next time —', 'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/', 'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.', '', 'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague', 'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.', 'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.', '', `Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`, '', `Recent workspaces (${corpus.workspaces.length}):`);
|
|
296
|
-
for (const ws of corpus.workspaces) {
|
|
297
|
-
lines.push(`\n## ${ws.name}`);
|
|
298
|
-
lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
|
|
299
|
-
if (!ws.session) {
|
|
300
|
-
lines.push('session: (no recent agent session found)');
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
|
|
304
|
-
if (ws.session.userPrompts.length) {
|
|
305
|
-
lines.push('user prompts:');
|
|
306
|
-
for (const p of ws.session.userPrompts)
|
|
307
|
-
lines.push(` - ${p.replace(/\n/g, ' ')}`);
|
|
308
|
-
}
|
|
309
|
-
if (ws.session.errors.length) {
|
|
310
|
-
lines.push('errors/failures observed:');
|
|
311
|
-
for (const e of ws.session.errors)
|
|
312
|
-
lines.push(` - ${e.replace(/\n/g, ' ')}`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
lines.push('', 'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):', '{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",', '"title": string, "detail": string, "target": string(optional workspace/repo/path),', '"priority":"high|medium|low", "example": string(optional snippet)}]}');
|
|
316
|
-
return lines.join('\n');
|
|
317
|
-
}
|
|
360
|
+
// The judge prompt is now built from pre-computed FACTS (see reflect-analyze.ts
|
|
361
|
+
// `buildAnalysisPrompt`), not raw transcripts, so the LLM call stays small/fast.
|
|
318
362
|
// ----------------------------------------------------------- response parse
|
|
319
363
|
const KINDS = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
|
|
320
364
|
const PRIORITIES = ['high', 'medium', 'low'];
|
package/package.json
CHANGED
package/src/cli/ai-prompt.ts
CHANGED
|
@@ -140,6 +140,10 @@ function runExtraction(cmd: string, fullArgs: string[], fallbackArgs?: string[])
|
|
|
140
140
|
const exec = (args: string[]) =>
|
|
141
141
|
execFileSync(cmd, args, {
|
|
142
142
|
encoding: 'utf-8',
|
|
143
|
+
// `input: ''` closes the child's stdin (EOF) so a stdin-reading agent (pi)
|
|
144
|
+
// can't block this synchronous call forever in a headless/piped context
|
|
145
|
+
// — the same hang the async judge hit, guarded here for `nemus -- "…"`.
|
|
146
|
+
input: '',
|
|
143
147
|
timeout: EXTRACTION_TIMEOUT_MS,
|
|
144
148
|
maxBuffer: 10 * 1024 * 1024,
|
|
145
149
|
});
|
package/src/commands/reflect.ts
CHANGED
|
@@ -4,13 +4,14 @@ import { outputJson, outputJsonError } from '../utils/output';
|
|
|
4
4
|
import { colorize } from '../utils/colors';
|
|
5
5
|
import {
|
|
6
6
|
gatherReflectionCorpus,
|
|
7
|
-
buildJudgePrompt,
|
|
8
7
|
parseReflectionReport,
|
|
9
8
|
REFLECT_SCHEMA,
|
|
10
9
|
ReflectionReport,
|
|
10
|
+
ReflectProgress,
|
|
11
11
|
Recommendation,
|
|
12
12
|
} from '../utils/reflect';
|
|
13
|
-
import {
|
|
13
|
+
import { analyzeCorpus, buildAnalysisPrompt } from '../utils/reflect-analyze';
|
|
14
|
+
import { runAgentJsonAsync } from '../utils/agent-judge';
|
|
14
15
|
|
|
15
16
|
export function registerReflectCommand(parent: Command) {
|
|
16
17
|
parent
|
|
@@ -18,6 +19,8 @@ export function registerReflectCommand(parent: Command) {
|
|
|
18
19
|
.alias('retro')
|
|
19
20
|
.description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
|
|
20
21
|
.option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
|
|
22
|
+
.option('--model <model>', 'Judge model override (agent-native pattern/id)')
|
|
23
|
+
.option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
|
|
21
24
|
.option('--json', 'Output the report as JSON')
|
|
22
25
|
.option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
|
|
23
26
|
.action(async (opts) => {
|
|
@@ -25,21 +28,25 @@ export function registerReflectCommand(parent: Command) {
|
|
|
25
28
|
});
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: boolean }) {
|
|
31
|
+
async function handleReflect(opts: { limit?: string; model?: string; thinking?: string; json?: boolean; dryRun?: boolean }) {
|
|
29
32
|
const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
|
|
30
33
|
try {
|
|
31
|
-
|
|
34
|
+
const showProgress = !opts.json && !opts.dryRun;
|
|
35
|
+
if (showProgress) {
|
|
32
36
|
logStep(`Analyzing your ${colorize(String(limit), 'cyan')} most recent workspaces…`);
|
|
33
|
-
logInfo('Reading sessions and distilling prompts + failures…');
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
const corpus = await gatherReflectionCorpus(limit);
|
|
39
|
+
const corpus = await gatherReflectionCorpus(limit, showProgress ? printProgress : undefined);
|
|
37
40
|
const withSessions = corpus.workspaces.filter((w) => w.session).length;
|
|
38
|
-
|
|
41
|
+
// A script does the heavy analysis (clustering failures, counting tools,
|
|
42
|
+
// spotting correction loops); the LLM only ever sees these compact facts,
|
|
43
|
+
// so the judge call stays small + fast regardless of workspace count.
|
|
44
|
+
const analysis = analyzeCorpus(corpus);
|
|
45
|
+
const prompt = buildAnalysisPrompt(analysis);
|
|
39
46
|
|
|
40
47
|
if (opts.dryRun) {
|
|
41
|
-
// No LLM call — surface exactly what the judge
|
|
42
|
-
if (opts.json) outputJson({
|
|
48
|
+
// No LLM call — surface the computed facts + exactly what the judge sees.
|
|
49
|
+
if (opts.json) outputJson({ analysis, prompt });
|
|
43
50
|
else {
|
|
44
51
|
process.stdout.write(prompt + '\n');
|
|
45
52
|
}
|
|
@@ -53,8 +60,21 @@ async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: bo
|
|
|
53
60
|
process.exit(1);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
// The judge shells the user's own agent and can take minutes; run it async
|
|
64
|
+
// (non-blocking) so a live spinner shows it's alive, not hung. Timeout is
|
|
65
|
+
// overridable for slow local models.
|
|
66
|
+
const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
|
|
67
|
+
const model = opts.model ?? process.env.NEMUS_JUDGE_MODEL ?? undefined;
|
|
68
|
+
const thinking = opts.thinking ?? process.env.NEMUS_JUDGE_THINKING ?? undefined;
|
|
69
|
+
const stopSpinner = opts.json
|
|
70
|
+
? () => {}
|
|
71
|
+
: startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = await runAgentJsonAsync(prompt, { schema: REFLECT_SCHEMA, timeoutMs, model, thinking });
|
|
75
|
+
} finally {
|
|
76
|
+
stopSpinner();
|
|
77
|
+
}
|
|
58
78
|
const report = parseReflectionReport(parsed);
|
|
59
79
|
|
|
60
80
|
if (opts.json) {
|
|
@@ -83,6 +103,46 @@ const KIND_LABEL: Record<Recommendation['kind'], string> = {
|
|
|
83
103
|
other: 'Other',
|
|
84
104
|
};
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A minimal stderr spinner with elapsed seconds. Returns a stop() that clears
|
|
108
|
+
* the line. No-op (single log line) when stderr isn't a TTY (piped/CI), so it
|
|
109
|
+
* never pollutes captured output. Kept local + tiny — no new dependency.
|
|
110
|
+
*/
|
|
111
|
+
function startSpinner(text: string): () => void {
|
|
112
|
+
if (!process.stderr.isTTY) {
|
|
113
|
+
logInfo(text);
|
|
114
|
+
return () => {};
|
|
115
|
+
}
|
|
116
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
let i = 0;
|
|
119
|
+
const render = () => {
|
|
120
|
+
const secs = Math.floor((Date.now() - start) / 1000);
|
|
121
|
+
process.stderr.write(`\r${colorize(frames[(i = (i + 1) % frames.length)], 'cyan')} ${text} ${colorize(`(${secs}s)`, 'dim')}`);
|
|
122
|
+
};
|
|
123
|
+
render();
|
|
124
|
+
const timer = setInterval(render, 100);
|
|
125
|
+
timer.unref?.(); // never keep the process alive on our account
|
|
126
|
+
return () => {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
process.stderr.write('\r' + ' '.repeat(text.length + 24) + '\r');
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Live per-workspace line during the gather phase (to stderr — stdout stays
|
|
133
|
+
* reserved for the report / JSON). */
|
|
134
|
+
function printProgress(p: ReflectProgress): void {
|
|
135
|
+
const n = colorize(`${p.index + 1}/${p.total}`, 'dim');
|
|
136
|
+
const d = p.digest.session;
|
|
137
|
+
if (!d) {
|
|
138
|
+
process.stderr.write(` ${colorize('·', 'dim')} ${n} ${p.digest.name} ${colorize('— no session', 'dim')}\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const failures = `${d.errors.length} ${d.errors.length === 1 ? 'failure' : 'failures'}`;
|
|
142
|
+
const stats = colorize(`${d.turns} turns · ${d.userPrompts.length} prompts · ${failures}`, 'dim');
|
|
143
|
+
process.stderr.write(` ${colorize('✓', 'green')} ${n} ${p.digest.name} ${stats}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
86
146
|
function priorityBadge(p: Recommendation['priority']): string {
|
|
87
147
|
if (p === 'high') return colorize('● high', 'red');
|
|
88
148
|
if (p === 'medium') return colorize('● med', 'yellow');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseAgentJson, runAgentRaw } from './agent-judge';
|
|
2
|
+
import { parseAgentJson, runAgentRaw, runAgentRawAsync, agentAttempts, spawnCollect } from './agent-judge';
|
|
3
3
|
|
|
4
4
|
describe('parseAgentJson', () => {
|
|
5
5
|
it('unwraps the common agent envelopes and shapes', () => {
|
|
@@ -16,6 +16,75 @@ describe('parseAgentJson', () => {
|
|
|
16
16
|
});
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
describe('spawnCollect (stdin-hang regression)', () => {
|
|
20
|
+
it('gives the child stdin EOF so a stdin-reading process does NOT hang', async () => {
|
|
21
|
+
// `cat` with no args reads stdin to EOF. If stdin were an open pipe (the old
|
|
22
|
+
// execFile default) this would block until the timeout and reject; with
|
|
23
|
+
// stdin ignored it gets immediate EOF and exits 0 fast. 2s timeout << any hang.
|
|
24
|
+
const out = await spawnCollect('cat', [], { timeout: 2000, maxBuffer: 1 << 20 });
|
|
25
|
+
expect(out).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('captures stdout and rejects on non-zero exit', async () => {
|
|
29
|
+
await expect(spawnCollect('node', ['-e', 'process.stdout.write("hi")'], { timeout: 5000, maxBuffer: 1 << 20 })).resolves.toBe('hi');
|
|
30
|
+
await expect(spawnCollect('node', ['-e', 'process.exit(3)'], { timeout: 5000, maxBuffer: 1 << 20 })).rejects.toThrow(/exit 3/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('agentAttempts', () => {
|
|
35
|
+
it('claude: preferred (schema + lean flags) then a plain fallback', () => {
|
|
36
|
+
const a = agentAttempts('claude', 'P', { schema: '{"type":"object"}' });
|
|
37
|
+
expect(a[0]).toEqual({ cmd: 'claude', args: expect.arrayContaining(['-p', 'P', '--output-format', 'json', '--json-schema', '{"type":"object"}']) });
|
|
38
|
+
expect(a[1]).toEqual({ cmd: 'claude', args: ['-p', 'P'] });
|
|
39
|
+
});
|
|
40
|
+
it('pi: lean then plain; opencode: single run', () => {
|
|
41
|
+
const pi = agentAttempts('pi', 'P');
|
|
42
|
+
expect(pi[0].args).toEqual(expect.arrayContaining(['--no-tools', '--no-skills', '-p', 'P']));
|
|
43
|
+
expect(pi[1]).toEqual({ cmd: 'pi', args: ['-p', 'P'] });
|
|
44
|
+
expect(agentAttempts('opencode', 'P')).toEqual([{ cmd: 'opencode', args: ['run', 'P'] }]);
|
|
45
|
+
});
|
|
46
|
+
it('threads --model (all) + --thinking (pi only); fallback keeps model, drops --thinking', () => {
|
|
47
|
+
const pi = agentAttempts('pi', 'P', { model: 'haiku', thinking: 'low' });
|
|
48
|
+
expect(pi[0].args).toEqual(expect.arrayContaining(['--model', 'haiku', '--thinking', 'low', '-p', 'P']));
|
|
49
|
+
// Safety net: model stays (stable flag), --thinking is dropped so an old pi
|
|
50
|
+
// that rejects --thinking still has a working fallback.
|
|
51
|
+
expect(pi[1].args).toEqual(['--model', 'haiku', '-p', 'P']);
|
|
52
|
+
expect(pi[1].args).not.toContain('--thinking');
|
|
53
|
+
const cl = agentAttempts('claude', 'P', { model: 'sonnet' });
|
|
54
|
+
expect(cl[0].args).toEqual(expect.arrayContaining(['--model', 'sonnet']));
|
|
55
|
+
expect(cl[0].args).not.toContain('--thinking'); // thinking is pi-only
|
|
56
|
+
expect(agentAttempts('opencode', 'P', { model: 'gpt' })[0].args).toEqual(['run', 'P', '--model', 'gpt']);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('runAgentRaw thinking default', () => {
|
|
61
|
+
it('applies the low-thinking default for pi', () => {
|
|
62
|
+
let seen: string[] = [];
|
|
63
|
+
runAgentRaw('P', { agentType: 'pi', exec: (cmd, args) => ((seen = [cmd, ...args]), 'ok') });
|
|
64
|
+
expect(seen).toEqual(expect.arrayContaining(['--thinking', 'low']));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('runAgentRawAsync', () => {
|
|
69
|
+
it('runs the preferred attempt and returns stdout', async () => {
|
|
70
|
+
const calls: string[][] = [];
|
|
71
|
+
const execAsync = async (cmd: string, args: string[]) => {
|
|
72
|
+
calls.push([cmd, ...args]);
|
|
73
|
+
return '{"ok":true}';
|
|
74
|
+
};
|
|
75
|
+
const out = await runAgentRawAsync('P', { agentType: 'pi', execAsync });
|
|
76
|
+
expect(out).toBe('{"ok":true}');
|
|
77
|
+
expect(calls).toHaveLength(1); // first attempt succeeded, no fallback
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('turns a timeout into an actionable error', async () => {
|
|
81
|
+
const execAsync = async () => {
|
|
82
|
+
throw Object.assign(new Error('spawn pi ETIMEDOUT'), { code: 'ETIMEDOUT', killed: true });
|
|
83
|
+
};
|
|
84
|
+
await expect(runAgentRawAsync('P', { agentType: 'pi', execAsync })).rejects.toThrow(/timed out.*NEMUS_JUDGE_TIMEOUT_MS/s);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
19
88
|
describe('runAgentRaw', () => {
|
|
20
89
|
it('claude: passes the schema + lean flags, falls back on a rejected flag', () => {
|
|
21
90
|
const calls: string[][] = [];
|