@cspeach/cli 0.7.1 → 0.9.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/bench/README.md +78 -0
- package/bench/prompts/abap-document-cds.md +44 -0
- package/bench/prompts/abap-explain-bdef-handler.md +57 -0
- package/bench/prompts/abap-test-method.md +42 -0
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
- package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
- package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
- package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
- package/dist/agent/loop.js +144 -26
- package/dist/agent/steering-queue.js +27 -0
- package/dist/approvals/jwt.js +45 -5
- package/dist/approvals/render.js +38 -0
- package/dist/classifier/client.js +6 -2
- package/dist/commands/plan-resume.js +308 -0
- package/dist/config/loader.js +13 -6
- package/dist/cost/pricing.js +14 -5
- package/dist/one-shot.js +6 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-plan.js +85 -0
- package/dist/projects/index.js +3 -0
- package/dist/projects/plan-run.js +120 -0
- package/dist/projects/plan-schema.js +150 -0
- package/dist/projects/promote-command.js +1 -0
- package/dist/projects/save-command.js +17 -1
- package/dist/projects/status.js +20 -0
- package/dist/projects/validate.js +2 -0
- package/dist/renderer/question-normalizer.js +16 -6
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/repl/at-picker.js +39 -20
- package/dist/repl/slash-picker.js +15 -4
- package/dist/repl.js +216 -17
- package/dist/router/classifier.js +74 -8
- package/dist/skill-catalog.js +8 -2
- package/dist/tools/subagent/agent_run.js +12 -0
- package/dist/ui/app.js +80 -11
- package/dist/ui/body.js +61 -2
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +8 -5
- package/dist/ui/line-resolution.js +81 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +26 -1
- package/package.json +6 -2
package/dist/agent/loop.js
CHANGED
|
@@ -9,6 +9,10 @@ import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js
|
|
|
9
9
|
import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner } from '../renderer/tool-widget.js';
|
|
10
10
|
import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
|
|
11
11
|
import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
|
|
12
|
+
// 2026-06-07 — status-row narration. Safe to import in every mode: the
|
|
13
|
+
// emitter has zero listeners outside Ink (classic / one-shot / subagent),
|
|
14
|
+
// so activity() calls are no-ops there.
|
|
15
|
+
import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
12
16
|
// (progress-chatter import removed 2026-05-01 — superseded by CC-style
|
|
13
17
|
// two-line dispatch renderer; re-add if a future in-place spinner returns)
|
|
14
18
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
@@ -24,6 +28,7 @@ import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
|
24
28
|
import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
|
|
25
29
|
import { objectKeyFromInput } from './retry-key.js';
|
|
26
30
|
import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
|
|
31
|
+
import { drainSteering } from './steering-queue.js';
|
|
27
32
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
28
33
|
import { applyToolResultCheckpoint } from './skill-checkpoint.js';
|
|
29
34
|
import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
|
|
@@ -73,6 +78,10 @@ const SAVE_HOOK_SKILLS = new Set([
|
|
|
73
78
|
// and can fan out into further chains after their own envelope saves.
|
|
74
79
|
'abap-modernize',
|
|
75
80
|
'abap-test',
|
|
81
|
+
// 2026-06-06 B3: /abap-plan create mode emits a csforge:plan-manifest
|
|
82
|
+
// JSON block → plan envelope. Resume turns set suppressSaveHook so the
|
|
83
|
+
// revision path in commands/plan-resume.ts persists instead.
|
|
84
|
+
'abap-plan',
|
|
76
85
|
]);
|
|
77
86
|
/**
|
|
78
87
|
* True when any user message in the session already carries the rendered
|
|
@@ -318,20 +327,47 @@ export async function runTurn(params) {
|
|
|
318
327
|
const watchdogConfig = loadWatchdogConfig();
|
|
319
328
|
const turnStartedAt = Date.now();
|
|
320
329
|
let watchdogWarned = false;
|
|
330
|
+
// 2026-06-08 — manifest-on-interrupt: track the last plan-manifest block we
|
|
331
|
+
// already handed to onPlanManifest, so we fire once per DISTINCT manifest
|
|
332
|
+
// (a continue-in-session run emits one per phase) and not once per round.
|
|
333
|
+
let lastFiredManifest = null;
|
|
321
334
|
// (emit was hoisted to the top of the function in v0.5 so the Phase J
|
|
322
335
|
// promote dispatch could share it. Original location was here.)
|
|
323
336
|
while (true) {
|
|
337
|
+
// Mid-turn steering (2026-06-08): inject any user corrections typed since the
|
|
338
|
+
// last round so the next createStream sees them. Drains at the top of EVERY
|
|
339
|
+
// round — round 1 normally drains an empty queue (no-op); a steer typed
|
|
340
|
+
// during round N lands at the start of round N+1 (between rounds). The
|
|
341
|
+
// Anthropic API concatenates consecutive user messages, so injecting after a
|
|
342
|
+
// tool_result user message is valid.
|
|
343
|
+
{
|
|
344
|
+
const steers = drainSteering();
|
|
345
|
+
if (steers.length > 0) {
|
|
346
|
+
const text = steers.join('\n');
|
|
347
|
+
session.messages.push({ role: 'user', content: text });
|
|
348
|
+
emit(chalk.cyan(`↳ steering: ${steers.join(' / ')}`));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
324
351
|
let buffer = initialBufferState();
|
|
325
352
|
let stream;
|
|
326
353
|
// 2026-05-06: paint a "thinking" spinner during the LLM round-trip
|
|
327
354
|
// wait so the user sees activity instead of staring at a silent
|
|
328
|
-
// prompt.
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
355
|
+
// prompt. Stopped on the first content_block_start (so the response
|
|
356
|
+
// stream paints in its place) and on any error path below.
|
|
357
|
+
//
|
|
358
|
+
// Phantom cursor #25 (2026-06-06) — pass chunkEmitter so the spinner
|
|
359
|
+
// self-disables under Ink, same as the heartbeat below. The original
|
|
360
|
+
// pre-Ink code deliberately called this with {} so the spinner would
|
|
361
|
+
// write directly to stdout; once Ink owned the frame that meant the
|
|
362
|
+
// spinner's stop() wrote \x1B[?25h on EVERY turn, re-showing the
|
|
363
|
+
// native cursor repl.tsx hid at mount → the blinking "booger cursor"
|
|
364
|
+
// below the Footer. Under Ink the user loses nothing: the raw spinner
|
|
365
|
+
// was overdrawn by the frame anyway, and the heartbeat (chunkEmitter-
|
|
366
|
+
// routed) covers liveness feedback. Classic mode (no chunkEmitter)
|
|
367
|
+
// keeps the stdout spinner unchanged.
|
|
368
|
+
// Guard: test/unit/ink-cursor-invariant.test.ts.
|
|
369
|
+
turnStatusEmitter.activity('thinking');
|
|
370
|
+
const thinkingSpinner = startThinkingSpinner({ chunkEmitter: params.chunkEmitter });
|
|
335
371
|
// v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
|
|
336
372
|
// The spinner self-disables on non-TTY (Windows PowerShell sometimes
|
|
337
373
|
// reports isTTY=false; resume mode also lost spinner visibility) so a
|
|
@@ -344,7 +380,25 @@ export async function runTurn(params) {
|
|
|
344
380
|
const thinkingHeartbeat = startThinkingHeartbeat({ chunkEmitter: params.chunkEmitter });
|
|
345
381
|
try {
|
|
346
382
|
stream = await retryWithBackoff(() => (async () => {
|
|
347
|
-
|
|
383
|
+
// W2.5 — apply optional tool filter (used by agent_run with
|
|
384
|
+
// read_only: true to send a smaller, read-only tool list to
|
|
385
|
+
// the subagent). Mirrors CC's Tq2() pattern.
|
|
386
|
+
const allTools = listTools();
|
|
387
|
+
const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
|
|
388
|
+
const tools = toAnthropicTools(filteredTools);
|
|
389
|
+
// Bug 11a (proactive heal, 2026-06-07) — strip any residual
|
|
390
|
+
// `partial_json` from tool_use blocks on EVERY outgoing request.
|
|
391
|
+
// `content_block_stop` normally deletes it (see below), but any miss —
|
|
392
|
+
// a multi-tool grounding round, an SDK edge case, poison rehydrated
|
|
393
|
+
// from disk — leaves a block carrying both `input` AND `partial_json`,
|
|
394
|
+
// and the NEXT createStream 400s with:
|
|
395
|
+
// messages.N.content.M.tool_use.partial_json: Extra inputs are not permitted
|
|
396
|
+
// which interrupts the turn and (in a /abap-plan resume) discards the
|
|
397
|
+
// expensive grounding work before any manifest is written. The reactive
|
|
398
|
+
// heal in the catch below only fires AFTER that damage. Sanitising here —
|
|
399
|
+
// the single chokepoint every request passes through, retries included —
|
|
400
|
+
// makes the 400 categorically impossible. Idempotent and O(messages).
|
|
401
|
+
healSessionMessagesInPlace(session.messages);
|
|
348
402
|
const streamParams = {
|
|
349
403
|
model: cfg.default_model,
|
|
350
404
|
// v0.3.1 — was 8192. Bumped because code-gen skills (abap-generate
|
|
@@ -444,6 +498,11 @@ export async function runTurn(params) {
|
|
|
444
498
|
// call stop on an already-stopped handle, which is a no-op.
|
|
445
499
|
thinkingSpinner.stop();
|
|
446
500
|
thinkingHeartbeat.stop();
|
|
501
|
+
// 2026-06-07 — status-row narration: the model is now emitting
|
|
502
|
+
// content. Long emissions (a 4k-token plan manifest) can look
|
|
503
|
+
// silent if the renderer buffers the block — the row keeps
|
|
504
|
+
// ticking "writing response · 90s" regardless.
|
|
505
|
+
turnStatusEmitter.activity('writing response');
|
|
447
506
|
const block = event.content_block;
|
|
448
507
|
currentAssistantContent.push(block);
|
|
449
508
|
if (block.type === 'text') {
|
|
@@ -558,18 +617,36 @@ export async function runTurn(params) {
|
|
|
558
617
|
process.stdout.write(flushed);
|
|
559
618
|
buffer = initialBufferState();
|
|
560
619
|
}
|
|
561
|
-
// Repair partial blocks before persistence
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
620
|
+
// Repair partial blocks before persistence. ALWAYS run, not just on
|
|
621
|
+
// interruption: on a clean tool-use round the model's tool_use blocks can
|
|
622
|
+
// still carry a leftover `partial_json` field, and persisting that poisons
|
|
623
|
+
// the on-disk session (the next createStream 400s — see the proactive heal
|
|
624
|
+
// above). `repairPartialBlocks` strips `partial_json` unconditionally and
|
|
625
|
+
// only DROPS a block when its `input` is undefined — which never happens on
|
|
626
|
+
// a clean round (content_block_stop always sets `input`), so this is a
|
|
627
|
+
// no-op-except-strip on the happy path. See ./repair-partial.ts.
|
|
628
|
+
currentAssistantContent = repairPartialBlocks(currentAssistantContent);
|
|
567
629
|
// Persist the assistant message — even if it's partial. Empty content
|
|
568
630
|
// arrays are skipped so we don't push a meaningless empty assistant
|
|
569
631
|
// message that would confuse the next round.
|
|
570
632
|
if (currentAssistantContent.length > 0) {
|
|
571
633
|
session.messages.push({ role: 'assistant', content: currentAssistantContent });
|
|
572
634
|
}
|
|
635
|
+
// 2026-06-08 — manifest-on-interrupt. The assistant message just pushed may
|
|
636
|
+
// carry a finalised csforge:plan-manifest block (the /abap-plan resume emits
|
|
637
|
+
// it BEFORE the continuation ask_question, in this same message). Persist it
|
|
638
|
+
// NOW — before the round's tool loop dispatches that modal — so a Ctrl+C /
|
|
639
|
+
// exit during the modal can't lose the phase. Fire once per distinct block;
|
|
640
|
+
// never let a save error crash the turn.
|
|
641
|
+
if (params.onPlanManifest) {
|
|
642
|
+
const turnText = collectTurnAssistantText(session.messages, turnStartMessageCount);
|
|
643
|
+
const blocks = turnText.match(/<!--\s*csforge:plan-manifest\s*\n[\s\S]*?\n\s*-->/g);
|
|
644
|
+
const latest = blocks ? blocks[blocks.length - 1] : null;
|
|
645
|
+
if (latest && latest !== lastFiredManifest) {
|
|
646
|
+
lastFiredManifest = latest;
|
|
647
|
+
void Promise.resolve(params.onPlanManifest(turnText)).catch(() => { });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
573
650
|
session.last_turn_at = new Date().toISOString();
|
|
574
651
|
if (interruptedError !== null) {
|
|
575
652
|
session.turnInterrupted = true;
|
|
@@ -657,16 +734,19 @@ export async function runTurn(params) {
|
|
|
657
734
|
// their manifest in an early message before a closing ask_question
|
|
658
735
|
// widget; the final wrap-up message ends the turn but doesn't
|
|
659
736
|
// carry the manifest. See ./turn-assistant-text.ts for details.
|
|
737
|
+
turnStatusEmitter.activity('finishing up');
|
|
660
738
|
const assistantText = collectTurnAssistantText(session.messages, turnStartMessageCount);
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
739
|
+
if (!params.suppressSaveHook) {
|
|
740
|
+
await maybeOfferSave({
|
|
741
|
+
skill: params.skill,
|
|
742
|
+
assistantText,
|
|
743
|
+
userMessage: userMessageForSave,
|
|
744
|
+
tokensUsed: Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens),
|
|
745
|
+
model: cfg.default_model ?? session.model ?? 'unknown',
|
|
746
|
+
emit,
|
|
747
|
+
promotedFrom: promotedFromForSave,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
670
750
|
// Phase 2 #9 (2026-05-16) — auto-compact end-of-turn hook.
|
|
671
751
|
// Runs only on the success path (after maybeOfferSave) so an interrupted
|
|
672
752
|
// turn never triggers a summariser call on partial state. The maybeAutoCompact
|
|
@@ -721,13 +801,51 @@ export async function runTurn(params) {
|
|
|
721
801
|
// animation. Spinner is purely a "still working" cue, not
|
|
722
802
|
// load-bearing for output.
|
|
723
803
|
const spinner = startToolSpinner({ chunkEmitter: params.chunkEmitter });
|
|
804
|
+
// 2026-06-06 (turn-liveness, B5 smoke feedback) — the in-place tool
|
|
805
|
+
// spinner self-disables under Ink (phantom cursor #25), which left
|
|
806
|
+
// slow tool calls (SAP over VPN: 10-60s) as DEAD AIR between the ⏺
|
|
807
|
+
// dispatch line and the ⎿ result line. Labelled heartbeat prints
|
|
808
|
+
// fresh "<tool> running… (10s)" lines at thresholds — visible on
|
|
809
|
+
// every terminal, Ink included.
|
|
810
|
+
// Interactive tools wait on the USER, not the system — ticking
|
|
811
|
+
// "ask_question running… (30s)" while they think is noise.
|
|
812
|
+
const isInteractiveTool = block.name === 'ask_question' || block.name === 'request_approval';
|
|
813
|
+
// Interactive tools block on the USER. PAUSE the turn-status row (don't
|
|
814
|
+
// just relabel it): a live 250ms tick repaints the dynamic frame and
|
|
815
|
+
// overdraws the inquirer approval picker / churns the Ink ask_question
|
|
816
|
+
// modal — the hidden-question + stacked-border + lost-Enter bug
|
|
817
|
+
// (2026-06-07). resume() in the finally brings it back the moment the
|
|
818
|
+
// user answers. Non-interactive tools keep the ticking label + heartbeat.
|
|
819
|
+
if (isInteractiveTool)
|
|
820
|
+
turnStatusEmitter.pause();
|
|
821
|
+
else
|
|
822
|
+
turnStatusEmitter.activity(block.name);
|
|
823
|
+
const toolHeartbeat = isInteractiveTool
|
|
824
|
+
? { stop: () => undefined }
|
|
825
|
+
: startThinkingHeartbeat({
|
|
826
|
+
chunkEmitter: params.chunkEmitter,
|
|
827
|
+
label: `${block.name} running…`,
|
|
828
|
+
});
|
|
724
829
|
// Phase 2b: dispatch (may take 100ms–several seconds for write tools).
|
|
725
830
|
const dispatchStart = Date.now();
|
|
726
831
|
// Bug 8 — short-circuit for 2nd+ write-class tool in the same round.
|
|
727
832
|
const guardDecision = checkAndMark(block.name, writeGuard);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
833
|
+
let result;
|
|
834
|
+
try {
|
|
835
|
+
result = guardDecision.allow
|
|
836
|
+
? await dispatchTool(block.name, block.input, params.ctx)
|
|
837
|
+
: { content: guardDecision.errorContent, is_error: true };
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
// Stop on the error path too — the interval is unref'd but would
|
|
841
|
+
// otherwise keep printing "<tool> running…" into the NEXT prompt
|
|
842
|
+
// after a dispatch throw.
|
|
843
|
+
toolHeartbeat.stop();
|
|
844
|
+
// Resume the turn-status row now that the user has answered (or the
|
|
845
|
+
// interactive dispatch threw). No-op for non-interactive tools.
|
|
846
|
+
if (isInteractiveTool)
|
|
847
|
+
turnStatusEmitter.resume();
|
|
848
|
+
}
|
|
731
849
|
const durationMs = Date.now() - dispatchStart;
|
|
732
850
|
// Phase 2c: stop the spinner, which erases its line so the result
|
|
733
851
|
// row paints in place of it (no scrollback artifacts).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-turn steering queue (2026-06-08).
|
|
3
|
+
*
|
|
4
|
+
* While a turn is in flight the user can type a correction and press Enter;
|
|
5
|
+
* the Footer enqueues it here instead of bouncing it. The agent loop drains
|
|
6
|
+
* this at each round boundary and injects the text as a user message before
|
|
7
|
+
* the next createStream, so the model reads the correction and adapts —
|
|
8
|
+
* CC-style "type any time". Module-level singleton, mirroring the
|
|
9
|
+
* pendingDispatch accessor pattern; there is exactly one interactive REPL
|
|
10
|
+
* turn loop at a time.
|
|
11
|
+
*/
|
|
12
|
+
const queue = [];
|
|
13
|
+
/** Add a steer. Trims; ignores empty / whitespace-only input. */
|
|
14
|
+
export function enqueueSteering(text) {
|
|
15
|
+
const t = text.trim();
|
|
16
|
+
if (t.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
queue.push(t);
|
|
19
|
+
}
|
|
20
|
+
/** Return all queued steers (FIFO) and clear the queue. */
|
|
21
|
+
export function drainSteering() {
|
|
22
|
+
return queue.splice(0, queue.length);
|
|
23
|
+
}
|
|
24
|
+
/** True when at least one steer is queued. */
|
|
25
|
+
export function hasSteering() {
|
|
26
|
+
return queue.length > 0;
|
|
27
|
+
}
|
package/dist/approvals/jwt.js
CHANGED
|
@@ -3,12 +3,44 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
// Session-scoped HMAC key — generated per CLI start, never persisted.
|
|
4
4
|
const sessionKey = crypto.randomBytes(32);
|
|
5
5
|
const usedNonces = new Set();
|
|
6
|
+
/**
|
|
7
|
+
* Op-group coverage. An approval the user granted for one op also authorizes
|
|
8
|
+
* the natural follow-on ops that *complete* it, on the SAME object:
|
|
9
|
+
*
|
|
10
|
+
* - approving `create` covers create → modify (source write) → activate,
|
|
11
|
+
* because creating a DDIC/repo object via ADT is always shell → source →
|
|
12
|
+
* activate. One user "yes" to "create ZPM_DT_REASON", not three.
|
|
13
|
+
* - approving `modify` covers modify → activate (write then activate).
|
|
14
|
+
* - `delete` / `release` stay single-op (and single-use) — one-shot ops the
|
|
15
|
+
* user must approve explicitly each time.
|
|
16
|
+
*
|
|
17
|
+
* This removes the op_mismatch APPROVAL_INVALID storm (create approval used,
|
|
18
|
+
* then set_source rejected because it's a different op) without weakening the
|
|
19
|
+
* invariant that an approval only ever authorizes ITS object, within TTL.
|
|
20
|
+
*/
|
|
21
|
+
const OP_COVERAGE = {
|
|
22
|
+
create: new Set(['create', 'modify', 'activate']),
|
|
23
|
+
modify: new Set(['modify', 'activate']),
|
|
24
|
+
delete: new Set(['delete']),
|
|
25
|
+
activate: new Set(['activate']),
|
|
26
|
+
release: new Set(['release']),
|
|
27
|
+
};
|
|
28
|
+
/** create/modify approvals are reusable across their object's lifecycle ops
|
|
29
|
+
* until TTL; delete/release are genuinely one-shot. */
|
|
30
|
+
function isOneShotOp(op) {
|
|
31
|
+
return op === 'delete' || op === 'release';
|
|
32
|
+
}
|
|
6
33
|
export async function mintApprovalId(payload) {
|
|
7
34
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
8
35
|
return new SignJWT({ ...payload, nonce })
|
|
9
36
|
.setProtectedHeader({ alg: 'HS256' })
|
|
10
37
|
.setIssuedAt()
|
|
11
|
-
.
|
|
38
|
+
// 30m, was 5m. A single build phase (DDIC batch + RAP stack) runs many
|
|
39
|
+
// rounds over a slow VPN; the one-write-per-round guard stretches it well
|
|
40
|
+
// past 5m, which expired approvals mid-phase and forced re-prompts. The
|
|
41
|
+
// key is session-scoped (regenerated per CLI start) and every approval is
|
|
42
|
+
// object+op-group bounded, so a longer in-turn window is safe.
|
|
43
|
+
.setExpirationTime('30m')
|
|
12
44
|
.sign(sessionKey);
|
|
13
45
|
}
|
|
14
46
|
export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
|
|
@@ -43,12 +75,20 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
|
|
|
43
75
|
// taxonomy short. Mismatch / spent-nonce paths NEVER reach this catch.
|
|
44
76
|
return { ok: false, reason: 'invalid_signature' };
|
|
45
77
|
}
|
|
46
|
-
if (usedNonces.has(payload.nonce))
|
|
47
|
-
return { ok: false, reason: 'nonce_spent' };
|
|
48
78
|
if (payload.object !== expectedObject)
|
|
49
79
|
return { ok: false, reason: 'object_mismatch' };
|
|
50
|
-
|
|
80
|
+
// Op-group coverage: a create approval also authorizes the modify+activate
|
|
81
|
+
// that complete the create; a modify approval authorizes its activate.
|
|
82
|
+
const covered = OP_COVERAGE[payload.op];
|
|
83
|
+
if (!covered || !covered.has(expectedOp))
|
|
51
84
|
return { ok: false, reason: 'op_mismatch' };
|
|
52
|
-
|
|
85
|
+
// Single-use only for one-shot destructive ops. create/modify approvals are
|
|
86
|
+
// reusable for their object's lifecycle ops (create→modify→activate) within
|
|
87
|
+
// TTL — that IS the change the user approved, so re-spending is not replay.
|
|
88
|
+
if (isOneShotOp(payload.op)) {
|
|
89
|
+
if (usedNonces.has(payload.nonce))
|
|
90
|
+
return { ok: false, reason: 'nonce_spent' };
|
|
91
|
+
usedNonces.add(payload.nonce);
|
|
92
|
+
}
|
|
53
93
|
return { ok: true, payload };
|
|
54
94
|
}
|
package/dist/approvals/render.js
CHANGED
|
@@ -2,6 +2,7 @@ import { select, input } from '@inquirer/prompts';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
4
4
|
import { approvalEmitter } from '../ui/approval-emitter.js';
|
|
5
|
+
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
5
6
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
6
7
|
import { inquirerTheme } from '../repl/inquirer-theme.js';
|
|
7
8
|
import { PEACH } from '../renderer/banners.js';
|
|
@@ -102,6 +103,43 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
|
|
|
102
103
|
const visibleChanges = collapseImplicitActivates(changes);
|
|
103
104
|
const visibleCount = visibleChanges.length;
|
|
104
105
|
const noun = visibleCount === 1 ? 'change' : 'changes';
|
|
106
|
+
// Ink path (default): route the gate through the ask_question choice modal so
|
|
107
|
+
// it renders INSIDE the React frame. The prior inquirer select drew raw to
|
|
108
|
+
// stdout while Ink owned the terminal — it toggled raw mode out from under
|
|
109
|
+
// Ink and competed with the Footer for keystrokes (the "turn in progress —
|
|
110
|
+
// input kept in box" confusion, and dead arrow keys on the modal that
|
|
111
|
+
// followed). One input system = no handoff, no competition. Mirrors
|
|
112
|
+
// renderPerChangeApprovalV3, which already went Ink-native via approvalEmitter.
|
|
113
|
+
if (shouldUseInk()) {
|
|
114
|
+
const ctx = [
|
|
115
|
+
summary,
|
|
116
|
+
[`${visibleCount} ${noun}`, riskTag(effectiveRisk.level), transport ? `transport ${transport}` : '']
|
|
117
|
+
.filter(Boolean).join(' · '),
|
|
118
|
+
];
|
|
119
|
+
if (effectiveRisk.raisedBy.length > 0) {
|
|
120
|
+
ctx.push(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`);
|
|
121
|
+
}
|
|
122
|
+
for (const c of visibleChanges)
|
|
123
|
+
ctx.push(` ${opIcon(c.op)} ${c.object} (${c.type})`);
|
|
124
|
+
const result = await askQuestionEmitter.request({
|
|
125
|
+
id: 'plan-gate',
|
|
126
|
+
question: 'Apply this plan?',
|
|
127
|
+
// Strip chalk ANSI from opIcon/riskTag — the Ink modal renders `context`
|
|
128
|
+
// itself (dimColor), so embedded escape codes would show as raw text.
|
|
129
|
+
context: ctx.join('\n').replace(/\x1b\[[0-9;]*m/g, ''),
|
|
130
|
+
kind: 'choice',
|
|
131
|
+
choices: [
|
|
132
|
+
{ value: 'apply_all', label: `Apply all ${visibleCount} ${noun}` },
|
|
133
|
+
{ value: 'review', label: 'Review each change individually' },
|
|
134
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
if (result.answer === 'apply_all' || result.answer === 'review' || result.answer === 'cancel') {
|
|
138
|
+
return result.answer;
|
|
139
|
+
}
|
|
140
|
+
return 'cancel'; // Esc / no answer → cancel the plan (safe default)
|
|
141
|
+
}
|
|
142
|
+
// Non-Ink (one-shot / non-TTY / classic): the original inquirer flow.
|
|
105
143
|
console.log('');
|
|
106
144
|
console.log(`${peach('Plan')} ${summary}`);
|
|
107
145
|
const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
|
|
@@ -32,6 +32,10 @@ const RULES = [
|
|
|
32
32
|
{ skill: 'abap-upgrade-scan', patterns: [/upgrade\s+scan|scan.*s\/4|readiness\s+variant/i] },
|
|
33
33
|
{ skill: 'abap-migrate', patterns: [/ecc.*migration|migrate.*s\/4|api\s+successor/i] },
|
|
34
34
|
{ skill: 'abap-performance', patterns: [/performance|\bn\+1\b|select\s+inside\s+loop|slow\s+(query|select)/i] },
|
|
35
|
+
// abap-plan (B4, 2026-06-06): deliberately NO bare \bplan\b — "I plan to
|
|
36
|
+
// refactor X" must keep falling through to the refactor/explain rules.
|
|
37
|
+
// Multi-session / project-plan framing only; refine after real prompts.
|
|
38
|
+
{ skill: 'abap-plan', patterns: [/project\s+plan|implementation\s+plan|plan\s+(this|the|my)\s+(project|build|development)|multi.session\s+(project|plan|build)|resume\s+(the\s+|my\s+)?plan|step.by.step\s+plan/i] },
|
|
35
39
|
{ skill: 'abap-preflight', patterns: [/preflight|pre.release|go\/no.go|release\s+readiness/i] },
|
|
36
40
|
{ skill: 'abap-radar', patterns: [/radar|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
|
|
37
41
|
{ skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo/i] },
|
|
@@ -42,12 +46,12 @@ const RULES = [
|
|
|
42
46
|
{ skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
|
|
43
47
|
{ skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
|
|
44
48
|
];
|
|
45
|
-
if (RULES.length !==
|
|
49
|
+
if (RULES.length !== 33) {
|
|
46
50
|
// Warn-not-throw — managed-mode users never exercise classify() (the proxy
|
|
47
51
|
// does server-side classification), so a module-load crash would break
|
|
48
52
|
// them for a local-only consistency check. Unit tests assert the exact
|
|
49
53
|
// count.
|
|
50
|
-
console.warn(`[classifier] RULES drift: expected
|
|
54
|
+
console.warn(`[classifier] RULES drift: expected 33, got ${RULES.length} — reconcile with .claude/skills/`);
|
|
51
55
|
}
|
|
52
56
|
export function classify(prompt) {
|
|
53
57
|
for (const rule of RULES) {
|