@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 CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-08-30
11
+
12
+ ### Fixed
13
+
14
+ - **`reflect` no longer hangs/times out — the real root cause was open stdin.**
15
+ The judge was spawned with its **stdin left as an open pipe** (`execFile`'s
16
+ default), so a stdin-reading agent like `pi` blocked forever waiting on input
17
+ and the run died at the timeout — regardless of prompt size or model. The
18
+ child now gets stdin `ignore` (async) / `input: ''` (sync) → immediate EOF,
19
+ and a real run drops from *timeout* to **~40s**. Regression-tested with a
20
+ stdin-reading child that would otherwise hang.
21
+
22
+ ### Changed
23
+
24
+ - **`reflect` now does the analysis in code and hands the LLM only compact
25
+ facts.** A new deterministic layer (`reflect-analyze.ts`) clusters recurring
26
+ failures into normalized **signatures** (paths/numbers/hashes stripped) with
27
+ counts + which workspaces they span, tallies tool usage, flags
28
+ correction/retry loops, and lists workspaces missing a context file. The judge
29
+ prompt is built from those aggregates, so it stays **~5KB regardless of how
30
+ many workspaces** are analyzed (was ~25KB and growing), the call is faster and
31
+ cheaper, less raw prompt text leaves your machine, and every recommendation is
32
+ grounded in a real count. Output shape (`--json`, the report) is unchanged;
33
+ `--dry-run` now shows the computed facts.
34
+ - **Faster judge + richer signal.** The judge now runs pi at **`--thinking low`**
35
+ by default (it's a mechanical facts→recommendations transform, not deep
36
+ reasoning) — overridable with `--thinking`/`NEMUS_JUDGE_THINKING` and
37
+ `--model`/`NEMUS_JUDGE_MODEL` (threaded through for claude/opencode where
38
+ supported; thinking is pi-only). The digest now includes **verbatim “re-steer”
39
+ quotes** (the user corrections/redirects that are the sharpest coaching signal)
40
+ and classifies each workspace's context file as **missing / boilerplate /
41
+ substantive** (distinguishing “has an AGENTS.md” from “has a *useful* one”), on
42
+ top of the error-signature clusters.
43
+
44
+ ## [0.3.1] - 2026-08-30
45
+
46
+ ### Fixed
47
+
48
+ - **`reflect` shows live progress instead of looking hung.** The gather phase
49
+ prints a **per-workspace line** as each session is read (`✓ 1/3 my-workspace
50
+ 635 turns · 12 prompts · 13 failures`); the judge runs **async** behind a live
51
+ spinner with elapsed seconds; the timeout is raised to 300s (overridable via
52
+ `NEMUS_JUDGE_TIMEOUT_MS`) with an **actionable** message on timeout.
53
+
10
54
  ## [0.3.0] - 2026-08-27
11
55
 
12
56
  ### Added
@@ -161,6 +161,10 @@ const EXTRACTION_TIMEOUT_MS = 120000;
161
161
  function runExtraction(cmd, fullArgs, fallbackArgs) {
162
162
  const exec = (args) => (0, child_process_1.execFileSync)(cmd, args, {
163
163
  encoding: 'utf-8',
164
+ // `input: ''` closes the child's stdin (EOF) so a stdin-reading agent (pi)
165
+ // can't block this synchronous call forever in a headless/piped context
166
+ // — the same hang the async judge hit, guarded here for `nemus -- "…"`.
167
+ input: '',
164
168
  timeout: EXTRACTION_TIMEOUT_MS,
165
169
  maxBuffer: 10 * 1024 * 1024,
166
170
  });
@@ -5,6 +5,7 @@ const logger_1 = require("../utils/logger");
5
5
  const output_1 = require("../utils/output");
6
6
  const colors_1 = require("../utils/colors");
7
7
  const reflect_1 = require("../utils/reflect");
8
+ const reflect_analyze_1 = require("../utils/reflect-analyze");
8
9
  const agent_judge_1 = require("../utils/agent-judge");
9
10
  function registerReflectCommand(parent) {
10
11
  parent
@@ -12,6 +13,8 @@ function registerReflectCommand(parent) {
12
13
  .alias('retro')
13
14
  .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
14
15
  .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
16
+ .option('--model <model>', 'Judge model override (agent-native pattern/id)')
17
+ .option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
15
18
  .option('--json', 'Output the report as JSON')
16
19
  .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
17
20
  .action(async (opts) => {
@@ -21,17 +24,21 @@ function registerReflectCommand(parent) {
21
24
  async function handleReflect(opts) {
22
25
  const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
23
26
  try {
24
- if (!opts.json && !opts.dryRun) {
27
+ const showProgress = !opts.json && !opts.dryRun;
28
+ if (showProgress) {
25
29
  (0, logger_1.logStep)(`Analyzing your ${(0, colors_1.colorize)(String(limit), 'cyan')} most recent workspaces…`);
26
- (0, logger_1.logInfo)('Reading sessions and distilling prompts + failures…');
27
30
  }
28
- const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit);
31
+ const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit, showProgress ? printProgress : undefined);
29
32
  const withSessions = corpus.workspaces.filter((w) => w.session).length;
30
- const prompt = (0, reflect_1.buildJudgePrompt)(corpus);
33
+ // A script does the heavy analysis (clustering failures, counting tools,
34
+ // spotting correction loops); the LLM only ever sees these compact facts,
35
+ // so the judge call stays small + fast regardless of workspace count.
36
+ const analysis = (0, reflect_analyze_1.analyzeCorpus)(corpus);
37
+ const prompt = (0, reflect_analyze_1.buildAnalysisPrompt)(analysis);
31
38
  if (opts.dryRun) {
32
- // No LLM call — surface exactly what the judge would see.
39
+ // No LLM call — surface the computed facts + exactly what the judge sees.
33
40
  if (opts.json)
34
- (0, output_1.outputJson)({ corpus, prompt });
41
+ (0, output_1.outputJson)({ analysis, prompt });
35
42
  else {
36
43
  process.stdout.write(prompt + '\n');
37
44
  }
@@ -45,9 +52,22 @@ async function handleReflect(opts) {
45
52
  (0, logger_1.logError)(msg);
46
53
  process.exit(1);
47
54
  }
48
- if (!opts.json)
49
- (0, logger_1.logInfo)(`Judging ${withSessions} session(s) with your configured agent…`);
50
- const parsed = (0, agent_judge_1.runAgentJson)(prompt, { schema: reflect_1.REFLECT_SCHEMA });
55
+ // The judge shells the user's own agent and can take minutes; run it async
56
+ // (non-blocking) so a live spinner shows it's alive, not hung. Timeout is
57
+ // overridable for slow local models.
58
+ const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
59
+ const model = opts.model ?? process.env.NEMUS_JUDGE_MODEL ?? undefined;
60
+ const thinking = opts.thinking ?? process.env.NEMUS_JUDGE_THINKING ?? undefined;
61
+ const stopSpinner = opts.json
62
+ ? () => { }
63
+ : startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
64
+ let parsed;
65
+ try {
66
+ parsed = await (0, agent_judge_1.runAgentJsonAsync)(prompt, { schema: reflect_1.REFLECT_SCHEMA, timeoutMs, model, thinking });
67
+ }
68
+ finally {
69
+ stopSpinner();
70
+ }
51
71
  const report = (0, reflect_1.parseReflectionReport)(parsed);
52
72
  if (opts.json) {
53
73
  (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
@@ -75,6 +95,44 @@ const KIND_LABEL = {
75
95
  workflow: 'Workflow',
76
96
  other: 'Other',
77
97
  };
98
+ /**
99
+ * A minimal stderr spinner with elapsed seconds. Returns a stop() that clears
100
+ * the line. No-op (single log line) when stderr isn't a TTY (piped/CI), so it
101
+ * never pollutes captured output. Kept local + tiny — no new dependency.
102
+ */
103
+ function startSpinner(text) {
104
+ if (!process.stderr.isTTY) {
105
+ (0, logger_1.logInfo)(text);
106
+ return () => { };
107
+ }
108
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
109
+ const start = Date.now();
110
+ let i = 0;
111
+ const render = () => {
112
+ const secs = Math.floor((Date.now() - start) / 1000);
113
+ process.stderr.write(`\r${(0, colors_1.colorize)(frames[(i = (i + 1) % frames.length)], 'cyan')} ${text} ${(0, colors_1.colorize)(`(${secs}s)`, 'dim')}`);
114
+ };
115
+ render();
116
+ const timer = setInterval(render, 100);
117
+ timer.unref?.(); // never keep the process alive on our account
118
+ return () => {
119
+ clearInterval(timer);
120
+ process.stderr.write('\r' + ' '.repeat(text.length + 24) + '\r');
121
+ };
122
+ }
123
+ /** Live per-workspace line during the gather phase (to stderr — stdout stays
124
+ * reserved for the report / JSON). */
125
+ function printProgress(p) {
126
+ const n = (0, colors_1.colorize)(`${p.index + 1}/${p.total}`, 'dim');
127
+ const d = p.digest.session;
128
+ if (!d) {
129
+ process.stderr.write(` ${(0, colors_1.colorize)('·', 'dim')} ${n} ${p.digest.name} ${(0, colors_1.colorize)('— no session', 'dim')}\n`);
130
+ return;
131
+ }
132
+ const failures = `${d.errors.length} ${d.errors.length === 1 ? 'failure' : 'failures'}`;
133
+ const stats = (0, colors_1.colorize)(`${d.turns} turns · ${d.userPrompts.length} prompts · ${failures}`, 'dim');
134
+ process.stderr.write(` ${(0, colors_1.colorize)('✓', 'green')} ${n} ${p.digest.name} ${stats}\n`);
135
+ }
78
136
  function priorityBadge(p) {
79
137
  if (p === 'high')
80
138
  return (0, colors_1.colorize)('● high', 'red');
@@ -1,11 +1,121 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_JUDGE_THINKING = void 0;
4
+ exports.spawnCollect = spawnCollect;
5
+ exports.agentAttempts = agentAttempts;
3
6
  exports.runAgentRaw = runAgentRaw;
7
+ exports.runAgentRawAsync = runAgentRawAsync;
4
8
  exports.runAgentJson = runAgentJson;
9
+ exports.runAgentJsonAsync = runAgentJsonAsync;
5
10
  exports.parseAgentJson = parseAgentJson;
6
11
  const child_process_1 = require("child_process");
7
12
  const agent_config_1 = require("./agent-config");
8
- const DEFAULT_TIMEOUT_MS = 180000; // judging N transcripts is heavier than extraction
13
+ /**
14
+ * Run a child to completion, capturing stdout, with stdin set to /dev/null.
15
+ *
16
+ * The stdin part is load-bearing: agents like `pi` block waiting on stdin if
17
+ * it's an open pipe (the default for execFile), which made the judge hang until
18
+ * the timeout regardless of prompt size or model speed. `stdio: ['ignore', …]`
19
+ * gives the child an immediate EOF, exactly like a non-interactive shell.
20
+ */
21
+ function spawnCollect(cmd, args, opts) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = (0, child_process_1.spawn)(cmd, args, {
24
+ stdio: ['ignore', 'pipe', 'pipe'],
25
+ timeout: opts.timeout,
26
+ killSignal: 'SIGKILL',
27
+ });
28
+ // Decode as UTF-8 at the stream boundary so a multi-byte char split across
29
+ // two chunks isn't corrupted (which would break JSON.parse downstream).
30
+ child.stdout.setEncoding('utf8');
31
+ child.stderr.setEncoding('utf8');
32
+ let stdout = '';
33
+ let stderr = '';
34
+ let overflow = false;
35
+ child.stdout.on('data', (d) => {
36
+ stdout += d;
37
+ if (stdout.length > opts.maxBuffer) {
38
+ overflow = true;
39
+ child.kill('SIGKILL');
40
+ }
41
+ });
42
+ child.stderr.on('data', (d) => {
43
+ stderr += d;
44
+ });
45
+ child.on('error', (e) => reject(e));
46
+ child.on('close', (code, signal) => {
47
+ if (overflow)
48
+ return reject(Object.assign(new Error('maxBuffer exceeded'), { stdout, stderr }));
49
+ if (signal)
50
+ return reject(Object.assign(new Error(`killed by ${signal}`), { killed: true, signal, stdout, stderr }));
51
+ if (code !== 0)
52
+ return reject(Object.assign(new Error(`exit ${code}`), { code, stdout, stderr }));
53
+ resolve(stdout);
54
+ });
55
+ });
56
+ }
57
+ /**
58
+ * Default thinking level for the judge. The judge is a mechanical transform
59
+ * (facts → recommendations), not deep reasoning, so a heavy default like Opus
60
+ * @ medium thinking just makes it slow. `low` keeps pi fast; override per-run.
61
+ */
62
+ exports.DEFAULT_JUDGE_THINKING = 'low';
63
+ /**
64
+ * The ordered invocation attempts for an agent (preferred → fallback), as pure
65
+ * data so both the sync and async runners share ONE flag ladder (and it's
66
+ * unit-testable without spawning anything). `--model` applies to all; pi also
67
+ * takes `--thinking` (the speed lever); both are ignored where unsupported.
68
+ */
69
+ function agentAttempts(agentType, prompt, opts = {}) {
70
+ const { schema, model, thinking } = opts;
71
+ if (agentType === 'claude') {
72
+ const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
73
+ if (model)
74
+ preferred.push('--model', model);
75
+ if (schema)
76
+ preferred.push('--json-schema', schema);
77
+ // Older claude may reject the newer flags — fall back to the plainest form.
78
+ const plain = ['-p', prompt];
79
+ if (model)
80
+ plain.push('--model', model);
81
+ return [{ cmd: 'claude', args: preferred }, { cmd: 'claude', args: plain }];
82
+ }
83
+ if (agentType === 'opencode') {
84
+ const args = ['run', prompt];
85
+ if (model)
86
+ args.push('--model', model);
87
+ return [{ cmd: 'opencode', args }];
88
+ }
89
+ // pi (and any other): run as lean as possible so a bloated env can't hang it.
90
+ const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
91
+ const modelArgs = model ? ['--model', model] : [];
92
+ const tune = thinking ? [...modelArgs, '--thinking', thinking] : modelArgs;
93
+ // Fallback keeps the stable --model but DROPS --thinking: --thinking is the
94
+ // newest flag and the most likely reason an older pi rejects the first
95
+ // attempt, so the safety net must not carry it (else both attempts fail).
96
+ return [
97
+ { cmd: 'pi', args: [...piLean, ...tune, '-p', prompt] },
98
+ { cmd: 'pi', args: [...modelArgs, '-p', prompt] },
99
+ ];
100
+ }
101
+ /** Resolve the attempt options for a run, applying the pi thinking default. */
102
+ function attemptOptions(agentType, opts) {
103
+ return {
104
+ schema: opts.schema,
105
+ model: opts.model,
106
+ thinking: opts.thinking ?? (agentType === 'pi' ? exports.DEFAULT_JUDGE_THINKING : undefined),
107
+ };
108
+ }
109
+ function wrapJudgeError(err, agentType) {
110
+ // A timeout is the common failure (big prompt + slow local model), so make it
111
+ // actionable instead of surfacing a raw `spawn … ETIMEDOUT`.
112
+ if (err?.killed || err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /ETIMEDOUT/.test(String(err?.message ?? ''))) {
113
+ return new Error(`agent judge (${agentType}) timed out. Try a smaller --limit, a faster agent, or raise the cap with NEMUS_JUDGE_TIMEOUT_MS.`);
114
+ }
115
+ const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
116
+ return new Error(`agent judge failed (${agentType}): ${detail}`);
117
+ }
118
+ const DEFAULT_TIMEOUT_MS = 300000; // judging N transcripts is heavier than extraction; a big prompt + slow model can run minutes
9
119
  const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
10
120
  /**
11
121
  * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
@@ -17,37 +127,42 @@ function runAgentRaw(prompt, opts = {}) {
17
127
  const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
18
128
  const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
19
129
  const exec = opts.exec ??
20
- ((cmd, args, o) => (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
21
- const attempt = (cmd, args) => exec(cmd, args, { timeout, maxBuffer });
22
- try {
23
- if (agentType === 'claude') {
24
- const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
25
- if (opts.schema)
26
- preferred.push('--json-schema', opts.schema);
27
- try {
28
- return attempt('claude', preferred);
29
- }
30
- catch {
31
- // Older claude may reject the newer flags — fall back to the plainest form.
32
- return attempt('claude', ['-p', prompt]);
33
- }
130
+ // `input: ''` closes the child's stdin (EOF) so a stdin-reading agent (pi)
131
+ // can't hang the synchronous call the sync twin of spawnCollect's fix.
132
+ ((cmd, args, o) => (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', input: '', timeout: o.timeout, maxBuffer: o.maxBuffer }));
133
+ const attempts = agentAttempts(agentType, prompt, attemptOptions(agentType, opts));
134
+ let lastErr;
135
+ for (const a of attempts) {
136
+ try {
137
+ return exec(a.cmd, a.args, { timeout, maxBuffer });
34
138
  }
35
- if (agentType === 'opencode') {
36
- return attempt('opencode', ['run', prompt]);
139
+ catch (err) {
140
+ lastErr = err; // try the next (fallback) form
37
141
  }
38
- // pi (and any other): run as lean as possible so a bloated env can't hang it.
39
- const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
142
+ }
143
+ throw wrapJudgeError(lastErr, agentType);
144
+ }
145
+ /**
146
+ * Non-blocking twin of {@link runAgentRaw}. Uses `execFile` (async) so the
147
+ * caller's event loop stays free — letting a spinner/progress UI animate while
148
+ * the judge (which can take minutes) runs. Prefer this in interactive commands.
149
+ */
150
+ async function runAgentRawAsync(prompt, opts = {}) {
151
+ const agentType = opts.agentType ?? (0, agent_config_1.getPrimaryAgent)().type;
152
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
153
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
154
+ const exec = opts.execAsync ?? ((cmd, args, o) => spawnCollect(cmd, args, o));
155
+ const attempts = agentAttempts(agentType, prompt, attemptOptions(agentType, opts));
156
+ let lastErr;
157
+ for (const a of attempts) {
40
158
  try {
41
- return attempt('pi', [...piLean, '-p', prompt]);
159
+ return await exec(a.cmd, a.args, { timeout, maxBuffer });
42
160
  }
43
- catch {
44
- return attempt('pi', ['-p', prompt]);
161
+ catch (err) {
162
+ lastErr = err; // try the next (fallback) form
45
163
  }
46
164
  }
47
- catch (err) {
48
- const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
49
- throw new Error(`agent judge failed (${agentType}): ${detail}`);
50
- }
165
+ throw wrapJudgeError(lastErr, agentType);
51
166
  }
52
167
  /**
53
168
  * Run the agent and parse its reply as JSON, tolerating the shapes different
@@ -58,6 +173,11 @@ function runAgentJson(prompt, opts = {}) {
58
173
  const raw = runAgentRaw(prompt, opts);
59
174
  return parseAgentJson(raw);
60
175
  }
176
+ /** Non-blocking twin of {@link runAgentJson}. */
177
+ async function runAgentJsonAsync(prompt, opts = {}) {
178
+ const raw = await runAgentRawAsync(prompt, opts);
179
+ return parseAgentJson(raw);
180
+ }
61
181
  /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
62
182
  function parseAgentJson(raw) {
63
183
  let text = raw.trim();
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeErrorSignature = normalizeErrorSignature;
4
+ exports.analyzeCorpus = analyzeCorpus;
5
+ exports.buildAnalysisPrompt = buildAnalysisPrompt;
6
+ const reflect_1 = require("./reflect");
7
+ // --------------------------------------------------------------- normalizing
8
+ /**
9
+ * Reduce a raw error string to a stable signature so near-identical failures
10
+ * cluster together: lowercase, drop quotes, replace filesystem paths, hashes,
11
+ * hex ids and bare numbers with placeholders, collapse whitespace, and cap the
12
+ * length. `"fatal: not a git repository (or any of the parent up to /Users/x)"`
13
+ * and the same from another path collapse to one signature.
14
+ */
15
+ function normalizeErrorSignature(raw) {
16
+ let s = (raw ?? '').toLowerCase();
17
+ s = s.replace(/[`'"]/g, ' ');
18
+ // Windows paths first (contain ':' and '\'), then unix paths.
19
+ s = s.replace(/[a-z]:\\[^\s]+/g, '<path>');
20
+ s = s.replace(/\/[^\s:)'"]+/g, '<path>');
21
+ // Long hex / uuids / sha-like tokens before bare numbers.
22
+ s = s.replace(/\b[0-9a-f]{7,}\b/g, '<hash>');
23
+ // No trailing \b, so a number glued to a unit ('4200ms', '9ms') still clusters.
24
+ s = s.replace(/\b\d[\d.,:]*/g, '<n>');
25
+ s = s.replace(/\s+/g, ' ').trim();
26
+ return s.slice(0, 100);
27
+ }
28
+ /**
29
+ * Turn a distilled corpus into aggregated facts. This is the "script processes
30
+ * the data" step — the LLM never sees the raw corpus, only the returned report.
31
+ */
32
+ function analyzeCorpus(corpus, opts = {}) {
33
+ const topErrorsK = opts.topErrors ?? 8;
34
+ const topToolsK = opts.topTools ?? 10;
35
+ const exampleChars = opts.exampleChars ?? 160;
36
+ const maxReSteer = opts.maxReSteer ?? 8;
37
+ const errorMap = new Map();
38
+ const toolMap = new Map();
39
+ const workspacesMissingContext = [];
40
+ const workspacesBoilerplateContext = [];
41
+ const reSteerSamples = [];
42
+ const workspaces = [];
43
+ let sessionsAnalyzed = 0;
44
+ let totalTurns = 0;
45
+ let correctionSignals = 0;
46
+ for (const ws of corpus.workspaces) {
47
+ if (ws.contextQuality === 'missing')
48
+ workspacesMissingContext.push(ws.name);
49
+ else if (ws.contextQuality === 'boilerplate')
50
+ workspacesBoilerplateContext.push(ws.name);
51
+ const s = ws.session;
52
+ if (!s) {
53
+ workspaces.push({ name: ws.name, turns: 0, failures: 0, contextQuality: ws.contextQuality });
54
+ continue;
55
+ }
56
+ sessionsAnalyzed++;
57
+ totalTurns += s.turns;
58
+ for (const t of s.tools)
59
+ toolMap.set(t, (toolMap.get(t) ?? 0) + 1);
60
+ correctionSignals += s.userPrompts.filter(reflect_1.isCorrectionPrompt).length;
61
+ for (const q of s.reSteerSamples) {
62
+ if (reSteerSamples.length < maxReSteer)
63
+ reSteerSamples.push(q);
64
+ }
65
+ // Per-workspace signature tally (drives the global map + this ws's topFailure).
66
+ const localSig = new Map();
67
+ for (const e of s.errors) {
68
+ const sig = normalizeErrorSignature(e);
69
+ if (!sig)
70
+ continue;
71
+ localSig.set(sig, (localSig.get(sig) ?? 0) + 1);
72
+ let entry = errorMap.get(sig);
73
+ if (!entry) {
74
+ entry = { count: 0, ws: new Set(), example: e.slice(0, exampleChars) };
75
+ errorMap.set(sig, entry);
76
+ }
77
+ entry.count++;
78
+ entry.ws.add(ws.name);
79
+ }
80
+ const topFailure = [...localSig.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
81
+ workspaces.push({ name: ws.name, turns: s.turns, failures: s.errors.length, contextQuality: ws.contextQuality, topFailure });
82
+ }
83
+ const topErrors = [...errorMap.entries()]
84
+ .map(([signature, v]) => ({ signature, count: v.count, workspaces: [...v.ws], example: v.example }))
85
+ // Most frequent first; break ties by cross-workspace spread (recurrence).
86
+ .sort((a, b) => b.count - a.count || b.workspaces.length - a.workspaces.length)
87
+ .slice(0, topErrorsK);
88
+ const topTools = [...toolMap.entries()]
89
+ .map(([tool, sessions]) => ({ tool, sessions }))
90
+ .sort((a, b) => b.sessions - a.sessions || a.tool.localeCompare(b.tool))
91
+ .slice(0, topToolsK);
92
+ return {
93
+ totalWorkspaces: corpus.workspaces.length,
94
+ sessionsAnalyzed,
95
+ totalTurns,
96
+ topErrors,
97
+ topTools,
98
+ correctionSignals,
99
+ reSteerSamples,
100
+ workspacesMissingContext,
101
+ workspacesBoilerplateContext,
102
+ availableSkills: corpus.availableSkills,
103
+ workspaces,
104
+ };
105
+ }
106
+ // --------------------------------------------------------------- judge prompt
107
+ /**
108
+ * Build the judge prompt from the pre-computed facts. Compact by construction
109
+ * (a handful of clusters + counts), so the LLM call stays small and fast no
110
+ * matter how many workspaces were analyzed. Output contract is unchanged
111
+ * (REFLECT_SCHEMA), so parsing/rendering are shared with the old path.
112
+ */
113
+ function buildAnalysisPrompt(a) {
114
+ const L = [];
115
+ L.push('You are an expert reviewer ("LLM as a judge"). A script has already analyzed an engineer\'s', 'recent AI coding-agent sessions and distilled them into the FACTS below. Do not ask for the', 'raw transcripts — reason only from these facts.', '', '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/workflow habits. Ground every recommendation in a fact below', '(a recurring failure, a correction loop, a missing context file). Prefer a few high-signal', 'items over many generic ones. Include a concrete `example` snippet for skills/context rules.', '', `Sessions analyzed: ${a.sessionsAnalyzed} across ${a.totalWorkspaces} workspaces (${a.totalTurns} total turns).`, `Installed skills (don't re-suggest; find genuine gaps): ${a.availableSkills.join(', ') || '(none)'}`, `User correction/retry signals: ${a.correctionSignals} prompt(s) looked like corrections.`, `Workspaces with NO context file (AGENTS.md/CLAUDE.md): ${a.workspacesMissingContext.join(', ') || '(none)'}`, `Workspaces whose context file is just boilerplate/template: ${a.workspacesBoilerplateContext.join(', ') || '(none)'}`);
116
+ if (a.reSteerSamples.length) {
117
+ L.push('', 'Verbatim user corrections/re-steers (the sharpest signal — quote/act on these):');
118
+ for (const q of a.reSteerSamples)
119
+ L.push(` - “${q.replace(/\n/g, ' ')}”`);
120
+ }
121
+ L.push('', 'Top recurring failures (count × workspaces — example):');
122
+ if (a.topErrors.length === 0)
123
+ L.push(' (none captured)');
124
+ for (const e of a.topErrors) {
125
+ L.push(` - [${e.count}× in ${e.workspaces.length} ws] ${e.signature}`);
126
+ L.push(` e.g. ${e.example.replace(/\n/g, ' ')}`);
127
+ }
128
+ L.push('', `Most-used tools: ${a.topTools.map((t) => `${t.tool}(${t.sessions})`).join(', ') || '(none)'}`);
129
+ L.push('', 'Per-workspace:');
130
+ for (const w of a.workspaces) {
131
+ const top = w.topFailure ? ` · top failure: ${w.topFailure}` : '';
132
+ L.push(` - ${w.name}: ${w.turns} turns, ${w.failures} failures, context:${w.contextQuality}${top}`);
133
+ }
134
+ L.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)}]}');
135
+ return L.join('\n');
136
+ }