@cspeach/cli 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/README.md +78 -0
- package/bench/prompts/abap-document-cds.md +44 -0
- package/bench/prompts/abap-explain-bdef-handler.md +57 -0
- package/bench/prompts/abap-test-method.md +42 -0
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
- package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
- package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
- package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
- package/dist/agent/loop.js +144 -26
- package/dist/agent/steering-queue.js +27 -0
- package/dist/approvals/jwt.js +45 -5
- package/dist/approvals/render.js +38 -0
- package/dist/classifier/client.js +6 -2
- package/dist/commands/plan-resume.js +308 -0
- package/dist/config/loader.js +13 -6
- package/dist/cost/pricing.js +14 -5
- package/dist/one-shot.js +6 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-plan.js +85 -0
- package/dist/projects/index.js +3 -0
- package/dist/projects/plan-run.js +120 -0
- package/dist/projects/plan-schema.js +150 -0
- package/dist/projects/promote-command.js +1 -0
- package/dist/projects/save-command.js +17 -1
- package/dist/projects/status.js +20 -0
- package/dist/projects/validate.js +2 -0
- package/dist/renderer/question-normalizer.js +16 -6
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/repl/at-picker.js +39 -20
- package/dist/repl/slash-picker.js +15 -4
- package/dist/repl.js +216 -17
- package/dist/router/classifier.js +74 -8
- package/dist/skill-catalog.js +8 -2
- package/dist/tools/subagent/agent_run.js +12 -0
- package/dist/ui/app.js +80 -11
- package/dist/ui/body.js +61 -2
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +8 -5
- package/dist/ui/line-resolution.js +81 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +26 -1
- package/package.json +6 -2
package/dist/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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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());
|
package/dist/skill-catalog.js
CHANGED
|
@@ -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
|
|
5
|
-
* in .claude/skills/ as of 2026-
|
|
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
|
},
|