@cspeach/cli 0.9.0 → 1.1.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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -1,5 +1,7 @@
1
1
  import { SignJWT, jwtVerify } from 'jose';
2
2
  import crypto from 'node:crypto';
3
+ import { auditLog } from '@cspeach/sap-client';
4
+ import { canonicalApprovalObject, approvalObjectMatches } from './canonical.js';
3
5
  // Session-scoped HMAC key — generated per CLI start, never persisted.
4
6
  const sessionKey = crypto.randomBytes(32);
5
7
  const usedNonces = new Set();
@@ -43,6 +45,24 @@ export async function mintApprovalId(payload) {
43
45
  .setExpirationTime('30m')
44
46
  .sign(sessionKey);
45
47
  }
48
+ /**
49
+ * THE minting path for request_approval changes (Task A3 — D17/D21/D28).
50
+ *
51
+ * The object string stored in the JWT is canonicalized by the same helper the
52
+ * spend-side comparison in verifyAndSpendApprovalId uses, so mint and spend
53
+ * can never disagree on decoration ("Transport: …", "(testclasses include)"),
54
+ * case, or whitespace. request_approval and any future minting site MUST go
55
+ * through this function instead of calling mintApprovalId with a free-text
56
+ * object string.
57
+ */
58
+ export async function mintChangeApproval(change, sessionId) {
59
+ return mintApprovalId({
60
+ object: canonicalApprovalObject(change.object),
61
+ type: change.type,
62
+ op: change.op,
63
+ session_id: sessionId,
64
+ });
65
+ }
46
66
  export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
47
67
  // 2026-05-15 (bug 7): the previous catch lumped EVERY thrown failure
48
68
  // into `'invalid_signature'`, including expirations (which used to be
@@ -75,8 +95,19 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
75
95
  // taxonomy short. Mismatch / spent-nonce paths NEVER reach this catch.
76
96
  return { ok: false, reason: 'invalid_signature' };
77
97
  }
78
- if (payload.object !== expectedObject)
98
+ // Canonical comparison (Task A3 — D17/D21/D28): both sides are normalized
99
+ // by the same helper that mintChangeApproval used at mint time, and a
100
+ // comma-joined batch approval matches each of its member objects. The spend
101
+ // side canonicalizes as well because (a) `expectedObject` is always the raw
102
+ // structured tool argument, never pre-canonicalized, and (b) it is
103
+ // defense-in-depth against any future mint site that bypasses
104
+ // mintChangeApproval and stores a raw string. (It is NOT back-compat for
105
+ // old JWTs: the HMAC key is per-process and never persisted, so no
106
+ // pre-canonicalization token can ever reach this verifier.)
107
+ const matchKind = approvalObjectMatches(payload.object, expectedObject);
108
+ if (!matchKind) {
79
109
  return { ok: false, reason: 'object_mismatch' };
110
+ }
80
111
  // Op-group coverage: a create approval also authorizes the modify+activate
81
112
  // that complete the create; a modify approval authorizes its activate.
82
113
  const covered = OP_COVERAGE[payload.op];
@@ -90,5 +121,11 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
90
121
  return { ok: false, reason: 'nonce_spent' };
91
122
  usedNonces.add(payload.nonce);
92
123
  }
93
- return { ok: true, payload };
124
+ // Auditability: a lenient (non-byte-equal) match is an authorization
125
+ // decision worth a trace — record WHICH fold let the spend through, in the
126
+ // same audit log the write tools use.
127
+ if (matchKind !== 'exact') {
128
+ await auditLog('approvalSpend', payload.type, expectedObject, 'success', 0, `matchKind=${matchKind} minted="${payload.object}"`);
129
+ }
130
+ return { ok: true, payload, matchKind };
94
131
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Human-readable operation + object labels for the approval surfaces.
3
+ *
4
+ * Replaces the cryptic op-code glyph (`A`/`M`/`D`/`>`/`^`) the live review
5
+ * found dishonest and information-poor. Every approval box, plan gate, and
6
+ * classic prompt now speaks a verb ("Create table ZMNT_NOTE") instead of a
7
+ * one-letter code where a word belongs.
8
+ */
9
+ const VERBS = {
10
+ create: 'Create',
11
+ modify: 'Change',
12
+ delete: 'Delete',
13
+ activate: 'Activate',
14
+ release: 'Release',
15
+ };
16
+ /**
17
+ * SAP ADT type codes → plain words. The `type` field is free text supplied by
18
+ * the model, so an unknown/decorated value (e.g. "testclasses include") falls
19
+ * through unchanged rather than being mangled.
20
+ */
21
+ const TYPE_WORDS = {
22
+ TABL: 'table',
23
+ CLAS: 'class',
24
+ INTF: 'interface',
25
+ PROG: 'program',
26
+ FUGR: 'function group',
27
+ DDLS: 'CDS view',
28
+ DDLX: 'metadata extension',
29
+ DCLS: 'access control',
30
+ BDEF: 'behavior definition',
31
+ SRVD: 'service definition',
32
+ SRVB: 'service binding',
33
+ DTEL: 'data element',
34
+ DOMA: 'domain',
35
+ STRU: 'structure',
36
+ TTYP: 'table type',
37
+ MSAG: 'message class',
38
+ DEVC: 'package',
39
+ ENHO: 'enhancement',
40
+ XSLT: 'transformation',
41
+ SHLP: 'search help',
42
+ };
43
+ export function humanVerb(op) {
44
+ return VERBS[op] ?? op.charAt(0).toUpperCase() + op.slice(1);
45
+ }
46
+ export function humanType(type) {
47
+ if (!type)
48
+ return '';
49
+ const key = type.trim().toUpperCase();
50
+ if (TYPE_WORDS[key])
51
+ return TYPE_WORDS[key];
52
+ // Already a word (has a lowercase letter or a space)? use it verbatim.
53
+ if (/[a-z ]/.test(type))
54
+ return type;
55
+ return type.toLowerCase();
56
+ }
57
+ /** e.g. "Create table ZMNT_NOTE", "Change class ZCL_X", "Delete table ZOLD". */
58
+ export function verbLine(change) {
59
+ return [humanVerb(change.op), humanType(change.type), change.object]
60
+ .filter(Boolean)
61
+ .join(' ');
62
+ }
63
+ /**
64
+ * The transport to DISPLAY on the approval surface for one change — mirrors
65
+ * how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
66
+ * review):
67
+ * - create-family (sap_create_object / set_class_include / create_domain /
68
+ * create_data_element): `args.transport ?? session current` — so the
69
+ * session fallback is honest to show.
70
+ * - modify/delete (sap_set_source / sap_update_method / sap_delete_object):
71
+ * resolveWriteTransport(args only, + owning-TR override) — NO session
72
+ * fallback in the actual write, so we only show a transport the request
73
+ * explicitly carries.
74
+ * - activate/release: the tools take no transport arg at all — never show
75
+ * a Transport segment.
76
+ * When nothing is resolvable, return undefined and the caller OMITS the
77
+ * segment — no "new (session)" promise (a $TMP write never creates one).
78
+ */
79
+ export function displayTransport(op, explicit, session) {
80
+ switch (op) {
81
+ case 'create':
82
+ return explicit ?? session ?? undefined;
83
+ case 'modify':
84
+ case 'delete':
85
+ return explicit;
86
+ default: // activate / release
87
+ return undefined;
88
+ }
89
+ }
90
+ /**
91
+ * A create/delete "diff" is really a source dump (all-adds, or plain source).
92
+ * Strip unified-diff scaffolding (hunk + file headers) and the leading +/-
93
+ * marker so View source shows clean source — no +/- colouring that would
94
+ * imply a before/after that does not exist for a create. Shared by the Ink
95
+ * modal's [v] view and the classic prompt's View source branch.
96
+ */
97
+ export function toSourceLines(content) {
98
+ return content
99
+ .split('\n')
100
+ .filter((l) => !l.startsWith('@@') && !l.startsWith('+++') && !l.startsWith('---'))
101
+ .map((l) => l.replace(/^[+-] ?/, ''));
102
+ }
103
+ /**
104
+ * The review key that MATCHES the operation — honesty over a blanket "Show
105
+ * diff":
106
+ * - modify → [d] Show diff (there IS a before/after to diff)
107
+ * - create → [v] View source, only when the new source is carried
108
+ * - delete → [v] View current source, only when the source is carried
109
+ * - activate/release → nothing to review (no source in the payload)
110
+ */
111
+ export function viewAffordance(change) {
112
+ switch (change.op) {
113
+ case 'modify':
114
+ // Existing behaviour: [d] always offered; the renderer warns when the
115
+ // model supplied a description rather than a real unified diff.
116
+ return { key: 'd', label: 'Show diff' };
117
+ case 'create':
118
+ return change.diff ? { key: 'v', label: 'View source' } : null;
119
+ case 'delete':
120
+ return change.diff ? { key: 'v', label: 'View current source' } : null;
121
+ default:
122
+ return null;
123
+ }
124
+ }
@@ -1,5 +1,6 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
+ import { verbLine, viewAffordance, toSourceLines } from './op-labels.js';
3
4
  import { shouldUseInk } from '../renderer/tty.js';
4
5
  import { approvalEmitter } from '../ui/approval-emitter.js';
5
6
  import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
@@ -7,26 +8,6 @@ import { withInquirer } from '../repl/inquirer-guard.js';
7
8
  import { inquirerTheme } from '../repl/inquirer-theme.js';
8
9
  import { PEACH } from '../renderer/banners.js';
9
10
  const peach = (s) => chalk.hex(PEACH)(s);
10
- /**
11
- * Op-row glyph. 2026-05-01: dropped the per-op color spread (yellow M,
12
- * green A, blue >, magenta ^). Using 5 different hues for routine plan
13
- * rows trains the user to ignore color as semantic signal — by the time
14
- * an actual delete shows up, red has already appeared on three other
15
- * lines and lost its punch. Now: only `D` (delete) gets red, because
16
- * delete is the only op that's destructive enough to deserve a hot
17
- * accent. Everything else renders dim — the row layout already tells
18
- * the user it's a change row.
19
- */
20
- function opIcon(op) {
21
- switch (op) {
22
- case 'create': return chalk.dim('A');
23
- case 'modify': return chalk.dim('M');
24
- case 'delete': return chalk.red('D');
25
- case 'activate': return chalk.dim('>');
26
- case 'release': return chalk.dim('^');
27
- default: return chalk.dim('?');
28
- }
29
- }
30
11
  /**
31
12
  * Risk tag. 2026-05-01: yellow used to fire on `medium` too, which
32
13
  * meant most plans had a yellow tag — defeating yellow's purpose as
@@ -119,12 +100,14 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
119
100
  if (effectiveRisk.raisedBy.length > 0) {
120
101
  ctx.push(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`);
121
102
  }
122
- for (const c of visibleChanges)
123
- ctx.push(` ${opIcon(c.op)} ${c.object} (${c.type})`);
103
+ // Numbered, operation-aware verb rows ("1. Create table ZMNT_NOTE") —
104
+ // no cryptic op-code glyph. The Ink modal renders `context` itself, so
105
+ // no chalk here.
106
+ visibleChanges.forEach((c, i) => ctx.push(` ${i + 1}. ${verbLine(c)}`));
124
107
  const result = await askQuestionEmitter.request({
125
108
  id: 'plan-gate',
126
109
  question: 'Apply this plan?',
127
- // Strip chalk ANSI from opIcon/riskTag — the Ink modal renders `context`
110
+ // Strip chalk ANSI from riskTag — the Ink modal renders `context`
128
111
  // itself (dimColor), so embedded escape codes would show as raw text.
129
112
  context: ctx.join('\n').replace(/\x1b\[[0-9;]*m/g, ''),
130
113
  kind: 'choice',
@@ -149,9 +132,9 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
149
132
  if (effectiveRisk.raisedBy.length > 0) {
150
133
  console.log(` ${chalk.dim(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`)}`);
151
134
  }
152
- for (const c of visibleChanges) {
153
- console.log(` ${opIcon(c.op)} ${c.object.padEnd(30)} ${chalk.dim(`(${c.type})`)}`);
154
- }
135
+ visibleChanges.forEach((c, i) => {
136
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
137
+ });
155
138
  console.log('');
156
139
  const choice = await withInquirer(() => select({
157
140
  message: 'Apply?',
@@ -173,17 +156,20 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
173
156
  // fires standalone (single-change request_approval) AND risk is non-low.
174
157
  void effectiveRisk;
175
158
  void transport;
176
- const objLabel = `${opIcon(change.op)} ${change.object} (${change.type})`;
159
+ // Verb-first label ("Create table ZMNT_NOTE — apply?") — no op-code glyph.
160
+ const objLabel = verbLine(change);
161
+ // Op-aware review choice: [d] Show diff for modify, [v] View source for
162
+ // create/delete-with-source, omitted for activate/release.
163
+ const aff = viewAffordance(change);
164
+ const choices = [{ value: 'apply', name: 'Apply' }];
165
+ if (aff)
166
+ choices.push({ value: 'diff', name: aff.label });
167
+ choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
177
168
  const choice = await withInquirer(() => select({
178
169
  message: `${objLabel} — apply?`,
179
170
  default: 'apply',
180
171
  theme: inquirerTheme,
181
- choices: [
182
- { value: 'apply', name: 'Apply' },
183
- { value: 'diff', name: 'Show diff' },
184
- { value: 'skip', name: 'Skip (tell model why)' },
185
- { value: 'cancel', name: 'Cancel turn' },
186
- ],
172
+ choices,
187
173
  }));
188
174
  if (choice === 'apply')
189
175
  return { approved: true };
@@ -193,9 +179,29 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
193
179
  const reason = await withInquirer(() => input({ message: 'Why skip?', theme: inquirerTheme }));
194
180
  return { approved: false, reason };
195
181
  }
196
- // 'diff' — print colorized + lines (green) / - lines (red) / hunk headers
197
- // (peach) so the user can scan the change at a glance instead of squinting
198
- // at flat-gray text.
182
+ // Review choice.
183
+ //
184
+ // I3 (2026-07-05 review): a 'v' affordance (create/delete View source) is
185
+ // NOT a diff — the payload is plain/all-adds source. Routing it through the
186
+ // diff printer fired the "⚠ Model did not provide a unified diff" warning
187
+ // under a View source label. Print numbered plain source instead — never
188
+ // the diff path or its warning. (The affordance is only offered when
189
+ // change.diff is present, so the fallback line is belt-and-braces.)
190
+ if (aff?.key === 'v') {
191
+ if (change.diff) {
192
+ const numbered = toSourceLines(change.diff)
193
+ .map((line, i) => `${chalk.dim(String(i + 1).padStart(4) + ' ')}${line}`)
194
+ .join('\n');
195
+ console.log('\n' + numbered + '\n');
196
+ }
197
+ else {
198
+ console.log(chalk.dim('(No source provided by the model)'));
199
+ }
200
+ return renderPerChangeApproval(change, effectiveRisk, transport);
201
+ }
202
+ // 'd' (modify) — print colorized + lines (green) / - lines (red) / hunk
203
+ // headers (peach) so the user can scan the change at a glance instead of
204
+ // squinting at flat-gray text.
199
205
  //
200
206
  // 2026-05-01: detect "model sent a description, not a diff" — when the
201
207
  // payload has no +/-/@ lines and no `--- /+++ ` headers, colorizeDiff
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Fetch the organisation's Anthropic API key from the CSPeach proxy.
3
+ *
4
+ * Returns the key string on success, or null on any failure.
5
+ * Non-fatal by design — callers must fall back to the manual prompt when null
6
+ * is returned. The key is NEVER logged.
7
+ */
8
+ export async function fetchOrgAnthropicKey(proxyUrl, bearer, deps) {
9
+ const fetchFn = deps?.fetchImpl ?? fetch;
10
+ const url = `${proxyUrl.replace(/\/$/, '')}/v1/me/anthropic-key`;
11
+ try {
12
+ const r = await fetchFn(url, {
13
+ headers: { Authorization: `Bearer ${bearer}` },
14
+ });
15
+ if (!r.ok)
16
+ return null;
17
+ const body = await r.json();
18
+ if (typeof body.key === 'string' && body.key.length > 0)
19
+ return body.key;
20
+ return null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
@@ -8,8 +8,10 @@ const RULES = [
8
8
  { skill: 'abap-document', patterns: [/developer\s+documentation|document\s+this\s+(class|program|object)/i] },
9
9
  // abap-incident MUST precede dump rules — pipeline framing beats keyword-only matches
10
10
  { skill: 'abap-incident', patterns: [/incident|resolve.*dump.*to.*fix|pipeline.*(short\s+dump|st22|dump)|(dump|st22).*to.*transport|formal\s+(incident|dump)\s+report|end.to.end.*dump/i] },
11
- { skill: 'abap-dump-triage', patterns: [/dump\s+triage|structured\s+investigation.*dump|dump\s+forensic/i] },
12
- { skill: 'abap-dump-analyze', patterns: [/short\s+dump|runtime\s+error|\bst22\b|dump\s+analys/i] },
11
+ // abap-dump (2026-06-12) — merger of abap-dump-analyze + abap-dump-triage.
12
+ // Triage-vs-fix is an intent decision INSIDE the skill now, so both
13
+ // pattern sets route here.
14
+ { skill: 'abap-dump', patterns: [/short\s+dump|runtime\s+error|\bst22\b|dump\s+(analys|triage|forensic)|structured\s+investigation.*dump/i] },
13
15
  { skill: 'abap-eml', patterns: [/\beml\b|entity\s+manipulation|behavior\s+handler/i] },
14
16
  { skill: 'abap-enhance', patterns: [/\bbadi\b|enhancement\s+spot|implicit\s+enhancement|customer\s+exit|user\s+exit|(hook\s+into|extend)\s+(sap|standard|me\d+|migo|miro|va\d+|vf\d+|vl\d+|fb\d+)/i] },
15
17
  { skill: 'abap-estimate', patterns: [/effort\s+estimat|ticket\s+estimat|size\s+this\s+(work|task|ticket|story)|how\s+(many|long).*(hours|days).*(build|develop|implement)/i] },
@@ -20,11 +22,24 @@ const RULES = [
20
22
  // abap-modernize MUST precede abap-rap so "rebuild as RAP" / "convert to RAP" stays
21
23
  // on modernize (rebuild intent) instead of falling to abap-rap's bare \brap\b match.
22
24
  { skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*to.*modern|convert.*to\s+(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
25
+ // abap-fiori-build (D12, 2026-06-12) MUST follow abap-modernize ("convert
26
+ // this WRITE report to Fiori" = rebuild intent) and precede abap-rap so
27
+ // "fiori app for my RAP/OData service" lands on the UI skill, not the
28
+ // backend scaffolder.
29
+ { skill: 'abap-fiori-build', patterns: [/\bfiori\b|\b(sap)?ui5\b|freestyle\s+app|\bui\s+app\b|front.?end\s+(for|on)\s/i] },
23
30
  // abap-rap MUST precede abap-generate so RAP-stack prompts route to rap, not the
24
31
  // generic generator (cluster I, 2026-05-03). Also captures "scaffold a CDS view +
25
32
  // BDEF + service binding" type prompts which collide with abap-generate.
26
33
  { skill: 'abap-rap', patterns: [/\brap\b|business\s+object(?!s?\s+for)|bdef|service\s+binding|srvb|scaffold.*(cds|odata\s+v4|bdef|behavior)|odata\s+v4\s+service/i] },
27
- { skill: 'abap-generate', patterns: [/\bgenerate\b|create\s+(a\s+)?(new\s+)?(class|program|report|include)|build.*from\s+scratch/i] },
34
+ // Daily-edit phrasings (D12, 2026-06-12): "add a validation to ZCL_X",
35
+ // "change/fix ZFOO" are abap-generate work — the old rules only matched
36
+ // explicit "generate"/"create" verbs, so daily edits fell through and the
37
+ // coaching picker offered enhance/refactor/test instead.
38
+ { skill: 'abap-generate', patterns: [
39
+ /\bgenerate\b|create\s+(a\s+)?(new\s+)?(class|program|report|include)|build.*from\s+scratch/i,
40
+ /\badd\s+(a\s+|an\s+)?[\w\s-]{1,40}\bto\s+z[_a-z0-9]+/i,
41
+ /\b(change|fix|update|modify|adjust)\s+(the\s+)?z[_a-z0-9]+\b/i,
42
+ ] },
28
43
  { skill: 'abap-impact', patterns: [/impact\s+analysis|where.*used|what.*affected\s+by/i] },
29
44
  // abap-upgrade-* must precede abap-migrate
30
45
  { skill: 'abap-upgrade-verify', patterns: [/upgrade\s+verify|remediation\s+report|verify\s+upgrade/i] },
package/dist/cli.js CHANGED
@@ -62,6 +62,20 @@ export async function main(argv) {
62
62
  }
63
63
  return 0;
64
64
  }
65
+ if (args[0] === 'config' && args[1] === 'unset') {
66
+ const { runConfigUnset, ConfigSetError } = await import('./commands/config-set.js');
67
+ try {
68
+ await runConfigUnset(args.slice(2));
69
+ }
70
+ catch (e) {
71
+ if (e instanceof ConfigSetError) {
72
+ console.error(chalk.red(`✗ ${e.message}`));
73
+ process.exit(e.exitCode);
74
+ }
75
+ throw e;
76
+ }
77
+ return 0;
78
+ }
65
79
  if (args[0] === 'config' && args[1] === 'show') {
66
80
  const { runConfigShow } = await import('./commands/config-show.js');
67
81
  await runConfigShow(args.slice(2));
@@ -134,6 +148,7 @@ Usage:
134
148
  cspeach config add-sap Add a SAP system interactively
135
149
  cspeach configure Alias for \`cspeach config add-sap\`
136
150
  cspeach config set <key> <value> Update a config key
151
+ cspeach config unset <key> Remove a config key (revert to default)
137
152
  cspeach config show [key] Show config (full or single key)
138
153
  cspeach doctor Diagnose install + connectivity
139
154
  cspeach login Sign in via device-code flow
@@ -161,6 +161,24 @@ export async function backupBeforeCompact(session) {
161
161
  await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8');
162
162
  return filePath;
163
163
  }
164
+ /**
165
+ * UX Wave 2 / Task 4 — render session.todos as a plain-text,
166
+ * status-prefixed block for the compacted context. Returns '' when there
167
+ * is no list (undefined or empty) so callers can append unconditionally.
168
+ *
169
+ * Format (explicit status words, not glyphs — the reader is the NEXT
170
+ * turn's model, and `[in_progress]` is unambiguous where `[>]` isn't):
171
+ *
172
+ * Current task list:
173
+ * - [completed] Create table ZORDER
174
+ * - [in_progress] Write behavior definition
175
+ * - [pending] Publish service binding
176
+ */
177
+ export function renderTodoSection(todos) {
178
+ if (!todos || todos.length === 0)
179
+ return '';
180
+ return ['', 'Current task list:', ...todos.map((t) => `- [${t.status}] ${t.text}`)].join('\n');
181
+ }
164
182
  /**
165
183
  * The compacted "synthesised earlier history" message that replaces the
166
184
  * summarised slice in session.messages. Role=user because (a) Anthropic
@@ -168,9 +186,16 @@ export async function backupBeforeCompact(session) {
168
186
  * makes it visually distinct from the model's prior assistant outputs, and
169
187
  * (c) the explicit "context compacted by /compact" framing tells the next
170
188
  * turn's model exactly what it's looking at.
189
+ *
190
+ * UX Wave 2 / Task 4: when `todos` is provided (runCompact passes
191
+ * session.todos), a `Current task list:` section is appended after the
192
+ * summary so the model retains its plan across /compact — the summarised
193
+ * turns are where the list was built, and Haiku isn't trusted to
194
+ * reproduce it verbatim.
171
195
  */
172
- export function buildSummaryMessage(summary, droppedTurns) {
196
+ export function buildSummaryMessage(summary, droppedTurns, todos) {
173
197
  const ts = new Date().toISOString();
198
+ const todoSection = renderTodoSection(todos);
174
199
  return {
175
200
  role: 'user',
176
201
  content: [
@@ -180,6 +205,7 @@ export function buildSummaryMessage(summary, droppedTurns) {
180
205
  `to ~/.cspeach/sessions/${'<session-id>'}.pre-compact-<timestamp>.json]`,
181
206
  '',
182
207
  summary,
208
+ ...(todoSection ? [todoSection] : []),
183
209
  ].join('\n'),
184
210
  };
185
211
  }
@@ -228,7 +254,7 @@ export async function runCompact(opts) {
228
254
  // which it will be unless the session is very long. (For very long sessions
229
255
  // we accept losing the original first prompt because the summary should
230
256
  // mention what the goal was.)
231
- const summaryMsg = buildSummaryMessage(summary, plan.toSummarise.length);
257
+ const summaryMsg = buildSummaryMessage(summary, plan.toSummarise.length, session.todos);
232
258
  session.messages = [summaryMsg, ...plan.toKeep];
233
259
  await saveSession(session);
234
260
  return {