@nemus-cli/nemus 0.2.13 → 0.3.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 CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **`nemus reflect` (alias `retro`) — LLM-as-a-judge retrospective.** Analyzes
15
+ your most recent workspaces (default 10) by reading their agent session
16
+ transcripts, distilling the human prompts + tool failures + tools used, and
17
+ asking your own configured agent (claude/pi/opencode — no API key of ours) to
18
+ recommend concrete setup improvements: which **skills** to add and where,
19
+ missing **AGENTS.md/context** rules, missing **connectivity/smoke tests**, and
20
+ **prompt/workflow** habits to change — each with a priority and a concrete
21
+ example snippet. Reads both Claude and pi transcript formats. Flags:
22
+ `--limit <n>`, `--json` (structured report, same `{ok:false,error}` failure
23
+ contract as the other JSON commands), and `--dry-run` (print the assembled
24
+ corpus + judge prompt without calling the agent). Idea courtesy of
25
+ **@lightpriest** — thank you for the great suggestion!
26
+
10
27
  ## [0.2.13] - 2026-08-27
11
28
 
12
29
  ### Added
package/README.md CHANGED
@@ -280,6 +280,23 @@ nemus snapshot save ws # (ss) capture exact branches/commits/dirty state
280
280
  nemus snapshot restore <id> # (sr)
281
281
  ```
282
282
 
283
+ ### Reflect — improve your setup over time
284
+
285
+ ```bash
286
+ nemus reflect # (retro) analyze your last 10 workspaces' sessions
287
+ nemus reflect --limit 5 # narrow the window
288
+ nemus reflect --json # structured report for tooling
289
+ nemus reflect --dry-run # show what the judge sees, without calling the agent
290
+ ```
291
+
292
+ `reflect` reads your recent agent **session transcripts** (Claude + pi), distills
293
+ the prompts you sent, the failures the agent hit, and the tools it used, then asks
294
+ **your own configured agent** (LLM-as-a-judge — no extra API key) to recommend
295
+ concrete improvements: which **skills** to add and where, missing
296
+ **AGENTS.md/context** rules, missing **connectivity/smoke tests**, and
297
+ **prompt/workflow** habits — each with a priority and an example snippet. It's a
298
+ fast retrospective on *how you drive the agent*, so next time works better.
299
+
283
300
  ### AI assistant
284
301
 
285
302
  ```bash
@@ -41,6 +41,7 @@ exports.extractIntent = extractIntent;
41
41
  exports.run = run;
42
42
  exports.main = main;
43
43
  const child_process_1 = require("child_process");
44
+ const agent_judge_1 = require("../utils/agent-judge");
44
45
  const util_1 = require("util");
45
46
  const fs = __importStar(require("fs"));
46
47
  const path = __importStar(require("path"));
@@ -267,35 +268,22 @@ async function extractIntent(prompt) {
267
268
  ];
268
269
  result = runExtraction('pi', [...piLean, ...piCore], piCore);
269
270
  }
270
- // Strip markdown code fences if present (Pi may wrap JSON in ```json...```)
271
- let jsonStr = result.trim();
272
- const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
273
- if (fenceMatch) {
274
- jsonStr = fenceMatch[1].trim();
275
- }
276
- const parsed = JSON.parse(jsonStr);
277
- // Handle different output formats:
278
- // - Claude: { structured_output: {...} } or { result: "..." }
279
- // - Pi: may return the object directly or wrap it
271
+ // Unwrap the agent's reply (code fences + the structured_output / result-string
272
+ // / result-object / bare-object envelopes) with the shared parser, so this and
273
+ // the reflect judge can't drift when a new agent shape is learned. The
274
+ // extraction-specific INVOCATION (lean flags + tailored timeout/auth errors)
275
+ // deliberately stays here — those messages are part of the `nemus --` UX.
280
276
  let intent;
281
- if (parsed.structured_output) {
282
- intent = parsed.structured_output;
283
- }
284
- else if (typeof parsed.result === 'string' && parsed.result) {
285
- try {
286
- intent = JSON.parse(parsed.result);
287
- }
288
- catch { /* ignore */ }
289
- }
290
- else if (typeof parsed.result === 'object' && parsed.result !== null) {
291
- intent = parsed.result;
277
+ try {
278
+ const parsed = (0, agent_judge_1.parseAgentJson)(result);
279
+ if (parsed && typeof parsed === 'object')
280
+ intent = parsed;
292
281
  }
293
- else if (parsed.workspaceName || parsed.repos || parsed.remainingIntent !== undefined) {
294
- // Pi may return the extracted object directly
295
- intent = parsed;
282
+ catch {
283
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
296
284
  }
297
285
  if (!intent) {
298
- throw new Error(`Could not extract intent from agent response. Parsed: ${JSON.stringify(parsed).slice(0, 200)}`);
286
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
299
287
  }
300
288
  // Coerce/validate field types so malformed model output can't crash the
301
289
  // downstream .trim()/sanitize path (a stringly-typed workspaceName or a
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerReflectCommand = registerReflectCommand;
4
+ const logger_1 = require("../utils/logger");
5
+ const output_1 = require("../utils/output");
6
+ const colors_1 = require("../utils/colors");
7
+ const reflect_1 = require("../utils/reflect");
8
+ const agent_judge_1 = require("../utils/agent-judge");
9
+ function registerReflectCommand(parent) {
10
+ parent
11
+ .command('reflect')
12
+ .alias('retro')
13
+ .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
14
+ .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
15
+ .option('--json', 'Output the report as JSON')
16
+ .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
17
+ .action(async (opts) => {
18
+ await handleReflect(opts);
19
+ });
20
+ }
21
+ async function handleReflect(opts) {
22
+ const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
23
+ try {
24
+ if (!opts.json && !opts.dryRun) {
25
+ (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
+ }
28
+ const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit);
29
+ const withSessions = corpus.workspaces.filter((w) => w.session).length;
30
+ const prompt = (0, reflect_1.buildJudgePrompt)(corpus);
31
+ if (opts.dryRun) {
32
+ // No LLM call — surface exactly what the judge would see.
33
+ if (opts.json)
34
+ (0, output_1.outputJson)({ corpus, prompt });
35
+ else {
36
+ process.stdout.write(prompt + '\n');
37
+ }
38
+ return;
39
+ }
40
+ if (withSessions === 0) {
41
+ const msg = 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
42
+ if (opts.json)
43
+ (0, output_1.outputJsonError)(msg);
44
+ else
45
+ (0, logger_1.logError)(msg);
46
+ process.exit(1);
47
+ }
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 });
51
+ const report = (0, reflect_1.parseReflectionReport)(parsed);
52
+ if (opts.json) {
53
+ (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
54
+ return;
55
+ }
56
+ printReport(report, corpus.workspaces.length, withSessions);
57
+ }
58
+ catch (error) {
59
+ const msg = error instanceof Error ? error.message : 'reflect failed';
60
+ if (opts.json)
61
+ (0, output_1.outputJsonError)(msg);
62
+ else {
63
+ (0, logger_1.logError)('Failed to analyze sessions');
64
+ (0, logger_1.logError)(msg);
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+ const KIND_LABEL = {
70
+ skill: 'Skill',
71
+ context: 'Context/AGENTS.md',
72
+ test: 'Test',
73
+ prompt: 'Prompt',
74
+ connectivity: 'Connectivity',
75
+ workflow: 'Workflow',
76
+ other: 'Other',
77
+ };
78
+ function priorityBadge(p) {
79
+ if (p === 'high')
80
+ return (0, colors_1.colorize)('● high', 'red');
81
+ if (p === 'medium')
82
+ return (0, colors_1.colorize)('● med', 'yellow');
83
+ return (0, colors_1.colorize)('● low', 'gray');
84
+ }
85
+ function printReport(report, workspaces, analyzed) {
86
+ console.log('');
87
+ console.log((0, colors_1.colorize)(' Reflection', 'bright') + (0, colors_1.colorize)(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
88
+ console.log((0, colors_1.colorize)(' ' + '─'.repeat(56), 'dim'));
89
+ if (report.summary) {
90
+ console.log('\n ' + report.summary.replace(/\n/g, '\n '));
91
+ }
92
+ if (report.recommendations.length === 0) {
93
+ console.log('\n ' + (0, colors_1.colorize)('No specific recommendations — looks solid.', 'green') + '\n');
94
+ return;
95
+ }
96
+ // High priority first.
97
+ const order = { high: 0, medium: 1, low: 2 };
98
+ const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
99
+ console.log('');
100
+ for (const r of recs) {
101
+ const target = r.target ? (0, colors_1.colorize)(` [${r.target}]`, 'cyan') : '';
102
+ console.log(` ${priorityBadge(r.priority)} ${(0, colors_1.colorize)(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
103
+ if (r.detail)
104
+ console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
105
+ if (r.example) {
106
+ console.log((0, colors_1.colorize)(' example:', 'dim'));
107
+ console.log((0, colors_1.colorize)(r.example.replace(/^/gm, ' '), 'dim'));
108
+ }
109
+ console.log('');
110
+ }
111
+ }
package/dist/program.js CHANGED
@@ -89,6 +89,7 @@ const save_context_1 = require("./commands/save-context");
89
89
  const migrate_1 = require("./commands/migrate");
90
90
  const report_bug_1 = require("./commands/report-bug");
91
91
  const completion_1 = require("./commands/completion");
92
+ const reflect_1 = require("./commands/reflect");
92
93
  (0, create_1.registerCreateCommand)(exports.program);
93
94
  (0, list_1.registerListCommand)(exports.program);
94
95
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -113,6 +114,7 @@ const completion_1 = require("./commands/completion");
113
114
  (0, migrate_1.registerMigrateCommand)(exports.program);
114
115
  (0, report_bug_1.registerReportBugCommand)(exports.program);
115
116
  (0, completion_1.registerCompletionCommand)(exports.program);
117
+ (0, reflect_1.registerReflectCommand)(exports.program);
116
118
  // Register TUI (delegates to existing Ink/React implementation)
117
119
  exports.program
118
120
  .command('tui')
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runAgentRaw = runAgentRaw;
4
+ exports.runAgentJson = runAgentJson;
5
+ exports.parseAgentJson = parseAgentJson;
6
+ const child_process_1 = require("child_process");
7
+ const agent_config_1 = require("./agent-config");
8
+ const DEFAULT_TIMEOUT_MS = 180000; // judging N transcripts is heavier than extraction
9
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
10
+ /**
11
+ * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
12
+ * on timeout / non-zero exit. Kept separate from parsing so callers can inspect
13
+ * raw output (e.g. `--dry-run`, debugging).
14
+ */
15
+ function runAgentRaw(prompt, opts = {}) {
16
+ const agentType = opts.agentType ?? (0, agent_config_1.getPrimaryAgent)().type;
17
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
18
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
19
+ 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
+ }
34
+ }
35
+ if (agentType === 'opencode') {
36
+ return attempt('opencode', ['run', prompt]);
37
+ }
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'];
40
+ try {
41
+ return attempt('pi', [...piLean, '-p', prompt]);
42
+ }
43
+ catch {
44
+ return attempt('pi', ['-p', prompt]);
45
+ }
46
+ }
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
+ }
51
+ }
52
+ /**
53
+ * Run the agent and parse its reply as JSON, tolerating the shapes different
54
+ * agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
55
+ * or a bare object. Returns `unknown`; callers validate/normalize their shape.
56
+ */
57
+ function runAgentJson(prompt, opts = {}) {
58
+ const raw = runAgentRaw(prompt, opts);
59
+ return parseAgentJson(raw);
60
+ }
61
+ /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
62
+ function parseAgentJson(raw) {
63
+ let text = raw.trim();
64
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
65
+ if (fence)
66
+ text = fence[1].trim();
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(text);
70
+ }
71
+ catch {
72
+ // Last resort: grab the outermost {...} span.
73
+ const span = text.match(/\{[\s\S]*\}/);
74
+ if (!span)
75
+ throw new Error('agent did not return JSON');
76
+ parsed = JSON.parse(span[0]);
77
+ }
78
+ // Unwrap the common agent envelopes.
79
+ if (parsed && typeof parsed === 'object') {
80
+ if (parsed.structured_output && typeof parsed.structured_output === 'object')
81
+ return parsed.structured_output;
82
+ if (typeof parsed.result === 'string') {
83
+ try {
84
+ return JSON.parse(parsed.result);
85
+ }
86
+ catch {
87
+ /* fall through — result was plain text, return the envelope */
88
+ }
89
+ }
90
+ if (parsed.result && typeof parsed.result === 'object')
91
+ return parsed.result;
92
+ }
93
+ return parsed;
94
+ }
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.REFLECT_SCHEMA = void 0;
37
+ exports.distillTranscript = distillTranscript;
38
+ exports.findLatestTranscriptFile = findLatestTranscriptFile;
39
+ exports.gatherReflectionCorpus = gatherReflectionCorpus;
40
+ exports.buildJudgePrompt = buildJudgePrompt;
41
+ exports.parseReflectionReport = parseReflectionReport;
42
+ const fs = __importStar(require("fs/promises"));
43
+ const path = __importStar(require("path"));
44
+ const workspace_meta_1 = require("./workspace-meta");
45
+ const agent_config_1 = require("./agent-config");
46
+ const claude_sessions_1 = require("./claude-sessions");
47
+ // --------------------------------------------------------- transcript distill
48
+ const MAX_PROMPTS = 25;
49
+ const MAX_ERRORS = 25;
50
+ const PROMPT_CHARS = 600;
51
+ const ERROR_CHARS = 300;
52
+ /** Flatten a message `content` (string or content-block array) to plain text. */
53
+ function contentToText(content) {
54
+ if (typeof content === 'string')
55
+ return content;
56
+ if (Array.isArray(content)) {
57
+ return content
58
+ .map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
59
+ .filter(Boolean)
60
+ .join('\n');
61
+ }
62
+ return '';
63
+ }
64
+ // Only used as a FALLBACK when a tool result carries no explicit error flag.
65
+ // Kept to strong failure signals so a successful grep/log line for the word
66
+ // "error", or a passing test named “…error…”, isn't mistaken for a failure.
67
+ const ERROR_RE = /\b(fatal|failed|failure|exception|traceback|denied|not a git repository|timed out|exit code\s+[1-9])\b/i;
68
+ /**
69
+ * Decide whether a tool result is a failure: trust the explicit `isError` flag
70
+ * when present (true => failure, false => success), and only guess from the
71
+ * text when there's no flag at all. This keeps the judge's “evidence” to real
72
+ * failures instead of any output that happens to contain the word “error”.
73
+ */
74
+ function isToolFailure(flag, text) {
75
+ if (flag === true)
76
+ return true;
77
+ if (flag === false)
78
+ return false;
79
+ return ERROR_RE.test(text);
80
+ }
81
+ /**
82
+ * Distill a raw `.jsonl` transcript into the signals a judge needs: the human
83
+ * prompts, tool failures, and which tools ran. Pure (operates on file content),
84
+ * defensive about the several line shapes Claude/pi emit, and bounded so a huge
85
+ * transcript can't blow the prompt budget.
86
+ */
87
+ function distillTranscript(raw, meta) {
88
+ const userPrompts = [];
89
+ const errors = [];
90
+ const tools = new Set();
91
+ let turns = 0;
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ let obj;
97
+ try {
98
+ obj = JSON.parse(trimmed);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ const msg = obj.message ?? obj;
104
+ const role = msg?.role ?? obj?.type;
105
+ const content = msg?.content;
106
+ // Assistant turn + tool uses. Claude uses `tool_use` blocks; pi uses `toolCall`.
107
+ if (role === 'assistant') {
108
+ turns++;
109
+ if (Array.isArray(content)) {
110
+ for (const b of content) {
111
+ if (b && (b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string')
112
+ tools.add(b.name);
113
+ }
114
+ }
115
+ }
116
+ // Tool results, two shapes:
117
+ // - pi: a top-level message with role 'toolResult' (+ toolName, content).
118
+ // - Claude: a `tool_result` block inside a user message's content array.
119
+ if (role === 'toolResult') {
120
+ if (typeof msg.toolName === 'string')
121
+ tools.add(msg.toolName);
122
+ const text = contentToText(content);
123
+ if (isToolFailure(msg.isError ?? msg.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
124
+ errors.push(text.trim().slice(0, ERROR_CHARS));
125
+ }
126
+ }
127
+ if (Array.isArray(content)) {
128
+ for (const b of content) {
129
+ if (b && b.type === 'tool_result') {
130
+ const text = contentToText(b.content);
131
+ if (isToolFailure(b.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
132
+ errors.push(text.trim().slice(0, ERROR_CHARS));
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // Human prompts: a user message that carries actual text (not a tool_result echo).
138
+ if (role === 'user') {
139
+ const isToolResultOnly = Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === 'tool_result');
140
+ if (!isToolResultOnly) {
141
+ const text = contentToText(content).trim();
142
+ if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
143
+ userPrompts.push(text.slice(0, PROMPT_CHARS));
144
+ }
145
+ }
146
+ }
147
+ }
148
+ return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
149
+ }
150
+ // --------------------------------------------------------- corpus gathering
151
+ /** Locate the most recent `.jsonl` transcript for a workspace under an agent. */
152
+ async function findLatestTranscriptFile(sessionProjectsDir, workspacePath, agentType) {
153
+ if (agentType !== 'claude' && agentType !== 'pi')
154
+ return null;
155
+ const projDir = path.join(sessionProjectsDir, (0, claude_sessions_1.pathToProjectDirName)(workspacePath, agentType));
156
+ let entries;
157
+ try {
158
+ entries = await fs.readdir(projDir);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ const jsonl = entries.filter((f) => f.endsWith('.jsonl'));
164
+ if (jsonl.length === 0)
165
+ return null;
166
+ const stats = await Promise.all(jsonl.map(async (f) => {
167
+ try {
168
+ return { f, mtime: (await fs.stat(path.join(projDir, f))).mtime.getTime() };
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }));
174
+ const best = stats.filter((s) => !!s).sort((a, b) => b.mtime - a.mtime)[0];
175
+ return best ? path.join(projDir, best.f) : null;
176
+ }
177
+ const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
178
+ /** Read + distill the transcript for a specific discovered session. Prefers the
179
+ * exact `<sessionId>.jsonl`; falls back to the latest transcript in that
180
+ * project dir if the exact file has been rotated away. */
181
+ async function readDigestForSession(s) {
182
+ if (s.agentType !== 'claude' && s.agentType !== 'pi')
183
+ return null;
184
+ const projectsDir = (0, agent_config_1.getAgentPaths)(s.agentType).sessionProjectsDir;
185
+ const exact = path.join(projectsDir, (0, claude_sessions_1.pathToProjectDirName)(s.workspacePath, s.agentType), `${s.sessionId}.jsonl`);
186
+ let file = exact;
187
+ try {
188
+ await fs.access(exact);
189
+ }
190
+ catch {
191
+ file = await findLatestTranscriptFile(projectsDir, s.workspacePath, s.agentType);
192
+ }
193
+ if (!file)
194
+ return null;
195
+ let raw;
196
+ try {
197
+ raw = await fs.readFile(file, 'utf-8');
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (raw.length > MAX_TRANSCRIPT_BYTES)
203
+ raw = raw.slice(raw.length - MAX_TRANSCRIPT_BYTES); // keep the tail (most recent)
204
+ return distillTranscript(raw, { sessionId: s.sessionId, agentType: s.agentType });
205
+ }
206
+ async function listAvailableSkills() {
207
+ const names = new Set();
208
+ for (const dir of (0, agent_config_1.getSkillsTargetDirs)()) {
209
+ try {
210
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
211
+ if (entry.isDirectory())
212
+ names.add(entry.name);
213
+ else if (entry.name.endsWith('.md'))
214
+ names.add(entry.name.replace(/\.md$/, ''));
215
+ }
216
+ }
217
+ catch {
218
+ /* dir may not exist */
219
+ }
220
+ }
221
+ return [...names].sort();
222
+ }
223
+ async function contextFilesFor(workspacePath) {
224
+ const present = [];
225
+ for (const name of (0, agent_config_1.getAllKnownContextFileNames)()) {
226
+ try {
227
+ await fs.access(path.join(workspacePath, name));
228
+ present.push(name);
229
+ }
230
+ catch {
231
+ /* not present */
232
+ }
233
+ }
234
+ return present;
235
+ }
236
+ /**
237
+ * Build the corpus the judge reasons over: the `limit` most **recently active**
238
+ * workspaces (by their latest agent session, not creation date — a retrospective
239
+ * is about recent *work*), each with its repos, context files, and distilled
240
+ * session, plus the globally-available skills.
241
+ */
242
+ async function gatherReflectionCorpus(limit) {
243
+ const [sessions, workspaces, availableSkills] = await Promise.all([
244
+ (0, claude_sessions_1.getWorkspaceSessions)(), // already sorted by last-active, one per workspace
245
+ (0, workspace_meta_1.listWorkspaces)(false),
246
+ listAvailableSkills(),
247
+ ]);
248
+ const metaByName = new Map(workspaces.map((w) => [w.name, w]));
249
+ const recent = sessions.slice(0, limit);
250
+ const digests = [];
251
+ for (const s of recent) {
252
+ const meta = metaByName.get(s.workspaceName);
253
+ digests.push({
254
+ name: s.workspaceName,
255
+ repoCount: meta?.metadata?.repositories?.length ?? 0,
256
+ repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
257
+ contextFiles: await contextFilesFor(s.workspacePath),
258
+ session: await readDigestForSession(s),
259
+ });
260
+ }
261
+ return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
262
+ }
263
+ // ------------------------------------------------------------- judge prompt
264
+ /** JSON schema for `claude --json-schema` (best-effort; other agents ignore it). */
265
+ exports.REFLECT_SCHEMA = JSON.stringify({
266
+ type: 'object',
267
+ properties: {
268
+ summary: { type: 'string' },
269
+ recommendations: {
270
+ type: 'array',
271
+ items: {
272
+ type: 'object',
273
+ properties: {
274
+ kind: { type: 'string', enum: ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'] },
275
+ title: { type: 'string' },
276
+ detail: { type: 'string' },
277
+ target: { type: 'string' },
278
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
279
+ example: { type: 'string' },
280
+ },
281
+ required: ['kind', 'title', 'detail', 'priority'],
282
+ },
283
+ },
284
+ },
285
+ required: ['summary', 'recommendations'],
286
+ });
287
+ /**
288
+ * Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
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
+ }
318
+ // ----------------------------------------------------------- response parse
319
+ const KINDS = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
320
+ const PRIORITIES = ['high', 'medium', 'low'];
321
+ /** Validate + normalize the judge's parsed JSON into a ReflectionReport. */
322
+ function parseReflectionReport(parsed) {
323
+ const obj = (parsed ?? {});
324
+ const summary = typeof obj.summary === 'string' ? obj.summary : '';
325
+ const rawRecs = Array.isArray(obj.recommendations) ? obj.recommendations : [];
326
+ const recommendations = rawRecs
327
+ .map((r) => {
328
+ if (!r || typeof r !== 'object')
329
+ return null;
330
+ const title = typeof r.title === 'string' ? r.title : '';
331
+ const detail = typeof r.detail === 'string' ? r.detail : '';
332
+ if (!title && !detail)
333
+ return null;
334
+ const kind = KINDS.includes(r.kind) ? r.kind : 'other';
335
+ const priority = PRIORITIES.includes(r.priority) ? r.priority : 'medium';
336
+ const rec = { kind, title, detail, priority };
337
+ if (typeof r.target === 'string' && r.target.trim())
338
+ rec.target = r.target.trim();
339
+ if (typeof r.example === 'string' && r.example.trim())
340
+ rec.example = r.example.trim();
341
+ return rec;
342
+ })
343
+ .filter((r) => r !== null);
344
+ return { summary, recommendations };
345
+ }