@rvry/mcp 0.12.1 → 0.13.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.
@@ -1,30 +1,116 @@
1
1
  /**
2
- * Local session writer -- writes a minimal markdown summary to .rvry/sessions/
3
- * when a session completes.
2
+ * Local session writer -- writes a client-owned markdown harvest to
3
+ * .rvry/<op>/<timestamp>-<slug>/harvest.md when a session completes.
4
+ *
5
+ * Optionally emits per-round files (round-N-<mode>.md) when the slash-command
6
+ * shim passes `rounds[]` (triggered by `--record` flag on the user side).
4
7
  *
5
8
  * PROTOCOL SURFACE: This file produces user-facing output. It must NEVER contain
6
- * constraint IDs, detection scores, mode names, gate verdicts, or any engine internals.
7
- * Only question + harvest data.
9
+ * constraint IDs, detection scores, mode names used as gate verdicts, or any
10
+ * engine internals. Only question + client-supplied harvest + recorded bodies.
8
11
  */
9
- interface LocalSessionData {
12
+ export type ChallengeVerdict = 'GO' | 'CONDITIONAL_GO' | 'NO_GO';
13
+ export type ChallengeBias = 'anchoring' | 'framing' | 'authority' | 'sunk_cost' | 'availability' | 'confirmation' | 'none';
14
+ export type ChallengeBlastRadius = 'contained' | 'cascading';
15
+ export type ChallengeAssumptionConfidence = 'high' | 'medium' | 'low';
16
+ export interface ChallengeAssumption {
17
+ assumption: string;
18
+ confidence: ChallengeAssumptionConfidence;
19
+ ifWrong: string;
20
+ bias: ChallengeBias;
21
+ biasEvidence: string;
22
+ }
23
+ export interface ChallengeFailureMode {
24
+ component: string;
25
+ howItFails: string;
26
+ blastRadius: ChallengeBlastRadius;
27
+ detection: string;
28
+ }
29
+ export interface ChallengeCounterArgument {
30
+ argument: string;
31
+ rebuttal: string;
32
+ }
33
+ export type ProblemSolveAdversarialVerdict = 'PASSED' | 'CAVEATS' | 'NEEDS_REVISION';
34
+ export type ProblemSolveBias = 'anchoring' | 'framing' | 'authority' | 'sunk_cost' | 'availability' | 'confirmation';
35
+ export interface ProblemSolveBiasOperating {
36
+ bias: ProblemSolveBias;
37
+ evidence: string;
38
+ sourceReflex?: string;
39
+ correction?: string;
40
+ }
41
+ export interface ProblemSolveFrameReversal {
42
+ originalFraming?: string;
43
+ invertedFraming?: string;
44
+ wouldReachSameConclusion?: boolean;
45
+ }
46
+ export interface ProblemSolveBiasAudit {
47
+ biasesOperating?: ProblemSolveBiasOperating[];
48
+ decisionStable?: boolean;
49
+ frameReversal?: ProblemSolveFrameReversal;
50
+ }
51
+ export interface ClientHarvest {
52
+ title?: string | null;
53
+ summary?: string;
54
+ keyFindings?: string[];
55
+ shimmer?: string;
56
+ openQuestions?: string[];
57
+ followUps?: Array<{
58
+ question: string;
59
+ rationale: string;
60
+ }>;
61
+ verdict?: ChallengeVerdict;
62
+ confidence?: number;
63
+ mitigations?: string[];
64
+ assumptions?: ChallengeAssumption[];
65
+ edgeCases?: string[];
66
+ failureModes?: ChallengeFailureMode[];
67
+ counterArguments?: ChallengeCounterArgument[];
68
+ biasAudit?: ProblemSolveBiasAudit;
69
+ adversarialVerdict?: ProblemSolveAdversarialVerdict;
70
+ reversalConditions?: string[];
71
+ }
72
+ export interface ClientRound {
73
+ round: number;
74
+ mode: string;
75
+ body: string;
76
+ }
77
+ export interface LocalSessionData {
10
78
  sessionId: string;
11
79
  operationType: string;
12
80
  question: string;
13
81
  rounds: number;
14
- harvest: {
15
- summary: string;
16
- keyFindings: string[];
17
- openQuestions: string[];
18
- followUps: Array<{
19
- question: string;
20
- rationale: string;
21
- }>;
22
- };
82
+ /** Engine-side harvest (telemetry). Kept for potential future use; not written. */
83
+ harvest?: unknown;
84
+ /** Client-authored harvest (the synthesis Claude produced). Preferred source. */
85
+ clientHarvest?: ClientHarvest | null;
86
+ /** Per-round bodies for `--record` mode. */
87
+ clientRounds?: ClientRound[] | null;
88
+ /** Override "now" for deterministic tests. */
89
+ now?: Date;
90
+ /** Override base directory (default: process.cwd()). */
91
+ baseDir?: string;
23
92
  }
24
93
  /**
25
- * Write a minimal markdown summary of a completed session to .rvry/sessions/.
94
+ * Build the main harvest.md content. If `clientHarvest` is missing, emits the
95
+ * graceful-degradation fallback (question + upgrade notice).
96
+ *
97
+ * Per-op rendering (FR-16):
98
+ * - Challenge: verdict line below H1; Required Mitigations (when CONDITIONAL_GO);
99
+ * Assumptions table; Edge Cases bullets; Failure Modes table; Counter-Arguments.
100
+ * - Problem-solve: Adversarial Verdict line below H1; Bias Audit; Reversal Conditions.
101
+ * - Other ops: render unchanged.
102
+ *
103
+ * All op-specific sections are conditional on the corresponding field being
104
+ * present and non-empty. Absent field = absent section, never an empty section.
105
+ */
106
+ export declare function buildMarkdown(data: LocalSessionData, now: Date): string;
107
+ /**
108
+ * Build a per-round markdown file body.
109
+ */
110
+ export declare function buildRoundMarkdown(round: ClientRound, now: Date): string;
111
+ /**
112
+ * Write session folder: harvest.md (always) + optional per-round files.
26
113
  *
27
114
  * Disabled via RVRY_LOCAL_SESSIONS=0. Fails silently on any error.
28
115
  */
29
116
  export declare function writeLocalSession(data: LocalSessionData): Promise<void>;
30
- export {};
@@ -1,16 +1,32 @@
1
1
  /**
2
- * Local session writer -- writes a minimal markdown summary to .rvry/sessions/
3
- * when a session completes.
2
+ * Local session writer -- writes a client-owned markdown harvest to
3
+ * .rvry/<op>/<timestamp>-<slug>/harvest.md when a session completes.
4
+ *
5
+ * Optionally emits per-round files (round-N-<mode>.md) when the slash-command
6
+ * shim passes `rounds[]` (triggered by `--record` flag on the user side).
4
7
  *
5
8
  * PROTOCOL SURFACE: This file produces user-facing output. It must NEVER contain
6
- * constraint IDs, detection scores, mode names, gate verdicts, or any engine internals.
7
- * Only question + harvest data.
9
+ * constraint IDs, detection scores, mode names used as gate verdicts, or any
10
+ * engine internals. Only question + client-supplied harvest + recorded bodies.
8
11
  */
9
12
  import { mkdirSync, writeFileSync } from 'fs';
10
13
  import { join } from 'path';
14
+ /** Op label for H1 headings. */
15
+ const OP_LABELS = {
16
+ deepthink: 'RVRY Deepthink',
17
+ problem_solve: 'RVRY Problem-Solve',
18
+ think: 'RVRY Think',
19
+ challenge: 'RVRY Challenge',
20
+ meta: 'RVRY Meta',
21
+ };
22
+ function opLabel(operationType) {
23
+ return OP_LABELS[operationType] ?? `RVRY ${operationType}`;
24
+ }
25
+ function opFolder(operationType) {
26
+ return operationType.replace(/_/g, '-');
27
+ }
11
28
  /**
12
- * Slugify a question string for use in filenames.
13
- * Lowercases, replaces non-alphanumeric runs with hyphens, trims, and caps at maxLen chars.
29
+ * Slugify text for filenames/folders.
14
30
  */
15
31
  function slugify(text, maxLen = 50) {
16
32
  return text
@@ -20,9 +36,7 @@ function slugify(text, maxLen = 50) {
20
36
  .slice(0, maxLen)
21
37
  .replace(/-+$/, '');
22
38
  }
23
- /**
24
- * Format a Date as YYYYMMDD-HHMM for filenames.
25
- */
39
+ /** YYYYMMDD-HHMM for folder names. */
26
40
  function formatTimestamp(date) {
27
41
  const y = date.getFullYear().toString();
28
42
  const mo = (date.getMonth() + 1).toString().padStart(2, '0');
@@ -31,9 +45,7 @@ function formatTimestamp(date) {
31
45
  const mi = date.getMinutes().toString().padStart(2, '0');
32
46
  return `${y}${mo}${d}-${h}${mi}`;
33
47
  }
34
- /**
35
- * Format a Date as YYYY-MM-DD HH:MM for YAML frontmatter.
36
- */
48
+ /** YYYY-MM-DD HH:MM for italic date line. */
37
49
  function formatDate(date) {
38
50
  const y = date.getFullYear().toString();
39
51
  const mo = (date.getMonth() + 1).toString().padStart(2, '0');
@@ -43,72 +55,355 @@ function formatDate(date) {
43
55
  return `${y}-${mo}-${d} ${h}:${mi}`;
44
56
  }
45
57
  /**
46
- * Build the markdown content for a completed session.
58
+ * Title-case the first 4 words of a question as an H1 fallback.
59
+ */
60
+ function fallbackTitleFromQuestion(question) {
61
+ const words = question
62
+ .trim()
63
+ .split(/\s+/)
64
+ .slice(0, 4)
65
+ .map((w) => w.replace(/[^\p{L}\p{N}'-]/gu, ''))
66
+ .filter((w) => w.length > 0);
67
+ if (words.length === 0)
68
+ return 'Session';
69
+ return words
70
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
71
+ .join(' ');
72
+ }
73
+ function isNonEmptyString(v) {
74
+ return typeof v === 'string' && v.trim().length > 0;
75
+ }
76
+ function isNonEmptyArray(v) {
77
+ return Array.isArray(v) && v.length > 0;
78
+ }
79
+ /**
80
+ * Build the main harvest.md content. If `clientHarvest` is missing, emits the
81
+ * graceful-degradation fallback (question + upgrade notice).
82
+ *
83
+ * Per-op rendering (FR-16):
84
+ * - Challenge: verdict line below H1; Required Mitigations (when CONDITIONAL_GO);
85
+ * Assumptions table; Edge Cases bullets; Failure Modes table; Counter-Arguments.
86
+ * - Problem-solve: Adversarial Verdict line below H1; Bias Audit; Reversal Conditions.
87
+ * - Other ops: render unchanged.
88
+ *
89
+ * All op-specific sections are conditional on the corresponding field being
90
+ * present and non-empty. Absent field = absent section, never an empty section.
47
91
  */
48
- function buildMarkdown(data, now) {
92
+ export function buildMarkdown(data, now) {
93
+ const label = opLabel(data.operationType);
49
94
  const lines = [];
50
- // YAML frontmatter
51
- lines.push('---');
52
- lines.push(`session: ${data.sessionId}`);
53
- lines.push(`operation: ${data.operationType}`);
54
- lines.push(`date: ${formatDate(now)}`);
55
- lines.push(`rounds: ${data.rounds}`);
56
- lines.push('---');
57
- lines.push('');
58
- // Question
59
- lines.push(`# ${data.question}`);
60
- lines.push('');
61
- // Summary
62
- lines.push('## Summary');
63
- lines.push(data.harvest.summary);
64
- lines.push('');
65
- // Key Findings
66
- if (data.harvest.keyFindings.length > 0) {
67
- lines.push('## Key Findings');
68
- for (const finding of data.harvest.keyFindings) {
69
- lines.push(`- ${finding}`);
70
- }
95
+ const harvest = data.clientHarvest;
96
+ if (!harvest) {
97
+ // Fallback: no client synthesis provided.
98
+ const title = fallbackTitleFromQuestion(data.question);
99
+ lines.push(`# ${label} — ${title}`);
100
+ lines.push('');
101
+ lines.push(`*${formatDate(now)}*`);
102
+ lines.push('');
103
+ lines.push('### Prompt');
104
+ lines.push('');
105
+ lines.push(data.question);
71
106
  lines.push('');
107
+ lines.push('## Summary');
108
+ lines.push('');
109
+ lines.push('*(Synthesis not provided — upgrade your slash commands by re-running `rvry-mcp setup`.)*');
110
+ lines.push('');
111
+ return lines.join('\n');
112
+ }
113
+ const title = isNonEmptyString(harvest.title)
114
+ ? harvest.title.trim()
115
+ : fallbackTitleFromQuestion(data.question);
116
+ lines.push(`# ${label} — ${title}`);
117
+ // Op-specific verdict line directly below H1.
118
+ if (data.operationType === 'challenge') {
119
+ const verdictLine = renderChallengeVerdictLine(harvest);
120
+ if (verdictLine)
121
+ lines.push(verdictLine);
122
+ }
123
+ else if (data.operationType === 'problem_solve') {
124
+ const verdictLine = renderProblemSolveVerdictLine(harvest);
125
+ if (verdictLine)
126
+ lines.push(verdictLine);
127
+ }
128
+ lines.push(`*${formatDate(now)}*`);
129
+ lines.push('');
130
+ lines.push('### Prompt');
131
+ lines.push('');
132
+ lines.push(data.question);
133
+ lines.push('');
134
+ if (isNonEmptyString(harvest.shimmer)) {
135
+ lines.push('## Shimmer');
136
+ lines.push('');
137
+ lines.push(harvest.shimmer.trim());
138
+ lines.push('');
139
+ }
140
+ if (isNonEmptyString(harvest.summary)) {
141
+ lines.push('## Summary');
142
+ lines.push('');
143
+ lines.push(harvest.summary.trim());
144
+ lines.push('');
145
+ }
146
+ // Challenge-specific structured sections (in declared rendering order).
147
+ if (data.operationType === 'challenge') {
148
+ appendChallengeMitigations(lines, harvest);
149
+ appendChallengeAssumptions(lines, harvest);
150
+ appendChallengeEdgeCases(lines, harvest);
151
+ appendChallengeFailureModes(lines, harvest);
152
+ appendChallengeCounterArguments(lines, harvest);
153
+ }
154
+ // Problem-solve-specific structured sections.
155
+ if (data.operationType === 'problem_solve') {
156
+ appendProblemSolveBiasAudit(lines, harvest);
157
+ appendProblemSolveReversalConditions(lines, harvest);
158
+ }
159
+ if (isNonEmptyArray(harvest.keyFindings)) {
160
+ const findings = harvest.keyFindings.filter(isNonEmptyString);
161
+ if (findings.length > 0) {
162
+ lines.push('## Key Findings');
163
+ lines.push('');
164
+ for (const f of findings)
165
+ lines.push(`- ${f.trim()}`);
166
+ lines.push('');
167
+ }
168
+ }
169
+ if (isNonEmptyArray(harvest.openQuestions)) {
170
+ const qs = harvest.openQuestions.filter(isNonEmptyString);
171
+ if (qs.length > 0) {
172
+ lines.push('## Open Questions');
173
+ lines.push('');
174
+ for (const q of qs)
175
+ lines.push(`- ${q.trim()}`);
176
+ lines.push('');
177
+ }
178
+ }
179
+ if (isNonEmptyArray(harvest.followUps)) {
180
+ const fus = harvest.followUps.filter((f) => f && isNonEmptyString(f.question));
181
+ if (fus.length > 0) {
182
+ lines.push('## Follow-ups');
183
+ lines.push('');
184
+ for (const f of fus) {
185
+ if (isNonEmptyString(f.rationale)) {
186
+ lines.push(`- ${f.question.trim()} — ${f.rationale.trim()}`);
187
+ }
188
+ else {
189
+ lines.push(`- ${f.question.trim()}`);
190
+ }
191
+ }
192
+ lines.push('');
193
+ }
194
+ }
195
+ return lines.join('\n');
196
+ }
197
+ // ─────────────────────────────────────────────────────────────────────
198
+ // Challenge renderers
199
+ // ─────────────────────────────────────────────────────────────────────
200
+ const CHALLENGE_VERDICT_LABELS = {
201
+ GO: 'GO',
202
+ CONDITIONAL_GO: 'CONDITIONAL_GO',
203
+ NO_GO: 'NO_GO',
204
+ };
205
+ function renderChallengeVerdictLine(harvest) {
206
+ const verdict = harvest.verdict;
207
+ if (!verdict || !CHALLENGE_VERDICT_LABELS[verdict])
208
+ return null;
209
+ const confidenceText = typeof harvest.confidence === 'number'
210
+ ? ` (confidence: ${harvest.confidence.toFixed(1)})`
211
+ : '';
212
+ return `**Verdict: ${CHALLENGE_VERDICT_LABELS[verdict]}${confidenceText}**`;
213
+ }
214
+ function appendChallengeMitigations(lines, harvest) {
215
+ // Conditional: only render when verdict is CONDITIONAL_GO and mitigations are non-empty.
216
+ if (harvest.verdict !== 'CONDITIONAL_GO')
217
+ return;
218
+ const mitigations = (harvest.mitigations ?? []).filter(isNonEmptyString);
219
+ if (mitigations.length === 0)
220
+ return;
221
+ lines.push('## Required Mitigations');
222
+ lines.push('');
223
+ for (const m of mitigations)
224
+ lines.push(`- ${m.trim()}`);
225
+ lines.push('');
226
+ }
227
+ function appendChallengeAssumptions(lines, harvest) {
228
+ const assumptions = (harvest.assumptions ?? []).filter((a) => a && isNonEmptyString(a.assumption));
229
+ if (assumptions.length === 0)
230
+ return;
231
+ lines.push('## Assumptions');
232
+ lines.push('');
233
+ lines.push('| Assumption | Confidence | If Wrong | Bias | Bias Evidence |');
234
+ lines.push('| --- | --- | --- | --- | --- |');
235
+ for (const a of assumptions) {
236
+ lines.push(`| ${escapeTableCell(a.assumption)} | ${escapeTableCell(a.confidence ?? '')} | ${escapeTableCell(a.ifWrong ?? '')} | ${escapeTableCell(a.bias ?? '')} | ${escapeTableCell(a.biasEvidence ?? '')} |`);
237
+ }
238
+ lines.push('');
239
+ }
240
+ function appendChallengeEdgeCases(lines, harvest) {
241
+ const cases = (harvest.edgeCases ?? []).filter(isNonEmptyString);
242
+ if (cases.length === 0)
243
+ return;
244
+ lines.push('## Edge Cases');
245
+ lines.push('');
246
+ for (const c of cases)
247
+ lines.push(`- ${c.trim()}`);
248
+ lines.push('');
249
+ }
250
+ function appendChallengeFailureModes(lines, harvest) {
251
+ const modes = (harvest.failureModes ?? []).filter((m) => m && isNonEmptyString(m.component));
252
+ if (modes.length === 0)
253
+ return;
254
+ lines.push('## Failure Modes');
255
+ lines.push('');
256
+ lines.push('| Component | How It Fails | Blast Radius | Detection |');
257
+ lines.push('| --- | --- | --- | --- |');
258
+ for (const m of modes) {
259
+ lines.push(`| ${escapeTableCell(m.component)} | ${escapeTableCell(m.howItFails ?? '')} | ${escapeTableCell(m.blastRadius ?? '')} | ${escapeTableCell(m.detection ?? '')} |`);
72
260
  }
73
- // Open Questions
74
- if (data.harvest.openQuestions.length > 0) {
75
- lines.push('## Open Questions');
76
- for (const q of data.harvest.openQuestions) {
77
- lines.push(`- ${q}`);
261
+ lines.push('');
262
+ }
263
+ function appendChallengeCounterArguments(lines, harvest) {
264
+ const args = (harvest.counterArguments ?? []).filter((c) => c && isNonEmptyString(c.argument));
265
+ if (args.length === 0)
266
+ return;
267
+ lines.push('## Counter-Arguments');
268
+ lines.push('');
269
+ for (const c of args) {
270
+ lines.push(`- **For:** ${c.argument.trim()}`);
271
+ if (isNonEmptyString(c.rebuttal)) {
272
+ lines.push(` **Against:** ${c.rebuttal.trim()}`);
273
+ }
274
+ }
275
+ lines.push('');
276
+ }
277
+ // ─────────────────────────────────────────────────────────────────────
278
+ // Problem-solve renderers
279
+ // ─────────────────────────────────────────────────────────────────────
280
+ const PROBLEM_SOLVE_VERDICT_LABELS = {
281
+ PASSED: 'PASSED',
282
+ CAVEATS: 'CAVEATS',
283
+ NEEDS_REVISION: 'NEEDS_REVISION',
284
+ };
285
+ function renderProblemSolveVerdictLine(harvest) {
286
+ const verdict = harvest.adversarialVerdict;
287
+ if (!verdict || !PROBLEM_SOLVE_VERDICT_LABELS[verdict])
288
+ return null;
289
+ return `**Adversarial Verdict: ${PROBLEM_SOLVE_VERDICT_LABELS[verdict]}**`;
290
+ }
291
+ function appendProblemSolveBiasAudit(lines, harvest) {
292
+ const audit = harvest.biasAudit;
293
+ if (!audit)
294
+ return;
295
+ const operating = (audit.biasesOperating ?? []).filter((b) => b && isNonEmptyString(b.bias) && isNonEmptyString(b.evidence));
296
+ const hasReversal = audit.frameReversal && (isNonEmptyString(audit.frameReversal.originalFraming) ||
297
+ isNonEmptyString(audit.frameReversal.invertedFraming) ||
298
+ typeof audit.frameReversal.wouldReachSameConclusion === 'boolean');
299
+ const hasStability = typeof audit.decisionStable === 'boolean';
300
+ if (operating.length === 0 && !hasReversal && !hasStability)
301
+ return;
302
+ lines.push('## Bias Audit');
303
+ lines.push('');
304
+ if (operating.length > 0) {
305
+ for (const b of operating) {
306
+ lines.push(`- **${b.bias.trim()}**: ${b.evidence.trim()}`);
307
+ if (isNonEmptyString(b.sourceReflex)) {
308
+ lines.push(` - Source reflex: ${b.sourceReflex.trim()}`);
309
+ }
310
+ if (isNonEmptyString(b.correction)) {
311
+ lines.push(` - Correction: ${b.correction.trim()}`);
312
+ }
78
313
  }
79
314
  lines.push('');
80
315
  }
81
- // Follow-ups
82
- if (data.harvest.followUps.length > 0) {
83
- lines.push('## Follow-ups');
84
- for (const f of data.harvest.followUps) {
85
- lines.push(`- ${f.question} — ${f.rationale}`);
316
+ if (hasStability) {
317
+ lines.push(`Decision stable under bias check: ${audit.decisionStable ? 'yes' : 'no'}`);
318
+ lines.push('');
319
+ }
320
+ if (hasReversal && audit.frameReversal) {
321
+ lines.push('### Frame Reversal');
322
+ lines.push('');
323
+ if (isNonEmptyString(audit.frameReversal.originalFraming)) {
324
+ lines.push(`- Original: ${audit.frameReversal.originalFraming.trim()}`);
325
+ }
326
+ if (isNonEmptyString(audit.frameReversal.invertedFraming)) {
327
+ lines.push(`- Inverted: ${audit.frameReversal.invertedFraming.trim()}`);
328
+ }
329
+ if (typeof audit.frameReversal.wouldReachSameConclusion === 'boolean') {
330
+ lines.push(`- Same conclusion under reversal: ${audit.frameReversal.wouldReachSameConclusion ? 'yes' : 'no'}`);
86
331
  }
87
332
  lines.push('');
88
333
  }
334
+ }
335
+ function appendProblemSolveReversalConditions(lines, harvest) {
336
+ const conds = (harvest.reversalConditions ?? []).filter(isNonEmptyString);
337
+ if (conds.length === 0)
338
+ return;
339
+ lines.push('## Reversal Conditions');
340
+ lines.push('');
341
+ for (const c of conds)
342
+ lines.push(`- ${c.trim()}`);
343
+ lines.push('');
344
+ }
345
+ /**
346
+ * Escape pipe and newline characters in markdown table cells.
347
+ * Keeps cell content on a single line and prevents pipe characters from
348
+ * breaking column alignment.
349
+ */
350
+ function escapeTableCell(value) {
351
+ return value
352
+ .replace(/\r?\n/g, ' ')
353
+ .replace(/\|/g, '\\|')
354
+ .trim();
355
+ }
356
+ /**
357
+ * Build a per-round markdown file body.
358
+ */
359
+ export function buildRoundMarkdown(round, now) {
360
+ const modeLabel = round.mode
361
+ ? round.mode.charAt(0).toUpperCase() + round.mode.slice(1)
362
+ : 'Round';
363
+ const lines = [];
364
+ lines.push(`# Round ${round.round} — ${modeLabel}`);
365
+ lines.push('');
366
+ lines.push(`*${formatDate(now)}*`);
367
+ lines.push('');
368
+ lines.push(round.body ?? '');
369
+ lines.push('');
89
370
  return lines.join('\n');
90
371
  }
372
+ function roundFilename(round) {
373
+ const modeSafe = (round.mode || 'round')
374
+ .toLowerCase()
375
+ .replace(/[^a-z0-9]+/g, '-')
376
+ .replace(/^-+|-+$/g, '') || 'round';
377
+ return `round-${round.round}-${modeSafe}.md`;
378
+ }
91
379
  /**
92
- * Write a minimal markdown summary of a completed session to .rvry/sessions/.
380
+ * Write session folder: harvest.md (always) + optional per-round files.
93
381
  *
94
382
  * Disabled via RVRY_LOCAL_SESSIONS=0. Fails silently on any error.
95
383
  */
96
384
  export async function writeLocalSession(data) {
97
- // Check opt-out
98
385
  if (process.env.RVRY_LOCAL_SESSIONS === '0')
99
386
  return;
100
387
  try {
101
- const now = new Date();
388
+ const now = data.now ?? new Date();
102
389
  const timestamp = formatTimestamp(now);
103
- const slug = slugify(data.question);
104
- const filename = `${timestamp}-${data.operationType}-${slug}.md`;
105
- const sessionsDir = join(process.cwd(), '.rvry', 'sessions');
106
- mkdirSync(sessionsDir, { recursive: true });
107
- const filePath = join(sessionsDir, filename);
108
- const content = buildMarkdown(data, now);
109
- writeFileSync(filePath, content, 'utf-8');
390
+ const slug = slugify(data.question) || 'session';
391
+ const folderName = `${timestamp}-${slug}`;
392
+ const baseDir = data.baseDir ?? process.cwd();
393
+ const sessionDir = join(baseDir, '.rvry', opFolder(data.operationType), folderName);
394
+ mkdirSync(sessionDir, { recursive: true });
395
+ const harvestPath = join(sessionDir, 'harvest.md');
396
+ writeFileSync(harvestPath, buildMarkdown(data, now), 'utf-8');
397
+ if (isNonEmptyArray(data.clientRounds)) {
398
+ for (const r of data.clientRounds) {
399
+ if (!r || typeof r.round !== 'number' || typeof r.mode !== 'string')
400
+ continue;
401
+ const fp = join(sessionDir, roundFilename(r));
402
+ writeFileSync(fp, buildRoundMarkdown(r, now), 'utf-8');
403
+ }
404
+ }
110
405
  }
111
406
  catch {
112
- // Fail silently -- this is a convenience feature, not critical path
407
+ // Fail silently -- convenience feature, not critical path.
113
408
  }
114
409
  }