@cspeach/cli 1.1.27 → 1.1.29

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 (68) hide show
  1. package/dist/agent/loop.js +33 -4
  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/agent/turn-error-ux.js +40 -0
  9. package/dist/approvals/grant-claims.js +70 -0
  10. package/dist/approvals/jwt.js +21 -1
  11. package/dist/approvals/plan-grant.js +297 -0
  12. package/dist/commands/config-set.js +12 -0
  13. package/dist/commands/deploy-card.js +10 -5
  14. package/dist/commands/export-audit.js +1 -1
  15. package/dist/commands/plan-audit-evidence.js +47 -1
  16. package/dist/commands/plan-audit.js +23 -8
  17. package/dist/commands/plan-batch-audit.js +349 -0
  18. package/dist/commands/plan-boundary.js +88 -0
  19. package/dist/commands/plan-chain.js +409 -566
  20. package/dist/commands/plan-continue.js +21 -7
  21. package/dist/commands/plan-gate.js +28 -49
  22. package/dist/commands/plan-reground.js +174 -0
  23. package/dist/commands/plan-resume.js +227 -103
  24. package/dist/commands/plan-sap-pause.js +105 -0
  25. package/dist/commands/plan-startup.js +218 -0
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/run.js +4 -4
  29. package/dist/lane/app-chain.js +88 -9
  30. package/dist/one-shot.js +1 -0
  31. package/dist/projects/extract-plan.js +34 -1
  32. package/dist/projects/handover-md.js +1 -1
  33. package/dist/projects/plan-objects.js +75 -0
  34. package/dist/projects/plan-run.js +74 -18
  35. package/dist/projects/plan-schema.js +6 -0
  36. package/dist/projects/plan-sources.js +199 -0
  37. package/dist/projects/promote-command.js +1 -1
  38. package/dist/projects/save-command.js +11 -13
  39. package/dist/projects/saved-lines.js +36 -0
  40. package/dist/renderer/checklist-format.js +5 -5
  41. package/dist/renderer/machine-blocks.js +141 -0
  42. package/dist/renderer/tool-labels.js +3 -0
  43. package/dist/repl/plan-hop.js +22 -0
  44. package/dist/repl/rule8-detector.js +16 -1
  45. package/dist/repl.js +320 -55
  46. package/dist/router/router-core.js +2 -2
  47. package/dist/sap/reachability-state.js +25 -0
  48. package/dist/session/audit-export.js +10 -16
  49. package/dist/session/recap.js +16 -26
  50. package/dist/skills/bundled-skills.js +81 -81
  51. package/dist/tools/_filesystem-shared.js +37 -0
  52. package/dist/tools/approval.js +48 -2
  53. package/dist/tools/dispatch-skill.js +3 -2
  54. package/dist/tools/filesystem/file-read.js +5 -3
  55. package/dist/tools/filesystem/glob.js +27 -4
  56. package/dist/tools/fiori/tools.js +6 -0
  57. package/dist/tools/lane/app-lane-resolve.js +1 -1
  58. package/dist/tools/sap-read.js +37 -3
  59. package/dist/tools/sap-write.js +65 -0
  60. package/dist/tools/text-elements/pool.js +250 -0
  61. package/dist/tools/text-elements/tools.js +433 -0
  62. package/dist/tools/tool-loading.js +3 -0
  63. package/dist/tools/transport.js +26 -5
  64. package/dist/tools/write-mode.js +4 -3
  65. package/dist/ui/app.js +4 -0
  66. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  67. package/package.json +120 -121
  68. package/dist/projects/email-template.js +0 -56
@@ -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: {
@@ -25,6 +25,7 @@ import crypto from 'node:crypto';
25
25
  import { readFileSync } from 'node:fs';
26
26
  import { registerTool } from './index.js';
27
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
28
+ import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
28
29
  import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
29
30
  import { includeSnapshotType } from './include-snapshot.js';
30
31
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
@@ -177,6 +178,12 @@ registerTool({
177
178
  is_error: true,
178
179
  };
179
180
  }
181
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
182
+ {
183
+ const claim = grantClaimMismatch(verify.payload, { type: args.type, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
184
+ if (claim)
185
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
186
+ }
180
187
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
181
188
  const startedAt = Date.now();
182
189
  // ── Rule 9 conflict warning ──────────────────────────────────────────────
@@ -193,6 +200,13 @@ registerTool({
193
200
  // If the object is already locked in an open request, write under THAT
194
201
  // request — any other transport is a guaranteed CTS lock conflict.
195
202
  const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
203
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
204
+ if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
205
+ return {
206
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.name, resolved.owning) }),
207
+ is_error: true,
208
+ };
209
+ }
196
210
  // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
197
211
  // Only a genuine 404 (new object) may proceed snapshot-less. A read
198
212
  // failure on a possibly-EXISTING object means no recovery copy → block.
@@ -352,6 +366,12 @@ registerTool({
352
366
  is_error: true,
353
367
  };
354
368
  }
369
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
370
+ {
371
+ const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
372
+ if (claim)
373
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
374
+ }
355
375
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
356
376
  const startedAt = Date.now();
357
377
  // ── Rule 9 conflict warning ──────────────────────────────────────────────
@@ -366,6 +386,13 @@ registerTool({
366
386
  }
367
387
  // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
368
388
  const resolved = await resolveWriteTransport(ctx, 'CLAS', args.class_name, args.transport);
389
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
390
+ if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
391
+ return {
392
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.class_name, resolved.owning) }),
393
+ is_error: true,
394
+ };
395
+ }
369
396
  // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
370
397
  // sap_update_method always targets an EXISTING class, so a read failure
371
398
  // (non-404) means no recovery copy → block. (A genuine 404 falls through
@@ -674,6 +701,12 @@ registerTool({
674
701
  is_error: true,
675
702
  };
676
703
  }
704
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
705
+ {
706
+ const claim = grantClaimMismatch(verify.payload, { type: args.type, package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
707
+ if (claim)
708
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
709
+ }
677
710
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
678
711
  const startedAt = Date.now();
679
712
  // No snapshot — new object has no prior source.
@@ -780,6 +813,12 @@ registerTool({
780
813
  is_error: true,
781
814
  };
782
815
  }
816
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
817
+ {
818
+ const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
819
+ if (claim)
820
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
821
+ }
783
822
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
784
823
  const startedAt = Date.now();
785
824
  // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
@@ -794,6 +833,13 @@ registerTool({
794
833
  // The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
795
834
  // locked in a request from earlier work — battery S1's 409 root cause.
796
835
  const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
836
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
837
+ if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
838
+ return {
839
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.className, resolved.owning) }),
840
+ is_error: true,
841
+ };
842
+ }
797
843
  // ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
798
844
  // Not the class main source: for a behaviour pool that is an empty shell and
799
845
  // the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
@@ -928,6 +974,12 @@ registerTool({
928
974
  is_error: true,
929
975
  };
930
976
  }
977
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
978
+ {
979
+ const claim = grantClaimMismatch(verify.payload, { type: 'DOMA', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
980
+ if (claim)
981
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
982
+ }
931
983
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
932
984
  const startedAt = Date.now();
933
985
  // No snapshot — new object has no prior source.
@@ -1061,6 +1113,12 @@ registerTool({
1061
1113
  is_error: true,
1062
1114
  };
1063
1115
  }
1116
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1117
+ {
1118
+ const claim = grantClaimMismatch(verify.payload, { type: 'DTEL', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1119
+ if (claim)
1120
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1121
+ }
1064
1122
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1065
1123
  const startedAt = Date.now();
1066
1124
  // No snapshot — new object has no prior source.
@@ -1188,6 +1246,13 @@ registerTool({
1188
1246
  is_error: true,
1189
1247
  };
1190
1248
  }
1249
+ // 2026-10-04 — a plan-grant id activates only the objects it names (every
1250
+ // member, not just the first), with the listed type for a single object.
1251
+ {
1252
+ const claim = grantActivateMismatch(verify.payload, args.objects);
1253
+ if (claim)
1254
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1255
+ }
1191
1256
  // Round 7 item 8e — an approval auto_approve minted (no human saw it)
1192
1257
  // never activates an object whose last syntax check found errors. Any
1193
1258
  // approval minted after the errors went through the card (risk floor).