@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -16,7 +16,7 @@
16
16
  * unlock(type, name, lockHandle) → Promise<void> line 1949
17
17
  * serviceBindingPublish(name) → Promise<{status,error?}> line 2658
18
18
  * serviceBindingUnpublish(name) → Promise<{status,error?}>
19
- * messageMaintain (STUB — not yet in sap-client)
19
+ * maintainMessages({name, messages, transport?}) → Promise<MaintainMessagesResult> (0.1.19)
20
20
  * numberRangeIntervals (STUB — not yet in sap-client)
21
21
  *
22
22
  * auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
@@ -26,7 +26,7 @@ import { readFileSync } from 'node:fs';
26
26
  import { registerTool } from './index.js';
27
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
28
28
  import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
29
- import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
29
+ import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
30
30
  import { includeSnapshotType } from './include-snapshot.js';
31
31
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
32
32
  import { buildChangeCard } from '../renderer/change-card.js';
@@ -49,6 +49,7 @@ import { noteWriteTransport, setCurrentTransport } from '../repl/current-transpo
49
49
  import { markTransportCreatedThisSession } from './transport.js';
50
50
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
51
51
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
52
+ import { noteManualLock, noteManualUnlock } from '../sap/session-end.js';
52
53
  // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
53
54
  //
54
55
  // Rule 7 auto-snapshots always fired but the transcript never said so
@@ -868,7 +869,11 @@ registerTool({
868
869
  object: result.objectName,
869
870
  type: result.objectType,
870
871
  package: result.packageName,
871
- next_step: 'write_source_via_sap_set_source_then_activate',
872
+ // A message class has no source and is never activated: its messages
873
+ // go in through sap_message_maintain (bug 3, 2026-10-12).
874
+ next_step: String(result.objectType ?? '').toUpperCase() === 'MSAG'
875
+ ? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
876
+ : 'write_source_via_sap_set_source_then_activate',
872
877
  }),
873
878
  };
874
879
  },
@@ -1573,6 +1578,9 @@ registerTool({
1573
1578
  },
1574
1579
  handler: async (args, ctx) => {
1575
1580
  const result = await ctx.adt.lock(args.type, args.name);
1581
+ // The turn-end session close keeps the session while this lock is held.
1582
+ if (result?.status === 'locked')
1583
+ noteManualLock();
1576
1584
  return { content: JSON.stringify(result) };
1577
1585
  },
1578
1586
  });
@@ -1596,6 +1604,7 @@ registerTool({
1596
1604
  },
1597
1605
  handler: async (args, ctx) => {
1598
1606
  await ctx.adt.unlock(args.type, args.name, args.lockHandle);
1607
+ noteManualUnlock();
1599
1608
  return { content: JSON.stringify({ status: 'ok' }) };
1600
1609
  },
1601
1610
  });
@@ -1644,34 +1653,256 @@ registerTool({
1644
1653
  return { content: JSON.stringify(result) };
1645
1654
  },
1646
1655
  });
1647
- // ── sap_message_maintain (STUB) ───────────────────────────────────────────────
1656
+ // ── sap_message_maintain ──────────────────────────────────────────────────────
1648
1657
  //
1649
- // STUB — MSAG message-class maintenance requires XML message-class-format
1650
- // parsing not yet implemented in @cspeach/sap-client.
1658
+ // Signature (sap-client 0.1.19): maintainMessages({name, messages, transport?})
1659
+ // → Promise<MaintainMessagesResult>
1660
+ // Adds or changes T100 messages in an existing message class, in one batch.
1661
+ // sap-client merges into the existing rows, locks, PUTs the whole class,
1662
+ // unlocks in a finally and READS BACK: success is `verified`, never HTTP 200.
1663
+ // Rule 7 snapshot = the class document (all messages) before the write.
1664
+ // No syntax check and no activation: a message class is not activatable.
1665
+ const MESSAGE_TEXT_MAX = 73;
1666
+ /** Same rule as sap-client's isValidMessageClassName (optional /NAMESPACE/). */
1667
+ const MESSAGE_CLASS_NAME_RE = /^(?:\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,20}$/;
1668
+ /** Review M-1: a plain sentence when SAP may still hold a lock, else undefined. */
1669
+ function lockWarning(className, r) {
1670
+ if (r.released !== false)
1671
+ return undefined;
1672
+ const what = (r.unlockFailures ?? []).join(', ') || 'unlock';
1673
+ return `SAP may still hold a lock on message class ${className} (failed: ${what}). Check SM12 and remove it there, or it clears when the session ends.`;
1674
+ }
1675
+ /** The rows from the tool args (`messages`, or the single-message short form). */
1676
+ function messageRowsFromArgs(args) {
1677
+ const list = Array.isArray(args['messages']) ? args['messages'] : [];
1678
+ const rows = list.map((m) => ({
1679
+ msgno: String(m?.['msgno'] ?? m?.['number'] ?? '').trim(),
1680
+ text: typeof m?.['text'] === 'string' ? m['text'] : '',
1681
+ ...(typeof m?.['selfExplanatory'] === 'boolean' ? { selfExplanatory: m['selfExplanatory'] } : {}),
1682
+ }));
1683
+ if (rows.length === 0 && args['messageNumber'] !== undefined) {
1684
+ rows.push({
1685
+ msgno: String(args['messageNumber']).trim(),
1686
+ text: typeof args['messageText'] === 'string' ? args['messageText'] : '',
1687
+ });
1688
+ }
1689
+ return rows;
1690
+ }
1691
+ /** Same rules as sap-client's validateMessageUpserts — checked here so a bad
1692
+ * request never spends the approval. A plain sentence, or null. */
1693
+ function messageRowsProblem(rows) {
1694
+ if (rows.length === 0)
1695
+ return 'Give at least one message to add or change.';
1696
+ const seen = new Set();
1697
+ for (const r of rows) {
1698
+ if (!/^\d{3}$/.test(r.msgno))
1699
+ return `Message number "${r.msgno}" is not valid. Use three digits, 000 to 999.`;
1700
+ if (seen.has(r.msgno))
1701
+ return `Message ${r.msgno} is in the request more than once.`;
1702
+ seen.add(r.msgno);
1703
+ if (r.text.trim().length === 0)
1704
+ return `Message ${r.msgno} has an empty text.`;
1705
+ const len = Array.from(r.text).length;
1706
+ if (len > MESSAGE_TEXT_MAX)
1707
+ return `Message ${r.msgno} is ${len} characters long. SAP allows ${MESSAGE_TEXT_MAX}.`;
1708
+ }
1709
+ return null;
1710
+ }
1651
1711
  registerTool({
1652
1712
  name: 'sap_message_maintain',
1653
- description: 'STUB — not currently implemented; do not call. '
1654
- + 'Maintaining MSAG message-class entries via ADT requires XML message-class-format '
1655
- + 'parsing not yet implemented in @cspeach/sap-client.',
1713
+ description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
1714
+ + 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
1715
+ + 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
1716
+ + 'Messages not in the call are kept. No source, no syntax check, no activation. '
1717
+ + 'The type (E, W, I, S) is not stored: it is chosen where the message is raised, e.g. MESSAGE e001(zfoo). '
1718
+ + 'Requires approval_id.',
1656
1719
  isMutating: true,
1657
1720
  requiresSap: true,
1658
1721
  input_schema: {
1659
1722
  type: 'object',
1660
1723
  properties: {
1661
- messageClass: { type: 'string', description: 'Message class name (e.g. ZMSAG_FOO)' },
1662
- messageNumber: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
1663
- messageText: { type: 'string', description: 'Message text (up to 73 characters)' },
1664
- messageType: { type: 'string', description: 'Message type: I, W, E, A, X, S' },
1724
+ messageClass: { type: 'string', description: 'Message class name (e.g. ZSALES_MSG)' },
1725
+ messages: {
1726
+ type: 'array',
1727
+ description: 'Messages to add or change',
1728
+ items: {
1729
+ type: 'object',
1730
+ properties: {
1731
+ msgno: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
1732
+ text: { type: 'string', description: 'Message text, up to 73 characters (e.g. "Order &1 not found")' },
1733
+ selfExplanatory: { type: 'boolean', description: 'Optional. New messages default to true (no long text).' },
1734
+ },
1735
+ required: ['msgno', 'text'],
1736
+ },
1737
+ },
1738
+ messageNumber: { type: 'string', description: 'Short form for one message: the number (use messages for several)' },
1739
+ messageText: { type: 'string', description: 'Short form for one message: the text' },
1740
+ transport: { type: 'string', description: 'Transport request number (optional)' },
1741
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
1665
1742
  },
1666
- required: ['messageClass', 'messageNumber', 'messageText'],
1743
+ required: ['messageClass', 'approval_id'],
1667
1744
  },
1668
- handler: async (_args, _ctx) => {
1745
+ handler: async (args, ctx) => {
1746
+ const className = String(args.messageClass ?? '').trim().toUpperCase();
1747
+ // ── Input check — before the approval is spent ──────────────────────────
1748
+ const rows = messageRowsFromArgs(args);
1749
+ const problem = !className
1750
+ ? 'Give the message class name.'
1751
+ : !MESSAGE_CLASS_NAME_RE.test(className)
1752
+ ? `"${String(args.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
1753
+ : messageRowsProblem(rows);
1754
+ if (problem) {
1755
+ return { content: JSON.stringify({ error: 'invalid_messages', detail: problem }), is_error: true };
1756
+ }
1757
+ // ── Capability — before the approval is spent (review I-1) ──────────────
1758
+ const adt = ctx.adt;
1759
+ if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
1760
+ return {
1761
+ content: JSON.stringify({
1762
+ error: 'message_maintain_unavailable',
1763
+ detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.',
1764
+ }),
1765
+ is_error: true,
1766
+ };
1767
+ }
1768
+ const msagClient = adt;
1769
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
1770
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.messageClass, 'modify');
1771
+ if (!verify.ok) {
1772
+ return {
1773
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1774
+ is_error: true,
1775
+ };
1776
+ }
1777
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1778
+ {
1779
+ const claim = grantClaimMismatch(verify.payload, { type: 'MSAG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1780
+ if (claim)
1781
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1782
+ }
1783
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1784
+ const startedAt = Date.now();
1785
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1786
+ {
1787
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
1788
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1789
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
1790
+ }
1791
+ }
1792
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1793
+ const resolved = await resolveWriteTransport(ctx, 'MSAG', className, effectiveTransport);
1794
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
1795
+ if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
1796
+ return {
1797
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(className, resolved.owning) }),
1798
+ is_error: true,
1799
+ };
1800
+ }
1801
+ // ── Gate 2: Snapshot (Rule 7) — HARD gate, of the whole class ────────────
1802
+ let snapOutcome;
1803
+ try {
1804
+ snapOutcome = await autoSnapshotMessageClass(className, msagClient);
1805
+ }
1806
+ catch (err) {
1807
+ return {
1808
+ content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
1809
+ is_error: true,
1810
+ };
1811
+ }
1812
+ if (snapOutcome.reason === 'object_not_found') {
1813
+ return {
1814
+ content: JSON.stringify({
1815
+ error: 'message_class_not_found',
1816
+ detail: `Message class ${className} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
1817
+ }),
1818
+ is_error: true,
1819
+ };
1820
+ }
1821
+ if (!snapOutcome.taken) {
1822
+ return {
1823
+ content: JSON.stringify({
1824
+ error: ERR.SNAPSHOT_FAILED,
1825
+ reason: snapOutcome.reason ?? 'source_read_failed',
1826
+ detail: snapOutcome.detail,
1827
+ recovery: 'The message class could not be read, so no snapshot was possible and nothing was written. If this is a connection or sign-in problem, fix it and try again.',
1828
+ }),
1829
+ is_error: true,
1830
+ };
1831
+ }
1832
+ emitSnapshotNotice(ctx, snapOutcome, 'MSAG', className);
1833
+ // ── WAL: record pending entry ────────────────────────────────────────────
1834
+ await appendPending(ctx.session, {
1835
+ tool_use_id: toolUseId,
1836
+ tool: 'sap_message_maintain',
1837
+ args: { messageClass: className, messages: rows.map((r) => r.msgno) },
1838
+ started_at: new Date().toISOString(),
1839
+ });
1840
+ // ── Write (sap-client locks, PUTs, unlocks and reads back) ───────────────
1841
+ let result;
1842
+ try {
1843
+ result = await msagClient.maintainMessages({
1844
+ name: className,
1845
+ messages: rows,
1846
+ ...(resolved.transport ? { transport: resolved.transport } : {}),
1847
+ });
1848
+ }
1849
+ catch (err) {
1850
+ const detail = err instanceof Error ? err.message : String(err);
1851
+ await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
1852
+ return {
1853
+ content: JSON.stringify({ error: 'message_maintain_failed', detail }),
1854
+ is_error: true,
1855
+ };
1856
+ }
1857
+ const lockNote = lockWarning(className, result);
1858
+ if (lockNote)
1859
+ ctx.chunkEmitter?.emit('warn', lockNote);
1860
+ if (!result.verified) {
1861
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1862
+ return {
1863
+ content: JSON.stringify({
1864
+ error: 'message_maintain_failed',
1865
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1866
+ detail: result.error ?? 'The messages could not be confirmed in SAP.',
1867
+ missing: result.missing,
1868
+ ...(result.httpStatus !== undefined ? { http_status: result.httpStatus } : {}),
1869
+ ...(result.lockedInTransport ? {
1870
+ locked_in_transport: result.lockedInTransport,
1871
+ recovery: `Retry this exact call with transport=${result.lockedInTransport} — that request already owns the message class.`,
1872
+ } : {}),
1873
+ }),
1874
+ is_error: true,
1875
+ };
1876
+ }
1877
+ if (result.noChange) {
1878
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ unchanged: true }), false, Date.now() - startedAt);
1879
+ return {
1880
+ content: JSON.stringify({
1881
+ written: false,
1882
+ unchanged: true,
1883
+ message_class: className,
1884
+ changes: result.changes,
1885
+ message: `Message class ${className} already has these texts. Nothing was changed.`,
1886
+ }),
1887
+ };
1888
+ }
1889
+ // Audit only after SAP's read-back confirmed every message.
1890
+ await auditLog('maintainMessages', 'MSAG', className, 'success', Date.now() - startedAt, result.changes.map((c) => `${c.msgno}:${c.action}`).join(' '));
1891
+ noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
1892
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, verified: true, changes: result.changes }), false, Date.now() - startedAt);
1893
+ const written = result.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
1894
+ ctx.chunkEmitter?.emit('info', `messages ✓ ${className} ${written.join(' ')} (no activation needed)`);
1895
+ const first = written[0] ?? rows[0].msgno;
1669
1896
  return {
1670
1897
  content: JSON.stringify({
1671
- error: 'not_implemented',
1672
- detail: 'MSAG message maintenance via ADT requires XML message-class-format parsing not yet implemented in @cspeach/sap-client',
1898
+ written: true,
1899
+ verified: true,
1900
+ message_class: className,
1901
+ changes: result.changes,
1902
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
1903
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1904
+ next_step: `Done — no activation needed. Raise these from code by number, e.g. MESSAGE e${first}(${className.toLowerCase()}) WITH …, or a T100 textid in an exception class. Never hard-code the texts.`,
1673
1905
  }),
1674
- is_error: true,
1675
1906
  };
1676
1907
  },
1677
1908
  });
@@ -127,6 +127,32 @@ export async function autoSnapshotClassInclude(className, includeType, adt) {
127
127
  const entry = await snapshots.take(includeSnapshotType(includeType), className, src, 'before_write');
128
128
  return { taken: true, entry };
129
129
  }
130
+ /**
131
+ * Rule-7 snapshot of a message class, taken before sap_message_maintain
132
+ * changes it. A message class has no source: the snapshot is the whole class
133
+ * document as SAP serves it (every message), stored under type MSAG.
134
+ *
135
+ * 404 → 'object_not_found' (no such class — the write cannot go ahead)
136
+ * other read error → 'source_read_failed' (+ detail) — the write is blocked
137
+ * read OK → snapshot taken
138
+ * A snapshots.take failure THROWS, like autoSnapshot.
139
+ */
140
+ export async function autoSnapshotMessageClass(name, adt) {
141
+ let xml;
142
+ try {
143
+ xml = (await adt.readMessageClass(name)).xml;
144
+ }
145
+ catch (err) {
146
+ if (isNotFound(err))
147
+ return { taken: false, reason: 'object_not_found' };
148
+ return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
149
+ }
150
+ if (typeof xml !== 'string' || xml.length === 0) {
151
+ return { taken: false, reason: 'source_read_failed', detail: 'the message class read returned no document' };
152
+ }
153
+ const entry = await snapshots.take('MSAG', name.toUpperCase(), xml, 'before_write');
154
+ return { taken: true, entry };
155
+ }
130
156
  // ── sap_snapshot_list ─────────────────────────────────────────────────────────
131
157
  registerTool({
132
158
  name: 'sap_snapshot_list',
@@ -1,4 +1,5 @@
1
1
  import { toAnthropicTools } from './index.js';
2
+ import { profileForMode } from '../agent/connection-profile.js';
2
3
  /** The server-side tool search tool (probe P6: this exact name). */
3
4
  export const TOOL_SEARCH_TOOL = Object.freeze({
4
5
  type: 'tool_search_tool_regex_20251119',
@@ -105,25 +106,23 @@ export function modelAcceptsDeferredTools(model) {
105
106
  return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
106
107
  }
107
108
  /**
108
- * Mode precedence (ruling F13): AI-hub and local are always 'static' (their
109
- * wire has no deferred loading). Otherwise env `CSPEACH_TOOL_LOADING`
110
- * (`deferred` | `static`) wins; then managed uses the served value (default
111
- * 'static': only a proxy that serves tool_loading takes the new shapes);
112
- * BYOK uses the served value when it fetched one, else 'static'.
109
+ * Mode precedence (ruling F13), read from the connection profile since the
110
+ * Azure tenant mode (Task 1): 'static' profiles (AI-hub, local, azure v0) are
111
+ * always 'static'; otherwise env `CSPEACH_TOOL_LOADING` (`deferred` | `static`)
112
+ * wins; then 'served' profiles (managed, BYOK) use the served value (default
113
+ * 'static'); an unknown/fake mode ('env-only') stays 'static'.
113
114
  */
114
115
  export function resolveToolLoading(i) {
115
- if (i.providerMode === 'ai-hub' || i.providerMode === 'local')
116
+ const rule = (i.profile ?? profileForMode(i.providerMode)).deferredTools;
117
+ if (rule === 'static')
116
118
  return 'static';
117
119
  const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
118
120
  if (raw === 'deferred' || raw === 'static')
119
121
  return raw;
120
122
  // Task 21 fix round (controller ruling): an old proxy serves no tool_loading
121
123
  // and adds none of the deferred-tool betas, so 'deferred' only when served.
122
- if (i.providerMode === 'managed')
124
+ if (rule === 'served')
123
125
  return i.served ?? 'static';
124
- if (i.providerMode === 'byok')
125
- return i.served ?? 'static';
126
- // Unknown/fake provider modes keep today's full list.
127
126
  return 'static';
128
127
  }
129
128
  /**
package/dist/ui/app.js CHANGED
@@ -6,7 +6,7 @@ import { roleProps } from './role-props.js';
6
6
  import { IDLE_EXIT_WINDOW_MS } from './interrupt.js';
7
7
  import { Body } from './body.js';
8
8
  import { Footer } from './footer.js';
9
- import { ApprovalModal } from './approval-modal.js';
9
+ import { ApprovalModal, estimateApprovalModalRows } from './approval-modal.js';
10
10
  import { CommandPalette } from './command-palette.js';
11
11
  import { FilePalette } from './file-palette.js';
12
12
  import { nextResolutionStep, applySlashPick } from './line-resolution.js';
@@ -15,7 +15,7 @@ import { FooterLine } from './footer-line.js';
15
15
  import { ShortcutsSheet } from './widgets/shortcuts-sheet.js';
16
16
  import { wasPasted } from '../repl/paste-marker.js';
17
17
  import { turnStatusEmitter } from './turn-status-emitter.js';
18
- import { pickSlotOccupant, liveBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
18
+ import { pickSlotOccupant, liveBudget, cardRowBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
19
19
  import { Checklist } from './checklist.js';
20
20
  import { formatChecklist, isPlanFinished, CHECKLIST_MAX_ROWS } from '../renderer/checklist-format.js';
21
21
  import { listWorkspaceFileChoices } from '../repl/at-picker.js';
@@ -27,7 +27,7 @@ import { approvalEmitter } from './approval-emitter.js';
27
27
  import { useHoldRawMode } from './widgets/hold-raw-mode.js';
28
28
  import { SapStateContext, globalStore } from './sap-state-store.js';
29
29
  import { coachingPickerEmitter } from './coaching-picker-emitter.js';
30
- import { CoachingPicker } from './widgets/coaching-picker.js';
30
+ import { CoachingPicker, estimateCoachingPickerRows } from './widgets/coaching-picker.js';
31
31
  import { askQuestionEmitter } from './ask-question-emitter.js';
32
32
  import { AskCard, estimateAskCardRows } from './widgets/ask-card.js';
33
33
  import { ConfirmCard, estimateConfirmCardRows } from './widgets/confirm-card.js';
@@ -575,14 +575,24 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
575
575
  // contributes a fixed estimate. Q2 (ask) and C1 (confirm) replace theirs
576
576
  // with a real estimator right below this line. P2 replaces `checklistRows: 0`.
577
577
  let cardRows = occupant === null ? 0 : SLOT_ROWS_ESTIMATE[occupant];
578
- // Q2 — the ask card's full height (descriptions included, so shedding them
579
- // cannot oscillate; spec §2 fold order: descriptions drop before options).
578
+ const paletteRows = lineResolution ? PALETTE_ROWS : 0;
579
+ // Fix round 1 (review I1): the room a card may take beside one input line.
580
+ const askMaxRows = cardRowBudget({ rows, paletteRows });
581
+ // Q2 — the ask card's height after its own folds under askMaxRows (labels
582
+ // to 1 row, descriptions to 1 row, then none — askCardLayout). Computed
583
+ // with descriptions ON, so shedding them cannot oscillate.
580
584
  if (occupant === 'ask' && askQuestionPending)
581
- cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns));
585
+ cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns), askMaxRows);
582
586
  // C1 — the confirm card's real height (facts + choices) for the spec §2 cap.
583
587
  if (occupant === 'confirm' && confirmSlot.pending)
584
588
  cardRows = estimateConfirmCardRows(confirmSlot.pending, uiWidth(columns), rows - 6);
585
- const paletteRows = lineResolution ? PALETTE_ROWS : 0;
589
+ // Fix batch 2026-10-12 — "Pick if:" wraps; count every row.
590
+ if (occupant === 'coaching' && coachingPending)
591
+ cardRows = estimateCoachingPickerRows(coachingPending.params, uiWidth(columns));
592
+ // Fix batch 2026-10-12 — the approval card's risk reasons wrap; count every
593
+ // row (+1: the slot's top margin).
594
+ if (occupant === 'approval' && pendingApproval)
595
+ cardRows = 1 + estimateApprovalModalRows(pendingApproval, uiWidth(columns) - 2);
586
596
  // P2 — the checklist's WANTED height (header + rows); liveBudget folds it.
587
597
  const showChecklist = todos.length > 0 && !planClosed;
588
598
  const checklistWidth = uiWidth(columns);
@@ -597,7 +607,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
597
607
  const budget = liveBudget({ rows, checklistRows: checklistDesiredRows, cardRows, inputLines, paletteRows });
598
608
  let slot = null;
599
609
  if (occupant === 'approval' && pendingApproval) {
600
- slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
610
+ slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, width: uiWidth(columns) - 2, onApply: () => {
601
611
  pendingApproval.resolve({ approved: true });
602
612
  clearApproval(pendingApproval);
603
613
  }, onSkip: () => {
@@ -614,7 +624,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
614
624
  }
615
625
  else if (occupant === 'ask' && askQuestionPending) {
616
626
  const req = askQuestionPending;
617
- slot = (_jsx(AskCard, { request: req, width: uiWidth(columns), showDescriptions: budget.showDescriptions, onResolveV1: (result) => {
627
+ slot = (_jsx(AskCard, { request: req, width: uiWidth(columns), showDescriptions: budget.showDescriptions, maxRows: askMaxRows, onResolveV1: (result) => {
618
628
  if (!('questions' in req))
619
629
  req.resolve(result);
620
630
  clearAskIfCurrent(req);
@@ -16,6 +16,35 @@ import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.
16
16
  import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
17
17
  import { getLook } from '../renderer/look.js';
18
18
  import { roleProps, inkChip, legacyInkColor } from './role-props.js';
19
+ import { wrapRows } from '../renderer/wrap-rows.js';
20
+ import { uiWidth } from '../renderer/ui-width.js';
21
+ /**
22
+ * Fix batch 2026-10-12 (Task 2): why the risk went up is the decision input,
23
+ * so the "Risk: … (raised: …)" text wraps onto up to this many rows (both
24
+ * looks) instead of being cut. The verb and meta rows stay one row (chrome).
25
+ */
26
+ export const RISK_MAX_ROWS = 4;
27
+ /** Border 2 + paddingX 2 on each side. */
28
+ const MODAL_CHROME_COLS = 6;
29
+ function riskText(effectiveRisk) {
30
+ const raised = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
31
+ return `Risk: ${effectiveRisk.level}${raised ? ` (raised: ${raised})` : ''}`;
32
+ }
33
+ /** The risk text as the rows it is drawn on at card width `width`. */
34
+ export function riskRows(effectiveRisk, width) {
35
+ return wrapRows(riskText(effectiveRisk), Math.max(1, width - MODAL_CHROME_COLS), RISK_MAX_ROWS);
36
+ }
37
+ /**
38
+ * The collapsed card's rendered height at `width` (the live-area budget; the
39
+ * App adds its own one-row top margin). Panels: border 2 + paddingY 2 + verb
40
+ * + meta? + risk rows + gap + keys. Legacy adds the "Write approval" title and
41
+ * the gap under it.
42
+ */
43
+ export function estimateApprovalModalRows(p, width = uiWidth()) {
44
+ const meta = p.change.package || p.transport ? 1 : 0;
45
+ const body = 1 + meta + riskRows(p.effectiveRisk, width).length + 2;
46
+ return 4 + body + (getLook() === 'panels' ? 0 : 2);
47
+ }
19
48
  // Legacy look: today's literal names (bytes unchanged while colour is on).
20
49
  function riskColor(level) {
21
50
  return level === 'high' ? 'red' : level === 'medium' ? 'yellow' : 'green';
@@ -35,7 +64,8 @@ function diffLineColor(line, panels) {
35
64
  export const BACK_GUARD_MS = 300;
36
65
  /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
37
66
  export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
38
- export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
67
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn, width }) {
68
+ const cardWidth = width ?? uiWidth();
39
69
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
40
70
  // when an inquirer gate ran earlier in the turn (see import block).
41
71
  useReclaimRawStdin();
@@ -148,7 +178,8 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
148
178
  : '';
149
179
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
150
180
  }
151
- const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
181
+ // Pre-wrapped rows, never Ink soft-wrap (Wave-1 rule): every row is counted.
182
+ const risks = riskRows(effectiveRisk, cardWidth);
152
183
  // Meta line: Package · Transport — each segment only when genuinely known.
153
184
  // The handler resolves `transport` per op (see op-labels displayTransport:
154
185
  // create-family may fall back to the session transport because the create
@@ -170,11 +201,12 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
170
201
  keys.push('[s] Skip', '[c] Cancel turn');
171
202
  // Panels look — same content, keys and risk colour; restyled only. The
172
203
  // approval runs BEFORE snapshot/write/syntax check, so the card claims no
173
- // step has run. Legacy return below stays byte-for-byte unchanged.
204
+ // step has run. (Fix batch 2026-10-12: the owner lifted the legacy byte pin
205
+ // for the wrapped risk rows and the fixed card width.)
174
206
  if (getLook() === 'panels') {
175
207
  const risk = roleProps(riskRole(effectiveRisk.level));
176
208
  const tag = inkChip('APPROVAL');
177
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), _jsxs(Text, { ...risk, wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
209
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), risks.map((row, i) => _jsx(Text, { ...risk, wrap: "truncate-end", children: row }, `risk-${i}`)), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
178
210
  }
179
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), _jsxs(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
211
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), risks.map((row, i) => (_jsx(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: row }, `risk-${i}`))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
180
212
  }
@@ -23,7 +23,8 @@ export const INPUT_MAX_LINES = 6;
23
23
  * occupant, fed to liveBudget as `cardRows`. They cover the collapsed card.
24
24
  * A task that owns a card with a variable height replaces its entry at the
25
25
  * App call site with a real estimator (Q2: estimateAskCardRows, C1:
26
- * estimateConfirmCardRows).
26
+ * estimateConfirmCardRows, fix batch 2026-10-12: estimateApprovalModalRows
27
+ * and estimateCoachingPickerRows).
27
28
  * approval margin 1 + border 2 + padding 2 + 3 fact rows + margin 1 + keys 1
28
29
  * rewind margin 1 + border 2 + title 1 + margin 1 + more 2 + REWIND_VISIBLE_ROWS 9 + margin 1 + hint 1
29
30
  * timeline margin 1 + border 2 + title 1 + margin 1 + more 2 + TIMELINE_VISIBLE_ROWS 9
@@ -69,3 +70,15 @@ export function liveBudget(args) {
69
70
  const inputMaxLines = Math.max(1, Math.min(Math.max(1, args.inputLines), INPUT_MAX_LINES, remaining));
70
71
  return { checklistRows, inputMaxLines, showDescriptions: remaining >= 1 };
71
72
  }
73
+ /**
74
+ * Fix round 1 (review I1): the rows a card may take so the whole live area
75
+ * stays ≤ rows − 2 with one input line beside it — the cap less the status
76
+ * strip, the input border, the footer, an open palette, the checklist's one
77
+ * folded line and one input line. A card that can fold (AskCard) folds to
78
+ * fit this.
79
+ */
80
+ export function cardRowBudget(args) {
81
+ const cap = Math.max(0, args.rows - 2);
82
+ const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
83
+ return Math.max(1, cap - fixed - 1 - 1);
84
+ }