@cspeach/cli 1.1.50 → 1.1.52

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.
Files changed (100) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +30 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +314 -44
  5. package/dist/agent/provider-shape.js +8 -4
  6. package/dist/agent/providers/ai-hub-provider.js +2 -2
  7. package/dist/agent/providers/azure-provider.js +2 -2
  8. package/dist/agent/providers/byok-provider.js +2 -2
  9. package/dist/agent/providers/local-provider.js +2 -2
  10. package/dist/agent/repair-partial.js +12 -0
  11. package/dist/agent/skill-block-key.js +134 -0
  12. package/dist/agent/steering-queue.js +50 -14
  13. package/dist/agent/system-prompt.js +22 -0
  14. package/dist/agent/tool-dispatch.js +19 -7
  15. package/dist/agent/turn-watchdog.js +1 -1
  16. package/dist/approvals/advisory-render.js +6 -3
  17. package/dist/approvals/approval-prompt.js +9 -4
  18. package/dist/approvals/op-labels.js +14 -0
  19. package/dist/approvals/plan-grant.js +8 -0
  20. package/dist/approvals/render.js +19 -8
  21. package/dist/approvals/risk-floor.js +32 -4
  22. package/dist/approvals/visible-changes.js +50 -0
  23. package/dist/cli.js +11 -3
  24. package/dist/commands/compact.js +12 -1
  25. package/dist/commands/config-set.js +14 -1
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-audit.js +26 -3
  28. package/dist/commands/export-conversation.js +346 -0
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/help.js +3 -0
  31. package/dist/commands/plan-boundary.js +5 -0
  32. package/dist/commands/plan-continue.js +11 -0
  33. package/dist/commands/plan-reground.js +27 -4
  34. package/dist/commands/plan-resume.js +111 -1
  35. package/dist/commands/plan-startup.js +5 -1
  36. package/dist/config/loader.js +5 -1
  37. package/dist/cost/cost-log.js +6 -0
  38. package/dist/cost/system-block.js +6 -0
  39. package/dist/docx/doc-meta.js +9 -1
  40. package/dist/docx/markdown-to-docx.js +14 -1
  41. package/dist/lane/app-chain.js +24 -1
  42. package/dist/models/server-config.js +1 -1
  43. package/dist/projects/document-save.js +65 -8
  44. package/dist/projects/document-skills.js +32 -1
  45. package/dist/projects/save-command.js +5 -1
  46. package/dist/projects/yes-no.js +5 -1
  47. package/dist/renderer/change-card.js +3 -1
  48. package/dist/renderer/steering-echo.js +53 -8
  49. package/dist/renderer/tool-labels.js +3 -1
  50. package/dist/repl/builtin-commands.js +1 -1
  51. package/dist/repl/chat-lane.js +5 -2
  52. package/dist/repl/inline-mode.js +51 -0
  53. package/dist/repl/leftover.js +90 -0
  54. package/dist/repl/reroute-turn.js +7 -1
  55. package/dist/repl/slash-completer.js +4 -0
  56. package/dist/repl/turn-stop.js +64 -0
  57. package/dist/repl.js +178 -32
  58. package/dist/router/classifier.js +1 -1
  59. package/dist/sap/reachability-state.js +7 -0
  60. package/dist/sap/unreachable.js +2 -2
  61. package/dist/session/cli-record.js +165 -0
  62. package/dist/session/fresh-start.js +4 -0
  63. package/dist/session/queued-step.js +3 -1
  64. package/dist/session/user-prompt.js +10 -1
  65. package/dist/skills/manifest-client.js +3 -2
  66. package/dist/skills/preamble.js +6 -4
  67. package/dist/skills/source-authed.js +3 -2
  68. package/dist/tools/approval.js +37 -7
  69. package/dist/tools/dispatch-skill.js +1 -1
  70. package/dist/tools/hand-off.js +49 -7
  71. package/dist/tools/lane/app-lane-resolve.js +9 -0
  72. package/dist/tools/method-only-change.js +241 -0
  73. package/dist/tools/rap/skeleton-resolve.js +4 -0
  74. package/dist/tools/sap-write.js +78 -3
  75. package/dist/tools/snapshot.js +7 -2
  76. package/dist/tools/subagent/agent_run.js +14 -0
  77. package/dist/tools/tool-loading.js +16 -8
  78. package/dist/tools/transport-fit-check.js +70 -0
  79. package/dist/tools/transport.js +17 -8
  80. package/dist/tools/write-pipeline.js +46 -5
  81. package/dist/ui/app.js +112 -13
  82. package/dist/ui/approval-modal.js +11 -6
  83. package/dist/ui/ascii-chars.js +21 -3
  84. package/dist/ui/card-slot.js +18 -1
  85. package/dist/ui/footer.js +128 -7
  86. package/dist/ui/interrupt.js +35 -1
  87. package/dist/ui/live-area.js +21 -4
  88. package/dist/ui/queue-box-emitter.js +14 -0
  89. package/dist/ui/rewind-panel.js +2 -1
  90. package/dist/ui/session-timeline.js +2 -1
  91. package/dist/ui/text-input.js +31 -2
  92. package/dist/ui/widgets/bar-chart.js +2 -1
  93. package/dist/ui/widgets/confirm-card.js +2 -1
  94. package/dist/ui/widgets/dep-graph.js +2 -1
  95. package/dist/ui/widgets/diff-viewer.js +2 -1
  96. package/dist/ui/widgets/question-card.js +2 -1
  97. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  98. package/dist/ui/widgets/stack-frames.js +2 -1
  99. package/dist/util/connect-retry.js +65 -0
  100. package/package.json +1 -1
@@ -0,0 +1,346 @@
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 { INJECTED_CONTEXT_TAGS, 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
+ /**
34
+ * Context blocks the CLI adds to a prompt (the developer never typed them) are
35
+ * dropped with the shared INJECTED_CONTEXT_TAGS list (session/user-prompt.ts),
36
+ * which carries Stage 1A's inline `<skill>` playbook blocks too.
37
+ */
38
+ function typedText(text) {
39
+ let out = text;
40
+ for (const tag of INJECTED_CONTEXT_TAGS) {
41
+ out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), '');
42
+ }
43
+ out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
44
+ return out.trim();
45
+ }
46
+ function blocks(m) {
47
+ return Array.isArray(m.content) ? m.content : [];
48
+ }
49
+ function assistantTexts(m) {
50
+ if (m.role !== 'assistant')
51
+ return [];
52
+ if (typeof m.content === 'string')
53
+ return [m.content];
54
+ return blocks(m).filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text);
55
+ }
56
+ /** Indexes of the messages that start a turn: a typed prompt, a plan step, a queued step (fix round 1, I-1/I-2). */
57
+ function turnStarts(messages, session) {
58
+ const out = [];
59
+ const marks = resolveCliMarks(session, messages);
60
+ messages.forEach((m, i) => { if (isUserPrompt(m) && startsTurn(marks[i]))
61
+ out.push(i); });
62
+ return out;
63
+ }
64
+ /**
65
+ * The last answer: everything CSPeach said after the last turn's prompt. A
66
+ * continuation after the output limit, a watchdog note or a mid-turn steer is
67
+ * not a new turn, so a long answer comes out whole. `turn` = session.turn_seq
68
+ * when the session has it (right after a compaction too), else the count.
69
+ */
70
+ export function lastAnswer(messages, session) {
71
+ const starts = turnStarts(messages, session);
72
+ if (starts.length === 0)
73
+ return null;
74
+ const last = starts[starts.length - 1];
75
+ const turn = session?.turn_seq ?? starts.length;
76
+ const text = readerText(messages.slice(last + 1).flatMap(assistantTexts).join('\n\n'));
77
+ return text.length > 0 ? { text: text + '\n', turn } : null;
78
+ }
79
+ /** tool_use_id → the result's text (only read for questions and approvals). */
80
+ function resultsById(messages) {
81
+ const out = new Map();
82
+ for (const m of messages) {
83
+ if (m.role !== 'user')
84
+ continue;
85
+ for (const b of blocks(m)) {
86
+ if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string')
87
+ continue;
88
+ const c = b.content;
89
+ const text = typeof c === 'string' ? c
90
+ : Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => String(x.text ?? '')).join('') : '';
91
+ out.set(b.tool_use_id, text);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ function parse(json) {
97
+ if (json === undefined)
98
+ return undefined;
99
+ try {
100
+ return JSON.parse(json);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ const quote = (lines) => lines.map((l) => (l.length ? `> ${l}` : '>')).join('\n');
107
+ /** One question (or a form of up to four) with its options and the answer picked. */
108
+ function questionBlock(input, result) {
109
+ let form;
110
+ try {
111
+ form = normalizeAskInput(input);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ const r = parse(result);
117
+ const answers = [];
118
+ if (r && Array.isArray(r.answers)) {
119
+ for (const a of r.answers)
120
+ answers.push({ id: String(a?.id), answer: a?.answer == null ? null : String(a.answer), auto: a?.auto_answered === true });
121
+ }
122
+ else if (r && 'answer' in r) {
123
+ answers.push({ id: String(r.id ?? form.questions[0]?.id), answer: r.answer == null ? null : String(r.answer), auto: r.auto_answered === true });
124
+ }
125
+ const out = [];
126
+ for (const q of form.questions) {
127
+ out.push(`**CSPeach asked:** ${q.question}`);
128
+ if (q.options.length)
129
+ out.push(`Options: ${q.options.map((o) => o.label).join(' · ')}`);
130
+ const a = answers.find((x) => x.id === q.id) ?? (form.questions.length === 1 ? answers[0] : undefined);
131
+ if (a && a.answer !== null && a.answer.length > 0) {
132
+ const shown = q.options.length && !q.freeText
133
+ ? a.answer.split(',').map((v) => q.options.find((o) => o.value === v)?.label ?? v).join(', ')
134
+ : a.answer;
135
+ out.push(a.auto ? `**Answer (picked automatically, no one answered):** ${shown}` : `**Answer:** ${shown}`);
136
+ }
137
+ else if (r?.error === 'headless_unanswerable' || r?.error === undefined && r?.headless) {
138
+ out.push('**Answer:** none (no one was there to answer)');
139
+ }
140
+ else if (r?.cancelled) {
141
+ out.push('**Answer:** none (the question was closed)');
142
+ }
143
+ else {
144
+ out.push('**Answer:** not recorded');
145
+ }
146
+ out.push('');
147
+ }
148
+ return quote(out.slice(0, -1));
149
+ }
150
+ /** An approval CSPeach asked for: the summary, the changes (never the diff), the answer. */
151
+ function approvalBlock(input, result) {
152
+ const r = parse(result);
153
+ if (!r || r.error)
154
+ return null; // refused before anything was shown: no question was asked
155
+ const out = [`**CSPeach asked for approval:** ${String(input?.summary ?? 'a change')}`];
156
+ for (const c of Array.isArray(input?.changes) ? input.changes : []) {
157
+ out.push(`- ${String(c?.op ?? '')} ${String(c?.object ?? '')}${c?.type ? ` (${String(c.type)})` : ''}`.replace(/\s+/g, ' ').trimEnd());
158
+ }
159
+ let answer;
160
+ if (typeof r.advisory_outcome === 'string') {
161
+ answer = r.headless ? '**Answer (no one answered):** not applied'
162
+ : `**Answer:** ${r.advisory_outcome === 'applied' ? 'applied by hand' : r.advisory_outcome === 'aborted' ? 'stopped' : 'skipped'}`;
163
+ }
164
+ else if (r.auto_approved) {
165
+ answer = '**Answer (approved automatically, auto-approve is on):** Approved';
166
+ }
167
+ else if (typeof r.approved_by === 'string') {
168
+ answer = `**Answer (approved earlier, by ${r.approved_by}):** Approved`;
169
+ }
170
+ else if (r.reason === 'headless_no_approval') {
171
+ answer = '**Answer (no one answered):** Not approved';
172
+ }
173
+ else if (r.reason === 'plan_rejected') {
174
+ answer = '**Answer:** Cancelled at the plan gate';
175
+ }
176
+ else if (r.reason === 'user_cancelled_turn') {
177
+ answer = '**Answer:** Stopped';
178
+ }
179
+ else if (r.approved === true) {
180
+ const skipped = Array.isArray(r.skipped) ? r.skipped.map((s) => String(s?.object ?? '')).filter(Boolean) : [];
181
+ answer = skipped.length ? `**Answer:** Approved, except ${skipped.join(', ')} (skipped)` : '**Answer:** Approved';
182
+ }
183
+ else {
184
+ answer = '**Answer:** Not approved';
185
+ }
186
+ out.push(answer);
187
+ return quote(out);
188
+ }
189
+ /** One CLI card: the question, what it showed, the options and the answer. */
190
+ function cardBlock(c) {
191
+ const out = [`**CSPeach asked:** ${c.question}`];
192
+ // Fix round 2 (N-1): when it was answered (UTC, to the minute) and which plan it was about.
193
+ 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` : '';
194
+ const about = c.ref ? `Plan: ${c.ref}` : '';
195
+ if (when || about)
196
+ out.push([when, about].filter(Boolean).join(' · '));
197
+ for (const f of c.facts ?? [])
198
+ out.push(`- ${f}`);
199
+ if (c.options?.length)
200
+ out.push(`Options: ${c.options.join(' · ')}`);
201
+ out.push(c.by === 'user' ? `**Answer:** ${c.answer}`
202
+ : c.by === 'auto' ? `**Answer (picked automatically):** ${c.answer}`
203
+ : `**Answer (no one at the terminal):** ${c.answer}`);
204
+ return quote(out);
205
+ }
206
+ /** The whole conversation, in order. Pure: the same session always gives the same text. */
207
+ export function conversationMarkdown(o) {
208
+ const results = resultsById(o.messages);
209
+ const parts = [
210
+ '# CSPeach conversation', '',
211
+ `- Date: ${o.date}`,
212
+ `- SAP system: ${o.system ? o.system : 'none'}`,
213
+ `- Session: ${o.sessionId}`,
214
+ ...(o.person ? [`- Signed in as: ${o.person}`] : []),
215
+ ];
216
+ let speaker = null;
217
+ const say = (who, text) => {
218
+ if (speaker !== who) {
219
+ parts.push('', who === 'you' ? '## You' : '## CSPeach');
220
+ speaker = who;
221
+ }
222
+ parts.push('', text);
223
+ };
224
+ // CLI cards, anchored by turn_seq. The turns kept in `messages` are the last
225
+ // ones, so the i-th turn start here is turn (turn_seq - count + 1 + i). A
226
+ // card answered in turn t is placed before the prompt of turn t + 1.
227
+ const cards = [...(o.session?.cli_cards ?? [])].sort((a, b) => a.turn_seq - b.turn_seq);
228
+ const starts = turnStarts(o.messages, o.session);
229
+ const marks = resolveCliMarks(o.session, o.messages);
230
+ const lastTurn = o.session?.turn_seq ?? starts.length;
231
+ const turnOf = new Map(starts.map((idx, i) => [idx, lastTurn - starts.length + 1 + i]));
232
+ let nextCard = 0;
233
+ const flushCards = (beforeTurn) => {
234
+ while (nextCard < cards.length && cards[nextCard].turn_seq < beforeTurn)
235
+ say('cspeach', cardBlock(cards[nextCard++]));
236
+ };
237
+ for (const [idx, m] of o.messages.entries()) {
238
+ if (m.role === 'user') {
239
+ const n = turnOf.get(idx);
240
+ if (n !== undefined)
241
+ flushCards(n);
242
+ const typed = userPromptText(m);
243
+ if (typed === null)
244
+ continue;
245
+ // A message CSPeach wrote itself: one plain line, never its body.
246
+ const mark = marks[idx];
247
+ if (mark && mark.kind !== 'steer') {
248
+ say('cspeach', `*${mark.line}*`);
249
+ continue;
250
+ }
251
+ const t = readerText(typedText(typed));
252
+ if (t.length)
253
+ say('you', t);
254
+ continue;
255
+ }
256
+ if (m.role !== 'assistant')
257
+ continue;
258
+ const list = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : blocks(m);
259
+ for (const b of list) {
260
+ if (b?.type === 'text' && typeof b.text === 'string') {
261
+ const t = readerText(b.text);
262
+ if (t.length)
263
+ say('cspeach', t);
264
+ }
265
+ else if (b?.type === 'tool_use' && typeof b.id === 'string') {
266
+ const block = b.name === 'ask_question' ? questionBlock(b.input, results.get(b.id))
267
+ : b.name === 'request_approval' ? approvalBlock(b.input, results.get(b.id))
268
+ : null;
269
+ if (block)
270
+ say('cspeach', block);
271
+ }
272
+ }
273
+ }
274
+ flushCards(Number.POSITIVE_INFINITY);
275
+ return parts.join('\n') + '\n';
276
+ }
277
+ function localDate() {
278
+ const d = new Date();
279
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
280
+ }
281
+ async function saveWithCopy(target, markdown, meta, emit) {
282
+ let mdPath;
283
+ try {
284
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
285
+ mdPath = await writeNoClobber(target, markdown);
286
+ }
287
+ catch (e) {
288
+ emit(`Not saved. ${e.message}`);
289
+ return 1;
290
+ }
291
+ emit(`Saved: ${path.resolve(mdPath)}`);
292
+ try {
293
+ const docx = await writeDocxNextTo(mdPath, markdown, meta);
294
+ emit(`Word copy: ${path.resolve(docx)}`);
295
+ return 0;
296
+ }
297
+ catch (e) {
298
+ emit(`Word copy not written. ${e.message}`);
299
+ return 1;
300
+ }
301
+ }
302
+ /** `/export docx …` in the REPL (Ink and classic call this one body). */
303
+ export async function runExportDocxRepl(o) {
304
+ const file = docxFileFromReplArgs(o.args);
305
+ const system = o.session.sap_system ?? null;
306
+ const cwd = o.cwd ?? process.cwd();
307
+ if (file !== undefined && /\.md$/i.test(file) && fs.existsSync(path.resolve(cwd, file))) {
308
+ // Unchanged: a Word copy next to an existing Markdown file.
309
+ return runExportDocxCommand({ file, docx: true, system, ...(o.cwd ? { cwd: o.cwd } : {}), ...(o.today ? { today: o.today } : {}), emit: o.emit });
310
+ }
311
+ const today = o.today ?? localDate();
312
+ const out = path.join(cwd, OUT_DIR);
313
+ if (file === undefined || !/^conversation$/i.test(file)) {
314
+ // The last answer. Any other name ("explain1", "explain1.docx", a .md that
315
+ // does not exist yet) is the name to save it under: bare names go to
316
+ // cspeach-out/, a name with a folder goes where it says.
317
+ const a = lastAnswer(o.session.messages, o.session);
318
+ if (!a) {
319
+ o.emit('There is no answer to export yet.');
320
+ return 1;
321
+ }
322
+ const meta = metaFromMarkdown(a.text, { date: today, kind: 'answer', ...(system ? { system } : {}) });
323
+ const raw = (file ?? '').replace(/\.(?:md|docx)$/i, '').trim().replace(/\\/g, '/');
324
+ const folder = raw.slice(0, raw.lastIndexOf('/') + 1);
325
+ const name = raw.slice(folder.length).trim() || `${today}-answer-${a.turn}`;
326
+ // A colon after the drive letter would write into a hidden Windows file
327
+ // stream (the user sees an empty file); the others are never valid.
328
+ if (/[<>:"|?*]/.test(folder.replace(/^[A-Za-z]:\//, '') + name)) {
329
+ o.emit(`"${file}" cannot be a file name. Use letters, numbers, spaces, - or _, for example: /export docx explain1`);
330
+ return 1;
331
+ }
332
+ const target = folder ? path.resolve(cwd, folder, `${name}.md`) : path.join(out, `${name}.md`);
333
+ return saveWithCopy(target, a.text, meta, o.emit);
334
+ }
335
+ if (turnStarts(o.session.messages, o.session).length === 0) {
336
+ o.emit('There is no conversation to export yet.');
337
+ return 1;
338
+ }
339
+ const md = conversationMarkdown({ messages: o.session.messages, session: o.session, date: today, system, sessionId: o.session.id, ...(o.person ? { person: o.person } : {}) });
340
+ const meta = {
341
+ title: 'CSPeach conversation', date: today, kind: 'conversation', session: o.session.id,
342
+ ...(system ? { system } : {}), ...(o.person ? { person: o.person } : {}),
343
+ };
344
+ const short = o.session.id.replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || 'session';
345
+ return saveWithCopy(path.join(out, `${today}-conversation-${short}.md`), md, meta, o.emit);
346
+ }
@@ -0,0 +1,124 @@
1
+ // cspeach-cli/src/commands/finish-check-guard.ts
2
+ //
3
+ // Finish-check guard (2026-10-23, inline-waiver findings §6 Option A).
4
+ // A plan step may not be saved "validated" while that turn's LAST
5
+ // rap_finish_check said fewer than 5 of 5 passed. The write-back
6
+ // downgrades it to "blocked" (never refuses: a refusal would cost another
7
+ // model turn). "validated-with-waiver" is kept only when the step's notes
8
+ // name every failed item id.
9
+ //
10
+ // The guard acts only when it reads the check confidently. No check this
11
+ // turn, an unreadable last check, a 5 of 5, or any other status → null
12
+ // (today's behaviour).
13
+ const TOOL_NAME = 'rap_finish_check';
14
+ // The real handler's shape (tools/rap/finish-check.ts formatFinish):
15
+ // Finish check: <n> of 5 passed
16
+ // F<k> <title> — passed | needs work: <problems>
17
+ const HEADER_RE = /^Finish check: (\d+) of (\d+) passed$/;
18
+ const ITEM_RE = /^(F[1-5]) .*? — (passed|needs work:.*)$/;
19
+ const ITEM_IDS = ['F1', 'F2', 'F3', 'F4', 'F5'];
20
+ function resultText(content) {
21
+ if (typeof content === 'string')
22
+ return content;
23
+ if (!Array.isArray(content))
24
+ return null;
25
+ const parts = [];
26
+ for (const b of content) {
27
+ if (b?.type === 'text' && typeof b.text === 'string')
28
+ parts.push(b.text);
29
+ }
30
+ return parts.length > 0 ? parts.join('\n') : null;
31
+ }
32
+ /** The text of this turn's LAST rap_finish_check tool result, or null when there is none. */
33
+ export function lastFinishCheckText(messages, messagesStart) {
34
+ const checkIds = new Set();
35
+ let last = null;
36
+ for (let i = Math.max(0, messagesStart); i < messages.length; i++) {
37
+ const m = messages[i];
38
+ if (!Array.isArray(m.content))
39
+ continue;
40
+ for (const b of m.content) {
41
+ if (!b || typeof b !== 'object')
42
+ continue;
43
+ if (m.role === 'assistant' && b.type === 'tool_use' && b.name === TOOL_NAME && typeof b.id === 'string') {
44
+ checkIds.add(b.id);
45
+ }
46
+ else if (m.role === 'user' && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && checkIds.has(b.tool_use_id)) {
47
+ // An unreadable last result still counts as the last one: it makes the guard stand down.
48
+ last = resultText(b.content) ?? '';
49
+ }
50
+ }
51
+ }
52
+ return last;
53
+ }
54
+ /** Parses a rap_finish_check result; null unless every line agrees with the handler's shape. */
55
+ export function parseFinishCheck(text) {
56
+ const lines = text.split(/\r?\n/).map((l) => l.trimEnd());
57
+ const head = HEADER_RE.exec(lines[0] ?? '');
58
+ if (!head)
59
+ return null;
60
+ const passed = Number(head[1]);
61
+ const total = Number(head[2]);
62
+ if (total !== ITEM_IDS.length || passed > total)
63
+ return null;
64
+ const seen = [];
65
+ const failed = [];
66
+ for (const line of lines.slice(1)) {
67
+ const m = ITEM_RE.exec(line);
68
+ if (!m)
69
+ continue;
70
+ seen.push(m[1]);
71
+ if (m[2] !== 'passed')
72
+ failed.push(m[1]);
73
+ }
74
+ if (seen.join(',') !== ITEM_IDS.join(','))
75
+ return null;
76
+ if (failed.length !== total - passed)
77
+ return null;
78
+ return { passed, total, failed };
79
+ }
80
+ const namesId = (notes, id) => new RegExp(`\\b${id}\\b`).test(notes);
81
+ /**
82
+ * The guard's note: one place for its wording. It goes into the step's
83
+ * work.notes and is shown as the run line.
84
+ * <step>: finish check 3 of 5, F1, F4 failed. Saved as blocked.
85
+ * <step>: finish check 3 of 5, F1, F4 failed. The notes do not waive F4. Saved as blocked.
86
+ */
87
+ const GUARD_NOTE_TAIL = 'Saved as blocked.';
88
+ const GUARD_NOTE_HEAD = 'finish check';
89
+ const GUARD_NOTE_RE = new RegExp(`: ${GUARD_NOTE_HEAD} \\d+ of \\d+, .* ${GUARD_NOTE_TAIL.replace(/\./g, '\\.')}$`);
90
+ export function formatFinishGuardNote(a) {
91
+ const waiver = a.notWaived && a.notWaived.length > 0 ? ` The notes do not waive ${a.notWaived.join(', ')}.` : '';
92
+ return `${a.step}: ${GUARD_NOTE_HEAD} ${a.score.passed} of ${a.score.total}, ${a.score.failed.join(', ')} failed.${waiver} ${GUARD_NOTE_TAIL}`;
93
+ }
94
+ /** True for a line the guard wrote itself (it never counts as a waiver). */
95
+ export function isFinishGuardNoteLine(line) {
96
+ return GUARD_NOTE_RE.test(line.trim());
97
+ }
98
+ /** The notes without the guard's own lines: only what the model or developer wrote. */
99
+ function notesWithoutGuardLines(notes) {
100
+ return notes.split(/\r?\n/).filter((l) => !isFinishGuardNoteLine(l)).join('\n');
101
+ }
102
+ /**
103
+ * The guard's ruling for the executed step: `{ status: 'blocked', note }` when
104
+ * the claimed status must be downgraded, otherwise null (save as written).
105
+ * A waiver counts from any turn's notes, but never from the guard's own lines.
106
+ */
107
+ export function finishCheckGuard(args) {
108
+ if (args.status !== 'validated' && args.status !== 'validated-with-waiver')
109
+ return null;
110
+ const text = lastFinishCheckText(args.messages, args.messagesStart);
111
+ if (text === null)
112
+ return null;
113
+ const score = parseFinishCheck(text);
114
+ if (!score || score.failed.length === 0)
115
+ return null;
116
+ let notWaived;
117
+ if (args.status === 'validated-with-waiver') {
118
+ const notes = notesWithoutGuardLines(args.notes ?? '');
119
+ notWaived = score.failed.filter((id) => !namesId(notes, id));
120
+ if (notWaived.length === 0)
121
+ return null;
122
+ }
123
+ return { status: 'blocked', note: formatFinishGuardNote({ step: args.step, score, notWaived }) };
124
+ }
@@ -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`;
@@ -99,10 +99,28 @@ export async function regroundPlan(a) {
99
99
  stillLocked.push(o.name);
100
100
  }
101
101
  let transportClosed = false;
102
+ let listed = null;
103
+ const isListed = (t, tr) => t.list.some((x) => x.trim().toUpperCase() === tr.trim().toUpperCase());
102
104
  if (transport) {
103
105
  const t = await a.deps.openTransports();
104
- transportClosed = t.complete && !t.list.some((x) => x.trim().toUpperCase() === transport.trim().toUpperCase());
106
+ listed = t;
107
+ transportClosed = t.complete && !isListed(t, transport);
105
108
  }
109
+ // The step transport counts as open only when SAP lists it. Any failure
110
+ // here keeps the old text and a "not open" verdict.
111
+ const step = a.stepTransport?.trim();
112
+ let stepOpen = false;
113
+ if (step) {
114
+ try {
115
+ listed = listed ?? await a.deps.openTransports();
116
+ stepOpen = isListed(listed, step);
117
+ }
118
+ catch (err) {
119
+ recordFullNotice('plan', `Transport check before a plan step failed: ${errText(err)}`);
120
+ }
121
+ }
122
+ const openStepTransport = !transport && stepOpen ? step : null;
123
+ const verdict = step ? { stepTransportOpen: stepOpen } : {};
106
124
  // An object left inactive gets the inactive instruction only, never the
107
125
  // "do not rewrite" one as well.
108
126
  const inactiveNames = new Set(inactive.map((o) => o.name));
@@ -124,7 +142,9 @@ export async function regroundPlan(a) {
124
142
  ? (transportClosed
125
143
  ? `- The plan's transport ${transport} is no longer open. Do not write to it. Creating a new transport needs the developer's approval (it is not on the approved list).`
126
144
  : `- Transport ${transport} is still open: use it.`)
127
- : '- No transport is recorded yet: the first writing step creates one.',
145
+ : openStepTransport
146
+ ? `- Transport ${openStepTransport} is open: use it.`
147
+ : '- No transport is recorded yet: the first writing step creates one.',
128
148
  '</sap_state>',
129
149
  ];
130
150
  if (stillLocked.length > 0) {
@@ -133,12 +153,14 @@ export async function regroundPlan(a) {
133
153
  ok: false, reason: 'locked',
134
154
  lines: [`${stillLocked.join(', ')} ${one ? 'is' : 'are'} still locked by another session. SAP frees ${one ? 'it' : 'them'} when that session ends or times out, usually within a few minutes.`],
135
155
  sapStateBlock: block,
156
+ ...verdict,
136
157
  };
137
158
  }
138
159
  const n = inactive.length;
139
160
  const summary = `Checked SAP before continuing: ${n} object${n === 1 ? '' : 's'} left inactive`
140
- + (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '') + '.';
141
- return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block };
161
+ + (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '')
162
+ + (openStepTransport ? `, transport ${openStepTransport} open` : '') + '.';
163
+ return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block, ...verdict };
142
164
  }
143
165
  catch (err) {
144
166
  const code = unreachableOf(err);
@@ -158,6 +180,7 @@ export async function regroundPlan(a) {
158
180
  built.length > 0 ? `- Already built by earlier steps of this plan: ${built.join(', ')}.` : '- No earlier step of this plan has built SAP objects yet.',
159
181
  '</sap_state>',
160
182
  ],
183
+ ...(a.stepTransport?.trim() ? { stepTransportOpen: false } : {}),
161
184
  };
162
185
  }
163
186
  }