@cspeach/cli 0.9.0 → 1.1.0
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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -59,18 +59,6 @@ export function disableBracketedPaste(stream = process.stdout) {
|
|
|
59
59
|
if (stream.isTTY)
|
|
60
60
|
stream.write(BRACKETED_PASTE_DISABLE);
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Transform stream that collapses the content between `\x1b[200~` and
|
|
64
|
-
* `\x1b[201~` into a single line (newlines replaced with spaces). The
|
|
65
|
-
* markers themselves are stripped. All other bytes pass through
|
|
66
|
-
* unchanged, including keystrokes, arrow-key escape sequences, and
|
|
67
|
-
* terminal responses — these are not affected because they never contain
|
|
68
|
-
* the paste markers.
|
|
69
|
-
*
|
|
70
|
-
* The implementation uses Buffer operations (not string concat) to avoid
|
|
71
|
-
* splitting multi-byte UTF-8 sequences at chunk boundaries when a paste
|
|
72
|
-
* spans multiple `data` events.
|
|
73
|
-
*/
|
|
74
62
|
export class BracketedPasteDecoder extends Transform {
|
|
75
63
|
/**
|
|
76
64
|
* Readline with `terminal: true` inspects `input.isTTY` and calls
|
|
@@ -81,9 +69,17 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
81
69
|
*
|
|
82
70
|
* We present as a TTY and forward the raw-mode toggle and the size
|
|
83
71
|
* getters to the real `process.stdin` / `process.stdout`.
|
|
72
|
+
*
|
|
73
|
+
* (Typed `boolean`, not the literal `true`, so InkStdinPasteGuard can
|
|
74
|
+
* mirror the real process.stdin.isTTY instead.)
|
|
84
75
|
*/
|
|
85
76
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
86
77
|
isTTY = true;
|
|
78
|
+
emitVerbatim;
|
|
79
|
+
constructor(options = {}) {
|
|
80
|
+
super();
|
|
81
|
+
this.emitVerbatim = options.emitVerbatim ?? false;
|
|
82
|
+
}
|
|
87
83
|
setRawMode(mode) {
|
|
88
84
|
if (typeof process.stdin.setRawMode === 'function')
|
|
89
85
|
process.stdin.setRawMode(mode);
|
|
@@ -166,9 +162,9 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
166
162
|
this.carryTimer = null;
|
|
167
163
|
}
|
|
168
164
|
// End of stream. If we were still inside a paste (unterminated),
|
|
169
|
-
// flush the buffered content
|
|
165
|
+
// flush the buffered content so it's not lost.
|
|
170
166
|
if (this.inPaste && this.pasteBuf.length > 0) {
|
|
171
|
-
this.push(this.collapseNewlines(this.pasteBuf));
|
|
167
|
+
this.push(this.emitVerbatim ? this.pasteBuf : this.collapseNewlines(this.pasteBuf));
|
|
172
168
|
this.pasteBuf = Buffer.alloc(0);
|
|
173
169
|
this.inPaste = false;
|
|
174
170
|
}
|
|
@@ -192,11 +188,24 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
192
188
|
return;
|
|
193
189
|
}
|
|
194
190
|
this.pasteBuf = Buffer.concat([this.pasteBuf, data.subarray(i, endIdx)]);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
191
|
+
if (this.emitVerbatim) {
|
|
192
|
+
// Ink mode: ONE chunk, newlines intact, markers stripped, no
|
|
193
|
+
// synthetic Enter. Downstream (Ink useInput → text-input paste
|
|
194
|
+
// branch) buffers the complete lines and keeps the fragment —
|
|
195
|
+
// nothing submits until the user presses Enter. Crucially a
|
|
196
|
+
// `\r` that arrived as its own stdin chunk INSIDE the span was
|
|
197
|
+
// accumulated here instead of reaching Ink's parseKeypress,
|
|
198
|
+
// which would have flagged it key.return and submitted.
|
|
199
|
+
if (this.pasteBuf.length > 0)
|
|
200
|
+
this.push(this.pasteBuf);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.push(this.collapseNewlines(this.pasteBuf));
|
|
204
|
+
// Append a newline so readline commits the pasted line as a
|
|
205
|
+
// single input — otherwise the user would still have to press
|
|
206
|
+
// Enter after the paste.
|
|
207
|
+
this.push(Buffer.from('\n'));
|
|
208
|
+
}
|
|
200
209
|
this.pasteBuf = Buffer.alloc(0);
|
|
201
210
|
this.inPaste = false;
|
|
202
211
|
i = endIdx + END_MARKER.length;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in (non-skill) slash commands — the single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from slash-picker.ts (2026-06-11) so the router layer
|
|
5
|
+
* (src/router/classifier.ts) can distinguish "user typed a builtin with
|
|
6
|
+
* arguments" (e.g. `/help me`) from "user typed an unknown skill"
|
|
7
|
+
* WITHOUT importing the inquirer-backed picker module. Pure data — no
|
|
8
|
+
* runtime dependencies.
|
|
9
|
+
*/
|
|
10
|
+
export const BUILTIN_ENTRIES = [
|
|
11
|
+
{ name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
|
|
12
|
+
{ name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
|
|
13
|
+
{ name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
|
|
14
|
+
{ name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
|
|
15
|
+
{ name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
|
|
16
|
+
{ name: '/exit', description: 'Exit CSPeach' },
|
|
17
|
+
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
18
|
+
{ name: '/help', description: 'Show all skills and commands' },
|
|
19
|
+
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
20
|
+
{ name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
|
|
21
|
+
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
22
|
+
{ name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
|
|
23
|
+
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
24
|
+
{ name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
|
|
25
|
+
{ name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
|
|
26
|
+
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
27
|
+
{ name: '/transport', description: 'View or set the active transport for this session' },
|
|
28
|
+
{ name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
|
|
32
|
+
* `/files` is a real REPL builtin (repl.tsx handles it in both paths) but
|
|
33
|
+
* is deliberately absent from the picker catalogue, so it's added here.
|
|
34
|
+
*/
|
|
35
|
+
const BUILTIN_HEADS = new Set([
|
|
36
|
+
...BUILTIN_ENTRIES.map((e) => e.name),
|
|
37
|
+
'/files',
|
|
38
|
+
]);
|
|
39
|
+
/** True when `head` (e.g. '/help', as typed, any case) is a builtin command. */
|
|
40
|
+
export function isBuiltinCommandHead(head) {
|
|
41
|
+
return BUILTIN_HEADS.has(head.toLowerCase());
|
|
42
|
+
}
|
|
@@ -14,16 +14,26 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Module-scope singleton per CLI process. Tests must call clearCurrentTransport()
|
|
16
16
|
* in beforeEach to avoid leakage across Vitest test files.
|
|
17
|
+
*
|
|
18
|
+
* Task 9 (ux-wave1) — this setter is ALSO the single statusline wire for the
|
|
19
|
+
* `tr <TRKORR>` segment: every path that changes the session transport
|
|
20
|
+
* (sap_transport create success, owning-transport adoption in the create
|
|
21
|
+
* guard, /transport slash command) funnels through set/clear here, so the
|
|
22
|
+
* globalStore push lives HERE rather than scattered across tool handlers.
|
|
23
|
+
* Store writes are harmless in classic mode (nothing subscribes there).
|
|
17
24
|
*/
|
|
25
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
18
26
|
let currentTransport = null;
|
|
19
27
|
export function setCurrentTransport(trkorr) {
|
|
20
28
|
currentTransport = trkorr;
|
|
29
|
+
globalStore.update({ transport: trkorr });
|
|
21
30
|
}
|
|
22
31
|
export function getCurrentTransport() {
|
|
23
32
|
return currentTransport;
|
|
24
33
|
}
|
|
25
34
|
export function clearCurrentTransport() {
|
|
26
35
|
currentTransport = null;
|
|
36
|
+
globalStore.update({ transport: null });
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
29
39
|
* Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Early-line buffer for piped-stdin REPL runs — UX-5 #5 (2026-06-12).
|
|
3
|
+
*
|
|
4
|
+
* The bug (found 2026-06-11): lines piped into the classic REPL
|
|
5
|
+
* (`"/cost`n/exit" | cspeach`) arrive on the readline BEFORE the loop's
|
|
6
|
+
* first rl.question() is pending. Node's readline only routes a line to a
|
|
7
|
+
* question callback when one is registered; otherwise it emits the 'line'
|
|
8
|
+
* EVENT — which nobody consumed, so every early line was silently dropped
|
|
9
|
+
* (even /cost never dispatched). stdin EOF then closed the readline and
|
|
10
|
+
* the old 'close' handler hard-exited. This blocked CI-driven multi-phase
|
|
11
|
+
* plan runs entirely.
|
|
12
|
+
*
|
|
13
|
+
* Fix shape:
|
|
14
|
+
* - attach a 'line' listener at readline creation that QUEUES lines
|
|
15
|
+
* arriving while no question is pending (readline emits 'line' only
|
|
16
|
+
* in that case — a pending question consumes the line directly);
|
|
17
|
+
* - the REPL loop drains this queue before calling rl.question;
|
|
18
|
+
* - on stdin EOF ('close') the buffer flips `closed` and the loop keeps
|
|
19
|
+
* processing queued lines before exiting (the repl.tsx close handler
|
|
20
|
+
* no longer hard-exits in piped mode);
|
|
21
|
+
* - the FIRST piped line gets a UTF-8 BOM strip — PowerShell pipes
|
|
22
|
+
* inject one, which used to corrupt the first command head.
|
|
23
|
+
*
|
|
24
|
+
* Only attached when stdin is NOT a TTY (see repl.tsx): in interactive
|
|
25
|
+
* mode, type-ahead during a turn should keep its existing
|
|
26
|
+
* behaviour rather than silently becoming queued commands.
|
|
27
|
+
*/
|
|
28
|
+
/** Strip a single leading UTF-8 BOM (PowerShell pipes inject one). */
|
|
29
|
+
export function stripBom(s) {
|
|
30
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Attach the queueing listeners to `rl` and return the buffer handle.
|
|
34
|
+
* Call once, immediately after readline creation, BEFORE any awaits —
|
|
35
|
+
* piped data starts flowing on the next tick.
|
|
36
|
+
*/
|
|
37
|
+
export function attachEarlyLineBuffer(rl) {
|
|
38
|
+
const queue = [];
|
|
39
|
+
let firstLine = true;
|
|
40
|
+
let isClosed = false;
|
|
41
|
+
rl.on('line', (l) => {
|
|
42
|
+
let line = String(l);
|
|
43
|
+
if (firstLine) {
|
|
44
|
+
line = stripBom(line);
|
|
45
|
+
firstLine = false;
|
|
46
|
+
}
|
|
47
|
+
queue.push(line);
|
|
48
|
+
});
|
|
49
|
+
rl.on('close', () => {
|
|
50
|
+
isClosed = true;
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
size: () => queue.length,
|
|
54
|
+
shift: () => queue.shift(),
|
|
55
|
+
closed: () => isClosed,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Inert buffer for interactive (TTY) runs — keeps the repl.tsx loop free
|
|
60
|
+
* of `isTTY` branches at every drain site.
|
|
61
|
+
*/
|
|
62
|
+
export function noopEarlyLineBuffer() {
|
|
63
|
+
return {
|
|
64
|
+
size: () => 0,
|
|
65
|
+
shift: () => undefined,
|
|
66
|
+
closed: () => false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent REPL history — the ONE writer shared by every append site:
|
|
3
|
+
* - classic readline path (repl.tsx)
|
|
4
|
+
* - Ink handleSubmit (repl.tsx)
|
|
5
|
+
* - Ink Footer (ui/footer.tsx)
|
|
6
|
+
*
|
|
7
|
+
* File shape: `~/.cspeach/history`, one entry per line, chronological
|
|
8
|
+
* (oldest at top, newest at bottom), appended on every submit; capped at
|
|
9
|
+
* READ time (HISTORY_SIZE in repl.tsx), not write time.
|
|
10
|
+
*
|
|
11
|
+
* Task 8 (ux-wave1) — consecutive-duplicate dedup lives HERE so all
|
|
12
|
+
* append sites share a single guard (CC parity: bash
|
|
13
|
+
* HISTCONTROL=ignoredups). `x, y, x` stores three entries — only
|
|
14
|
+
* back-to-back repeats collapse, never across gaps. Comparison is on the
|
|
15
|
+
* flattened + trimmed entry, against the file tail — so it also holds
|
|
16
|
+
* across session restarts, AND it collapses the Ink path's historical
|
|
17
|
+
* double append (Footer wrote the entry, then repl.tsx's handleSubmit
|
|
18
|
+
* wrote the same submission again — unflattened).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
// Resolved lazily (not at module load) so it honours the HOME/USERPROFILE
|
|
24
|
+
// active at call time — lets tests point it at a temp dir.
|
|
25
|
+
export function historyFile() {
|
|
26
|
+
return path.join(os.homedir(), '.cspeach', 'history');
|
|
27
|
+
}
|
|
28
|
+
/** All persisted entries, oldest → newest, trimmed, blank lines dropped. */
|
|
29
|
+
export function readHistoryEntries() {
|
|
30
|
+
try {
|
|
31
|
+
return fs
|
|
32
|
+
.readFileSync(historyFile(), 'utf-8')
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((l) => l.trim())
|
|
35
|
+
.filter((l) => l.length > 0);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Flatten a submission into a single history-file entry: `\r\n` becomes
|
|
43
|
+
* ONE space, then every remaining C0 control + DEL becomes a space.
|
|
44
|
+
*
|
|
45
|
+
* Wave1 fast-follow (tab-flatten): newlines were already flattened, but
|
|
46
|
+
* tabs (e.g. from a pasted tab-indented ABAP snippet) survived into the
|
|
47
|
+
* history file. A tab-bearing entry later rendered in the Ctrl+R fuzzy
|
|
48
|
+
* list dodges Ink's truncate-end — widestLine/string-width measure `\t`
|
|
49
|
+
* as 0 cells while the terminal renders up to 8 — re-opening the exact
|
|
50
|
+
* wrapped-row erase-accounting leak Bug 1 fixed (see text-input.tsx
|
|
51
|
+
* maxDisplayWidth doc). Flattening at WRITE time keeps new entries clean;
|
|
52
|
+
* the fuzzy-row render flattens too, covering pre-fix history files.
|
|
53
|
+
*/
|
|
54
|
+
export function flattenHistoryEntry(line) {
|
|
55
|
+
// eslint-disable-next-line no-control-regex
|
|
56
|
+
return line.replace(/\r\n/g, ' ').replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Append one entry to the history file.
|
|
60
|
+
* - Submissions are flattened (newlines, tabs, and all other C0
|
|
61
|
+
* controls → ' ') — the file is one-entry-per-line (Task 7 contract)
|
|
62
|
+
* and entries must never carry width-invisible controls (tab-flatten
|
|
63
|
+
* fast-follow; see flattenHistoryEntry).
|
|
64
|
+
* - Skips when the flattened + trimmed entry equals the file's LAST
|
|
65
|
+
* entry (consecutive dedup — trimmed comparison).
|
|
66
|
+
* - Never throws: history persistence failure must not crash the REPL.
|
|
67
|
+
*
|
|
68
|
+
* @returns true when the entry was written, false when skipped.
|
|
69
|
+
*/
|
|
70
|
+
export function appendHistoryEntry(line) {
|
|
71
|
+
const entry = flattenHistoryEntry(line);
|
|
72
|
+
if (!entry)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
const entries = readHistoryEntries();
|
|
76
|
+
if (entries.length > 0 && entries[entries.length - 1] === entry)
|
|
77
|
+
return false;
|
|
78
|
+
const file = historyFile();
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.appendFileSync(file, entry + '\n', 'utf-8');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InkStdinPasteGuard — bracketed-paste interception for the INK path
|
|
3
|
+
* (wave1 live-fix, Bug 2: multiline paste submitted line-by-line on real
|
|
4
|
+
* Windows Terminal).
|
|
5
|
+
*
|
|
6
|
+
* What the live test proved (and Task 7's assumption it disproved):
|
|
7
|
+
* Windows Terminal does NOT deliver a paste as one big chunk with
|
|
8
|
+
* embedded newlines — pasted newlines arrive as `\r`, and a `\r` can
|
|
9
|
+
* land as its OWN stdin chunk. Ink's parseKeypress flags a chunk that is
|
|
10
|
+
* exactly '\r' as key.return (ink/build/parse-keypress.js:145-148), so
|
|
11
|
+
* every pasted line submitted immediately — the garbled multi-dispatch
|
|
12
|
+
* the user saw.
|
|
13
|
+
*
|
|
14
|
+
* Why the interception must happen BEFORE Ink parses:
|
|
15
|
+
* - With DEC 2004 enabled the paste arrives as `\x1b[200~ … \x1b[201~`,
|
|
16
|
+
* but Ink's parser mangles the markers: parse-keypress' fnKeyRe
|
|
17
|
+
* matches `\x1b[200~` with code '[200~' → name undefined, and
|
|
18
|
+
* use-input.js:73-75 strips the leading ESC from the sequence, so the
|
|
19
|
+
* marker would land in the input as literal garbage text ("[200~…").
|
|
20
|
+
* There is no reliable way to reassemble a marker split across chunks
|
|
21
|
+
* from inside useInput.
|
|
22
|
+
* - The reliable seam is the stdin stream itself: Ink accepts a custom
|
|
23
|
+
* `stdin` in its render options and touches exactly this surface on
|
|
24
|
+
* it (ink/build/components/App.js:104-139): `isTTY`, `setEncoding`,
|
|
25
|
+
* `ref`/`unref`, `setRawMode`, `addListener('readable')` + `read()`.
|
|
26
|
+
* A Transform (Duplex) provides the readable side natively; the four
|
|
27
|
+
* TTY-ish members are shimmed here, forwarding to process.stdin.
|
|
28
|
+
*
|
|
29
|
+
* Wiring (repl.tsx Ink branch): enableBracketedPaste() →
|
|
30
|
+
* `process.stdin.pipe(guard)` → `render(…, { stdin: guard })`. The base
|
|
31
|
+
* class (BracketedPasteDecoder with emitVerbatim) strips the markers,
|
|
32
|
+
* accumulates the whole span — including any `\r`-only chunks inside it —
|
|
33
|
+
* and re-emits the paste as ONE chunk with newlines preserved. Ink then
|
|
34
|
+
* hands that single chunk unsplit to useInput, where text-input's Task-7
|
|
35
|
+
* paste branch buffers complete lines via onPasteLines and keeps the
|
|
36
|
+
* tail fragment. NOTHING can submit mid-paste because no `\r`-only chunk
|
|
37
|
+
* ever reaches parseKeypress between the markers.
|
|
38
|
+
*
|
|
39
|
+
* Terminals without bracketed-paste support simply never send markers:
|
|
40
|
+
* the guard is pass-through and behaviour is exactly the pre-fix Task 7
|
|
41
|
+
* path (no regression, same as the classic-mode caveat).
|
|
42
|
+
*/
|
|
43
|
+
import { BracketedPasteDecoder } from './bracketed-paste.js';
|
|
44
|
+
export class InkStdinPasteGuard extends BracketedPasteDecoder {
|
|
45
|
+
/**
|
|
46
|
+
* Mirror the REAL stdin's TTY-ness (the base class hardcodes true for
|
|
47
|
+
* readline's sake). Ink gates isRawModeSupported on this — lying about
|
|
48
|
+
* a non-TTY stdin would make Ink call setRawMode on a pipe.
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
51
|
+
isTTY = process.stdin.isTTY ?? false;
|
|
52
|
+
constructor() {
|
|
53
|
+
super({ emitVerbatim: true });
|
|
54
|
+
}
|
|
55
|
+
/** Ink calls stdin.ref()/unref() around raw-mode enable/disable. */
|
|
56
|
+
ref() {
|
|
57
|
+
process.stdin.ref?.();
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
unref() {
|
|
61
|
+
process.stdin.unref?.();
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -28,6 +28,50 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { clearActiveSpinner } from '../renderer/tool-widget.js';
|
|
30
30
|
let state = null;
|
|
31
|
+
/**
|
|
32
|
+
* UX-5 #4 (2026-06-12) — sequence-scoped (debounced) release.
|
|
33
|
+
*
|
|
34
|
+
* Failure mode this fixes: between BACK-TO-BACK inquirer prompts fired by
|
|
35
|
+
* the SAME handler (safety-confirm's two-step select, ask_question's
|
|
36
|
+
* select → custom-input follow-up, /files step 1 → step 2), the old
|
|
37
|
+
* per-prompt release resumed the outer readline the instant prompt N
|
|
38
|
+
* finished — readline repainted its prompt with a stale history/buffer
|
|
39
|
+
* entry for a frame — and then prompt N+1 paused it again. The user saw
|
|
40
|
+
* the old line flash between the two pickers.
|
|
41
|
+
*
|
|
42
|
+
* Scope note (M1, 2026-06-12): consecutive ask_question TOOL calls are
|
|
43
|
+
* separated by a full LLM round-trip (seconds apart) — the 50ms debounce
|
|
44
|
+
* neither covers nor needs to cover those; readline legitimately resumes
|
|
45
|
+
* between turns. The debounce only spans same-handler prompt sequences.
|
|
46
|
+
*
|
|
47
|
+
* Fix shape: the release (rl.resume + suppress-clear) is DEFERRED by
|
|
48
|
+
* RELEASE_DEBOUNCE_MS. If another withInquirer starts inside that window
|
|
49
|
+
* (an ask SEQUENCE), the pending release is cancelled and the outer
|
|
50
|
+
* prompt stays parked across the whole sequence — no repaint between
|
|
51
|
+
* prompts. Only the final prompt's release actually fires.
|
|
52
|
+
*
|
|
53
|
+
* Note: this is a CLASSIC-path concern only. Under Ink, ask_question /
|
|
54
|
+
* approval render through the Ink-native emitter modals (see
|
|
55
|
+
* tools/ask-question.ts shouldUseInk branch) and registerReadline is
|
|
56
|
+
* never called, so withInquirer is a pass-through there.
|
|
57
|
+
*
|
|
58
|
+
* The suppress flag intentionally stays TRUE during the debounce window —
|
|
59
|
+
* any spurious 'close' inquirer emits on release is still swallowed,
|
|
60
|
+
* which is strictly safer than the old nextTick clearing.
|
|
61
|
+
*/
|
|
62
|
+
// 50ms (M4 rationale): covers same-tick and next-macrotask prompt chaining
|
|
63
|
+
// with ~3 frames of margin (one terminal frame ≈ 16ms) while staying far
|
|
64
|
+
// below human-perceptible delay before the outer prompt repaints.
|
|
65
|
+
export const RELEASE_DEBOUNCE_MS = 50;
|
|
66
|
+
let pendingRelease = null;
|
|
67
|
+
/** Re-entrancy depth — release is scheduled only when the OUTERMOST call exits. */
|
|
68
|
+
let activeDepth = 0;
|
|
69
|
+
function cancelPendingRelease() {
|
|
70
|
+
if (pendingRelease) {
|
|
71
|
+
clearTimeout(pendingRelease);
|
|
72
|
+
pendingRelease = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
31
75
|
/**
|
|
32
76
|
* Called by repl.tsx once the main readline is created. The `setSuppress`
|
|
33
77
|
* callback must flip the same boolean the readline 'close' handler
|
|
@@ -38,6 +82,8 @@ export function registerReadline(rl, setSuppress) {
|
|
|
38
82
|
}
|
|
39
83
|
/** Clear the guard — call when the REPL is shutting down. */
|
|
40
84
|
export function unregisterReadline() {
|
|
85
|
+
cancelPendingRelease();
|
|
86
|
+
activeDepth = 0;
|
|
41
87
|
state = null;
|
|
42
88
|
}
|
|
43
89
|
/**
|
|
@@ -66,6 +112,12 @@ export async function withInquirer(fn) {
|
|
|
66
112
|
// which collides with inquirer's prompt rendering — visible as ghost-text
|
|
67
113
|
// and a cursor that hides under the rotating frame while the user types.
|
|
68
114
|
clearActiveSpinner();
|
|
115
|
+
// UX-5 #4 — continuing an ask SEQUENCE: a release from the previous
|
|
116
|
+
// prompt is still pending, so cancel it. The readline stays parked
|
|
117
|
+
// (paused + close-suppressed) across the gap — no stale-line repaint
|
|
118
|
+
// between consecutive prompts.
|
|
119
|
+
cancelPendingRelease();
|
|
120
|
+
activeDepth++;
|
|
69
121
|
// 2026-05-08: snapshot stdin 'data' / 'keypress' listeners BEFORE the
|
|
70
122
|
// inquirer prompt runs. Some inquirer widgets (notably `search`, used
|
|
71
123
|
// by /files step 1) strip ALL listeners on cleanup — including
|
|
@@ -120,11 +172,24 @@ export async function withInquirer(fn) {
|
|
|
120
172
|
// Order matters here:
|
|
121
173
|
// 1. resume stdin so the event loop stays referenced (otherwise the
|
|
122
174
|
// next rl.question prints its prompt and the process exits).
|
|
123
|
-
// 2.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
175
|
+
// 2. SCHEDULE the readline resume + suppress-clear instead of doing
|
|
176
|
+
// them inline (UX-5 #4). If another inquirer prompt opens within
|
|
177
|
+
// RELEASE_DEBOUNCE_MS — an ask sequence — the entry above cancels
|
|
178
|
+
// this timer and the outer readline never repaints between
|
|
179
|
+
// prompts. The timer is deliberately NOT unref'd: it must hold
|
|
180
|
+
// the event loop open across the gap.
|
|
181
|
+
// 3. inside the deferred release, still defer the suppress-clear by
|
|
182
|
+
// one tick so the spurious 'close' that inquirer synchronously
|
|
183
|
+
// emits on release is ignored.
|
|
126
184
|
process.stdin.resume();
|
|
127
|
-
|
|
128
|
-
|
|
185
|
+
activeDepth = Math.max(0, activeDepth - 1);
|
|
186
|
+
if (activeDepth === 0) {
|
|
187
|
+
cancelPendingRelease();
|
|
188
|
+
pendingRelease = setTimeout(() => {
|
|
189
|
+
pendingRelease = null;
|
|
190
|
+
rl.resume();
|
|
191
|
+
process.nextTick(() => setSuppress(false));
|
|
192
|
+
}, RELEASE_DEBOUNCE_MS);
|
|
193
|
+
}
|
|
129
194
|
}
|
|
130
195
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Ordered from most to least restricted. */
|
|
2
|
+
export const WRITE_MODE_ORDER = ['advisory-only', 'approval-gated', 'auto'];
|
|
3
|
+
const ROLE_CEILING = {
|
|
4
|
+
dev: 'auto',
|
|
5
|
+
qas: 'approval-gated',
|
|
6
|
+
prd: 'advisory-only',
|
|
7
|
+
};
|
|
8
|
+
/** The most permissive write mode a system role allows. */
|
|
9
|
+
export function modeCeiling(role) {
|
|
10
|
+
return role === undefined ? 'auto' : ROLE_CEILING[role];
|
|
11
|
+
}
|
|
12
|
+
/** Clamp a mode to the role's ceiling. */
|
|
13
|
+
export function clampToCeiling(mode, role) {
|
|
14
|
+
const ceiling = modeCeiling(role);
|
|
15
|
+
return WRITE_MODE_ORDER.indexOf(mode) <= WRITE_MODE_ORDER.indexOf(ceiling) ? mode : ceiling;
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
|
|
4
|
+
*
|
|
5
|
+
* The config's `write_mode` is the durable setting; this module layers a
|
|
6
|
+
* SESSION override on top (Shift+Tab-style cycling, lost on exit) and clamps
|
|
7
|
+
* the result to the active system role's ceiling (mode-ceiling.ts):
|
|
8
|
+
*
|
|
9
|
+
* effective = clamp(sessionOverride ?? configMode, roleCeiling)
|
|
10
|
+
*
|
|
11
|
+
* Cycling walks WRITE_MODE_ORDER restricted to the modes the role allows:
|
|
12
|
+
* dev / unset → advisory → gated → auto → advisory …
|
|
13
|
+
* qas → advisory → gated → advisory … (auto not in the cycle — a
|
|
14
|
+
* gated→advisory step is a
|
|
15
|
+
* WRAP, not a cap)
|
|
16
|
+
* prd → pinned at advisory-only; a cycle attempt reports
|
|
17
|
+
* `capped: true` so the caller can render the refusal line.
|
|
18
|
+
*
|
|
19
|
+
* Pure module — no I/O, no config access; callers pass config mode + role in.
|
|
20
|
+
* Module-level state is test-resettable via `setSessionWriteMode(null)`.
|
|
21
|
+
*/
|
|
22
|
+
let sessionWriteMode = null;
|
|
23
|
+
/** Session override; null = follow config. Module-level, test-resettable. */
|
|
24
|
+
export function setSessionWriteMode(mode) {
|
|
25
|
+
sessionWriteMode = mode;
|
|
26
|
+
}
|
|
27
|
+
export function getSessionWriteMode() {
|
|
28
|
+
return sessionWriteMode;
|
|
29
|
+
}
|
|
30
|
+
/** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
|
|
31
|
+
export function getEffectiveWriteMode(cfgMode, role) {
|
|
32
|
+
return clampToCeiling(sessionWriteMode ?? cfgMode, role);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Cycle to the next mode the role allows (wraps). Sets the session override
|
|
36
|
+
* so the new mode takes effect immediately. Returns the new mode AND whether
|
|
37
|
+
* the requested step was capped (for the refusal line).
|
|
38
|
+
*/
|
|
39
|
+
export function cycleWriteMode(cfgMode, role) {
|
|
40
|
+
const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
|
|
41
|
+
// Modes at or below the ceiling, in cycling order (most→least restricted).
|
|
42
|
+
const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
|
|
43
|
+
if (allowed.length === 1) {
|
|
44
|
+
// prd: pinned — there is nowhere to step. Report capped for the refusal line.
|
|
45
|
+
setSessionWriteMode(allowed[0]);
|
|
46
|
+
return { mode: allowed[0], capped: true };
|
|
47
|
+
}
|
|
48
|
+
const current = getEffectiveWriteMode(cfgMode, role);
|
|
49
|
+
const next = allowed[(allowed.indexOf(current) + 1) % allowed.length];
|
|
50
|
+
setSessionWriteMode(next);
|
|
51
|
+
return { mode: next, capped: false };
|
|
52
|
+
}
|
|
53
|
+
/** Short /mode arguments → WriteMode. */
|
|
54
|
+
const MODE_ARGS = {
|
|
55
|
+
advisory: 'advisory-only',
|
|
56
|
+
gated: 'approval-gated',
|
|
57
|
+
auto: 'auto',
|
|
58
|
+
};
|
|
59
|
+
function announceLine(prev, next) {
|
|
60
|
+
return `mode: ${prev} → ${next} (session only — config unchanged)`;
|
|
61
|
+
}
|
|
62
|
+
function refusalLine(clamped, role, alias) {
|
|
63
|
+
return `mode capped at ${clamped} — sap.${alias ?? '?'}.role = ${role} (policy)`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* /mode command core (UX Wave 1, Task 3). Pure apart from the session
|
|
67
|
+
* override it shares with cycleWriteMode — the REPL builtin and the
|
|
68
|
+
* Shift+Tab / Alt+M keybindings are thin shells over this.
|
|
69
|
+
*
|
|
70
|
+
* - bare / empty arg → cycle (wraps within the role's allowed modes)
|
|
71
|
+
* - 'advisory'|'gated'|'auto' → jump directly (still clamped to the ceiling;
|
|
72
|
+
* a jump above it returns the refusal line and
|
|
73
|
+
* the clamped mode)
|
|
74
|
+
* - anything else → usage line, mode untouched
|
|
75
|
+
*
|
|
76
|
+
* Returns the line to print (caller dims it) and the resulting effective
|
|
77
|
+
* mode (caller pushes it into the SapStateStore so the statusline is live).
|
|
78
|
+
*/
|
|
79
|
+
export function runModeCommand(arg, cfgMode, role, alias) {
|
|
80
|
+
const prev = getEffectiveWriteMode(cfgMode, role);
|
|
81
|
+
const normalized = arg?.trim().toLowerCase() ?? '';
|
|
82
|
+
if (normalized === '') {
|
|
83
|
+
const { mode, capped } = cycleWriteMode(cfgMode, role);
|
|
84
|
+
// capped is only ever true with a prd role, so `role!` is safe here.
|
|
85
|
+
if (capped)
|
|
86
|
+
return { line: refusalLine(mode, role, alias), mode };
|
|
87
|
+
return { line: announceLine(prev, mode), mode };
|
|
88
|
+
}
|
|
89
|
+
const target = MODE_ARGS[normalized];
|
|
90
|
+
if (target === undefined) {
|
|
91
|
+
return {
|
|
92
|
+
line: `usage: /mode [advisory|gated|auto] — bare /mode cycles (current: ${prev})`,
|
|
93
|
+
mode: prev,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const clamped = clampToCeiling(target, role);
|
|
97
|
+
setSessionWriteMode(clamped);
|
|
98
|
+
if (clamped !== target) {
|
|
99
|
+
// Jump above the ceiling — clamp landed us lower than requested. `role!`
|
|
100
|
+
// is safe: without a role the ceiling is 'auto' and nothing clamps.
|
|
101
|
+
return { line: refusalLine(clamped, role, alias), mode: clamped };
|
|
102
|
+
}
|
|
103
|
+
return { line: announceLine(prev, clamped), mode: clamped };
|
|
104
|
+
}
|