@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
package/dist/repl.js CHANGED
@@ -37,6 +37,7 @@ import { slashCompleter } from './repl/slash-completer.js';
37
37
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
38
38
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
39
39
  import { atCompleter } from './repl/at-completer.js';
40
+ import { isPlanResumeCommand } from './commands/plan-resume.js';
40
41
  /**
41
42
  * Pre-fill the readline buffer with `text`, cursor at end, and trigger
42
43
  * a redraw. Bypasses rl.write() — which simulates per-character
@@ -640,13 +641,20 @@ export async function runRepl(opts) {
640
641
  chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
641
642
  if (rr.warning)
642
643
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
643
- await runTurn({
644
- provider,
645
- userMessage: rr.prompt,
646
- skill: rr.skill,
647
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
648
- chunkEmitter,
649
- });
644
+ const { turnStatusEmitter: rerouteStatus } = await import('./ui/turn-status-emitter.js');
645
+ rerouteStatus.start(`/${rr.skill}`);
646
+ try {
647
+ await runTurn({
648
+ provider,
649
+ userMessage: rr.prompt,
650
+ skill: rr.skill,
651
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
652
+ chunkEmitter,
653
+ });
654
+ }
655
+ finally {
656
+ rerouteStatus.stop();
657
+ }
650
658
  return;
651
659
  }
652
660
  // /new — clear any "awaiting skill answer" state so the next prompt
@@ -673,7 +681,9 @@ export async function runRepl(opts) {
673
681
  // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
674
682
  // context is lost on reroute, acceptable v0.3.1 limitation
675
683
  session.lastUserPrompt = parsed.prompt;
676
- const prompt = parsed.prompt;
684
+ // `let`, not `const` — the B3 plan-resume interception below rewrites
685
+ // the prompt with the prepared bounded phase context before runTurn.
686
+ let prompt = parsed.prompt;
677
687
  const extractedObject = parsed.object ?? null;
678
688
  const previousSkill = session.skill;
679
689
  const previousObject = session.last_object ?? null;
@@ -715,6 +725,12 @@ export async function runRepl(opts) {
715
725
  last_skill: session.last_skill ?? null,
716
726
  last_object: extractedObject ?? session.last_object ?? null,
717
727
  });
728
+ if (c.offline) {
729
+ // Remote classify failed twice — local-rules fallback fired.
730
+ // Tell the user so they know the picker preselection came
731
+ // from regex, not the LLM, and surface the real cause.
732
+ chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
733
+ }
718
734
  const decision = decideRouting(c);
719
735
  // 2026-05-17 — pass the just-used skill so the picker can
720
736
  // default cursor to it (one-Enter "continue" UX for follow-up
@@ -759,7 +775,7 @@ export async function runRepl(opts) {
759
775
  // consume model tokens, so we intercept here AFTER skill resolution
760
776
  // but BEFORE runTurn. The body lives in `prompt` (post-parseShortcut),
761
777
  // e.g. `--status @./foo.cspeach.json`.
762
- if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
778
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
763
779
  /(^|\s)--status(\s|$)/.test(prompt)) {
764
780
  try {
765
781
  const files = prompt
@@ -779,6 +795,42 @@ export async function runRepl(opts) {
779
795
  session.last_object = extractedObject ?? previousObject ?? null;
780
796
  return;
781
797
  }
798
+ // B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
799
+ // pre-work (load envelope, pick next eligible phase, print the
800
+ // tracker board, inline the phase's rule files) happens in
801
+ // preparePlanResume; terminal states (plan complete / all blocked)
802
+ // print and end the turn without any model call. When a phase IS
803
+ // eligible, the prepared bounded prompt replaces the user prompt
804
+ // and falls through to runTurn below — with suppressSaveHook so
805
+ // the new-envelope save hook stays quiet; finishPlanResume then
806
+ // persists the version-N+1 REVISION after the turn.
807
+ let planResume = null;
808
+ let planResumeMsgStart = 0;
809
+ if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
810
+ try {
811
+ const { preparePlanResume } = await import('./commands/plan-resume.js');
812
+ planResume = await preparePlanResume({
813
+ body: prompt,
814
+ cwd: process.cwd(),
815
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
816
+ });
817
+ }
818
+ catch (err) {
819
+ chunkEmitter.emit('chunk', chalk.red(`\n[--resume] ${err.message}\n`));
820
+ }
821
+ if (!planResume) {
822
+ session.last_skill = previousSkill ?? null;
823
+ session.last_object = extractedObject ?? previousObject ?? null;
824
+ return;
825
+ }
826
+ planResumeMsgStart = session.messages.length;
827
+ prompt = planResume.llmPrompt;
828
+ }
829
+ // 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
830
+ // onPlanManifest hook has persisted the revision, so the post-turn
831
+ // finishPlanResume below doesn't write it a second time.
832
+ let planSavedEarly = false;
833
+ const planResumeForHook = planResume;
782
834
  // Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
783
835
  // pushPostTurnStatus can compute per-turn deltas. The classic-mode
784
836
  // turn loop has its own equivalent below; both paths now call the
@@ -792,6 +844,11 @@ export async function runRepl(opts) {
792
844
  // the signal we thread into runTurn.
793
845
  currentTurnAbort = new AbortController();
794
846
  interruptCount = 0;
847
+ // 2026-06-07 — persistent status row lifecycle. Started here (not in
848
+ // loop.ts) so the try/finally GUARANTEES the row clears even when the
849
+ // turn throws; loop.ts only narrates activity changes in between.
850
+ const { turnStatusEmitter } = await import('./ui/turn-status-emitter.js');
851
+ turnStatusEmitter.start(`/${skill}`);
795
852
  try {
796
853
  await runTurn({
797
854
  provider,
@@ -800,6 +857,27 @@ export async function runRepl(opts) {
800
857
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
801
858
  chunkEmitter,
802
859
  signal: currentTurnAbort.signal,
860
+ suppressSaveHook: planResume !== null,
861
+ // Persist the plan revision the instant its manifest is finalised
862
+ // (before the continuation modal) so a Ctrl+C can't lose the phase.
863
+ onPlanManifest: planResumeForHook
864
+ ? async (assistantText) => {
865
+ try {
866
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
867
+ await finishPlanResume({
868
+ prepared: planResumeForHook,
869
+ messages: session.messages,
870
+ messagesStart: planResumeMsgStart,
871
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
872
+ textOverride: assistantText,
873
+ });
874
+ planSavedEarly = true;
875
+ }
876
+ catch (err) {
877
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
878
+ }
879
+ }
880
+ : undefined,
803
881
  });
804
882
  }
805
883
  catch (err) {
@@ -811,8 +889,31 @@ export async function runRepl(opts) {
811
889
  });
812
890
  }
813
891
  finally {
892
+ turnStatusEmitter.stop();
814
893
  currentTurnAbort = null;
815
894
  }
895
+ // B3 — persist the plan revision from the resume turn's manifest
896
+ // block (runs even after a turn error: partial assistant content is
897
+ // already in session.messages, and a phase may have written real SAP
898
+ // objects whose status must not be lost). FALLBACK path: skipped when
899
+ // the mid-turn onPlanManifest hook already saved it (manifest-on-
900
+ // interrupt, 2026-06-08). Still runs when no manifest was streamed —
901
+ // e.g. the turn errored before emitting one — so behaviour is unchanged
902
+ // for the no-manifest case.
903
+ if (planResume && !planSavedEarly) {
904
+ try {
905
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
906
+ await finishPlanResume({
907
+ prepared: planResume,
908
+ messages: session.messages,
909
+ messagesStart: planResumeMsgStart,
910
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
911
+ });
912
+ }
913
+ catch (err) {
914
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
915
+ }
916
+ }
816
917
  pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
817
918
  session.last_skill = previousSkill ?? null;
818
919
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -824,7 +925,18 @@ export async function runRepl(opts) {
824
925
  const dispatched = pendingDispatchAccessor.get();
825
926
  if (dispatched) {
826
927
  pendingDispatchAccessor.set(null);
827
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
928
+ // Auto-fire next phase: a plan resume rebuilds its bounded prompt from
929
+ // the envelope, so clear the finished phase's transcript first — the
930
+ // next phase then costs exactly what a manual fresh resume costs.
931
+ // (The phase is already saved via manifest-on-interrupt, so nothing is
932
+ // lost; prior prose is mirrored to ~/.cspeach/streams/.)
933
+ if (isPlanResumeCommand(dispatched)) {
934
+ session.messages = [];
935
+ chunkEmitter.emit('chunk', chalk.dim('↪ Auto-running next phase in fresh context\n'));
936
+ }
937
+ else {
938
+ chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
939
+ }
828
940
  // Schedule on next tick so the current handleSubmit's promise
829
941
  // resolves cleanly before the next one starts. Recursion via
830
942
  // setImmediate keeps the call stack flat across long chains.
@@ -868,11 +980,19 @@ export async function runRepl(opts) {
868
980
  writeMode: cfg.write_mode,
869
981
  onInterrupt,
870
982
  }), {
871
- // Critical: without this Ink calls process.exit() on Ctrl+C and
872
- // kills the CLI before our onInterrupt handler can decide what
873
- // to do. We want to make that decision ourselves (abort turn
874
- // vs exit at idle).
983
+ // exitOnCtrlC: false — Ink would otherwise call process.exit()
984
+ // on Ctrl+C before onInterrupt can decide whether to abort the
985
+ // in-flight turn or exit cleanly.
875
986
  exitOnCtrlC: false,
987
+ // W2.1 (2026-05-18) — patchConsole intercepts console.log /
988
+ // console.error / console.warn from inside ANY code that runs
989
+ // while Ink owns the frame, routes them above the Ink render
990
+ // region as Static-committed scrollback rather than corrupting
991
+ // the live frame. CC uses this (12 hits in v0.2.9 source).
992
+ // Removes a whole class of "console.log ate my frame" bugs and
993
+ // lets us delete the 6 manual chunkEmitter routings we added
994
+ // in commit 349ff75.
995
+ patchConsole: true,
876
996
  });
877
997
  await donePromise;
878
998
  unmount();
@@ -1150,7 +1270,13 @@ export async function runRepl(opts) {
1150
1270
  const dispatchedCmd = pendingDispatchAccessor.get();
1151
1271
  if (dispatchedCmd) {
1152
1272
  pendingDispatchAccessor.set(null);
1153
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1273
+ if (isPlanResumeCommand(dispatchedCmd)) {
1274
+ session.messages = [];
1275
+ console.log(chalk.dim('↪ Auto-running next phase in fresh context'));
1276
+ }
1277
+ else {
1278
+ console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1279
+ }
1154
1280
  line = dispatchedCmd;
1155
1281
  }
1156
1282
  else {
@@ -1499,7 +1625,9 @@ export async function runRepl(opts) {
1499
1625
  // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
1500
1626
  // context is lost on reroute, acceptable v0.3.1 limitation
1501
1627
  session.lastUserPrompt = parsed.prompt;
1502
- const prompt = parsed.prompt;
1628
+ // `let`, not `const` — the B3 plan-resume interception below rewrites
1629
+ // the prompt with the prepared bounded phase context before runTurn.
1630
+ let prompt = parsed.prompt;
1503
1631
  const extractedObject = parsed.object ?? null;
1504
1632
  const previousSkill = session.skill; // snapshot before this turn dispatches
1505
1633
  const previousObject = session.last_object ?? null; // carries from two turns ago
@@ -1536,6 +1664,9 @@ export async function runRepl(opts) {
1536
1664
  last_skill: session.last_skill ?? null,
1537
1665
  last_object: extractedObject ?? session.last_object ?? null,
1538
1666
  });
1667
+ if (c.offline) {
1668
+ console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
1669
+ }
1539
1670
  const decision = decideRouting(c);
1540
1671
  // 2026-05-17 — pass the just-used skill so the picker can
1541
1672
  // default cursor to it. session.skill = immediately-previous
@@ -1584,7 +1715,7 @@ export async function runRepl(opts) {
1584
1715
  // /abap-design, and /abap-estimate (renderer in projects/status.ts is
1585
1716
  // type-agnostic). Intercept after skill resolution, before runTurn. The
1586
1717
  // prompt body (post-parseShortcut) looks like `--status @./foo.cspeach.json`.
1587
- if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
1718
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
1588
1719
  /(^|\s)--status(\s|$)/.test(prompt)) {
1589
1720
  try {
1590
1721
  const files = prompt
@@ -1604,6 +1735,38 @@ export async function runRepl(opts) {
1604
1735
  session.last_object = extractedObject ?? previousObject ?? null;
1605
1736
  continue;
1606
1737
  }
1738
+ // B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
1739
+ // mirror of the Ink path above; see that block for the full design
1740
+ // note). Token-free pre-work; terminal states end the turn with no
1741
+ // model call; otherwise the prepared bounded prompt falls through to
1742
+ // runTurn with suppressSaveHook, and finishPlanResume persists the
1743
+ // envelope revision after the turn.
1744
+ let planResume = null;
1745
+ let planResumeMsgStart = 0;
1746
+ if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1747
+ try {
1748
+ const { preparePlanResume } = await import('./commands/plan-resume.js');
1749
+ planResume = await preparePlanResume({
1750
+ body: prompt,
1751
+ cwd: process.cwd(),
1752
+ log: (...lines) => lines.forEach((l) => console.log(l)),
1753
+ });
1754
+ }
1755
+ catch (err) {
1756
+ console.log(chalk.red(`\n[--resume] ${err.message}\n`));
1757
+ }
1758
+ if (!planResume) {
1759
+ session.last_skill = previousSkill ?? null;
1760
+ session.last_object = extractedObject ?? previousObject ?? null;
1761
+ continue;
1762
+ }
1763
+ planResumeMsgStart = session.messages.length;
1764
+ prompt = planResume.llmPrompt;
1765
+ }
1766
+ // 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
1767
+ // the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
1768
+ let planSavedEarly = false;
1769
+ const planResumeForHook = planResume;
1607
1770
  const turnStart = Date.now();
1608
1771
  // H1 — `session.usage` is session-LIFETIME (initialised once in
1609
1772
  // newSession, saved across turns). The footer's "this turn" label was
@@ -1658,6 +1821,25 @@ export async function runRepl(opts) {
1658
1821
  skill: skill,
1659
1822
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1660
1823
  signal: turnAbort.signal,
1824
+ suppressSaveHook: planResume !== null,
1825
+ onPlanManifest: planResumeForHook
1826
+ ? async (assistantText) => {
1827
+ try {
1828
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
1829
+ await finishPlanResume({
1830
+ prepared: planResumeForHook,
1831
+ messages: session.messages,
1832
+ messagesStart: planResumeMsgStart,
1833
+ log: (...lines) => lines.forEach((l) => console.log(l)),
1834
+ textOverride: assistantText,
1835
+ });
1836
+ planSavedEarly = true;
1837
+ }
1838
+ catch (err) {
1839
+ console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
1840
+ }
1841
+ }
1842
+ : undefined,
1661
1843
  });
1662
1844
  }
1663
1845
  catch (err) {
@@ -1677,6 +1859,23 @@ export async function runRepl(opts) {
1677
1859
  rl.removeListener('SIGINT', onSigint);
1678
1860
  turnInProgress = false;
1679
1861
  }
1862
+ // B3 — persist the plan revision from the resume turn's manifest block.
1863
+ // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
1864
+ // (manifest-on-interrupt, 2026-06-08); still runs when no manifest streamed.
1865
+ if (planResume && !planSavedEarly) {
1866
+ try {
1867
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
1868
+ await finishPlanResume({
1869
+ prepared: planResume,
1870
+ messages: session.messages,
1871
+ messagesStart: planResumeMsgStart,
1872
+ log: (...lines) => lines.forEach((l) => console.log(l)),
1873
+ });
1874
+ }
1875
+ catch (err) {
1876
+ console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1877
+ }
1878
+ }
1680
1879
  // Status footer after each turn.
1681
1880
  // tool_result messages also have role:'user' but content is an array —
1682
1881
  // filter them out so we count actual user prompts, not tool rounds.
@@ -1,19 +1,85 @@
1
1
  import { loadConfig } from '../config/loader.js';
2
2
  import { getStore } from '../auth/api-key.js';
3
+ import { classify as classifyLocal } from '../classifier/client.js';
4
+ // 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
5
+ // probe) plus headroom for a slow network. Tighter values risk timing out
6
+ // legitimate cold-start hits and burning the retry budget on the same.
7
+ const FETCH_TIMEOUT_MS = 8000;
8
+ // 250ms — long enough for a half-open TCP/keep-alive socket to be fully
9
+ // torn down before we try again, short enough that the user doesn't feel
10
+ // the retry as a hang.
11
+ const RETRY_DELAY_MS = 250;
12
+ /**
13
+ * Extract the real failure cause from a Node fetch / undici error. Node's
14
+ * native fetch surfaces `TypeError: fetch failed` and hides the actual
15
+ * layer on `err.cause` (UND_ERR_*, ENOTFOUND, ECONNRESET, ETIMEDOUT, TLS
16
+ * handshake aborts, etc.). This lets future failure messages name the
17
+ * actual layer so we can tell DNS vs TLS vs socket-reset vs Fly cold start.
18
+ */
19
+ function extractCause(err) {
20
+ const e = err;
21
+ if (e.cause) {
22
+ return `${e.cause.code ?? e.cause.errno ?? 'unknown'}${e.cause.message ? `: ${e.cause.message}` : ''}`;
23
+ }
24
+ return e.message;
25
+ }
26
+ async function tryFetchOnce(proxyUrl, key, prompt, context) {
27
+ const ctrl = new AbortController();
28
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
29
+ try {
30
+ return await fetch(`${proxyUrl}/v1/classify`, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Authorization': `Bearer ${key}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ body: JSON.stringify({ prompt, context }),
37
+ signal: ctrl.signal,
38
+ });
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
3
44
  export async function classifyPrompt(prompt, context) {
4
45
  const cfg = await loadConfig();
5
46
  const store = await getStore();
6
47
  const key = await store.get();
7
48
  if (!key)
8
49
  throw new Error('no_api_key');
9
- const r = await fetch(`${cfg.proxy_url}/v1/classify`, {
10
- method: 'POST',
11
- headers: {
12
- 'Authorization': `Bearer ${key}`,
13
- 'Content-Type': 'application/json',
14
- },
15
- body: JSON.stringify({ prompt, context }),
16
- });
50
+ let r;
51
+ let lastErr;
52
+ // Two attempts total: the second masks the common case of a stale
53
+ // keep-alive socket reused against a just-cold-suspended Fly machine
54
+ // (2026-05-20 probe showed first-hit cold = 3.2s, warm = 460ms — the
55
+ // half-open socket fails fast at the transport layer, then a fresh
56
+ // connection succeeds). HTTP-level failures (non-2xx) do NOT retry —
57
+ // they bubble through as before.
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ try {
60
+ r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
61
+ break;
62
+ }
63
+ catch (err) {
64
+ lastErr = err;
65
+ if (attempt === 0)
66
+ await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
67
+ }
68
+ }
69
+ if (!r) {
70
+ // Both attempts failed at the transport layer. Degrade to the bundled
71
+ // local rules so the user's turn is NOT dropped — they still get a
72
+ // coaching picker (confidence 0.3 < 0.60 = picker decision), and the
73
+ // REPL surfaces an offline hint with the real cause.
74
+ const skill = classifyLocal(prompt) ?? 'abap-radar';
75
+ return {
76
+ skill,
77
+ confidence: 0.3,
78
+ alternates: [],
79
+ offline: true,
80
+ transport_error: lastErr ? extractCause(lastErr) : 'unknown',
81
+ };
82
+ }
17
83
  if (!r.ok)
18
84
  throw new Error(`classifier HTTP ${r.status}`);
19
85
  return (await r.json());
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static skill catalog for CSPeach CLI v0.2.
3
3
  *
4
- * STALENESS RISK (spec §3.3): This constant is hardcoded against the 33 skills
5
- * in .claude/skills/ as of 2026-04-19. It will drift if skills are added or
4
+ * STALENESS RISK (spec §3.3): This constant is hardcoded against the 34 skills
5
+ * in .claude/skills/ as of 2026-06-06 (abap-plan added). It will drift if skills are added or
6
6
  * removed. At Plan 4b kickoff decision the build-time generation option
7
7
  * (scripts/generate-skill-catalog.ts reads SKILL.md frontmatter) or runtime
8
8
  * fetch (/v1/skills endpoint) will be chosen to replace this file. Until then,
@@ -219,4 +219,10 @@ export const SKILL_CATALOG = [
219
219
  description: 'Effort estimation for ABAP tickets — component breakdown with hour ranges (optimistic / realistic / pessimistic) + risk adjustments',
220
220
  whenToUse: `pick this for hour ranges + risk adjustments on a confirmed object list (step 3 of 3 — needs /abap-design first; high uncertainty without it)`,
221
221
  },
222
+ {
223
+ name: 'abap-plan',
224
+ category: 'Pre-Coding',
225
+ description: 'Multi-session project plan — phase envelope holds state across sessions; create from a goal or spec-gap file, resume one bounded phase per session',
226
+ whenToUse: `pick this when the work spans multiple sessions or components (not /abap-design — that's one design in one session; plan calls design per phase)`,
227
+ },
222
228
  ];
@@ -124,6 +124,13 @@ export async function agentRunHandler(args, ctx) {
124
124
  // Increment depth env var BEFORE the child runs; restore after.
125
125
  const priorDepth = process.env.CSPEACH_AGENT_DEPTH;
126
126
  process.env.CSPEACH_AGENT_DEPTH = String(depth + 1);
127
+ // W2.5 — when read_only is set, build a tool filter that drops every
128
+ // mutating tool from the subagent's tool list. Saves tokens on the
129
+ // sub-agent invocation (smaller tools array = less input cost) AND
130
+ // hard-prevents accidental writes from the subagent.
131
+ const toolFilter = args.read_only
132
+ ? (t) => !t.isMutating
133
+ : undefined;
127
134
  try {
128
135
  await runTurn({
129
136
  provider: ctx.provider,
@@ -132,6 +139,7 @@ export async function agentRunHandler(args, ctx) {
132
139
  ctx: childCtx,
133
140
  chunkEmitter: nullSinkEmitter,
134
141
  maxTokensOverride,
142
+ toolFilter,
135
143
  });
136
144
  }
137
145
  catch (err) {
@@ -179,6 +187,10 @@ registerTool({
179
187
  type: 'number',
180
188
  description: 'Optional per-call cap. Default 100000, hard cap 200000.',
181
189
  },
190
+ read_only: {
191
+ type: 'boolean',
192
+ description: 'When true, the subagent only sees read-only tools (no sap_set_source, no file-write, no shell_exec, etc.). Use this for research / analysis subagents that should not make changes — e.g. spawning a subagent to read code, run greps, or summarise findings. Smaller tools array = lower per-call cost; also a hard guard against accidental writes.',
193
+ },
182
194
  },
183
195
  required: ['skill', 'task'],
184
196
  },