@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.
- package/dist/agent/chat-prompt.js +47 -0
- package/dist/agent/chat-unavailable.js +9 -0
- package/dist/agent/level-state.js +11 -0
- package/dist/agent/level.js +60 -0
- package/dist/agent/loop.js +110 -2
- package/dist/agent/parallel-write-guard.js +2 -0
- package/dist/agent/provider-shape.js +6 -2
- package/dist/agent/providers/ai-hub-provider.js +3 -1
- package/dist/agent/providers/azure-provider.js +3 -1
- package/dist/agent/providers/byok-provider.js +3 -1
- package/dist/agent/providers/local-provider.js +3 -1
- package/dist/agent/style-state.js +11 -0
- package/dist/agent/style.js +59 -0
- package/dist/agent/system-prompt.js +24 -3
- package/dist/agent/tool-dispatch.js +55 -0
- package/dist/approvals/jwt.js +5 -2
- package/dist/approvals/op-labels.js +39 -4
- package/dist/approvals/render.js +36 -9
- package/dist/approvals/risk-floor.js +14 -3
- package/dist/cli.js +8 -0
- package/dist/commands/config-set.js +59 -1
- package/dist/commands/config-show.js +1 -0
- package/dist/commands/export-docx.js +66 -0
- package/dist/commands/help.js +28 -2
- package/dist/config/loader.js +51 -1
- package/dist/docx/doc-meta.js +47 -0
- package/dist/docx/markdown-to-docx.js +250 -0
- package/dist/docx/write-docx.js +54 -0
- package/dist/one-shot.js +11 -0
- package/dist/projects/document-save.js +62 -0
- package/dist/projects/document-skills.js +9 -0
- package/dist/renderer/footer-line.js +6 -1
- package/dist/renderer/progress-chatter.js +9 -2
- package/dist/renderer/startup-lines.js +6 -2
- package/dist/renderer/tool-labels.js +5 -2
- package/dist/renderer/tool-widget.js +5 -1
- package/dist/repl/builtin-commands.js +4 -1
- package/dist/repl/chat-command.js +64 -0
- package/dist/repl/chat-lane.js +135 -0
- package/dist/repl/level-command.js +79 -0
- package/dist/repl/style-command.js +67 -0
- package/dist/repl.js +187 -105
- package/dist/sap/profile-features.js +5 -0
- package/dist/session/audit-export.js +50 -2
- package/dist/session/queued-step.js +5 -1
- package/dist/session/schema.js +1 -0
- package/dist/session/store.js +4 -0
- package/dist/tools/approval.js +59 -1
- package/dist/tools/hand-off.js +86 -0
- package/dist/tools/release-texts.js +9 -0
- package/dist/tools/sap-read.js +6 -2
- package/dist/tools/sap-run.js +163 -0
- package/dist/tools/tool-loading.js +11 -7
- package/dist/tools/transport.js +248 -22
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/approval-modal.js +11 -3
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -1
- package/dist/ui/widgets/ask-card.js +2 -1
- package/package.json +3 -3
package/dist/approvals/jwt.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/approvals/render.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/config/loader.js
CHANGED
|
@@ -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'. */
|