@cspeach/cli 1.1.34 → 1.1.36

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 (47) hide show
  1. package/.gitattributes +1 -0
  2. package/dist/agent/loop.js +127 -31
  3. package/dist/agent/parallel-write-guard.js +3 -1
  4. package/dist/commands/plan-audit-evidence.js +6 -1
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +9 -0
  7. package/dist/lib/graceful-exit.js +17 -0
  8. package/dist/one-shot.js +3 -0
  9. package/dist/projects/answer-blockers.js +25 -12
  10. package/dist/projects/extract-plan.js +6 -0
  11. package/dist/projects/plan-objects.js +1 -1
  12. package/dist/projects/plan-skeleton.js +93 -0
  13. package/dist/projects/save-command.js +17 -0
  14. package/dist/rap/finish-checks.js +389 -0
  15. package/dist/rap/generator-names.js +57 -0
  16. package/dist/rap/skeleton-card.js +31 -0
  17. package/dist/rap/skeleton-rules.js +38 -0
  18. package/dist/rap/skeleton-session.js +21 -0
  19. package/dist/rap/skeleton-store.js +40 -0
  20. package/dist/renderer/change-card.js +28 -4
  21. package/dist/renderer/notices.js +27 -13
  22. package/dist/renderer/progress-chatter.js +3 -1
  23. package/dist/renderer/tool-labels.js +12 -3
  24. package/dist/renderer/verify-chain.js +41 -18
  25. package/dist/renderer/wrap-rows.js +87 -0
  26. package/dist/repl/rule8-detector.js +3 -2
  27. package/dist/repl.js +11 -2
  28. package/dist/sap/connection-manager.js +15 -0
  29. package/dist/sap/generator-fact.js +21 -0
  30. package/dist/sap/session-end.js +66 -0
  31. package/dist/session/audit-export.js +19 -1
  32. package/dist/tools/ask-question.js +1 -1
  33. package/dist/tools/rap/finish-check.js +118 -0
  34. package/dist/tools/rap/generate-objects.js +317 -0
  35. package/dist/tools/rap/skeleton-resolve.js +283 -0
  36. package/dist/tools/sap-read.js +22 -22
  37. package/dist/tools/sap-write.js +249 -18
  38. package/dist/tools/snapshot.js +26 -0
  39. package/dist/tools/tool-loading.js +7 -1
  40. package/dist/tools/write-mode.js +2 -0
  41. package/dist/ui/app.js +19 -9
  42. package/dist/ui/approval-modal.js +37 -5
  43. package/dist/ui/live-area.js +14 -1
  44. package/dist/ui/widgets/ask-card.js +183 -107
  45. package/dist/ui/widgets/coaching-picker.js +22 -1
  46. package/dist/ui/widgets/confirm-card.js +102 -76
  47. package/package.json +2 -2
@@ -36,13 +36,18 @@ import stringWidth from 'string-width';
36
36
  import { getLook } from './look.js';
37
37
  import { getGlyphs } from './glyphs.js';
38
38
  import { truncateToWidth, uiWidth } from './ui-width.js';
39
+ import { wrapRows } from './wrap-rows.js';
40
+ import { paint } from './theme.js';
41
+ import chalk from 'chalk';
39
42
  import { getNoticeLogPath, recordFullNotice } from './notice-log.js';
40
43
  import { getEffectiveBodyWidth, isDeclaredOneShot } from './tty.js';
41
44
  import { EventEmitter } from 'node:events';
45
+ /** Fix batch 2026-10-12 — a panels notice wraps onto at most this many rows. */
46
+ export const NOTICE_MAX_ROWS = 3;
42
47
  /**
43
- * Format one notice as a single terminal line (trailing newline included).
44
- * Multi-line messages are collapsed to one line — notices are glanceable
45
- * margin notes, not documents; anything long belongs in the tool result.
48
+ * Format one notice (trailing newline included). Multi-line messages are
49
+ * collapsed to one line of text; in the panels look that text then wraps
50
+ * onto up to NOTICE_MAX_ROWS screen rows (fix batch 2026-10-12).
46
51
  *
47
52
  * Piece 2 §9: warn → the attention shape ("! message"), info → the info
48
53
  * shape ("· message", dim). Same glyphs as every other notice.
@@ -50,10 +55,11 @@ import { EventEmitter } from 'node:events';
50
55
  export function formatNoticeLine(level, message) {
51
56
  // Live fix 2026-09-26: a SAP HTML error page must never leak tags/CSS here.
52
57
  const readable = looksLikeMarkup(message) ? cleanErrorText(message, 400) : message;
53
- let oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
54
- // Live fix 2026-09-26 (panels): never wrap mid-word — cut to one screen
55
- // line ("<glyph> " + text ≤ uiWidth) and keep the full text in the
56
- // session's notice log. Legacy stays byte-identical.
58
+ const oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
59
+ // Live fix 2026-09-26 (panels): never let the terminal wrap mid-word —
60
+ // the text is pre-wrapped at word breaks inside uiWidth (below) and a cut
61
+ // notice keeps its full text in the session's notice log. Legacy stays
62
+ // byte-identical.
57
63
  // Review I3: only when a screen is there to wrap (stdout is a TTY) or the
58
64
  // full text has somewhere to go (a notice log). One-shot / piped output
59
65
  // keeps the whole single line — nothing else would record it.
@@ -61,15 +67,23 @@ export function formatNoticeLine(level, message) {
61
67
  // keeps the whole line too.
62
68
  const wrapsOnScreen = process.stdout.isTTY === true && !isDeclaredOneShot();
63
69
  if (getLook() === 'panels' && (wrapsOnScreen || getNoticeLogPath() !== undefined)) {
70
+ // Fix batch 2026-10-12 (Task 2): a notice may carry what the user must
71
+ // act on (a transport number, SAP's reason), so it wraps at word breaks
72
+ // onto up to NOTICE_MAX_ROWS rows — further rows indented two spaces, the
73
+ // notice shape — instead of being cut to one. Only a longer notice is
74
+ // cut; its full text goes to the notice log and the screen says so.
64
75
  const g = getGlyphs();
65
76
  const room = Math.min(uiWidth(), getEffectiveBodyWidth()) - stringWidth(level === 'warn' ? g.attention : g.info) - 1;
66
- const cut = truncateToWidth(oneLine, room);
67
- // Review M10: truncateToWidth turns tabs into two spaces — compare against
68
- // the same normalisation so only a real cut is logged.
69
- if (cut !== oneLine.replace(/\t/g, ' ')) {
77
+ const rows = wrapRows(oneLine, room, NOTICE_MAX_ROWS);
78
+ const cut = wrapRows(oneLine, room, NOTICE_MAX_ROWS + 1).length > NOTICE_MAX_ROWS;
79
+ const logPath = getNoticeLogPath();
80
+ if (cut)
70
81
  recordFullNotice(level, message);
71
- oneLine = cut;
72
- }
82
+ const head = level === 'warn' ? formatAttention({ text: rows[0] }) : formatInfo(rows[0]);
83
+ const more = rows.slice(1).map((r) => (level === 'warn' ? ` ${chalk.bold(r)}` : paint('dim', ` ${r}`)));
84
+ if (cut && logPath)
85
+ more.push(paint('dim', truncateToWidth(` Full text: ${logPath}`, room + 2)));
86
+ return head + more.map((r) => `${r}\n`).join('');
73
87
  }
74
88
  return level === 'warn' ? formatAttention({ text: oneLine }) : formatInfo(oneLine);
75
89
  }
@@ -165,7 +165,9 @@ const MESSAGES = {
165
165
  'Walking enhancement spots…',
166
166
  ],
167
167
  sap_message_maintain: [
168
- '(stub — short-circuit)',
168
+ 'Reading the message class…',
169
+ 'Writing the messages…',
170
+ 'Reading the messages back…',
169
171
  ],
170
172
  sap_number_range_intervals: [
171
173
  '(stub — short-circuit)',
@@ -160,6 +160,9 @@ const RULES = {
160
160
  sap_link: (a) => ({ verb: 'SAP link', object: str(a['action']) ?? '' }),
161
161
  cap_link_smoke: (a) => ({ verb: 'S/4 link check', object: str(a['s4']?.['entitySet']) ?? '' }),
162
162
  app_lane_resolve: (a) => ({ verb: 'Chose lane', object: shortLeft(str(a['specPath']) ?? '') }),
163
+ rap_skeleton_resolve: (a) => ({ verb: 'Chose skeleton', object: str(a['table']) ?? '' }),
164
+ sap_generate_objects: () => ({ verb: 'Ran SAP generator', object: 'RAP skeleton' }),
165
+ rap_finish_check: (a) => ({ verb: 'Checked finish', object: str(a['root']) ?? '' }),
163
166
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
164
167
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
165
168
  team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
@@ -177,7 +180,13 @@ const RULES = {
177
180
  sap_unlock: sapObject('Unlocked'),
178
181
  sap_service_binding_publish: sapObject('Published'),
179
182
  sap_service_binding_unpublish: sapObject('Unpublished'),
180
- sap_message_maintain: (a) => ({ verb: 'Message', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) }),
183
+ sap_message_maintain: (a) => {
184
+ const n = Array.isArray(a['messages']) ? a['messages'].length : 0;
185
+ if (n > 0) {
186
+ return { verb: 'Messages', object: shortRight(str(a['messageClass']) ?? ''), detail: n === 1 ? '1 message' : `${n} messages` };
187
+ }
188
+ return { verb: 'Messages', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) };
189
+ },
181
190
  sap_number_range_intervals: (a) => ({ verb: 'Number range', object: shortRight(str(a['nrObject']) ?? '') }),
182
191
  ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
183
192
  todo_set: () => ({ verb: 'Updated todos', object: '' }),
@@ -252,12 +261,12 @@ const PRESENT = {
252
261
  Structure: 'Reading structure of', 'Change log': 'Reading change log of', 'Read dumps': 'Reading dumps',
253
262
  'API state': 'Checking API state of', 'ABAP docs': 'Reading ABAP docs for', 'Read includes': 'Reading includes of',
254
263
  Compared: 'Comparing', 'Previewed change': 'Previewing change to', 'Ran unit tests': 'Running unit tests on',
255
- 'OData test': 'Testing OData', 'Service catalog': 'Reading service catalog', 'Import metadata': 'Importing metadata', 'SAP link': 'SAP link', 'S/4 link check': 'Checking S/4 link', 'Chose lane': 'Choosing build lane', 'Searched BAdIs': 'Searching BAdIs', 'Listed snapshots': 'Listing snapshots',
264
+ 'OData test': 'Testing OData', 'Service catalog': 'Reading service catalog', 'Import metadata': 'Importing metadata', 'SAP link': 'SAP link', 'S/4 link check': 'Checking S/4 link', 'Chose lane': 'Choosing build lane', 'Chose skeleton': 'Choosing RAP skeleton builder', 'Ran SAP generator': 'Running SAP generator', 'Checked finish': 'Checking finish items', 'Searched BAdIs': 'Searching BAdIs', 'Listed snapshots': 'Listing snapshots',
256
265
  'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
257
266
  'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
258
267
  'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
259
268
  'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
260
- Published: 'Publishing', Unpublished: 'Unpublishing', Message: 'Maintaining message',
269
+ Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
261
270
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
262
271
  'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
263
272
  'Searched web': 'Searching web', 'Read project context': 'Reading project context',
@@ -8,13 +8,15 @@
8
8
  * never synthesized. States render truthfully:
9
9
  * - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
10
10
  * nothing to save; NEVER used for other read failures)
11
- * - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
11
+ * - snapshot 'source_read_failed' → "① snapshot – (no source read)" and the
12
+ * reason on its own row under the chain —
12
13
  * the pre-write read failed for a non-404 reason; the object may exist
13
14
  * but there is no recovery copy. Hard-gate tools block instead of
14
15
  * emitting this; it only appears on soft (best-effort) gates.
15
16
  * - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
16
17
  * where the gate is soft, e.g. class includes)
17
- * - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
18
+ * - syntax fail → "③ syntax ✗", the first error whole on the rows under
19
+ * the chain (fix batch 2026-10-12: no 60-character cut) AND no ④ at all —
18
20
  * activation is blocked, so we don't show a fake pending step
19
21
  * - syntax 'not_checked' → "③ syntax – (checked at activation)" —
20
22
  * sap_set_class_include has no standalone syntax gate
@@ -28,21 +30,43 @@
28
30
  * result row in both renderers.
29
31
  */
30
32
  import chalk from 'chalk';
33
+ import { wrapRows } from './wrap-rows.js';
34
+ import { uiWidth } from './ui-width.js';
31
35
  const pass = (s) => `${s} ${chalk.green('✓')}`;
32
36
  const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
33
37
  const skip = (s, why) => chalk.dim(`${s} – (${why})`);
34
- const ERROR_DETAIL_MAX = 60;
35
- const SNAPSHOT_DETAIL_MAX = 40;
36
- const truncate = (s, max) => {
37
- const flat = s.replace(/\s+/g, ' ').trim();
38
- return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
39
- };
38
+ /**
39
+ * Fix batch 2026-10-12 (Task 2): the SAP error is what the user needs, so it
40
+ * is never cut to a few characters. Details go on their own rows under the
41
+ * chain, wrapped at word breaks inside the width, up to these many rows (the
42
+ * last ends with … only when even that is not enough; the model gets the
43
+ * full text in the tool result).
44
+ */
45
+ export const ERROR_DETAIL_ROWS = 4;
46
+ export const SNAPSHOT_DETAIL_ROWS = 2;
47
+ /** Detail rows sit under the chain: its two-space indent plus two. */
48
+ const DETAIL_INDENT = ' ';
49
+ const flatDetail = (s) => s.replace(/\s+/g, ' ').trim();
50
+ /** Detail rows: indented, wrapped inside `width`, at most `maxRows`. */
51
+ export function detailRows(text, width, maxRows) {
52
+ return wrapRows(flatDetail(text), Math.max(1, width - DETAIL_INDENT.length), maxRows).map((r) => `${DETAIL_INDENT}${r}`);
53
+ }
54
+ /**
55
+ * Detail-row width: the UI width less the Ink body's 4-cell border overhead
56
+ * (tty.ts BORDER_OVERHEAD), so a row never reaches the body edge in either
57
+ * renderer. Pure (no tty import): many tool tests mock tty.js partially.
58
+ */
59
+ export function chainWidth() {
60
+ return Math.max(20, uiWidth() - 4);
61
+ }
40
62
  /**
41
63
  * Build the chain line (no trailing newline). Returns the full line
42
- * including the two-space indent that aligns with the ⎿ result row.
64
+ * including the two-space indent that aligns with the ⎿ result row, then
65
+ * any detail rows (snapshot read failure, first syntax error) under it.
43
66
  */
44
- export function buildWriteChainLine(input) {
67
+ export function buildWriteChainLine(input, width = chainWidth()) {
45
68
  const parts = [];
69
+ const details = [];
46
70
  // ① snapshot
47
71
  if (input.snapshot === 'taken')
48
72
  parts.push(pass('① snapshot'));
@@ -51,10 +75,10 @@ export function buildWriteChainLine(input) {
51
75
  else if (input.snapshot === 'source_read_failed') {
52
76
  // Honest: the read failed — we do NOT know the object is new, and there
53
77
  // is no recovery copy. Never render this as "(new object)".
54
- const why = input.snapshotDetail
55
- ? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
56
- : 'no source read';
57
- parts.push(skip('① snapshot', why));
78
+ parts.push(skip('① snapshot', 'no source read'));
79
+ if (input.snapshotDetail) {
80
+ details.push(...detailRows(`No source read: ${input.snapshotDetail}`, width, SNAPSHOT_DETAIL_ROWS).map((r) => chalk.dim(r)));
81
+ }
58
82
  }
59
83
  else
60
84
  parts.push(skip('① snapshot', 'skipped'));
@@ -70,10 +94,9 @@ export function buildWriteChainLine(input) {
70
94
  parts.push(chalk.dim('④ activate pending'));
71
95
  }
72
96
  else {
73
- const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
74
- const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
75
- parts.push(fail('③ syntax', detail));
97
+ parts.push(fail('③ syntax'));
98
+ details.push(...detailRows(input.syntax.errors[0] ?? 'syntax errors', width, ERROR_DETAIL_ROWS).map((r) => chalk.red(r)));
76
99
  // No ④ — activation is blocked; rendering a pending step would lie.
77
100
  }
78
- return ' ' + parts.join(' ');
101
+ return [' ' + parts.join(' '), ...details].join('\n');
79
102
  }
@@ -0,0 +1,87 @@
1
+ // src/renderer/wrap-rows.ts
2
+ //
3
+ // The one wrap helper for card content (fix batch 2026-10-12, Task 2).
4
+ //
5
+ // THE RULE: card content wraps; chrome may cut. Text a person reads to make
6
+ // a decision — card titles, facts, option labels and descriptions, risk and
7
+ // failure reasons, SAP error details — is pre-wrapped here into a KNOWN
8
+ // number of rows. Ink never soft-wraps a live-frame row (its erase
9
+ // accounting counts rows), so callers draw each returned row as its own
10
+ // truncate-end <Text> and count every row in their height estimate. Status
11
+ // rows, hints, palettes, spinners, traces and "… N more" counters stay one
12
+ // row and cut with truncateToWidth.
13
+ //
14
+ // Replaces factRows (confirm-card) and wrapCardText (ask-card), which did the
15
+ // same job twice.
16
+ import stringWidth from 'string-width';
17
+ import { truncateToWidth } from './ui-width.js';
18
+ import { sanitizeControlChars } from './sanitize.js';
19
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
20
+ /** The longest grapheme-whole prefix of `word` that fits `cells` (at least one grapheme). */
21
+ function takeCells(word, cells) {
22
+ let out = '';
23
+ let used = 0;
24
+ for (const { segment } of GRAPHEMES.segment(word)) {
25
+ const w = stringWidth(segment);
26
+ if (used + w > cells && out.length > 0)
27
+ break;
28
+ out += segment;
29
+ used += w;
30
+ if (used >= cells)
31
+ break;
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * `text` as the rows it is drawn on, each at most `cells` terminal cells.
37
+ * Control characters are neutralised, tabs become two spaces and newlines
38
+ * one space. Text that fits is one row, unchanged. Otherwise it wraps at
39
+ * spaces (a word wider than the row is cut where it must be; wide characters
40
+ * count two cells and are never split) onto at most `maxRows` rows; when
41
+ * more is left, the last row ends with the `more` glyph (… / ...). With
42
+ * `maxRows` 1 this is truncateToWidth: one row, cut.
43
+ */
44
+ export function wrapRows(text, cells, maxRows = 1) {
45
+ const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
46
+ const width = Math.max(1, cells);
47
+ const max = Math.max(1, Math.floor(maxRows));
48
+ if (stringWidth(flat) <= width)
49
+ return [flat];
50
+ if (max === 1)
51
+ return [truncateToWidth(flat, width)];
52
+ const indent = /^ */.exec(flat)[0].slice(0, Math.max(0, width - 1));
53
+ const rows = [];
54
+ let cur = indent;
55
+ const curEmpty = () => cur.length === 0 || cur === indent;
56
+ for (let word of flat.trim().split(/ +/).filter((w) => w.length > 0)) {
57
+ while (stringWidth(word) > width) {
58
+ if (!curEmpty()) {
59
+ rows.push(cur);
60
+ cur = '';
61
+ }
62
+ const head = takeCells(word, width - stringWidth(cur));
63
+ rows.push(cur + head);
64
+ cur = '';
65
+ word = word.slice(head.length);
66
+ }
67
+ if (word.length === 0)
68
+ continue;
69
+ if (curEmpty())
70
+ cur += word;
71
+ else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
72
+ cur += ` ${word}`;
73
+ else {
74
+ rows.push(cur);
75
+ cur = word;
76
+ }
77
+ }
78
+ if (!curEmpty() || rows.length === 0)
79
+ rows.push(cur);
80
+ if (rows.length <= max)
81
+ return rows;
82
+ const kept = rows.slice(0, max);
83
+ // The trailing space guarantees a cut (and so the more glyph) even when the
84
+ // joined rest would happen to fit.
85
+ kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
86
+ return kept;
87
+ }
@@ -32,12 +32,13 @@ export function setRule8BatchApprovedProvider(fn) {
32
32
  }
33
33
  /**
34
34
  * True for a tool that cannot run without an approval id from request_approval
35
- * (its schema requires `approval_id`): each such call is already approved one
35
+ * (its schema requires `approval_id`, or `approval_ids` for a multi-object write): each such call is already approved one
36
36
  * by one. Shell, file and other tools without an id are never in this set.
37
37
  */
38
38
  export function toolSpendsApprovalId(tool) {
39
39
  const req = tool.input_schema?.required;
40
- return Array.isArray(req) && req.includes('approval_id');
40
+ // RAP generator final review M4 — sap_generate_objects spends one id per object (`approval_ids`).
41
+ return Array.isArray(req) && (req.includes('approval_id') || req.includes('approval_ids'));
41
42
  }
42
43
  /**
43
44
  * Returns true on the 2nd (and later) mutating call of the turn — the gate
package/dist/repl.js CHANGED
@@ -144,6 +144,7 @@ import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-m
144
144
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
145
145
  import { createRestoreGuard } from './repl/restore-guard.js';
146
146
  import { gracefulExit } from './lib/graceful-exit.js';
147
+ import { endSapSessionAfterTurn } from './sap/session-end.js';
147
148
  import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
148
149
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
149
150
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
@@ -171,6 +172,9 @@ import './tools/cap/metadata-import.js';
171
172
  import './tools/cap/sap-link-tool.js';
172
173
  import './tools/cap/link-smoke.js';
173
174
  import './tools/lane/app-lane-resolve.js';
175
+ import './tools/rap/skeleton-resolve.js';
176
+ import './tools/rap/generate-objects.js';
177
+ import './tools/rap/finish-check.js';
174
178
  import './tools/extend-model/tool.js';
175
179
  import './tools/text-elements/tools.js';
176
180
  import './tools/capability/tool.js';
@@ -1979,7 +1983,7 @@ export async function runRepl(opts) {
1979
1983
  // Task 7 — plan-phase subagent read-only enforcement: set for EVERY
1980
1984
  // plan-resume phase turn regardless of mode (guarded AND step);
1981
1985
  // agent_run consults the flag and forces its read-only tool filter.
1982
- setPlanPhaseActive(true, planPhaseCtx.envelope.id);
1986
+ setPlanPhaseActive(true, planPhaseCtx.envelope.id, planPhaseCtx.path);
1983
1987
  // Task 10 — the writes declaration of the phase EXECUTING this turn
1984
1988
  // (fail-safe: undefined ⇒ true, the deviation backstop never fires).
1985
1989
  // request_approval consults it in guarded mode to deny undeclared
@@ -2118,6 +2122,9 @@ export async function runRepl(opts) {
2118
2122
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
2119
2123
  }
2120
2124
  }
2125
+ // 2026-10-13 — end the stateful SAP session between turns, so a lock
2126
+ // a failed write left behind does not outlive the turn (SM12).
2127
+ await endSapSessionAfterTurn(adt);
2121
2128
  }
2122
2129
  // B3 — persist the plan revision from the resume turn's manifest
2123
2130
  // block (runs even after a turn error: partial assistant content is
@@ -3994,7 +4001,7 @@ export async function runRepl(opts) {
3994
4001
  const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
3995
4002
  // Task 7 — plan-phase subagent read-only enforcement (classic mirror):
3996
4003
  // set for EVERY plan-resume phase turn regardless of mode.
3997
- setPlanPhaseActive(true, planPhaseCtx.envelope.id);
4004
+ setPlanPhaseActive(true, planPhaseCtx.envelope.id, planPhaseCtx.path);
3998
4005
  // Task 10 — executing phase's writes declaration for the deviation
3999
4006
  // backstop + stale-flag discard (classic mirror; see Ink path note).
4000
4007
  setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
@@ -4117,6 +4124,8 @@ export async function runRepl(opts) {
4117
4124
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
4118
4125
  }
4119
4126
  }
4127
+ // 2026-10-13 — end the stateful SAP session between turns (Ink mirror).
4128
+ await endSapSessionAfterTurn(adt);
4120
4129
  }
4121
4130
  // B3 — persist the plan revision from the resume turn's manifest block.
4122
4131
  // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
@@ -3,8 +3,22 @@ import { SapConnection } from '@cspeach/sap-client';
3
3
  import { resolveSapTls } from '../config/loader.js';
4
4
  import { password as promptPassword } from '@inquirer/prompts';
5
5
  import { isHeadless } from '../renderer/tty.js';
6
+ import { registerExitTask } from '../lib/graceful-exit.js';
7
+ import { endSapSessions } from './session-end.js';
6
8
  const SAP_SERVICE = 'cspeach-sap';
7
9
  const cache = new Map();
10
+ let exitTaskRegistered = false;
11
+ /**
12
+ * 2026-10-13 — on exit, end the stateful SAP session of every connection this
13
+ * process opened, so locks left in it leave SM12 now instead of when SAP times
14
+ * the session out. Registered once, on the first connection.
15
+ */
16
+ function registerSessionEndOnExit() {
17
+ if (exitTaskRegistered)
18
+ return;
19
+ exitTaskRegistered = true;
20
+ registerExitTask(() => endSapSessions(cache.values()));
21
+ }
8
22
  /**
9
23
  * The single place that reads an SAP alias password: keychain first, then a
10
24
  * masked prompt when interactive. Headless callers (and the SAP link, which
@@ -69,6 +83,7 @@ export async function getConnection(alias, cfg) {
69
83
  // our existing cookies belong to a different one.
70
84
  await conn.login();
71
85
  cache.set(alias, conn);
86
+ registerSessionEndOnExit();
72
87
  return conn;
73
88
  }
74
89
  export function clearConnectionCache() {
@@ -0,0 +1,21 @@
1
+ const cache = new Map();
2
+ export async function getGeneratorFact(adt, alias) {
3
+ const hit = cache.get(alias);
4
+ if (hit)
5
+ return hit;
6
+ let r;
7
+ try {
8
+ r = await adt.hasRepositoryGenerators();
9
+ }
10
+ catch {
11
+ return 'unknown';
12
+ }
13
+ if (r === null)
14
+ return 'unknown';
15
+ const fact = r ? 'yes' : 'no';
16
+ cache.set(alias, fact);
17
+ return fact;
18
+ }
19
+ export function resetGeneratorFactCache() {
20
+ cache.clear();
21
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Ending CSPeach's stateful SAP session (live finding 2026-10-13).
3
+ *
4
+ * Lock chains open a stateful ADT session on SAP. Nothing ended it, so any
5
+ * lock left inside it — after a failed write, or from a unit-test run — stayed
6
+ * in SM12 until SAP timed the session out, even after the CLI exited.
7
+ *
8
+ * When it is ended:
9
+ * - after every TOP-LEVEL turn (the REPL), so a lock left by a failed write
10
+ * never outlives the turn — except while a manual `sap_lock` is held,
11
+ * because ending the session would silently drop that lock;
12
+ * - on exit, for every connection, always (the process is going away).
13
+ *
14
+ * Both are capped so a slow or unreachable SAP never holds up the prompt or
15
+ * the exit, and neither ever throws. The next lock chain simply opens a fresh
16
+ * session (sap-client forgets the context cookie when it ends one).
17
+ */
18
+ /** Longest we wait for SAP to confirm the session ended. */
19
+ export const END_SESSION_CAP_MS = 3_000;
20
+ let manualLocks = 0;
21
+ /** A `sap_lock` call returned a handle. */
22
+ export function noteManualLock() {
23
+ manualLocks += 1;
24
+ }
25
+ /** A `sap_unlock` call ran. */
26
+ export function noteManualUnlock() {
27
+ manualLocks = Math.max(0, manualLocks - 1);
28
+ }
29
+ export function __resetManualLocksForTests() {
30
+ manualLocks = 0;
31
+ }
32
+ function capped(p, onTimeout) {
33
+ let timer;
34
+ return Promise.race([
35
+ p,
36
+ new Promise((r) => { timer = setTimeout(() => r(onTimeout), END_SESSION_CAP_MS); }),
37
+ ]).finally(() => { if (timer)
38
+ clearTimeout(timer); });
39
+ }
40
+ /** After a top-level turn: end the session unless a manual lock is still held. */
41
+ export async function endSapSessionAfterTurn(client) {
42
+ // Duck-typed: the offline client and an older sap-client have no endSession.
43
+ const adt = client;
44
+ if (!adt || typeof adt.endSession !== 'function')
45
+ return { ended: false, skipped: 'no_client' };
46
+ if (manualLocks > 0)
47
+ return { ended: false, skipped: 'manual_lock' };
48
+ try {
49
+ return await capped(adt.endSession(), { ended: false, error: 'timeout' });
50
+ }
51
+ catch (e) {
52
+ return { ended: false, error: e instanceof Error ? e.message : String(e) };
53
+ }
54
+ }
55
+ /** On exit: end the session of every connection, capped as a whole. */
56
+ export async function endSapSessions(conns) {
57
+ const all = [...conns].map(async (c) => {
58
+ if (typeof c.endStatefulSession !== 'function')
59
+ return;
60
+ try {
61
+ await c.endStatefulSession();
62
+ }
63
+ catch { /* best effort on the way out */ }
64
+ });
65
+ await capped(Promise.all(all).then(() => undefined), undefined);
66
+ }
@@ -59,6 +59,9 @@ const WRITE_TOOLS = new Set([
59
59
  // wrong silence.
60
60
  'sap_transport_create',
61
61
  'sap_transport_release',
62
+ // Final review I2 — SAP's RAP generator (tools/rap/generate-objects.ts) creates
63
+ // the skeleton objects in one call; its row names them.
64
+ 'sap_generate_objects',
62
65
  ]);
63
66
  /**
64
67
  * Non-mutating tools that are interaction/plumbing, not object reads — excluded
@@ -189,6 +192,19 @@ function writeObject(tool, args, result) {
189
192
  const inc = str(args.includeType);
190
193
  return inc ? `${cls} (${inc})` : cls;
191
194
  }
195
+ case 'sap_generate_objects': {
196
+ // The generated objects live in the RESULT; a failed call names its decision file.
197
+ let r = {};
198
+ try {
199
+ r = asRecord(JSON.parse(result ?? ''));
200
+ }
201
+ catch { /* r stays {} */ }
202
+ const names = Array.isArray(r.objects) ? r.objects.map((o) => str(asRecord(o).name)).filter(Boolean) : [];
203
+ if (names.length)
204
+ return names.join(', ');
205
+ const d = str(args.decisionPath);
206
+ return d ? `RAP skeleton (${d})` : 'RAP skeleton';
207
+ }
192
208
  case 'sap_activate': {
193
209
  if (Array.isArray(args.objects)) {
194
210
  const names = args.objects.map((o) => str(asRecord(o).name) ?? '?');
@@ -362,7 +378,9 @@ export function buildAuditReport(input) {
362
378
  L('| --- | --- | --- | :---: | --- | --- |');
363
379
  for (const c of writes) {
364
380
  const args = asRecord(c.args);
365
- const approvalId = str(args.approval_id);
381
+ // sap_generate_objects carries one approval per object (approval_ids); pair the first.
382
+ const approvalId = str(args.approval_id)
383
+ ?? (Array.isArray(args.approval_ids) ? str(args.approval_ids[0]) : undefined);
366
384
  const rec = findApproval(approvalId, approvals);
367
385
  const key = snapshotKey(c.tool, args);
368
386
  const snap = key ? newestSnapshotBefore(snapIndex.get(key), Date.parse(tsOf(c))) : undefined;
@@ -251,7 +251,7 @@ registerTool({
251
251
  },
252
252
  multiSelect: {
253
253
  type: 'boolean',
254
- description: 'true = the user may pick several options; false/omitted = pick exactly one.',
254
+ description: 'true = the user may pick several options; false/omitted = pick exactly one. When the user ticks options and also types an answer, it comes back as free text (custom: true): the ticked labels, then the typed text, comma-separated.',
255
255
  },
256
256
  options: {
257
257
  type: 'array',