@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.
Files changed (50) hide show
  1. package/bench/README.md +78 -0
  2. package/bench/prompts/abap-document-cds.md +44 -0
  3. package/bench/prompts/abap-explain-bdef-handler.md +57 -0
  4. package/bench/prompts/abap-test-method.md +42 -0
  5. package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
  6. package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
  7. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
  8. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
  9. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
  10. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
  11. package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
  12. package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
  13. package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
  14. package/dist/agent/loop.js +144 -26
  15. package/dist/agent/steering-queue.js +27 -0
  16. package/dist/approvals/jwt.js +45 -5
  17. package/dist/approvals/render.js +38 -0
  18. package/dist/classifier/client.js +6 -2
  19. package/dist/commands/plan-resume.js +308 -0
  20. package/dist/config/loader.js +13 -6
  21. package/dist/cost/pricing.js +14 -5
  22. package/dist/one-shot.js +6 -0
  23. package/dist/projects/email-template.js +2 -0
  24. package/dist/projects/extract-plan.js +85 -0
  25. package/dist/projects/index.js +3 -0
  26. package/dist/projects/plan-run.js +120 -0
  27. package/dist/projects/plan-schema.js +150 -0
  28. package/dist/projects/promote-command.js +1 -0
  29. package/dist/projects/save-command.js +17 -1
  30. package/dist/projects/status.js +20 -0
  31. package/dist/projects/validate.js +2 -0
  32. package/dist/renderer/question-normalizer.js +16 -6
  33. package/dist/renderer/syntax.js +16 -1
  34. package/dist/renderer/thinking-heartbeat.js +13 -1
  35. package/dist/repl/at-picker.js +39 -20
  36. package/dist/repl/slash-picker.js +15 -4
  37. package/dist/repl.js +216 -17
  38. package/dist/router/classifier.js +74 -8
  39. package/dist/skill-catalog.js +8 -2
  40. package/dist/tools/subagent/agent_run.js +12 -0
  41. package/dist/ui/app.js +80 -11
  42. package/dist/ui/body.js +61 -2
  43. package/dist/ui/command-palette.js +46 -10
  44. package/dist/ui/file-palette.js +44 -0
  45. package/dist/ui/footer.js +8 -5
  46. package/dist/ui/line-resolution.js +81 -0
  47. package/dist/ui/turn-status-emitter.js +52 -0
  48. package/dist/ui/turn-status.js +59 -0
  49. package/dist/ui/widgets/ask-question-modal.js +26 -1
  50. package/package.json +6 -2
@@ -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. The function disables itself when a chunkEmitter is
329
- // provided (it would otherwise race with the chunkEmitter renderer's
330
- // own paint loop), so call it without one — the spinner writes
331
- // directly to stdout. Stopped on the first content_block_start (so
332
- // the response stream paints in its place) and on any error path
333
- // below.
334
- const thinkingSpinner = startThinkingSpinner({});
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
- const tools = toAnthropicTools(listTools());
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 — drop incomplete tool_use
562
- // fragments so the next API call doesn't reject the assistant message.
563
- // See ./repair-partial.ts for the full rationale.
564
- if (interruptedError !== null) {
565
- currentAssistantContent = repairPartialBlocks(currentAssistantContent);
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
- await maybeOfferSave({
662
- skill: params.skill,
663
- assistantText,
664
- userMessage: userMessageForSave,
665
- tokensUsed: Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens),
666
- model: cfg.default_model ?? session.model ?? 'unknown',
667
- emit,
668
- promotedFrom: promotedFromForSave,
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
- const result = guardDecision.allow
729
- ? await dispatchTool(block.name, block.input, params.ctx)
730
- : { content: guardDecision.errorContent, is_error: true };
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
+ }
@@ -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
- .setExpirationTime('5m')
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
- if (payload.op !== expectedOp)
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
- usedNonces.add(payload.nonce);
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
  }
@@ -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 !== 32) {
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 32, got ${RULES.length} — reconcile with .claude/skills/`);
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) {