@cspeach/cli 1.1.14 → 1.1.16

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 (38) hide show
  1. package/README.md +63 -0
  2. package/dist/agent/loop.js +32 -7
  3. package/dist/agent/tool-dispatch.js +13 -9
  4. package/dist/cli.js +81 -27
  5. package/dist/commands/compact.js +6 -2
  6. package/dist/commands/project-context-impact.js +2 -2
  7. package/dist/commands/project.js +301 -0
  8. package/dist/commands/team.js +1166 -0
  9. package/dist/one-shot.js +2 -0
  10. package/dist/projects/image-attachments.js +155 -0
  11. package/dist/projects/save-command.js +20 -0
  12. package/dist/register/atc-file.js +802 -0
  13. package/dist/register/baseline.js +106 -0
  14. package/dist/register/measure.js +380 -0
  15. package/dist/register/records.js +159 -0
  16. package/dist/register/store.js +227 -0
  17. package/dist/repl/post-turn-status.js +2 -1
  18. package/dist/repl.js +16 -2
  19. package/dist/rewind/candidates.js +14 -1
  20. package/dist/rewind/restore.js +53 -19
  21. package/dist/router/classifier.js +24 -4
  22. package/dist/session/recap.js +8 -5
  23. package/dist/session/resume.js +37 -17
  24. package/dist/session/store.js +5 -3
  25. package/dist/session/user-prompt.js +23 -0
  26. package/dist/skill-catalog.js +33 -0
  27. package/dist/skills/bundled-skills.js +1 -1
  28. package/dist/tools/filesystem/extract-document.js +6 -0
  29. package/dist/tools/filesystem/extract-xlsx.js +139 -0
  30. package/dist/tools/filesystem/file-write.js +20 -0
  31. package/dist/tools/filesystem/read-document.js +15 -6
  32. package/dist/tools/include-snapshot.js +24 -0
  33. package/dist/tools/sap-write.js +19 -9
  34. package/dist/tools/snapshot.js +27 -0
  35. package/dist/ui/clipboard-image.js +125 -0
  36. package/dist/ui/footer.js +11 -1
  37. package/dist/ui/text-input.js +30 -2
  38. package/package.json +7 -4
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The prompt text of a user message: string content as-is, or the joined
3
+ * text blocks of array content that carries no tool_result. null for
4
+ * tool_result rounds, text-less arrays and non-user messages.
5
+ */
6
+ export function userPromptText(m) {
7
+ if (m.role !== 'user')
8
+ return null;
9
+ if (typeof m.content === 'string')
10
+ return m.content;
11
+ if (!Array.isArray(m.content))
12
+ return null;
13
+ const blocks = m.content;
14
+ if (blocks.some((b) => b?.type === 'tool_result'))
15
+ return null;
16
+ const texts = blocks
17
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
18
+ .map((b) => b.text);
19
+ return texts.length > 0 ? texts.join('\n') : null;
20
+ }
21
+ export function isUserPrompt(m) {
22
+ return userPromptText(m) !== null;
23
+ }
@@ -19,36 +19,42 @@ export const SKILL_CATALOG = [
19
19
  category: 'Read / Analyze',
20
20
  description: 'Impact analysis before changes — traces usage, callers, transports',
21
21
  whenToUse: 'pick this for blast radius before a change (not /abap-explain — explain = teach the code)',
22
+ asks: 'which object? e.g. "ZCL_ORDER_UTILS" or "what breaks if I change ZSD_ORDER_RELEASE"',
22
23
  },
23
24
  {
24
25
  name: 'abap-explain',
25
26
  category: 'Read / Analyze',
26
27
  description: 'Understand an object — plain-language (default) or `technical` orientation (deps, tables, risks)',
27
28
  whenToUse: `plain teaching, or technical=fast orientation (not /abap-impact = blast radius)`,
29
+ asks: 'which object, plain or technical? e.g. "ZFI_DUNNING_SELECT in plain words"',
28
30
  },
29
31
  {
30
32
  name: 'abap-review',
31
33
  category: 'Read / Analyze',
32
34
  description: 'Read-only review across lenses: quality (default), performance, cloud-readiness',
33
35
  whenToUse: `grade source: quality (default) + performance/cloud lenses`,
36
+ asks: 'which object, and which lens? e.g. "ZCL_CREDIT_SVC for performance"',
34
37
  },
35
38
  {
36
39
  name: 'abap-clean-core',
37
40
  category: 'Read / Analyze',
38
41
  description: 'Clean-core decision & defense — recommend the clean-core-correct approach, classify the level (A/B/C/D), produce citable evidence; verifies live when connected. Read-only.',
39
42
  whenToUse: `pick this for "is this clean-core-OK?" decisions + defendable evidence (not /abap-review cloud lens — that grades code)`,
43
+ asks: 'which object or requirement? e.g. "can I add a field to VBAK the clean way"',
40
44
  },
41
45
  {
42
46
  name: 'abap-handover',
43
47
  category: 'Read / Analyze',
44
48
  description: 'Documentation for one object (`object` scope) or a whole package (`package` scope)',
45
49
  whenToUse: `object doc OR full package handover dossier`,
50
+ asks: 'which object or package? e.g. "package ZTEST_LAS, full handover"',
46
51
  },
47
52
  {
48
53
  name: 'abap-dump',
49
54
  category: 'Read / Analyze',
50
55
  description: 'Diagnose ABAP short dumps (ST22) and fix them — root cause, call stack, fix with approval; ask for "triage" for ranked read-only hypotheses',
51
56
  whenToUse: `pick this for any ST22 dump — diagnose + fix by default, say "triage" for read-only ranking (not /abap-incident — that's the full dump-to-transport pipeline)`,
57
+ asks: 'which dump? e.g. "the dump in ZFIN_DOC_JOURNAL this morning"',
52
58
  },
53
59
  // ── Modify / Fix ─────────────────────────────────────────────────────────
54
60
  {
@@ -56,48 +62,56 @@ export const SKILL_CATALOG = [
56
62
  category: 'Modify / Fix',
57
63
  description: 'Run ATC checks against an object and auto-fix findings with safety gates',
58
64
  whenToUse: `pick this for ad-hoc ATC fix on one object (not /abap-upgrade-fix — that's worklist-driven)`,
65
+ asks: 'which object or package? e.g. "ZCL_ORDER_UTILS, fix what is safe"',
59
66
  },
60
67
  {
61
68
  name: 'abap-refactor',
62
69
  category: 'Modify / Fix',
63
70
  description: 'Refactor a Z object in place — FORM→method, SELECT *→specific fields, guard clauses, inline DATA. Same architecture, cleaner code.',
64
71
  whenToUse: `pick this to clean up YOUR Z code, same architecture (not /abap-modernize — that rebuilds as RAP/Fiori)`,
72
+ asks: 'which object and what change? e.g. "ZLEGACY_FI_REPORT — add a net amount column"',
65
73
  },
66
74
  {
67
75
  name: 'abap-enhance',
68
76
  category: 'Modify / Fix',
69
77
  description: 'Extend SAP standard via BAdI implementations, enhancement spots, user exits',
70
78
  whenToUse: `pick this to hook into SAP-STANDARD code via BAdI/exit (not /abap-refactor — that's for YOUR Z code)`,
79
+ asks: 'which SAP object and what behaviour? e.g. "block goods issue when the credit check fails"',
71
80
  },
72
81
  {
73
82
  name: 'abap-extend-model',
74
83
  category: 'Modify / Fix',
75
84
  description: 'Add a field or @UI column to an EXISTING custom CDS view / DDLX metadata extension in place — anchored insertion, snapshot, syntax-check, activate, republish-if-needed. The Fiori Elements column-add.',
76
85
  whenToUse: `pick this to add a field/column to an EXISTING custom CDS/DDLX in place (not /abap-rap — that's a new stack; not /abap-refactor — that's ABAP code, not CDS)`,
86
+ asks: 'which view or BO, and what to add? e.g. "add ShippingPoint to the ZC_Delivery list"',
77
87
  },
78
88
  {
79
89
  name: 'abap-incident',
80
90
  category: 'Modify / Fix',
81
91
  description: 'End-to-end incident pipeline — dump to fix deployed to transport in one skill',
82
92
  whenToUse: `pick this for full ST22-to-transport pipeline with formal report (not /abap-dump — single fix only)`,
93
+ asks: 'the incident. e.g. "dump in ZOPEN_ORDER_REPORT, users blocked"',
83
94
  },
84
95
  {
85
96
  name: 'abap-test',
86
97
  category: 'Modify / Fix',
87
98
  description: 'Generate ABAP Unit test classes for existing code with dependency injection',
88
99
  whenToUse: `pick this to generate + run ABAP Unit tests (not /abap-eml — EML body only)`,
100
+ asks: 'which class? e.g. "unit tests for ZCL_CREDIT_SVC"',
89
101
  },
90
102
  {
91
103
  name: 'abap-eml',
92
104
  category: 'Modify / Fix',
93
105
  description: 'Write and test EML (Entity Manipulation Language) for RAP business objects',
94
106
  whenToUse: `pick this for EML inside a RAP context (not /abap-test — that's the test class)`,
107
+ asks: 'what the EML should do. e.g. "create a draft inspection and activate it"',
95
108
  },
96
109
  {
97
110
  name: 'abap-segw',
98
111
  category: 'Modify / Fix',
99
112
  description: 'Implement SEGW OData DPC_EXT method bodies — reads generated MPC/DPC base classes. SEGW project itself created in transaction SEGW. For S/4 1909+ RAP services use /abap-rap.',
100
113
  whenToUse: `pick this for DPC_EXT method bodies on an EXISTING SEGW project (ECC / older S/4; not /abap-rap — that's S/4 1909+ RAP; SEGW project + MPC/DPC must already exist)`,
114
+ asks: 'which project and operation? e.g. "GET_ENTITYSET for ZORDER_SRV"',
101
115
  },
102
116
  // ── Create / Generate ────────────────────────────────────────────────────
103
117
  {
@@ -105,30 +119,35 @@ export const SKILL_CATALOG = [
105
119
  category: 'Create / Generate',
106
120
  description: 'Generate ONE ABAP class / program / include from a clear scope — asks clarifying questions first per Forge Rule 1. For a full RAP stack use /abap-rap; for DDIC only use /abap-data-model.',
107
121
  whenToUse: `pick this for ONE class/program/include from clear scope (not /abap-rap — that's a full RAP stack; not /abap-data-model — that's DDIC only)`,
122
+ asks: 'what to build, or the scope. e.g. "all changes" after a design, or "a report of open orders by plant"',
108
123
  },
109
124
  {
110
125
  name: 'abap-rap',
111
126
  category: 'Create / Generate',
112
127
  description: 'Scaffold a complete RAP stack on S/4HANA 1909+ — table, CDS interface, projection, BDEF, BP class, SRVD, SRVB, DDLX, DCL. ECC/older S/4 → use /abap-segw.',
113
128
  whenToUse: `pick this for a full RAP stack on S/4 1909+ (10 objects, table → SRVB; not /abap-segw — that's ECC/older S/4 OData; not /abap-generate — that's one object)`,
129
+ asks: 'the business object. e.g. "maintenance requests with draft and an approve action"',
114
130
  },
115
131
  {
116
132
  name: 'abap-fiori-build',
117
133
  category: 'Create / Generate',
118
134
  description: 'Build a working freestyle SAPUI5 app on the laptop from a published OData service — scaffold, pages, dialogs, chart, value help, local preview. Freestyle only, not Fiori Elements.',
119
135
  whenToUse: `pick this for a freestyle UI5 frontend on a published OData service (not /abap-rap — that's the backend; produces local files, not ADT objects)`,
136
+ asks: 'which service and what UI? e.g. "list + object page on ZUI_LAS_INSPECTIONLOG_O4"',
120
137
  },
121
138
  {
122
139
  name: 'abap-data-model',
123
140
  category: 'Create / Generate',
124
141
  description: 'Design and create DDIC objects — domains, data elements, tables, structures',
125
142
  whenToUse: `pick this for DDIC ONLY — domain/data element/table/structure (step after /abap-design; embedded inside /abap-rap step 1 — call standalone when you don't need the rest of the stack)`,
143
+ asks: 'the tables and fields. e.g. "inspection results, header and items"',
126
144
  },
127
145
  {
128
146
  name: 'abap-modernize',
129
147
  category: 'Create / Generate',
130
148
  description: 'Transform classic ABAP to the best modern architecture for the target release (ECC→SEGW, S/4→RAP)',
131
149
  whenToUse: `pick this to rebuild a WRITE/dynpro/FM as Fiori/RAP (not /abap-upgrade-fix — that's syntax patches)`,
150
+ asks: 'which program? e.g. "ZLEGACY_SD_PROCESS onto a modern stack"',
132
151
  },
133
152
  // ── Upgrade / Migrate ────────────────────────────────────────────────────
134
153
  {
@@ -136,42 +155,49 @@ export const SKILL_CATALOG = [
136
155
  category: 'Upgrade / Migrate',
137
156
  description: 'Custom code analysis pipeline for S/4 upgrade — whole-estate inventory + tier classification + ATC findings + report. Multi-session project with state file.',
138
157
  whenToUse: `pick this to discover + classify the whole custom estate (not /abap-migrate — that's per object)`,
158
+ asks: 'which package or namespace? e.g. "Z*" or "package ZTEST_LAS"',
139
159
  },
140
160
  {
141
161
  name: 'abap-upgrade-scan',
142
162
  category: 'Upgrade / Migrate',
143
163
  description: 'Scan custom ABAP code for S/4HANA upgrade findings via ATC readiness variant',
144
164
  whenToUse: 'pick this to baseline ATC for one package + S/4 release (step 1 of 3 — feeds /abap-upgrade-fix)',
165
+ asks: 'the scope. e.g. "package ZTEST_LAS" or "Z* with the S/4 readiness variant"',
145
166
  },
146
167
  {
147
168
  name: 'abap-upgrade-fix',
148
169
  category: 'Upgrade / Migrate',
149
170
  description: 'AI-guided upgrade remediation — fixes ATC findings one object at a time with approval gates',
150
171
  whenToUse: 'pick this to fix the upgrade-scan baseline object-by-object (step 2 of 3 — needs /abap-upgrade-scan first)',
172
+ asks: 'which findings. e.g. "@upgrade-progress" or "the API findings in ZCL_ORDER_UTILS"',
151
173
  },
152
174
  {
153
175
  name: 'abap-upgrade-verify',
154
176
  category: 'Upgrade / Migrate',
155
177
  description: 'Verify remediation results — re-runs ATC with same variant, compares before/after finding counts',
156
178
  whenToUse: 'pick this for the customer sign-off report after fixing (step 3 of 3 — re-runs ATC vs baseline)',
179
+ asks: 'which scan to verify against. e.g. "@upgrade-progress from the first scan"',
157
180
  },
158
181
  {
159
182
  name: 'abap-upgrade-merge',
160
183
  category: 'Upgrade / Migrate',
161
184
  description: 'Reconcile multiple parallel /abap-upgrade-fix progress files into one consolidated upgrade-progress',
162
185
  whenToUse: 'pick this when a multi-dev team ran scoped fix slices in parallel — merges N progress files for /abap-upgrade-verify',
186
+ asks: 'which progress files. e.g. "merge the three @upgrade-progress files here"',
163
187
  },
164
188
  {
165
189
  name: 'abap-cca-merge',
166
190
  category: 'Upgrade / Migrate',
167
191
  description: 'Reconcile multiple parallel /abap-cca assessment files into one consolidated cca-assessment',
168
192
  whenToUse: 'pick this when multiple consultants ran scoped /abap-cca slices — merges N assessments into one team view',
193
+ asks: 'which assessments. e.g. "merge the ZFI and ZSD cca files"',
169
194
  },
170
195
  {
171
196
  name: 'abap-migrate',
172
197
  category: 'Upgrade / Migrate',
173
198
  description: 'ECC to S/4HANA migration assessment — API analysis, clean-core alignment, migration path',
174
199
  whenToUse: `pick this to pick retire/replace/refactor per object (not /abap-cca — cca discovers them)`,
200
+ asks: 'which objects. e.g. "package ZLEGACY, ECC to S/4"',
175
201
  },
176
202
  // ── Ship / Release ───────────────────────────────────────────────────────
177
203
  {
@@ -179,18 +205,21 @@ export const SKILL_CATALOG = [
179
205
  category: 'Ship / Release',
180
206
  description: 'Transport management — create, list, validate, release transport requests',
181
207
  whenToUse: `pick this to CREATE/LIST/RELEASE a TR (inspect-only? use /abap-transport-analysis)`,
208
+ asks: 'what to do. e.g. "create a transport for this session" or "release S4HK903449"',
182
209
  },
183
210
  {
184
211
  name: 'abap-transport-analysis',
185
212
  category: 'Ship / Release',
186
213
  description: 'Pre-release transport analysis — objects, ATC scan, activation status, conflict check',
187
214
  whenToUse: `pick this if you have a TR NUMBER and want a read-only safety scan`,
215
+ asks: 'which transport. e.g. "S4HK903449 before it goes to QA"',
188
216
  },
189
217
  {
190
218
  name: 'abap-preflight',
191
219
  category: 'Ship / Release',
192
220
  description: 'Pre-release checklist — ATC, syntax, inactive objects, transport conflicts',
193
221
  whenToUse: `pick this for a full ship-readiness package on a CHANGE SET (no TR# needed)`,
222
+ asks: 'what is going out. e.g. "the objects in S4HK903449"',
194
223
  },
195
224
  // ── Pre-Coding ───────────────────────────────────────────────────────────
196
225
  {
@@ -198,24 +227,28 @@ export const SKILL_CATALOG = [
198
227
  category: 'Pre-Coding',
199
228
  description: 'Find missing requirements before coding — business questions, technical gaps, assumptions',
200
229
  whenToUse: `pick this for missing business + technical questions on a vague spec (step 1 of 3 — feeds /abap-design)`,
230
+ asks: 'the requirement. e.g. paste the ticket text, or "@spec.pdf"',
201
231
  },
202
232
  {
203
233
  name: 'abap-design',
204
234
  category: 'Pre-Coding',
205
235
  description: 'Solution architecture — object list, dependency sequence, patterns, transport strategy. Plans the build, does not generate code.',
206
236
  whenToUse: `pick this for the object list + dependency sequence + pattern choices (step 2 of 3 — needs /abap-spec-gap done, feeds /abap-estimate or /abap-generate)`,
237
+ asks: 'the requirement, once the gaps are closed. e.g. "the inspection log from the spec above"',
207
238
  },
208
239
  {
209
240
  name: 'abap-estimate',
210
241
  category: 'Pre-Coding',
211
242
  description: 'Effort estimation for ABAP tickets — component breakdown with hour ranges (optimistic / realistic / pessimistic) + risk adjustments',
212
243
  whenToUse: `pick this for hour ranges + risk adjustments on a confirmed object list (step 3 of 3 — needs /abap-design first; high uncertainty without it)`,
244
+ asks: 'what to estimate. e.g. "the objects from the design above" or "all changes in this ticket"',
213
245
  },
214
246
  {
215
247
  name: 'abap-plan',
216
248
  category: 'Pre-Coding',
217
249
  description: 'Multi-session project plan — phase envelope holds state across sessions; create from a goal or spec-gap file, resume one bounded phase per session',
218
250
  whenToUse: `pick this when the work spans multiple sessions or components (not /abap-design — that's one design in one session; plan calls design per phase)`,
251
+ asks: 'the goal, or a plan to resume. e.g. "build the inspection log end to end"',
219
252
  },
220
253
  ];
221
254
  /**
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-09-17T22:46:40.872Z
3
+ // Generated: 2026-09-24T14:23:23.324Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-atc-fix": {
@@ -3,6 +3,7 @@
3
3
  import { extractText, getDocumentProxy } from 'unpdf';
4
4
  import mammoth from 'mammoth';
5
5
  import TurndownService from 'turndown';
6
+ import { extractXlsx } from './extract-xlsx.js';
6
7
  /** Thrown when a PDF exceeds the caller's page ceiling — checked before full extraction. */
7
8
  export class DocumentTooLargeError extends Error {
8
9
  pages;
@@ -20,6 +21,11 @@ export async function extractDocument(buffer, ext, opts) {
20
21
  return extractPdf(buffer, opts?.maxPages);
21
22
  if (ext === '.docx')
22
23
  return extractDocx(buffer);
24
+ // 2026-09-23 — specs and object lists arrive as spreadsheets (Glenn).
25
+ if (ext === '.xlsx') {
26
+ const x = extractXlsx(buffer);
27
+ return { text: x.text, sheets: x.sheets, warnings: x.warnings };
28
+ }
23
29
  throw new Error(`extractDocument: unsupported extension "${ext}"`);
24
30
  }
25
31
  async function extractPdf(buffer, maxPages) {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * .xlsx text extraction (2026-09-23 — Glenn: "it cannot read the excel file").
3
+ *
4
+ * Specs and object lists arrive as spreadsheets, so read_document reads them
5
+ * like any other document. An .xlsx IS a zip of XML parts, and adm-zip already
6
+ * ships inside the tree (SAP's own @sap-ux packages pull it), so nothing new
7
+ * enters the product to do this.
8
+ *
9
+ * Output shape: one "## Sheet: <name>" heading per sheet, then one line per
10
+ * row, cells separated by tabs, empty trailing cells dropped and empty leading
11
+ * ones kept so columns still line up. A model reads that as a table; a human
12
+ * reading the transcript can too.
13
+ *
14
+ * Deliberately NOT done: formulas (the cached VALUE is used, which is what the
15
+ * sheet shows), formatting, merged-cell geometry, charts, images. Dates come
16
+ * out as the serial number Excel stores unless the cell is text — the caller is
17
+ * told, rather than us guessing a locale.
18
+ */
19
+ import AdmZip from 'adm-zip';
20
+ /** Hard ceilings so one huge workbook cannot fill a turn. */
21
+ export const MAX_SHEET_ROWS = 2000;
22
+ export const MAX_CELLS = 100_000;
23
+ const decode = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
24
+ .replace(/&apos;/g, "'").replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
25
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
26
+ .replace(/&amp;/g, '&'); // last: an encoded "&amp;lt;" must not become "<"
27
+ /** All <t> text inside one shared-string <si>, joined (rich text is split across runs). */
28
+ function siText(si) {
29
+ const parts = [...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => decode(m[1]));
30
+ return parts.join('');
31
+ }
32
+ export function sharedStrings(xml) {
33
+ if (!xml)
34
+ return [];
35
+ return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => siText(m[1]));
36
+ }
37
+ /** Column letters → 0-based index: A→0, B→1, AA→26. */
38
+ export function colIndex(ref) {
39
+ const letters = /^([A-Z]+)/.exec(ref.toUpperCase())?.[1] ?? 'A';
40
+ let n = 0;
41
+ for (const ch of letters)
42
+ n = n * 26 + (ch.charCodeAt(0) - 64);
43
+ return n - 1;
44
+ }
45
+ /** One worksheet's cells as a rectangular grid of strings. */
46
+ export function sheetRows(xml, strings, budget) {
47
+ const rows = [];
48
+ for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
49
+ if (rows.length >= MAX_SHEET_ROWS || budget.cells <= 0)
50
+ break;
51
+ const cells = [];
52
+ for (const cell of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g)) {
53
+ const attrs = cell[1] ?? cell[3] ?? '';
54
+ const body = cell[2] ?? '';
55
+ const at = colIndex(/r="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? 'A1');
56
+ const type = /t="([^"]+)"/.exec(attrs)?.[1];
57
+ let value = '';
58
+ if (type === 's') {
59
+ const idx = Number(/<v>([\s\S]*?)<\/v>/.exec(body)?.[1] ?? '-1');
60
+ value = strings[idx] ?? '';
61
+ }
62
+ else if (type === 'inlineStr') {
63
+ value = siText(body);
64
+ }
65
+ else {
66
+ // n (number), str (formula result), b, d, e — the cached value as shown.
67
+ value = decode(/<v>([\s\S]*?)<\/v>/.exec(body)?.[1] ?? '');
68
+ }
69
+ while (cells.length < at)
70
+ cells.push('');
71
+ cells[at] = value.replace(/[\t\r\n]+/g, ' ').trim();
72
+ budget.cells -= 1;
73
+ if (budget.cells <= 0)
74
+ break;
75
+ }
76
+ while (cells.length > 0 && cells[cells.length - 1] === '')
77
+ cells.pop();
78
+ rows.push(cells);
79
+ }
80
+ while (rows.length > 0 && rows[rows.length - 1].length === 0)
81
+ rows.pop();
82
+ return rows;
83
+ }
84
+ /** Sheet name → part path, in the workbook's own order. */
85
+ export function sheetOrder(workbookXml, relsXml) {
86
+ if (!workbookXml)
87
+ return [];
88
+ const rels = new Map();
89
+ for (const m of (relsXml ?? '').matchAll(/<Relationship\b([^>]*)\/>/g)) {
90
+ const id = /Id="([^"]+)"/.exec(m[1])?.[1];
91
+ const target = /Target="([^"]+)"/.exec(m[1])?.[1];
92
+ if (id && target)
93
+ rels.set(id, target.replace(/^\/?xl\//, '').replace(/^\//, ''));
94
+ }
95
+ const out = [];
96
+ for (const m of workbookXml.matchAll(/<sheet\b([^>]*)\/>/g)) {
97
+ const attrs = m[1];
98
+ const name = decode(/name="([^"]*)"/.exec(attrs)?.[1] ?? `Sheet${out.length + 1}`);
99
+ const rid = /r:id="([^"]+)"/.exec(attrs)?.[1] ?? '';
100
+ out.push({ name, path: `xl/${rels.get(rid) ?? `worksheets/sheet${out.length + 1}.xml`}` });
101
+ }
102
+ return out;
103
+ }
104
+ export function extractXlsx(buffer) {
105
+ let zip;
106
+ try {
107
+ zip = new AdmZip(buffer);
108
+ }
109
+ catch {
110
+ return { text: '', sheets: 0, warnings: ['not a readable .xlsx (the file may be .xls, password-protected or damaged)'] };
111
+ }
112
+ const read = (p) => {
113
+ const entry = zip.getEntry(p);
114
+ return entry ? entry.getData().toString('utf-8') : null;
115
+ };
116
+ const strings = sharedStrings(read('xl/sharedStrings.xml'));
117
+ const sheets = sheetOrder(read('xl/workbook.xml'), read('xl/_rels/workbook.xml.rels'));
118
+ if (sheets.length === 0) {
119
+ return { text: '', sheets: 0, warnings: ['no sheets found — the file may be .xls (old format); save it as .xlsx'] };
120
+ }
121
+ const budget = { cells: MAX_CELLS };
122
+ const warnings = [];
123
+ const blocks = [];
124
+ for (const s of sheets) {
125
+ const xml = read(s.path);
126
+ if (xml === null)
127
+ continue;
128
+ const rows = sheetRows(xml, strings, budget);
129
+ if (rows.length >= MAX_SHEET_ROWS)
130
+ warnings.push(`sheet "${s.name}" was cut at ${MAX_SHEET_ROWS} rows`);
131
+ blocks.push(`## Sheet: ${s.name}\n\n${rows.map((r) => r.join('\t')).join('\n')}`.trimEnd());
132
+ }
133
+ if (budget.cells <= 0)
134
+ warnings.push('the workbook was cut short — it holds more cells than one read returns');
135
+ const text = blocks.join('\n\n').trim();
136
+ if (text.length === 0)
137
+ warnings.push('no cell text found — the workbook may be empty or hold only charts/images');
138
+ return { text, sheets: sheets.length, warnings };
139
+ }
@@ -21,6 +21,25 @@ import { promises as fs } from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { registerTool } from '../index.js';
23
23
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
24
+ /**
25
+ * 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
26
+ * nothing about it. He opened it in a text editor, then tried to drop it into
27
+ * an unrelated page on the site and got an error: "what does this file do …
28
+ * where would this enter back into the CSPEACH process?". Say it once, when
29
+ * the file appears, in the human stream.
30
+ */
31
+ let guideNoteShown = false;
32
+ export function __resetGuideNoteForTests() { guideNoteShown = false; }
33
+ function noteGuideWritten(relPath, ctx) {
34
+ if (guideNoteShown)
35
+ return;
36
+ if (path.basename(relPath).toLowerCase() !== 'guide.md')
37
+ return;
38
+ guideNoteShown = true;
39
+ ctx.chunkEmitter?.emit('info', `${relPath} is your step-by-step list for applying these objects in SAP — `
40
+ + 'open it at https://cspeach.dev/guide, or in any text editor. '
41
+ + 'If a step fails, come back here and say "step N failed" with the error.');
42
+ }
24
43
  export async function fileWriteHandler(args, ctx) {
25
44
  // 1. Path containment — sync phase (pure string math, catches .. traversals)
26
45
  let abs;
@@ -110,6 +129,7 @@ export async function fileWriteHandler(args, ctx) {
110
129
  }
111
130
  const relPath = path.relative(path.resolve(ctx.cwd), target).replace(/\\/g, '/');
112
131
  const bytes = Buffer.byteLength(args.content, 'utf-8');
132
+ noteGuideWritten(relPath, ctx);
113
133
  return { content: `wrote ${relPath} (${bytes} bytes)` };
114
134
  }
115
135
  registerTool({
@@ -12,7 +12,7 @@ import { extractDocument, DocumentTooLargeError } from './extract-document.js';
12
12
  const MAX_LINES = 2000;
13
13
  const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
14
14
  const MAX_PAGES = 150;
15
- const SUPPORTED = new Set(['.pdf', '.docx']);
15
+ const SUPPORTED = new Set(['.pdf', '.docx', '.xlsx']);
16
16
  export async function readDocumentHandler(args, ctx) {
17
17
  if (args.offset !== undefined && args.offset < 0) {
18
18
  return { content: `error: offset must be non-negative (got ${args.offset})`, is_error: true };
@@ -32,7 +32,10 @@ export async function readDocumentHandler(args, ctx) {
32
32
  const ext = path.extname(abs).toLowerCase();
33
33
  if (!SUPPORTED.has(ext)) {
34
34
  return {
35
- content: `error: read_document supports .pdf and .docx; convert "${args.path}" first (.doc/.pptx/scanned images are not supported)`,
35
+ content: ext === '.xls' || ext === '.csv'
36
+ ? `error: read_document reads .xlsx, not ${ext}; save "${args.path}" as .xlsx`
37
+ + (ext === '.csv' ? ' — or read a .csv with file_read, it is plain text' : '')
38
+ : `error: read_document supports .pdf, .docx and .xlsx; convert "${args.path}" first (.doc/.pptx/scanned images are not supported)`,
36
39
  is_error: true,
37
40
  };
38
41
  }
@@ -74,8 +77,14 @@ export async function readDocumentHandler(args, ctx) {
74
77
  }
75
78
  return { content: `error: could not read "${args.path}": ${err instanceof Error ? err.message : String(err)}`, is_error: true };
76
79
  }
77
- // 'document' is the intentional label for .docx: result.pages is PDF-only and undefined for Word files.
78
- const header = `${args.path} — ${result.pages !== undefined ? `${result.pages} pages` : 'document'}`;
80
+ // 'document' is the intentional label for .docx: result.pages is PDF-only,
81
+ // result.sheets is .xlsx-only, and a Word file has neither.
82
+ const scale = result.pages !== undefined
83
+ ? `${result.pages} pages`
84
+ : result.sheets !== undefined
85
+ ? `${result.sheets} sheet${result.sheets === 1 ? '' : 's'}`
86
+ : 'document';
87
+ const header = `${args.path} — ${scale}`;
79
88
  const warnLines = result.warnings.map((w) => `[warning] ${w}`);
80
89
  const allLines = result.text.replace(/\n$/, '').split('\n');
81
90
  const offset = Math.max(0, args.offset ?? 0);
@@ -90,14 +99,14 @@ export async function readDocumentHandler(args, ctx) {
90
99
  }
91
100
  registerTool({
92
101
  name: 'read_document',
93
- description: 'Read a .pdf or .docx and return its extracted text with page/heading markers and line numbers. Optional offset + limit for windowed reads of large documents.',
102
+ description: 'Read a .pdf, .docx or .xlsx and return its text with page/sheet/heading markers and line numbers. A spreadsheet comes back one sheet at a time, rows as tab-separated cells (cached values, not formulas). Optional offset + limit for windowed reads of large documents.',
94
103
  isMutating: false,
95
104
  category: 'filesystem',
96
105
  flagGated: true,
97
106
  input_schema: {
98
107
  type: 'object',
99
108
  properties: {
100
- path: { type: 'string', description: 'Path to a .pdf or .docx, relative to the project root or absolute inside it.' },
109
+ path: { type: 'string', description: 'Path to a .pdf, .docx or .xlsx, relative to the project root or absolute inside it.' },
101
110
  offset: { type: 'number', description: 'Optional 0-based line offset into the extracted text.' },
102
111
  limit: { type: 'number', description: 'Optional max lines to return (cap 2000).' },
103
112
  },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Snapshot-store key for a class LOCAL INCLUDE (CCDEF / CCIMP / CCMAC / test
3
+ * classes). Pure — no fs, no SAP.
4
+ *
5
+ * Why a separate key (2026-09-21, found on the first clean-core fix): the
6
+ * include write used to snapshot the class MAIN source. For a RAP behaviour
7
+ * pool the main source is an 8-line shell and the change lives in CCIMP — so
8
+ * the Rule-7 backup never covered the change, and rewind wrote the unchanged
9
+ * main source back and reported success while the include change stayed.
10
+ *
11
+ * The include is stored under type `CLAS.<INCLUDE>` (e.g. `CLAS.LOCALS_IMP`),
12
+ * name = class name. A dot keeps the store's two-level type/name layout (no
13
+ * nested dirs) and can never collide with a real ABAP type code.
14
+ */
15
+ const INCLUDE_TYPE_RE = /^CLAS\.(LOCALS_DEF|LOCALS_IMP|MACROS|TESTCLASSES)$/i;
16
+ /** Snapshot type key for one include of a class. */
17
+ export function includeSnapshotType(includeType) {
18
+ return `CLAS.${includeType.toUpperCase()}`;
19
+ }
20
+ /** The include a snapshot type key names, or null for an ordinary object type. */
21
+ export function parseIncludeSnapshotType(type) {
22
+ const m = INCLUDE_TYPE_RE.exec(type);
23
+ return m ? m[1].toLowerCase() : null;
24
+ }
@@ -24,7 +24,8 @@
24
24
  import crypto from 'node:crypto';
25
25
  import { registerTool } from './index.js';
26
26
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
27
- import { autoSnapshot } from './snapshot.js';
27
+ import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
28
+ import { includeSnapshotType } from './include-snapshot.js';
28
29
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
29
30
  import { verifySyntax, verifyActive } from './verify.js';
30
31
  import { appendPending, finalizeToolCall } from '../session/pending.js';
@@ -663,9 +664,9 @@ registerTool({
663
664
  // Writes a class local-include source (CCDEF/CCIMP/CCMAC/test classes). This is
664
665
  // how RAP behaviour-pool handlers (which live in CCIMP / locals_imp) get landed.
665
666
  // AdtClient locks the CLASS object, tries a ladder of PUT URL/content-type
666
- // variants (first 2xx wins), and always unlocks. Snapshot is best-effort:
667
- // the include source may not be independently readable, so a failed snapshot
668
- // must not block the write (consistent with sap_delete_object).
667
+ // variants (first 2xx wins), and always unlocks. The Rule-7 snapshot is of the
668
+ // INCLUDE itself (autoSnapshotClassInclude). It is non-blocking — a fresh
669
+ // class's include may not be readable yet — but a failed one warns loudly.
669
670
  registerTool({
670
671
  name: 'sap_set_class_include',
671
672
  description: 'Write a class local-include source (CCDEF locals_def, CCIMP locals_imp, '
@@ -713,15 +714,24 @@ registerTool({
713
714
  // The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
714
715
  // locked in a request from earlier work — battery S1's 409 root cause.
715
716
  const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
716
- // ── Gate 2: Snapshot (Rule 7) — best-effort, include may not be readable ─
717
+ // ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
718
+ // Not the class main source: for a behaviour pool that is an empty shell and
719
+ // the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
720
+ // the change with no restore point, and rewind "restored" the wrong thing).
721
+ // Still non-blocking (a fresh class's include may not be readable yet), but
722
+ // a missing restore point is now said out loud instead of swallowed.
717
723
  let snapOutcome;
718
724
  try {
719
- snapOutcome = await autoSnapshot(args.className, 'CLAS', ctx.adt);
725
+ snapOutcome = await autoSnapshotClassInclude(args.className, args.includeType, ctx.adt);
720
726
  }
721
- catch {
722
- // Silently ignore — include source may not be independently readable.
727
+ catch (err) {
728
+ snapOutcome = { taken: false, reason: 'source_read_failed', detail: String(err) };
729
+ }
730
+ emitSnapshotNotice(ctx, snapOutcome, includeSnapshotType(args.includeType), args.className);
731
+ if (snapOutcome?.reason === 'source_read_failed') {
732
+ ctx.chunkEmitter?.emit('warn', `CLAS ${args.className} ${args.includeType}: could not snapshot the include before writing `
733
+ + `(${snapOutcome.detail ?? 'read failed'}) — this write has NO restore point; rewind cannot undo it`);
723
734
  }
724
- emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.className);
725
735
  // ── WAL: record pending entry ────────────────────────────────────────────
726
736
  await appendPending(ctx.session, {
727
737
  tool_use_id: toolUseId,
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { registerTool } from './index.js';
17
17
  import { snapshots, SapError } from '@cspeach/sap-client';
18
+ import { includeSnapshotType } from './include-snapshot.js';
18
19
  /** True only for a genuine ADT 404 — the object does not exist. Any other
19
20
  * failure (network, auth, 5xx, timeout) is a READ failure, not proof of
20
21
  * absence, and must never be labelled "object not found". */
@@ -100,6 +101,32 @@ export async function autoSnapshot(objectName, objectType, adt) {
100
101
  const entry = await snapshots.take(objectType, objectName, src, 'before_write');
101
102
  return { taken: true, entry };
102
103
  }
104
+ /**
105
+ * Rule-7 snapshot of ONE class local include, taken before sap_set_class_include
106
+ * overwrites it. Stored under includeSnapshotType(includeType) + class name so
107
+ * rewind can put the include (not the class main source) back.
108
+ *
109
+ * 404 → 'object_not_found' (include has no source yet — nothing to protect)
110
+ * other read error → 'source_read_failed' (+ detail) — caller decides; no restore point
111
+ * read OK (even '') → snapshot taken
112
+ * A snapshots.take failure THROWS, like autoSnapshot.
113
+ */
114
+ export async function autoSnapshotClassInclude(className, includeType, adt) {
115
+ let src;
116
+ try {
117
+ src = await adt.classIncludes(className, includeType);
118
+ }
119
+ catch (err) {
120
+ if (isNotFound(err))
121
+ return { taken: false, reason: 'object_not_found' };
122
+ return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
123
+ }
124
+ if (typeof src !== 'string') {
125
+ return { taken: false, reason: 'source_read_failed', detail: 'include read returned no source text' };
126
+ }
127
+ const entry = await snapshots.take(includeSnapshotType(includeType), className, src, 'before_write');
128
+ return { taken: true, entry };
129
+ }
103
130
  // ── sap_snapshot_list ─────────────────────────────────────────────────────────
104
131
  registerTool({
105
132
  name: 'sap_snapshot_list',