@cspeach/cli 1.1.26 → 1.1.28

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 (74) hide show
  1. package/dist/agent/loop.js +41 -5
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/commands/whoami.js +1 -1
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/checks/skill.js +60 -11
  29. package/dist/doctor/run.js +4 -6
  30. package/dist/lane/app-chain.js +116 -9
  31. package/dist/one-shot.js +1 -0
  32. package/dist/projects/extract-design.js +4 -3
  33. package/dist/projects/extract-estimate.js +4 -3
  34. package/dist/projects/extract-plan.js +34 -1
  35. package/dist/projects/extract-spec-gap.js +28 -5
  36. package/dist/projects/handover-md.js +1 -1
  37. package/dist/projects/plan-objects.js +75 -0
  38. package/dist/projects/plan-run.js +74 -18
  39. package/dist/projects/plan-schema.js +6 -0
  40. package/dist/projects/plan-sources.js +199 -0
  41. package/dist/projects/promote-command.js +1 -1
  42. package/dist/projects/save-command.js +15 -14
  43. package/dist/projects/saved-lines.js +36 -0
  44. package/dist/projects/title-heading.js +36 -0
  45. package/dist/renderer/checklist-format.js +5 -5
  46. package/dist/renderer/machine-blocks.js +141 -0
  47. package/dist/renderer/thinking-heartbeat.js +2 -1
  48. package/dist/renderer/tool-labels.js +3 -0
  49. package/dist/repl/plan-hop.js +22 -0
  50. package/dist/repl/rule8-detector.js +16 -1
  51. package/dist/repl.js +320 -55
  52. package/dist/router/router-core.js +2 -2
  53. package/dist/sap/reachability-state.js +25 -0
  54. package/dist/sap/unreachable.js +4 -1
  55. package/dist/session/audit-export.js +10 -16
  56. package/dist/session/recap.js +16 -26
  57. package/dist/skills/bundled-skills.js +1 -1
  58. package/dist/tools/_filesystem-shared.js +37 -0
  59. package/dist/tools/approval.js +48 -2
  60. package/dist/tools/dispatch-skill.js +3 -2
  61. package/dist/tools/filesystem/file-read.js +5 -3
  62. package/dist/tools/filesystem/glob.js +27 -4
  63. package/dist/tools/fiori/tools.js +6 -0
  64. package/dist/tools/lane/app-lane-resolve.js +1 -1
  65. package/dist/tools/sap-read.js +37 -3
  66. package/dist/tools/sap-write.js +65 -0
  67. package/dist/tools/text-elements/pool.js +250 -0
  68. package/dist/tools/text-elements/tools.js +433 -0
  69. package/dist/tools/tool-loading.js +3 -0
  70. package/dist/tools/transport.js +26 -5
  71. package/dist/tools/write-mode.js +4 -3
  72. package/package.json +3 -4
  73. package/dist/doctor/checks/zcspeach.js +0 -96
  74. package/dist/projects/email-template.js +0 -56
@@ -36,7 +36,7 @@ export const ROUTER_SCHEMA_VERSION = 1;
36
36
  * its own bundled copy — so a stale published manifest never rolls a newer
37
37
  * CLI back to older routing. router-revision-pin.test.ts fails until bumped.
38
38
  */
39
- export const ROUTER_REVISION = 1;
39
+ export const ROUTER_REVISION = 2;
40
40
  // 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
41
41
  // abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
42
42
  // added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
@@ -74,7 +74,7 @@ export const SKILL_DESCRIPTIONS = {
74
74
  'abap-incident': 'End-to-end incident pipeline — from short dump to fix deployed and documented.',
75
75
  'abap-migrate': 'ECC→S/4HANA migration assessment for custom objects — API analysis, successor lookup, per-object effort.',
76
76
  'abap-modernize': 'Transform a CLASSIC program into a modern architecture (e.g. report → Fiori Elements / RAP) for the release level.',
77
- 'abap-plan': 'Plan and run a multi-session SAP project — phase envelope carries state across sessions, one phase per session.',
77
+ 'abap-plan': 'Plan and run a multi-step SAP project — one approval of the plan and its object list, then the steps run back to back; the plan file carries state across sessions.',
78
78
  'abap-preflight': 'Pre-release checklist for a change set — dependencies, tests, rollback, readiness.',
79
79
  'abap-rap': 'Scaffold a complete RAP stack from scratch — table, CDS interface+projection, BDEF, behavior class, service definition+binding.',
80
80
  'abap-refactor': 'Modify / improve / add a feature to EXISTING custom ABAP code in place — restructure, clean up, or change a Z/Y program/class (incl. small feature changes like adding output coloring).',
@@ -0,0 +1,25 @@
1
+ // cspeach-cli/src/sap/reachability-state.ts
2
+ //
3
+ // 2026-10-04 (continuous run, spec §3.5) — set when a SAP tool fails because
4
+ // the SAP host is not reachable from this machine; read (and cleared) by the
5
+ // REPL after a plan turn so the build pauses instead of failing.
6
+ let unreachable = null;
7
+ const CODE_RE = /\b(ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET|EAI_AGAIN)\b/;
8
+ export const SAP_UNREACHABLE_DIRECTIVE = 'SAP is not reachable from this machine right now. Stop here: do not retry SAP tools and do not write the plan manifest. '
9
+ + 'End the turn with one short sentence. CSPeach pauses the build and runs this step again when the network is back.';
10
+ export function noteSapUnreachable(code) {
11
+ unreachable = code;
12
+ }
13
+ /** True while a SAP-unreachable failure is noted for this turn (does not clear it). */
14
+ export function isSapUnreachableNoted() {
15
+ return unreachable !== null;
16
+ }
17
+ export function getAndResetSapUnreachable() {
18
+ const c = unreachable;
19
+ unreachable = null;
20
+ return c;
21
+ }
22
+ export function unreachableCodeInText(text) {
23
+ const m = CODE_RE.exec(String(text ?? ''));
24
+ return m ? m[1] : null;
25
+ }
@@ -23,6 +23,9 @@ export function unreachableCode(err) {
23
23
  return undefined;
24
24
  }
25
25
  export function unreachableMessage(alias, host, port, code) {
26
- return (`Can't reach SAP system ${alias} (${host}:${port}) — ${code}. Check the VPN / network, ` +
26
+ return (
27
+ // 2026-10-04: never assume a VPN — many users sit on the company network.
28
+ `Can't reach SAP system ${alias} (${host}:${port}) from this machine — ${code}. ` +
29
+ 'Check network access to the SAP system (company network or VPN), ' +
27
30
  'or run with --no-sap for work that does not need SAP.');
28
31
  }
@@ -45,6 +45,7 @@ const WRITE_TOOLS = new Set([
45
45
  'sap_delete_object',
46
46
  'sap_create_object',
47
47
  'sap_set_class_include',
48
+ 'sap_set_text_elements',
48
49
  'sap_create_domain',
49
50
  'sap_create_data_element',
50
51
  'sap_activate',
@@ -277,18 +278,11 @@ function outcomeCell(c) {
277
278
  }
278
279
  // ── Operator identity ─────────────────────────────────────────────────────
279
280
  //
280
- // SessionState carries NO SAP logon user (only the sap_system alias). The one
281
- // identity available is the local CSPeach operator, resolved the same way as
282
- // agent/loop.ts getAuthorIdentity (CSPEACH_AUTHOR_NAME → USER → USERNAME). It
283
- // is the person who RAN CSPeach, not the SAP account writes landed under — the
284
- // header labels it "Operator" and shows the SAP system separately so the two
285
- // are never conflated. (Inlined rather than importing agent/loop.js to keep
286
- // this a dependency-light pure builder.)
287
- function resolveOperator() {
288
- return (process.env.CSPEACH_AUTHOR_NAME ||
289
- process.env.USER ||
290
- process.env.USERNAME ||
291
- 'unknown');
281
+ // 2026-10-04 — the person who RAN CSPeach, as CSPeach knows them: the signed-in
282
+ // account (from /v1/me), else an explicit CSPEACH_AUTHOR_NAME, else "not
283
+ // signed in". NEVER the OS account name (USER/USERNAME printed "Admin").
284
+ function resolveOperator(operator) {
285
+ return operator?.trim() || process.env.CSPEACH_AUTHOR_NAME?.trim() || 'not signed in (local run)';
292
286
  }
293
287
  // ── Transports seen ───────────────────────────────────────────────────────
294
288
  function transportsSeen(toolCalls, sessionTransport) {
@@ -320,7 +314,7 @@ function transportsSeen(toolCalls, sessionTransport) {
320
314
  }
321
315
  // ── Main builder ──────────────────────────────────────────────────────────
322
316
  export function buildAuditReport(input) {
323
- const { session, costEntries, transport, credits } = input;
317
+ const { session, costEntries, transport, credits, operator } = input;
324
318
  const calls = session.toolCalls;
325
319
  const completed = calls.filter((c) => c.completed === true);
326
320
  const approvals = parseApprovals(calls);
@@ -344,14 +338,14 @@ export function buildAuditReport(input) {
344
338
  L();
345
339
  L(`- **Session:** \`${session.id}\``);
346
340
  L(`- **SAP system:** ${session.sap_system ?? '—'}`);
347
- L(`- **Operator (local):** ${resolveOperator()}`);
341
+ L(`- **Operator (CSPeach sign-in):** ${resolveOperator(operator)}`);
348
342
  L(`- **Period:** ${dateFrom} → ${dateTo}`);
349
343
  const trs = transportsSeen(calls, transport);
350
344
  L(`- **Transport(s):** ${trs.length ? trs.map((t) => `\`${t}\``).join(', ') : '—'}`);
351
345
  L();
352
346
  L('> Scope: this record covers SAP-system mutations only — local file and');
353
- L('> shell operations are excluded by design. Operator is the local CSPeach');
354
- L('> user (who ran the session); the SAP logon user is not recorded in');
347
+ L('> shell operations are excluded by design. Operator is the CSPeach sign-in');
348
+ L('> of the person who ran the session; the SAP logon user is not recorded in');
355
349
  L('> session state. Plan audit states, if any, live in the plan handover,');
356
350
  L('> not this report.');
357
351
  L();
@@ -90,25 +90,18 @@ export function buildRecap(session, cwd, opts) {
90
90
  return lines;
91
91
  }
92
92
  /**
93
- * Scan `workspaceDir` for the newest plan family and, when it still has
94
- * unsatisfied phases, produce the plan recap fact. Returns null when there
95
- * is no plan, the newest plan family is fully satisfied, or its envelope is
96
- * invalid (silent skip by design).
97
- *
98
- * "Newest family": envelope files group into families by filename base
99
- * (`<base>-v<N>[-<sub>].cspeach.json` — same convention as
100
- * pickNewestPlanVersion / findNewestVersion in commands/plan-resume.ts);
101
- * within a family the highest version wins; families rank by their newest
102
- * file's mtime. Non-plan envelopes (design, spec-gap, …) are skipped so the
103
- * newest PLAN family is found even when a newer non-plan artefact exists.
93
+ * Newest file per envelope family, families newest first (by that file's
94
+ * mtime). [] when the folder is missing. Within a family the highest version
95
+ * wins, then the collision sub, then mtime. A version-less file is its own
96
+ * single-file family. Shared by the recap and the startup Continue offer.
104
97
  */
105
- function buildPlanPart(workspaceDir) {
98
+ export function newestPlanFamilyFiles(workspaceDir) {
106
99
  let names;
107
100
  try {
108
101
  names = readdirSync(workspaceDir);
109
102
  }
110
103
  catch {
111
- return null; // workspace missing — nothing to recap
104
+ return []; // workspace missing
112
105
  }
113
106
  const re = /^(.+)-v(\d+)(?:-(\d+))?\.cspeach\.json$/;
114
107
  const cands = [];
@@ -124,17 +117,10 @@ function buildPlanPart(workspaceDir) {
124
117
  continue;
125
118
  }
126
119
  const m = re.exec(name);
127
- if (m) {
128
- cands.push({ path, family: m[1], version: Number(m[2]), sub: m[3] ? Number(m[3]) : 0, mtimeMs });
129
- }
130
- else {
131
- // Hand-renamed / version-less envelope: its own single-file family.
132
- cands.push({ path, family: name.slice(0, -'.cspeach.json'.length), version: 0, sub: 0, mtimeMs });
133
- }
120
+ cands.push(m
121
+ ? { path, family: m[1], version: Number(m[2]), sub: m[3] ? Number(m[3]) : 0, mtimeMs }
122
+ : { path, family: name.slice(0, -'.cspeach.json'.length), version: 0, sub: 0, mtimeMs });
134
123
  }
135
- if (cands.length === 0)
136
- return null;
137
- // Newest version per family (version, then collision sub, then mtime).
138
124
  const byFamily = new Map();
139
125
  for (const c of cands) {
140
126
  const cur = byFamily.get(c.family);
@@ -144,9 +130,13 @@ function buildPlanPart(workspaceDir) {
144
130
  byFamily.set(c.family, c);
145
131
  }
146
132
  }
147
- // Families newest-first by their representative file's mtime; the first
148
- // family whose envelope IS a plan is "the newest plan family".
149
- const familiesNewestFirst = [...byFamily.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
133
+ return [...byFamily.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
134
+ }
135
+ function buildPlanPart(workspaceDir) {
136
+ // The first family whose envelope IS a plan is "the newest plan family".
137
+ const familiesNewestFirst = newestPlanFamilyFiles(workspaceDir);
138
+ if (familiesNewestFirst.length === 0)
139
+ return null;
150
140
  for (const cand of familiesNewestFirst) {
151
141
  let env;
152
142
  try {
@@ -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-10-04T16:06:20.457Z
3
+ // Generated: 2026-10-05T13:05:26.367Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-app": {
@@ -371,6 +371,43 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
371
371
  }
372
372
  return { blocked: false, relFromRoot };
373
373
  }
374
+ /**
375
+ * READ-ONLY carve-out (2026-10-04, plan sources): the project's own saved
376
+ * results — `.cspeach/<name>.cspeach.json` (spec-gap, plan, design, … envelopes,
377
+ * directly in `.cspeach/`) and `.cspeach/lane/<name>.json` (lane decisions) —
378
+ * are project data the developer saved, not secrets. file_read and glob may
379
+ * READ / LIST them; file_write / file_edit never consult this, so the
380
+ * envelopes stay CLI-owned for writes (audits, statuses and `from` refs can
381
+ * never be forged through a file tool).
382
+ *
383
+ * Narrow by construction — true only when ALL hold:
384
+ * - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
385
+ * whatever the root (a project run from the home folder gets nothing);
386
+ * - it sits inside the spelled root, at exactly one of the two shapes above
387
+ * (no deeper nesting, no other `.cspeach` file such as conventions.toml);
388
+ * - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
389
+ * and no '~' segment (a possible 8.3 short name — fail closed);
390
+ * - the basename is not in the `.env` family.
391
+ * `.git` / `.cspeach-design` are never matched (the shapes require `.cspeach/`).
392
+ */
393
+ export function isReadOnlyProjectFile(realAbsPath, root, platform = process.platform) {
394
+ const isWin = platform === 'win32';
395
+ const native = platform === process.platform;
396
+ const p = isWin ? path.win32 : path.posix;
397
+ if (isUnderCliHome(realAbsPath, p, native))
398
+ return false;
399
+ const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
400
+ if (!relIsInside(p, rel) || rel === '')
401
+ return false;
402
+ const segments = rel.split('/');
403
+ if (segments.some((seg) => seg.includes(':') || seg.includes('~') || ntfsCanonicalSegment(seg) !== seg))
404
+ return false;
405
+ if (BLOCKED_BASENAME_RE.test(segments[segments.length - 1]))
406
+ return false;
407
+ const relLower = rel.toLowerCase();
408
+ return /^\.cspeach\/[^/]+\.cspeach\.json$/.test(relLower)
409
+ || /^\.cspeach\/lane\/[^/]+\.json$/.test(relLower);
410
+ }
374
411
  export class PathOutsideRootError extends Error {
375
412
  constructor(givenPath, root) {
376
413
  super(`Path "${givenPath}" resolves outside the project root "${root}"`);
@@ -17,7 +17,9 @@ function noteApprovedTransport(t) {
17
17
  import { mintChangeApproval } from '../approvals/jwt.js';
18
18
  import { canonicalApprovalObject } from '../approvals/canonical.js';
19
19
  import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
20
- import { loadConfig } from '../config/loader.js';
20
+ import { loadConfig, effectiveApprovals } from '../config/loader.js';
21
+ import { getPlanGrant, grantCovers, noteGrantUse, noteApprovalsSetting, grantClaimsFor } from '../approvals/plan-grant.js';
22
+ import { normalizeObjectType } from '../projects/plan-objects.js';
21
23
  import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
22
24
  import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
23
25
  import { promptAdvisory } from '../approvals/advisory-prompt.js';
@@ -161,7 +163,9 @@ registerTool({
161
163
  // sanctioned rule-4a self-create. The very act of routing through
162
164
  // request_approval IS the user-consent gate, so it must NOT be denied here
163
165
  // — fall through to the normal approval flow (which prompts the user, whose
164
- // approval is the 4a consent). Guarded-mode clients could otherwise never
166
+ // approval is the 4a consent; or, for the ONE new transport of a plan that
167
+ // had none, the plan object-list grant, whose "a new one for this build"
168
+ // the user already approved). Guarded-mode clients could otherwise never
165
169
  // run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
166
170
  // exception fires only when EVERY change is infra; a deliverable
167
171
  // (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
@@ -202,6 +206,48 @@ registerTool({
202
206
  const changes = args.changes;
203
207
  const declared = args.risk;
204
208
  const eff = effectiveRisk(declared, changes, riskCtx);
209
+ // 2026-10-04 (continuous run, spec §3.2) — the plan object-list grant. The
210
+ // developer approved these exact objects on "Build this now?" (or
211
+ // "Continue"); a request whose EVERY change is on that list is approved by
212
+ // that human answer — not an auto-approval, so it applies in guarded runs.
213
+ // Anything else (off the list, another package or transport, a delete, a
214
+ // release, a deploy) falls through to the cards below. The advisory clamp
215
+ // above already returned for advisory/production; approvals = per-write
216
+ // turns this off; a headless run never uses it.
217
+ // A change that is really high risk (declared high, or an object whose last
218
+ // syntax check found errors) always shows its card, listed or not. The
219
+ // auto_approve = never setting also lifts every request to high, but that
220
+ // is a setting, not a risk (see shownRiskReasons), so it is left out here.
221
+ // The approvals setting is recorded for Rule 8's batch card as well.
222
+ const grant = getPlanGrant();
223
+ const approvalsSetting = grant ? effectiveApprovals(cfg) : null;
224
+ if (approvalsSetting)
225
+ noteApprovalsSetting(approvalsSetting);
226
+ if (grant && !isHeadless() && approvalsSetting === 'plan'
227
+ && effectiveRisk(declared, changes, { ...riskCtx, autoApprovePolicy: 'medium' }).level !== 'high'
228
+ && ctx.session?.id === grant.sessionId
229
+ && grantCovers(grant, changes, args.transport)) {
230
+ noteGrantUse(changes);
231
+ const ids = [];
232
+ for (const c of changes) {
233
+ // The grant's one new transport: its id creates ONE transport, never more.
234
+ const oneShot = c.op === 'create' && normalizeObjectType(c.type ?? '') === 'CTS';
235
+ // Grant claims: the write tools hold the id to the listed type,
236
+ // package and bound transport.
237
+ ids.push(await mintChangeApproval(c, ctx.session.id, { grant: grantClaimsFor(c), ...(oneShot ? { oneShot: true } : {}) }));
238
+ }
239
+ markPlanGateApproved();
240
+ noteApprovedTransport(args.transport);
241
+ return {
242
+ content: JSON.stringify({
243
+ approved: true,
244
+ approved_by: 'the plan object list',
245
+ approval_ids: ids,
246
+ effective_risk: eff.level,
247
+ raised_by: eff.raisedBy.map(riskReasonText),
248
+ }),
249
+ };
250
+ }
205
251
  // Transport shown on the approval surfaces — resolved PER OP to mirror
206
252
  // what the write tools will actually do (I2, 2026-07-05 review):
207
253
  // create-family tools fall back to the session transport, so the box may
@@ -33,9 +33,10 @@
33
33
  */
34
34
  import { registerTool } from './index.js';
35
35
  import { isPlanResumeCommand } from '../commands/plan-resume.js';
36
+ export const PLAN_RESUME_DISPATCH_REASON = 'Do not dispatch /abap-plan --resume. When your plan manifest is saved, CSPeach runs the next step itself with the exact saved file. Simply end the turn now.';
36
37
  registerTool({
37
38
  name: 'dispatch_skill',
38
- description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is harness-owned: the CLI offers and dispatches the next phase itself after the manifest is persisted. After this tool returns, end the turn — do not also print a continuation hint.',
39
+ description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is owned by CSPeach: when the manifest is saved, CSPeach runs the next step itself. After this tool returns, end the turn — do not also print a continuation hint.',
39
40
  isMutating: false,
40
41
  input_schema: {
41
42
  type: 'object',
@@ -75,7 +76,7 @@ registerTool({
75
76
  is_error: true,
76
77
  content: JSON.stringify({
77
78
  error: 'PLAN_RESUME_HARNESS_OWNED',
78
- reason: 'Do not dispatch /abap-plan --resume. After your plan manifest is persisted, the CLI itself offers the user the next phase and dispatches the resume with the exact saved file in a fresh bounded context. Simply end the turn now.',
79
+ reason: PLAN_RESUME_DISPATCH_REASON,
79
80
  }),
80
81
  };
81
82
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { promises as fs } from 'node:fs';
12
12
  import { registerTool } from '../index.js';
13
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
13
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
14
14
  import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
15
15
  import { basename } from 'node:path';
16
16
  const MAX_LINES = 2000;
@@ -45,9 +45,11 @@ export async function fileReadHandler(args, ctx) {
45
45
  }
46
46
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
47
47
  }
48
- // Denylist check: refuse sensitive in-root paths
48
+ // Denylist check: refuse sensitive in-root paths. The project's own saved
49
+ // results (.cspeach/*.cspeach.json, .cspeach/lane/*.json) are readable —
50
+ // isReadOnlyProjectFile; the write tools never get this carve-out.
49
51
  const denylist = isDenylistedPath(realAbs, ctx.cwd);
50
- if (denylist.blocked) {
52
+ if (denylist.blocked && !isReadOnlyProjectFile(realAbs, ctx.cwd)) {
51
53
  return {
52
54
  content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
53
55
  is_error: true,
@@ -19,7 +19,7 @@
19
19
  import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { registerTool } from '../index.js';
22
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
22
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
23
23
  const MAX_RESULTS = 500;
24
24
  const MAX_FILES_TRAVERSED = 10_000;
25
25
  const ALWAYS_SKIPPED_DIRS = new Set([
@@ -70,6 +70,16 @@ export function globToRegex(pattern) {
70
70
  out += '$';
71
71
  return new RegExp(out);
72
72
  }
73
+ /** True when the pattern explicitly asks for the project's saved results. */
74
+ export function isCspeachPattern(pattern) {
75
+ const p = pattern.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
76
+ return p.startsWith('.cspeach/') || p.includes('.cspeach.json');
77
+ }
78
+ /** True for a relPath inside the root-level `.cspeach/` folder. */
79
+ function inCspeach(relPath) {
80
+ const l = relPath.toLowerCase();
81
+ return l === '.cspeach' || l.startsWith('.cspeach/');
82
+ }
73
83
  export async function globHandler(args, ctx) {
74
84
  // Validate args
75
85
  if (!args.pattern || typeof args.pattern !== 'string') {
@@ -97,6 +107,12 @@ export async function globHandler(args, ctx) {
97
107
  return { content: `error: search root "${args.cwd ?? '.'}" does not exist`, is_error: true };
98
108
  }
99
109
  const regex = globToRegex(args.pattern);
110
+ // 2026-10-04 (plan sources) — the project's saved results live in
111
+ // `.cspeach/` (skipped by default: noise + CLI-owned). A pattern that
112
+ // names them (`.cspeach/...` or `*.cspeach.json`) walks the ROOT-level
113
+ // `.cspeach/` folder; each file there is listed only when it is readable
114
+ // (isReadOnlyProjectFile, or a carved-out work file).
115
+ const wantsCspeach = isCspeachPattern(args.pattern);
100
116
  const collected = [];
101
117
  let budget = MAX_FILES_TRAVERSED;
102
118
  async function walk(dir, relPrefix) {
@@ -125,7 +141,10 @@ export async function globHandler(args, ctx) {
125
141
  continue;
126
142
  }
127
143
  if (ent.isDirectory()) {
128
- if (ALWAYS_SKIPPED_DIRS.has(ent.name))
144
+ const intoCspeach = wantsCspeach && dir === realRoot && ent.name.toLowerCase() === '.cspeach';
145
+ // Inside the walked `.cspeach/` every subfolder is entered (lane/, the
146
+ // work subdirs); the per-file read check decides what is listed.
147
+ if (ALWAYS_SKIPPED_DIRS.has(ent.name) && !intoCspeach && !inCspeach(relPrefix))
129
148
  continue;
130
149
  await walk(path.join(dir, ent.name), relPath);
131
150
  }
@@ -136,7 +155,7 @@ export async function globHandler(args, ctx) {
136
155
  // Denylist check on the matched path
137
156
  const abs = path.join(dir, ent.name);
138
157
  const denylist = isDenylistedPath(abs, ctx.cwd);
139
- if (denylist.blocked)
158
+ if (denylist.blocked && !isReadOnlyProjectFile(abs, ctx.cwd))
140
159
  continue;
141
160
  let mtime = 0;
142
161
  try {
@@ -178,7 +197,11 @@ export async function globHandler(args, ctx) {
178
197
  collected.sort((a, b) => b.mtime - a.mtime);
179
198
  const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
180
199
  if (sample.length === 0) {
181
- return { content: `no files match "${args.pattern}"` };
200
+ // A search for saved results that did not name `.cspeach/` gets the hint.
201
+ const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
202
+ ? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
203
+ : '';
204
+ return { content: `no files match "${args.pattern}"${hint}` };
182
205
  }
183
206
  const truncated = collected.length > MAX_RESULTS;
184
207
  const trailer = truncated
@@ -774,6 +774,12 @@ export async function fioriDraftSmokeHandler(args, ctx, deps) {
774
774
  if (!verify.ok) {
775
775
  return { content: `error: APPROVAL_INVALID: ${verify.reason} — ${howToApprove}.`, is_error: true };
776
776
  }
777
+ // 2026-10-04 — the id must be one minted for this data run (type DATA), not
778
+ // one for the service binding object itself (e.g. a plan-list SRVB change):
779
+ // the smoke writes live rows, so the developer must have approved exactly that.
780
+ if (String(verify.payload.type ?? '').trim().toUpperCase() !== 'DATA') {
781
+ return { content: `error: APPROVAL_INVALID: type_mismatch — ${howToApprove}.`, is_error: true };
782
+ }
777
783
  if (draftSmokeApprovalsUsed.has(verify.payload.nonce)) {
778
784
  return {
779
785
  content: `error: this approval_id already ran one draft smoke — each run needs its own approval; ${howToApprove}.`,
@@ -306,7 +306,7 @@ registerTool({
306
306
  name: 'app_lane_resolve',
307
307
  description: 'App lane routing for /abap-app. Pass the spec file path and the signal sheet: for each fixed key a value and, ' +
308
308
  'when the value is true / a service name, an evidence quote copied word for word from the spec (max 300 chars). ' +
309
- 'The CLI checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
309
+ 'CSPeach checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
310
310
  'shows the lane card, saves the decision file and queues the plan. Never guess a lane yourself. ' +
311
311
  'Keys: app_requested, ui_only_service (string|null), report_only, analytical, transactional, custom_ui, ' +
312
312
  'cap_external_users, cap_multi_system, cap_own_release_cycle, cap_ui_on_released_api.',
@@ -258,11 +258,33 @@ registerTool({
258
258
  // -----------------------------------------------------------------------
259
259
  // 5. sap_sql_query
260
260
  // -----------------------------------------------------------------------
261
+ /**
262
+ * 2026-10-04 (live run, S/4HANA 2023): a design phase guessed
263
+ * `SELECT object_type, object_key … FROM ars_w_api_state` → SAP HTTP 400
264
+ * "Unknown column name OBJECT_KEY", then retried. The columns below are the
265
+ * ones SAP returned for `SELECT * FROM ars_w_api_state` in that run
266
+ * (I_OPERATIONALACCTGDOCITEM: OBJECT_ID = OBJECT_NAME, OBJECT_TYPE DDLS,
267
+ * SUB_OBJECT_TYPE CDS_STOB, COMPATIBILITY_CONTRACT C1, RELEASE_STATE RELEASED).
268
+ */
269
+ export const ARS_COLUMNS_HINT = 'Release state of an SAP object (C1/C2): SELECT object_id, object_type, compatibility_contract, release_state '
270
+ + 'FROM ars_w_api_state WHERE object_id LIKE \'I_JOURNALENTRY%\' — columns are OBJECT_ID, OBJECT_TYPE (DDLS for CDS views, '
271
+ + 'CLAS, BDEF, …), OBJECT_NAME, SUB_OBJECT_TYPE, SUB_OBJECT_NAME, COMPATIBILITY_CONTRACT, RELEASE_STATE, '
272
+ + 'SUCCESSOR_OBJECT_TYPE, SUCCESSOR_OBJECT_NAME (there is no OBJECT_KEY).';
273
+ /** The hint for a failed ARS_* query that named an unknown column, else undefined. */
274
+ export function arsColumnErrorHint(query, err) {
275
+ if (typeof query !== 'string' || !/\bars_(?:w_)?api_\w+/i.test(query))
276
+ return undefined;
277
+ const msg = err instanceof Error ? err.message : String(err);
278
+ if (!/unknown column/i.test(msg))
279
+ return undefined;
280
+ return `${msg}\n${ARS_COLUMNS_HINT}`;
281
+ }
261
282
  registerTool({
262
283
  name: 'sap_sql_query',
263
284
  description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically. '
264
285
  + 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
265
- + '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries.',
286
+ + '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries. '
287
+ + ARS_COLUMNS_HINT,
266
288
  isMutating: false,
267
289
  requiresSap: true,
268
290
  input_schema: {
@@ -274,7 +296,18 @@ registerTool({
274
296
  required: ['query'],
275
297
  },
276
298
  handler: async (args, ctx) => {
277
- const result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
299
+ let result;
300
+ try {
301
+ result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
302
+ }
303
+ catch (e) {
304
+ // A wrong ARS_W_API_STATE column costs a failed round trip: answer with
305
+ // the real column list so the retry is right the first time.
306
+ const hint = arsColumnErrorHint(args.query, e);
307
+ if (hint)
308
+ return { content: hint, is_error: true };
309
+ throw e;
310
+ }
278
311
  return spillToolResult({ content: formatSqlTsv(result) }, ctx, 'sql', { label: 'query result' });
279
312
  },
280
313
  });
@@ -391,7 +424,8 @@ registerTool({
391
424
  // -----------------------------------------------------------------------
392
425
  registerTool({
393
426
  name: 'sap_api_state',
394
- description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST".',
427
+ description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST" '
428
+ + '(CDS views: "DDLS:I_JOURNALENTRY"). Returns RELEASE_STATE only; for the contract (C1/C2) query ARS_W_API_STATE with sap_sql_query.',
395
429
  isMutating: false,
396
430
  requiresSap: true,
397
431
  input_schema: {