@cspeach/cli 1.0.0 → 1.1.0

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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -37,7 +37,7 @@
37
37
  *
38
38
  * FLAG / LOCAL_BUILD
39
39
  * ------------------
40
- * All four are flagGated:true (category 'fiori') and listed in
40
+ * Every fiori_* tool here is flagGated:true (category 'fiori') and listed in
41
41
  * LOCAL_BUILD_TOOLS, so `cspeach config set local_build on` enables them
42
42
  * alongside file_write/shell_exec — they are part of "build apps locally".
43
43
  *
@@ -53,6 +53,21 @@ import { applyEntry } from './apply.js';
53
53
  import { listCatalog } from './catalog/index.js';
54
54
  import { writeDeployConfig } from './deploy-config.js';
55
55
  import { scaffoldFioriElements } from './fe-scaffold.js';
56
+ import { feExtend, FE_EXTEND_OPS, NotV4FeAppError } from './fe-extend.js';
57
+ import { runSmoke } from './smoke/run-smoke.js';
58
+ import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
59
+ import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
60
+ import { searchSamples } from './samples/search.js';
61
+ import { loadSampleSource, SampleFetchError, UnsafeSampleNameError } from './samples/loader.js';
62
+ // The grounding corpus ships as an imported JSON module + the bundled
63
+ // SAMPLE_SOURCES map (read inside loadSampleSource). NEVER readFileSync the
64
+ // corpus — the import is the only shipping-safe read (rule A-C1).
65
+ import sampleIndexJson from './samples/data/index.json' with { type: 'json' };
66
+ const SAMPLE_INDEX = sampleIndexJson;
67
+ // Short provenance line, always attached to a fiori_sample_get payload so the
68
+ // model can cite the source (licensing constraint A-M1). Derived from the
69
+ // corpus NOTICE: OpenUI5 sources, adapted, under Apache-2.0.
70
+ const SAMPLE_ATTRIBUTION = 'Adapted from OpenUI5 (github.com/SAP/openui5) sample sources, licensed under Apache-2.0.';
56
71
  /** Resolve a user-supplied dir inside the project root, mapping the escape error. */
57
72
  function safeDir(ctx, userPath) {
58
73
  try {
@@ -213,6 +228,9 @@ export async function fioriScaffoldFeHandler(args, ctx) {
213
228
  return { content: 'error: appId is required', is_error: true };
214
229
  if (!args.mainEntity)
215
230
  return { content: 'error: mainEntity is required', is_error: true };
231
+ if (args.localAnnotations && (!args.localAnnotations.technicalName || !args.localAnnotations.xml)) {
232
+ return { content: 'error: localAnnotations requires both technicalName and xml', is_error: true };
233
+ }
216
234
  // metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
217
235
  const resolved = safeDir(ctx, args.basePath);
218
236
  if ('error' in resolved)
@@ -226,6 +244,7 @@ export async function fioriScaffoldFeHandler(args, ctx) {
226
244
  service: { url: args.serviceUrl, path: args.servicePath, version: args.serviceVersion ?? '4.0', metadata: args.metadata, client: args.client },
227
245
  mainEntity: args.mainEntity,
228
246
  ui5Version: args.ui5Version,
247
+ localAnnotations: args.localAnnotations,
229
248
  });
230
249
  }
231
250
  catch (err) {
@@ -239,6 +258,171 @@ export async function fioriScaffoldFeHandler(args, ctx) {
239
258
  `Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
240
259
  };
241
260
  }
261
+ // ───────────────────────────── fiori_fe_extend ────────────────────────────
262
+ export async function feExtendHandler(args, ctx) {
263
+ if (!args.basePath)
264
+ return { content: 'error: basePath is required', is_error: true };
265
+ if (!args.op)
266
+ return { content: 'error: op is required', is_error: true };
267
+ if (!FE_EXTEND_OPS.includes(args.op)) {
268
+ return { content: `error: unknown op "${args.op}" — expected one of ${FE_EXTEND_OPS.join(', ')}`, is_error: true };
269
+ }
270
+ const resolved = safeDir(ctx, args.basePath);
271
+ if ('error' in resolved)
272
+ return { content: `error: ${resolved.error}`, is_error: true };
273
+ const before = new Set(await listFilesUnder(ctx, resolved.abs));
274
+ try {
275
+ await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {} });
276
+ }
277
+ catch (err) {
278
+ // V4-only scope limit: fe-fpm-writer refuses non-V4-FE apps (no
279
+ // sap.fe.templates) — surfaced as a clean typed line, not the writer's
280
+ // internal wording.
281
+ if (err instanceof NotV4FeAppError)
282
+ return { content: `error: ${err.message}`, is_error: true };
283
+ return { content: `error: fe extend failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
284
+ }
285
+ // Report only the newly-written files (the generator also touches manifest.json).
286
+ const after = await listFilesUnder(ctx, resolved.abs);
287
+ const created = after.filter((f) => !before.has(f));
288
+ const appDir = rel(ctx, resolved.abs);
289
+ return {
290
+ content: `Added FE extension "${args.op}" to ${appDir}/ (manifest.json updated).\n` +
291
+ (created.length
292
+ ? `New files:\n${created.map((f) => ` ${f}`).join('\n')}`
293
+ : `No new files (manifest-only change).`),
294
+ };
295
+ }
296
+ /**
297
+ * FREESTYLE MODE (appDir given): derive a SmokeSpec from the app's own webapp/
298
+ * tree via deriveFreestyleSmokeSpec, then let explicit spec args override the
299
+ * derived fields FIELD-BY-FIELD (explicit wins). `appUrl` always comes from the
300
+ * arg — derivation produces no URL. Returns the merged spec + derivation warnings,
301
+ * or a clean `error` string (bad appDir, or a typed derivation failure — named).
302
+ *
303
+ * Kept as a pure, exported helper so the derive+merge contract is unit-testable
304
+ * without launching a browser; the handler only orchestrates.
305
+ */
306
+ export function resolveFreestyleSmokeSpec(args, ctx) {
307
+ const resolved = safeDir(ctx, args.appDir);
308
+ if ('error' in resolved)
309
+ return { error: resolved.error };
310
+ let derived;
311
+ try {
312
+ derived = deriveFreestyleSmokeSpec(resolved.abs);
313
+ }
314
+ catch (err) {
315
+ // Name the typed derivation failure (no stack trace) so the model sees WHY.
316
+ if (err instanceof FreestyleSmokeDerivationError) {
317
+ return { error: `${err.name}: ${err.message}` };
318
+ }
319
+ return { error: err instanceof Error ? err.message : String(err) };
320
+ }
321
+ const { warnings } = derived;
322
+ // Field-by-field merge (explicit wins). `??` keeps an explicitly-provided empty
323
+ // array/false as an override, and omits fields that are neither explicit nor derived.
324
+ const controlKind = args.controlKind ?? derived.controlKind;
325
+ const expectedColumns = args.expectedColumns ?? derived.expectedColumns;
326
+ const exercises = args.exercises ?? derived.exercises;
327
+ const spec = {
328
+ appUrl: args.appUrl, // always the arg — derivation carries no real preview URL
329
+ ...(controlKind !== undefined ? { controlKind } : {}),
330
+ ...(expectedColumns !== undefined ? { expectedColumns } : {}),
331
+ ...(args.allowEmptyRows !== undefined ? { allowEmptyRows: args.allowEmptyRows } : {}),
332
+ ...(exercises !== undefined ? { exercises } : {}),
333
+ };
334
+ return { spec, warnings };
335
+ }
336
+ export async function fioriRenderSmokeHandler(args, ctx,
337
+ // Test seam: injected runSmoke deps (browser/openApp/renderSmokeEnabled). In
338
+ // production this is undefined and the real config-driven deps are used.
339
+ deps) {
340
+ if (!args.appUrl) {
341
+ return {
342
+ content: 'error: appUrl is required — the LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / ' +
343
+ '`fiori run` URL, e.g. http://localhost:8080/index.html), NOT the deployed BSP URL.',
344
+ is_error: true,
345
+ };
346
+ }
347
+ // FE mode (no appDir): byte-identical to Track 2 — `args` is the spec, no warnings.
348
+ // Freestyle mode (appDir): derive + merge, and surface the derivation warnings.
349
+ let spec = args;
350
+ let warnings;
351
+ if (args.appDir) {
352
+ const derived = resolveFreestyleSmokeSpec(args, ctx);
353
+ if ('error' in derived) {
354
+ return {
355
+ content: `error: could not derive a freestyle smoke spec from appDir "${args.appDir}" — ${derived.error}`,
356
+ is_error: true,
357
+ };
358
+ }
359
+ spec = derived.spec;
360
+ warnings = derived.warnings;
361
+ }
362
+ // The render_smoke config toggle (plain default true) gates whether a browser
363
+ // is actually launched; when off, runSmoke returns a skipped manual result.
364
+ const cfg = await loadConfig();
365
+ const runDeps = { renderSmokeEnabled: resolveRenderSmoke(cfg), ...deps };
366
+ const result = await runSmoke(spec, runDeps);
367
+ // A skip is NOT a tool error — it's a valid `verification:'manual'` outcome the
368
+ // caller must see and act on (bring the preview up, etc.). Return the full
369
+ // SmokeResult as JSON either way. In freestyle mode attach the derivation
370
+ // warnings (always present — even []) so the model sees skipped presses/routes.
371
+ const payload = warnings !== undefined ? { ...result, warnings } : result;
372
+ return { content: JSON.stringify(payload, null, 2) };
373
+ }
374
+ // ─────────────────────────────── fiori_sample_search ──────────────────────
375
+ export async function fioriSampleSearchHandler(args, _ctx) {
376
+ if (!args.text || !args.text.trim()) {
377
+ return { content: 'error: text is required — free-text keywords to match against the sample corpus', is_error: true };
378
+ }
379
+ // searchSamples is pure (Task 2): score the imported index, keep only real
380
+ // matches, ordered by descending score. Shape each hit as a compact,
381
+ // CatalogIndexItem-style summary + its score for the model to pick from.
382
+ // Clamp at the tool boundary: a model-supplied 0, negative, or fractional max
383
+ // would otherwise reach searchSamples' slice as-is (0 → no hits at all, a
384
+ // negative → silently drops from the tail). Undefined still means "default".
385
+ const max = args.max === undefined ? undefined : Math.max(1, Math.floor(args.max) || 1);
386
+ const hits = searchSamples(SAMPLE_INDEX, { text: args.text, control: args.control, max });
387
+ const summaries = hits.map(({ entry, score }) => ({
388
+ name: entry.name,
389
+ control: entry.control,
390
+ library: entry.library,
391
+ description: entry.description,
392
+ keywords: entry.keywords,
393
+ vendored: entry.sourceRef.vendored,
394
+ score,
395
+ }));
396
+ return { content: JSON.stringify(summaries, null, 2) };
397
+ }
398
+ // ─────────────────────────────── fiori_sample_get ─────────────────────────
399
+ export async function fioriSampleGetHandler(args, _ctx) {
400
+ if (!args.name || !args.name.trim()) {
401
+ return { content: 'error: name is required — the sample name from fiori_sample_search (e.g. "sap.m/Wizard")', is_error: true };
402
+ }
403
+ const entry = SAMPLE_INDEX.find((e) => e.name === args.name);
404
+ if (!entry) {
405
+ return {
406
+ content: `error: sample "${args.name}" is not found in the sample index — call fiori_sample_search first to get a valid name.`,
407
+ is_error: true,
408
+ };
409
+ }
410
+ // loadSampleSource (Task 3): vendored → bundled SAMPLE_SOURCES; un-vendored →
411
+ // lazy fetch + cache. Its typed errors carry actionable, model-facing messages
412
+ // (fall back to recipes + sap-docs) — surface them verbatim, don't mask them.
413
+ try {
414
+ const loaded = await loadSampleSource(entry, {});
415
+ return {
416
+ content: JSON.stringify({ name: entry.name, files: loaded.files, license: 'Apache-2.0', attribution: SAMPLE_ATTRIBUTION }, null, 2),
417
+ };
418
+ }
419
+ catch (err) {
420
+ if (err instanceof SampleFetchError || err instanceof UnsafeSampleNameError) {
421
+ return { content: `error: ${err.message}`, is_error: true };
422
+ }
423
+ return { content: `error: could not load sample "${args.name}" — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
424
+ }
425
+ }
242
426
  // ─────────────────────────────── registration ─────────────────────────────
243
427
  registerTool({
244
428
  name: 'fiori_scaffold',
@@ -327,7 +511,7 @@ registerTool({
327
511
  });
328
512
  registerTool({
329
513
  name: 'fiori_scaffold_fe',
330
- description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, or Overview Page) ' +
514
+ description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, Overview Page, or Analytical List Page) ' +
331
515
  'against an ALREADY-PUBLISHED OData service. Driven entirely by the backend @UI annotations on the ' +
332
516
  'CDS projection/DDLX — authors NO local annotation.xml (not an FE generator). metadata is OPTIONAL ' +
333
517
  '(the running app reads it from the live service); pass it only if you already have it. basePath ' +
@@ -341,7 +525,7 @@ registerTool({
341
525
  basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root).' },
342
526
  appId: { type: 'string', description: 'App namespace, e.g. "z.tcrs.courses".' },
343
527
  appTitle: { type: 'string', description: 'Human-readable title. Defaults to appId.' },
344
- template: { type: 'string', enum: ['lrop', 'worklist', 'ovp'], description: 'FE template. Default "lrop". All three ship.' },
528
+ template: { type: 'string', enum: ['lrop', 'worklist', 'ovp', 'alp'], description: 'FE template: lrop (List Report), worklist, ovp (Overview Page), or alp (Analytical List Page). Default "lrop". All four ship.' },
345
529
  serviceUrl: { type: 'string', description: 'Backend host, e.g. "https://host:44300".' },
346
530
  servicePath: { type: 'string', description: 'OData service path, e.g. "/sap/opu/odata4/sap/zc_x/srvd/sap/zc_x/0001/".' },
347
531
  serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version. Default "4.0".' },
@@ -349,8 +533,149 @@ registerTool({
349
533
  client: { type: 'string', description: 'OPTIONAL SAP client (e.g. "100"), written to the manifest for the proxy layer.' },
350
534
  mainEntity: { type: 'string', description: 'The entity set to bind, e.g. "Course".' },
351
535
  ui5Version: { type: 'string', description: 'UI5 version. Default 1.120.0.' },
536
+ localAnnotations: {
537
+ type: 'object',
538
+ description: 'OPTIONAL local annotations.xml for a FOREIGN OData service you do NOT own (cannot add backend @UI). ' +
539
+ 'Wires the given EDMX as a LOCAL ODataAnnotation dataSource (annotations/<technicalName>.xml) that ' +
540
+ 'resolves at runtime — no catalog fetch. Omit for services whose backend already carries @UI annotations.',
541
+ properties: {
542
+ technicalName: { type: 'string', description: 'Local annotation name; also the dataSource key and file name (annotations/<technicalName>.xml).' },
543
+ xml: { type: 'string', description: 'The annotations EDMX (UI.LineItem/HeaderInfo/SelectionFields/Facets in EDMX form).' },
544
+ },
545
+ required: ['technicalName', 'xml'],
546
+ },
352
547
  },
353
548
  required: ['basePath', 'appId', 'serviceUrl', 'servicePath', 'mainEntity'],
354
549
  },
355
550
  handler: fioriScaffoldFeHandler,
356
551
  });
552
+ registerTool({
553
+ name: 'fiori_fe_extend',
554
+ description: 'Add a Fiori Elements extension point to an EXISTING V4 FE app on disk via @sap-ux/fe-fpm-writer: ' +
555
+ 'a custom-column (table column + fragment), custom-action (toolbar/table action), custom-section ' +
556
+ '(object-page section + fragment), or controller-extension (a .controller.js/.ts + manifest wiring). ' +
557
+ 'V4 Fiori Elements ONLY (LROP/Worklist/ALP/FEOP) — OVP and all OData V2 apps are refused with a typed ' +
558
+ 'error. params map 1:1 to fe-fpm-writer\'s CustomTableColumn / CustomAction / CustomSection / ' +
559
+ 'ControllerExtension config and are passed verbatim. basePath is sandboxed to the project root.',
560
+ isMutating: true,
561
+ category: 'fiori',
562
+ flagGated: true,
563
+ input_schema: {
564
+ type: 'object',
565
+ properties: {
566
+ basePath: { type: 'string', description: 'App root of the existing V4 FE app (the folder containing webapp/manifest.json), relative to the project root.' },
567
+ op: {
568
+ type: 'string',
569
+ enum: [...FE_EXTEND_OPS],
570
+ description: 'Extension point to add: custom-column, custom-action, custom-section, or controller-extension.',
571
+ },
572
+ params: {
573
+ type: 'object',
574
+ description: 'Extension config, passed VERBATIM to the matching fe-fpm-writer generator. Required fields per op: ' +
575
+ 'custom-column → { name, target (routing target, e.g. "<Entity>List"), targetEntity, position { placement: "After"|"Before"|"End", anchor? }, header }; ' +
576
+ 'custom-action → { name, target { page (routing target), control ("@com.sap.vocabularies.UI.v1.LineItem" for a table action) }, settings { text } }; ' +
577
+ 'custom-section → { name, target (object-page routing target, e.g. "<Entity>ObjectPage"), title }; ' +
578
+ 'controller-extension → { name, extension ("ListReport"|"ObjectPage" or a page-target object) }. ' +
579
+ 'eventHandler is optional on column/action/section.',
580
+ },
581
+ },
582
+ required: ['basePath', 'op', 'params'],
583
+ },
584
+ handler: feExtendHandler,
585
+ });
586
+ registerTool({
587
+ name: 'fiori_render_smoke',
588
+ description: 'Content-asserting render smoke (Track 2 D-4): launch a headless Edge/Chrome against the LOCAL ' +
589
+ 'AUTHENTICATED PREVIEW (the `npm run start` URL — NEVER the deployed BSP URL) and assert the app ' +
590
+ 'actually rendered — booted with no console errors, no failed OData ($metadata/$batch/entity) calls, ' +
591
+ 'expected columns present, and (for a list) rows returned. TWO MODES: FIORI-ELEMENTS mode — pass the ' +
592
+ 'spec fields (expectedColumns/controlKind/…) yourself. FREESTYLE mode — pass `appDir` (the scaffolded ' +
593
+ 'app root) plus `appUrl` and the spec is DERIVED from the app\'s own webapp/ views + i18n + manifest ' +
594
+ '(controlKind, expected columns, press/route exercises); any spec field you ALSO pass overrides the ' +
595
+ 'derived one. Derivation notes surface as a `warnings` array in the result. SKIPPABLE BUT NEVER ' +
596
+ 'SILENTLY GREEN: when the render_smoke config is off, no browser is found, the preview is unreachable, ' +
597
+ 'or the page is a SAP logon form, it returns verification:"manual" + passed:false with a "skipped" ' +
598
+ 'check explaining why — it can never report a passing smoke without a real render. Flag-gated (enabled ' +
599
+ 'with local_build), read-only.',
600
+ isMutating: false,
601
+ category: 'fiori',
602
+ flagGated: true,
603
+ input_schema: {
604
+ type: 'object',
605
+ properties: {
606
+ appUrl: {
607
+ type: 'string',
608
+ description: 'The LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / `fiori run` URL that proxies to the ' +
609
+ 'real backend with your auth), e.g. "http://localhost:8080/index.html". NOT the deployed BSP URL.',
610
+ },
611
+ appDir: {
612
+ type: 'string',
613
+ description: 'FREESTYLE mode only: the scaffolded app root (relative to the project root, sandboxed). When ' +
614
+ 'given, the SmokeSpec is DERIVED from that app\'s webapp/ views + i18n + manifest, and any spec ' +
615
+ 'field you also pass overrides the derived one. Omit for Fiori Elements mode (pass the spec fields directly).',
616
+ },
617
+ expectedColumns: {
618
+ type: 'array',
619
+ items: { type: 'string' },
620
+ description: 'Column headers that MUST be present (checked case-insensitively). Omit for non-list controls.',
621
+ },
622
+ controlKind: {
623
+ type: 'string',
624
+ enum: ['table', 'cards', 'chart', 'form', 'custom'],
625
+ description: 'The app\'s primary control. Only "table" gets a row-count assertion.',
626
+ },
627
+ allowEmptyRows: {
628
+ type: 'boolean',
629
+ description: 'When true, a table that renders 0 rows still passes (empty list acknowledged). Default false.',
630
+ },
631
+ exercises: {
632
+ type: 'array',
633
+ description: 'Post-boot interactions to exercise (press/route). Declared for Track 3; unused in Track 2.',
634
+ items: { type: 'object' },
635
+ },
636
+ },
637
+ required: ['appUrl'],
638
+ },
639
+ handler: fioriRenderSmokeHandler,
640
+ });
641
+ registerTool({
642
+ name: 'fiori_sample_search',
643
+ description: 'Search the vendored UI5 Demo Kit sample corpus (grounding for the freestyle composer): free-text ' +
644
+ 'keywords + an optional exact control name return the best-matching samples as scored summaries ' +
645
+ '(name, control, library, description, keywords, vendored flag, score), highest score first. Use this ' +
646
+ 'to FIND a real UI5 sample to ground a control against, then fiori_sample_get to read its source. ' +
647
+ 'Read-only; flag-gated (enabled with local_build).',
648
+ isMutating: false,
649
+ category: 'fiori',
650
+ flagGated: true,
651
+ input_schema: {
652
+ type: 'object',
653
+ properties: {
654
+ text: { type: 'string', description: 'Free-text keywords, e.g. "wizard step" or "value help dialog".' },
655
+ control: { type: 'string', description: 'OPTIONAL exact UI5 control name (e.g. "Wizard") — an exact match dominates the ranking.' },
656
+ max: { type: 'number', description: 'Max results to return. Default 5.' },
657
+ },
658
+ required: ['text'],
659
+ },
660
+ handler: fioriSampleSearchHandler,
661
+ });
662
+ registerTool({
663
+ name: 'fiori_sample_get',
664
+ description: 'Fetch one UI5 sample\'s source files by name (the grounding payload the composer reads). Returns ' +
665
+ '{ files: [{ path, content }], license: "Apache-2.0", attribution }. Vendored samples are served from ' +
666
+ 'the bundled corpus; un-vendored ones are lazily fetched and cached. The Apache-2.0 attribution line ' +
667
+ 'is ALWAYS included. Call fiori_sample_search first to get a valid name. An unknown name, or a sample ' +
668
+ 'that cannot be retrieved, returns a clean error steering you to the recipes catalog + sap-docs. ' +
669
+ 'Read-only; flag-gated (enabled with local_build).',
670
+ isMutating: false,
671
+ category: 'fiori',
672
+ flagGated: true,
673
+ input_schema: {
674
+ type: 'object',
675
+ properties: {
676
+ name: { type: 'string', description: 'The sample name from fiori_sample_search, e.g. "sap.m/Wizard".' },
677
+ },
678
+ required: ['name'],
679
+ },
680
+ handler: fioriSampleGetHandler,
681
+ });
@@ -10,8 +10,13 @@
10
10
  *
11
11
  * filesystem: file_read, file_write, file_edit, glob, grep
12
12
  * shell: shell_exec
13
- * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config
13
+ * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
14
+ * fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
15
+ * fiori_sample_search, fiori_sample_get
14
16
  * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
17
+ * subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
18
+ * dispatch when local_build is on; plan phases force its
19
+ * read-only tool filter regardless of the model's args)
15
20
  *
16
21
  * This is what makes /abap-fiori-build runnable end to end — it writes local
17
22
  * app files (FS tools), runs safelisted build CLIs (shell), AND scaffolds /
@@ -56,8 +61,13 @@ export const LOCAL_BUILD_TOOLS = [
56
61
  'fiori_list',
57
62
  'fiori_deploy_config',
58
63
  'fiori_scaffold_fe',
64
+ 'fiori_fe_extend',
65
+ 'fiori_render_smoke',
66
+ 'fiori_sample_search',
67
+ 'fiori_sample_get',
59
68
  'extend_model_insert',
60
69
  'system_capability',
70
+ 'agent_run',
61
71
  ];
62
72
  /**
63
73
  * Apply the persisted `local_build` toggle. When on, force-enables the FS +
@@ -32,7 +32,9 @@ registerTool({
32
32
  + 'omitting it lets SAP choose (usually active). When the object has '
33
33
  + 'pending inactive changes and the caller did not pass `version`, '
34
34
  + 'the response is the active source AND a warning is emitted so the '
35
- + 'caller knows the inactive edits are not visible.',
35
+ + 'caller knows the inactive edits are not visible. For a freshly '
36
+ + 'created object with NO active version yet, the inactive (working) '
37
+ + 'source is returned with version="inactive" and an honest note.',
36
38
  isMutating: false,
37
39
  input_schema: {
38
40
  type: 'object',
@@ -68,15 +70,41 @@ registerTool({
68
70
  const conflict = inactive.find((o) => o.name.toUpperCase() === upperName &&
69
71
  (o.type === '' || o.type.toUpperCase().startsWith(upperType)));
70
72
  if (conflict) {
71
- // SAP defaults to the active version when version is unspecified,
72
- // but the caller should know an inactive copy exists so they don't
73
- // base dependent edits on stale committed source.
74
- resolvedVersion = 'active';
75
- splitStateWarning =
76
- `${args.type} ${args.name} has a pending inactive version. `
77
- + `This response is the ACTIVE source; pending edits are NOT included. `
78
- + `Pass version="inactive" to read the in-flight version, or have the user `
79
- + `activate (or discard) the inactive version before basing dependent code on this read.`;
73
+ // Fable review 2026-07-06 (clas-create-hygiene I1): a FRESH shell
74
+ // (created, never activated) ALSO appears in inactiveObjects(), but
75
+ // it has NO active version at all — sap-client's inactive-read
76
+ // fallback just served the INACTIVE (working) source. Claiming
77
+ // "this is the ACTIVE source" here would be false. Cross-check
78
+ // objectStructure's adtcore:version (honest for fresh shells now
79
+ // that objectStructure carries the same inactive fallback):
80
+ // 'inactive' ⇒ inactive-only shell.
81
+ let inactiveOnly = false;
82
+ try {
83
+ const struct = await ctx.adt.objectStructure(args.type, args.name);
84
+ const m = /\badtcore:version="([^"]*)"/.exec(struct.rawXml ?? '');
85
+ inactiveOnly = m?.[1] === 'inactive';
86
+ }
87
+ catch {
88
+ // Cross-check failed — fall through to the conservative
89
+ // split-state warning (never claim inactive-only without proof).
90
+ }
91
+ if (inactiveOnly) {
92
+ resolvedVersion = 'inactive';
93
+ splitStateWarning =
94
+ `${args.type} ${args.name} has no active version yet — this is the `
95
+ + `inactive (working) source. Activate it via sap_activate when ready.`;
96
+ }
97
+ else {
98
+ // SAP defaults to the active version when version is unspecified,
99
+ // but the caller should know an inactive copy exists so they don't
100
+ // base dependent edits on stale committed source.
101
+ resolvedVersion = 'active';
102
+ splitStateWarning =
103
+ `${args.type} ${args.name} has a pending inactive version. `
104
+ + `This response is the ACTIVE source; pending edits are NOT included. `
105
+ + `Pass version="inactive" to read the in-flight version, or have the user `
106
+ + `activate (or discard) the inactive version before basing dependent code on this read.`;
107
+ }
80
108
  }
81
109
  else {
82
110
  resolvedVersion = 'active';
@@ -112,7 +140,22 @@ registerTool({
112
140
  },
113
141
  handler: async (args, ctx) => {
114
142
  const result = await ctx.adt.objectStructure(args.type, args.name);
115
- return { content: JSON.stringify(result, null, 2) };
143
+ // Fable review 2026-07-05 (audit-reverify item 2) — surface the object's
144
+ // adtcore:version EARLY in the JSON. The sap-client ObjectStructure has
145
+ // no parsed version field; adtcore:version lives deep inside rawXml,
146
+ // beyond the audit evidence's 200-char result excerpt — so the audit
147
+ // contract's "sap_object_structure reporting an active version" proof
148
+ // path for verify-only re-runs was unreachable in production, and a
149
+ // re-run that skipped sap_inactive_objects would false-FAIL an active
150
+ // object. Parsing here (CLI-side, no sap-client bump) and placing
151
+ // `version` right after name/type puts it inside the excerpt window.
152
+ const versionMatch = /\badtcore:version="([^"]*)"/.exec(result.rawXml ?? '');
153
+ const version = versionMatch?.[1] || 'unknown';
154
+ // name/type/version lead the serialized JSON (rawXml stays last via the
155
+ // rest spread), so `version` sits inside the excerpt window while every
156
+ // ObjectStructure field is preserved.
157
+ const { name, type, ...rest } = result;
158
+ return { content: JSON.stringify({ name, type, version, ...rest }, null, 2) };
116
159
  },
117
160
  });
118
161
  // -----------------------------------------------------------------------
@@ -475,6 +518,31 @@ registerTool({
475
518
  handler: async (args, ctx) => {
476
519
  // runUnitTest(type, name) — type first
477
520
  const result = await ctx.adt.runUnitTest(args.type, args.name);
521
+ // B2 false-green guard (project_aunit_run_gap): the ADT AUnit-run endpoint
522
+ // returns HTTP 200 with an EMPTY runResult (total:0, no programs) even for a
523
+ // class that is active + syntax-clean with a valid FOR TESTING local class —
524
+ // a known environmental gap, exhaustively probed (sap-client buildUnitTestXml
525
+ // comment). The raw {total:0} shape reads to a caller like "0 tests, nothing
526
+ // failed → green", so an unguarded pass-through risks a false-green test gate.
527
+ // When we detect that empty signature, wrap the (still-carried) raw result in
528
+ // an explicit UNVERIFIED envelope that names Eclipse "Run As → ABAP Unit" as
529
+ // the authoritative check. A genuine run (total > 0) passes through unchanged.
530
+ const summary = result?.summary;
531
+ const programs = result?.programs;
532
+ const noTestsRun = summary?.total === 0 && (!Array.isArray(programs) || programs.length === 0);
533
+ if (noTestsRun) {
534
+ return {
535
+ content: JSON.stringify({
536
+ verification: 'UNVERIFIED',
537
+ warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
538
+ 'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
539
+ 'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
540
+ 'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
541
+ 'this as a green test gate.',
542
+ result,
543
+ }, null, 2),
544
+ };
545
+ }
478
546
  return { content: JSON.stringify(result, null, 2) };
479
547
  },
480
548
  });
@@ -48,15 +48,27 @@ function emitSnapshotNotice(ctx, snap, type, name) {
48
48
  ctx.chunkEmitter?.emit('info', `snapshot ✓ ${type} ${name} ${snap.entry.timestamp}`);
49
49
  }
50
50
  }
51
+ /** Distinct LOUD note when the write proceeds against a fresh/never-activated
52
+ * shell that has no committed active version — nothing to snapshot (Rule 7 is
53
+ * satisfied because there is no prior source to protect). Kept separate from
54
+ * the 'new_object' (genuine 404) path so the transcript is honest about which
55
+ * case it was — this is the RAP behaviour-pool ZBP first-write. */
56
+ function emitNewShellNote(ctx, type, name) {
57
+ ctx.chunkEmitter?.emit('warn', `${type} ${name}: new/empty shell — no active version, no prior source to snapshot; proceeding with first write`);
58
+ }
51
59
  /** Map the snapshot-gate outcome onto the chain line's honest states.
52
60
  * `undefined` covers the best-effort gates whose failure was swallowed.
53
61
  * 'object_not_found' is a genuine ADT 404 only; any other read failure maps
54
- * to 'source_read_failed' (+ short detail) — never to "(new object)". */
62
+ * to 'source_read_failed' (+ short detail) — never to "(new object)".
63
+ * 'new_shell' (exists but no active version — nothing to save) reuses the
64
+ * 'new_object' chain glyph; the honest which-case distinction is carried by
65
+ * the LOUD emitNewShellNote line, not the compact chain. */
55
66
  function snapshotChainState(snap) {
56
67
  if (snap?.taken)
57
68
  return { snapshot: 'taken' };
58
- if (snap?.reason === 'object_not_found')
69
+ if (snap?.reason === 'object_not_found' || snap?.reason === 'new_shell') {
59
70
  return { snapshot: 'new_object' };
71
+ }
60
72
  if (snap?.reason === 'source_read_failed') {
61
73
  return { snapshot: 'source_read_failed', snapshotDetail: snap.detail };
62
74
  }
@@ -128,11 +140,15 @@ registerTool({
128
140
  error: ERR.SNAPSHOT_FAILED,
129
141
  reason: 'source_read_failed',
130
142
  detail: snapOutcome.detail,
131
- recovery: 'Pre-write source read failed (non-404) — the object may exist but no snapshot could be taken. Fix the read failure (connection/auth) and retry; do not bypass Rule 7.',
143
+ recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the object may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
132
144
  }),
133
145
  is_error: true,
134
146
  };
135
147
  }
148
+ // Fresh/never-activated shell (no active version → nothing to snapshot):
149
+ // proceed like a new object, but say so LOUDLY and distinctly.
150
+ if (snapOutcome?.reason === 'new_shell')
151
+ emitNewShellNote(ctx, args.type, args.name);
136
152
  emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
137
153
  // ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
138
154
  if (isSafetyConfirmEnabled()) {
@@ -285,11 +301,15 @@ registerTool({
285
301
  error: ERR.SNAPSHOT_FAILED,
286
302
  reason: 'source_read_failed',
287
303
  detail: snapOutcome.detail,
288
- recovery: 'Pre-write source read failed (non-404) — the class exists but no snapshot could be taken. Fix the read failure (connection/auth) and retry; do not bypass Rule 7.',
304
+ recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
289
305
  }),
290
306
  is_error: true,
291
307
  };
292
308
  }
309
+ // Fresh/never-activated class shell (no active version → nothing to
310
+ // snapshot): proceed like a new object, but say so LOUDLY and distinctly.
311
+ if (snapOutcome?.reason === 'new_shell')
312
+ emitNewShellNote(ctx, 'CLAS', args.class_name);
293
313
  emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.class_name);
294
314
  // ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
295
315
  if (!ctx.previewHook) {
@@ -66,7 +66,33 @@ export async function autoSnapshot(objectName, objectType, adt) {
66
66
  catch (err) {
67
67
  if (isNotFound(err))
68
68
  return { taken: false, reason: 'object_not_found' }; // new object
69
- return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
69
+ // The DEFAULT read failed for a non-404 reason. Before treating this as a
70
+ // hard read failure (which blocks the write, Rule 7), distinguish an
71
+ // empty/never-activated SHELL from a real object whose source we couldn't
72
+ // read. A freshly-created behaviour-pool CLAS (ZBP … FOR BEHAVIOR OF …)
73
+ // EXISTS but has no committed source yet, so the default read returns HTTP
74
+ // 400 — not 404. Such a shell has NO committed ACTIVE version. Probe for one:
75
+ // - active read 404 → no active version ⇒ fresh shell, nothing to
76
+ // snapshot ⇒ proceed ('new_shell').
77
+ // - active read succeeds → there IS committed source to protect ⇒
78
+ // snapshot THAT active source and proceed.
79
+ // - active read fails otherwise → can't confirm emptiness ⇒ conservative
80
+ // block ('source_read_failed', unchanged).
81
+ let activeSrc;
82
+ try {
83
+ activeSrc = await adt.getSource(objectType, objectName, 'active');
84
+ }
85
+ catch (activeErr) {
86
+ if (isNotFound(activeErr))
87
+ return { taken: false, reason: 'new_shell' };
88
+ // Could not confirm the object is empty — keep the safety property: a
89
+ // real object with source we couldn't read must still block the write.
90
+ return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
91
+ }
92
+ // Committed active version exists — snapshot it. A snapshots.take failure
93
+ // still THROWS (Rule 7 hard-fail), same as the clean-read path below.
94
+ const activeEntry = await snapshots.take(objectType, objectName, activeSrc, 'before_write');
95
+ return { taken: true, entry: activeEntry };
70
96
  }
71
97
  const entry = await snapshots.take(objectType, objectName, src, 'before_write');
72
98
  return { taken: true, entry };