@cspeach/cli 1.1.47 → 1.1.49

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 (110) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/config-set.js +5 -5
  11. package/dist/commands/cost.js +2 -1
  12. package/dist/commands/help.js +1 -1
  13. package/dist/commands/project.js +301 -0
  14. package/dist/commands/team-plan.js +16 -6
  15. package/dist/commands/team.js +295 -68
  16. package/dist/config/loader.js +13 -0
  17. package/dist/cost/cost-log.js +7 -4
  18. package/dist/cost/credits-wire.js +11 -6
  19. package/dist/cost/credits.js +15 -0
  20. package/dist/cost/display-mode.js +16 -4
  21. package/dist/doctor/checks/zcspeach.js +96 -0
  22. package/dist/journey/chain.js +489 -61
  23. package/dist/journey/clean-core.js +61 -36
  24. package/dist/journey/flow.js +75 -2
  25. package/dist/journey/people.js +37 -0
  26. package/dist/journey/rebuild.js +7 -6
  27. package/dist/journey/run.js +129 -42
  28. package/dist/journey/trigger.js +16 -10
  29. package/dist/one-shot.js +1 -1
  30. package/dist/project-context/render.js +7 -0
  31. package/dist/projects/clean-core-confirm.js +166 -44
  32. package/dist/projects/email-template.js +56 -0
  33. package/dist/projects/extract-cca.js +8 -7
  34. package/dist/projects/index.js +0 -1
  35. package/dist/projects/merge-cca.js +0 -19
  36. package/dist/projects/merge-upgrade.js +2 -2
  37. package/dist/projects/project-folder.js +2 -1
  38. package/dist/projects/save-command.js +51 -14
  39. package/dist/register/decision-rules.js +2 -1
  40. package/dist/register/export-files.js +18 -0
  41. package/dist/register/picture.js +3 -3
  42. package/dist/register/project-transport.js +125 -0
  43. package/dist/register/records.js +25 -13
  44. package/dist/register/rules-edit.js +5 -0
  45. package/dist/register/status-file/progress-chart.js +17 -6
  46. package/dist/register/status-file/register-map.js +8 -5
  47. package/dist/register/status-file/register-model.js +55 -36
  48. package/dist/register/status-file/register-parts.js +97 -82
  49. package/dist/register/status-file/register-status-file.js +16 -7
  50. package/dist/register/status-file/report-model.js +293 -205
  51. package/dist/register/status-file/team-css.js +1 -1
  52. package/dist/register/status-html.js +11 -4
  53. package/dist/register/store.js +2 -2
  54. package/dist/register/workbooks.js +24 -13
  55. package/dist/renderer/footer-line.js +10 -2
  56. package/dist/renderer/status-footer.js +2 -1
  57. package/dist/renderer/tool-labels.js +2 -2
  58. package/dist/repl/builtin-commands.js +1 -1
  59. package/dist/repl/continue-skill.js +5 -0
  60. package/dist/repl/level-command.js +6 -15
  61. package/dist/repl.js +21 -8
  62. package/dist/router/router-core.js +5 -3
  63. package/dist/session/audit-export.js +4 -1
  64. package/dist/skill-catalog.js +4 -7
  65. package/dist/tools/_team-work-path.js +53 -0
  66. package/dist/tools/approval.js +97 -19
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-write.js +8 -1
  69. package/dist/tools/local-files.js +31 -0
  70. package/dist/tools/project/_merge-shared.js +2 -2
  71. package/dist/tools/project/_team-tool-shared.js +5 -0
  72. package/dist/tools/project/cca_merge.js +1 -11
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/subagent/schedule_create.js +131 -0
  78. package/dist/tools/text-elements/tools.js +7 -1
  79. package/dist/tools/tool-loading.js +1 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +19 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/error-emitter.js +21 -0
  84. package/dist/ui/footer-line.js +19 -1
  85. package/dist/ui/header.js +41 -0
  86. package/dist/ui/ink-classifier-route.js +19 -0
  87. package/dist/ui/rich-error-box.js +9 -0
  88. package/dist/ui/sidebar.js +12 -0
  89. package/dist/ui/status-emitter.js +19 -0
  90. package/dist/ui/status-line.js +62 -0
  91. package/dist/ui/status-row.js +38 -0
  92. package/dist/ui/todo-panel.js +85 -0
  93. package/dist/ui/widgets/ask-form.js +250 -0
  94. package/dist/ui/widgets/ask-question-modal.js +178 -0
  95. package/fiori/zlas-inspection-lr/package.json +26 -0
  96. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  97. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  98. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  99. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  100. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  101. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  102. package/live-check-2026-10-17-1343.txt +41 -0
  103. package/package.json +121 -121
  104. package/test-harness/fe-draft/app.ts +0 -188
  105. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  106. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  107. package/test-harness/fe-draft/routes.ts +0 -224
  108. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  109. package/test-harness/fe-draft/server.ts +0 -225
  110. package/test-harness/fe-draft/variants.ts +0 -196
@@ -26,7 +26,7 @@ export function parseLevel(raw) {
26
26
  export const LEVEL_INVARIANT = `The level changes how much you explain, never what you found: findings, numbers, object names, warnings, risks, approvals, plan and safety prompts stay exact. It does not change what this person is allowed to do in SAP.`;
27
27
  export const LEVEL_SENTENCES = Object.freeze({
28
28
  learning: 'You\'re talking to a developer who is still building experience. Explain the why, not just the what. The first time a technical term comes up, say in a few words what it means. When you show a fix, point out the pattern behind it so they can use it again. Stay encouraging and concrete; never talk down. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
29
- functional: 'You\'re talking to someone who knows SAP and the business process well but is not a developer. Start with what it means for the business: which documents, amounts, users or reports are affected. Avoid code and technical terms unless they are needed; when one is needed, explain it in plain words. Show code only when they ask for it. When a code change is needed, don\'t hand it to a workflow: write a developer request to the project folder as cspeach-out/developer-request-<object-or-topic>.md (if that name is taken, add -2, -3 and so on) with the sections Summary, What we found, Proposed change, How to test and Open questions, written so a developer can act on it without asking anything; then tell them where it is and that a developer can open it in CSPeach and continue from it. Hand off to a workflow only if they clearly ask to make the change themselves. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
29
+ functional: 'You\'re talking to a developer who wants the business view first. Start with what it means for the business: which documents, amounts, users or reports are affected. Avoid technical terms unless they are needed; when one is needed, explain it in plain words. Show code only when they ask for it. If they ask you to write it up for a developer, write cspeach-out/developer-request-<object-or-topic>.md (if that name is taken, add -2, -3 and so on) with the sections Summary, What we found, Proposed change, How to test and Open questions. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
30
30
  });
31
31
  // ---- pinned: level text composition ----
32
32
  /** The text appended under a "Who you're talking to" heading; null for developer (today's behaviour). */
@@ -72,6 +72,7 @@ import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
72
72
  import { maybeBuildProjectContext } from './maybe-build-project-context.js';
73
73
  import { buildSapConnectionContext } from './sap-connection-adapter.js';
74
74
  import { getCurrentTransport } from '../repl/current-transport.js';
75
+ import { projectTransport } from '../register/project-transport.js';
75
76
  import { collectTurnAssistantText } from './turn-assistant-text.js';
76
77
  import { checkAndMark, newGuardState } from './parallel-write-guard.js';
77
78
  import { userPromptText } from '../session/user-prompt.js';
@@ -116,12 +117,10 @@ const SAVE_HOOK_SKILLS = new Set([
116
117
  'abap-upgrade-fix',
117
118
  'abap-upgrade-verify',
118
119
  'abap-upgrade-merge',
119
- // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca-merge produces
120
- // a consolidated cca-assessment from N parallel consultant slices.
121
120
  // 2026-10-06: the thin /abap-cca (1.1.31) reads the estate inventory and
122
121
  // writes no envelope, so it is no longer a save-hook skill. An old
123
122
  // cca-manifest in a turn is still offered through detectArtifactSkill.
124
- 'abap-cca-merge',
123
+ // 2026-10-20: /abap-cca-merge cut (drift spec §3.6); its name aliases to abap-cca.
125
124
  // 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
126
125
  // /abap-test emits test-coverage. Both accept cca-assessment via --from
127
126
  // and can fan out into further chains after their own envelope saves.
@@ -408,7 +407,7 @@ export async function maybeOfferSave(p) {
408
407
  // the output carried a complete csforge:plan-manifest block. The label
409
408
  // stays the primary trigger — it is authoritative for prose-extracted
410
409
  // artefacts with no inline marker (spec-gap / design / estimate) and keeps
411
- // merge-skill provenance exact (abap-cca-merge saves as abap-cca-merge).
410
+ // merge-skill provenance exact (abap-upgrade-merge saves as abap-upgrade-merge).
412
411
  // When the label would NOT fire, a recognizable manifest block in the
413
412
  // output resolves the registry skill instead (detectArtifactSkill), so a
414
413
  // misroute can no longer silently lose the artifact.
@@ -925,6 +924,8 @@ ${imageExpansion.error}`));
925
924
  const sapConnection = params.ctx.sapAlias
926
925
  ? buildSapConnectionContext(params.ctx.sapAlias, sapSystemInfo, {
927
926
  currentTransport: getCurrentTransport() ?? null,
927
+ // Task 6 (project view): the project request of this folder; null outside a project.
928
+ projectTransport: projectTransport(params.ctx.cwd),
928
929
  })
929
930
  : null;
930
931
  projectContextBlock = await maybeBuildProjectContext({
@@ -1106,7 +1107,7 @@ ${imageExpansion.error}`));
1106
1107
  session.turn_seq = (session.turn_seq ?? Math.max(turnNumber - 1, session.lastAutoCompactTurn ?? 0)) + 1;
1107
1108
  // Snapshot session.messages.length at turn start so the save hook can
1108
1109
  // walk every assistant message added during this turn — not just the
1109
- // final one. Skills like /abap-cca-merge emit their manifest in an early
1110
+ // final one. Skills like /abap-upgrade-merge emit their manifest in an early
1110
1111
  // message before a closing ask_question widget; the final wrap-up
1111
1112
  // message ends the turn but doesn't carry the manifest. See
1112
1113
  // ./turn-assistant-text.ts for the full rationale.
@@ -1932,7 +1933,7 @@ ${imageExpansion.error}`));
1932
1933
  // a background block on the next REPL prompt.
1933
1934
  //
1934
1935
  // 2026-05-12: collect text from ALL assistant messages added during
1935
- // this turn, not just the final one. Skills like /abap-cca-merge emit
1936
+ // this turn, not just the final one. Skills like /abap-upgrade-merge emit
1936
1937
  // their manifest in an early message before a closing ask_question
1937
1938
  // widget; the final wrap-up message ends the turn but doesn't
1938
1939
  // carry the manifest. See ./turn-assistant-text.ts for details.
@@ -34,6 +34,8 @@ export function buildSapConnectionContext(alias, systemInfo, state = {}) {
34
34
  alias,
35
35
  system: toSummary(systemInfo),
36
36
  currentTransport: state.currentTransport ?? null,
37
+ // Only in a project folder: a context without one stays as it was, key for key.
38
+ ...(state.projectTransport ? { projectTransport: state.projectTransport } : {}),
37
39
  activeScope: toActiveScope(state),
38
40
  recentObjects: state.recentObjects ?? [],
39
41
  };
@@ -47,7 +47,7 @@ export function buildReaderSystemPrompt(opts) {
47
47
  * Chat lane (spec 2026-10-17 D3) — one block, the pinned chat prompt, as the
48
48
  * proxy sends it for the `_chat` skill header. The coding-workflow preamble is
49
49
  * NOT sent on chat turns. A non-professional style is appended to that block;
50
- * a non-developer level (§10 D14) follows the style text.
50
+ * a non-default level (§10 D14) follows the style text.
51
51
  */
52
52
  export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
53
53
  const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
@@ -62,7 +62,7 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
62
62
  * has no SKILL.md, so it never reaches the skill source (a lookup would fail);
63
63
  * nor does the built-in `_chat` (D3). A non-professional `style` (D9) is
64
64
  * appended to the END of the last block — professional changes nothing, and a
65
- * reader (which reports facts) never gets style. A non-developer `level`
65
+ * reader (which reports facts) never gets style. A non-default `level`
66
66
  * (§10 D14) follows the style text; developer changes nothing; a reader never
67
67
  * gets it.
68
68
  */
@@ -21,17 +21,24 @@ export const CHAT_UNIT_TEST_REASON = 'This chat reads but never runs code on SAP
21
21
  export const CHAT_DISPATCH_SKILL_REASON = 'In this chat, dispatch_skill is not available. To start a guided workflow, call hand_off with the workflow name and a clear request.';
22
22
  /** A call carrying an approval_id in chat — approvals belong to workflows, never to chat. */
23
23
  export const CHAT_APPROVAL_ID_REASON = 'This chat never uses an approval. To make this change, call hand_off with the right workflow and a clear request — the workflow asks before it writes.';
24
+ /**
25
+ * team_lane_move in chat (Task 4, 2026-10-20) — listed like every tool, refused
26
+ * here. Moving packages between people is a lead's decision with its own card;
27
+ * in chat the lead is given the command instead.
28
+ */
29
+ export const CHAT_LANE_MOVE_REASON = 'In this chat, packages are not moved between people. The lead runs: cspeach team lane move --packages <names> --to <name> --reason "<why>"';
24
30
  /**
25
31
  * Tools refused in chat beyond the generic rule: the two smokes write SAP
26
32
  * data over OData although requiresSap is false; sap_run_unit_test runs code
27
33
  * that can COMMIT; dispatch_skill could queue any slash command (hand_off is
28
- * chat's switch, D5).
34
+ * chat's switch, D5); team_lane_move writes the team project's lanes (Task 4).
29
35
  */
30
36
  const CHAT_EXTRA_REFUSED = new Map([
31
37
  ['fiori_draft_smoke', CHAT_READ_ONLY_REASON],
32
38
  ['cap_link_smoke', CHAT_READ_ONLY_REASON],
33
39
  ['sap_run_unit_test', CHAT_UNIT_TEST_REASON],
34
40
  ['dispatch_skill', CHAT_DISPATCH_SKILL_REASON],
41
+ ['team_lane_move', CHAT_LANE_MOVE_REASON],
35
42
  ]);
36
43
  /**
37
44
  * Chat lane refusal set (D-F): WRITE_TOOLS ∪ {request_approval} ∪ every tool
@@ -256,6 +263,11 @@ function declined(r) {
256
263
  export function isLocalProjectWork(tool, args) {
257
264
  if (tool.category === 'filesystem')
258
265
  return true;
266
+ // Task 4 (2026-10-20) — every project tool writes project files behind its
267
+ // own card (team_lane_move) or reads only: a second lane move in one turn
268
+ // never gets a batch card on top of its own card. No project tool reaches SAP.
269
+ if (tool.category === 'project')
270
+ return true;
259
271
  // 2026-10-02 — fiori_deploy mode "test" (the default) writes nothing to SAP:
260
272
  // the backend validates the upload and keeps nothing. Not a batch write, so
261
273
  // a deploy phase shows ONE approval (the real deploy's request_approval),
@@ -1,5 +1,8 @@
1
1
  import { typeCode } from './risk-floor.js';
2
2
  import { sanitizeControlChars } from '../renderer/sanitize.js';
3
+ import { wrapRows } from '../renderer/wrap-rows.js';
4
+ import { shownUpTo } from '../register/atc-file.js';
5
+ import { transportForWrite } from '../register/project-transport.js';
3
6
  /**
4
7
  * Human-readable operation + object labels for the approval surfaces.
5
8
  *
@@ -89,6 +92,32 @@ export function affectsLine(change) {
89
92
  const t = sanitizeControlChars(String(change.affects ?? '')).trim();
90
93
  return t ? `Affects: ${t}` : null;
91
94
  }
95
+ /** Task 7 — the longest behaviour text a card shows, and the most rows it takes. */
96
+ const BEHAVIOUR_MAX = 240;
97
+ /** Words that mean "nothing changes": no row. */
98
+ const NOTHING_CHANGES = /^(none|no change|n\/a)\.?$/i;
99
+ export const BEHAVIOUR_MAX_ROWS = 3;
100
+ /**
101
+ * Task 7 — "Behaviour: <text>" for a change that says what callers or users
102
+ * will see differently; null when absent, empty, or 'none' / 'no change' /
103
+ * 'n/a'. The text is the model's: escape sequences and control characters
104
+ * go, whitespace collapses, cut at 240 code points.
105
+ */
106
+ export function behaviourText(change) {
107
+ // shownUpTo drops whole escape sequences first (a lone sanitizeControlChars
108
+ // pass would leave their '[31m' tails), then every control character.
109
+ // The cut counts code points, so it never leaves half a surrogate pair.
110
+ const flat = Array.from(shownUpTo(String(change.behaviour ?? ''), Number.POSITIVE_INFINITY));
111
+ const t = flat.length > BEHAVIOUR_MAX ? `${flat.slice(0, BEHAVIOUR_MAX).join('')}…` : flat.join('');
112
+ if (!t || NOTHING_CHANGES.test(t))
113
+ return null;
114
+ return `Behaviour: ${t}`;
115
+ }
116
+ /** Task 7 — the behaviour text as the rows it is drawn on at `width` cells (at most 3); [] when none. */
117
+ export function behaviourLines(change, width) {
118
+ const t = behaviourText(change);
119
+ return t ? wrapRows(t, width, BEHAVIOUR_MAX_ROWS) : [];
120
+ }
92
121
  /** Part 3 D12 — the longest request description a release card shows. */
93
122
  const RELEASE_DESCRIPTION_MAX = 60;
94
123
  const RELEASE_LAST_LINE = 'Cannot be undone. Once released, the request can move to the other systems on the transport route.';
@@ -136,17 +165,27 @@ export function releaseLines(change) {
136
165
  * show a Transport segment.
137
166
  * When nothing is resolvable, return undefined and the caller OMITS the
138
167
  * segment — no "new (session)" promise (a $TMP write never creates one).
168
+ *
169
+ * Task 8 (B11) — two more facts, both absent → today's result:
170
+ * - `owning`: the request that already holds the object (request_approval's
171
+ * lookup). A modify/delete is forced into it by resolveWriteTransport, so
172
+ * it wins and says why: "(object already locked here)" — never worded as
173
+ * if the person chose it.
174
+ * - `project`: the request of the project joined in this folder. Every
175
+ * write tool uses it when the change names none (before the session
176
+ * transport), so it shows as "<id> (project)" — one order for card and write:
177
+ * owning lock (modify/delete) ?? explicit ?? project ?? session.
178
+ *
179
+ * Task 6 (project view): the choice itself is `transportForWrite`
180
+ * (register/project-transport.ts), the one the write tools call too. This
181
+ * card hands it the session request for a create only. 'resolve' writes
182
+ * (set_source, update_method, delete) never use the session request; the
183
+ * class-include, message and text-elements writes do fall back to it when no
184
+ * project request is set, and their modify card does not show it — today's
185
+ * card, kept byte for byte.
139
186
  */
140
- export function displayTransport(op, explicit, session) {
141
- switch (op) {
142
- case 'create':
143
- return explicit ?? session ?? undefined;
144
- case 'modify':
145
- case 'delete':
146
- return explicit;
147
- default: // activate / release / run
148
- return undefined;
149
- }
187
+ export function displayTransport(op, explicit, session, owning, project) {
188
+ return transportForWrite({ op, explicit, owning, project, session: op === 'create' ? session : undefined }).label;
150
189
  }
151
190
  /**
152
191
  * A create/delete "diff" is really a source dump (all-adds, or plain source).
@@ -1,7 +1,8 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { riskReasonText, shownRiskReasons } from './risk-floor.js';
4
- import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } from './op-labels.js';
4
+ import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourText, behaviourLines } from './op-labels.js';
5
+ import { uiWidth } from '../renderer/ui-width.js';
5
6
  import { shouldUseInk } from '../renderer/tty.js';
6
7
  import { approvalEmitter } from '../ui/approval-emitter.js';
7
8
  import { withInquirer } from '../repl/inquirer-guard.js';
@@ -11,6 +12,7 @@ import { emitTrace } from '../renderer/trace.js';
11
12
  import { getGlyphs } from '../renderer/glyphs.js';
12
13
  import { paint } from '../renderer/theme.js';
13
14
  import { sanitizeControlChars } from '../renderer/sanitize.js';
15
+ import { collapseImplicitActivates, looksLikeUnifiedDiff } from './visible-changes.js';
14
16
  import { withSpinnerSuspended } from '../renderer/tool-widget.js';
15
17
  // Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
16
18
  const peach = (s) => paint('brand', s);
@@ -73,19 +75,7 @@ function colorizeDiff(diff) {
73
75
  * compressed. This is what fixes the "what 2 changes?" complaint when the
74
76
  * model declares modify+activate of the same object.
75
77
  */
76
- function collapseImplicitActivates(changes) {
77
- const writeKeys = new Set();
78
- for (const c of changes) {
79
- if (c.op === 'modify' || c.op === 'create') {
80
- writeKeys.add(`${c.type}:${c.object}`);
81
- }
82
- }
83
- return changes.filter((c) => {
84
- if (c.op !== 'activate')
85
- return true;
86
- return !writeKeys.has(`${c.type}:${c.object}`);
87
- });
88
- }
78
+ // collapseImplicitActivates lives in ./visible-changes.ts (Task 9: request_approval uses it too).
89
79
  export async function renderPlanGate(summary, changes, transport, effectiveRisk, system) {
90
80
  const visibleChanges = collapseImplicitActivates(changes);
91
81
  const visibleCount = visibleChanges.length;
@@ -122,7 +112,9 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
122
112
  // "Apply all" mints it without its per-change card.
123
113
  const steps = visibleChanges.map((c, i) => {
124
114
  const affects = affectsLine(c);
125
- return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : []), ...releaseLines(c).map((l) => ` ${l}`)];
115
+ // Task 7 — "Behaviour: …" is one fact (the card wraps it) and one row of the budget.
116
+ const behaviour = behaviourText(c);
117
+ return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : []), ...(behaviour ? [` ${behaviour}`] : []), ...releaseLines(c).map((l) => ` ${l}`)];
126
118
  });
127
119
  // Fix round 1 (owner ruling) — a release step is ALWAYS on the card, with
128
120
  // its fact lines, wherever it sits: its rows have their own budget and
@@ -202,6 +194,9 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
202
194
  const affects = affectsLine(c);
203
195
  if (affects)
204
196
  console.log(` ${chalk.dim(affects)}`);
197
+ // Task 7 — what callers or users will see differently, under its line.
198
+ for (const l of behaviourLines(c, uiWidth() - 11))
199
+ console.log(` ${chalk.dim(l)}`);
205
200
  // Part 3 D12 / F-13 — a release row's facts, under its line.
206
201
  for (const l of releaseLines(c))
207
202
  console.log(` ${chalk.dim(l)}`);
@@ -240,8 +235,10 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
240
235
  const affects = affectsLine(change);
241
236
  // Part 3 D12 — a release card's facts, under the verb line.
242
237
  const release = releaseLines(change).map((l) => `\n ${chalk.dim(l)}`).join('');
238
+ // Task 7 — "Behaviour: …" rows, after Affects and before the release lines.
239
+ const behaviour = behaviourLines(change, uiWidth() - 2).map((l) => `\n ${chalk.dim(l)}`).join('');
243
240
  const choice = await withInquirer(() => select({
244
- message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}${release}`,
241
+ message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}${behaviour}${release}`,
245
242
  default: 'apply',
246
243
  theme: inquirerTheme,
247
244
  choices,
@@ -305,13 +302,7 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
305
302
  * option doesn't return useless dim grey text when the model passed a
306
303
  * one-paragraph description in the `diff` field.
307
304
  */
308
- function looksLikeUnifiedDiff(s) {
309
- return s.split('\n').some((line) => {
310
- if (line.startsWith('+++') || line.startsWith('---'))
311
- return false;
312
- return line.startsWith('+') || line.startsWith('-') || line.startsWith('@@');
313
- });
314
- }
305
+ // looksLikeUnifiedDiff lives in ./visible-changes.ts (next to collapseImplicitActivates).
315
306
  /**
316
307
  * v0.3 dispatcher: routes to Ink modal (via approvalEmitter) when running
317
308
  * under Ink, else delegates to the existing inquirer-based flow.
@@ -6,15 +6,159 @@
6
6
  export function typeCode(type) {
7
7
  return String(type ?? '').trim().toUpperCase().split('/')[0].trim();
8
8
  }
9
+ const DECLARATION_OPENER = /^\s*(CLASS-)?METHODS\b/i;
10
+ /** A line that may be a parameter of a declaration whose opener is outside the hunk. */
11
+ const PARAMETER_LIKE = /\b(TYPE|LIKE)\b/i;
12
+ const PARAMETER_KEYWORD = /^\s*(IMPORTING|EXPORTING|CHANGING|RETURNING|RAISING)\b/i;
13
+ const LOCAL_DECLARATION = /^\s*(DATA|FIELD-SYMBOLS|TYPES|CONSTANTS|STATICS)\b/i;
14
+ /**
15
+ * A context line shaped like one parameter: a name (or VALUE(…) /
16
+ * REFERENCE(…)) right before TYPE or LIKE. Two words before TYPE (`RAISE
17
+ * EXCEPTION TYPE`, `DATA lv TYPE`) are not this shape.
18
+ */
19
+ const PARAMETER_LINE = /^\s*(!?[a-z_/][\w/]*|(VALUE|REFERENCE)\(\s*!?[\w/]+\s*\))\s+(TYPE|LIKE)\b/i;
20
+ /** A bare identifier list ending in `,` or `.` — e.g. exception classes under RAISING. */
21
+ const BARE_LIST = /^\s*[a-z_/][\w/]*(\s*,\s*[a-z_/][\w/]*)*\s*[,.]\s*$/i;
22
+ /** Single-word statements that are never part of a signature. */
23
+ const BARE_STATEMENT = /^\s*(END\w*|ELSE|RETURN|EXIT|CONTINUE|CLEANUP)\s*\.\s*$/i;
24
+ /**
25
+ * Task 7 (R2) — a statement that opens or ends a processing block. Its
26
+ * statement holds no signature (METHOD, FUNCTION, MODULE, END…) — FORM is the
27
+ * one exception: its USING / CHANGING / TABLES / RAISING parameters follow it,
28
+ * so a FORM settles the hunk only once its statement has ended in `.`.
29
+ * METHODS does not match (the `\b` stops at METHOD).
30
+ */
31
+ const BLOCK_STATEMENT = /^\s*(METHOD|ENDMETHOD|FORM|ENDFORM|FUNCTION|ENDFUNCTION|MODULE|ENDMODULE)\b/i;
32
+ const FORM_STATEMENT = /^\s*FORM\b/i;
33
+ /** Fix round 1 — a parameter named like a keyword (`method TYPE string`, `form LIKE x`). */
34
+ const TYPED_AFTER_FIRST_WORD = /^\s*\S+\s+(TYPE|LIKE)\b/i;
35
+ /**
36
+ * Task 7 (R2) — the state a hunk header's text (between or after the `@@`
37
+ * marks) or a context line sets, when it is a block statement: 'out' (the
38
+ * lines after it are body), 'in' for a FORM that goes on (its parameters
39
+ * follow), undefined when it is not a block statement. Fix round 1 — a line
40
+ * shaped like a parameter is never a block statement.
41
+ */
42
+ function blockState(code) {
43
+ if (!BLOCK_STATEMENT.test(code))
44
+ return undefined;
45
+ if (PARAMETER_LINE.test(code) || TYPED_AFTER_FIRST_WORD.test(code))
46
+ return undefined;
47
+ if (FORM_STATEMENT.test(code) && !code.trimEnd().endsWith('.'))
48
+ return 'in';
49
+ return 'out';
50
+ }
51
+ /** The text of a hunk header: `@@ -1,2 +1,2 @@ METHOD x.` or `@@ METHOD x. @@` → `METHOD x.`. */
52
+ function hunkHeaderText(raw) {
53
+ const m = /^@@(.*?)@@(.*)$/.exec(raw);
54
+ if (!m)
55
+ return raw.replace(/^@@/, '');
56
+ const inner = m[1].replace(/^\s*-\d+(,\d+)?\s+\+\d+(,\d+)?\s*$/, '');
57
+ return (m[2].trim() !== '' ? m[2] : inner);
58
+ }
59
+ /** The code part of an ABAP line: a `*` line is all comment; a `"` starts a trailing comment. */
60
+ function codeOf(line) {
61
+ if (line.startsWith('*'))
62
+ return '';
63
+ return line.replace(/".*$/, '');
64
+ }
65
+ /**
66
+ * Task 7 (B10) — might a unified diff change a method signature? This is a
67
+ * risk-floor input: it errs toward true (over-raising is accepted) and it
68
+ * can only ever raise risk, never lower it. True when:
69
+ * - any changed (`+`/`-`, not `+++`/`---`) METHODS / CLASS-METHODS line —
70
+ * an added, removed or renamed method, with or without parameters;
71
+ * - any changed line with code inside a declaration that a context
72
+ * `METHODS x` line opened (until its code ends in `.`; comments do not
73
+ * count) — a parameter, a type, an exception class on its own line;
74
+ * - from a hunk start until a context line ending a statement settles it
75
+ * (e.g. `METHOD x.`), a changed line that looks like part of a
76
+ * declaration whose opener sits outside the hunk's context: TYPE or LIKE
77
+ * (or a leading IMPORTING … RAISING), no `=`, not a local DATA,
78
+ * FIELD-SYMBOLS, TYPES, CONSTANTS or STATICS; or a bare identifier list
79
+ * ending in `,` or `.` (exception classes), not END… / RETURN / EXIT;
80
+ * - in that hunk-start state, an unchanged IMPORTING … RAISING line or an
81
+ * unchanged `name TYPE x` parameter line opens the declaration, so the
82
+ * changed lines after it count as above.
83
+ * A body line after `METHOD x.` (`CALL FUNCTION … EXPORTING`, `DATA … TYPE`)
84
+ * stays false.
85
+ * Task 7 (R2) — an unchanged METHOD / FORM / FUNCTION / MODULE / END… line
86
+ * (not one shaped like a parameter) settles the state to 'out' before any
87
+ * other rule reads it: the lines after it are body. A FORM whose statement
88
+ * goes on (no `.`) sets 'in' instead: its USING / CHANGING lines are its
89
+ * signature. A changed FORM line is a signature change.
90
+ * Fix round 1 — a hunk header is model text, so it never sets 'out'. A
91
+ * header naming a block (`@@ METHOD to_internal. @@`,
92
+ * `@@ -1,2 +1,2 @@ METHOD x.`) sets 'hint': only the bare hunk-start guess
93
+ * on a changed line is off, so a bare `EXPORTING` of a CALL FUNCTION is not
94
+ * taken for a parameter list. Unchanged IMPORTING … RAISING, `name TYPE x`
95
+ * and METHODS lines still open a declaration. Known gap (pinned in a test):
96
+ * changed parameter lines alone, with no context, under such a header.
97
+ */
98
+ export function signatureChange(diff) {
99
+ if (!diff)
100
+ return false;
101
+ let state = 'unknown';
102
+ for (const raw of diff.split('\n')) {
103
+ if (raw.startsWith('+++') || raw.startsWith('---'))
104
+ continue;
105
+ if (raw.startsWith('@@')) {
106
+ // Task 7 (R2) / fix round 1 — a header naming the block
107
+ // (`@@ METHOD to_internal. @@`) hints that the hunk starts in its body.
108
+ // It is model text: a hint, never 'out'.
109
+ // A FORM header never hints: its USING lines are its signature, so the
110
+ // hunk-start guess stays on (as before Task 7).
111
+ const text = codeOf(hunkHeaderText(raw).trim());
112
+ const block = blockState(text);
113
+ state = block === 'in' ? 'in' : block === 'out' && !FORM_STATEMENT.test(text) ? 'hint' : 'unknown';
114
+ continue;
115
+ }
116
+ const changed = raw.startsWith('+') || raw.startsWith('-');
117
+ const code = codeOf(/^[+\- ]/.test(raw) ? raw.slice(1) : raw);
118
+ // Task 7 (R2) — an unchanged block statement settles the state before any
119
+ // other rule reads it. A changed one goes through every rule below.
120
+ if (!changed) {
121
+ const block = blockState(code);
122
+ if (block) {
123
+ state = block;
124
+ continue;
125
+ }
126
+ }
127
+ // A changed FORM line is a changed FORM signature (or name), like a changed METHODS line.
128
+ if (changed && FORM_STATEMENT.test(code))
129
+ return true;
130
+ if (DECLARATION_OPENER.test(code)) {
131
+ if (changed)
132
+ return true;
133
+ state = 'in';
134
+ }
135
+ // Round 3 — at a hunk start, an unchanged parameter line (IMPORTING …
136
+ // RAISING, or `name TYPE x`) means the hunk starts inside a declaration.
137
+ if (!changed && (state === 'unknown' || state === 'hint') && !code.includes('=') && !LOCAL_DECLARATION.test(code)
138
+ && (PARAMETER_KEYWORD.test(code) || PARAMETER_LINE.test(code)))
139
+ state = 'in';
140
+ if (changed && state === 'in' && code.trim() !== '')
141
+ return true;
142
+ if (changed && state === 'unknown' && !code.includes('=') && !LOCAL_DECLARATION.test(code)
143
+ && (PARAMETER_LIKE.test(code) || PARAMETER_KEYWORD.test(code)
144
+ || (BARE_LIST.test(code) && !BARE_STATEMENT.test(code))))
145
+ return true;
146
+ if (code.trimEnd().endsWith('.') && (state === 'in' || !changed))
147
+ state = 'out';
148
+ }
149
+ return false;
150
+ }
9
151
  export function effectiveRisk(declared, changes, ctx) {
10
- let floor = declared;
11
- const raisedBy = [];
12
152
  const rank = (r) => ({ low: 0, medium: 1, high: 2 }[r]);
13
- const raise = (r, label) => {
14
- if (rank(r) > rank(floor)) {
15
- floor = r;
16
- raisedBy.push(label);
17
- }
153
+ // Task 7 (R2) — every rule that applies is noted, in rule order. The shown
154
+ // reasons are each rule that on its own justifies the final level (at or
155
+ // above it, and above what the model declared), plus the reasons that are
156
+ // always shown. Before, only the first rule to cross a level was kept, so
157
+ // a body-only change read "may alter a method signature" when activation
158
+ // was the true reason.
159
+ const applied = [];
160
+ const raise = (r, label, always = false) => {
161
+ applied.push({ r, label, always });
18
162
  };
19
163
  if (ctx.autoApprovePolicy === 'never')
20
164
  raise('high', 'non_prod_override');
@@ -29,19 +173,20 @@ export function effectiveRisk(declared, changes, ctx) {
29
173
  const badNames = new Set(ctx.syntaxErrorObjects.map((k) => k.slice(k.lastIndexOf(':') + 1).toUpperCase()));
30
174
  const hit = (c) => badNames.has(c.object.trim().split(/\s+/)[0].toUpperCase());
31
175
  if (changes.some(hit)) {
32
- raise('high', 'syntax_errors');
33
176
  // Final review A M-4 — the reason is always shown, even when the floor
34
177
  // was already high (policy never, or declared high).
35
- if (!raisedBy.includes('syntax_errors'))
36
- raisedBy.push('syntax_errors');
178
+ raise('high', 'syntax_errors', true);
37
179
  }
38
180
  }
39
181
  // Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
40
182
  // high (never auto-approved), and the reason is always shown.
41
183
  if (changes.some((c) => typeCode(c.type) === 'ENHO')) {
42
- raise('high', 'standard_process');
43
- if (!raisedBy.includes('standard_process'))
44
- raisedBy.push('standard_process');
184
+ raise('high', 'standard_process', true);
185
+ }
186
+ // Task 7 (B10) — a changed method signature on a class or interface can
187
+ // break its callers: medium, and the reason is always shown.
188
+ if (changes.some((c) => c.op === 'modify' && ['CLAS', 'INTF'].includes(typeCode(c.type)) && signatureChange(c.diff))) {
189
+ raise('medium', 'signature_change', true);
45
190
  }
46
191
  if (changes.some((c) => c.op === 'delete'))
47
192
  raise('medium', 'delete_op');
@@ -51,14 +196,10 @@ export function effectiveRisk(declared, changes, ctx) {
51
196
  // Part 3 D2 — a release cannot be undone, and a run executes code on the
52
197
  // system: both are high (never auto-approved), and the reason is always shown.
53
198
  if (changes.some((c) => c.op === 'release')) {
54
- raise('high', 'transport_release');
55
- if (!raisedBy.includes('transport_release'))
56
- raisedBy.push('transport_release');
199
+ raise('high', 'transport_release', true);
57
200
  }
58
201
  if (changes.some((c) => c.op === 'run')) {
59
- raise('high', 'code_run');
60
- if (!raisedBy.includes('code_run'))
61
- raisedBy.push('code_run');
202
+ raise('high', 'code_run', true);
62
203
  }
63
204
  if (changes.some((c) => c.op === 'activate'))
64
205
  raise('medium', 'activation');
@@ -70,6 +211,26 @@ export function effectiveRisk(declared, changes, ctx) {
70
211
  raise('medium', 'escalated_in_session');
71
212
  if (ctx.prodNamespaces && changes.some((c) => ctx.prodNamespaces.some((p) => c.object.startsWith(p))))
72
213
  raise('medium', 'prod_namespace');
214
+ // The level is the highest rule (never below what the model declared).
215
+ let floor = declared;
216
+ for (const a of applied)
217
+ if (rank(a.r) > rank(floor))
218
+ floor = a.r;
219
+ const raisedBy = [];
220
+ for (const a of applied) {
221
+ const justifies = rank(a.r) >= rank(floor) && rank(a.r) > rank(declared);
222
+ if ((justifies || a.always) && !raisedBy.includes(a.label))
223
+ raisedBy.push(a.label);
224
+ }
225
+ // Task 7 (R2) / fix round 1 — when activation and a signature change are
226
+ // both shown, activation leads (it is the sure reason; the signature one is
227
+ // a reading of the diff). Otherwise the rule order stands.
228
+ const sig = raisedBy.indexOf('signature_change');
229
+ const act = raisedBy.indexOf('activation');
230
+ if (sig >= 0 && act > sig) {
231
+ raisedBy.splice(act, 1);
232
+ raisedBy.splice(sig, 0, 'activation');
233
+ }
73
234
  return { level: floor, raisedBy };
74
235
  }
75
236
  /** Plain words for each risk reason code — the codes stay in audit data only. */
@@ -85,7 +246,23 @@ const RISK_REASON_TEXT = {
85
246
  syntax_errors: 'the last syntax check of this object found errors',
86
247
  standard_process: 'this code runs inside a standard SAP process (a BAdI implementation)',
87
248
  code_run: 'this runs code on the SAP system',
249
+ signature_change: 'the change may alter a method signature, so callers may be affected',
88
250
  };
251
+ /**
252
+ * Task 7 (R2) — one sentence on every request_approval result (and once in
253
+ * the journey fix block): the risk line is CSPeach's reading of the diff, so
254
+ * the model relays it rather than telling the person it is wrong.
255
+ */
256
+ export const RISK_NOTE = "The risk line is CSPeach's reading of the diff; relay it, and if you think it is off, say why.";
257
+ /**
258
+ * Task 7 (R2) — the risk fields of every request_approval result:
259
+ * effective_risk and raised_by as before (older readers), plus risk { level,
260
+ * reasons } in plain words and the note.
261
+ */
262
+ export function riskResultFields(eff) {
263
+ const reasons = eff.raisedBy.map(riskReasonText); // round 8 item 4: plain words, never codes
264
+ return { effective_risk: eff.level, raised_by: reasons, risk: { level: eff.level, reasons: [...reasons] }, note: RISK_NOTE };
265
+ }
89
266
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
90
267
  export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
91
268
  /** Round 4 item 8a — never show an internal reason code raw. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Collapse the changes list to what a person sees — `activate` is the
3
+ * implicit follow-on of a `modify`/`create` on the same object, so the
4
+ * activate row is hidden when its sibling write is already in the list. The
5
+ * underlying `changes[]` (which drives approval-id minting) is unchanged.
6
+ * This is what fixes the "what 2 changes?" complaint when the model declares
7
+ * modify+activate of the same object. Used by the plan gate's display
8
+ * (render.ts) and, Task 9 (C8), by request_approval to decide whether the
9
+ * plan gate opens at all (only for more than one visible change).
10
+ */
11
+ export function collapseImplicitActivates(changes) {
12
+ const writeKeys = new Set();
13
+ for (const c of changes) {
14
+ if (c.op === 'modify' || c.op === 'create') {
15
+ writeKeys.add(`${c.type}:${c.object}`);
16
+ }
17
+ }
18
+ return changes.filter((c) => {
19
+ if (c.op !== 'activate')
20
+ return true;
21
+ return !writeKeys.has(`${c.type}:${c.object}`);
22
+ });
23
+ }
24
+ /**
25
+ * Heuristic: a real unified diff has at least one line starting with `+`,
26
+ * `-`, or `@@`. A summary or change description won't. Used to gate
27
+ * colorizeDiff vs the "model didn't supply a diff" warning (render.ts).
28
+ */
29
+ export function looksLikeUnifiedDiff(s) {
30
+ return s.split('\n').some((line) => {
31
+ if (line.startsWith('+++') || line.startsWith('---'))
32
+ return false;
33
+ return line.startsWith('+') || line.startsWith('-') || line.startsWith('@@');
34
+ });
35
+ }
@@ -598,17 +598,17 @@ export async function runConfigSet(args) {
598
598
  break;
599
599
  }
600
600
  case 'show_cogs': {
601
- // Operator escape hatch — render OUR Anthropic COGS in USD even on a
602
- // managed, metered account (where the customer-facing unit is credits).
603
- // Off/absent is the product default; see cost/display-mode.ts.
601
+ // Operator view — our cost in USD on a managed account. Works only when
602
+ // the server also grants the account `cost_view` (2026-10-19); on its
603
+ // own this setting changes nothing. See cost/display-mode.ts.
604
604
  const b = coerceBool(value);
605
605
  if (b === null) {
606
606
  err(`${key} must be on/off (also accepts true/false, 1/0).`);
607
607
  }
608
608
  cfg.show_cogs = b;
609
609
  console.log(chalk.dim(b
610
- ? ' → cost figures will show USD (our Anthropic cost), not credits.'
611
- : ' → cost figures will show credits on a managed, metered account.'));
610
+ ? ' → applies only to accounts allowed to see costs; everyone else keeps credits.'
611
+ : ' → cost figures show credits.'));
612
612
  break;
613
613
  }
614
614
  case 'word_copy': {
@@ -141,6 +141,7 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
141
141
  emit('');
142
142
  if (inCredits)
143
143
  emit(formatInfo('This month and your top skills: /usage').trimEnd());
144
- emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
144
+ if (!inCredits)
145
+ emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
145
146
  emit('');
146
147
  }