@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
@@ -11,16 +11,25 @@
11
11
  * BOTH this file AND the proxy's version. Verify on the public pricing page:
12
12
  * https://www.anthropic.com/pricing
13
13
  *
14
- * Last verified 2026-05-12. Same as proxy.
14
+ * Last verified 2026-06-07. Same as proxy.
15
+ *
16
+ * 2026-06-07 SYNC: e294cff switched the default model to claude-opus-4-8 AND
17
+ * applied the 3x Opus correction, but only to the proxy copy — this CLI copy
18
+ * was left stale, so every opus-4-8 turn was an unknown model (footer $0) and
19
+ * opus-4-5/4-6/4-7 still carried the legacy $15/$75 (~3x overstated). Brought
20
+ * back in line with cspeach-proxy/src/anthropic/pricing.ts below.
15
21
  *
16
22
  * Unknown models → returns null + records the model name to a process-level
17
23
  * Set so we surface them as an obvious warning rather than silently zeroing.
18
24
  */
19
25
  const PRICING = {
20
- // Claude Opus 4.x family — input 15, output 75, cache_read 1.50, cache_write 18.75
21
- 'claude-opus-4-7': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
22
- 'claude-opus-4-6': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
23
- 'claude-opus-4-5': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
26
+ // Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
27
+ // 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
28
+ // since Opus 4.5 is $5/$25. Only claude-opus-4 (4.0/4.1) genuinely was $15/$75.
29
+ 'claude-opus-4-8': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
30
+ 'claude-opus-4-7': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
31
+ 'claude-opus-4-6': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
32
+ 'claude-opus-4-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
24
33
  'claude-opus-4': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
25
34
  // Claude Sonnet 4.x family — input 3, output 15, cache_read 0.30, cache_write 3.75
26
35
  'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheCreatePer1M: 3.75 },
@@ -35,6 +35,7 @@ export function probeGet(url, opts) {
35
35
  path: u.pathname + u.search,
36
36
  timeout: opts.timeoutMs ?? 8000,
37
37
  rejectUnauthorized: opts.rejectUnauthorized,
38
+ ...(opts.ca !== undefined ? { ca: opts.ca } : {}),
38
39
  headers,
39
40
  }, (res) => {
40
41
  const chunks = [];
@@ -1,5 +1,5 @@
1
1
  import tls from 'node:tls';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
3
  export async function checkCert() {
4
4
  const cfg = await loadConfig();
5
5
  const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
@@ -7,14 +7,25 @@ export async function checkCert() {
7
7
  return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
8
8
  const results = [];
9
9
  for (const [alias, sys] of systems) {
10
+ const tlsMode = resolveSapTls(sys);
10
11
  const info = await new Promise((resolve) => {
12
+ // Probe with rejectUnauthorized:false so we can always read the cert and
13
+ // report the OS-trust chain status, then describe the configured posture
14
+ // separately (verify / CA-trusted / INSECURE).
11
15
  const socket = tls.connect({ host: sys.host, port: sys.port, rejectUnauthorized: false, timeout: 5000 }, () => {
12
16
  const cert = socket.getPeerCertificate();
13
17
  const authorized = socket.authorized;
14
18
  socket.end();
15
19
  const chainValid = authorized ? 'yes' : 'no';
16
- const clientAccepted = !sys.sslVerify ? 'yes (trust-manager allows self-signed)' : (authorized ? 'yes' : 'no');
17
- resolve(`${alias}: chain valid: ${chainValid} / client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
20
+ // What the configured client mode means for acceptance:
21
+ // verify → accepts only if the OS/Node trust chain validates
22
+ // CA-trusted → accepts via the configured CA (verification ON)
23
+ // INSECURE → accepts anything (verification OFF — dev only)
24
+ const clientAccepted = tlsMode.mode === 'insecure' ? 'yes (INSECURE — verification disabled, dev only)'
25
+ : tlsMode.mode === 'ca-trusted' ? 'yes (CA-trusted — verification ON via configured CA)'
26
+ : (authorized ? 'yes' : 'no (verify ON — configure ca_cert_path to trust an internal CA)');
27
+ resolve(`${alias}: mode: ${tlsMode.mode} / chain valid: ${chainValid} / ` +
28
+ `client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
18
29
  });
19
30
  socket.on('error', (err) => resolve(`${alias}: ${err.message}`));
20
31
  });
@@ -1,4 +1,5 @@
1
- import { loadConfig } from '../../config/loader.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
2
3
  import { probeGet } from './_http-probe.js';
3
4
  export async function checkSap() {
4
5
  const cfg = await loadConfig();
@@ -9,23 +10,44 @@ export async function checkSap() {
9
10
  let anyFailed = false;
10
11
  for (const [alias, sys] of systems) {
11
12
  const url = `${sys.useSsl ? 'https' : 'http'}://${sys.host}:${sys.port}/sap/bc/adt/discovery`;
12
- // Use node's https directly so we can honor sslVerify=false for
13
- // self-signed dev systems — Node's built-in fetch() doesn't expose a
14
- // per-request TLS-verify toggle. See checks/_http-probe.ts.
13
+ // TLS posture (2026-06-14): verification ON by default. resolveSapTls maps
14
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
15
+ // sslVerify:false fallback). Node's built-in fetch() doesn't expose a
16
+ // per-request TLS-verify toggle, so we use node's https directly — see
17
+ // checks/_http-probe.ts.
18
+ const tls = resolveSapTls(sys);
19
+ // Tag the connectivity result with the cert mode so doctor surfaces it.
20
+ const modeTag = tls.mode === 'verify' ? 'verify'
21
+ : tls.mode === 'ca-trusted' ? 'CA-trusted'
22
+ : 'INSECURE';
23
+ let ca;
24
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
25
+ try {
26
+ ca = await readFile(tls.caCertPath);
27
+ }
28
+ catch (e) {
29
+ // Fail closed, like the connection path: a configured-but-unreadable CA
30
+ // is an error, not a reason to drop verification.
31
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
32
+ anyFailed = true;
33
+ continue;
34
+ }
35
+ }
15
36
  const r = await probeGet(url, {
16
- rejectUnauthorized: sys.sslVerify,
37
+ rejectUnauthorized: tls.mode !== 'insecure',
38
+ ca,
17
39
  timeoutMs: 8000,
18
40
  });
19
41
  if (r.error) {
20
- results.push(`${alias}: ${r.error}`);
42
+ results.push(`${alias}: ${r.error} [${modeTag}]`);
21
43
  anyFailed = true;
22
44
  }
23
45
  else if (r.status === 200 || r.status === 401) {
24
46
  // 200 = anonymous discovery allowed; 401 = reachable + auth-required (expected, fine)
25
- results.push(`${alias}: reachable`);
47
+ results.push(`${alias}: reachable [${modeTag}]`);
26
48
  }
27
49
  else {
28
- results.push(`${alias}: HTTP ${r.status}`);
50
+ results.push(`${alias}: HTTP ${r.status} [${modeTag}]`);
29
51
  anyFailed = true;
30
52
  }
31
53
  }
@@ -1,5 +1,6 @@
1
1
  import keytar from 'keytar';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
4
  import { probeGet } from './_http-probe.js';
4
5
  const SAP_SERVICE = 'cspeach-sap';
5
6
  export async function checkZcspeach() {
@@ -29,10 +30,24 @@ export async function checkZcspeach() {
29
30
  results.push(`${alias}: skipped (no password in keychain)`);
30
31
  continue;
31
32
  }
32
- // Use node's https directly so we can honor sslVerify=false for
33
- // self-signed dev systems. See checks/_http-probe.ts.
33
+ // TLS posture (2026-06-14): verification ON by default; resolveSapTls maps
34
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
35
+ // sslVerify:false fallback). See checks/_http-probe.ts.
36
+ const tls = resolveSapTls(sys);
37
+ let ca;
38
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
39
+ try {
40
+ ca = await readFile(tls.caCertPath);
41
+ }
42
+ catch (e) {
43
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
44
+ anyFailed = true;
45
+ continue;
46
+ }
47
+ }
34
48
  const r = await probeGet(url, {
35
- rejectUnauthorized: sys.sslVerify,
49
+ rejectUnauthorized: tls.mode !== 'insecure',
50
+ ca,
36
51
  auth: `${sys.username}:${pwd}`,
37
52
  accept: 'application/json',
38
53
  timeoutMs: 5000,
package/dist/one-shot.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import chalk from 'chalk';
2
- import { loadConfig } from './config/loader.js';
2
+ import { loadConfig, resolveLocalBuild } from './config/loader.js';
3
3
  import { getStore } from './auth/api-key.js';
4
4
  import { getConnection } from './sap/connection-manager.js';
5
5
  import { AdtClient } from '@cspeach/sap-client';
6
6
  import { makeSessionId, saveSession } from './session/store.js';
7
7
  import { newSession } from './session/schema.js';
8
8
  import { createProviderForMode } from './agent/providers/factory.js';
9
+ import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
9
10
  import { runTurn } from './agent/loop.js';
10
- import { classifyPrompt, parseShortcut } from './router/classifier.js';
11
- import { setRenderingOverride } from './renderer/tty.js';
11
+ import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
12
+ import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
12
13
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
14
+ import { createClassicOutputEmitter } from './renderer/notices.js';
15
+ import { applyLocalBuildConfig } from './tools/local-build.js';
13
16
  // Side-effect imports for tool registration.
14
17
  import './tools/sap-read.js';
15
18
  import './tools/sap-write.js';
@@ -19,16 +22,22 @@ import './tools/snapshot.js';
19
22
  import './tools/ask-question.js';
20
23
  import './tools/dispatch-skill.js';
21
24
  import './tools/filesystem/file-read.js';
25
+ import './tools/filesystem/read-document.js';
22
26
  import './tools/filesystem/file-edit.js';
23
27
  import './tools/filesystem/file-write.js';
24
28
  import './tools/filesystem/glob.js';
25
29
  import './tools/filesystem/grep.js';
26
30
  import './tools/shell/shell_exec.js';
31
+ import './tools/fiori/tools.js';
32
+ import './tools/extend-model/tool.js';
33
+ import './tools/capability/tool.js';
27
34
  import './tools/web/web_fetch.js';
28
35
  import './tools/web/web_search.js';
29
36
  import './tools/project/project_context_get.js';
30
37
  import './tools/project/playbook_get.js';
31
38
  import './tools/project/convention_get.js';
39
+ import './tools/project/cca_merge.js';
40
+ import './tools/project/upgrade_merge_progress.js';
32
41
  import './tools/subagent/_background-shared.js';
33
42
  import './tools/subagent/background_run.js';
34
43
  import './tools/subagent/monitor_emit.js';
@@ -37,12 +46,27 @@ import './tools/subagent/agent_run.js';
37
46
  export async function runOneShot(prompt, opts) {
38
47
  const cfg = await loadConfig();
39
48
  setRenderingOverride(cfg.ui.rendering);
49
+ // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
50
+ // REPL, often piped (CI / battery). Any stdin prompt would hang forever
51
+ // (defect D1). The headless answer policy (ask_question auto-answers,
52
+ // save hook defaults YES, approvals fail fast) keys off this flag.
53
+ setHeadlessOverride(true);
40
54
  // Task C.10 — advisory-only is the only mode the one-shot announces, because
41
55
  // non-interactive output should stay minimal. The user needs to know that
42
56
  // writes will NOT be executed before a skill that normally writes runs.
43
57
  if (cfg.write_mode === 'advisory-only') {
44
58
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
45
59
  }
60
+ // local_build toggle — force-enable the flag-gated filesystem tools AND
61
+ // shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
62
+ // compat (legacy `local_files = on` still applies). Announce so headless
63
+ // transcripts record that local file access + command execution were live
64
+ // (trust-visibility / auditability).
65
+ {
66
+ const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
67
+ if (announce)
68
+ console.log(chalk.dim(announce));
69
+ }
46
70
  const store = await getStore();
47
71
  if (!(await store.get())) {
48
72
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
@@ -66,6 +90,14 @@ export async function runOneShot(prompt, opts) {
66
90
  const body = parsed.prompt;
67
91
  let skill;
68
92
  if (parsed.skill) {
93
+ // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
94
+ // catalog. One-shot has no picker, so fail fast with the notice ("did
95
+ // you mean" / builtin-with-args) instead of dispatching to a
96
+ // nonexistent skill (and NEVER falling through to the classifier with
97
+ // the slash embedded). Shared gate: rejectUnknownSkill in classifier.ts.
98
+ if (rejectUnknownSkill(parsed, (m) => console.error(chalk.red(m)))) {
99
+ return 1;
100
+ }
69
101
  skill = parsed.skill;
70
102
  }
71
103
  else {
@@ -94,6 +126,19 @@ export async function runOneShot(prompt, opts) {
94
126
  }
95
127
  skill = c.skill;
96
128
  }
129
+ // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
130
+ // inference, so without this anyone who never logged in could use the bundled
131
+ // skills untracked. Require a valid CSPeach login. (See license-gate.ts.)
132
+ try {
133
+ await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
134
+ }
135
+ catch (e) {
136
+ if (e instanceof CspeachLicenseError) {
137
+ console.error(chalk.yellow(`\n${e.message}\n`));
138
+ process.exit(1);
139
+ }
140
+ throw e;
141
+ }
97
142
  const provider = createProviderForMode(cfg, async () => {
98
143
  const key = await store.get();
99
144
  if (!key)
@@ -104,7 +149,10 @@ export async function runOneShot(prompt, opts) {
104
149
  provider,
105
150
  userMessage: body,
106
151
  skill,
107
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook },
152
+ // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
153
+ // — Rule 9 transport overrides, snapshot lines) to stdout instead of
154
+ // dropping them. Headless transcripts need these for auditability.
155
+ ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter() },
108
156
  });
109
157
  return 0;
110
158
  }
@@ -0,0 +1,137 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { readProjectFile } from './status.js';
3
+ import { validateEnvelope } from './validate.js';
4
+ /**
5
+ * The still-open BLOCKER-severity questions of a spec-gap envelope, in item
6
+ * order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
7
+ * a severity + an `answered` status). Carries any candidate `options` so the
8
+ * answer UI can offer choices instead of a bare text box.
9
+ */
10
+ export function openBlockers(env) {
11
+ if (env.artefactType !== 'spec-gap')
12
+ return [];
13
+ const gapById = new Map();
14
+ for (const s of env.content.sections) {
15
+ for (const g of s.gaps)
16
+ gapById.set(g.id, g);
17
+ }
18
+ const out = [];
19
+ for (const it of env.interaction.items) {
20
+ if (it.status !== 'open')
21
+ continue;
22
+ const g = gapById.get(it.id);
23
+ if (g?.severity !== 'BLOCKER')
24
+ continue;
25
+ const ob = { id: it.id, text: g.text };
26
+ if (g.options && g.options.length > 0) {
27
+ ob.options = g.options;
28
+ if (g.optionsMultiSelect)
29
+ ob.multiSelect = true;
30
+ }
31
+ out.push(ob);
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Return a copy of `env` with each item in `answers` marked answered. Keys with
37
+ * a blank value are ignored (treated as skipped). version + history are left
38
+ * untouched (invariant version === history.length holds); lastEditedAt/By move
39
+ * to the answerer to reflect who filled them.
40
+ */
41
+ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
42
+ const items = env.interaction.items.map((it) => {
43
+ const a = answers.get(it.id);
44
+ if (a === undefined || a.trim().length === 0)
45
+ return it;
46
+ return {
47
+ ...it,
48
+ status: 'answered',
49
+ answer: a,
50
+ answeredBy: answerer,
51
+ answeredAt: nowIso,
52
+ };
53
+ });
54
+ return {
55
+ ...env,
56
+ interaction: { items },
57
+ lastEditedAt: nowIso,
58
+ lastEditedBy: answerer,
59
+ };
60
+ }
61
+ function affirmative(reply) {
62
+ const v = reply.trim().toLowerCase();
63
+ return v === 'y' || v === 'yes';
64
+ }
65
+ /**
66
+ * Offer to answer the open blockers of the just-saved spec-gap file inline.
67
+ * No-op (no prompt) when the file isn't a spec-gap or has no open blockers, so
68
+ * it is always safe to call after any save. In headless runs the prompt returns
69
+ * '' → declined → nothing happens.
70
+ *
71
+ * Returns the count recorded, or null when nothing was offered/written.
72
+ */
73
+ export async function offerAnswerBlockers(args) {
74
+ let env;
75
+ try {
76
+ env = readProjectFile(args.path);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const blockers = openBlockers(env);
82
+ if (blockers.length === 0)
83
+ return null;
84
+ const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
85
+ if (!affirmative(reply))
86
+ return null;
87
+ const answers = new Map();
88
+ for (let i = 0; i < blockers.length; i++) {
89
+ const b = blockers[i];
90
+ args.log('');
91
+ args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
92
+ // Choices first, where the question has them and a picker is available.
93
+ // 'write' falls through to the text box; 'skip' leaves the blocker open.
94
+ let askText = true;
95
+ if (b.options && b.options.length > 0 && args.choose) {
96
+ const c = await args.choose({
97
+ question: b.text,
98
+ options: b.options,
99
+ multiSelect: !!b.multiSelect,
100
+ index: i,
101
+ total: blockers.length,
102
+ });
103
+ if (c.kind === 'picked') {
104
+ if (c.answer.trim().length > 0)
105
+ answers.set(b.id, c.answer.trim());
106
+ askText = false;
107
+ }
108
+ else if (c.kind === 'skip') {
109
+ askText = false;
110
+ }
111
+ // c.kind === 'write' → askText stays true
112
+ }
113
+ if (askText) {
114
+ const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
115
+ if (a.length > 0)
116
+ answers.set(b.id, a);
117
+ }
118
+ }
119
+ if (answers.size === 0) {
120
+ args.log('');
121
+ args.log('No answers entered — all blockers left open for the viewer.');
122
+ return null;
123
+ }
124
+ const nowIso = (args.now ?? (() => new Date().toISOString()))();
125
+ const revised = applyBlockerAnswers(env, answers, args.identity, nowIso);
126
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
127
+ if (!check.ok) {
128
+ args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
129
+ return null;
130
+ }
131
+ writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
132
+ const stillOpen = blockers.length - answers.size;
133
+ args.log('');
134
+ args.log(`Recorded ${answers.size} answer(s) in the file`
135
+ + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
136
+ return answers.size;
137
+ }
@@ -11,6 +11,7 @@ const SUBJECT_BY_TYPE = {
11
11
  'cca-assessment': (t) => `Custom code assessment — ${t}`,
12
12
  'modernize-result': (t) => `Modernization result — ${t}`,
13
13
  'test-coverage': (t) => `Test coverage report — ${t}`,
14
+ 'plan': (t) => `Project plan — ${t}`,
14
15
  };
15
16
  const BODY_BY_TYPE = {
16
17
  'spec-gap': (a) => `Attached is the gap analysis for the ${a.title}.\n${a.itemCount} ${a.itemNoun} across business / data / authorisation / edge cases.`,
@@ -24,6 +25,7 @@ const BODY_BY_TYPE = {
24
25
  'cca-assessment': (a) => `Attached is the custom code assessment for ${a.title}.\n${a.itemCount} ${a.itemNoun} classified — review keep / fix / retire / redesign per object.`,
25
26
  'modernize-result': (a) => `Attached is the modernization result for ${a.title}.\n${a.itemCount} ${a.itemNoun} attempted — review applied / skipped / failed per object before sign-off.`,
26
27
  'test-coverage': (a) => `Attached is the test coverage report for ${a.title}.\n${a.itemCount} test ${a.itemNoun} generated — review the targets + method counts before merging.`,
28
+ 'plan': (a) => `Attached is the project plan for ${a.title}.\n${a.itemCount} ${a.itemNoun} ordered bottom-up — each phase runs in its own session via /abap-plan --resume.`,
27
29
  };
28
30
  export function renderEmailTemplate(a) {
29
31
  const subject = SUBJECT_BY_TYPE[a.artefactType](a.title);
@@ -9,7 +9,7 @@
9
9
  * <!-- csforge:cca-manifest
10
10
  * artefact: cca-assessment
11
11
  * title: ZFI / ZSD — S/4HANA 2023 estate
12
- * detail_path: .abapforge/cca/projects/zfi-zsd-2026-04/project.json
12
+ * detail_path: .cspeach/cca/projects/zfi-zsd-2026-04/project.json
13
13
  * project_id: zfi-zsd-2026-04
14
14
  * scope: ZFI* + ZSD*
15
15
  * atc_variant: S4HANA_READINESS_2023
@@ -78,6 +78,13 @@ function intOr(kv, key, fallback) {
78
78
  const n = Number.parseInt(v, 10);
79
79
  return Number.isFinite(n) ? n : fallback;
80
80
  }
81
+ function optionalInt(kv, key) {
82
+ const v = kv[key];
83
+ if (v === undefined)
84
+ return undefined;
85
+ const n = Number.parseInt(v, 10);
86
+ return Number.isFinite(n) ? n : undefined;
87
+ }
81
88
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
82
89
  const CONFIDENCES = ['high', 'medium', 'low'];
83
90
  function asClassification(raw) {
@@ -92,6 +99,36 @@ function asConfidence(raw) {
92
99
  ? v
93
100
  : 'medium';
94
101
  }
102
+ /**
103
+ * Parse the optional 6th table cell on a merged row:
104
+ * `conflict=<label>:<cls>;<label>:<cls>`
105
+ * (rendered by merge-cca.ts:renderCcaMergeManifest). Returns undefined
106
+ * for absent / unrecognisable cells; invalid classification values are
107
+ * skipped pair-wise so one typo doesn't drop the whole record.
108
+ */
109
+ function parseConflictCell(raw) {
110
+ if (!raw)
111
+ return undefined;
112
+ const m = /^conflict=(.+)$/.exec(raw.trim());
113
+ if (!m)
114
+ return undefined;
115
+ const values = {};
116
+ for (const pair of m[1].split(';')) {
117
+ const idx = pair.lastIndexOf(':');
118
+ if (idx === -1)
119
+ continue;
120
+ const label = pair.slice(0, idx).trim();
121
+ const cls = pair.slice(idx + 1).trim().toLowerCase();
122
+ if (label.length === 0)
123
+ continue;
124
+ if (!CLASSIFICATIONS.includes(cls))
125
+ continue;
126
+ values[label] = cls;
127
+ }
128
+ if (Object.keys(values).length === 0)
129
+ return undefined;
130
+ return { values, rule: 'most-restrictive' };
131
+ }
95
132
  export function extractCcaAssessment(markdown) {
96
133
  const { kv, tables } = parseManifestBlock(markdown);
97
134
  if (kv.artefact !== 'cca-assessment') {
@@ -103,27 +140,82 @@ export function extractCcaAssessment(markdown) {
103
140
  if (!kv.project_id) {
104
141
  throw new Error("cca-assessment manifest missing required 'project_id'");
105
142
  }
106
- const classifications = (tables.classifications ?? []).map((row, i) => ({
107
- id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
108
- objectName: row[1] ?? '',
109
- objectType: row[2] ?? '',
110
- classification: asClassification(row[3] ?? 'keep'),
111
- confidence: asConfidence(row[4] ?? 'medium'),
112
- }));
143
+ const classifications = (tables.classifications ?? []).map((row, i) => {
144
+ const conflict = parseConflictCell(row[5]);
145
+ return {
146
+ id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
147
+ objectName: row[1] ?? '',
148
+ objectType: row[2] ?? '',
149
+ classification: asClassification(row[3] ?? 'keep'),
150
+ confidence: asConfidence(row[4] ?? 'medium'),
151
+ ...(conflict ? { conflict } : {}),
152
+ };
153
+ });
154
+ const summary = {
155
+ totalObjects: intOr(kv, 'total_objects', classifications.length),
156
+ keep: intOr(kv, 'keep', 0),
157
+ fix: intOr(kv, 'fix', 0),
158
+ retire: intOr(kv, 'retire', 0),
159
+ redesign: intOr(kv, 'redesign', 0),
160
+ uncategorized: intOr(kv, 'uncategorized', 0),
161
+ };
162
+ // ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
163
+ // Battery S4 caught manifests whose counts contradicted their own rows
164
+ // (`retire: 0` next to 7 retire rows) and tables silently truncated
165
+ // (27/104 rows, no marker). These manifests are CHAIN INPUT for
166
+ // /abap-cca-merge — a quiet save corrupts the consolidated estate
167
+ // numbers, so a contradiction fails the extract LOUDLY instead (same
168
+ // philosophy as extract-plan's loud-failure path in save-command.ts).
169
+ const truncated = kv.classifications_truncated === 'true';
170
+ const shown = optionalInt(kv, 'classifications_shown');
171
+ const totalCls = optionalInt(kv, 'classifications_total');
172
+ const rowTally = {
173
+ keep: 0, fix: 0, retire: 0, redesign: 0,
174
+ };
175
+ for (const c of classifications)
176
+ rowTally[c.classification]++;
177
+ if (truncated) {
178
+ if (shown !== undefined && shown !== classifications.length) {
179
+ throw new Error(`cca-assessment manifest contradiction: classifications_shown is ${shown} but the table has ${classifications.length} rows.`);
180
+ }
181
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
182
+ if (rowTally[cls] > summary[cls]) {
183
+ throw new Error(`cca-assessment manifest contradiction: ${rowTally[cls]} '${cls}' rows in the table but the summary says ${cls}: ${summary[cls]}. `
184
+ + `Counts must cover every row even when the table is truncated.`);
185
+ }
186
+ }
187
+ if (totalCls !== undefined && totalCls < classifications.length) {
188
+ throw new Error(`cca-assessment manifest contradiction: classifications_total (${totalCls}) is smaller than the ${classifications.length} rows shown.`);
189
+ }
190
+ }
191
+ else {
192
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
193
+ if (rowTally[cls] !== summary[cls]) {
194
+ throw new Error(`cca-assessment manifest contradiction: summary says ${cls}: ${summary[cls]} but the table has ${rowTally[cls]} '${cls}' rows. `
195
+ + `Counts must equal rows — if the table was capped, mark it with classifications_truncated: true + classifications_shown + classifications_total.`);
196
+ }
197
+ }
198
+ }
199
+ // total_objects must equal the class-count sum in BOTH branches — the
200
+ // summary counts cover the full estate even when the table is capped.
201
+ const expectedTotal = summary.keep + summary.fix + summary.retire + summary.redesign + summary.uncategorized;
202
+ if (summary.totalObjects !== expectedTotal) {
203
+ throw new Error(`cca-assessment manifest contradiction: total_objects is ${summary.totalObjects} but keep+fix+retire+redesign+uncategorized = ${expectedTotal}.`);
204
+ }
113
205
  const content = {
114
206
  detailPath: kv.detail_path,
115
207
  projectId: kv.project_id,
116
208
  scope: kv.scope ?? '',
117
209
  atcVariant: kv.atc_variant ?? '',
118
- summary: {
119
- totalObjects: intOr(kv, 'total_objects', classifications.length),
120
- keep: intOr(kv, 'keep', 0),
121
- fix: intOr(kv, 'fix', 0),
122
- retire: intOr(kv, 'retire', 0),
123
- redesign: intOr(kv, 'redesign', 0),
124
- uncategorized: intOr(kv, 'uncategorized', 0),
125
- },
210
+ summary,
126
211
  classifications,
212
+ ...(truncated
213
+ ? {
214
+ classificationsTruncated: true,
215
+ ...(shown !== undefined ? { classificationsShown: shown } : {}),
216
+ ...(totalCls !== undefined ? { classificationsTotal: totalCls } : {}),
217
+ }
218
+ : {}),
127
219
  };
128
220
  // One InteractionItem per classification entry, default status 'open'
129
221
  // (auto-classified by the AI, awaiting human review).
@@ -11,7 +11,7 @@
11
11
  * <!-- csforge:modernize-manifest
12
12
  * artefact: modernize-result
13
13
  * title: ZDPR_PAYMENTS — modernization
14
- * detail_path: .abapforge/modernize/zdpr-payments/project.json
14
+ * detail_path: .cspeach/modernize/zdpr-payments/project.json
15
15
  * baseline_path: <project-relative path>
16
16
  * baseline_sha256: <hex>
17
17
  * transport: <id or ->