@cspeach/cli 0.8.0 → 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/dist/agent/loop.js +123 -18
- 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 +7 -2
- package/dist/cost/pricing.js +14 -5
- 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/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 +195 -13
- package/dist/skill-catalog.js +8 -2
- package/dist/ui/app.js +80 -11
- 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 +5 -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,9 +327,27 @@ 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
|
|
@@ -339,6 +366,7 @@ export async function runTurn(params) {
|
|
|
339
366
|
// routed) covers liveness feedback. Classic mode (no chunkEmitter)
|
|
340
367
|
// keeps the stdout spinner unchanged.
|
|
341
368
|
// Guard: test/unit/ink-cursor-invariant.test.ts.
|
|
369
|
+
turnStatusEmitter.activity('thinking');
|
|
342
370
|
const thinkingSpinner = startThinkingSpinner({ chunkEmitter: params.chunkEmitter });
|
|
343
371
|
// v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
|
|
344
372
|
// The spinner self-disables on non-TTY (Windows PowerShell sometimes
|
|
@@ -358,6 +386,19 @@ export async function runTurn(params) {
|
|
|
358
386
|
const allTools = listTools();
|
|
359
387
|
const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
|
|
360
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);
|
|
361
402
|
const streamParams = {
|
|
362
403
|
model: cfg.default_model,
|
|
363
404
|
// v0.3.1 — was 8192. Bumped because code-gen skills (abap-generate
|
|
@@ -457,6 +498,11 @@ export async function runTurn(params) {
|
|
|
457
498
|
// call stop on an already-stopped handle, which is a no-op.
|
|
458
499
|
thinkingSpinner.stop();
|
|
459
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');
|
|
460
506
|
const block = event.content_block;
|
|
461
507
|
currentAssistantContent.push(block);
|
|
462
508
|
if (block.type === 'text') {
|
|
@@ -571,18 +617,36 @@ export async function runTurn(params) {
|
|
|
571
617
|
process.stdout.write(flushed);
|
|
572
618
|
buffer = initialBufferState();
|
|
573
619
|
}
|
|
574
|
-
// Repair partial blocks before persistence
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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);
|
|
580
629
|
// Persist the assistant message — even if it's partial. Empty content
|
|
581
630
|
// arrays are skipped so we don't push a meaningless empty assistant
|
|
582
631
|
// message that would confuse the next round.
|
|
583
632
|
if (currentAssistantContent.length > 0) {
|
|
584
633
|
session.messages.push({ role: 'assistant', content: currentAssistantContent });
|
|
585
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
|
+
}
|
|
586
650
|
session.last_turn_at = new Date().toISOString();
|
|
587
651
|
if (interruptedError !== null) {
|
|
588
652
|
session.turnInterrupted = true;
|
|
@@ -670,16 +734,19 @@ export async function runTurn(params) {
|
|
|
670
734
|
// their manifest in an early message before a closing ask_question
|
|
671
735
|
// widget; the final wrap-up message ends the turn but doesn't
|
|
672
736
|
// carry the manifest. See ./turn-assistant-text.ts for details.
|
|
737
|
+
turnStatusEmitter.activity('finishing up');
|
|
673
738
|
const assistantText = collectTurnAssistantText(session.messages, turnStartMessageCount);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
+
}
|
|
683
750
|
// Phase 2 #9 (2026-05-16) — auto-compact end-of-turn hook.
|
|
684
751
|
// Runs only on the success path (after maybeOfferSave) so an interrupted
|
|
685
752
|
// turn never triggers a summariser call on partial state. The maybeAutoCompact
|
|
@@ -734,13 +801,51 @@ export async function runTurn(params) {
|
|
|
734
801
|
// animation. Spinner is purely a "still working" cue, not
|
|
735
802
|
// load-bearing for output.
|
|
736
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
|
+
});
|
|
737
829
|
// Phase 2b: dispatch (may take 100ms–several seconds for write tools).
|
|
738
830
|
const dispatchStart = Date.now();
|
|
739
831
|
// Bug 8 — short-circuit for 2nd+ write-class tool in the same round.
|
|
740
832
|
const guardDecision = checkAndMark(block.name, writeGuard);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
}
|
|
744
849
|
const durationMs = Date.now() - dispatchStart;
|
|
745
850
|
// Phase 2c: stop the spinner, which erases its line so the result
|
|
746
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) {
|