@cspeach/cli 1.1.45 → 1.1.46

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 (45) hide show
  1. package/dist/agent/chat-prompt.js +47 -0
  2. package/dist/agent/chat-unavailable.js +9 -0
  3. package/dist/agent/level-state.js +11 -0
  4. package/dist/agent/level.js +60 -0
  5. package/dist/agent/loop.js +110 -2
  6. package/dist/agent/provider-shape.js +6 -2
  7. package/dist/agent/providers/ai-hub-provider.js +3 -1
  8. package/dist/agent/providers/azure-provider.js +3 -1
  9. package/dist/agent/providers/byok-provider.js +3 -1
  10. package/dist/agent/providers/local-provider.js +3 -1
  11. package/dist/agent/style-state.js +11 -0
  12. package/dist/agent/style.js +59 -0
  13. package/dist/agent/system-prompt.js +24 -3
  14. package/dist/agent/tool-dispatch.js +55 -0
  15. package/dist/cli.js +8 -0
  16. package/dist/commands/config-set.js +59 -1
  17. package/dist/commands/config-show.js +1 -0
  18. package/dist/commands/export-docx.js +66 -0
  19. package/dist/commands/help.js +28 -2
  20. package/dist/config/loader.js +51 -1
  21. package/dist/docx/doc-meta.js +47 -0
  22. package/dist/docx/markdown-to-docx.js +250 -0
  23. package/dist/docx/write-docx.js +54 -0
  24. package/dist/one-shot.js +10 -0
  25. package/dist/projects/document-save.js +62 -0
  26. package/dist/projects/document-skills.js +9 -0
  27. package/dist/renderer/footer-line.js +6 -1
  28. package/dist/renderer/startup-lines.js +6 -2
  29. package/dist/renderer/tool-labels.js +2 -1
  30. package/dist/renderer/tool-widget.js +5 -1
  31. package/dist/repl/builtin-commands.js +4 -1
  32. package/dist/repl/chat-command.js +64 -0
  33. package/dist/repl/chat-lane.js +135 -0
  34. package/dist/repl/level-command.js +79 -0
  35. package/dist/repl/style-command.js +67 -0
  36. package/dist/repl.js +186 -105
  37. package/dist/session/queued-step.js +5 -1
  38. package/dist/session/schema.js +1 -0
  39. package/dist/session/store.js +4 -0
  40. package/dist/tools/hand-off.js +86 -0
  41. package/dist/tools/tool-loading.js +3 -0
  42. package/dist/ui/sap-state-store.js +1 -0
  43. package/dist/ui/widgets/ask-card.js +2 -1
  44. package/package.json +2 -2
  45. package/.cspeach/project.json +0 -4
package/dist/cli.js CHANGED
@@ -275,6 +275,13 @@ export async function main(argv) {
275
275
  const { runOneShot } = await import('./one-shot.js');
276
276
  return runOneShot(args.slice(1).join(' '), { sapAlias, noSap });
277
277
  }
278
+ // Word export (2026-10-19): `cspeach export <file>.md --docx` writes a Word
279
+ // copy next to the file. Must sit above the one-shot fallback, or the words
280
+ // would be sent to the model as a paid prompt.
281
+ if (args[0] === 'export') {
282
+ const { parseExportDocxArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
283
+ return runExportDocxCommand({ ...parseExportDocxArgs(args.slice(1)), cwd: process.cwd(), system: noSap ? null : sapAlias ?? null, emit: (l) => console.log(l) });
284
+ }
278
285
  // Default: if first arg exists and is not a flag, treat as one-shot prompt.
279
286
  if (args.length > 0 && !args[0].startsWith('-')) {
280
287
  const { runOneShot } = await import('./one-shot.js');
@@ -301,6 +308,7 @@ Usage:
301
308
  cspeach Start interactive REPL
302
309
  cspeach "<prompt>" One-shot — runs one turn and exits
303
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
304
312
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
305
313
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
306
314
  cspeach --continue, -c Continue the latest session in this folder
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { formatAttention } from '../renderer/notice-shape.js';
3
3
  import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
4
+ import { ASSISTANT_STYLES, parseStyle } from '../agent/style.js';
5
+ import { ASSISTANT_LEVELS, parseLevel } from '../agent/level.js';
4
6
  import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
5
7
  /**
6
8
  * Mutable keys exposed via `cspeach config set`.
@@ -14,9 +16,13 @@ const MUTABLE_KEYS = new Set([
14
16
  'effort',
15
17
  'telemetry',
16
18
  'classifier.safe_mode',
19
+ 'assistant.lane',
20
+ 'assistant.style',
21
+ 'assistant.level',
17
22
  'local_build',
18
23
  'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
19
24
  'show_cogs',
25
+ 'word_copy',
20
26
  'write_mode',
21
27
  'plan_mode',
22
28
  'plan_audit',
@@ -76,6 +82,7 @@ const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local', 'azure'];
76
82
  const PLAN_MODE_VALUES = ['step', 'guarded'];
77
83
  const PLAN_AUDIT_VALUES = ['on', 'off'];
78
84
  const USAGE_REPORTING_VALUES = ['on', 'off'];
85
+ const ASSISTANT_LANE_VALUES = ['chat', 'skills'];
79
86
  const APPROVALS_VALUES = ['plan', 'per-write'];
80
87
  /**
81
88
  * Thrown on validation failure. The outer CLI dispatcher converts to
@@ -102,7 +109,7 @@ function errUnset(msg) {
102
109
  * Internal, loader-derived flags that are never serialised (stripped by
103
110
  * saveConfig). They must never be targets of `config set`/`config unset`.
104
111
  */
105
- const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys']);
112
+ const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys', 'assistant_file_keys']);
106
113
  /**
107
114
  * Virtual keys whose values live in the OS keychain, not config.toml. `config
108
115
  * unset` cannot remove them — that is a keychain operation — so they are
@@ -135,6 +142,18 @@ export function markExplicit(cfg, key) {
135
142
  cfg.file_keys = [];
136
143
  if (!cfg.file_keys.includes(top))
137
144
  cfg.file_keys.push(top);
145
+ // [assistant] is sparse per field too (staged rollout, 2026-10-17): an
146
+ // explicit `config set assistant.lane skills` must stick after a later
147
+ // release flips the lane default.
148
+ if (top === 'assistant') {
149
+ const leaf = key.split('.').slice(1).join('.');
150
+ if (leaf) {
151
+ if (!Array.isArray(cfg.assistant_file_keys))
152
+ cfg.assistant_file_keys = [];
153
+ if (!cfg.assistant_file_keys.includes(leaf))
154
+ cfg.assistant_file_keys.push(leaf);
155
+ }
156
+ }
138
157
  }
139
158
  /** Human-readable "allowed values" error for a standalone.<field> set. */
140
159
  function standaloneValueError(field, value) {
@@ -519,6 +538,30 @@ export async function runConfigSet(args) {
519
538
  }
520
539
  cfg.classifier.safe_mode = value === 'true';
521
540
  break;
541
+ // Chat lane (spec 2026-10-17 D2/D9). markExplicit below keeps the table.
542
+ case 'assistant.lane': {
543
+ const lane = value.trim().toLowerCase();
544
+ if (lane !== 'chat' && lane !== 'skills') {
545
+ err(`assistant.lane must be one of: ${ASSISTANT_LANE_VALUES.join(', ')}`);
546
+ }
547
+ cfg.assistant.lane = lane;
548
+ break;
549
+ }
550
+ case 'assistant.style': {
551
+ const style = parseStyle(value);
552
+ if (!style)
553
+ err(`assistant.style must be one of: ${ASSISTANT_STYLES.join(', ')}`);
554
+ cfg.assistant.style = style;
555
+ break;
556
+ }
557
+ // Spec §10 D13 — who CSPeach is talking to.
558
+ case 'assistant.level': {
559
+ const level = parseLevel(value);
560
+ if (!level)
561
+ err(`assistant.level must be one of: ${ASSISTANT_LEVELS.join(', ')}`);
562
+ cfg.assistant.level = level;
563
+ break;
564
+ }
522
565
  case 'local_files':
523
566
  // Deprecated alias (2026-06-13). Still applied — but it WRITES to the new
524
567
  // `local_build` key so the user's config converges on the new name. The
@@ -568,6 +611,14 @@ export async function runConfigSet(args) {
568
611
  : ' → cost figures will show credits on a managed, metered account.'));
569
612
  break;
570
613
  }
614
+ case 'word_copy': {
615
+ const b = coerceBool(value);
616
+ if (b === null)
617
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
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.'));
620
+ break;
621
+ }
571
622
  case 'write_mode':
572
623
  if (!isValidWriteMode(value)) {
573
624
  err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
@@ -747,9 +798,16 @@ export function validateValue(key, value) {
747
798
  return TELEMETRY_VALUES.includes(value);
748
799
  case 'classifier.safe_mode':
749
800
  return value === 'true' || value === 'false';
801
+ case 'assistant.lane':
802
+ return ASSISTANT_LANE_VALUES.includes(value.trim().toLowerCase());
803
+ case 'assistant.style':
804
+ return parseStyle(value) !== null;
805
+ case 'assistant.level':
806
+ return parseLevel(value) !== null;
750
807
  case 'local_build':
751
808
  case 'local_files': // deprecated alias — same on/off coercion
752
809
  case 'show_cogs':
810
+ case 'word_copy':
753
811
  return coerceBool(value) !== null;
754
812
  case 'write_mode':
755
813
  return isValidWriteMode(value);
@@ -26,6 +26,7 @@ const REDACTED_KEYS = new Set([
26
26
  const INTERNAL_KEYS = new Set([
27
27
  'default_model_set',
28
28
  'file_keys',
29
+ 'assistant_file_keys',
29
30
  // Task 4 — effort_set is loader-derived; effort_explicit is the stored
30
31
  // marker behind it (plumbing, not a user-facing setting).
31
32
  'effort_set',
@@ -0,0 +1,66 @@
1
+ // cspeach-cli/src/commands/export-docx.ts
2
+ //
3
+ // `cspeach export <file>.md --docx` and `/export docx <file>`: one body for both.
4
+ // Reads the Markdown, writes <base>.docx next to it (never overwriting), prints
5
+ // the path. Same emit seam as export-audit.ts (chunkEmitter in Ink, console.log
6
+ // in classic, console.log in cli.ts).
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { metaFromMarkdown } from '../docx/doc-meta.js';
10
+ import { writeDocxNextTo } from '../docx/write-docx.js';
11
+ export const EXPORT_DOCX_USAGE = 'Usage: cspeach export <file>.md --docx (in the REPL: /export docx <file>.md)';
12
+ export function parseExportDocxArgs(args) {
13
+ let docx = false;
14
+ let file;
15
+ for (const a of args) {
16
+ if (a === '--docx')
17
+ docx = true;
18
+ else if (!a.startsWith('-') && !file)
19
+ file = a;
20
+ }
21
+ return { ...(file ? { file } : {}), docx };
22
+ }
23
+ /**
24
+ * The file after `/export docx` in the REPL: the whole rest of the line, so a
25
+ * path with spaces survives (OneDrive folders), with one pair of quotes removed.
26
+ */
27
+ export function docxFileFromReplArgs(rest) {
28
+ const m = /^\s*docx\b\s*(.*)$/is.exec(rest);
29
+ if (!m)
30
+ return undefined;
31
+ const f = m[1].trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
32
+ return f.length > 0 ? f : undefined;
33
+ }
34
+ function localDate() {
35
+ const d = new Date();
36
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
37
+ }
38
+ export async function runExportDocxCommand(opts) {
39
+ const { emit } = opts;
40
+ if (!opts.docx || !opts.file) {
41
+ emit(EXPORT_DOCX_USAGE);
42
+ return 1;
43
+ }
44
+ const mdPath = path.resolve(opts.cwd ?? process.cwd(), opts.file);
45
+ if (path.extname(mdPath).toLowerCase() !== '.md') {
46
+ emit(`Only .md files can be exported to Word. ${EXPORT_DOCX_USAGE}`);
47
+ return 1;
48
+ }
49
+ if (!fs.existsSync(mdPath)) {
50
+ emit(`File not found: ${mdPath}`);
51
+ return 1;
52
+ }
53
+ try {
54
+ // Inside the try: a directory named x.md (EISDIR) or an unreadable file
55
+ // (EACCES) gives the one failure line below, not a stack.
56
+ const markdown = await fs.promises.readFile(mdPath, 'utf8');
57
+ const meta = metaFromMarkdown(markdown, { date: opts.today ?? localDate(), ...(opts.system ? { system: opts.system } : {}) });
58
+ const out = await writeDocxNextTo(mdPath, markdown, meta);
59
+ emit(`Word copy: ${out}`);
60
+ return 0;
61
+ }
62
+ catch (e) {
63
+ emit(`Word copy not written. ${e.message}`);
64
+ return 1;
65
+ }
66
+ }
@@ -50,6 +50,7 @@ export const HELP_GROUPS = [
50
50
  ] },
51
51
  { title: 'Session', rows: [
52
52
  { name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
53
+ { name: '/chat on', description: fixed('Switch to chat — just talk; /abap-… for a guided workflow') },
53
54
  { name: '/recap', description: fixed('Show the recap (last session, open tasks, plan)') },
54
55
  { name: '/cost', description: costCommandDescription },
55
56
  { name: '/usage', description: usageCommandDescription },
@@ -61,8 +62,30 @@ export const HELP_GROUPS = [
61
62
  { title: 'Look and settings', rows: [
62
63
  { name: '/ui [auto|ink]', description: fixed('Change the UI mode (applies after a relaunch)') },
63
64
  { name: '/ui look [panels|legacy]', description: fixed('Switch between the panels and legacy looks') },
65
+ { name: '/style [professional|conversational|humor|blunt]', description: fixed('Change the talking style — words only; findings and warnings never change') },
66
+ { name: '/level [developer|learning|functional]', description: fixed('Who CSPeach is talking to — how much it explains, never what it found; for a non-developer also run cspeach config set write_mode advisory-only') },
64
67
  ] },
65
68
  ];
69
+ /** Chat lane (spec 2026-10-17 D8): the Session group's /new reads "Fresh start"
70
+ * and /chat + /chat off replace the skills lane's /chat on row. */
71
+ const CHAT_HELP_GROUPS = HELP_GROUPS.map((g) => g.title !== 'Session' ? g : {
72
+ title: g.title,
73
+ rows: g.rows.flatMap((r) => {
74
+ if (r.name === '/new')
75
+ return [
76
+ { name: '/new', description: fixed('Fresh start (also /reset)') },
77
+ { name: '/chat', description: fixed('Back to chat — leave the current workflow') },
78
+ { name: '/chat off', description: fixed('Skill routing — every message picks a skill again') },
79
+ ];
80
+ if (r.name === '/chat on')
81
+ return [];
82
+ return [r];
83
+ }),
84
+ });
85
+ /** The help groups for a lane; the skills lane gets HELP_GROUPS itself. */
86
+ export function helpGroupsFor(lane) {
87
+ return lane === 'chat' ? CHAT_HELP_GROUPS : HELP_GROUPS;
88
+ }
66
89
  /** /help lists the `?` key itself first, then I2's sheet rows in their order. */
67
90
  const SHORTCUTS = [['?', 'shortcuts'], ...SHORTCUT_ROWS];
68
91
  /**
@@ -103,14 +126,17 @@ const groupTitle = (t) => paint('sap', chalk.bold(t));
103
126
  * can route output through chunkEmitter instead of console.log (which
104
127
  * corrupts the React frame). Default keeps the classic-mode behaviour.
105
128
  */
106
- export function printHelp(emit = (s) => console.log(s)) {
129
+ export function printHelp(emit = (s) => console.log(s), opts = {}) {
130
+ const lane = opts.lane ?? 'skills';
107
131
  const width = uiWidth();
108
132
  const descWidth = Math.max(MIN_DESC_WIDTH, width - NAME_COL_WIDTH);
109
133
  const continuationIndent = ' '.repeat(NAME_COL_WIDTH);
110
134
  const row = (name, desc) => ` ${chalk.bold(truncateToWidth(name, NAME_COL_PAD).padEnd(NAME_COL_PAD))} ${paint('dim', truncateToWidth(desc, descWidth))}`;
111
135
  emit('');
112
136
  emit(panelHeading('Commands', 'type / to search'));
113
- for (const group of HELP_GROUPS) {
137
+ if (lane === 'chat')
138
+ emit(paint('dim', 'Just talk to me. Use /abap-… when you want a specific guided workflow.'));
139
+ for (const group of helpGroupsFor(lane)) {
114
140
  emit('');
115
141
  emit(groupTitle(group.title));
116
142
  for (const r of group.rows) {
@@ -4,6 +4,8 @@ import toml from '@iarna/toml';
4
4
  import { configFile, cspeachRoot } from './paths.js';
5
5
  import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
6
6
  import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
7
+ import { DEFAULT_STYLE, parseStyle } from '../agent/style.js';
8
+ import { DEFAULT_LEVEL, parseLevel } from '../agent/level.js';
7
9
  /**
8
10
  * Whitelist guard for WriteMode — rejects typos / stale values from disk so
9
11
  * downstream code that trusts the WriteMode type doesn't see garbage.
@@ -36,6 +38,12 @@ export const DEFAULT_CONFIG = {
36
38
  // the default; 'legacy' reproduces the pre-overhaul output exactly.
37
39
  // `background` is left unset: getBackground() then reads COLORFGBG, else dark.
38
40
  ui: { rendering: 'auto', look: 'panels' },
41
+ // 2026-10-17 — chat lane (spec docs/superpowers/specs/2026-10-17-chat-lane-and-style-design.md
42
+ // D2/D9). 'skills' is today's router — the default for the first release
43
+ // (staged rollout, owner 2026-10-17; users opt in with /chat on, a later
44
+ // release flips this); 'professional' sends no style; 'developer' sends no
45
+ // level (§10 D13).
46
+ assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL },
39
47
  // Default `approval-gated` — preserves current UX (user approves each write).
40
48
  write_mode: 'approval-gated',
41
49
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
@@ -186,6 +194,14 @@ export async function loadConfig() {
186
194
  // when it's strictly true — a string/typo collapses to "absent" so it
187
195
  // can't win the `local_build ?? local_files` fallback.
188
196
  local_files: parsed.local_files === true ? true : undefined,
197
+ // word_copy: same absent-preserving coercion as local_build. Absent →
198
+ // undefined ("ask"); a typo collapses to false ("never ask"), so a typo
199
+ // can never write files nobody asked for.
200
+ word_copy: parsed.word_copy === true
201
+ ? true
202
+ : parsed.word_copy === undefined
203
+ ? undefined
204
+ : false,
189
205
  // render_smoke: plain boolean, DEFAULT true (no legacy alias). Preserve
190
206
  // only a genuine boolean; a typo/wrong-type collapses to undefined which
191
207
  // resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
@@ -268,6 +284,9 @@ export async function loadConfig() {
268
284
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
269
285
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
270
286
  ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
287
+ assistant: { ...DEFAULT_CONFIG.assistant, ...(parsed.assistant ?? {}) },
288
+ // INTERNAL — the sub-keys the file's [assistant] table carried (see the type).
289
+ assistant_file_keys: parsed.assistant && typeof parsed.assistant === 'object' ? Object.keys(parsed.assistant) : [],
271
290
  // Deep-merge `compact` so a user config that only overrides one
272
291
  // field (e.g. just `keep_recent_turns = 10`) still gets the four
273
292
  // unspecified defaults — and a typo'd field falls through harmlessly.
@@ -284,6 +303,11 @@ export async function loadConfig() {
284
303
  // 2026-09-26 — unknown values fall back instead of reaching the renderer.
285
304
  if (merged.ui.look !== 'panels' && merged.ui.look !== 'legacy')
286
305
  merged.ui.look = DEFAULT_CONFIG.ui.look;
306
+ // Chat lane (2026-10-17) — unknown values fall back, like ui.look.
307
+ if (merged.assistant.lane !== 'chat' && merged.assistant.lane !== 'skills')
308
+ merged.assistant.lane = DEFAULT_CONFIG.assistant.lane;
309
+ merged.assistant.style = parseStyle(merged.assistant.style) ?? DEFAULT_CONFIG.assistant.style;
310
+ merged.assistant.level = parseLevel(merged.assistant.level) ?? DEFAULT_CONFIG.assistant.level;
287
311
  if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
288
312
  delete merged.ui.background;
289
313
  }
@@ -311,6 +335,9 @@ export async function loadConfig() {
311
335
  // callers see a genuine "absent" shape (treated as OFF → credits).
312
336
  if (merged.show_cogs === undefined)
313
337
  delete merged.show_cogs;
338
+ // Same for `word_copy` (absent = ask each time).
339
+ if (merged.word_copy === undefined)
340
+ delete merged.word_copy;
314
341
  // Same for `plan_mode`: absent-or-invalid must not serialise as
315
342
  // `plan_mode = undefined` — drop the key so `config show` stays clean and
316
343
  // callers see a genuine "absent" shape (treated as 'step').
@@ -378,6 +405,14 @@ export async function loadConfig() {
378
405
  throw err;
379
406
  }
380
407
  }
408
+ /** The effective Word-copy behaviour of the save hook (see `word_copy`). */
409
+ export function resolveWordCopy(cfg) {
410
+ if (cfg.word_copy === true)
411
+ return 'always';
412
+ if (cfg.word_copy === false)
413
+ return 'never';
414
+ return 'ask';
415
+ }
381
416
  /**
382
417
  * Resolve the effective "local build tools" toggle from a loaded config.
383
418
  *
@@ -432,7 +467,7 @@ export async function saveConfig(config) {
432
467
  // file-key snapshot) and must never be written back into config.toml.
433
468
  // effort_set is internal too (Task 4); the stored marker effort_explicit is kept.
434
469
  // compact_threshold_set is internal too (Task 19).
435
- const { default_model_set: _dropSet, effort_set: _dropEffortSet, compact_threshold_set: _dropThresholdSet, file_keys: _dropKeys, ...toWrite } = config;
470
+ const { default_model_set: _dropSet, effort_set: _dropEffortSet, compact_threshold_set: _dropThresholdSet, file_keys: _dropKeys, assistant_file_keys: assistantFileKeys, ...toWrite } = config;
436
471
  // Sparse serialisation: drop any key that the file did NOT carry AND whose
437
472
  // value is deep-equal to the built-in default. Keys absent from DEFAULT_CONFIG
438
473
  // (optional keys like audit_model) never deep-equal a defined value, so any
@@ -446,6 +481,21 @@ export async function saveConfig(config) {
446
481
  delete writable[k];
447
482
  }
448
483
  }
484
+ // The same rule one level down for [assistant] (staged rollout, 2026-10-17):
485
+ // write a field only when the file carried it or it differs from the default.
486
+ if (writable.assistant && typeof writable.assistant === 'object') {
487
+ const keep = assistantFileKeys ?? [];
488
+ const defaults = DEFAULT_CONFIG.assistant;
489
+ const a = { ...writable.assistant };
490
+ for (const k of Object.keys(a)) {
491
+ if (!keep.includes(k) && a[k] === defaults[k])
492
+ delete a[k];
493
+ }
494
+ if (Object.keys(a).length === 0)
495
+ delete writable.assistant;
496
+ else
497
+ writable.assistant = a;
498
+ }
449
499
  await fs.writeFile(configFile(), toml.stringify(writable), 'utf-8');
450
500
  }
451
501
  /** The effective plan-run approval mode — absent/invalid ⇒ 'plan'. */
@@ -0,0 +1,47 @@
1
+ // cspeach-cli/src/docx/doc-meta.ts
2
+ //
3
+ // The metadata a Word copy carries on its title page. Filled by the caller
4
+ // (the save hook knows the session's SAP alias; the export command knows the
5
+ // file) and, for what the text itself says, by metaFromMarkdown (Task 3).
6
+ const KIND_TITLE = { review: 'Code review', handover: 'Handover', document: 'Document' };
7
+ /** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
8
+ const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
9
+ /** Inline Markdown → plain words: `code` → code, [text](url) → text, **x** → x. */
10
+ function plain(s) {
11
+ return s
12
+ .replace(/`([^`]*)`/g, '$1')
13
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
14
+ .replace(/\*\*([^*]*)\*\*/g, '$1')
15
+ // *x* / _x_ only at word edges, so ZFI_DUP_INVOICE_CHECK keeps its underscores.
16
+ .replace(/(^|[^\w])([*_])([^*_\n]+)\2(?!\w)/g, '$1$3')
17
+ .replace(/\s+/g, ' ')
18
+ .trim();
19
+ }
20
+ /**
21
+ * Title = the first ATX heading (any level), cleaned. Object = the Z/Y name after
22
+ * " — " / " - " in that heading, else the first Z/Y name anywhere in that
23
+ * heading, else the first `**Object:** NAME` line. Kind from
24
+ * the hints. Never throws; a text with no heading gets the kind's title.
25
+ */
26
+ export function metaFromMarkdown(markdown, hints) {
27
+ const lines = (markdown ?? '').split(/\r?\n/);
28
+ const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l));
29
+ const title = heading ? plain(heading.replace(/^#{1,6}\s+/, '')) : KIND_TITLE[hints.kind ?? 'document'];
30
+ let object;
31
+ const afterDash = heading ? plain(heading).split(/\s[—–-]\s/)[1] : undefined;
32
+ object = afterDash?.match(OBJECT_RE)?.[1];
33
+ // Headings without a dash: "Performance Analysis: ZCL_X", "ZCL_X (CLAS)".
34
+ if (!object && heading)
35
+ object = plain(heading).match(OBJECT_RE)?.[1];
36
+ if (!object) {
37
+ const objLine = lines.find((l) => /^\*\*Object:\*\*/i.test(l.trim()));
38
+ object = objLine ? plain(objLine.replace(/^\*\*Object:\*\*/i, '')).match(OBJECT_RE)?.[1] : undefined;
39
+ }
40
+ return {
41
+ title,
42
+ date: hints.date,
43
+ ...(hints.system ? { system: hints.system } : {}),
44
+ ...(object ? { object } : {}),
45
+ ...(hints.kind ? { kind: hints.kind } : {}),
46
+ };
47
+ }