@cspeach/cli 1.1.50 → 1.1.51

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.
@@ -45,7 +45,9 @@ import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
45
45
  import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
46
46
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
47
47
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
48
- import { DOCUMENT_SKILLS } from '../projects/document-skills.js';
48
+ import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
49
+ import { metaFromMarkdown } from '../docx/doc-meta.js';
50
+ import { bindCliCardSession, documentOffersFor, markCliMessage, CONTINUE_LINE, WATCHDOG_LINE } from '../session/cli-record.js';
49
51
  import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
50
52
  import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
51
53
  import { afterRebuildTurn } from '../journey/rebuild.js';
@@ -373,6 +375,69 @@ export async function inkAwareChoose(a) {
373
375
  emitTrace(formatAnswerTrace(header, shown, uiWidth()));
374
376
  return { kind: 'picked', answer };
375
377
  }
378
+ /**
379
+ * Word copy everywhere (2026-10-23) — a newer document skill offers the save
380
+ * only when its answer is shaped like a document: at least one Markdown
381
+ * heading. A short answer to a quick question ("what is clean core?") is
382
+ * not offered. Review/handover do not use this check (unchanged).
383
+ */
384
+ function looksLikeDocument(text) {
385
+ return /^#{1,6}\s+\S/m.test(text);
386
+ }
387
+ const OBJECT_TOKEN_RE = /(?<![\w/])((?:\/[A-Za-z0-9]+\/)?[ZYzy][A-Za-z0-9_]{2,39})\b/g;
388
+ /**
389
+ * The first Z/Y object name in the prompt (final-review m-2): a name with an
390
+ * underscore (zcl_order, ZFI_DUP_INVOICE_CHECK), or one typed in capitals with
391
+ * at least 4 characters (ZTEST). Ordinary words never count: you, yes, YES, YOU.
392
+ */
393
+ export function promptObjectName(prompt) {
394
+ for (const m of prompt.matchAll(OBJECT_TOKEN_RE)) {
395
+ const t = m[1];
396
+ if (t.includes('_') || (t.length >= 4 && t === t.toUpperCase()))
397
+ return t.toUpperCase();
398
+ }
399
+ return undefined;
400
+ }
401
+ /**
402
+ * Note this turn's (skill, object). `fresh` is false only when the key was
403
+ * already offered in this session (final-review I-3: a clarifying question
404
+ * before the document no longer uses up the offer). Without a session: always
405
+ * fresh (no rule). The offers are cleared by /new (session/cli-record.ts).
406
+ */
407
+ function noteDocumentTurn(p) {
408
+ if (!p.session)
409
+ return { fresh: true, markOffered: () => { } };
410
+ const object = metaFromMarkdown(p.assistantText, { date: '' }).object ?? promptObjectName(p.userMessage) ?? '';
411
+ const key = `${p.skill}|${object}`;
412
+ const offered = documentOffersFor(p.session);
413
+ return { fresh: !offered.has(key), markOffered: () => { offered.add(key); } };
414
+ }
415
+ /** Today's local date, YYYY-MM-DD (the document file names use it). */
416
+ function localToday() {
417
+ const d = new Date();
418
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
419
+ }
420
+ /**
421
+ * The Word step after an artefact save. document-save.ts (and the docx
422
+ * package) loads only here, inside the catch: a broken docx install costs
423
+ * one line after the save and never the save itself.
424
+ */
425
+ async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
426
+ try {
427
+ const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
428
+ const { isHeadless } = await import('../renderer/tty.js');
429
+ const { loadConfig, resolveWordCopy } = await import('../config/loader.js');
430
+ await offerWordCopyAfterArtifactSave({
431
+ saveSkill, savedPath, markdown: p.assistantText, cwd: process.cwd(), sapSystem: p.sapSystem ?? null,
432
+ // Inside a guided flow the Word step never asks: same rule as headless.
433
+ today: localToday(), headless: isHeadless() || p.guided === true, wordCopy: resolveWordCopy(await loadConfig()),
434
+ confirm, emit: p.emit,
435
+ });
436
+ }
437
+ catch (e) {
438
+ p.emit(formatInfo(`Word copy not written: ${e.message}`).trimEnd());
439
+ }
440
+ }
376
441
  export async function maybeOfferSave(p) {
377
442
  if (p.assistantText.trim().length === 0)
378
443
  return;
@@ -383,7 +448,26 @@ export async function maybeOfferSave(p) {
383
448
  // The skill check reads a tiny constant module; document-save.ts (and the
384
449
  // docx package) loads only for these two skills, inside the catch, so a
385
450
  // broken docx install costs one line on a review save and nothing else.
386
- if (DOCUMENT_SKILLS[p.skill]) {
451
+ // Word copy everywhere (2026-10-23): the newer document skills (explain,
452
+ // impact, preflight, …) yield to the artefact save when their answer
453
+ // carries an artefact block, and offer nothing when the turn ended on a
454
+ // question. Review and handover take this branch exactly as before.
455
+ const confirm = p.confirm ?? inkAwareConfirm;
456
+ const doc = DOCUMENT_SKILLS[p.skill];
457
+ // Fix round 1 (I-3): which skill and object this turn was about (every turn,
458
+ // so a follow-up of the same skill on the same object is known).
459
+ const docTurn = noteDocumentTurn(p);
460
+ let offerDoc = doc !== undefined;
461
+ if (doc?.yieldsToArtifact) {
462
+ // Headless runs of the newer document skills write no file (scripts and CI
463
+ // keep today's behaviour). Review and handover keep theirs.
464
+ // A sub-agent turn never saves, so it never uses up the offer (final-review m-1).
465
+ offerDoc = !p.subagent && docTurn.fresh && !(await import('../renderer/tty.js')).isHeadless() && !p.awaitingAnswer && !p.guided
466
+ && looksLikeDocument(p.assistantText) && !detectArtifactSkill(p.assistantText);
467
+ if (offerDoc)
468
+ docTurn.markOffered();
469
+ }
470
+ if (doc && offerDoc) {
387
471
  if (p.subagent)
388
472
  return;
389
473
  const d = new Date();
@@ -395,7 +479,7 @@ export async function maybeOfferSave(p) {
395
479
  await offerDocumentSave({
396
480
  skill: p.skill, markdown: p.assistantText, cwd: process.cwd(), sapSystem: p.sapSystem ?? null,
397
481
  today, headless: isHeadless(), wordCopy: resolveWordCopy(await loadConfig()),
398
- confirm: inkAwareConfirm, emit: p.emit,
482
+ confirm, emit: p.emit,
399
483
  });
400
484
  }
401
485
  catch (e) {
@@ -471,6 +555,13 @@ export async function maybeOfferSave(p) {
471
555
  // Never headless: a headless journey queues nothing.
472
556
  ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
473
557
  });
558
+ // Word copy everywhere (2026-10-23) — a saved project file whose answer
559
+ // is a document (spec gaps, design, estimate, plan, upgrade scan/report)
560
+ // gets the same Word step as a review: `word_copy` decides, headless
561
+ // never asks. The project file itself is untouched. Sub-agent turns: no.
562
+ if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]) {
563
+ await offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm);
564
+ }
474
565
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
475
566
  // questions inline (recorded as `answered` items in the SAME file) — the
476
567
  // business questions stay open for a consultant to answer in the viewer.
@@ -988,6 +1079,12 @@ ${imageExpansion.error}`));
988
1079
  // Images (if any) ride as blocks ahead of the text; with none this is the
989
1080
  // same plain string as before.
990
1081
  session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
1082
+ if (params.userMessageOrigin) {
1083
+ markCliMessage(session, session.messages[session.messages.length - 1].content, params.userMessageOrigin.kind, params.userMessageOrigin.line);
1084
+ }
1085
+ // The CLI cards answered during this turn (plan gate, lane, journey) belong to this session.
1086
+ if (!params.ctx.subagent && !readerChild)
1087
+ bindCliCardSession(session);
991
1088
  // Round 6 review I4 / final batch 1 — the switch note is dropped only once
992
1089
  // it is in a pushed user message.
993
1090
  if (switchNoteUsed)
@@ -1196,6 +1293,7 @@ ${imageExpansion.error}`));
1196
1293
  if (steers.length > 0) {
1197
1294
  const text = steers.join('\n');
1198
1295
  session.messages.push({ role: 'user', content: text });
1296
+ markCliMessage(session, text, 'steer', '');
1199
1297
  if (roundIndex === 0)
1200
1298
  round0Steers = steers;
1201
1299
  emit(chalk.cyan(`↳ steering: ${steers.join(' / ')}`));
@@ -2042,6 +2140,9 @@ ${imageExpansion.error}`));
2042
2140
  emit,
2043
2141
  sapSystem: session.sap_system,
2044
2142
  ...(params.ctx.subagent ? { subagent: true } : {}),
2143
+ ...(session.awaitingSkillAnswer === true ? { awaitingAnswer: true } : {}),
2144
+ ...(getJourneyFlow(session) || getAppFlow(session) ? { guided: true } : {}),
2145
+ session,
2045
2146
  promotedFrom: promotedFromForSave,
2046
2147
  ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
2047
2148
  ...(() => {
@@ -2195,6 +2296,7 @@ ${imageExpansion.error}`));
2195
2296
  + 'If you were mid-way through a tool call, or (for /abap-plan) had not yet emitted the closing cspeach:plan-manifest block, complete it now. '
2196
2297
  + 'Keep narration brief to stay within the limit.',
2197
2298
  });
2299
+ markCliMessage(session, session.messages[session.messages.length - 1].content, 'continue', CONTINUE_LINE);
2198
2300
  continue;
2199
2301
  }
2200
2302
  if (sawMaxTokens) {
@@ -2546,6 +2648,7 @@ ${imageExpansion.error}`));
2546
2648
  watchdogWarned = true;
2547
2649
  emit(chalk.yellow(`\n[watchdog] ${decision.reason}. Asking the model to wrap up.`));
2548
2650
  session.messages.push({ role: 'user', content: decision.message });
2651
+ markCliMessage(session, decision.message, 'watchdog', WATCHDOG_LINE);
2549
2652
  await saveSession(session);
2550
2653
  }
2551
2654
  }
package/dist/cli.js CHANGED
@@ -308,7 +308,7 @@ Usage:
308
308
  cspeach Start interactive REPL
309
309
  cspeach "<prompt>" One-shot — runs one turn and exits
310
310
  cspeach --one-shot "<prompt>" Same as above, explicit flag
311
- cspeach export <file>.md --docx Write a Word copy next to a saved review or handover
311
+ cspeach export <file>.md --docx Write a Word copy next to a Markdown file
312
312
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
313
313
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
314
314
  cspeach --continue, -c Continue the latest session in this folder
@@ -33,6 +33,7 @@ import { paint } from '../renderer/theme.js';
33
33
  import { getGlyphs } from '../renderer/glyphs.js';
34
34
  import { saveSession } from '../session/store.js';
35
35
  import { isUserPrompt } from '../session/user-prompt.js';
36
+ import { rebuildCliMarksAfterCompact, COMPACT_LINE } from '../session/cli-record.js';
36
37
  import { getActiveDeployments } from '../models/model-identity.js';
37
38
  /** Keep this many recent user-prompt anchored turns verbatim. */
38
39
  export const KEEP_RECENT_TURNS = 5;
@@ -305,7 +306,12 @@ export async function runCompact(opts) {
305
306
  // that produced it). The summary changed them, so the kept tail goes out
306
307
  // without thinking; text and tool calls stay. The backup above keeps all.
307
308
  const kept = plan.toKeep.map(stripThinking);
309
+ const oldMessages = session.messages;
308
310
  session.messages = [summaryMsg, ...kept];
311
+ // /export docx conversation: the summary is CSPeach's, not the developer's
312
+ // (one plain line there). The record is rebuilt in message order so the kept
313
+ // plan steps, continuations and steers keep their marks (final-review I-1).
314
+ rebuildCliMarksAfterCompact(session, oldMessages, plan.cutAt, summaryMsg.content, { kind: 'compact', line: COMPACT_LINE });
309
315
  // Task 19 — the recorded context size described the old history; the next
310
316
  // call records the new one. Clearing it keeps the auto-compact gate honest.
311
317
  delete session.last_context_tokens;
@@ -616,7 +616,7 @@ export async function runConfigSet(args) {
616
616
  if (b === null)
617
617
  err(`${key} must be on/off (also accepts true/false, 1/0).`);
618
618
  cfg.word_copy = b;
619
- console.log(chalk.dim(b ? ' → a Word copy is written with every saved review or handover.' : ' → CSPeach will not ask about Word copies.'));
619
+ console.log(chalk.dim(b ? ' → a Word copy is written with every document CSPeach saves.' : ' → CSPeach will not ask about Word copies.'));
620
620
  break;
621
621
  }
622
622
  case 'write_mode':
@@ -17,10 +17,33 @@ import path from 'node:path';
17
17
  import { buildAuditReport } from '../session/audit-export.js';
18
18
  import { globalCredits } from '../cost/credits.js';
19
19
  import { readCostLog } from '../cost/cost-log.js';
20
- /** Parse the text after `/export` into a subcommand + optional filename. */
20
+ /**
21
+ * The /export usage line (2026-10-23). Here, in the light audit module both
22
+ * REPL handlers already load, so `/export audit` and a mistyped `/export`
23
+ * never load the Word package (final-review I-2).
24
+ */
25
+ export const EXPORT_REPL_USAGE = 'Usage: /export docx (the last answer to Word) · /export docx <name> · /export docx conversation · /export audit [file]';
26
+ /**
27
+ * Parse the text after `/export` into a subcommand + optional filename.
28
+ *
29
+ * 2026-10-23 (a customer's screenshot): the Word forms people actually type
30
+ * all mean "Word": `/export docx x.docx`, `/export x.md --docx`,
31
+ * `/export x.docx`, `/export conversation`, `/export word`. A `--docx` or
32
+ * `--word` flag anywhere is dropped. For sub 'docx', `docxArgs` is the line
33
+ * to hand to runExportDocxRepl (" docx <rest>").
34
+ */
21
35
  export function parseExportArgs(rest) {
22
- const parts = rest.trim().split(/\s+/).filter(Boolean);
23
- return { sub: (parts[0] ?? '').toLowerCase(), filename: parts[1] };
36
+ let flag = false;
37
+ const cleaned = rest.replace(/(^|\s)--(?:docx|word)(?=\s|$)/gi, () => { flag = true; return ' '; }).trim();
38
+ const parts = cleaned.split(/\s+/).filter(Boolean);
39
+ const first = (parts[0] ?? '').toLowerCase();
40
+ if (first === 'docx' || first === 'word') {
41
+ return { sub: 'docx', docxArgs: ` docx ${cleaned.slice(parts[0].length).trim()}`.trimEnd() };
42
+ }
43
+ if (first !== 'audit' && (flag || first === 'conversation' || /\.(?:md|docx)["']?$/i.test(cleaned))) {
44
+ return { sub: 'docx', docxArgs: ` docx ${cleaned}`.trimEnd() };
45
+ }
46
+ return { sub: first, filename: parts[1] };
24
47
  }
25
48
  /** Deterministic default filename: date from session data, not now(). */
26
49
  export function defaultAuditFilename(session) {
@@ -0,0 +1,343 @@
1
+ // cspeach-cli/src/commands/export-conversation.ts
2
+ //
3
+ // /export docx (2026-10-23, from a customer's own words): turn what he sees in
4
+ // the terminal into a Word document at any point, to attach to the change
5
+ // ticket.
6
+ //
7
+ // /export docx the last answer CSPeach gave
8
+ // /export docx conversation the whole conversation so far: the developer's
9
+ // messages, CSPeach's answers, and every question
10
+ // CSPeach asked (ask_question, approvals) with the
11
+ // options shown and the answer picked
12
+ // /export docx <file>.md unchanged (export-docx.ts)
13
+ //
14
+ // Reads only what is already in the session: no model call, no SAP call.
15
+ // Raw tool results are never written (a source body read from SAP stays out);
16
+ // only the answers to questions are taken from them. Files go to
17
+ // cspeach-out/ (never overwritten), the .docx next to the .md.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { metaFromMarkdown } from '../docx/doc-meta.js';
21
+ import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
22
+ import { normalizeAskInput } from '../tools/ask-question.js';
23
+ import { isUserPrompt, userPromptText } from '../session/user-prompt.js';
24
+ import { resolveCliMarks, startsTurn } from '../session/cli-record.js';
25
+ import { docxFileFromReplArgs, runExportDocxCommand } from './export-docx.js';
26
+ // The usage line lives in the light export-audit module (final-review I-2); re-exported here.
27
+ export { EXPORT_REPL_USAGE } from './export-audit.js';
28
+ const OUT_DIR = 'cspeach-out';
29
+ /** HTML comments are machine blocks (artefact manifests, widget markers): the terminal never shows them. */
30
+ function readerText(text) {
31
+ return text.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
32
+ }
33
+ /** Context blocks the CLI adds to a prompt; the developer never typed them. */
34
+ const INJECTED_TAGS = ['session_context', 'project_context', 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection'];
35
+ function typedText(text) {
36
+ let out = text;
37
+ for (const tag of INJECTED_TAGS) {
38
+ out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), '');
39
+ }
40
+ out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
41
+ return out.trim();
42
+ }
43
+ function blocks(m) {
44
+ return Array.isArray(m.content) ? m.content : [];
45
+ }
46
+ function assistantTexts(m) {
47
+ if (m.role !== 'assistant')
48
+ return [];
49
+ if (typeof m.content === 'string')
50
+ return [m.content];
51
+ return blocks(m).filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text);
52
+ }
53
+ /** Indexes of the messages that start a turn: a typed prompt, a plan step, a queued step (fix round 1, I-1/I-2). */
54
+ function turnStarts(messages, session) {
55
+ const out = [];
56
+ const marks = resolveCliMarks(session, messages);
57
+ messages.forEach((m, i) => { if (isUserPrompt(m) && startsTurn(marks[i]))
58
+ out.push(i); });
59
+ return out;
60
+ }
61
+ /**
62
+ * The last answer: everything CSPeach said after the last turn's prompt. A
63
+ * continuation after the output limit, a watchdog note or a mid-turn steer is
64
+ * not a new turn, so a long answer comes out whole. `turn` = session.turn_seq
65
+ * when the session has it (right after a compaction too), else the count.
66
+ */
67
+ export function lastAnswer(messages, session) {
68
+ const starts = turnStarts(messages, session);
69
+ if (starts.length === 0)
70
+ return null;
71
+ const last = starts[starts.length - 1];
72
+ const turn = session?.turn_seq ?? starts.length;
73
+ const text = readerText(messages.slice(last + 1).flatMap(assistantTexts).join('\n\n'));
74
+ return text.length > 0 ? { text: text + '\n', turn } : null;
75
+ }
76
+ /** tool_use_id → the result's text (only read for questions and approvals). */
77
+ function resultsById(messages) {
78
+ const out = new Map();
79
+ for (const m of messages) {
80
+ if (m.role !== 'user')
81
+ continue;
82
+ for (const b of blocks(m)) {
83
+ if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string')
84
+ continue;
85
+ const c = b.content;
86
+ const text = typeof c === 'string' ? c
87
+ : Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => String(x.text ?? '')).join('') : '';
88
+ out.set(b.tool_use_id, text);
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ function parse(json) {
94
+ if (json === undefined)
95
+ return undefined;
96
+ try {
97
+ return JSON.parse(json);
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ const quote = (lines) => lines.map((l) => (l.length ? `> ${l}` : '>')).join('\n');
104
+ /** One question (or a form of up to four) with its options and the answer picked. */
105
+ function questionBlock(input, result) {
106
+ let form;
107
+ try {
108
+ form = normalizeAskInput(input);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ const r = parse(result);
114
+ const answers = [];
115
+ if (r && Array.isArray(r.answers)) {
116
+ for (const a of r.answers)
117
+ answers.push({ id: String(a?.id), answer: a?.answer == null ? null : String(a.answer), auto: a?.auto_answered === true });
118
+ }
119
+ else if (r && 'answer' in r) {
120
+ answers.push({ id: String(r.id ?? form.questions[0]?.id), answer: r.answer == null ? null : String(r.answer), auto: r.auto_answered === true });
121
+ }
122
+ const out = [];
123
+ for (const q of form.questions) {
124
+ out.push(`**CSPeach asked:** ${q.question}`);
125
+ if (q.options.length)
126
+ out.push(`Options: ${q.options.map((o) => o.label).join(' · ')}`);
127
+ const a = answers.find((x) => x.id === q.id) ?? (form.questions.length === 1 ? answers[0] : undefined);
128
+ if (a && a.answer !== null && a.answer.length > 0) {
129
+ const shown = q.options.length && !q.freeText
130
+ ? a.answer.split(',').map((v) => q.options.find((o) => o.value === v)?.label ?? v).join(', ')
131
+ : a.answer;
132
+ out.push(a.auto ? `**Answer (picked automatically, no one answered):** ${shown}` : `**Answer:** ${shown}`);
133
+ }
134
+ else if (r?.error === 'headless_unanswerable' || r?.error === undefined && r?.headless) {
135
+ out.push('**Answer:** none (no one was there to answer)');
136
+ }
137
+ else if (r?.cancelled) {
138
+ out.push('**Answer:** none (the question was closed)');
139
+ }
140
+ else {
141
+ out.push('**Answer:** not recorded');
142
+ }
143
+ out.push('');
144
+ }
145
+ return quote(out.slice(0, -1));
146
+ }
147
+ /** An approval CSPeach asked for: the summary, the changes (never the diff), the answer. */
148
+ function approvalBlock(input, result) {
149
+ const r = parse(result);
150
+ if (!r || r.error)
151
+ return null; // refused before anything was shown: no question was asked
152
+ const out = [`**CSPeach asked for approval:** ${String(input?.summary ?? 'a change')}`];
153
+ for (const c of Array.isArray(input?.changes) ? input.changes : []) {
154
+ out.push(`- ${String(c?.op ?? '')} ${String(c?.object ?? '')}${c?.type ? ` (${String(c.type)})` : ''}`.replace(/\s+/g, ' ').trimEnd());
155
+ }
156
+ let answer;
157
+ if (typeof r.advisory_outcome === 'string') {
158
+ answer = r.headless ? '**Answer (no one answered):** not applied'
159
+ : `**Answer:** ${r.advisory_outcome === 'applied' ? 'applied by hand' : r.advisory_outcome === 'aborted' ? 'stopped' : 'skipped'}`;
160
+ }
161
+ else if (r.auto_approved) {
162
+ answer = '**Answer (approved automatically, auto-approve is on):** Approved';
163
+ }
164
+ else if (typeof r.approved_by === 'string') {
165
+ answer = `**Answer (approved earlier, by ${r.approved_by}):** Approved`;
166
+ }
167
+ else if (r.reason === 'headless_no_approval') {
168
+ answer = '**Answer (no one answered):** Not approved';
169
+ }
170
+ else if (r.reason === 'plan_rejected') {
171
+ answer = '**Answer:** Cancelled at the plan gate';
172
+ }
173
+ else if (r.reason === 'user_cancelled_turn') {
174
+ answer = '**Answer:** Stopped';
175
+ }
176
+ else if (r.approved === true) {
177
+ const skipped = Array.isArray(r.skipped) ? r.skipped.map((s) => String(s?.object ?? '')).filter(Boolean) : [];
178
+ answer = skipped.length ? `**Answer:** Approved, except ${skipped.join(', ')} (skipped)` : '**Answer:** Approved';
179
+ }
180
+ else {
181
+ answer = '**Answer:** Not approved';
182
+ }
183
+ out.push(answer);
184
+ return quote(out);
185
+ }
186
+ /** One CLI card: the question, what it showed, the options and the answer. */
187
+ function cardBlock(c) {
188
+ const out = [`**CSPeach asked:** ${c.question}`];
189
+ // Fix round 2 (N-1): when it was answered (UTC, to the minute) and which plan it was about.
190
+ const when = c.at && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(c.at) ? `At: ${c.at.slice(0, 10)} ${c.at.slice(11, 16)} UTC` : '';
191
+ const about = c.ref ? `Plan: ${c.ref}` : '';
192
+ if (when || about)
193
+ out.push([when, about].filter(Boolean).join(' · '));
194
+ for (const f of c.facts ?? [])
195
+ out.push(`- ${f}`);
196
+ if (c.options?.length)
197
+ out.push(`Options: ${c.options.join(' · ')}`);
198
+ out.push(c.by === 'user' ? `**Answer:** ${c.answer}`
199
+ : c.by === 'auto' ? `**Answer (picked automatically):** ${c.answer}`
200
+ : `**Answer (no one at the terminal):** ${c.answer}`);
201
+ return quote(out);
202
+ }
203
+ /** The whole conversation, in order. Pure: the same session always gives the same text. */
204
+ export function conversationMarkdown(o) {
205
+ const results = resultsById(o.messages);
206
+ const parts = [
207
+ '# CSPeach conversation', '',
208
+ `- Date: ${o.date}`,
209
+ `- SAP system: ${o.system ? o.system : 'none'}`,
210
+ `- Session: ${o.sessionId}`,
211
+ ...(o.person ? [`- Signed in as: ${o.person}`] : []),
212
+ ];
213
+ let speaker = null;
214
+ const say = (who, text) => {
215
+ if (speaker !== who) {
216
+ parts.push('', who === 'you' ? '## You' : '## CSPeach');
217
+ speaker = who;
218
+ }
219
+ parts.push('', text);
220
+ };
221
+ // CLI cards, anchored by turn_seq. The turns kept in `messages` are the last
222
+ // ones, so the i-th turn start here is turn (turn_seq - count + 1 + i). A
223
+ // card answered in turn t is placed before the prompt of turn t + 1.
224
+ const cards = [...(o.session?.cli_cards ?? [])].sort((a, b) => a.turn_seq - b.turn_seq);
225
+ const starts = turnStarts(o.messages, o.session);
226
+ const marks = resolveCliMarks(o.session, o.messages);
227
+ const lastTurn = o.session?.turn_seq ?? starts.length;
228
+ const turnOf = new Map(starts.map((idx, i) => [idx, lastTurn - starts.length + 1 + i]));
229
+ let nextCard = 0;
230
+ const flushCards = (beforeTurn) => {
231
+ while (nextCard < cards.length && cards[nextCard].turn_seq < beforeTurn)
232
+ say('cspeach', cardBlock(cards[nextCard++]));
233
+ };
234
+ for (const [idx, m] of o.messages.entries()) {
235
+ if (m.role === 'user') {
236
+ const n = turnOf.get(idx);
237
+ if (n !== undefined)
238
+ flushCards(n);
239
+ const typed = userPromptText(m);
240
+ if (typed === null)
241
+ continue;
242
+ // A message CSPeach wrote itself: one plain line, never its body.
243
+ const mark = marks[idx];
244
+ if (mark && mark.kind !== 'steer') {
245
+ say('cspeach', `*${mark.line}*`);
246
+ continue;
247
+ }
248
+ const t = readerText(typedText(typed));
249
+ if (t.length)
250
+ say('you', t);
251
+ continue;
252
+ }
253
+ if (m.role !== 'assistant')
254
+ continue;
255
+ const list = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : blocks(m);
256
+ for (const b of list) {
257
+ if (b?.type === 'text' && typeof b.text === 'string') {
258
+ const t = readerText(b.text);
259
+ if (t.length)
260
+ say('cspeach', t);
261
+ }
262
+ else if (b?.type === 'tool_use' && typeof b.id === 'string') {
263
+ const block = b.name === 'ask_question' ? questionBlock(b.input, results.get(b.id))
264
+ : b.name === 'request_approval' ? approvalBlock(b.input, results.get(b.id))
265
+ : null;
266
+ if (block)
267
+ say('cspeach', block);
268
+ }
269
+ }
270
+ }
271
+ flushCards(Number.POSITIVE_INFINITY);
272
+ return parts.join('\n') + '\n';
273
+ }
274
+ function localDate() {
275
+ const d = new Date();
276
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
277
+ }
278
+ async function saveWithCopy(target, markdown, meta, emit) {
279
+ let mdPath;
280
+ try {
281
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
282
+ mdPath = await writeNoClobber(target, markdown);
283
+ }
284
+ catch (e) {
285
+ emit(`Not saved. ${e.message}`);
286
+ return 1;
287
+ }
288
+ emit(`Saved: ${path.resolve(mdPath)}`);
289
+ try {
290
+ const docx = await writeDocxNextTo(mdPath, markdown, meta);
291
+ emit(`Word copy: ${path.resolve(docx)}`);
292
+ return 0;
293
+ }
294
+ catch (e) {
295
+ emit(`Word copy not written. ${e.message}`);
296
+ return 1;
297
+ }
298
+ }
299
+ /** `/export docx …` in the REPL (Ink and classic call this one body). */
300
+ export async function runExportDocxRepl(o) {
301
+ const file = docxFileFromReplArgs(o.args);
302
+ const system = o.session.sap_system ?? null;
303
+ const cwd = o.cwd ?? process.cwd();
304
+ if (file !== undefined && /\.md$/i.test(file) && fs.existsSync(path.resolve(cwd, file))) {
305
+ // Unchanged: a Word copy next to an existing Markdown file.
306
+ return runExportDocxCommand({ file, docx: true, system, ...(o.cwd ? { cwd: o.cwd } : {}), ...(o.today ? { today: o.today } : {}), emit: o.emit });
307
+ }
308
+ const today = o.today ?? localDate();
309
+ const out = path.join(cwd, OUT_DIR);
310
+ if (file === undefined || !/^conversation$/i.test(file)) {
311
+ // The last answer. Any other name ("explain1", "explain1.docx", a .md that
312
+ // does not exist yet) is the name to save it under: bare names go to
313
+ // cspeach-out/, a name with a folder goes where it says.
314
+ const a = lastAnswer(o.session.messages, o.session);
315
+ if (!a) {
316
+ o.emit('There is no answer to export yet.');
317
+ return 1;
318
+ }
319
+ const meta = metaFromMarkdown(a.text, { date: today, kind: 'answer', ...(system ? { system } : {}) });
320
+ const raw = (file ?? '').replace(/\.(?:md|docx)$/i, '').trim().replace(/\\/g, '/');
321
+ const folder = raw.slice(0, raw.lastIndexOf('/') + 1);
322
+ const name = raw.slice(folder.length).trim() || `${today}-answer-${a.turn}`;
323
+ // A colon after the drive letter would write into a hidden Windows file
324
+ // stream (the user sees an empty file); the others are never valid.
325
+ if (/[<>:"|?*]/.test(folder.replace(/^[A-Za-z]:\//, '') + name)) {
326
+ o.emit(`"${file}" cannot be a file name. Use letters, numbers, spaces, - or _, for example: /export docx explain1`);
327
+ return 1;
328
+ }
329
+ const target = folder ? path.resolve(cwd, folder, `${name}.md`) : path.join(out, `${name}.md`);
330
+ return saveWithCopy(target, a.text, meta, o.emit);
331
+ }
332
+ if (turnStarts(o.session.messages, o.session).length === 0) {
333
+ o.emit('There is no conversation to export yet.');
334
+ return 1;
335
+ }
336
+ const md = conversationMarkdown({ messages: o.session.messages, session: o.session, date: today, system, sessionId: o.session.id, ...(o.person ? { person: o.person } : {}) });
337
+ const meta = {
338
+ title: 'CSPeach conversation', date: today, kind: 'conversation', session: o.session.id,
339
+ ...(system ? { system } : {}), ...(o.person ? { person: o.person } : {}),
340
+ };
341
+ const short = o.session.id.replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || 'session';
342
+ return saveWithCopy(path.join(out, `${today}-conversation-${short}.md`), md, meta, o.emit);
343
+ }
@@ -47,6 +47,9 @@ export const HELP_GROUPS = [
47
47
  { name: '/files', description: fixed('Pick a saved project file and what to do with it') },
48
48
  { name: '@<file>', description: fixed('Attach a file from this folder to your prompt') },
49
49
  { name: '/export audit [file]', description: fixed('Write the signable session audit record to a file') },
50
+ { name: '/export docx', description: fixed('Word copy of the last answer, saved in cspeach-out. Add a name to choose the file name: /export docx explain1') },
51
+ { name: '/export docx conversation', description: fixed('Word copy of the whole conversation: your messages, the answers, the questions and what you picked') },
52
+ { name: '/export docx <file>.md', description: fixed('Word copy of a Markdown file, next to it') },
50
53
  ] },
51
54
  { title: 'Session', rows: [
52
55
  { name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
@@ -1,4 +1,5 @@
1
1
  import { saveSession } from '../session/store.js';
2
+ import { rebuildCliMarksAfterCompact, PLAN_BOUNDARY_LINE } from '../session/cli-record.js';
2
3
  import { formatInfo } from '../renderer/notice-shape.js';
3
4
  import { recordFullNotice } from '../renderer/notice-log.js';
4
5
  import { paint } from '../renderer/theme.js';
@@ -35,7 +36,11 @@ export async function compactAtPhaseBoundary(session, summarise) {
35
36
  if (!summary || summary.trim().length === 0)
36
37
  throw new Error('the summary came back empty');
37
38
  const backupPath = await backupBeforeCompact(session);
39
+ const oldMessages = session.messages;
38
40
  session.messages = [buildPhaseBoundaryMessage(summary)];
41
+ // /export docx conversation: the summary is CSPeach's, not the developer's (one plain line there).
42
+ // Every earlier message is dropped: the record holds only the summary's mark.
43
+ rebuildCliMarksAfterCompact(session, oldMessages, oldMessages.length, session.messages[0].content, { kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE });
39
44
  delete session.last_context_tokens;
40
45
  await saveSession(session);
41
46
  return { performed: true, backupPath };
@@ -29,6 +29,17 @@ function stepTitle(envelope, phaseId) {
29
29
  const p = pc.ok ? pc.content.phases.find((q) => q.id === phaseId) : undefined;
30
30
  return p && pc.ok ? phaseDisplayTitle(p, pc.content.phases) : 'the interrupted step';
31
31
  }
32
+ /**
33
+ * /export docx conversation (2026-10-23): the one line that stands for a plan
34
+ * step prompt CSPeach wrote, e.g. "CSPeach ran plan step 2 (Skeleton)."
35
+ */
36
+ export function planStepOriginLine(prepared) {
37
+ const pc = parsePlanContent(prepared.envelope.content);
38
+ const i = pc.ok ? pc.content.phases.findIndex((q) => q.id === prepared.nextPhaseId) : -1;
39
+ if (!pc.ok || i < 0)
40
+ return 'CSPeach ran the next plan step.';
41
+ return `CSPeach ran plan step ${i + 1} (${phaseDisplayTitle(pc.content.phases[i], pc.content.phases)}).`;
42
+ }
32
43
  /** The one line shown when a bare "continue" re-attaches to an interrupted step. */
33
44
  export function continuationNotice(prepared) {
34
45
  return `Continuing ${stepTitle(prepared.envelope, prepared.nextPhaseId)} where it stopped`;