@cspeach/cli 1.1.22 → 1.1.24

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 (54) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/agent/tool-dispatch.js +4 -2
  3. package/dist/classifier/client.js +27 -2
  4. package/dist/cli.js +13 -0
  5. package/dist/commands/plan-audit-evidence.js +61 -4
  6. package/dist/commands/plan-audit.js +13 -1
  7. package/dist/commands/plan-continue.js +11 -1
  8. package/dist/commands/plan-resume.js +38 -1
  9. package/dist/commands/sap-link.js +52 -0
  10. package/dist/lane/app-chain.js +297 -0
  11. package/dist/lane/app-flow.js +55 -0
  12. package/dist/lane/card.js +148 -0
  13. package/dist/lane/chain-token.js +59 -0
  14. package/dist/lane/inputs.js +459 -0
  15. package/dist/lane/paths.js +21 -0
  16. package/dist/lane/resolve.js +109 -0
  17. package/dist/lane/roots.js +113 -0
  18. package/dist/lane/signals.js +80 -0
  19. package/dist/lane/store.js +232 -0
  20. package/dist/lane/types.js +1 -0
  21. package/dist/lib/shutdown-hooks.js +87 -0
  22. package/dist/one-shot.js +5 -0
  23. package/dist/projects/extract-plan.js +46 -6
  24. package/dist/projects/plan-lane.js +214 -0
  25. package/dist/projects/plan-run.js +5 -1
  26. package/dist/projects/plan-schema.js +12 -1
  27. package/dist/projects/promote-command.js +8 -2
  28. package/dist/projects/save-command.js +71 -25
  29. package/dist/projects/workspace.js +10 -0
  30. package/dist/renderer/tool-labels.js +6 -1
  31. package/dist/repl/credential-handover-gate.js +12 -6
  32. package/dist/repl/resume-standalone.js +3 -0
  33. package/dist/repl.js +27 -2
  34. package/dist/sap/connection-manager.js +30 -24
  35. package/dist/sap-link/policy.js +185 -0
  36. package/dist/sap-link/project.js +163 -0
  37. package/dist/sap-link/registry.js +259 -0
  38. package/dist/sap-link/server.js +351 -0
  39. package/dist/sap-link/upstream.js +367 -0
  40. package/dist/sap-link/write-log.js +60 -0
  41. package/dist/skill-catalog.js +14 -6
  42. package/dist/skills/bundled-skills.js +61 -54
  43. package/dist/tools/cap/_fetch-errors.js +20 -0
  44. package/dist/tools/cap/link-smoke.js +703 -0
  45. package/dist/tools/cap/metadata-import.js +325 -0
  46. package/dist/tools/cap/sap-link-tool.js +133 -0
  47. package/dist/tools/cap/service-catalog.js +248 -0
  48. package/dist/tools/fiori/preview/registry.js +30 -24
  49. package/dist/tools/lane/app-lane-resolve.js +329 -0
  50. package/dist/tools/local-build.js +15 -0
  51. package/dist/tools/subagent/_background-shared.js +59 -37
  52. package/dist/tools/subagent/background_run.js +9 -5
  53. package/dist/tools/tool-loading.js +2 -0
  54. package/package.json +121 -121
@@ -37,6 +37,10 @@ import { enrichUserMessage } from '../router/intent-extractor.js';
37
37
  import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
38
38
  import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
39
39
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
40
+ import { planLaneBlockForTurn } from '../projects/plan-lane.js';
41
+ import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
42
+ import { afterPlanSave, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
43
+ import { shownToken } from '../lane/card.js';
40
44
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
41
45
  import { input } from '@inquirer/prompts';
42
46
  import { withInquirer } from '../repl/inquirer-guard.js';
@@ -312,6 +316,10 @@ export async function maybeOfferSave(p) {
312
316
  confirm: isHeadless() ? undefined : inkAwareConfirm,
313
317
  log: (...lines) => lines.forEach((l) => p.emit(l)),
314
318
  promotedFrom: p.promotedFrom ?? null,
319
+ // Task 7 review round 2 — the app-flow lane only for the turn routed to
320
+ // the skill it saves (the /abap-plan turn that got the lane block); a plan
321
+ // found in another skill's output (detectArtifactSkill) never gets it.
322
+ ...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
315
323
  });
316
324
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
317
325
  // questions inline (recorded as `answered` items in the SAME file) — the
@@ -328,6 +336,18 @@ export async function maybeOfferSave(p) {
328
336
  log: (...lines) => lines.forEach((l) => p.emit(l)),
329
337
  });
330
338
  }
339
+ // App lane routing (Task 7) — the harness-owned /abap-app chain. Built
340
+ // from the exact saved path, never from model text. Outside an app flow
341
+ // (spec-gap) or without from.lane (plan) these do nothing. Review round 1:
342
+ // only when the turn was routed to the skill it saved — an artefact found
343
+ // in another skill's output (detectArtifactSkill) never moves the chain.
344
+ if (savedPath && p.appChain && p.skill === saveSkill) {
345
+ const hooks = { ...p.appChain, headless: isHeadless(), emit: p.emit };
346
+ if (saveSkill === 'abap-spec-gap')
347
+ await afterSpecGapSave(hooks, savedPath);
348
+ else if (saveSkill === 'abap-plan')
349
+ await afterPlanSave(hooks, savedPath);
350
+ }
331
351
  }
332
352
  catch (err) {
333
353
  p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
@@ -444,6 +464,64 @@ export async function runTurn(params) {
444
464
  // prepend a "Promoted from..." block to the LLM input, and stash the
445
465
  // snapshot for the post-turn save hook so the saved envelope's
446
466
  // promotedFrom field is populated.
467
+ // App lane routing (Task 7) — stage 1 of the /abap-app chain. `/abap-app
468
+ // @<spec>` (no --from) starts the session's app flow with the cleaned,
469
+ // workspace-relative spec path and runs as abap-spec-gap, so the spec-gap
470
+ // skill body does the work. A spec that is missing or outside the workspace
471
+ // is refused with one line: no flow (a stale one is dropped), and the turn
472
+ // still runs as spec-gap so the skill can say what is wrong.
473
+ // I1 (final review) — only an explicit slash command, or a fresh classifier
474
+ // pick (the session was not already in abap-app), or any turn with no flow
475
+ // at the lane step (final residual 3), starts stage 1. A plain reply at the
476
+ // lane step ("ok", "use RAP") falls through to the gate and stays in the flow.
477
+ if (!readerChild && !params.ctx.subagent && params.skill === 'abap-app' && !parseFromFlag(params.userMessage).fromPath
478
+ && (params.explicitSlash === true || session.skill !== 'abap-app' || getAppFlow(session)?.expect !== 'lane')) {
479
+ // I5 — the lane step needs app_lane_resolve (flag-gated: local_build or
480
+ // standalone). Check the same visible list the turn would send, and
481
+ // refuse before any model call rather than failing at stage 2.
482
+ if (!toolsForContext(listTools(), params.ctx).some((t) => t.name === 'app_lane_resolve')) {
483
+ clearAppFlow(session);
484
+ const raw = extractSpecToken(params.userMessage);
485
+ const tok = raw === undefined ? '' : cleanSpecToken(raw);
486
+ emit(chalk.yellow('/abap-app needs local build tools.'));
487
+ emit(chalk.yellow('Run: cspeach config set local_build on'));
488
+ emit(chalk.yellow(`Then restart cspeach and run /abap-app ${shownToken(tok || undefined, 'spec')} again.`));
489
+ return;
490
+ }
491
+ const spec = await resolveAppSpec(params.ctx.cwd, params.userMessage);
492
+ if (spec.ok) {
493
+ startAppFlow(session, spec.rel);
494
+ emit(STEP_1);
495
+ }
496
+ else {
497
+ clearAppFlow(session);
498
+ emit(chalk.yellow(spec.line));
499
+ }
500
+ params = { ...params, skill: 'abap-spec-gap' };
501
+ }
502
+ else if (!readerChild && !params.ctx.subagent) {
503
+ // R11 (review round 1) — any turn that is not part of the flow's current
504
+ // step ends the flow, so a stale flow never changes a later run.
505
+ const gate = await gateAppFlowForTurn(session, {
506
+ skill: params.skill,
507
+ userMessage: params.userMessage,
508
+ explicitSlash: params.explicitSlash === true,
509
+ previousSkill: session.skill,
510
+ cwd: params.ctx.cwd,
511
+ });
512
+ // I4(a) — the flow ends here: say once, plainly, that this plan will not
513
+ // use the build lane. Interactive only (headless / one-shot keep no flow).
514
+ if (gate.ended && gate.hadState) {
515
+ const { isHeadless } = await import('../renderer/tty.js');
516
+ if (!isHeadless())
517
+ emit(chalk.yellow(FLOW_ENDED_LINE));
518
+ }
519
+ }
520
+ // Stage 3 — the /abap-plan --from turn of an app flow at its plan step.
521
+ if (!readerChild && !params.ctx.subagent && params.skill === 'abap-plan' && params.suppressSaveHook !== true
522
+ && parseFromFlag(params.userMessage).fromPath && getAppFlow(session)?.expect === 'plan') {
523
+ emit(STEP_3);
524
+ }
447
525
  let promotedFromForSave = null;
448
526
  let userMessageForLLM = params.userMessage;
449
527
  let userMessageForSave = params.userMessage;
@@ -463,12 +541,23 @@ export async function runTurn(params) {
463
541
  }
464
542
  const { fromPath, rest } = parseFromFlag(docAttach ? '' : params.userMessage);
465
543
  if (fromPath) {
544
+ // M4 — inside an /abap-app flow the open-items question is asked once:
545
+ // a yes at stage 2 (lane step) is recorded on the flow, and stage 3
546
+ // (plan step) skips it when it promotes the same real file.
547
+ const flowNow = !params.ctx.subagent ? getAppFlow(session) : undefined;
548
+ const confirmedGap = flowNow?.gapConfirmed;
466
549
  const result = await runPromoteCommand({
467
550
  sourcePath: fromPath,
468
551
  targetSkill: params.skill,
469
552
  prompt: inkAwarePrompt,
470
553
  confirm: inkAwareConfirm,
471
554
  log: (...lines) => lines.forEach((l) => emit(l)),
555
+ // Final residual 4 — the same folder gateAppFlowForTurn resolves @X against.
556
+ cwd: params.ctx.cwd,
557
+ ...(flowNow?.expect === 'lane' && params.skill === 'abap-app'
558
+ ? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) } : {}),
559
+ ...(flowNow?.expect === 'plan' && params.skill === 'abap-plan' && confirmedGap
560
+ ? { skipOpenWarnings: (p) => sameRealFile(p, confirmedGap) } : {}),
472
561
  });
473
562
  if (!result) {
474
563
  // User declined, source invalid, or unsupported target — skip the turn.
@@ -519,6 +608,27 @@ ${imageExpansion.error}`));
519
608
  if (!readerChild) {
520
609
  userMessageForLLM = await expandTextFileAttachments(userMessageForLLM, params.ctx.cwd, (line) => emit(chalk.dim(line)));
521
610
  }
611
+ // App lane routing (Task 6) — the /abap-plan creation turn inside an app
612
+ // flow gets the confirmed <lane_decision> block (read with the app-flow sha;
613
+ // a changed file is refused with a plain line). '' on every other turn, so
614
+ // the model input is unchanged. Resume turns add their own block.
615
+ if (!readerChild) {
616
+ const laneBlock = await planLaneBlockForTurn({
617
+ skill: params.skill,
618
+ session,
619
+ cwd: params.ctx.cwd,
620
+ emit: (line) => emit(chalk.yellow(line)),
621
+ resumeTurn: params.suppressSaveHook === true,
622
+ });
623
+ // M5 — the lane cannot be used: the refusal line is out; end the turn
624
+ // now (no model call) and end the flow, which cannot reach a saved plan.
625
+ if (laneBlock === null) {
626
+ clearAppFlow(session);
627
+ return;
628
+ }
629
+ if (laneBlock)
630
+ userMessageForLLM = `${userMessageForLLM}\n\n${laneBlock}`;
631
+ }
522
632
  // 2026-05-06: alongside the deterministic prompt-fact extraction
523
633
  // (package / transport / etc.), inject the connected SAP system's
524
634
  // release + platform + ABAP version. One CVERS query the first time
@@ -1454,6 +1564,18 @@ ${imageExpansion.error}`));
1454
1564
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
1455
1565
  emit,
1456
1566
  promotedFrom: promotedFromForSave,
1567
+ ...(() => {
1568
+ const flowLane = getAppFlow(session)?.lane;
1569
+ return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
1570
+ })(),
1571
+ // Task 7 — subagent / reader turns never drive the app chain.
1572
+ ...(!readerChild && !params.ctx.subagent ? {
1573
+ appChain: {
1574
+ session,
1575
+ cwd: params.ctx.cwd,
1576
+ ...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
1577
+ },
1578
+ } : {}),
1457
1579
  });
1458
1580
  }
1459
1581
  }
@@ -1691,6 +1813,8 @@ ${imageExpansion.error}`));
1691
1813
  })();
1692
1814
  const isInteractiveTool = block.name === 'ask_question' ||
1693
1815
  block.name === 'request_approval' ||
1816
+ // App lane routing (Task 5): shows the lane card from inside the handler.
1817
+ block.name === 'app_lane_resolve' ||
1694
1818
  willTripBatchGate;
1695
1819
  // Interactive tools block on the USER. PAUSE the turn-status row (don't
1696
1820
  // just relabel it): a live 250ms tick repaints the dynamic frame and
@@ -38,6 +38,8 @@ export async function dispatchTool(name, args, ctx) {
38
38
  // silent). Headless runs are declined, never auto-approved.
39
39
  // The hook may be async (it reads the app's files). A hook that throws is
40
40
  // doubt — never let the call through unprompted: fail the call instead.
41
+ // `scope` (optional) keys the approval per tool too — sap_link sets it so a
42
+ // fiori_preview approval for the same alias does not cover it (Task 8 M-2).
41
43
  let handover = null;
42
44
  try {
43
45
  handover = (await tool.credentialHandover?.(args, ctx)) ?? null;
@@ -49,7 +51,7 @@ export async function dispatchTool(name, args, ctx) {
49
51
  };
50
52
  }
51
53
  const sessionId = ctx.session?.id;
52
- if (handover && !isCredentialHandoverApproved(sessionId, handover.alias)) {
54
+ if (handover && !isCredentialHandoverApproved(sessionId, handover.alias, handover.scope)) {
53
55
  const r = await presentSafetyConfirmation({
54
56
  rule: 'CREDENTIAL_HANDOVER',
55
57
  op: tool.name,
@@ -61,7 +63,7 @@ export async function dispatchTool(name, args, ctx) {
61
63
  });
62
64
  if (!r.confirmed)
63
65
  return declined(r);
64
- markCredentialHandoverApproved(sessionId, handover.alias);
66
+ markCredentialHandoverApproved(sessionId, handover.alias, handover.scope);
65
67
  }
66
68
  // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
67
69
  // explicit confirmation. Fires regardless of /safety-mode toggle (batch
@@ -1,7 +1,27 @@
1
1
  const RULES = [
2
+ // abap-app (app lane routing slice 1, 2026-10-01) — building an app FROM a
3
+ // spec / requirements document. FIRST so a spec-document build beats every
4
+ // stack-specific rule below (fiori-build, rap, cap, spec-gap, generate). A
5
+ // direct request without a spec document never matches and keeps its skill.
6
+ // Review round 1 (I2): a whole-prompt guard keeps "spec gap(s)" prompts on
7
+ // abap-spec-gap (whitespace only — a hyphenated spec-gap-….json file name
8
+ // does not trip it).
9
+ { skill: 'abap-app', patterns: [
10
+ /^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?\b(build|create|make|develop)\b[^.?!\n]{0,60}\b(app|application)\b[^.?!\n]{0,60}(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)/i,
11
+ /^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)[^.?!\n]{0,60}\b(build|create|make|develop)\b[^.?!\n]{0,30}\b(app|application)\b/i,
12
+ ] },
2
13
  { skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix/i] },
3
14
  { skill: 'abap-cca', patterns: [/custom\s+code\s+analysis|\bcca\b|clean\s+core\s+(assessment|analysis)/i] },
4
15
  { skill: 'abap-cloud-check', patterns: [/cloud\s+ready|clean\s+core(?!\s+(assessment|analysis))|s\/4hana\s+cloud\s+check/i] },
16
+ // abap-cap (2026-10-01) MUST precede abap-data-model ("CDS domain model"
17
+ // would hit its bare `domain`) and abap-fiori-build / abap-rap (a CAP app
18
+ // ships a Fiori Elements UI). Side-by-side / BTP framing → CAP.
19
+ // Review round 1 (I1): narrowed so no prompt that routed before abap-cap
20
+ // existed changes skill — "a cap on the quantity", "RAP/Fiori on BTP",
21
+ // "BTP destination in ZCL_X", "the CDS model ZI_X", "side by side" diffs.
22
+ // A bare BTP mention counts only when no RAP/ABAP/Fiori/UI5/Z-object word
23
+ // appears anywhere in the prompt.
24
+ { skill: 'abap-cap', patterns: [/\b(sap\s+)?cap\b(?=\s+(app|application|project|service|model|backend|extension|handler)s?\b)|\b(in|with|using|use)\s+(sap\s+)?cap\b|cloud\s+application\s+programming|\bcds\s+(model|service)\b(?!\s+(definition|[zy]\w*))|side.?by.?side\s+(app|application|extension)|^(?![\s\S]*\b(rap|abap|fiori|ui5|[zy]\w*_\w+)\b)[\s\S]*\bbtp\b/i] },
5
25
  { skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)/i] },
6
26
  { skill: 'abap-design', patterns: [/solution\s+architecture|object\s+list|design\s+the\s+solution/i] },
7
27
  { skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover/i] },
@@ -61,12 +81,17 @@ const RULES = [
61
81
  { skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
62
82
  { skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
63
83
  ];
64
- if (RULES.length !== 33) {
84
+ /** The skill each offline rule routes to, in rule order (one rule per skill). */
85
+ export const RULE_SKILLS = Object.freeze(RULES.map((r) => r.skill));
86
+ // 2026-10-01: 33 + abap-app + abap-cap = 35. The four merged-away donors
87
+ // (abap-cloud-check, abap-document, abap-performance, abap-radar) keep their
88
+ // rules: their .claude/skills folders are still on disk and still resolve.
89
+ if (RULES.length !== 35) {
65
90
  // Warn-not-throw — managed-mode users never exercise classify() (the proxy
66
91
  // does server-side classification), so a module-load crash would break
67
92
  // them for a local-only consistency check. Unit tests assert the exact
68
93
  // count.
69
- console.warn(`[classifier] RULES drift: expected 33, got ${RULES.length} — reconcile with .claude/skills/`);
94
+ console.warn(`[classifier] RULES drift: expected 35, got ${RULES.length} — reconcile with .claude/skills/`);
70
95
  }
71
96
  export function classify(prompt) {
72
97
  for (const rule of RULES) {
package/dist/cli.js CHANGED
@@ -203,6 +203,18 @@ export async function main(argv) {
203
203
  },
204
204
  });
205
205
  }
206
+ // CAP phase 2 — run the SAP link in the foreground for a local CAP app.
207
+ if (args[0] === 'sap' && args[1] === 'link') {
208
+ const { runSapLinkCommand } = await import('./commands/sap-link.js');
209
+ return runSapLinkCommand(args.slice(2), {
210
+ print: (s) => console.log(s),
211
+ defaultAlias: sapAlias,
212
+ standalone: async () => {
213
+ const { loadConfig, resolveStandalone } = await import('./config/loader.js');
214
+ return resolveStandalone(await loadConfig(), noSap);
215
+ },
216
+ });
217
+ }
206
218
  // Typed from yesterday's notes. Without this the words fall through to the
207
219
  // one-shot path at the bottom of this function and are sent to the model as
208
220
  // a PAID prompt on a machine that is configured and logged in. A hint, not
@@ -255,6 +267,7 @@ Usage:
255
267
  cspeach config set <key> <value> Update a config key
256
268
  cspeach config unset <key> Remove a config key (revert to default)
257
269
  cspeach config show [key] Show config (full or single key)
270
+ cspeach sap link [alias] [--project dir] Let a local CAP app reach its S/4 services (Ctrl+C stops)
258
271
  cspeach doctor Diagnose install + connectivity
259
272
  cspeach login Sign in via device-code flow
260
273
  cspeach logout Sign out (keep keychain key, if any)
@@ -69,6 +69,52 @@ const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_o
69
69
  const EXISTENCE_READ_RE = /^sap_(object_structure|get_source|transport_for_object)/;
70
70
  /** Any row the auditor may see is the union of the two tiers. */
71
71
  const EVIDENCE_TOOL_RE = new RegExp(`${PROTECTED_TOOL_RE.source}|${EXISTENCE_READ_RE.source}`);
72
+ /**
73
+ * CAP phase 2 (2026-09-30): a CAP phase writes LOCAL files and proves itself
74
+ * with `cds build` + the Fiori/CAP smokes — none of which are sap_* rows. Without
75
+ * these the auditor sees the "(no ...)" sentinel and B4-blocks a finished phase.
76
+ * PROTECTED tier: they are the proof, never evicted by reads.
77
+ *
78
+ * R26: the CAP-only tools (and `cds build` rows) exist only in CAP work, so they
79
+ * are admitted always. The fiori_* smokes also run in RAP/Fiori sessions; they
80
+ * are admitted ONLY when the audited phase is a cap.* phase, so RAP/Fiori audits
81
+ * stay byte-identical to main.
82
+ */
83
+ export const CAP_PROOF_TOOL_RE = /^(cap_metadata_import|cap_link_smoke)$/;
84
+ /** Fiori smokes: CAP proof only in a cap.* phase audit (R26). */
85
+ export const CAP_PHASE_SMOKE_TOOL_RE = /^(fiori_render_smoke|fiori_draft_smoke|fiori_metadata_audit)$/;
86
+ /** True for a CAP proof row (tool name + args), given whether the audited phase is cap.*. */
87
+ function isCapProof(toolName, args, capPhase) {
88
+ if (CAP_PROOF_TOOL_RE.test(toolName))
89
+ return true;
90
+ if (capPhase && CAP_PHASE_SMOKE_TOOL_RE.test(toolName))
91
+ return true;
92
+ return toolName === 'shell_exec' && isCapBuildShellRow(args);
93
+ }
94
+ /**
95
+ * A shell_exec row is CAP proof only for `cds build|compile|import`, with `cds`
96
+ * matched BY POSITION (controller ruling R6): argv[0] (`cds build`), argv[1]
97
+ * after `npx`, or argv[2] after `npm exec` / `pnpm exec`. Not `pnpm dlx cds`: it
98
+ * fetches the registry package `cds`, not @sap/cds-dk, so it proves nothing. Matching
99
+ * `cds` anywhere would let a safelisted row such as
100
+ * `git commit -m cds build` or `node x.js cds build` pass as PROTECTED build proof.
101
+ */
102
+ export function isCapBuildShellRow(args) {
103
+ if (!args || typeof args !== 'object')
104
+ return false;
105
+ const a = args;
106
+ const argv = [a.command, ...(Array.isArray(a.args) ? a.args : [])].map(String);
107
+ let i = -1;
108
+ if (argv[0] === 'cds')
109
+ i = 0;
110
+ else if (argv[0] === 'npx' && argv[1] === 'cds')
111
+ i = 1;
112
+ else if ((argv[0] === 'npm' || argv[0] === 'pnpm') && argv[1] === 'exec') {
113
+ if (argv[2] === 'cds')
114
+ i = 2;
115
+ }
116
+ return i >= 0 && ['build', 'compile', 'import'].includes(argv[i + 1] ?? '');
117
+ }
72
118
  const NO_LOG_SENTINEL = '(no tool-call log found for this session)';
73
119
  const NO_EVIDENCE_ROWS = '(tool-call log present, but no write/verify calls recorded for this session)';
74
120
  const DEFAULT_MAX_ROWS = 60;
@@ -101,7 +147,7 @@ function resultCapFor(toolName) {
101
147
  return ATC_RESULT_CAP;
102
148
  return RESULT_EXCERPT_CHARS;
103
149
  }
104
- function normalizeRow(raw) {
150
+ function normalizeRow(raw, capPhase) {
105
151
  if (raw === null || typeof raw !== 'object')
106
152
  return null;
107
153
  const r = raw;
@@ -119,9 +165,19 @@ function normalizeRow(raw) {
119
165
  // PROTECTED is checked first: it is a strict-enough family that no
120
166
  // existence-read name can collide with it, but ordering makes the tiering
121
167
  // explicit for the reader.
122
- const kind = PROTECTED_TOOL_RE.test(toolName) ? 'protected' : 'existence';
168
+ const kind = PROTECTED_TOOL_RE.test(toolName) || isCapProof(toolName, r.args, capPhase)
169
+ ? 'protected' : 'existence';
123
170
  return { toolName, args: r.args, result: r.result, isError, at, kind };
124
171
  }
172
+ /**
173
+ * Admission filter: the sap_* write/verify + existence-read tiers, plus CAP
174
+ * proof (cap_link_smoke / cap_metadata_import, the fiori_* smokes in a cap.*
175
+ * phase only, and shell_exec ONLY for `cds build|compile|import` — never
176
+ * arbitrary shell rows).
177
+ */
178
+ function isEvidenceRow(row, capPhase) {
179
+ return EVIDENCE_TOOL_RE.test(row.toolName) || isCapProof(row.toolName, row.args, capPhase);
180
+ }
125
181
  /** Render the args as `name/type` when available, else a compact fallback. */
126
182
  function renderArgs(args) {
127
183
  if (args === null || args === undefined || typeof args !== 'object')
@@ -201,6 +257,7 @@ function renderRow(row) {
201
257
  */
202
258
  export async function extractAuditEvidence(sessionId, opts) {
203
259
  const maxRows = opts?.maxRows ?? DEFAULT_MAX_ROWS;
260
+ const capPhase = opts?.capPhase === true;
204
261
  const sinceIso = opts?.sinceIso;
205
262
  const filePath = path.join(checkpointsRoot(), sessionId, 'tool-calls.jsonl');
206
263
  let content;
@@ -222,8 +279,8 @@ export async function extractAuditEvidence(sessionId, opts) {
222
279
  catch {
223
280
  continue; // malformed line — skip silently
224
281
  }
225
- const row = normalizeRow(parsed);
226
- if (!row || !EVIDENCE_TOOL_RE.test(row.toolName))
282
+ const row = normalizeRow(parsed, capPhase);
283
+ if (!row || !isEvidenceRow(row, capPhase))
227
284
  continue;
228
285
  // D-B attempt-window: drop rows recorded before this attempt started.
229
286
  // ISO-8601 UTC strings compare correctly lexicographically. A row with no
@@ -189,6 +189,13 @@ export const PLAN_AUDIT_PROMPT_CONTRACT = [
189
189
  '- Transport (infra, not a deliverable): recorded in work.transport (and/or an sap_transport',
190
190
  ' row). A transport is NEVER required in work.generated and is NEVER activated — do not flag',
191
191
  ' a transport for having no write and no activation.',
192
+ '- Local CAP work (phase layer cap.link / cap.model / cap.service / cap.ui): NO SAP object is',
193
+ ' written and NO sap_activate is expected — its absence is NEVER a finding for these layers.',
194
+ ' Proof is: an ok shell_exec `cds build` (or `cds compile`) row for the build, and for',
195
+ ' cap.link an ok cap_link_smoke live read leg (a result saying "mock only" does not count);',
196
+ ' for cap.model and cap.service an ok `cds build` plus an ok cap_link_smoke mock; for cap.ui',
197
+ ' ok fiori_render_smoke, ok fiori_draft_smoke when the app has draft, and ok cap_link_smoke.',
198
+ ' A cap_link_smoke result that says "mock only" proves the offline gate, not the live one.',
192
199
  'If you cannot map a type code to one of these classes, that is a W3 warn, not a block.',
193
200
  '',
194
201
  'ESCALATION EXCEPTION (§6, governs B3) — recognized from GROUND TRUTH, not the manifest\'s',
@@ -427,7 +434,12 @@ async function buildAuditPrompt(args) {
427
434
  manifest: phase.manifest,
428
435
  work: phase.work ?? null,
429
436
  }, null, 2);
430
- const evidence = await extractAuditEvidence(sessionId, sinceIso ? { sinceIso } : undefined);
437
+ // R26: fiori_* smoke rows are evidence only when the audited phase is a CAP phase.
438
+ const capPhase = typeof phase.layer === 'string' && phase.layer.startsWith('cap.');
439
+ const evidence = await extractAuditEvidence(sessionId, {
440
+ ...(sinceIso ? { sinceIso } : {}),
441
+ ...(capPhase ? { capPhase } : {}),
442
+ });
431
443
  const windowAttr = sinceIso ? ` window="this attempt only, since ${sinceIso}"` : '';
432
444
  return [
433
445
  `Independent audit of plan phase "${phaseId}" — plan "${envelope.title}" (envelope v${envelope.version}).`,
@@ -16,6 +16,7 @@ import { findNewestVersion } from './plan-resume.js';
16
16
  import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
17
17
  import { finishPlanResume } from './plan-resume.js';
18
18
  import { runPostPhaseChain } from './plan-chain.js';
19
+ import { loadPlanLaneForRun } from '../projects/plan-lane.js';
19
20
  /**
20
21
  * The same block shape loop.ts's manifest-on-interrupt hook scans for.
21
22
  * E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
@@ -76,7 +77,7 @@ export function classifyContinuationText(text, envelopeTitle) {
76
77
  * never leaves a fresh lock behind. Every null return means "run a normal
77
78
  * turn"; `stale` additionally tells the caller to clear the marker.
78
79
  */
79
- export function preparePlanContinuation(args) {
80
+ export async function preparePlanContinuation(args) {
80
81
  const { marker } = args;
81
82
  let path = marker.path;
82
83
  if (!existsSync(path))
@@ -99,6 +100,14 @@ export function preparePlanContinuation(args) {
99
100
  const phase = parsed.content.phases.find((p) => p.id === marker.phaseId);
100
101
  if (!phase)
101
102
  return { prepared: null, stale: true };
103
+ // App lane routing — same gate as preparePlanResume: pinned read, record
104
+ // re-validation, stored phases checked against the lane. Before the lease,
105
+ // so a paused plan never leaves a lock behind. Lane-less plans pass.
106
+ const laneGate = await loadPlanLaneForRun(args.cwd ?? process.cwd(), parsed.content);
107
+ if (!laneGate.ok) {
108
+ args.log(...laneGate.lines);
109
+ return { prepared: null, stale: false, paused: true };
110
+ }
102
111
  // Lease LAST: a stale marker (missing file / unknown phase) must never leave
103
112
  // a fresh lock behind. A LIVE other-session holder is contention, NOT stale —
104
113
  // the marker stays so the user can finish there or take over via --resume.
@@ -122,6 +131,7 @@ export function preparePlanContinuation(args) {
122
131
  ...(marker.effectiveMode ? { effectiveMode: marker.effectiveMode } : {}),
123
132
  ...(marker.auditFlag ? { auditFlag: marker.auditFlag } : {}),
124
133
  ...(marker.auditMode ? { auditMode: marker.auditMode } : {}),
134
+ ...(laneGate.rec ? { laneRecord: laneGate.rec } : {}),
125
135
  };
126
136
  return { prepared };
127
137
  }
@@ -54,6 +54,7 @@ import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
54
54
  import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
55
55
  import { askYesNo, isYes } from '../projects/yes-no.js';
56
56
  import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
57
+ import { checkLaneConsistency, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
57
58
  /**
58
59
  * Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
59
60
  * audit off-switch (`--audit` / `--no-audit`) flags from a plan-resume body.
@@ -227,6 +228,19 @@ export async function preparePlanResume(args) {
227
228
  return null;
228
229
  }
229
230
  let content = pc.content;
231
+ // App lane routing (Task 6, Review Focus 3) — a plan made for a lane reads
232
+ // the decision file with the sha it recorded, BEFORE anything else happens
233
+ // (no transport adoption, no lease, no phase). A changed or missing file
234
+ // pauses the plan; the edited lane is never used. Plans without from.lane
235
+ // skip this entirely.
236
+ // R5 fix round: the stored phases must also fit the lane, checked here
237
+ // before any phase, lease or transport work.
238
+ const laneGate = await loadPlanLaneForRun(args.cwd, content);
239
+ if (!laneGate.ok) {
240
+ args.log(...laneGate.lines);
241
+ return null;
242
+ }
243
+ const laneRecord = laneGate.rec;
230
244
  // Transport display on resume (2026-07-09) — adopt the plan's dedicated
231
245
  // transport into the session so the `tr <TRKORR>` status segment shows it.
232
246
  // A fresh process resuming mid-plan runs no sap_transport_create (the
@@ -400,6 +414,7 @@ export async function preparePlanResume(args) {
400
414
  auditMode: effectiveAudit,
401
415
  reauditPhaseId,
402
416
  reauditPhaseIds,
417
+ ...(laneRecord ? { laneRecord } : {}),
403
418
  };
404
419
  }
405
420
  if (!next) {
@@ -449,7 +464,13 @@ export async function preparePlanResume(args) {
449
464
  ruleBlocks.push(`<rule file="${rel}" missing="true"/> <!-- not found locally — apply built-in Forge defaults -->`);
450
465
  }
451
466
  }
452
- const planState = JSON.stringify({ title: envelope.title, version: envelope.version, statuses, content }, null, 2);
467
+ // App lane routing (Task 6): the lane ref is CLI-owned — the model sees the
468
+ // <lane_decision> block below, never the decision file path. A lane-less
469
+ // plan passes `content` itself (byte-identical plan_state).
470
+ const stateContent = content.from.lane
471
+ ? { ...content, from: (({ lane: _lane, ...rest }) => rest)(content.from) }
472
+ : content;
473
+ const planState = JSON.stringify({ title: envelope.title, version: envelope.version, statuses, content: stateContent }, null, 2);
453
474
  // A2 (defects D23/D30) — compact write-back: the model re-emitting the
454
475
  // full plan content every phase cost ~6–10k output tokens/phase at Opus
455
476
  // pricing for data the envelope already holds. The resume turn emits
@@ -502,6 +523,9 @@ export async function preparePlanResume(args) {
502
523
  ...ruleBlocks,
503
524
  '</phase_rules>',
504
525
  '',
526
+ // App lane routing (Task 6): the lane decision, so design and build phases
527
+ // stay in the lane. Absent ⇒ nothing added (byte-identical prompt).
528
+ ...(laneRecord ? [renderLaneBlock(laneRecord), ''] : []),
505
529
  // A1 (2026-06-10, defect D23): the old contract let the model ask a
506
530
  // continuation question and keep executing phases in-turn — a 6-phase
507
531
  // run snowballed to a 3.24M-token context because the turn never
@@ -531,6 +555,7 @@ export async function preparePlanResume(args) {
531
555
  nextPhaseWrites: phaseWrites(next),
532
556
  llmPrompt, mode: flags.mode, effectiveMode,
533
557
  auditFlag: flags.audit, auditMode: effectiveAudit,
558
+ ...(laneRecord ? { laneRecord } : {}),
534
559
  };
535
560
  }
536
561
  /**
@@ -729,6 +754,18 @@ export async function finishPlanResume(args) {
729
754
  args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
730
755
  return null;
731
756
  }
757
+ // App lane routing (Task 6, Review Focus 4) — the write-back may restructure
758
+ // the plan (full shape) or overlay a phase; it must still fit the lane.
759
+ if (args.prepared.laneRecord) {
760
+ const conflict = checkLaneConsistency(extract.content, args.prepared.laneRecord);
761
+ if (conflict) {
762
+ args.log('');
763
+ args.log(`[plan] PHASE RESULT NOT PERSISTED — ${conflict}`);
764
+ args.log('[plan] The plan envelope is unchanged. Check what the phase actually built, then');
765
+ args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
766
+ return null;
767
+ }
768
+ }
732
769
  const nowIso = new Date().toISOString();
733
770
  const statuses = extract.statuses;
734
771
  const executedId = args.prepared.nextPhaseId;
@@ -0,0 +1,52 @@
1
+ // cspeach-cli/src/commands/sap-link.ts
2
+ //
3
+ // CAP phase 2, Task 8 — `cspeach sap link [alias] [--project dir]`: run the SAP
4
+ // link in the foreground for a local CAP app. Ctrl+C stops it and exits 0.
5
+ // Never prompts for a password; refuses in standalone (no-SAP) mode; prints
6
+ // only plain registry messages, never raw exception text.
7
+ import { ensureSapLink, stopSapLink, SapLinkError } from '../sap-link/registry.js';
8
+ import { LinkConfigError } from '../sap-link/project.js';
9
+ function waitForSignal() {
10
+ return new Promise((resolve) => {
11
+ const done = () => {
12
+ process.off('SIGINT', done);
13
+ process.off('SIGTERM', done);
14
+ resolve();
15
+ };
16
+ process.once('SIGINT', done);
17
+ process.once('SIGTERM', done);
18
+ });
19
+ }
20
+ export async function runSapLinkCommand(argv, deps) {
21
+ if (deps.standalone && (await deps.standalone())) {
22
+ deps.print('The SAP link needs a SAP system. CSPeach is running without one (standalone).');
23
+ return 1;
24
+ }
25
+ const pi = argv.indexOf('--project');
26
+ const projectDir = pi >= 0 ? argv[pi + 1] ?? '.' : '.';
27
+ const alias = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--project') ?? deps.defaultAlias;
28
+ if (!alias) {
29
+ deps.print('Name the SAP system: cspeach sap link <alias>');
30
+ return 1;
31
+ }
32
+ try {
33
+ const r = await ensureSapLink(projectDir, alias, deps.linkDeps);
34
+ deps.print(`SAP link on: ${r.handle.url} → ${alias}`);
35
+ deps.print(`Services: ${r.services.join(', ')}`);
36
+ if (r.roleNote)
37
+ deps.print(r.roleNote);
38
+ if (r.tlsNote)
39
+ deps.print(r.tlsNote);
40
+ if (r.httpNote)
41
+ deps.print(r.httpNote);
42
+ deps.print('Start your app with: cds watch --profile s4 (Ctrl+C here stops the link)');
43
+ }
44
+ catch (e) {
45
+ deps.print(e instanceof SapLinkError || e instanceof LinkConfigError ? e.message : 'The SAP link could not start.');
46
+ return 1;
47
+ }
48
+ await (deps.waitForStop ?? waitForSignal)();
49
+ await stopSapLink(projectDir);
50
+ deps.print('SAP link stopped.');
51
+ return 0;
52
+ }