@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.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. 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 && configured.length > 0
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
- if (!isEnvelope && !isText)
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
- function isTextFilename(name) {
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
- if (!isEnvelope && !isText)
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
- return resolveAtToken({ token, cwd, workspace, files });
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
- : `text ${formatBytes(f.sizeBytes).padStart(7)}`;
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
- if (!isTextFilename(path))
443
- continue;
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
- try {
453
- content = readFileSync(path, 'utf8');
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
- catch (err) {
456
- if (log)
457
- log(`(could not read @${token}: ${err.message})`);
458
- continue;
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…',
@@ -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 hljs from 'highlight.js';
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 THRESHOLDS_MS = [
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 = argSummary(name, args);
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 argSummary(toolName, args) {
180
+ export function summariseToolArgs(toolName, args) {
167
181
  if (!args || Object.keys(args).length === 0)
168
182
  return '';
169
183
  const a = args;
@@ -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
+ }