@cspeach/cli 1.1.51 → 1.1.53

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 (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
14
14
  import { bytesToHex } from '@noble/hashes/utils';
15
15
  import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
16
16
  import { fileSkillsCache } from './skills-cache.js';
17
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
17
18
  // Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
18
19
  export { SkillIntegrityError };
19
20
  export class SkillNotInPlanError extends Error {
@@ -69,10 +70,10 @@ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch)
69
70
  throw new SkillSignInError();
70
71
  let r;
71
72
  try {
72
- r = await doFetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
73
+ r = await fetchWithConnectRetry(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, () => ({
73
74
  headers: { Authorization: `Bearer ${bearer}` },
74
75
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
- });
76
+ }), { fetchImpl: doFetch });
76
77
  }
77
78
  catch {
78
79
  throw new SkillsUnavailableError('could not reach CSPeach');
@@ -6,6 +6,14 @@ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
6
6
  import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
7
7
  import { objectsWithSyntaxErrors } from './syntax-state.js';
8
8
  import { noteWriteTransport } from '../repl/current-transport.js';
9
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
10
+ /**
11
+ * LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
12
+ * card opens and no approval id is minted. The shape dispatchTool returns for
13
+ * a tool that never started (the tool_use stays paired), with a text that says
14
+ * nothing ran.
15
+ */
16
+ const stoppedBeforeCard = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
9
17
  /**
10
18
  * Round 8 item 6 — the footer showed "no transport" while the approved card
11
19
  * said "transport S4HK903731". An approval granted for an explicit transport
@@ -29,7 +37,7 @@ import { promptAdvisory } from '../approvals/advisory-prompt.js';
29
37
  import { markPlanGateApproved } from '../repl/rule8-detector.js';
30
38
  import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
31
39
  import { getCurrentTransport } from '../repl/current-transport.js';
32
- import { displayTransport } from '../approvals/op-labels.js';
40
+ import { displayTransport, knownTypeWord } from '../approvals/op-labels.js';
33
41
  import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
34
42
  import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
35
43
  import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
@@ -223,7 +231,8 @@ registerTool({
223
231
  type: 'object',
224
232
  properties: {
225
233
  op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
226
- object: { type: 'string' },
234
+ // P-2 (1A fix wave): a group word ('DCLs') is not an object the plan grant can match.
235
+ object: { type: 'string', description: "One real object name per row (comma-join several only in an activate row), never a group word such as 'DCLs'." },
227
236
  type: { type: 'string' },
228
237
  diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
229
238
  package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
@@ -298,6 +307,10 @@ registerTool({
298
307
  // no role configured and no session override this is exactly
299
308
  // cfg.write_mode — byte-identical behavior.
300
309
  const role = cfg.sap[ctx.sapAlias]?.role;
310
+ // LT-15 D6 — stopped during the profile read above: no advisory card, no card.
311
+ const stopA = stoppedBeforeCard(ctx);
312
+ if (stopA)
313
+ return stopA;
301
314
  // Advisory-only short-circuit — never mint approval_ids.
302
315
  // The developer applies the change manually in ADT; the AI gets an
303
316
  // ai_directive telling it not to attempt any SAP write tools.
@@ -313,11 +326,17 @@ registerTool({
313
326
  return { content: JSON.stringify(releaseRefusal), is_error: true };
314
327
  // Task 8 (B11) — the request that already holds each modified object, before any card.
315
328
  await attachOwningTransport(args.changes ?? [], ctx);
329
+ // LT-15 D6 — stopped during the reads above: no card, no nag, no id minted
330
+ // (the plan-grant and auto-approve paths below mint without a card).
331
+ const stopB = stoppedBeforeCard(ctx);
332
+ if (stopB)
333
+ return stopB;
316
334
  let sapCfg = cfg.sap[ctx.sapAlias];
317
335
  const riskCtx = {
318
336
  sapAlias: ctx.sapAlias,
319
337
  autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
320
338
  syntaxErrorObjects: objectsWithSyntaxErrors(),
339
+ typeWord: knownTypeWord, // LT-10: the syntax-errors reason names the kind of object
321
340
  };
322
341
  const changes = args.changes;
323
342
  const declared = args.risk;
@@ -421,6 +440,10 @@ registerTool({
421
440
  sapCfg = cfg.sap[ctx.sapAlias];
422
441
  riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
423
442
  }
443
+ // LT-15 D6 (review M3) — stopped while the nag was up: no card after it, no id.
444
+ const stopC = stoppedBeforeCard(ctx);
445
+ if (stopC)
446
+ return stopC;
424
447
  // 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
425
448
  // yes. Live, auto_approve="low" let the model's own "low" create package
426
449
  // ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
@@ -478,12 +501,14 @@ registerTool({
478
501
  // for one task" complaint. Review-each falls back to the per-change
479
502
  // gate. Cancel rejects the whole plan.
480
503
  //
481
- // Task 9 (C8): only for more than one VISIBLE change — the same count the
482
- // plan gate shows (an activate whose write is in the list is not a change
483
- // a person sees). A modify + its activate on one class gets the
484
- // per-change card only; the activate follows its approved write below.
504
+ // Task 9 (C8): only for more than one VISIBLE change (an activate whose
505
+ // write is in the list is not a change a person sees). A modify + its
506
+ // activate on one class gets the per-change card only; the activate
507
+ // follows its approved write below. LT-12: the plan gate's LIST also
508
+ // hides a modify that a create of the same object covers (planRows), so
509
+ // it may show fewer rows than this count; this count is unchanged.
485
510
  let skipPerChangeGates = false;
486
- // Raw keys on purpose: the same collapse (and count) the plan gate displays.
511
+ // Raw keys on purpose: activates collapsed, nothing else.
487
512
  const visibleCount = collapseImplicitActivates(changes).length;
488
513
  if (visibleCount > 1) {
489
514
  const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
@@ -549,7 +574,23 @@ registerTool({
549
574
  outcome = { approved: false, reason: 'sibling_write_skipped' };
550
575
  }
551
576
  else {
552
- outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
577
+ // LT-15 D6 (review M3) — stopped during the mint after the card before:
578
+ // the next card does not open; ids minted so far are dropped unused.
579
+ const stopD = stoppedBeforeCard(ctx);
580
+ if (stopD)
581
+ return stopD;
582
+ // 1.1.53 item 8 (live F-C): the batch risk put another object's reason
583
+ // on this card. Each card shows the risk of its own change, plus the
584
+ // activate of the same object that this card approves with it. One
585
+ // visible change keeps the batch risk, exactly as before. Display only:
586
+ // the gates above and the result fields keep `eff`.
587
+ const cardRisk = visibleCount <= 1 ? eff : effectiveRisk(declared, [
588
+ c,
589
+ ...((c.op === 'modify' || c.op === 'create')
590
+ ? changes.filter((a) => a.op === 'activate' && `${a.type}:${canonicalApprovalObject(a.object)}` === objKey)
591
+ : []),
592
+ ], riskCtx);
593
+ outcome = await renderPerChangeApprovalV3(c, cardRisk, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
553
594
  }
554
595
  if (outcome.approved) {
555
596
  if (c.op === 'modify' || c.op === 'create')
@@ -44,7 +44,7 @@ registerTool({
44
44
  properties: {
45
45
  command: {
46
46
  type: 'string',
47
- description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
47
+ description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`. Never put a transport description in it unless the person typed it.',
48
48
  },
49
49
  },
50
50
  required: ['command'],
@@ -5,6 +5,9 @@
5
5
  * chat turn then ends and the workflow runs with its own plan + approvals.
6
6
  * No extra consent card (D5): the workflow asks before any write.
7
7
  * Refused outside the chat lane; dispatch_skill stays the skills' own switch.
8
+ * Stage 1A (T1A.4): in an inline session (`ctx.inlineSkills`) a playbook turn
9
+ * may hand off too — still only queues — except while a plan is in charge or
10
+ * the slot already holds a step (the harness's next-phase hop wins).
8
11
  *
9
12
  * Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
10
13
  * session never lists it and its tools array stays byte-identical to today.
@@ -14,7 +17,37 @@ import { isPlanResumeCommand } from '../commands/plan-resume.js';
14
17
  import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
15
18
  /** Longest request hand_off queues (after whitespace is collapsed). */
16
19
  export const HAND_OFF_REQUEST_MAX = 500;
20
+ /**
21
+ * I3 (owner, 2026-10-22) — one hand-off per person message. Refusal code when
22
+ * the running turn was itself started by a hand_off: no workflow-to-workflow
23
+ * chain of reads and credits without a new person message. A new person
24
+ * message resets it (the REPL passes who started each turn as `ctx.turnBy`).
25
+ * dispatch_skill after the person's pick is not capped.
26
+ */
27
+ export const HAND_OFF_CHAIN_CAPPED = 'HAND_OFF_CHAIN_CAPPED';
17
28
  export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
29
+ /** The success note when the model wrote nothing before the call in this turn. */
30
+ export const HAND_OFF_NOTE = 'The workflow runs next and asks before it writes. End the turn now. If you wrote a line before the call, add nothing. If you wrote nothing, add one short line saying the workflow takes it from here. Do not print the command.';
31
+ /**
32
+ * LT-3 (live check 2026-10-26) — the model often skipped the "if you wrote a
33
+ * line" test and announced the hand-off twice. The loop knows whether text
34
+ * came first in this turn and then gives this plain note instead.
35
+ */
36
+ export const HAND_OFF_NOTE_ALREADY_SAID = 'The workflow runs next and asks before it writes. You already told the developer in this turn, so end the turn now and write nothing more. Do not print the command.';
37
+ /** The hand_off success content with the note for a turn that already said its line. Anything else unchanged. */
38
+ export function settleHandOffNote(content, saidBefore) {
39
+ if (!saidBefore)
40
+ return content;
41
+ try {
42
+ const r = JSON.parse(content);
43
+ if (r.ok !== true || r.note !== HAND_OFF_NOTE)
44
+ return content;
45
+ return JSON.stringify({ ...r, note: HAND_OFF_NOTE_ALREADY_SAID });
46
+ }
47
+ catch {
48
+ return content;
49
+ }
50
+ }
18
51
  export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
19
52
  const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
20
53
  /**
@@ -45,15 +78,25 @@ registerTool({
45
78
  type: 'object',
46
79
  properties: {
47
80
  skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
48
- request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
81
+ request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags. Never put a transport description in it unless the person typed it.' },
49
82
  },
50
83
  required: ['skill', 'request'],
51
84
  },
52
85
  handler: async (args, ctx) => {
53
86
  const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
54
- if (ctx.session?.lane !== 'chat') {
87
+ // Stage 1A (T1A.4) — in an inline session a playbook turn may hand off too.
88
+ const playbookTurn = ctx.session?.lane !== 'chat';
89
+ if (playbookTurn && !ctx.inlineSkills) {
55
90
  return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
56
91
  }
92
+ // The plan harness queues its next phase through the same slot; never race it.
93
+ if (playbookTurn && (ctx.session?.plan_in_flight || ctx.session?.skill === 'abap-plan')) {
94
+ return bad('HAND_OFF_PLAN_IN_FLIGHT', 'finish this step; after the plan, the person can ask for that in chat');
95
+ }
96
+ // I3 — one hand-off per person message.
97
+ if (ctx.turnBy === 'hand_off') {
98
+ return bad(HAND_OFF_CHAIN_CAPPED, "this workflow was already handed off from the person's message; one hand-off per message. Finish this workflow's answer, then let the person decide the next step: offer that workflow as a suggestion in your answer instead of calling hand_off. Do not call dispatch_skill either unless the person picks it.");
99
+ }
57
100
  const skill = normaliseSkill(args.skill);
58
101
  if (!skill) {
59
102
  return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
@@ -74,13 +117,12 @@ registerTool({
74
117
  if (!ctx.pendingDispatch) {
75
118
  return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
76
119
  }
120
+ if (playbookTurn && ctx.pendingDispatch.get() !== null) {
121
+ return bad('HAND_OFF_SLOT_TAKEN', 'a step is already queued; end the turn');
122
+ }
77
123
  ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
78
124
  return {
79
- content: JSON.stringify({
80
- ok: true,
81
- queued: command,
82
- note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
83
- }),
125
+ content: JSON.stringify({ ok: true, queued: command, note: HAND_OFF_NOTE }),
84
126
  };
85
127
  },
86
128
  });
@@ -36,6 +36,9 @@ import { askConfirm } from '../../ui/confirm-request.js';
36
36
  import { isHeadless } from '../../renderer/tty.js';
37
37
  import { loadConfig } from '../../config/loader.js';
38
38
  import { getSapSystemInfo } from '../../sap/system-info.js';
39
+ import { STOPPED_BEFORE_START_RESULT } from '../../agent/repair-partial.js';
40
+ /** LT-15 D6 (review M2) — after a stop (Esc / Ctrl+C) no card opens and nothing is saved. */
41
+ const stopped = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
39
42
  const fail = (message) => ({ content: `error: ${message}`, is_error: true });
40
43
  const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
41
44
  const STOP = 'STOP — lane needs your choice.';
@@ -200,6 +203,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
200
203
  }
201
204
  else {
202
205
  // 7. The lane card.
206
+ const stopLane = stopped(ctx);
207
+ if (stopLane)
208
+ return stopLane;
203
209
  const confirm = deps.confirm ?? ((req) => askConfirm(req));
204
210
  const card = laneCardRequest(decision);
205
211
  const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
@@ -224,6 +230,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
224
230
  }
225
231
  // 8. A CAP lane needs a runtime.
226
232
  if (finalLane.backend === 'cap' && !runtime) {
233
+ const stopRuntime = stopped(ctx);
234
+ if (stopRuntime)
235
+ return stopRuntime;
227
236
  const rr = await confirm(runtimeCardRequest());
228
237
  const answer = rr.value;
229
238
  if (!answer || !RUNTIMES.includes(answer))
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Rule 7a in code (2026-10-22, owner go).
3
+ *
4
+ * sap_set_source on an existing class replaces the whole source. When the
5
+ * only thing that changed is the inside of one or more method bodies, the
6
+ * write must go through sap_update_method instead (one call per method): a
7
+ * whole-class rewrite can silently drop parameters, alter untouched methods
8
+ * or lose local definitions.
9
+ *
10
+ * `methodOnlyChange(current, proposed)` splits both sources into
11
+ * - the frame: every line outside method bodies (definition part(s), the
12
+ * IMPLEMENTATION header, the `METHOD x.` / `ENDMETHOD.` lines, ENDCLASS);
13
+ * - each method body: the lines strictly between `METHOD x.` and `ENDMETHOD.`.
14
+ * It returns the names of the changed methods ONLY when the frame is
15
+ * identical, the methods are the same (names, order) and at least one body
16
+ * differs. Anything else returns undefined, and the write goes on as today.
17
+ *
18
+ * Normalised: line endings (CRLF / LF / CR) and trailing whitespace (per
19
+ * line, and blank lines at the end of the source). Nothing else.
20
+ *
21
+ * Conservative: when the split is not certain, return undefined (never
22
+ * refuse). That covers unbalanced METHOD / ENDMETHOD, a METHOD statement with
23
+ * more than its name (AMDP `BY DATABASE PROCEDURE …`), the words METHOD /
24
+ * ENDMETHOD anywhere else in code, macros (DEFINE), INCLUDE programs, an
25
+ * unterminated literal, and any changed method that sap_update_method could
26
+ * not address exactly (its name appears more than once as `METHOD name.`, or
27
+ * its body holds the text `ENDMETHOD.`).
28
+ *
29
+ * Never guarded, by design: a pragma or anything else after the name on the
30
+ * METHOD line (e.g. ADT's ZCX exception-class template), AMDP methods, a
31
+ * component or parameter named "method", and a class with a pending inactive
32
+ * version (anyone's: sap_update_method would refuse it with 409).
33
+ */
34
+ /** The audit-line detail: at most 5 names, then "+N more" (the audit log keeps 200 chars). */
35
+ export function methodOnlyAuditDetail(methods) {
36
+ const shown = methods.slice(0, 5).join(',');
37
+ const more = methods.length > 5 ? ` +${methods.length - 5} more` : '';
38
+ return `refused: method_only_change (Rule 7a) methods=${shown}${more}`;
39
+ }
40
+ /** The model-facing refusal for a method-only change. */
41
+ export function methodOnlyRefusal(methods) {
42
+ return {
43
+ error: 'method_only_change',
44
+ detail: `Only the bodies of these methods changed: ${methods.join(', ')}. `
45
+ + 'Use sap_update_method once per method instead of rewriting the whole class (Rule 7a). '
46
+ + 'The class definition and the other methods stay untouched that way. '
47
+ + 'Your approval id still works for those calls.',
48
+ };
49
+ }
50
+ const METHOD_START = /^METHOD\s+([^\s.]+)\s*\.$/i;
51
+ const METHOD_END = /^ENDMETHOD\s*\.$/i;
52
+ /** METHOD / ENDMETHOD as a word: not part of a longer name (METHODS, ls_x-method, lo->method, if~method). */
53
+ const KEYWORD = /(?<![\w\-~>\/=%$])(END)?METHOD(?![\w\-~\/%$])/gi;
54
+ const DEFINE_STMT = /(^|\.)\s*DEFINE\s/i;
55
+ /** An INCLUDE program; `INCLUDE TYPE` / `INCLUDE STRUCTURE` inside TYPES / DATA are not. */
56
+ const INCLUDE_STMT = /(^|\.)\s*INCLUDE\s+(?!(TYPE|STRUCTURE)\s)/i;
57
+ /** Names (upper case) of the methods whose bodies alone changed; undefined when the write must go on. */
58
+ export function methodOnlyChange(current, proposed) {
59
+ const a = splitClassSource(current);
60
+ const b = splitClassSource(proposed);
61
+ if (!a || !b)
62
+ return undefined;
63
+ if (a.methods.length === 0 || a.methods.length !== b.methods.length)
64
+ return undefined;
65
+ if (!sameLines(a.frame, b.frame))
66
+ return undefined;
67
+ const changed = [];
68
+ for (let i = 0; i < a.methods.length; i++) {
69
+ const ma = a.methods[i];
70
+ const mb = b.methods[i];
71
+ if (ma.name.toUpperCase() !== mb.name.toUpperCase())
72
+ return undefined;
73
+ if (!sameLines(ma.body, mb.body))
74
+ changed.push(ma.name.toUpperCase());
75
+ }
76
+ if (changed.length === 0)
77
+ return undefined;
78
+ for (const name of changed) {
79
+ if (!updateMethodCanAddress(current, name) || !updateMethodCanAddress(proposed, name))
80
+ return undefined;
81
+ }
82
+ return changed;
83
+ }
84
+ /**
85
+ * sap_update_method (sap-client replaceMethodBlock) takes the FIRST
86
+ * `METHOD name.` match anywhere in the source and the body up to the first
87
+ * `ENDMETHOD.` after it. Only point at it when that lands on our block.
88
+ */
89
+ function updateMethodCanAddress(source, name) {
90
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ const starts = source.match(new RegExp(`\\bMETHOD\\s+${escaped}\\s*\\.`, 'gi')) ?? [];
92
+ if (starts.length !== 1)
93
+ return false;
94
+ const split = splitClassSource(source);
95
+ const block = split?.methods.find((m) => m.name.toUpperCase() === name);
96
+ return !!block && !block.body.some((l) => /\bENDMETHOD\s*\./i.test(l));
97
+ }
98
+ function sameLines(x, y) {
99
+ return x.length === y.length && x.every((l, i) => l === y[i]);
100
+ }
101
+ /** The frame / body split, or undefined when it is not certain. Exported for tests. */
102
+ export function splitClassSource(source) {
103
+ const lines = source.replace(/\s+$/, '').split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, ''));
104
+ const frame = [];
105
+ const methods = [];
106
+ let open;
107
+ for (const line of lines) {
108
+ if (line.startsWith('*') || line.trimStart().startsWith('"')) {
109
+ (open ? open.body : frame).push(line);
110
+ continue;
111
+ }
112
+ const code = codeOf(line);
113
+ if (code === undefined)
114
+ return undefined;
115
+ if (DEFINE_STMT.test(code) || INCLUDE_STMT.test(code))
116
+ return undefined;
117
+ const stmt = code.trim();
118
+ const start = METHOD_START.exec(stmt);
119
+ if (start) {
120
+ if (open)
121
+ return undefined;
122
+ frame.push(line);
123
+ open = { name: start[1], body: [] };
124
+ continue;
125
+ }
126
+ if (METHOD_END.test(stmt)) {
127
+ if (!open)
128
+ return undefined;
129
+ methods.push(open);
130
+ open = undefined;
131
+ frame.push(line);
132
+ continue;
133
+ }
134
+ if (hasStrayKeyword(code))
135
+ return undefined;
136
+ (open ? open.body : frame).push(line);
137
+ }
138
+ if (open)
139
+ return undefined;
140
+ return { frame, methods };
141
+ }
142
+ /** METHOD / ENDMETHOD in code other than a whole `METHOD x.` / `ENDMETHOD.` line. `CALL METHOD` is a call. */
143
+ function hasStrayKeyword(code) {
144
+ for (const m of code.matchAll(KEYWORD)) {
145
+ const isEnd = !!m[1];
146
+ if (!isEnd && /\bCALL\s+$/i.test(code.slice(0, m.index)))
147
+ continue;
148
+ return true;
149
+ }
150
+ return false;
151
+ }
152
+ /**
153
+ * The code part of one line: literal contents blanked, a trailing `"`
154
+ * comment dropped. Undefined when a literal does not close on the line.
155
+ */
156
+ function codeOf(line) {
157
+ let out = '';
158
+ for (let i = 0; i < line.length; i++) {
159
+ const ch = line[i];
160
+ if (ch === '"')
161
+ break;
162
+ if (ch === "'" || ch === '`') {
163
+ let j = i + 1;
164
+ for (; j < line.length; j++) {
165
+ if (line[j] === ch) {
166
+ if (line[j + 1] === ch) {
167
+ j++;
168
+ continue;
169
+ } // doubled quote = escaped
170
+ break;
171
+ }
172
+ }
173
+ if (j >= line.length)
174
+ return undefined;
175
+ out += ch + ' '.repeat(j - i - 1) + ch;
176
+ i = j;
177
+ continue;
178
+ }
179
+ if (ch === '|') {
180
+ let j = i + 1;
181
+ for (; j < line.length; j++) {
182
+ if (line[j] === '\\') {
183
+ j++;
184
+ continue;
185
+ }
186
+ if (line[j] === '|')
187
+ break;
188
+ }
189
+ if (j >= line.length)
190
+ return undefined;
191
+ out += '|' + ' '.repeat(j - i - 1) + '|';
192
+ i = j;
193
+ continue;
194
+ }
195
+ out += ch;
196
+ }
197
+ return out;
198
+ }
199
+ /**
200
+ * Runs the guard for one sap_set_source call. Classes only (the caller checks
201
+ * the type). Returns `refuse` with the changed methods, or `go` with the read
202
+ * outcome for the snapshot to reuse (undefined when nothing was read).
203
+ *
204
+ * Before refusing, the class must have NO pending inactive version — the same
205
+ * check sap-client's updateMethod makes (it refuses with 409 when the class is
206
+ * listed). Otherwise refusing would leave no way to write the class at all.
207
+ * That second read happens only on the refusal path; if it fails, never refuse.
208
+ */
209
+ export async function runMethodOnlyGuard(adt, g) {
210
+ if (typeof adt?.getSource !== 'function')
211
+ return { go: undefined };
212
+ let outcome;
213
+ try {
214
+ outcome = { source: await adt.getSource(g.type, g.name) };
215
+ }
216
+ catch (error) {
217
+ outcome = { error };
218
+ }
219
+ if (!('source' in outcome) || typeof outcome.source !== 'string')
220
+ return { go: outcome };
221
+ const changed = methodOnlyChange(outcome.source, g.source);
222
+ if (!changed)
223
+ return { go: outcome };
224
+ if (await classIsInactive(adt, g.name) !== false)
225
+ return { go: outcome };
226
+ return { refuse: changed };
227
+ }
228
+ /** true / false from the inactive-objects list; undefined when it could not be checked. */
229
+ async function classIsInactive(adt, name) {
230
+ if (typeof adt.inactiveObjects !== 'function')
231
+ return undefined;
232
+ try {
233
+ const upper = name.toUpperCase();
234
+ const list = await adt.inactiveObjects();
235
+ return list.some((o) => String(o.name ?? '').toUpperCase() === upper
236
+ && (o.type === '' || String(o.type ?? '').toUpperCase().startsWith('CLAS')));
237
+ }
238
+ catch {
239
+ return undefined;
240
+ }
241
+ }