@cspeach/cli 1.1.44 → 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 (60) 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/parallel-write-guard.js +2 -0
  7. package/dist/agent/provider-shape.js +6 -2
  8. package/dist/agent/providers/ai-hub-provider.js +3 -1
  9. package/dist/agent/providers/azure-provider.js +3 -1
  10. package/dist/agent/providers/byok-provider.js +3 -1
  11. package/dist/agent/providers/local-provider.js +3 -1
  12. package/dist/agent/style-state.js +11 -0
  13. package/dist/agent/style.js +59 -0
  14. package/dist/agent/system-prompt.js +24 -3
  15. package/dist/agent/tool-dispatch.js +55 -0
  16. package/dist/approvals/jwt.js +5 -2
  17. package/dist/approvals/op-labels.js +39 -4
  18. package/dist/approvals/render.js +36 -9
  19. package/dist/approvals/risk-floor.js +14 -3
  20. package/dist/cli.js +8 -0
  21. package/dist/commands/config-set.js +59 -1
  22. package/dist/commands/config-show.js +1 -0
  23. package/dist/commands/export-docx.js +66 -0
  24. package/dist/commands/help.js +28 -2
  25. package/dist/config/loader.js +51 -1
  26. package/dist/docx/doc-meta.js +47 -0
  27. package/dist/docx/markdown-to-docx.js +250 -0
  28. package/dist/docx/write-docx.js +54 -0
  29. package/dist/one-shot.js +11 -0
  30. package/dist/projects/document-save.js +62 -0
  31. package/dist/projects/document-skills.js +9 -0
  32. package/dist/renderer/footer-line.js +6 -1
  33. package/dist/renderer/progress-chatter.js +9 -2
  34. package/dist/renderer/startup-lines.js +6 -2
  35. package/dist/renderer/tool-labels.js +5 -2
  36. package/dist/renderer/tool-widget.js +5 -1
  37. package/dist/repl/builtin-commands.js +4 -1
  38. package/dist/repl/chat-command.js +64 -0
  39. package/dist/repl/chat-lane.js +135 -0
  40. package/dist/repl/level-command.js +79 -0
  41. package/dist/repl/style-command.js +67 -0
  42. package/dist/repl.js +187 -105
  43. package/dist/sap/profile-features.js +5 -0
  44. package/dist/session/audit-export.js +50 -2
  45. package/dist/session/queued-step.js +5 -1
  46. package/dist/session/schema.js +1 -0
  47. package/dist/session/store.js +4 -0
  48. package/dist/tools/approval.js +59 -1
  49. package/dist/tools/hand-off.js +86 -0
  50. package/dist/tools/release-texts.js +9 -0
  51. package/dist/tools/sap-read.js +6 -2
  52. package/dist/tools/sap-run.js +163 -0
  53. package/dist/tools/tool-loading.js +11 -7
  54. package/dist/tools/transport.js +248 -22
  55. package/dist/tools/write-mode.js +2 -0
  56. package/dist/ui/approval-modal.js +11 -3
  57. package/dist/ui/sap-state-store.js +1 -0
  58. package/dist/ui/session-timeline.js +1 -1
  59. package/dist/ui/widgets/ask-card.js +2 -1
  60. package/package.json +3 -3
@@ -15,6 +15,8 @@ const usedNonces = new Set();
15
15
  * - approving `modify` covers modify → activate (write then activate).
16
16
  * - `delete` / `release` stay single-op (and single-use) — one-shot ops the
17
17
  * user must approve explicitly each time.
18
+ * - `run` (Part 3 D2) is single-op and single-use too: one approval runs a
19
+ * class or program once.
18
20
  *
19
21
  * This removes the op_mismatch APPROVAL_INVALID storm (create approval used,
20
22
  * then set_source rejected because it's a different op) without weakening the
@@ -26,11 +28,12 @@ const OP_COVERAGE = {
26
28
  delete: new Set(['delete']),
27
29
  activate: new Set(['activate']),
28
30
  release: new Set(['release']),
31
+ run: new Set(['run']),
29
32
  };
30
33
  /** create/modify approvals are reusable across their object's lifecycle ops
31
- * until TTL; delete/release are genuinely one-shot. */
34
+ * until TTL; delete/release/run are genuinely one-shot. */
32
35
  function isOneShotOp(op) {
33
- return op === 'delete' || op === 'release';
36
+ return op === 'delete' || op === 'release' || op === 'run';
34
37
  }
35
38
  export async function mintApprovalId(payload) {
36
39
  const nonce = crypto.randomBytes(16).toString('hex');
@@ -14,6 +14,7 @@ const VERBS = {
14
14
  delete: 'Delete',
15
15
  activate: 'Activate',
16
16
  release: 'Release',
17
+ run: 'Run',
17
18
  };
18
19
  /**
19
20
  * SAP ADT type codes → plain words. The `type` field is free text supplied by
@@ -45,6 +46,8 @@ const TYPE_WORDS = {
45
46
  FUNC: 'function module',
46
47
  FINC: 'function group include',
47
48
  INCL: 'include',
49
+ // Part 3 (T1 ruling): the release and the create card both read "transport request".
50
+ CTS: 'transport request',
48
51
  };
49
52
  export function humanVerb(op) {
50
53
  return VERBS[op] ?? op.charAt(0).toUpperCase() + op.slice(1);
@@ -86,6 +89,38 @@ export function affectsLine(change) {
86
89
  const t = sanitizeControlChars(String(change.affects ?? '')).trim();
87
90
  return t ? `Affects: ${t}` : null;
88
91
  }
92
+ /** Part 3 D12 — the longest request description a release card shows. */
93
+ const RELEASE_DESCRIPTION_MAX = 60;
94
+ const RELEASE_LAST_LINE = 'Cannot be undone. Once released, the request can move to the other systems on the transport route.';
95
+ /** Part 3 D13 — the card a person decides on says what CSPeach checks and where to check it after the release (owner wording 2026-10-17). */
96
+ export const RELEASE_CHECK_LINE = 'CSPeach reads SAP\'s verdict after each step and checks the request afterwards. Check the transport in SE09 after the release.';
97
+ /**
98
+ * Part 3 D12 — the fixed lines under a release change; [] for any other change
99
+ * or when no facts are attached. The facts are SAP's (request_approval reads
100
+ * them from the request before the card is drawn); text from SAP still passes
101
+ * through sanitizeControlChars. A task owned by someone other than the request
102
+ * owner is named (preflight F-15: SAP releases it under the caller's
103
+ * authorization).
104
+ */
105
+ export function releaseLines(change) {
106
+ const f = change.releaseFacts;
107
+ if (change.op !== 'release' || !f)
108
+ return [];
109
+ const clean = (v) => sanitizeControlChars(String(v ?? '')).trim();
110
+ let description = clean(f.description);
111
+ if (description.length > RELEASE_DESCRIPTION_MAX)
112
+ description = `${description.slice(0, RELEASE_DESCRIPTION_MAX)}…`;
113
+ const owner = clean(f.owner);
114
+ const tasks = Array.isArray(f.tasks) ? f.tasks : [];
115
+ const others = [...new Set(tasks.map((t) => clean(t.owner)).filter((o) => o && o !== owner))];
116
+ return [
117
+ `Request ${clean(f.request)} "${description}" · owner ${owner} · target ${clean(f.target) || 'none'}`,
118
+ `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'}: ${tasks.map((t) => clean(t.number)).join(', ')} · ${f.objects === null || f.objects === undefined ? 'objects: could not count' : `${f.objects} ${f.objects === 1 ? 'object' : 'objects'}`}`
119
+ + (others.length ? ` · other owners: ${others.join(', ')}` : ''),
120
+ RELEASE_LAST_LINE,
121
+ RELEASE_CHECK_LINE,
122
+ ];
123
+ }
89
124
  /**
90
125
  * The transport to DISPLAY on the approval surface for one change — mirrors
91
126
  * how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
@@ -97,8 +132,8 @@ export function affectsLine(change) {
97
132
  * resolveWriteTransport(args only, + owning-TR override) — NO session
98
133
  * fallback in the actual write, so we only show a transport the request
99
134
  * explicitly carries.
100
- * - activate/release: the tools take no transport arg at all — never show
101
- * a Transport segment.
135
+ * - activate/release/run: the tools take no transport arg at all — never
136
+ * show a Transport segment.
102
137
  * When nothing is resolvable, return undefined and the caller OMITS the
103
138
  * segment — no "new (session)" promise (a $TMP write never creates one).
104
139
  */
@@ -109,7 +144,7 @@ export function displayTransport(op, explicit, session) {
109
144
  case 'modify':
110
145
  case 'delete':
111
146
  return explicit;
112
- default: // activate / release
147
+ default: // activate / release / run
113
148
  return undefined;
114
149
  }
115
150
  }
@@ -132,7 +167,7 @@ export function toSourceLines(content) {
132
167
  * - modify → [d] Show diff (there IS a before/after to diff)
133
168
  * - create → [v] View source, only when the new source is carried
134
169
  * - delete → [v] View current source, only when the source is carried
135
- * - activate/release → nothing to review (no source in the payload)
170
+ * - activate/release/run → nothing to review (no source in the payload)
136
171
  */
137
172
  export function viewAffordance(change) {
138
173
  switch (change.op) {
@@ -1,7 +1,7 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { riskReasonText, shownRiskReasons } from './risk-floor.js';
4
- import { verbLine, viewAffordance, toSourceLines, affectsLine } from './op-labels.js';
4
+ import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } from './op-labels.js';
5
5
  import { shouldUseInk } from '../renderer/tty.js';
6
6
  import { approvalEmitter } from '../ui/approval-emitter.js';
7
7
  import { withInquirer } from '../repl/inquirer-guard.js';
@@ -118,22 +118,44 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
118
118
  // implementation) carries its "Affects:" row right under it. The card's
119
119
  // step budget counts rows, and a step is never shown without its row.
120
120
  // No affects anywhere: one row per step, exactly as before.
121
+ // Part 3 D12 / F-13 — a release step carries its fact lines the same way:
122
+ // "Apply all" mints it without its per-change card.
121
123
  const steps = visibleChanges.map((c, i) => {
122
124
  const affects = affectsLine(c);
123
- return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : [])];
125
+ return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : []), ...releaseLines(c).map((l) => ` ${l}`)];
124
126
  });
125
- const totalRows = steps.reduce((n, s) => n + s.length, 0);
127
+ // Fix round 1 (owner ruling) — a release step is ALWAYS on the card, with
128
+ // its fact lines, wherever it sits: its rows have their own budget and
129
+ // never count toward MAX_STEPS. The other steps keep today's budget, so a
130
+ // plan without a release renders exactly as before.
131
+ const isRelease = visibleChanges.map((c) => releaseLines(c).length > 0);
132
+ const totalRows = steps.reduce((n, s, i) => n + (isRelease[i] ? 0 : s.length), 0);
126
133
  if (totalRows > MAX_STEPS) {
127
134
  // Object names are model-supplied: neutralise control characters
128
135
  // before they reach scrollback (the card sanitises its own rows).
129
136
  emitTrace(sanitizeControlChars(['Plan', ...steps.flat().map((s) => ` ${s}`)].join('\n')));
137
+ const shownRows = [];
130
138
  let shown = 0;
139
+ let others = 0;
131
140
  let rows = 0;
132
- while (shown < steps.length && (shown === 0 || rows + steps[shown].length <= MAX_STEPS)) {
133
- rows += steps[shown].length;
134
- shown += 1;
135
- }
136
- facts.push(...steps.slice(0, shown).flat(), `${getGlyphs().more} ${steps.length - shown} more steps, listed above`);
141
+ let full = false;
142
+ steps.forEach((s, i) => {
143
+ if (isRelease[i]) {
144
+ shownRows.push(...s);
145
+ shown += 1;
146
+ return;
147
+ }
148
+ if (!full && (others === 0 || rows + s.length <= MAX_STEPS)) {
149
+ shownRows.push(...s);
150
+ rows += s.length;
151
+ others += 1;
152
+ shown += 1;
153
+ }
154
+ else {
155
+ full = true;
156
+ }
157
+ });
158
+ facts.push(...shownRows, `${getGlyphs().more} ${steps.length - shown} more steps, listed above`);
137
159
  }
138
160
  else {
139
161
  facts.push(...steps.flat());
@@ -180,6 +202,9 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
180
202
  const affects = affectsLine(c);
181
203
  if (affects)
182
204
  console.log(` ${chalk.dim(affects)}`);
205
+ // Part 3 D12 / F-13 — a release row's facts, under its line.
206
+ for (const l of releaseLines(c))
207
+ console.log(` ${chalk.dim(l)}`);
183
208
  });
184
209
  console.log('');
185
210
  const choice = await withInquirer(() => select({
@@ -213,8 +238,10 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
213
238
  choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
214
239
  // Part 2b D7 — the process a BAdI implementation runs in, under the verb line.
215
240
  const affects = affectsLine(change);
241
+ // Part 3 D12 — a release card's facts, under the verb line.
242
+ const release = releaseLines(change).map((l) => `\n ${chalk.dim(l)}`).join('');
216
243
  const choice = await withInquirer(() => select({
217
- message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}`,
244
+ message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}${release}`,
218
245
  default: 'apply',
219
246
  theme: inquirerTheme,
220
247
  choices,
@@ -48,8 +48,18 @@ export function effectiveRisk(declared, changes, ctx) {
48
48
  // Round 4 item 8a — these were both labelled 'shared_transport', so a plan
49
49
  // on a transport created seconds earlier read "raised … by shared_transport".
50
50
  // Each reason now says what actually raised it.
51
- if (changes.some((c) => c.op === 'release'))
52
- raise('medium', 'transport_release');
51
+ // Part 3 D2 — a release cannot be undone, and a run executes code on the
52
+ // system: both are high (never auto-approved), and the reason is always shown.
53
+ if (changes.some((c) => c.op === 'release')) {
54
+ raise('high', 'transport_release');
55
+ if (!raisedBy.includes('transport_release'))
56
+ raisedBy.push('transport_release');
57
+ }
58
+ if (changes.some((c) => c.op === 'run')) {
59
+ raise('high', 'code_run');
60
+ if (!raisedBy.includes('code_run'))
61
+ raisedBy.push('code_run');
62
+ }
53
63
  if (changes.some((c) => c.op === 'activate'))
54
64
  raise('medium', 'activation');
55
65
  if (ctx.recentAtcFailure && changes.some((c) => ctx.recentAtcFailure.includes(c.object)))
@@ -66,7 +76,7 @@ export function effectiveRisk(declared, changes, ctx) {
66
76
  const RISK_REASON_TEXT = {
67
77
  non_prod_override: 'auto-approve is set to never on this system (a setting, not a risk)',
68
78
  delete_op: 'the plan deletes an object',
69
- transport_release: 'the plan releases a transport',
79
+ transport_release: 'the plan releases a transport; this cannot be undone',
70
80
  activation: 'activation makes the change live on the system',
71
81
  atc_failed_24h: 'ATC failed on this object in the last 24 hours',
72
82
  shared_transport: 'the transport is shared by 3 or more developers',
@@ -74,6 +84,7 @@ const RISK_REASON_TEXT = {
74
84
  prod_namespace: 'the object is in a production namespace',
75
85
  syntax_errors: 'the last syntax check of this object found errors',
76
86
  standard_process: 'this code runs inside a standard SAP process (a BAdI implementation)',
87
+ code_run: 'this runs code on the SAP system',
77
88
  };
78
89
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
79
90
  export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
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'. */