@cspeach/cli 1.1.18 → 1.1.19
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/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
package/dist/session/recap.js
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
// who just resumed (or typed /recap) without scrolling back through the
|
|
5
5
|
// transcript.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// last session: /abap-generate · 4 turns · 3h ago · "add a validation…"
|
|
8
|
+
// tasks: 2 of 5 done — next: write the BDEF validation
|
|
9
|
+
// plan Maintenance Requests: phase 3 of 7 validated — next: /abap-plan --resume @…
|
|
10
|
+
//
|
|
11
|
+
// Piece 2: buildRecapParts() exposes the three facts without labels so the
|
|
12
|
+
// panels start screen can place them (resume line / "!" plan notice).
|
|
10
13
|
//
|
|
11
14
|
// Line 1 always prints (unless the session has zero user turns → recap is
|
|
12
15
|
// empty and callers print nothing). Line 2 only when the session's todo list
|
|
@@ -26,18 +29,14 @@ import { readProjectFile } from '../projects/status.js';
|
|
|
26
29
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
27
30
|
import { statusesFromItems, isPhaseSatisfied } from '../projects/plan-run.js';
|
|
28
31
|
import { defaultWorkspaceFor } from '../projects/workspace.js';
|
|
29
|
-
import { userPromptText } from './user-prompt.js';
|
|
32
|
+
import { userPromptText, promptPreviewText } from './user-prompt.js';
|
|
30
33
|
/** Wave-1 truncation rule (see /reroute): ≤60 chars total, '...' suffix. */
|
|
31
34
|
function truncatePrompt(s) {
|
|
32
35
|
return s.length > 60 ? s.slice(0, 57) + '...' : s;
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*/
|
|
38
|
-
export function buildRecap(session, cwd, opts) {
|
|
39
|
-
const lines = [];
|
|
40
|
-
// ── line 1: session orientation ─────────────────────────────────────────
|
|
37
|
+
export function buildRecapParts(session, cwd, opts) {
|
|
38
|
+
const parts = { last: null, tasks: null, plan: null };
|
|
39
|
+
// ── fact 1: session orientation ─────────────────────────────────────────
|
|
41
40
|
// "User turns" = typed prompts, not tool_result rounds — same counting
|
|
42
41
|
// rule as the resume picker (session/store.ts); see session/user-prompt.ts.
|
|
43
42
|
const userTexts = session.messages
|
|
@@ -45,7 +44,7 @@ export function buildRecap(session, cwd, opts) {
|
|
|
45
44
|
.filter((t) => t !== null);
|
|
46
45
|
const turns = userTexts.length;
|
|
47
46
|
if (turns === 0)
|
|
48
|
-
return
|
|
47
|
+
return parts;
|
|
49
48
|
const skillLabel = session.skill ? `/${session.skill}` : 'no skill';
|
|
50
49
|
// Last user prompt: prefer the persisted lastUserPrompt (user-typed prose;
|
|
51
50
|
// null after slash-only commands), fall back to the last string user
|
|
@@ -54,32 +53,45 @@ export function buildRecap(session, cwd, opts) {
|
|
|
54
53
|
let prompt = session.lastUserPrompt;
|
|
55
54
|
if (!prompt) {
|
|
56
55
|
const last = userTexts[userTexts.length - 1];
|
|
57
|
-
prompt = last
|
|
56
|
+
prompt = promptPreviewText(last) || null;
|
|
58
57
|
}
|
|
59
58
|
const quoted = prompt ? `"${truncatePrompt(prompt)}"` : '(no prompt)';
|
|
60
59
|
const ago = formatTimeAgo(session.last_turn_at, opts?.now);
|
|
61
|
-
|
|
62
|
-
// ──
|
|
60
|
+
parts.last = `${skillLabel} · ${turns} turn${turns === 1 ? '' : 's'} · ${ago} · ${quoted}`;
|
|
61
|
+
// ── fact 2: todo list (Task 4's todo_set) — only when open work exists ──
|
|
63
62
|
const todos = session.todos ?? [];
|
|
64
63
|
const open = todos.filter((t) => t.status !== 'completed');
|
|
65
64
|
if (open.length > 0) {
|
|
66
65
|
const done = todos.length - open.length;
|
|
67
66
|
const next = todos.find((t) => t.status === 'in_progress') ?? todos.find((t) => t.status === 'pending');
|
|
68
|
-
|
|
67
|
+
parts.tasks = `${done} of ${todos.length} done — next: ${next.text}`;
|
|
69
68
|
}
|
|
70
|
-
// ──
|
|
71
|
-
// Never throws: any fs/parse problem silently drops the
|
|
69
|
+
// ── fact 3: newest plan family with unsatisfied phases ──────────────────
|
|
70
|
+
// Never throws: any fs/parse problem silently drops the fact.
|
|
72
71
|
try {
|
|
73
|
-
|
|
74
|
-
if (planLine)
|
|
75
|
-
lines.push(planLine);
|
|
72
|
+
parts.plan = buildPlanPart(opts?.workspaceDir ?? defaultWorkspaceFor(cwd));
|
|
76
73
|
}
|
|
77
74
|
catch { /* recap must never throw */ }
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build the recap lines for a session. Returns [] for an empty session
|
|
79
|
+
* (zero user turns) — callers print nothing in that case.
|
|
80
|
+
*/
|
|
81
|
+
export function buildRecap(session, cwd, opts) {
|
|
82
|
+
const p = buildRecapParts(session, cwd, opts);
|
|
83
|
+
if (p.last === null)
|
|
84
|
+
return [];
|
|
85
|
+
const lines = [`last session: ${p.last}`];
|
|
86
|
+
if (p.tasks)
|
|
87
|
+
lines.push(`tasks: ${p.tasks}`);
|
|
88
|
+
if (p.plan)
|
|
89
|
+
lines.push(`plan ${p.plan.text} — next: ${p.plan.next}`);
|
|
78
90
|
return lines;
|
|
79
91
|
}
|
|
80
92
|
/**
|
|
81
93
|
* Scan `workspaceDir` for the newest plan family and, when it still has
|
|
82
|
-
* unsatisfied phases, produce the plan recap
|
|
94
|
+
* unsatisfied phases, produce the plan recap fact. Returns null when there
|
|
83
95
|
* is no plan, the newest plan family is fully satisfied, or its envelope is
|
|
84
96
|
* invalid (silent skip by design).
|
|
85
97
|
*
|
|
@@ -90,7 +102,7 @@ export function buildRecap(session, cwd, opts) {
|
|
|
90
102
|
* file's mtime. Non-plan envelopes (design, spec-gap, …) are skipped so the
|
|
91
103
|
* newest PLAN family is found even when a newer non-plan artefact exists.
|
|
92
104
|
*/
|
|
93
|
-
function
|
|
105
|
+
function buildPlanPart(workspaceDir) {
|
|
94
106
|
let names;
|
|
95
107
|
try {
|
|
96
108
|
names = readdirSync(workspaceDir);
|
|
@@ -157,7 +169,7 @@ function buildPlanLine(workspaceDir) {
|
|
|
157
169
|
const validated = pc.content.phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit)).length;
|
|
158
170
|
if (total === 0 || validated >= total)
|
|
159
171
|
return null; // fully satisfied — no line
|
|
160
|
-
return
|
|
172
|
+
return { text: `${env.title}: phase ${validated} of ${total} validated`, next: `/abap-plan --resume @${basename(cand.path)}` };
|
|
161
173
|
}
|
|
162
174
|
return null;
|
|
163
175
|
}
|
package/dist/session/resume.js
CHANGED
|
@@ -1,57 +1,113 @@
|
|
|
1
|
-
import { select } from '
|
|
1
|
+
import { select } from '../repl/themed-prompts.js';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { listSessions, loadSession } from './store.js';
|
|
3
|
+
import { listSessions, loadSession, RESUME_PICKER_LIMIT } from './store.js';
|
|
4
4
|
import { formatTimeAgo } from './time-ago.js';
|
|
5
5
|
import { runRepl } from '../repl.js';
|
|
6
|
-
|
|
6
|
+
import { formatFailure, formatAttention } from '../renderer/notice-shape.js';
|
|
7
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
8
|
+
import { paint } from '../renderer/theme.js';
|
|
9
|
+
import { truncateToWidth } from '../renderer/ui-width.js';
|
|
10
|
+
import { interruptedPickerLine } from './interrupt-reason.js';
|
|
11
|
+
import stringWidth from 'string-width';
|
|
12
|
+
import { STANDALONE_ALIAS } from '../tools/index.js';
|
|
13
|
+
import { loadConfig, resolveStandalone } from '../config/loader.js';
|
|
14
|
+
/** inquirer's Ctrl+C / force-close rejection. */
|
|
15
|
+
function isPromptCancel(err) {
|
|
16
|
+
return err?.name === 'ExitPromptError';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The sessions the resume picker offers: round 6 item 4b — no 0-turn
|
|
20
|
+
* sessions (a start-then-quit left a "0t (no prompt)" row every time) unless
|
|
21
|
+
* interrupted; interrupted ones first (most likely wanted after a crash),
|
|
22
|
+
* then most recent.
|
|
23
|
+
*/
|
|
24
|
+
export function pickerSessions(sessions) {
|
|
25
|
+
return sessions
|
|
26
|
+
.filter((s) => s.userTurnCount > 0 || s.interrupted)
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
if (a.interrupted !== b.interrupted)
|
|
29
|
+
return a.interrupted ? -1 : 1;
|
|
30
|
+
return b.lastTurnAt.localeCompare(a.lastTurnAt);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function runResume(id, opts = {}) {
|
|
7
34
|
let targetId = id;
|
|
8
35
|
if (!targetId) {
|
|
9
|
-
const sessions = await listSessions(
|
|
36
|
+
const sessions = pickerSessions(await listSessions(RESUME_PICKER_LIMIT, { skipEmpty: true }));
|
|
10
37
|
if (sessions.length === 0) {
|
|
11
38
|
console.log('No recent sessions.');
|
|
12
39
|
return 0;
|
|
13
40
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return a.interrupted ? -1 : 1;
|
|
19
|
-
return b.lastTurnAt.localeCompare(a.lastTurnAt);
|
|
20
|
-
});
|
|
21
|
-
targetId = await select({
|
|
22
|
-
message: `Pick a session to resume:
|
|
41
|
+
const sorted = sessions;
|
|
42
|
+
try {
|
|
43
|
+
targetId = await select({
|
|
44
|
+
message: `Pick a session to resume:
|
|
23
45
|
${formatSessionHeader()}`,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
:
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
choices: sorted.map((s) => ({
|
|
47
|
+
value: s.id,
|
|
48
|
+
name: formatSessionRow(s),
|
|
49
|
+
// Round 6 item 4a — the answered line shows the id, not the whole row.
|
|
50
|
+
short: s.id,
|
|
51
|
+
description: s.interrupted
|
|
52
|
+
? interruptedPickerLine(s.interruptedReason)
|
|
53
|
+
: `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
|
|
54
|
+
})),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// Ctrl+C in the picker: exit quietly, like Claude Code (fix round 3).
|
|
59
|
+
if (isPromptCancel(err))
|
|
60
|
+
return 0;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
32
63
|
}
|
|
33
64
|
let session;
|
|
34
65
|
try {
|
|
35
66
|
session = await loadSession(targetId);
|
|
36
67
|
}
|
|
37
68
|
catch (err) {
|
|
38
|
-
console.error(
|
|
69
|
+
console.error('\n' + formatFailure({ what: 'Could not load the session', detail: [err.message], next: 'cspeach --resume (pick from the list)' }));
|
|
39
70
|
return 1;
|
|
40
71
|
}
|
|
41
72
|
const unfinished = session.toolCalls.filter((c) => !c.completed);
|
|
73
|
+
// Round 6 review I3 — moving a session with unfinished SAP changes to
|
|
74
|
+
// another system would leave those changes unreconcilable: refuse.
|
|
75
|
+
const from = session.sap_system;
|
|
76
|
+
if (opts.sapAlias && unfinished.length > 0 && from && from !== STANDALONE_ALIAS && from !== opts.sapAlias) {
|
|
77
|
+
console.error(formatFailure({ what: `This session has unfinished changes on ${from} · resume without --sap-alias` }).trimEnd());
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
// Final batch 4 + re-review — the same rule for standalone on a SAP
|
|
81
|
+
// session, whether --no-sap asked for it or the persisted no_sap setting.
|
|
82
|
+
if (unfinished.length > 0 && from && from !== STANDALONE_ALIAS) {
|
|
83
|
+
const cfg = await loadConfig();
|
|
84
|
+
if (resolveStandalone(cfg, opts.noSap === true)) {
|
|
85
|
+
const how = opts.noSap ? 'resume without --no-sap' : 'resume with SAP enabled';
|
|
86
|
+
console.error(formatFailure({ what: `This session has unfinished changes on ${from} · ${how}` }).trimEnd());
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
42
90
|
if (unfinished.length > 0) {
|
|
43
|
-
console.log(
|
|
91
|
+
console.log('\n' + formatAttention({ text: `${unfinished.length} unfinished SAP change${unfinished.length === 1 ? '' : 's'} from the previous session`, fact: 'choose how to reconcile below' }));
|
|
44
92
|
for (const u of unfinished) {
|
|
45
93
|
console.log(` ${u.tool} ${JSON.stringify(u.args)}`);
|
|
46
94
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
95
|
+
let action;
|
|
96
|
+
try {
|
|
97
|
+
action = await select({
|
|
98
|
+
message: 'Reconcile how?',
|
|
99
|
+
choices: [
|
|
100
|
+
{ value: 'reconcile', name: 'Query SAP to find out what actually happened (requires SAP reachable)' },
|
|
101
|
+
{ value: 'continue', name: 'Leave as unresolved, continue (risky)' },
|
|
102
|
+
{ value: 'abandon', name: 'Abandon this session (file is kept on disk)' },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (isPromptCancel(err))
|
|
108
|
+
return 0;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
55
111
|
if (action === 'abandon')
|
|
56
112
|
return 0;
|
|
57
113
|
if (action === 'reconcile') {
|
|
@@ -65,19 +121,31 @@ ${formatSessionHeader()}`,
|
|
|
65
121
|
// kept (a stub from v0.1). Now resume genuinely picks up where the prior
|
|
66
122
|
// turn left off — including mid-turn-interrupted ones recoverable thanks
|
|
67
123
|
// to the persistence work in agent/loop.ts.
|
|
68
|
-
|
|
124
|
+
// Round 6 review C1 — the start flags reach the REPL (they used to be
|
|
125
|
+
// dropped, so --sap-alias never switched a resumed session).
|
|
126
|
+
return runRepl({ resumedSession: session, sapAlias: opts.sapAlias, noSap: opts.noSap });
|
|
69
127
|
}
|
|
70
128
|
// Column widths shared by the rows and the header so the labels line up.
|
|
71
129
|
const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
|
|
72
|
-
|
|
73
|
-
|
|
130
|
+
/**
|
|
131
|
+
* One picker row. Round 4 item 10 — the preview is cut to the terminal
|
|
132
|
+
* width (a long one wrapped onto a second line at 100 columns and broke the
|
|
133
|
+
* table): room = columns − the picker's 2-cell cursor gutter − the fixed
|
|
134
|
+
* columns − 1 spare cell.
|
|
135
|
+
*/
|
|
136
|
+
export function formatSessionRow(s, columns = process.stdout.columns ?? 80) {
|
|
137
|
+
const flag = s.interrupted ? paint('warn', getGlyphs().attention) + ' ' : ' ';
|
|
74
138
|
const idCol = chalk.cyan(s.id.padEnd(W.id));
|
|
75
139
|
const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
|
|
76
140
|
const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
|
|
77
141
|
const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
|
|
78
142
|
const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
|
|
79
|
-
const
|
|
80
|
-
|
|
143
|
+
const prefix = `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} `;
|
|
144
|
+
// Measured (string-width ignores colour codes), not summed: a value wider
|
|
145
|
+
// than its column pads nothing.
|
|
146
|
+
const room = Math.max(8, columns - 2 - stringWidth(prefix) - 1);
|
|
147
|
+
const snippet = s.promptSnippet != null ? truncateToWidth(s.promptSnippet, room) : chalk.dim('(no prompt)');
|
|
148
|
+
return `${prefix}${snippet}`;
|
|
81
149
|
}
|
|
82
150
|
/**
|
|
83
151
|
* One dim line naming the row columns (2026-09-23 — rows had no labels). The
|
package/dist/session/store.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { isUserStopReason } from './interrupt-reason.js';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import crypto from 'node:crypto';
|
|
4
6
|
import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
|
|
5
7
|
import { SCHEMA_VERSION } from './schema.js';
|
|
6
|
-
import { userPromptText, isUserPrompt } from './user-prompt.js';
|
|
8
|
+
import { userPromptText, isUserPrompt, promptPreviewText } from './user-prompt.js';
|
|
9
|
+
import { stripUnsignedThinking, dropTrailingThinkingOnly } from '../agent/repair-partial.js';
|
|
7
10
|
export function makeSessionId() {
|
|
8
11
|
return crypto.randomBytes(4).toString('hex');
|
|
9
12
|
}
|
|
@@ -12,6 +15,19 @@ function sessionPath(id, startedAt) {
|
|
|
12
15
|
const stamp = startedAt.replace(/[:.]/g, '-');
|
|
13
16
|
return path.join(dir, `${stamp}-${id}.json`);
|
|
14
17
|
}
|
|
18
|
+
/** Round 6 review I1 — has this session ever been written to disk? */
|
|
19
|
+
export function sessionSaved(session) {
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(sessionPath(session.id, session.started_at)))
|
|
22
|
+
return true;
|
|
23
|
+
// Final batch 2 — a pinned session lives in the pinned folder (any stamp).
|
|
24
|
+
const suffix = `-${session.id}.json`;
|
|
25
|
+
return existsSync(pinnedSessionsDir()) && readdirSync(pinnedSessionsDir()).some((f) => f.endsWith(suffix));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
15
31
|
export async function saveSession(session) {
|
|
16
32
|
const dir = sessionsDir();
|
|
17
33
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -84,19 +100,49 @@ function validateSchema(parsed) {
|
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
}
|
|
103
|
+
// 2026-09-27: heal sessions poisoned by a turn cancelled mid-thinking — an
|
|
104
|
+
// unsigned thinking block makes every later request 400 ("Invalid
|
|
105
|
+
// `signature` in `thinking` block"), so --resume and `continue` failed.
|
|
106
|
+
stripUnsignedThinking(parsed.messages);
|
|
107
|
+
// Review M2 — and a thinking-only last assistant turn (cut right after it).
|
|
108
|
+
dropTrailingThinkingOnly(parsed.messages);
|
|
87
109
|
return parsed;
|
|
88
110
|
}
|
|
89
|
-
|
|
111
|
+
/** How many sessions `cspeach --resume` lists — the unfinished count uses the same window. */
|
|
112
|
+
export const RESUME_PICKER_LIMIT = 20;
|
|
113
|
+
/**
|
|
114
|
+
* The session files most likely to be the `limit` most recent, newest first
|
|
115
|
+
* by modification time (fix round 3, items 11d/11e). Every save rewrites the
|
|
116
|
+
* file, so mtime tracks last_turn_at; a margin of extra candidates absorbs
|
|
117
|
+
* clock / copy skew, and the caller still sorts by last_turn_at. With
|
|
118
|
+
* thousands of files this parses a few dozen instead of all of them (each
|
|
119
|
+
* full pass cost ~1.3 s at startup with 4,410 files).
|
|
120
|
+
*/
|
|
121
|
+
async function recentSessionFiles(dir, limit) {
|
|
122
|
+
const files = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
|
|
123
|
+
const cap = Math.max(limit * 3, 50);
|
|
124
|
+
if (files.length <= cap)
|
|
125
|
+
return files;
|
|
126
|
+
const stamped = await Promise.all(files.map(async (f) => {
|
|
127
|
+
try {
|
|
128
|
+
return { f, t: (await fs.stat(path.join(dir, f))).mtimeMs };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { f, t: 0 };
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
stamped.sort((a, b) => b.t - a.t);
|
|
135
|
+
return stamped.slice(0, cap).map((x) => x.f);
|
|
136
|
+
}
|
|
137
|
+
export async function listSessions(limit = 10, opts = {}) {
|
|
90
138
|
// NOTE: bypasses validateSchema() for performance — reads JSON directly via
|
|
91
139
|
// JSON.parse + cast. Any new SessionState field accessed here must be
|
|
92
140
|
// defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
|
|
93
141
|
// serialised sessions on disk lack the field.
|
|
94
142
|
const dir = sessionsDir();
|
|
95
|
-
const files = await
|
|
143
|
+
const files = await recentSessionFiles(dir, limit);
|
|
96
144
|
const results = [];
|
|
97
145
|
for (const f of files) {
|
|
98
|
-
if (!f.endsWith('.json'))
|
|
99
|
-
continue;
|
|
100
146
|
try {
|
|
101
147
|
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
102
148
|
const parsed = JSON.parse(raw);
|
|
@@ -113,7 +159,9 @@ export async function listSessions(limit = 10) {
|
|
|
113
159
|
// Strip <session_context>...</session_context> wrapper that the
|
|
114
160
|
// first turn injects, leaving only the user-typed prompt visible
|
|
115
161
|
// in the picker.
|
|
116
|
-
|
|
162
|
+
// (fix round 3: every injected block, e.g. <project_context>, and
|
|
163
|
+
// newlines — they broke the picker's table row.)
|
|
164
|
+
const stripped = promptPreviewText(text);
|
|
117
165
|
promptSnippet = (stripped || '<empty>').slice(0, 80);
|
|
118
166
|
break;
|
|
119
167
|
}
|
|
@@ -135,32 +183,26 @@ export async function listSessions(limit = 10) {
|
|
|
135
183
|
catch { /* skip corrupt */ }
|
|
136
184
|
}
|
|
137
185
|
results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
|
|
138
|
-
|
|
186
|
+
// Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
|
|
187
|
+
// BEFORE the limit, so a pile of start-then-quit files cannot crowd out
|
|
188
|
+
// the sessions worth resuming.
|
|
189
|
+
const kept = opts.skipEmpty ? results.filter((r) => r.userTurnCount > 0 || r.interrupted) : results;
|
|
190
|
+
return kept.slice(0, limit);
|
|
139
191
|
}
|
|
140
192
|
/**
|
|
141
|
-
* Count interrupted sessions for the startup banner
|
|
142
|
-
*
|
|
143
|
-
*
|
|
193
|
+
* Count interrupted sessions for the startup banner — among the sessions the
|
|
194
|
+
* `--resume` picker can show (RESUME_PICKER_LIMIT most recent), so the
|
|
195
|
+
* "! N unfinished sessions" notice never points at sessions the picker does
|
|
196
|
+
* not list (fix round 3, item 11d). Returns 0 on any IO failure.
|
|
144
197
|
*/
|
|
145
198
|
export async function countInterruptedSessions() {
|
|
146
|
-
// NOTE: bypasses validateSchema() (same as listSessions). Any new
|
|
147
|
-
// SessionState field accessed here must be defaulted at the read site.
|
|
148
199
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
157
|
-
const parsed = JSON.parse(raw);
|
|
158
|
-
if (parsed.turnInterrupted === true)
|
|
159
|
-
count++;
|
|
160
|
-
}
|
|
161
|
-
catch { /* skip corrupt */ }
|
|
162
|
-
}
|
|
163
|
-
return count;
|
|
200
|
+
// Round 8 item 7 — a turn the USER stopped (Ctrl+C, /exit mid-turn) is
|
|
201
|
+
// not unfinished work: counting it kept "! 1 unfinished session" on every
|
|
202
|
+
// start until that session happened to finish a turn. The picker still
|
|
203
|
+
// lists it as "Stopped by you".
|
|
204
|
+
return (await listSessions(RESUME_PICKER_LIMIT))
|
|
205
|
+
.filter((s) => s.interrupted && !isUserStopReason(s.interruptedReason)).length;
|
|
164
206
|
}
|
|
165
207
|
catch {
|
|
166
208
|
return 0;
|
|
@@ -21,3 +21,24 @@ export function userPromptText(m) {
|
|
|
21
21
|
export function isUserPrompt(m) {
|
|
22
22
|
return userPromptText(m) !== null;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Tags of the context blocks the agent loop injects into a user message
|
|
26
|
+
* (`<session_context>` from the intent extractor / SAP system info,
|
|
27
|
+
* `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
|
|
28
|
+
* them, so a prompt preview must never show them (fix round 3, item 2).
|
|
29
|
+
*/
|
|
30
|
+
const INJECTED_CONTEXT_TAGS = ['session_context', 'project_context'];
|
|
31
|
+
/**
|
|
32
|
+
* One-line preview of what the user typed: injected context blocks removed,
|
|
33
|
+
* `<attached file="x">…</attached>` expansions shown as the original `@x`
|
|
34
|
+
* token, and all whitespace runs (newlines included) collapsed to one space.
|
|
35
|
+
* Used by the resume picker and the resumed-session recap line.
|
|
36
|
+
*/
|
|
37
|
+
export function promptPreviewText(text) {
|
|
38
|
+
let out = text;
|
|
39
|
+
for (const tag of INJECTED_CONTEXT_TAGS) {
|
|
40
|
+
out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), ' ');
|
|
41
|
+
}
|
|
42
|
+
out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
|
|
43
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
44
|
+
}
|
package/dist/skill-catalog.js
CHANGED
|
@@ -135,6 +135,13 @@ export const SKILL_CATALOG = [
|
|
|
135
135
|
whenToUse: `pick this for a freestyle UI5 frontend on a published OData service (not /abap-rap — that's the backend; produces local files, not ADT objects)`,
|
|
136
136
|
asks: 'which service and what UI? e.g. "list + object page on ZUI_LAS_INSPECTIONLOG_O4"',
|
|
137
137
|
},
|
|
138
|
+
{
|
|
139
|
+
name: 'abap-cap',
|
|
140
|
+
category: 'Create / Generate',
|
|
141
|
+
description: 'Build a CAP (Node.js) app on the laptop from a spec — CDS domain model, service, handlers, Fiori Elements annotations, run it locally with cds watch and prove it with the Fiori checks. Side-by-side extensions, not on-stack ABAP.',
|
|
142
|
+
whenToUse: `pick this for a CAP / BTP side-by-side app (cds, Node.js), not ABAP — on-stack RAP is /abap-rap; a UI on an existing OData service is /abap-fiori-build`,
|
|
143
|
+
asks: 'what should the app manage? e.g. "suppliers and their certificates, with draft editing"',
|
|
144
|
+
},
|
|
138
145
|
{
|
|
139
146
|
name: 'abap-data-model',
|
|
140
147
|
category: 'Create / Generate',
|