@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
@@ -100,8 +100,13 @@ export function signatureChange(diff) {
100
100
  return false;
101
101
  let state = 'unknown';
102
102
  for (const raw of diff.split('\n')) {
103
- if (raw.startsWith('+++') || raw.startsWith('---'))
103
+ // LT-10 (1A fix wave) — a file header starts another file (a diff can
104
+ // cover several includes): a declaration still open in the file before
105
+ // it never carries over.
106
+ if (raw.startsWith('+++') || raw.startsWith('---')) {
107
+ state = 'unknown';
104
108
  continue;
109
+ }
105
110
  if (raw.startsWith('@@')) {
106
111
  // Task 7 (R2) / fix round 1 — a header naming the block
107
112
  // (`@@ METHOD to_internal. @@`) hints that the hunk starts in its body.
@@ -157,6 +162,7 @@ export function effectiveRisk(declared, changes, ctx) {
157
162
  // a body-only change read "may alter a method signature" when activation
158
163
  // was the true reason.
159
164
  const applied = [];
165
+ let syntaxObjectWord;
160
166
  const raise = (r, label, always = false) => {
161
167
  applied.push({ r, label, always });
162
168
  };
@@ -176,6 +182,16 @@ export function effectiveRisk(declared, changes, ctx) {
176
182
  // Final review A M-4 — the reason is always shown, even when the floor
177
183
  // was already high (policy never, or declared high).
178
184
  raise('high', 'syntax_errors', true);
185
+ // LT-10 — "this class" / "this program" from the RECORDED keys' types
186
+ // for the hit names (the match is by name, so the row's type may not be
187
+ // what was checked). Only when they all agree on one known word.
188
+ const hitNames = new Set(changes.filter(hit).map((c) => c.object.trim().split(/\s+/)[0].toUpperCase()));
189
+ const words = new Set(ctx.syntaxErrorObjects
190
+ .filter((k) => hitNames.has(k.slice(k.lastIndexOf(':') + 1).toUpperCase()))
191
+ .map((k) => ctx.typeWord?.(k.slice(0, k.lastIndexOf(':')))));
192
+ const [only] = [...words];
193
+ if (words.size === 1 && only)
194
+ syntaxObjectWord = only;
179
195
  }
180
196
  }
181
197
  // Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
@@ -231,7 +247,7 @@ export function effectiveRisk(declared, changes, ctx) {
231
247
  raisedBy.splice(act, 1);
232
248
  raisedBy.splice(sig, 0, 'activation');
233
249
  }
234
- return { level: floor, raisedBy };
250
+ return syntaxObjectWord ? { level: floor, raisedBy, syntaxObjectWord } : { level: floor, raisedBy };
235
251
  }
236
252
  /** Plain words for each risk reason code — the codes stay in audit data only. */
237
253
  const RISK_REASON_TEXT = {
@@ -252,15 +268,17 @@ const RISK_REASON_TEXT = {
252
268
  * Task 7 (R2) — one sentence on every request_approval result (and once in
253
269
  * the journey fix block): the risk line is CSPeach's reading of the diff, so
254
270
  * the model relays it rather than telling the person it is wrong.
271
+ * LT-6 (1A fix wave): the card already shows it, so the model neither repeats
272
+ * nor explains it unless asked (live: "rated medium only because…").
255
273
  */
256
- export const RISK_NOTE = "The risk line is CSPeach's reading of the diff; relay it, and if you think it is off, say why.";
274
+ export const RISK_NOTE = "The risk line is CSPeach's reading of the diff. Do not repeat, explain or dispute it unless the person asks.";
257
275
  /**
258
276
  * Task 7 (R2) — the risk fields of every request_approval result:
259
277
  * effective_risk and raised_by as before (older readers), plus risk { level,
260
278
  * reasons } in plain words and the note.
261
279
  */
262
280
  export function riskResultFields(eff) {
263
- const reasons = eff.raisedBy.map(riskReasonText); // round 8 item 4: plain words, never codes
281
+ const reasons = eff.raisedBy.map((c) => reasonTextFor(c, eff)); // round 8 item 4: plain words, never codes
264
282
  return { effective_risk: eff.level, raised_by: reasons, risk: { level: eff.level, reasons: [...reasons] }, note: RISK_NOTE };
265
283
  }
266
284
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
@@ -279,3 +297,13 @@ const NOT_A_RISK_REASON = new Set(['non_prod_override']);
279
297
  export function shownRiskReasons(raisedBy) {
280
298
  return raisedBy.filter((c) => !NOT_A_RISK_REASON.has(c));
281
299
  }
300
+ /** LT-10 — one reason in plain words; the syntax-errors one names the kind of object when known. */
301
+ function reasonTextFor(code, eff) {
302
+ if (code === 'syntax_errors' && eff.syntaxObjectWord)
303
+ return `the last syntax check of this ${eff.syntaxObjectWord} found errors`;
304
+ return riskReasonText(code);
305
+ }
306
+ /** LT-10 — the shown reasons of a result, in plain words (every approval surface). */
307
+ export function riskReasonTexts(eff) {
308
+ return shownRiskReasons(eff.raisedBy).map((c) => reasonTextFor(c, eff));
309
+ }
@@ -21,6 +21,56 @@ export function collapseImplicitActivates(changes) {
21
21
  return !writeKeys.has(`${c.type}:${c.object}`);
22
22
  });
23
23
  }
24
+ /**
25
+ * LT-12 (1A fix wave) — the rows a plan list shows. On top of the collapse
26
+ * above, a `modify` of an object that also has a `create` in the list is
27
+ * hidden: the create already covers its source write and activation
28
+ * (approvals/jwt.ts OP_COVERAGE), so "Create X" + "Change X" read as two
29
+ * changes for one new object. Fix round 1: only a modify with no diff of its
30
+ * own, or the same diff as the create, is hidden; a modify carrying other
31
+ * content stays, so the person sees it before "Apply all". When the create
32
+ * row has no behaviour (or affects) and a hidden modify has one, the row
33
+ * shows the modify's — a display copy, never the caller's object. Display
34
+ * only: the `changes[]` behind the card (approval ids, grants) and the gate
35
+ * count (collapseImplicitActivates) are untouched.
36
+ */
37
+ export function planRows(changes) {
38
+ const rows = collapseImplicitActivates(changes);
39
+ const createOf = new Map();
40
+ for (const c of rows)
41
+ if (c.op === 'create' && !createOf.has(`${c.type}:${c.object}`))
42
+ createOf.set(`${c.type}:${c.object}`, c);
43
+ const hidden = new Set();
44
+ const hiddenOf = new Map();
45
+ for (const c of rows) {
46
+ if (c.op !== 'modify')
47
+ continue;
48
+ const key = `${c.type}:${c.object}`;
49
+ const cr = createOf.get(key);
50
+ if (cr && (!c.diff || c.diff === cr.diff)) {
51
+ hidden.add(c);
52
+ if (!hiddenOf.has(key))
53
+ hiddenOf.set(key, c);
54
+ }
55
+ }
56
+ const out = [];
57
+ for (const c of rows) {
58
+ if (hidden.has(c))
59
+ continue;
60
+ const m = c.op === 'create' ? hiddenOf.get(`${c.type}:${c.object}`) : undefined;
61
+ if (!m) {
62
+ out.push(c);
63
+ continue;
64
+ }
65
+ const row = { ...c };
66
+ if (row.behaviour === undefined && m.behaviour !== undefined)
67
+ row.behaviour = m.behaviour;
68
+ if (row.affects === undefined && m.affects !== undefined)
69
+ row.affects = m.affects;
70
+ out.push(row);
71
+ }
72
+ return out;
73
+ }
24
74
  /**
25
75
  * Heuristic: a real unified diff has at least one line starting with `+`,
26
76
  * `-`, or `@@`. A summary or change description won't. Used to gate
package/dist/cli.js CHANGED
@@ -157,12 +157,20 @@ export async function main(argv) {
157
157
  // Task 12 — the full dump ends with "# model set by your account admin"
158
158
  // when /v1/me/model-config says the admin's model is enforced. Capped at
159
159
  // 1.5 s and fail-open: offline / signed out ⇒ the output is unchanged.
160
- const [{ loadConfig }, { getStore }, { loadCustomerModelConfig }] = await Promise.all([
160
+ const [{ loadConfig }, { getStore }, { loadCustomerModelConfig, loadServerModelConfig }] = await Promise.all([
161
161
  import('./config/loader.js'),
162
162
  import('./auth/api-key.js'),
163
163
  import('./models/server-config.js'),
164
164
  ]);
165
- await loadCustomerModelConfig(await loadConfig(), async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { });
165
+ const showCfg = await loadConfig();
166
+ await Promise.all([
167
+ loadCustomerModelConfig(showCfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { }),
168
+ // Stage 1A — only a stored `assistant.skills = inline` needs the served
169
+ // flag (the "not active" comment). Managed only, same cap, fail-open.
170
+ showCfg.assistant?.skills === 'inline'
171
+ ? loadServerModelConfig(showCfg, { timeoutMs: 1500 }).catch(() => { })
172
+ : Promise.resolve(),
173
+ ]);
166
174
  }
167
175
  await runConfigShow(args.slice(2));
168
176
  return 0;
@@ -308,7 +316,7 @@ Usage:
308
316
  cspeach Start interactive REPL
309
317
  cspeach "<prompt>" One-shot — runs one turn and exits
310
318
  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
319
+ cspeach export <file>.md --docx Write a Word copy next to a Markdown file
312
320
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
313
321
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
314
322
  cspeach --continue, -c Continue the latest session in this folder
@@ -33,6 +33,8 @@ import { paint } from '../renderer/theme.js';
33
33
  import { getGlyphs } from '../renderer/glyphs.js';
34
34
  import { saveSession } from '../session/store.js';
35
35
  import { isUserPrompt } from '../session/user-prompt.js';
36
+ import { stripSkillBlocksInMessage } from '../agent/inline-skill-block.js';
37
+ import { rebuildCliMarksAfterCompact, COMPACT_LINE } from '../session/cli-record.js';
36
38
  import { getActiveDeployments } from '../models/model-identity.js';
37
39
  /** Keep this many recent user-prompt anchored turns verbatim. */
38
40
  export const KEEP_RECENT_TURNS = 5;
@@ -130,7 +132,11 @@ export function buildSummarisationPrompt() {
130
132
  */
131
133
  export function serialiseForSummariser(messages) {
132
134
  const out = [];
133
- for (const msg of messages) {
135
+ for (const original of messages) {
136
+ // Stage 1A (S-A, M-3) — a playbook body never reaches the summariser: a
137
+ // hash-passing <skill> block in user text becomes [skill block: <name>].
138
+ // A message without one is the same object (byte-identical output).
139
+ const msg = stripSkillBlocksInMessage(original);
134
140
  if (typeof msg.content === 'string') {
135
141
  out.push({ role: msg.role, content: msg.content });
136
142
  }
@@ -305,7 +311,12 @@ export async function runCompact(opts) {
305
311
  // that produced it). The summary changed them, so the kept tail goes out
306
312
  // without thinking; text and tool calls stay. The backup above keeps all.
307
313
  const kept = plan.toKeep.map(stripThinking);
314
+ const oldMessages = session.messages;
308
315
  session.messages = [summaryMsg, ...kept];
316
+ // /export docx conversation: the summary is CSPeach's, not the developer's
317
+ // (one plain line there). The record is rebuilt in message order so the kept
318
+ // plan steps, continuations and steers keep their marks (final-review I-1).
319
+ rebuildCliMarksAfterCompact(session, oldMessages, plan.cutAt, summaryMsg.content, { kind: 'compact', line: COMPACT_LINE });
309
320
  // Task 19 — the recorded context size described the old history; the next
310
321
  // call records the new one. Clearing it keeps the auto-compact gate honest.
311
322
  delete session.last_context_tokens;
@@ -19,6 +19,7 @@ const MUTABLE_KEYS = new Set([
19
19
  'assistant.lane',
20
20
  'assistant.style',
21
21
  'assistant.level',
22
+ 'assistant.skills',
22
23
  'local_build',
23
24
  'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
24
25
  'show_cogs',
@@ -83,6 +84,7 @@ const PLAN_MODE_VALUES = ['step', 'guarded'];
83
84
  const PLAN_AUDIT_VALUES = ['on', 'off'];
84
85
  const USAGE_REPORTING_VALUES = ['on', 'off'];
85
86
  const ASSISTANT_LANE_VALUES = ['chat', 'skills'];
87
+ const ASSISTANT_SKILLS_VALUES = ['system', 'inline'];
86
88
  const APPROVALS_VALUES = ['plan', 'per-write'];
87
89
  /**
88
90
  * Thrown on validation failure. The outer CLI dispatcher converts to
@@ -547,6 +549,15 @@ export async function runConfigSet(args) {
547
549
  cfg.assistant.lane = lane;
548
550
  break;
549
551
  }
552
+ // Stage 1A — explicit, like the lane. markExplicit below keeps the table.
553
+ case 'assistant.skills': {
554
+ const skills = value.trim().toLowerCase();
555
+ if (skills !== 'system' && skills !== 'inline') {
556
+ err(`assistant.skills must be one of: ${ASSISTANT_SKILLS_VALUES.join(', ')}`);
557
+ }
558
+ cfg.assistant.skills = skills;
559
+ break;
560
+ }
550
561
  case 'assistant.style': {
551
562
  const style = parseStyle(value);
552
563
  if (!style)
@@ -616,7 +627,7 @@ export async function runConfigSet(args) {
616
627
  if (b === null)
617
628
  err(`${key} must be on/off (also accepts true/false, 1/0).`);
618
629
  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.'));
630
+ console.log(chalk.dim(b ? ' → a Word copy is written with every document CSPeach saves.' : ' → CSPeach will not ask about Word copies.'));
620
631
  break;
621
632
  }
622
633
  case 'write_mode':
@@ -800,6 +811,8 @@ export function validateValue(key, value) {
800
811
  return value === 'true' || value === 'false';
801
812
  case 'assistant.lane':
802
813
  return ASSISTANT_LANE_VALUES.includes(value.trim().toLowerCase());
814
+ case 'assistant.skills':
815
+ return ASSISTANT_SKILLS_VALUES.includes(value.trim().toLowerCase());
803
816
  case 'assistant.style':
804
817
  return parseStyle(value) !== null;
805
818
  case 'assistant.level':
@@ -3,6 +3,7 @@ import { loadConfig } from '../config/loader.js';
3
3
  import { paint } from '../renderer/theme.js';
4
4
  import { formatAttention } from '../renderer/notice-shape.js';
5
5
  import { getCustomerModelConfig } from '../models/server-config.js';
6
+ import { inlineInactiveReason, inlineServed } from '../repl/inline-mode.js';
6
7
  /**
7
8
  * Dotted paths of keys that must never appear in plaintext config output.
8
9
  * SAP passwords live in the keychain and should never land in config.toml,
@@ -74,7 +75,11 @@ function printLlmMissingHints(llm) {
74
75
  console.log(`ai_hub_router_model = ${paint('dim', '<not set — skill routing uses ai_hub_model_alias>')}`);
75
76
  }
76
77
  }
77
- function printConfigAsToml(cfg, prefix = '') {
78
+ /**
79
+ * `notes` maps a dotted key to a trailing comment (Stage 1A: a stored
80
+ * `assistant.skills = inline` that would not be active).
81
+ */
82
+ function printConfigAsToml(cfg, prefix = '', notes = {}) {
78
83
  // Emit scalars before nested objects — otherwise a top-level scalar that
79
84
  // follows a nested table (e.g. `write_mode` declared after `ui` in the
80
85
  // config shape) would render underneath `[ui]` in TOML, which reparses as
@@ -91,7 +96,8 @@ function printConfigAsToml(cfg, prefix = '') {
91
96
  console.log(`${k} = [REDACTED]`);
92
97
  }
93
98
  else {
94
- console.log(`${k} = ${formatTomlValue(v)}`);
99
+ const note = notes[fullKey];
100
+ console.log(`${k} = ${formatTomlValue(v)}` + (note ? chalk.gray(` # ${note}`) : ''));
95
101
  // Surface an explanatory hint for advisory-only mode so users understand
96
102
  // the implication (writes proposed, never executed) without having to
97
103
  // read the docs.
@@ -104,7 +110,7 @@ function printConfigAsToml(cfg, prefix = '') {
104
110
  const fullKey = prefix ? `${prefix}.${k}` : k;
105
111
  console.log('');
106
112
  console.log(chalk.bold(`[${fullKey}]`));
107
- printConfigAsToml(v, fullKey);
113
+ printConfigAsToml(v, fullKey, notes);
108
114
  // Mode-aware extras for the `[llm]` section — print required keys that
109
115
  // are empty as `<not set>` so a user who ran `cspeach config set llm.mode
110
116
  // ai-hub` without also setting the base URL can see what is still
@@ -165,7 +171,13 @@ export async function runConfigShow(args) {
165
171
  // Full config dump
166
172
  console.log('');
167
173
  console.log(chalk.gray('# ~/.cspeach/config.toml'));
168
- printConfigAsToml(cfg);
174
+ // Stage 1A — say when a stored `inline` would not be active in an
175
+ // interactive session (the served flag is loaded by cli.ts).
176
+ // Read defensively: a config without an [assistant] table means the defaults.
177
+ const inlineNote = cfg.assistant?.skills === 'inline'
178
+ ? inlineInactiveReason({ skills: 'inline', lane: cfg.assistant.lane, served: inlineServed(cfg.llm?.mode) })
179
+ : null;
180
+ printConfigAsToml(cfg, '', inlineNote ? { 'assistant.skills': inlineNote } : {});
169
181
  console.log('');
170
182
  // Task 12 — one trailing line when the account admin's model is enforced
171
183
  // by the proxy (/v1/me/model-config). No snapshot ⇒ output unchanged.
@@ -17,10 +17,33 @@ import path from 'node:path';
17
17
  import { buildAuditReport } from '../session/audit-export.js';
18
18
  import { globalCredits } from '../cost/credits.js';
19
19
  import { readCostLog } from '../cost/cost-log.js';
20
- /** Parse the text after `/export` into a subcommand + optional filename. */
20
+ /**
21
+ * The /export usage line (2026-10-23). Here, in the light audit module both
22
+ * REPL handlers already load, so `/export audit` and a mistyped `/export`
23
+ * never load the Word package (final-review I-2).
24
+ */
25
+ export const EXPORT_REPL_USAGE = 'Usage: /export docx (the last answer to Word) · /export docx <name> · /export docx conversation · /export audit [file]';
26
+ /**
27
+ * Parse the text after `/export` into a subcommand + optional filename.
28
+ *
29
+ * 2026-10-23 (a customer's screenshot): the Word forms people actually type
30
+ * all mean "Word": `/export docx x.docx`, `/export x.md --docx`,
31
+ * `/export x.docx`, `/export conversation`, `/export word`. A `--docx` or
32
+ * `--word` flag anywhere is dropped. For sub 'docx', `docxArgs` is the line
33
+ * to hand to runExportDocxRepl (" docx <rest>").
34
+ */
21
35
  export function parseExportArgs(rest) {
22
- const parts = rest.trim().split(/\s+/).filter(Boolean);
23
- return { sub: (parts[0] ?? '').toLowerCase(), filename: parts[1] };
36
+ let flag = false;
37
+ const cleaned = rest.replace(/(^|\s)--(?:docx|word)(?=\s|$)/gi, () => { flag = true; return ' '; }).trim();
38
+ const parts = cleaned.split(/\s+/).filter(Boolean);
39
+ const first = (parts[0] ?? '').toLowerCase();
40
+ if (first === 'docx' || first === 'word') {
41
+ return { sub: 'docx', docxArgs: ` docx ${cleaned.slice(parts[0].length).trim()}`.trimEnd() };
42
+ }
43
+ if (first !== 'audit' && (flag || first === 'conversation' || /\.(?:md|docx)["']?$/i.test(cleaned))) {
44
+ return { sub: 'docx', docxArgs: ` docx ${cleaned}`.trimEnd() };
45
+ }
46
+ return { sub: first, filename: parts[1] };
24
47
  }
25
48
  /** Deterministic default filename: date from session data, not now(). */
26
49
  export function defaultAuditFilename(session) {