@cspeach/cli 0.8.0 → 1.0.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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -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 +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -90,6 +90,24 @@ function maybeAddToGitignore(workspaceDir) {
|
|
|
90
90
|
// still works, the user just has to ignore it manually.
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the workspace folder PATH without creating it. Same resolution
|
|
95
|
+
* order as ensureWorkspace (config override > project-rooted default) but
|
|
96
|
+
* strictly read-only: no mkdir, no .gitignore append. For callers that only
|
|
97
|
+
* need to LOOK at the workspace — e.g. the pre-confirm prior-plan scan in
|
|
98
|
+
* save-command.ts, which must not leave filesystem side effects before the
|
|
99
|
+
* user says yes to the save.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveWorkspacePath() {
|
|
102
|
+
const cfg = await loadConfig();
|
|
103
|
+
const configured = cfg.workspace_folder?.trim();
|
|
104
|
+
return resolveTarget(configured);
|
|
105
|
+
}
|
|
106
|
+
function resolveTarget(configured) {
|
|
107
|
+
return configured && configured.length > 0
|
|
108
|
+
? expandHome(configured)
|
|
109
|
+
: defaultWorkspaceFor(process.cwd());
|
|
110
|
+
}
|
|
93
111
|
/**
|
|
94
112
|
* Resolve the workspace folder path, creating it if missing.
|
|
95
113
|
* Returns the absolute path. Idempotent.
|
|
@@ -97,9 +115,7 @@ function maybeAddToGitignore(workspaceDir) {
|
|
|
97
115
|
export async function ensureWorkspace() {
|
|
98
116
|
const cfg = await loadConfig();
|
|
99
117
|
const configured = cfg.workspace_folder?.trim();
|
|
100
|
-
const target = configured
|
|
101
|
-
? expandHome(configured)
|
|
102
|
-
: defaultWorkspaceFor(process.cwd());
|
|
118
|
+
const target = resolveTarget(configured);
|
|
103
119
|
const justCreated = !existsSync(target);
|
|
104
120
|
if (justCreated) {
|
|
105
121
|
mkdirSync(target, { recursive: true });
|
|
@@ -159,7 +175,8 @@ export function listProjectFilesSync(workspace) {
|
|
|
159
175
|
for (const name of entries) {
|
|
160
176
|
const isEnvelope = name.endsWith('.cspeach.json');
|
|
161
177
|
const isText = isTextFilename(name);
|
|
162
|
-
|
|
178
|
+
const isDoc = isDocFilename(name);
|
|
179
|
+
if (!isEnvelope && !isText && !isDoc)
|
|
163
180
|
continue;
|
|
164
181
|
const full = join(workspace, name);
|
|
165
182
|
const stat = statSync(full);
|
|
@@ -167,7 +184,7 @@ export function listProjectFilesSync(workspace) {
|
|
|
167
184
|
continue;
|
|
168
185
|
out.push({
|
|
169
186
|
filename: name,
|
|
170
|
-
kind: isEnvelope ? 'envelope' : 'text',
|
|
187
|
+
kind: isEnvelope ? 'envelope' : isDoc ? 'doc' : 'text',
|
|
171
188
|
mtimeMs: stat.mtimeMs,
|
|
172
189
|
});
|
|
173
190
|
}
|
|
@@ -190,10 +207,31 @@ function expandHome(p) {
|
|
|
190
207
|
}
|
|
191
208
|
/** File suffixes recognised as text-input candidates for skills. */
|
|
192
209
|
const TEXT_SUFFIXES = ['.txt', '.md'];
|
|
193
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Whether `name` is a text-input candidate (.txt / .md). Exported so the
|
|
212
|
+
* @<file> picker (at-picker.ts) reuses the exact same suffix rule instead
|
|
213
|
+
* of duplicating it.
|
|
214
|
+
*/
|
|
215
|
+
export function isTextFilename(name) {
|
|
194
216
|
const lower = name.toLowerCase();
|
|
195
217
|
return TEXT_SUFFIXES.some((s) => lower.endsWith(s));
|
|
196
218
|
}
|
|
219
|
+
/** File suffixes recognised as binary document inputs (read via extraction). */
|
|
220
|
+
const DOC_SUFFIXES = ['.docx', '.pdf'];
|
|
221
|
+
/**
|
|
222
|
+
* Whether `name` is a binary document input (.docx / .pdf). Listed and pickable
|
|
223
|
+
* like text inputs, but ingested via document extraction (the same path
|
|
224
|
+
* read_document uses) rather than read as UTF-8 text — so a picked Word/PDF
|
|
225
|
+
* spec just works as skill input.
|
|
226
|
+
*/
|
|
227
|
+
export function isDocFilename(name) {
|
|
228
|
+
const lower = name.toLowerCase();
|
|
229
|
+
return DOC_SUFFIXES.some((s) => lower.endsWith(s));
|
|
230
|
+
}
|
|
231
|
+
/** Any user-droppable skill input the @<file> picker should surface. */
|
|
232
|
+
export function isAttachableFilename(name) {
|
|
233
|
+
return isTextFilename(name) || isDocFilename(name);
|
|
234
|
+
}
|
|
197
235
|
/**
|
|
198
236
|
* List supported workspace files (.cspeach.json envelopes AND .txt/.md
|
|
199
237
|
* text inputs), parsed for a tiny metadata summary. Sorted by mtime
|
|
@@ -210,7 +248,8 @@ export async function listProjectFiles() {
|
|
|
210
248
|
for (const name of entries) {
|
|
211
249
|
const isEnvelope = name.endsWith('.cspeach.json');
|
|
212
250
|
const isText = isTextFilename(name);
|
|
213
|
-
|
|
251
|
+
const isDoc = isDocFilename(name);
|
|
252
|
+
if (!isEnvelope && !isText && !isDoc)
|
|
214
253
|
continue;
|
|
215
254
|
const path = join(ws, name);
|
|
216
255
|
const stat = statSync(path);
|
|
@@ -244,11 +283,11 @@ export async function listProjectFiles() {
|
|
|
244
283
|
});
|
|
245
284
|
}
|
|
246
285
|
else {
|
|
247
|
-
// text — no envelope metadata, just the file presence
|
|
286
|
+
// text / doc — no envelope metadata, just the file presence
|
|
248
287
|
out.push({
|
|
249
288
|
path,
|
|
250
289
|
filename: name,
|
|
251
|
-
kind: 'text',
|
|
290
|
+
kind: isDoc ? 'doc' : 'text',
|
|
252
291
|
artefactType: 'unknown',
|
|
253
292
|
version: null,
|
|
254
293
|
title: null,
|
|
@@ -336,14 +375,96 @@ export function resolveAtToken(args) {
|
|
|
336
375
|
}
|
|
337
376
|
return { kind: 'ambiguous', matches };
|
|
338
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether `token` is a BARE name (no path semantics) — the only case the
|
|
380
|
+
* cwd fallback applies to. Absolute paths and relative paths (./, ../, or
|
|
381
|
+
* containing a separator) already resolve verbatim in resolveAtToken.
|
|
382
|
+
*/
|
|
383
|
+
function isBareToken(token) {
|
|
384
|
+
if (isAbsolute(token))
|
|
385
|
+
return false;
|
|
386
|
+
if (token.startsWith('./') || token.startsWith('../') || token.startsWith('.\\') || token.startsWith('..\\')) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
if (token.includes('/') || token.includes('\\'))
|
|
390
|
+
return false;
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Substring-match a BARE token against the .txt/.md/.docx/.pdf files directly
|
|
395
|
+
* in `cwd` (non-recursive). Mirrors the workspace matching rule:
|
|
396
|
+
* - exactly one match → { kind: 'path', path: join(cwd, filename) }
|
|
397
|
+
* - multiple matches → { kind: 'ambiguous', matches }
|
|
398
|
+
* - none → null (caller keeps the original notFound)
|
|
399
|
+
*
|
|
400
|
+
* Never throws — an unreadable cwd yields null. Exported for direct unit
|
|
401
|
+
* testing of the fallback in isolation from ensureWorkspace I/O.
|
|
402
|
+
*/
|
|
403
|
+
export function resolveBareTokenInCwd(token, cwd) {
|
|
404
|
+
let entries;
|
|
405
|
+
try {
|
|
406
|
+
entries = readdirSync(cwd);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const lower = token.toLowerCase();
|
|
412
|
+
const matchedFiles = [];
|
|
413
|
+
for (const name of entries) {
|
|
414
|
+
if (name.startsWith('.'))
|
|
415
|
+
continue;
|
|
416
|
+
if (!isAttachableFilename(name))
|
|
417
|
+
continue;
|
|
418
|
+
if (!name.toLowerCase().includes(lower))
|
|
419
|
+
continue;
|
|
420
|
+
const path = join(cwd, name);
|
|
421
|
+
let stat;
|
|
422
|
+
try {
|
|
423
|
+
stat = statSync(path);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (!stat.isFile())
|
|
429
|
+
continue;
|
|
430
|
+
matchedFiles.push({
|
|
431
|
+
path,
|
|
432
|
+
filename: name,
|
|
433
|
+
kind: isDocFilename(name) ? 'doc' : 'text',
|
|
434
|
+
artefactType: 'unknown',
|
|
435
|
+
version: null,
|
|
436
|
+
title: null,
|
|
437
|
+
sizeBytes: stat.size,
|
|
438
|
+
mtimeMs: stat.mtimeMs,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (matchedFiles.length === 1) {
|
|
442
|
+
return { kind: 'path', path: matchedFiles[0].path };
|
|
443
|
+
}
|
|
444
|
+
if (matchedFiles.length > 1) {
|
|
445
|
+
return { kind: 'ambiguous', matches: matchedFiles };
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
339
449
|
/**
|
|
340
450
|
* High-level resolver — combines listProjectFiles + resolveAtToken into a
|
|
341
451
|
* single async call. Callers in promote-command, status, etc. use this.
|
|
452
|
+
*
|
|
453
|
+
* Bug #4: when a BARE token doesn't match anything in the workspace, fall
|
|
454
|
+
* back to matching .txt/.md files in `cwd` before reporting notFound. This
|
|
455
|
+
* lets a user drop a spec file next to their project and reference it with
|
|
456
|
+
* a plain `@spec.txt` instead of an absolute path.
|
|
342
457
|
*/
|
|
343
458
|
export async function resolveAtTokenAsync(token, cwd) {
|
|
344
459
|
const workspace = await ensureWorkspace();
|
|
345
460
|
const files = await listProjectFiles();
|
|
346
|
-
|
|
461
|
+
const result = resolveAtToken({ token, cwd, workspace, files });
|
|
462
|
+
if (result.kind === 'notFound' && isBareToken(token)) {
|
|
463
|
+
const cwdResult = resolveBareTokenInCwd(token, cwd);
|
|
464
|
+
if (cwdResult)
|
|
465
|
+
return cwdResult;
|
|
466
|
+
}
|
|
467
|
+
return result;
|
|
347
468
|
}
|
|
348
469
|
/**
|
|
349
470
|
* Format `listProjectFiles` output as a human-readable block for the
|
|
@@ -362,7 +483,7 @@ export function formatProjectFileList(files, workspace) {
|
|
|
362
483
|
const ageStr = relativeTime(now - f.mtimeMs);
|
|
363
484
|
const meta = f.kind === 'envelope'
|
|
364
485
|
? `${f.artefactType.padEnd(9)} v${f.version ?? '?'}`
|
|
365
|
-
:
|
|
486
|
+
: `${f.kind.padEnd(9)} ${formatBytes(f.sizeBytes).padStart(7)}`;
|
|
366
487
|
lines.push(` ${f.filename} — ${meta} ${ageStr}`);
|
|
367
488
|
}
|
|
368
489
|
lines.push('');
|
|
@@ -439,8 +560,10 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
439
560
|
if (resolved.kind !== 'path')
|
|
440
561
|
continue;
|
|
441
562
|
const path = resolved.path;
|
|
442
|
-
|
|
443
|
-
|
|
563
|
+
const asText = isTextFilename(path);
|
|
564
|
+
const asDoc = isDocFilename(path);
|
|
565
|
+
if (!asText && !asDoc)
|
|
566
|
+
continue; // envelopes / unknown → left for --from/--status
|
|
444
567
|
// Only ingest if the file exists. Missing files are just left as
|
|
445
568
|
// literal @<token> in the message (the LLM gets to see them too).
|
|
446
569
|
if (!existsSync(path)) {
|
|
@@ -448,19 +571,40 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
448
571
|
log(`(attached @${token} not found in workspace — left as literal)`);
|
|
449
572
|
continue;
|
|
450
573
|
}
|
|
574
|
+
const filename = path.split(/[\\\/]/).pop() ?? token;
|
|
451
575
|
let content;
|
|
452
|
-
|
|
453
|
-
|
|
576
|
+
if (asDoc) {
|
|
577
|
+
// .docx/.pdf — extract text the same way read_document does, so a picked
|
|
578
|
+
// Word/PDF spec becomes inline skill input with no separate tool call.
|
|
579
|
+
try {
|
|
580
|
+
const buf = readFileSync(path);
|
|
581
|
+
const ext = path.toLowerCase().endsWith('.pdf') ? '.pdf' : '.docx';
|
|
582
|
+
const { extractDocument } = await import('../tools/filesystem/extract-document.js');
|
|
583
|
+
const res = await extractDocument(buf, ext, { maxPages: 150 });
|
|
584
|
+
content = res.text;
|
|
585
|
+
if (log)
|
|
586
|
+
for (const w of res.warnings)
|
|
587
|
+
log(`(${filename}: ${w})`);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
if (log)
|
|
591
|
+
log(`(could not read @${token}: ${err.message})`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
454
594
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
595
|
+
else {
|
|
596
|
+
try {
|
|
597
|
+
content = readFileSync(path, 'utf8');
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (log)
|
|
601
|
+
log(`(could not read @${token}: ${err.message})`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
459
604
|
}
|
|
460
605
|
if (content.length > MAX_TEXT_FILE_CHARS) {
|
|
461
606
|
content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
|
|
462
607
|
}
|
|
463
|
-
const filename = path.split(/[\\\/]/).pop() ?? token;
|
|
464
608
|
const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
|
|
465
609
|
substitutions.push({ index, matchLen: match.length, replacement });
|
|
466
610
|
if (log)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notice rendering — the 'warn' / 'info' side-channel (battery B2 fix).
|
|
3
|
+
*
|
|
4
|
+
* Tool handlers emit user-facing notices on the SAME EventEmitter that
|
|
5
|
+
* carries 'chunk' stream data:
|
|
6
|
+
*
|
|
7
|
+
* ctx.chunkEmitter?.emit('warn', msg) — user-actionable notices
|
|
8
|
+
* (e.g. "ignoring transport=X,
|
|
9
|
+
* object locked in Y")
|
|
10
|
+
* ctx.chunkEmitter?.emit('info', msg) — dim, non-fatal diagnostics
|
|
11
|
+
* (e.g. "snapshot ✓", lookup
|
|
12
|
+
* fallback traces)
|
|
13
|
+
*
|
|
14
|
+
* Before this module existed those events were NO-OPS in the live REPL:
|
|
15
|
+
* the Ink Body only consumed 'chunk', and the classic/one-shot paths never
|
|
16
|
+
* put a chunkEmitter on the ToolContext at all. resolveWriteTransport's
|
|
17
|
+
* carefully-worded Rule 9 notices (D15/D20) were invisible.
|
|
18
|
+
*
|
|
19
|
+
* Two consumers, one formatter:
|
|
20
|
+
* - Ink: ui/body.tsx subscribes 'warn'/'info' and pushes
|
|
21
|
+
* formatNoticeLine(...) through the SAME chunk batcher, so
|
|
22
|
+
* ordering relative to surrounding chunks is preserved.
|
|
23
|
+
* - classic / one-shot: createClassicOutputEmitter() returns an emitter
|
|
24
|
+
* whose listeners write straight to stdout. repl.tsx
|
|
25
|
+
* (classic branch) and one-shot.ts put it on ctx.chunkEmitter.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT wired for subagents (tools/subagent/agent_run.ts):
|
|
28
|
+
* their ctx.chunkEmitter stays undefined so child-turn notices remain
|
|
29
|
+
* muted — exactly the pre-existing contract pinned in agent_run.test.ts.
|
|
30
|
+
*/
|
|
31
|
+
import chalk from 'chalk';
|
|
32
|
+
import { EventEmitter } from 'node:events';
|
|
33
|
+
/**
|
|
34
|
+
* Format one notice as a single terminal line (trailing newline included).
|
|
35
|
+
* Multi-line messages are collapsed to one line — notices are glanceable
|
|
36
|
+
* margin notes, not documents; anything long belongs in the tool result.
|
|
37
|
+
*
|
|
38
|
+
* warn → yellow " ⚠ message"
|
|
39
|
+
* info → dim " · message"
|
|
40
|
+
*/
|
|
41
|
+
export function formatNoticeLine(level, message) {
|
|
42
|
+
const oneLine = message.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
43
|
+
if (level === 'warn')
|
|
44
|
+
return chalk.yellow(` ⚠ ${oneLine}`) + '\n';
|
|
45
|
+
return chalk.dim(` · ${oneLine}`) + '\n';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
|
|
49
|
+
*
|
|
50
|
+
* Listeners:
|
|
51
|
+
* 'chunk' → written verbatim (already-formatted stream text — e.g. the
|
|
52
|
+
* verify-chain line emitted by write tools)
|
|
53
|
+
* 'warn' → formatNoticeLine('warn', …)
|
|
54
|
+
* 'info' → formatNoticeLine('info', …)
|
|
55
|
+
*
|
|
56
|
+
* The `write` sink defaults to process.stdout — injectable for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function createClassicOutputEmitter(write = (s) => process.stdout.write(s)) {
|
|
59
|
+
const emitter = new EventEmitter();
|
|
60
|
+
emitter.on('chunk', (s) => write(s));
|
|
61
|
+
emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
|
|
62
|
+
emitter.on('info', (msg) => write(formatNoticeLine('info', msg)));
|
|
63
|
+
return emitter;
|
|
64
|
+
}
|
|
@@ -92,6 +92,10 @@ const MESSAGES = {
|
|
|
92
92
|
'Reserving a transport request…',
|
|
93
93
|
'Asking SAP for a fresh TR…',
|
|
94
94
|
],
|
|
95
|
+
sap_transport_for_object: [
|
|
96
|
+
'Checking who owns {object_name}…',
|
|
97
|
+
'Looking for an owning transport…',
|
|
98
|
+
],
|
|
95
99
|
sap_transport_list: [
|
|
96
100
|
'Listing open transports…',
|
|
97
101
|
'Finding modifiable TRs…',
|
|
@@ -156,6 +160,10 @@ const MESSAGES = {
|
|
|
156
160
|
'Publishing service binding…',
|
|
157
161
|
'Asking SAP to expose the OData endpoint…',
|
|
158
162
|
],
|
|
163
|
+
sap_service_binding_unpublish: [
|
|
164
|
+
'Unpublishing service binding…',
|
|
165
|
+
'Asking SAP to remove the OData endpoint…',
|
|
166
|
+
],
|
|
159
167
|
};
|
|
160
168
|
const FALLBACK_MESSAGES = [
|
|
161
169
|
'Working on it…',
|
package/dist/renderer/syntax.js
CHANGED
|
@@ -22,10 +22,25 @@
|
|
|
22
22
|
* that object — the shape of the API is the same.
|
|
23
23
|
*/
|
|
24
24
|
import { highlight } from 'cli-highlight';
|
|
25
|
-
import
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
26
|
import abapGrammar from './highlighters/abap.js';
|
|
27
27
|
import cdsGrammar from './highlighters/cds.js';
|
|
28
28
|
import bdefGrammar from './highlighters/bdef.js';
|
|
29
|
+
// A5 fix (2026-06-06) — registration must hit the EXACT module instance
|
|
30
|
+
// cli-highlight loads, and on this pnpm/Windows layout that is NOT what a
|
|
31
|
+
// plain `import hljs from 'highlight.js'` (or even a local require)
|
|
32
|
+
// resolves to. Diagnosed live: our resolution lands on the top-level
|
|
33
|
+
// `node_modules/highlight.js` while cli-highlight requires its nested
|
|
34
|
+
// `.pnpm/highlight.js@10.7.3/...` copy — node treats them as two separate
|
|
35
|
+
// modules with separate language registries, so the grammars registered
|
|
36
|
+
// on ours were invisible to cli-highlight and EVERY abap/cds/bdef block
|
|
37
|
+
// silently fell back to unhighlighted echo ("Could not find the language
|
|
38
|
+
// 'abap'") — in production as well as tests, since the first release.
|
|
39
|
+
// Fix: chain createRequire through cli-highlight's own entry point so we
|
|
40
|
+
// follow its resolution and register on the instance it actually uses.
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
43
|
+
const hljs = createRequire(require.resolve('cli-highlight'))('highlight.js');
|
|
29
44
|
// Module-load-time registration. The grammar modules export plain objects
|
|
30
45
|
// (shape matches highlight.js `Language` definition). Cast is safe — the
|
|
31
46
|
// grammars match the runtime contract even when strict typings drift.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* fallback.
|
|
30
30
|
*/
|
|
31
31
|
import chalk from 'chalk';
|
|
32
|
-
const
|
|
32
|
+
const LLM_THRESHOLDS = [
|
|
33
33
|
{ at: 3_000, label: 'thinking…' },
|
|
34
34
|
{ at: 10_000, label: 'thinking… (10s)' },
|
|
35
35
|
{ at: 30_000, label: 'thinking… (30s)' },
|
|
@@ -39,10 +39,22 @@ const THRESHOLDS_MS = [
|
|
|
39
39
|
{ at: 180_000, label: 'thinking… (3m — Ctrl+C is recommended; the stream may have stalled)' },
|
|
40
40
|
{ at: 300_000, label: 'thinking… (5m — Ctrl+C and try again)' },
|
|
41
41
|
];
|
|
42
|
+
/** Suffixes for labelled (tool / hook) waits — appended to opts.label. */
|
|
43
|
+
const GENERIC_SUFFIXES = [
|
|
44
|
+
{ at: 3_000, suffix: '' },
|
|
45
|
+
{ at: 10_000, suffix: ' (10s)' },
|
|
46
|
+
{ at: 30_000, suffix: ' (30s)' },
|
|
47
|
+
{ at: 60_000, suffix: ' (60s — still running, hold on)' },
|
|
48
|
+
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
49
|
+
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
|
|
50
|
+
];
|
|
42
51
|
export function startThinkingHeartbeat(opts) {
|
|
43
52
|
const startedAt = Date.now();
|
|
44
53
|
let nextIdx = 0;
|
|
45
54
|
let stopped = false;
|
|
55
|
+
const THRESHOLDS_MS = opts?.label
|
|
56
|
+
? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
|
|
57
|
+
: LLM_THRESHOLDS;
|
|
46
58
|
const emitLine = (line) => {
|
|
47
59
|
if (opts?.chunkEmitter) {
|
|
48
60
|
// Phase A #4 — route through Ink's chunk pipeline. The Body
|
|
@@ -61,7 +61,7 @@ export function renderToolCallTop(params) {
|
|
|
61
61
|
else
|
|
62
62
|
process.stdout.write(s);
|
|
63
63
|
};
|
|
64
|
-
const summary =
|
|
64
|
+
const summary = summariseToolArgs(name, args);
|
|
65
65
|
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
66
66
|
const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
|
|
67
67
|
write('\n' + line + '\n');
|
|
@@ -136,7 +136,7 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
|
|
|
136
136
|
return { stop: stopFn };
|
|
137
137
|
}
|
|
138
138
|
export function renderToolCallBottom(params) {
|
|
139
|
-
const { durationMs, isError, resultSummary, chunkEmitter } = params;
|
|
139
|
+
const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
|
|
140
140
|
const write = (s) => {
|
|
141
141
|
if (chunkEmitter)
|
|
142
142
|
chunkEmitter.emit('chunk', s);
|
|
@@ -144,15 +144,26 @@ export function renderToolCallBottom(params) {
|
|
|
144
144
|
process.stdout.write(s);
|
|
145
145
|
};
|
|
146
146
|
const icon = isError ? chalk.red('✗') : chalk.green('✓');
|
|
147
|
+
// Label: plain (not bold — the top line keeps visual primacy) name +
|
|
148
|
+
// dim arg summary, mirroring the top-line shape so orphaned rows still
|
|
149
|
+
// read as "which tool, on what object".
|
|
150
|
+
let label = '';
|
|
151
|
+
if (name) {
|
|
152
|
+
const summary = summariseToolArgs(name, args ?? {});
|
|
153
|
+
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
154
|
+
label = ` ${name}${wrapped}`;
|
|
155
|
+
}
|
|
147
156
|
const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
|
|
148
157
|
const detail = detailRaw
|
|
149
158
|
? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
|
|
150
159
|
: '';
|
|
151
160
|
const timing = chalk.dim(formatDuration(durationMs));
|
|
152
161
|
const sep = chalk.dim('·');
|
|
153
|
-
let line = ` ${peach('⎿')} ${icon}`;
|
|
162
|
+
let line = ` ${peach('⎿')} ${icon}${label}`;
|
|
154
163
|
if (detail)
|
|
155
164
|
line += ` ${detail} ${sep} ${timing}`;
|
|
165
|
+
else if (label)
|
|
166
|
+
line += ` ${sep} ${timing}`;
|
|
156
167
|
else
|
|
157
168
|
line += ` ${timing}`;
|
|
158
169
|
write(line + '\n');
|
|
@@ -162,8 +173,11 @@ export function renderToolCallBottom(params) {
|
|
|
162
173
|
* arguments. Per-tool aware: pulls LLM-supplied summary first, then the
|
|
163
174
|
* common (name, type, package) triple, then specific keys for known tools.
|
|
164
175
|
* Falls back to a truncated JSON dump.
|
|
176
|
+
*
|
|
177
|
+
* Exported (D29) so the bottom result row can self-identify with the same
|
|
178
|
+
* summary the top line used.
|
|
165
179
|
*/
|
|
166
|
-
function
|
|
180
|
+
export function summariseToolArgs(toolName, args) {
|
|
167
181
|
if (!args || Object.keys(args).length === 0)
|
|
168
182
|
return '';
|
|
169
183
|
const a = args;
|
package/dist/renderer/tty.js
CHANGED
|
@@ -25,6 +25,36 @@ export function setRenderingOverride(mode) {
|
|
|
25
25
|
export function getRenderingOverride() {
|
|
26
26
|
return renderingOverride;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* B5 (2026-06-11) — headless answer policy signal.
|
|
30
|
+
*
|
|
31
|
+
* "Headless" means NO human can answer a prompt: stdin will never deliver
|
|
32
|
+
* an answer, so any inquirer/readline wait blocks forever (battery S2 Pass A
|
|
33
|
+
* stalled on exactly this — defect D1). Consumers (ask_question, the save
|
|
34
|
+
* hook, request_approval) branch to auto-answer / fail-fast policies instead
|
|
35
|
+
* of prompting.
|
|
36
|
+
*
|
|
37
|
+
* Decision order:
|
|
38
|
+
* 1. Explicit override — runOneShot() sets `true` at startup because
|
|
39
|
+
* one-shot is a single non-interactive turn by design (CI parity:
|
|
40
|
+
* the same invocation must behave identically piped or from a
|
|
41
|
+
* terminal). Tests set/clear it directly.
|
|
42
|
+
* 2. Fallback — stdin is not a TTY. Covers piped/redirected invocations
|
|
43
|
+
* that never went through runOneShot. stdin (not stdout) is the right
|
|
44
|
+
* floor: prompts BLOCK on stdin; piping only stdout still leaves a
|
|
45
|
+
* human able to answer.
|
|
46
|
+
*/
|
|
47
|
+
let headlessOverride = null;
|
|
48
|
+
/** Set (or clear with null) the explicit headless override. */
|
|
49
|
+
export function setHeadlessOverride(v) {
|
|
50
|
+
headlessOverride = v;
|
|
51
|
+
}
|
|
52
|
+
/** True when no human can answer prompts — see setHeadlessOverride. */
|
|
53
|
+
export function isHeadless() {
|
|
54
|
+
if (headlessOverride !== null)
|
|
55
|
+
return headlessOverride;
|
|
56
|
+
return process.stdin.isTTY !== true;
|
|
57
|
+
}
|
|
28
58
|
/**
|
|
29
59
|
* True when the Ink UI frame should be mounted.
|
|
30
60
|
*
|
|
@@ -32,8 +62,17 @@ export function getRenderingOverride() {
|
|
|
32
62
|
* 1. If override === 'classic', never use Ink (explicit opt-out)
|
|
33
63
|
* 2. If override === 'ink', use Ink when a TTY is available; otherwise
|
|
34
64
|
* fall back — Ink cannot render on a pipe regardless of user preference
|
|
35
|
-
* 3. If override === 'auto' (default), use Ink when interactive TTY
|
|
36
|
-
* not one-shot + CI=unset
|
|
65
|
+
* 3. If override === 'auto' (default), use Ink when interactive TTY on
|
|
66
|
+
* BOTH stdout and stdin + not one-shot + CI=unset
|
|
67
|
+
*
|
|
68
|
+
* UX-5 review C1 (2026-06-12): the auto branch must also require
|
|
69
|
+
* `process.stdin.isTTY` — Ink mounts raw-mode key handling on stdin, and a
|
|
70
|
+
* piped stdin (`"/cost`n/exit" | cspeach`) cannot enter raw mode. Worse,
|
|
71
|
+
* mounting Ink there bypassed the classic loop's early-line buffer
|
|
72
|
+
* (UX-5 #5), so every piped line was dropped. Piped-stdin invocations now
|
|
73
|
+
* take the classic path even when stdout is a terminal. The explicit
|
|
74
|
+
* `ui.rendering = 'ink'` override (branch 2) deliberately keeps its
|
|
75
|
+
* stdout-only floor — that's the user's explicit choice.
|
|
37
76
|
*
|
|
38
77
|
* v0.3+: pipeline.ts and approvals/render.ts branch on this.
|
|
39
78
|
*/
|
|
@@ -42,8 +81,8 @@ export function shouldUseInk() {
|
|
|
42
81
|
return false;
|
|
43
82
|
if (renderingOverride === 'ink')
|
|
44
83
|
return isTTY();
|
|
45
|
-
// auto
|
|
46
|
-
return isTTY() && !isOneShot() && !process.env['CI'];
|
|
84
|
+
// auto — stdin must be a TTY too (raw-mode capable, no piped lines to drop)
|
|
85
|
+
return isTTY() && process.stdin.isTTY === true && !isOneShot() && !process.env['CI'];
|
|
47
86
|
}
|
|
48
87
|
/**
|
|
49
88
|
* Stub for v0.3 Windows ConPTY / CI TTY hinting.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify-chain line — one compact row per completed SAP write summarizing
|
|
3
|
+
* the Forge-Rule pipeline the tool actually ran (tour slide-7 alignment):
|
|
4
|
+
*
|
|
5
|
+
* ① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activate pending
|
|
6
|
+
*
|
|
7
|
+
* HONESTY CONTRACT: every segment is derived from the tool's real result —
|
|
8
|
+
* never synthesized. States render truthfully:
|
|
9
|
+
* - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
|
|
10
|
+
* nothing to save; NEVER used for other read failures)
|
|
11
|
+
* - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
|
|
12
|
+
* the pre-write read failed for a non-404 reason; the object may exist
|
|
13
|
+
* but there is no recovery copy. Hard-gate tools block instead of
|
|
14
|
+
* emitting this; it only appears on soft (best-effort) gates.
|
|
15
|
+
* - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
|
|
16
|
+
* where the gate is soft, e.g. class includes)
|
|
17
|
+
* - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
|
|
18
|
+
* activation is blocked, so we don't show a fake pending step
|
|
19
|
+
* - syntax 'not_checked' → "③ syntax – (checked at activation)" —
|
|
20
|
+
* sap_set_class_include has no standalone syntax gate
|
|
21
|
+
* - ④ is always 'pending', never '✓': activation is a SEPARATE tool call
|
|
22
|
+
* (sap_activate); these write tools never activate.
|
|
23
|
+
*
|
|
24
|
+
* Consumed by the write tools in tools/sap-write.ts, emitted on the
|
|
25
|
+
* 'chunk' channel so it lands between the ⏺ dispatch line and the ⎿
|
|
26
|
+
* result row in both renderers.
|
|
27
|
+
*/
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
const pass = (s) => `${s} ${chalk.green('✓')}`;
|
|
30
|
+
const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
|
|
31
|
+
const skip = (s, why) => chalk.dim(`${s} – (${why})`);
|
|
32
|
+
const ERROR_DETAIL_MAX = 60;
|
|
33
|
+
const SNAPSHOT_DETAIL_MAX = 40;
|
|
34
|
+
const truncate = (s, max) => {
|
|
35
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
36
|
+
return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build the chain line (no trailing newline). Returns the full line
|
|
40
|
+
* including the two-space indent that aligns with the ⎿ result row.
|
|
41
|
+
*/
|
|
42
|
+
export function buildWriteChainLine(input) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
// ① snapshot
|
|
45
|
+
if (input.snapshot === 'taken')
|
|
46
|
+
parts.push(pass('① snapshot'));
|
|
47
|
+
else if (input.snapshot === 'new_object')
|
|
48
|
+
parts.push(skip('① snapshot', 'new object'));
|
|
49
|
+
else if (input.snapshot === 'source_read_failed') {
|
|
50
|
+
// Honest: the read failed — we do NOT know the object is new, and there
|
|
51
|
+
// is no recovery copy. Never render this as "(new object)".
|
|
52
|
+
const why = input.snapshotDetail
|
|
53
|
+
? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
|
|
54
|
+
: 'no source read';
|
|
55
|
+
parts.push(skip('① snapshot', why));
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
parts.push(skip('① snapshot', 'skipped'));
|
|
59
|
+
// ② written
|
|
60
|
+
parts.push(input.written ? pass('② written') : fail('② written'));
|
|
61
|
+
// ③ syntax (+ ④ activate, which only exists when activation is possible)
|
|
62
|
+
if (input.syntax === 'not_checked') {
|
|
63
|
+
parts.push(skip('③ syntax', 'checked at activation'));
|
|
64
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
65
|
+
}
|
|
66
|
+
else if (input.syntax.ok) {
|
|
67
|
+
parts.push(pass('③ syntax'));
|
|
68
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
|
|
72
|
+
const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
|
|
73
|
+
parts.push(fail('③ syntax', detail));
|
|
74
|
+
// No ④ — activation is blocked; rendering a pending step would lie.
|
|
75
|
+
}
|
|
76
|
+
return ' ' + parts.join(' ');
|
|
77
|
+
}
|