@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.
@@ -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 — drop incomplete tool_use
575
- // fragments so the next API call doesn't reject the assistant message.
576
- // See ./repair-partial.ts for the full rationale.
577
- if (interruptedError !== null) {
578
- currentAssistantContent = repairPartialBlocks(currentAssistantContent);
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
- await maybeOfferSave({
675
- skill: params.skill,
676
- assistantText,
677
- userMessage: userMessageForSave,
678
- tokensUsed: Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens),
679
- model: cfg.default_model ?? session.model ?? 'unknown',
680
- emit,
681
- promotedFrom: promotedFromForSave,
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
- const result = guardDecision.allow
742
- ? await dispatchTool(block.name, block.input, params.ctx)
743
- : { 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
+ }
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
+ }
@@ -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) {