@cspeach/cli 1.1.48 → 1.1.50

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 (93) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/level.js +1 -1
  4. package/dist/agent/loop.js +90 -8
  5. package/dist/agent/oversized-results.js +204 -0
  6. package/dist/agent/sap-connection-adapter.js +2 -0
  7. package/dist/agent/system-prompt.js +2 -2
  8. package/dist/agent/tool-dispatch.js +13 -1
  9. package/dist/agent/turn-error-ux.js +14 -0
  10. package/dist/approvals/op-labels.js +49 -10
  11. package/dist/approvals/render.js +14 -23
  12. package/dist/approvals/risk-floor.js +196 -19
  13. package/dist/approvals/visible-changes.js +35 -0
  14. package/dist/commands/help.js +1 -1
  15. package/dist/commands/plan-continue-gate.js +11 -0
  16. package/dist/commands/team-plan.js +16 -6
  17. package/dist/commands/team.js +295 -68
  18. package/dist/cost/cost-log.js +4 -0
  19. package/dist/cost/system-block.js +25 -0
  20. package/dist/journey/chain.js +489 -61
  21. package/dist/journey/clean-core.js +61 -36
  22. package/dist/journey/flow.js +75 -2
  23. package/dist/journey/people.js +37 -0
  24. package/dist/journey/rebuild.js +7 -6
  25. package/dist/journey/run.js +129 -42
  26. package/dist/journey/trigger.js +16 -10
  27. package/dist/models/server-config.js +13 -0
  28. package/dist/one-shot.js +1 -1
  29. package/dist/project-context/render.js +7 -0
  30. package/dist/projects/clean-core-confirm.js +166 -44
  31. package/dist/projects/extract-cca.js +8 -7
  32. package/dist/projects/index.js +0 -1
  33. package/dist/projects/merge-upgrade.js +2 -2
  34. package/dist/projects/project-folder.js +2 -1
  35. package/dist/projects/save-command.js +51 -14
  36. package/dist/projects/workspace.js +6 -3
  37. package/dist/register/decision-rules.js +2 -1
  38. package/dist/register/export-files.js +18 -0
  39. package/dist/register/picture.js +3 -3
  40. package/dist/register/project-transport.js +125 -0
  41. package/dist/register/records.js +25 -13
  42. package/dist/register/rules-edit.js +5 -0
  43. package/dist/register/status-file/progress-chart.js +17 -6
  44. package/dist/register/status-file/register-map.js +8 -5
  45. package/dist/register/status-file/register-model.js +55 -36
  46. package/dist/register/status-file/register-parts.js +97 -82
  47. package/dist/register/status-file/register-status-file.js +16 -7
  48. package/dist/register/status-file/report-model.js +293 -205
  49. package/dist/register/status-file/team-css.js +1 -1
  50. package/dist/register/status-html.js +11 -4
  51. package/dist/register/store.js +2 -2
  52. package/dist/register/workbooks.js +24 -13
  53. package/dist/renderer/footer-line.js +10 -2
  54. package/dist/renderer/tool-labels.js +2 -2
  55. package/dist/repl/builtin-commands.js +1 -1
  56. package/dist/repl/continue-skill.js +5 -0
  57. package/dist/repl/level-command.js +6 -15
  58. package/dist/repl.js +37 -14
  59. package/dist/router/router-core.js +5 -3
  60. package/dist/session/fresh-start.js +15 -0
  61. package/dist/skill-catalog.js +4 -7
  62. package/dist/tools/_filesystem-shared.js +7 -0
  63. package/dist/tools/_team-work-path.js +53 -0
  64. package/dist/tools/approval.js +97 -19
  65. package/dist/tools/cut-markers.js +47 -0
  66. package/dist/tools/filesystem/extract-document.js +50 -2
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-read.js +2 -2
  69. package/dist/tools/filesystem/file-write.js +8 -1
  70. package/dist/tools/filesystem/read-document.js +20 -4
  71. package/dist/tools/project/_merge-shared.js +2 -2
  72. package/dist/tools/project/_team-tool-shared.js +5 -0
  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/text-elements/tools.js +7 -1
  78. package/dist/tools/tool-loading.js +1 -1
  79. package/dist/tools/tool-output-read.js +5 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +24 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/footer-line.js +19 -1
  84. package/package.json +3 -3
  85. package/dist/projects/merge-cca.js +0 -311
  86. package/dist/tools/project/cca_merge.js +0 -174
  87. package/test-harness/fe-draft/app.ts +0 -188
  88. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  89. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  90. package/test-harness/fe-draft/routes.ts +0 -224
  91. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  92. package/test-harness/fe-draft/server.ts +0 -225
  93. package/test-harness/fe-draft/variants.ts +0 -196
@@ -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
+ }
@@ -63,7 +63,7 @@ export const HELP_GROUPS = [
63
63
  { name: '/ui [auto|ink]', description: fixed('Change the UI mode (applies after a relaunch)') },
64
64
  { name: '/ui look [panels|legacy]', description: fixed('Switch between the panels and legacy looks') },
65
65
  { name: '/style [professional|conversational|humor|blunt]', description: fixed('Change the talking style — words only; findings and warnings never change') },
66
- { name: '/level [developer|learning|functional]', description: fixed('Who CSPeach is talking to — how much it explains, never what it found; for a non-developer also run cspeach config set write_mode advisory-only') },
66
+ { name: '/level [developer|learning|functional]', description: fixed('How CSPeach explains things — never what it finds or what it does') },
67
67
  ] },
68
68
  ];
69
69
  /** Chat lane (spec 2026-10-17 D8): the Session group's /new reads "Fresh start"
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The marker a turn continues from, or null: only an /abap-plan turn that is
3
+ * not already a plan resume, with a plan step in flight. /new clears the
4
+ * marker (session/fresh-start.ts), so "continue" after /new never takes this
5
+ * path.
6
+ */
7
+ export function planContinuationMarker(session, skill, planResume) {
8
+ if (skill !== 'abap-plan' || planResume !== null)
9
+ return null;
10
+ return session.plan_in_flight ?? null;
11
+ }
@@ -11,16 +11,19 @@
11
11
  * Every change is one append-only record in the lead's `plan/` folder, written
12
12
  * whole then renamed. Lanes stay patterns in `lanes.json`; everything per object
13
13
  * is a plan record, and the register folds them on read. Nothing here edits a
14
- * record or reaches SAP, and none of it is reachable from a model tool.
14
+ * record or reaches SAP, and none of it is reachable from a model tool (the
15
+ * lane-move record is written by moveLanes in team.ts, behind the person's card
16
+ * when team_lane_move runs it).
15
17
  */
16
18
  import fs from 'node:fs';
17
19
  import path from 'node:path';
18
20
  import { createHash } from 'node:crypto';
19
- import { buildRegister, DEFAULT_WEIGHTS, inLane, objectKey, proposeLanes, utcWords, } from '@cspeach/register-core';
21
+ import { buildRegister, DEFAULT_WEIGHTS, objectKey, proposeLanes, utcWords, } from '@cspeach/register-core';
20
22
  import { RegisterError, collectRecords, readBaseline, readLanes, readProjectFile, writeLanes, } from '../register/store.js';
21
23
  import { diffPlan, readPlanSheet } from '../register/plan-import.js';
22
24
  import { writeLeadRecord } from '../register/records.js';
23
25
  import { registerFor } from '../register/picture.js';
26
+ import { projectViewFor } from '../register/status-html.js';
24
27
  import { shown, shownUpTo } from '../register/atc-file.js';
25
28
  import { flags, heldReportLine, joinedOrThrow, list, requireLead, whoAmI, writeReports } from './team.js';
26
29
  const MAX_REASON = 300;
@@ -157,11 +160,16 @@ export async function laneProposal(args, deps) {
157
160
  deps.print(` Written to the plan folder: ${files.join(', ')}`);
158
161
  return 0;
159
162
  }
160
- /** `lane add|remove`: the history beside the lanes file. Returns the line to print. */
161
- export function laneChangeRecord(deps, change, before, after, id, reason) {
163
+ /**
164
+ * `lane add|remove|move`: the history beside the lanes file. Returns the line
165
+ * to print. A move also names the packages that moved (`moved`) and the
166
+ * lanes they left (`from`).
167
+ */
168
+ export function laneChangeRecord(deps, change, before, after, id, reason, moved, from) {
162
169
  const lead = asLead(deps);
163
170
  const side = (l) => (l ? { owner: l.owner, packages: l.packages, namePatterns: l.namePatterns } : null);
164
- const file = writePlan(lead, { action: 'lane', lane: { id, change, before: side(before), after: side(after) }, ...(reason ? { reason } : {}) }, [], deps);
171
+ const lane = { id, change, ...(moved ? { moved: [...moved] } : {}), ...(from ? { from: from.map((f) => ({ id: f.id, owner: f.owner })) } : {}), before: side(before), after: side(after) };
172
+ const file = writePlan(lead, { action: 'lane', lane, ...(reason ? { reason } : {}) }, [], deps);
165
173
  return writtenLine(file);
166
174
  }
167
175
  // ── focus ───────────────────────────────────────────────────────────────────
@@ -225,7 +233,9 @@ export function focusCommand(args, deps) {
225
233
  }
226
234
  else {
227
235
  const lane = laneOrThrow(readLanes(lead.projectDir), opts.lane);
228
- keys = register.objects.filter((o) => inLane(o, lane.id)).map((o) => o.key);
236
+ // Owner decision (final review): the lane's OWN objects, the ones its counts cover (the view's lane scope).
237
+ const view = projectViewFor(register, readProjectFile(lead.projectDir));
238
+ keys = (view.lanes.find((l) => l.scope.kind === 'lane' && l.scope.id === lane.id)?.objects ?? []).map((v) => v.key);
229
239
  selector = { lane: lane.id };
230
240
  }
231
241
  if (!keys.length)