@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.
- package/.gitattributes +1 -0
- package/dist/agent/loop.js +127 -31
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +12 -3
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +11 -2
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/generator-fact.js +21 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
package/dist/renderer/notices.js
CHANGED
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
54
|
-
// Live fix 2026-09-26 (panels): never wrap mid-word —
|
|
55
|
-
//
|
|
56
|
-
// session's notice log. Legacy stays
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (cut
|
|
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
|
-
|
|
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
|
-
'
|
|
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) =>
|
|
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',
|
|
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
|
|
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 ✗
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
:
|
|
57
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|