@cspeach/cli 1.1.26 → 1.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/agent/loop.js +41 -5
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/commands/whoami.js +1 -1
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/checks/skill.js +60 -11
  29. package/dist/doctor/run.js +4 -6
  30. package/dist/lane/app-chain.js +116 -9
  31. package/dist/one-shot.js +1 -0
  32. package/dist/projects/extract-design.js +4 -3
  33. package/dist/projects/extract-estimate.js +4 -3
  34. package/dist/projects/extract-plan.js +34 -1
  35. package/dist/projects/extract-spec-gap.js +28 -5
  36. package/dist/projects/handover-md.js +1 -1
  37. package/dist/projects/plan-objects.js +75 -0
  38. package/dist/projects/plan-run.js +74 -18
  39. package/dist/projects/plan-schema.js +6 -0
  40. package/dist/projects/plan-sources.js +199 -0
  41. package/dist/projects/promote-command.js +1 -1
  42. package/dist/projects/save-command.js +15 -14
  43. package/dist/projects/saved-lines.js +36 -0
  44. package/dist/projects/title-heading.js +36 -0
  45. package/dist/renderer/checklist-format.js +5 -5
  46. package/dist/renderer/machine-blocks.js +141 -0
  47. package/dist/renderer/thinking-heartbeat.js +2 -1
  48. package/dist/renderer/tool-labels.js +3 -0
  49. package/dist/repl/plan-hop.js +22 -0
  50. package/dist/repl/rule8-detector.js +16 -1
  51. package/dist/repl.js +320 -55
  52. package/dist/router/router-core.js +2 -2
  53. package/dist/sap/reachability-state.js +25 -0
  54. package/dist/sap/unreachable.js +4 -1
  55. package/dist/session/audit-export.js +10 -16
  56. package/dist/session/recap.js +16 -26
  57. package/dist/skills/bundled-skills.js +1 -1
  58. package/dist/tools/_filesystem-shared.js +37 -0
  59. package/dist/tools/approval.js +48 -2
  60. package/dist/tools/dispatch-skill.js +3 -2
  61. package/dist/tools/filesystem/file-read.js +5 -3
  62. package/dist/tools/filesystem/glob.js +27 -4
  63. package/dist/tools/fiori/tools.js +6 -0
  64. package/dist/tools/lane/app-lane-resolve.js +1 -1
  65. package/dist/tools/sap-read.js +37 -3
  66. package/dist/tools/sap-write.js +65 -0
  67. package/dist/tools/text-elements/pool.js +250 -0
  68. package/dist/tools/text-elements/tools.js +433 -0
  69. package/dist/tools/tool-loading.js +3 -0
  70. package/dist/tools/transport.js +26 -5
  71. package/dist/tools/write-mode.js +4 -3
  72. package/package.json +3 -4
  73. package/dist/doctor/checks/zcspeach.js +0 -96
  74. package/dist/projects/email-template.js +0 -56
@@ -12,6 +12,7 @@ import { settleSessionToolLoading } from './tool-loading-pin.js';
12
12
  import { resolveSkillAlias } from '../skill-catalog.js';
13
13
  import { retryWithBackoff } from './retry.js';
14
14
  import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
15
+ import { filterMachineBlocks, flushMachineBlocks, initialMachineBlockState } from '../renderer/machine-blocks.js';
15
16
  import { nextWritingActivity } from '../renderer/fence-state.js';
16
17
  import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool, toolActivityText, toolHeartbeatLabel } from '../renderer/tool-widget.js';
17
18
  import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
@@ -39,7 +40,7 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
39
40
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
40
41
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
41
42
  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 { afterPlanSave, afterSpecGapNotSaved, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
43
44
  import { shownToken } from '../lane/card.js';
44
45
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
45
46
  import { input } from '@inquirer/prompts';
@@ -300,6 +301,7 @@ export async function maybeOfferSave(p) {
300
301
  return 'y';
301
302
  }
302
303
  : inkAwarePrompt;
304
+ let notSaved;
303
305
  try {
304
306
  const savedPath = await runSaveCommand({
305
307
  skillOutput: p.assistantText,
@@ -316,10 +318,12 @@ export async function maybeOfferSave(p) {
316
318
  confirm: isHeadless() ? undefined : inkAwareConfirm,
317
319
  log: (...lines) => lines.forEach((l) => p.emit(l)),
318
320
  promotedFrom: p.promotedFrom ?? null,
321
+ ...(p.promotedFromPath ? { promotedFromPath: p.promotedFromPath } : {}),
319
322
  // Task 7 review round 2 — the app-flow lane only for the turn routed to
320
323
  // the skill it saves (the /abap-plan turn that got the lane block); a plan
321
324
  // found in another skill's output (detectArtifactSkill) never gets it.
322
325
  ...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
326
+ onNotSaved: (reason) => { notSaved = reason; },
323
327
  });
324
328
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
325
329
  // questions inline (recorded as `answered` items in the SAME file) — the
@@ -348,6 +352,11 @@ export async function maybeOfferSave(p) {
348
352
  else if (saveSkill === 'abap-plan')
349
353
  await afterPlanSave(hooks, savedPath);
350
354
  }
355
+ // 2026-10-04 — a spec-gap that produced no file inside the chain says so
356
+ // (one line) instead of the chain stopping in silence.
357
+ if (!savedPath && notSaved && p.appChain && p.skill === saveSkill && saveSkill === 'abap-spec-gap') {
358
+ afterSpecGapNotSaved({ ...p.appChain, headless: isHeadless(), emit: p.emit }, notSaved, p.assistantText);
359
+ }
351
360
  }
352
361
  catch (err) {
353
362
  p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
@@ -523,6 +532,7 @@ export async function runTurn(params) {
523
532
  emit(STEP_3);
524
533
  }
525
534
  let promotedFromForSave = null;
535
+ let promotedFromPathForSave;
526
536
  let userMessageForLLM = params.userMessage;
527
537
  let userMessageForSave = params.userMessage;
528
538
  // Task 18 fix M2 — a reader's brief is plain text from the model: no
@@ -565,6 +575,7 @@ export async function runTurn(params) {
565
575
  return;
566
576
  }
567
577
  promotedFromForSave = result.promotedFrom;
578
+ promotedFromPathForSave = result.sourcePath;
568
579
  // 2026-05-08: thread the original @<filename> token into the LLM
569
580
  // prompt so skills that need to dispatch a follow-up command (e.g.
570
581
  // dispatch_skill calling /abap-rap --from @<same-file>) can use the
@@ -909,6 +920,7 @@ ${imageExpansion.error}`));
909
920
  }
910
921
  }
911
922
  let buffer = initialBufferState();
923
+ let machineBlocks = initialMachineBlockState();
912
924
  let writingCode = false; // piece 2 §11.2 — "writing code…" while a fence is held back
913
925
  let stream;
914
926
  // 2026-05-06: paint a "thinking" spinner during the LLM round-trip
@@ -1049,7 +1061,11 @@ ${imageExpansion.error}`));
1049
1061
  // Do NOT add `skill` to streamParams — it is not part of the Anthropic SDK body schema.
1050
1062
  // v0.6 — forward the per-turn abort signal so Esc / SIGINT-during-turn
1051
1063
  // tears down the in-flight stream cleanly.
1052
- const headers = { 'X-CSForge-Skill': params.skill, 'X-CSPeach-Model-Role': params.modelRole ?? 'session_default' };
1064
+ const headers = {
1065
+ 'X-CSForge-Skill': params.skill,
1066
+ 'X-CSPeach-Model-Role': params.modelRole ?? 'session_default',
1067
+ ...(params.delegateSkill ? { 'X-CSPeach-Delegate-Skill': params.delegateSkill } : {}),
1068
+ };
1053
1069
  sentParams = streamParams;
1054
1070
  sentHeaders = headers;
1055
1071
  return params.provider.createStream(streamParams, {
@@ -1191,7 +1207,11 @@ ${imageExpansion.error}`));
1191
1207
  const delta = event.delta;
1192
1208
  const last = currentAssistantContent[currentAssistantContent.length - 1];
1193
1209
  if (delta.type === 'text_delta') {
1194
- const { output, state } = renderChunk(delta.text, buffer);
1210
+ // 1.1.28 — machine blocks (plan / cca / upgrade manifests) are parsed
1211
+ // from the raw content below, never shown (renderer/machine-blocks.ts).
1212
+ const shown = filterMachineBlocks(delta.text, machineBlocks);
1213
+ machineBlocks = shown.state;
1214
+ const { output, state } = renderChunk(shown.output, buffer);
1195
1215
  buffer = state;
1196
1216
  const wa = nextWritingActivity(writingCode, buffer.pending);
1197
1217
  writingCode = wa.writingCode;
@@ -1327,6 +1347,20 @@ ${imageExpansion.error}`));
1327
1347
  }
1328
1348
  // Final flush: emit any pending partial line after the stream ends.
1329
1349
  // Runs on both clean completion and mid-stream interruption.
1350
+ // First release prose the machine-block filter was still holding (an
1351
+ // unterminated block releases nothing).
1352
+ const heldProse = flushMachineBlocks(machineBlocks);
1353
+ machineBlocks = initialMachineBlockState();
1354
+ if (heldProse) {
1355
+ const { output, state } = renderChunk(heldProse, buffer);
1356
+ buffer = state;
1357
+ if (output) {
1358
+ if (params.chunkEmitter)
1359
+ params.chunkEmitter.emit('chunk', output);
1360
+ else
1361
+ writeStdout(output);
1362
+ }
1363
+ }
1330
1364
  if (buffer.pending.length > 0) {
1331
1365
  const flushed = render(buffer.pending);
1332
1366
  if (params.chunkEmitter)
@@ -1576,6 +1610,7 @@ ${imageExpansion.error}`));
1576
1610
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
1577
1611
  emit,
1578
1612
  promotedFrom: promotedFromForSave,
1613
+ ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
1579
1614
  ...(() => {
1580
1615
  const flowLane = getAppFlow(session)?.lane;
1581
1616
  return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
@@ -1586,6 +1621,7 @@ ${imageExpansion.error}`));
1586
1621
  session,
1587
1622
  cwd: params.ctx.cwd,
1588
1623
  ...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
1624
+ ...(params.ctx.sapAlias && params.ctx.sapAlias !== 'none' ? { sapAlias: params.ctx.sapAlias } : {}),
1589
1625
  },
1590
1626
  } : {}),
1591
1627
  });
@@ -1676,7 +1712,7 @@ ${imageExpansion.error}`));
1676
1712
  session.messages.push({
1677
1713
  role: 'user',
1678
1714
  content: 'Your previous response was cut off at the output-token limit. Continue exactly where you left off — do NOT repeat what you already wrote. '
1679
- + 'If you were mid-way through a tool call, or (for /abap-plan) had not yet emitted the closing csforge:plan-manifest block, complete it now. '
1715
+ + 'If you were mid-way through a tool call, or (for /abap-plan) had not yet emitted the closing cspeach:plan-manifest block, complete it now. '
1680
1716
  + 'Keep narration brief to stay within the limit.',
1681
1717
  });
1682
1718
  continue;
@@ -1821,7 +1857,7 @@ ${imageExpansion.error}`));
1821
1857
  // modal for the Ink frame or raw-mode stdin.
1822
1858
  const willTripBatchGate = (() => {
1823
1859
  const t = getTool(block.name);
1824
- return !!t?.isMutating && shouldGateRule8();
1860
+ return !!t?.isMutating && shouldGateRule8(t);
1825
1861
  })();
1826
1862
  const isInteractiveTool = block.name === 'ask_question' ||
1827
1863
  block.name === 'request_approval' ||
@@ -1,4 +1,5 @@
1
1
  import { TOOL_CHANGES_BETA, isToolSearchEntry } from '../tools/tool-loading.js';
2
+ import { DELEGATE_SKILL_HEADER } from './system-prompt.js';
2
3
  /** Betas the proxy sends on every managed turn (messages.ts `betas: [...]`). */
3
4
  export const BASE_BETAS = ['compact-2026-01-12'];
4
5
  /** Task 6 — beta that enables `fallbacks: 'default'` (server-side fallback). */
@@ -238,17 +239,23 @@ export function withoutInternalHeaders(headers) {
238
239
  }
239
240
  /**
240
241
  * Task 9 — the role header is new proxy plumbing; AI-hub keeps today's header
241
- * set, so only X-CSPeach-Model-Role is removed there.
242
+ * set, so X-CSPeach-Model-Role (and, 2026-10-04, the delegate header) are removed.
242
243
  */
243
244
  export function withoutRoleHeader(headers) {
244
245
  const out = {};
245
246
  for (const [k, v] of Object.entries(headers ?? {})) {
246
- if (k.toLowerCase() === 'x-cspeach-model-role')
247
+ const lk = k.toLowerCase();
248
+ if (lk === 'x-cspeach-model-role' || lk === DELEGATE_SKILL_HEADER.toLowerCase())
247
249
  continue;
248
250
  out[k] = v;
249
251
  }
250
252
  return out;
251
253
  }
254
+ /** 2026-10-04 — the plan phase's delegate skill, when the loop sent one. */
255
+ export function delegateSkillName(options) {
256
+ const v = options.headers?.[DELEGATE_SKILL_HEADER];
257
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
258
+ }
252
259
  /**
253
260
  * The active skill travels as the X-CSForge-Skill header (coordination §1).
254
261
  * Direct providers need it to fetch the skill body; without it the model
@@ -2,7 +2,7 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
3
  import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
4
  import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
- import { requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
5
+ import { delegateSkillName, requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
6
6
  import { systemPromptForSkill } from '../system-prompt.js';
7
7
  const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
8
8
  const KEYCHAIN_ACCOUNT = 'token';
@@ -25,7 +25,7 @@ export function createAiHubProvider(cfg) {
25
25
  const skillName = requireSkillName(options, 'AI Hub');
26
26
  const token = await getToken();
27
27
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
28
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false });
28
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options));
29
29
  const request = {
30
30
  ...toLegacyShape(params),
31
31
  system,
@@ -2,7 +2,7 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
3
  import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
4
  import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
- import { modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
5
+ import { delegateSkillName, modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
6
6
  import { systemPromptForSkill } from '../system-prompt.js';
7
7
  const KEYCHAIN_SERVICE = 'cspeach.anthropic';
8
8
  const KEYCHAIN_ACCOUNT = 'api-key';
@@ -19,7 +19,7 @@ export function createByokProvider(_cfg) {
19
19
  const skillName = requireSkillName(options, 'BYOK');
20
20
  const key = await getKey();
21
21
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
22
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: true });
22
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: true }, delegateSkillName(options));
23
23
  // Task 6 — opt into server-side fallback, only for the families probe
24
24
  // P3 proved (allow-list); requiredBetas adds its beta.
25
25
  const prompted = withSkillPrompt(params, system, { cacheMarkers: true });
@@ -5,8 +5,8 @@ import { sha256 } from '@noble/hashes/sha256';
5
5
  import { bytesToHex } from '@noble/hashes/utils';
6
6
  import { createBundledSkillSource } from '../../skills/source-bundled.js';
7
7
  import { BUNDLED_SKILLS } from '../../skills/bundled-skills.js';
8
- import { requireSkillName } from '../provider-shape.js';
9
- import { buildSystemPrompt } from '../system-prompt.js';
8
+ import { delegateSkillName, requireSkillName } from '../provider-shape.js';
9
+ import { systemPromptForSkill } from '../system-prompt.js';
10
10
  export function createLocalProvider(cfg) {
11
11
  if (!cfg.llm.local_base_url) {
12
12
  throw new Error('local mode requires llm.local_base_url — run: cspeach config set llm.local_base_url http://localhost:11434/v1');
@@ -26,10 +26,10 @@ export function createLocalProvider(cfg) {
26
26
  // message (no cache markers). anthropicParamsToOpenAI copies only
27
27
  // max_tokens / messages / system, so newer fields never reach the endpoint.
28
28
  const skillName = requireSkillName(options, 'Local');
29
- const skill = await skillSource.getSkillBody(skillName);
29
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options));
30
30
  const OpenAI = await loadOpenAI();
31
31
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
32
- const openAiParams = anthropicParamsToOpenAI({ ...params, system: buildSystemPrompt(skill.body, { cacheMarkers: false }) }, model);
32
+ const openAiParams = anthropicParamsToOpenAI({ ...params, system }, model);
33
33
  // stream: true widens via object spread; the literal-true overload cannot be
34
34
  // selected by structural inference, so cast to the streaming return shape.
35
35
  // v0.6 — pass AbortSignal through OpenAI SDK request options.
@@ -9,11 +9,26 @@
9
9
  */
10
10
  import { FORGE_PRINCIPLES_PREAMBLE } from '../skills/preamble.js';
11
11
  import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-prompt.js';
12
- export function buildSystemPrompt(skillBody, opts) {
12
+ /** 2026-10-04 (continuous run) — the phase's delegate skill travels as this header. */
13
+ export const DELEGATE_SKILL_HEADER = 'X-CSPeach-Delegate-Skill';
14
+ /** The line that tells the model how the two bodies relate. Same text in cspeach-proxy. */
15
+ export function delegatePreface(name) {
16
+ return `This plan step is delegated to the ${name} playbook below. Follow it for HOW to build. `
17
+ + 'The abap-plan run contract above still decides WHAT this step does, how it ends and what it records. '
18
+ + 'The plan\'s one approval already covers this playbook\'s own "present the plan and wait for yes" step: do not ask it again. '
19
+ + 'Ask only a real question the spec and the system cannot answer.';
20
+ }
21
+ /** Skill body, plus the delegate playbook when one is given (one text block — no extra cache breakpoint). */
22
+ export function skillSystemText(skillBody, delegate) {
23
+ if (!delegate)
24
+ return skillBody;
25
+ return `${skillBody}\n\n<delegate_skill name="${delegate.name}">\n${delegatePreface(delegate.name)}\n\n${delegate.body}\n</delegate_skill>`;
26
+ }
27
+ export function buildSystemPrompt(skillBody, opts, delegate) {
13
28
  const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
14
29
  return [
15
30
  { type: 'text', text: FORGE_PRINCIPLES_PREAMBLE, ...marker },
16
- { type: 'text', text: skillBody, ...marker },
31
+ { type: 'text', text: skillSystemText(skillBody, delegate), ...marker },
17
32
  ];
18
33
  }
19
34
  /**
@@ -26,13 +41,25 @@ export function buildReaderSystemPrompt(opts) {
26
41
  return [{ type: 'text', text: READER_SYSTEM_PROMPT, ...marker }];
27
42
  }
28
43
  /**
29
- * The system prompt for the skill named in the X-CSForge-Skill header. The
30
- * internal `_reader` skill has no SKILL.md, so it never reaches the skill
31
- * source (a lookup would fail).
44
+ * The system prompt for the skill named in the X-CSForge-Skill header, plus —
45
+ * on a plan phase turn — the delegate playbook named in X-CSPeach-Delegate-Skill.
46
+ * A delegate that cannot be fetched degrades to the plain prompt (never throws);
47
+ * a delegate equal to the skill itself is ignored. The internal `_reader` skill
48
+ * has no SKILL.md, so it never reaches the skill source (a lookup would fail).
32
49
  */
33
- export async function systemPromptForSkill(skillName, skillSource, opts) {
50
+ export async function systemPromptForSkill(skillName, skillSource, opts, delegateName) {
34
51
  if (skillName === READER_SKILL)
35
52
  return buildReaderSystemPrompt(opts);
36
53
  const skill = await skillSource.getSkillBody(skillName);
37
- return buildSystemPrompt(skill.body, opts);
54
+ let delegate;
55
+ if (delegateName && delegateName !== skillName) {
56
+ try {
57
+ const d = await skillSource.getSkillBody(delegateName);
58
+ delegate = { name: delegateName, body: d.body };
59
+ }
60
+ catch {
61
+ delegate = undefined;
62
+ }
63
+ }
64
+ return buildSystemPrompt(skill.body, opts, delegate);
38
65
  }
@@ -1,6 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { getTool, isStandalone } from '../tools/index.js';
3
3
  import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
4
+ import { unreachableCode } from '../sap/unreachable.js';
5
+ import { noteSapUnreachable, unreachableCodeInText, SAP_UNREACHABLE_DIRECTIVE } from '../sap/reachability-state.js';
6
+ import { isPlanPhaseActive } from '../commands/plan-gate.js';
4
7
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
5
8
  import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
6
9
  import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
@@ -79,7 +82,7 @@ export async function dispatchTool(name, args, ctx) {
79
82
  // refused the 2nd edit of a CAP schema and left it half-changed. SAP writes
80
83
  // are gated exactly as before.
81
84
  const scratchWrite = isLocalProjectWork(tool, args) || isScratchOutputWrite(tool, args, ctx.cwd);
82
- if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
85
+ if (tool.isMutating && !scratchWrite && shouldGateRule8(tool)) {
83
86
  const r = await presentSafetyConfirmation({
84
87
  rule: 'RULE_8_BATCH_PLAN',
85
88
  op: tool.name,
@@ -106,9 +109,25 @@ export async function dispatchTool(name, args, ctx) {
106
109
  if (tool.isMutating && !scratchWrite && !(result.is_error ?? false)) {
107
110
  recordWriteOp(tool.name, args);
108
111
  }
112
+ // 2026-10-04 (continuous run, spec §3.5) — a SAP tool that failed because
113
+ // SAP is not reachable from this machine: note it (the REPL pauses the
114
+ // plan after the turn) and tell the model to stop without a manifest.
115
+ // Only inside a plan step: elsewhere the error text stays as it is.
116
+ if (tool.requiresSap && (result.is_error ?? false) && isPlanPhaseActive()) {
117
+ const code = unreachableCodeInText(result.content);
118
+ if (code) {
119
+ noteSapUnreachable(code);
120
+ return { content: `${result.content}\n\n${SAP_UNREACHABLE_DIRECTIVE}`, is_error: true };
121
+ }
122
+ }
109
123
  return { content: result.content, is_error: result.is_error ?? false };
110
124
  }
111
125
  catch (err) {
126
+ const code = tool.requiresSap && isPlanPhaseActive() ? (unreachableCode(err) ?? unreachableCodeInText(String(err))) : null;
127
+ if (code) {
128
+ noteSapUnreachable(code);
129
+ return { content: JSON.stringify({ error: String(err), sap_unreachable: true, directive: SAP_UNREACHABLE_DIRECTIVE }), is_error: true };
130
+ }
112
131
  return { content: JSON.stringify({ error: String(err) }), is_error: true };
113
132
  }
114
133
  }
@@ -0,0 +1,70 @@
1
+ // cspeach-cli/src/approvals/grant-claims.ts
2
+ //
3
+ // 2026-10-04 (continuous run) — the claims a plan-grant-minted approval id
4
+ // carries, and the spend-side check the write tools run against them. Kept
5
+ // apart from jwt.ts so the write tools' tests can mock jwt.ts and still run
6
+ // the real check.
7
+ import { approvalObjectMatches } from './canonical.js';
8
+ const norm = (v) => String(v ?? '').trim().toUpperCase();
9
+ const typeCode = (v) => norm(v).split('/')[0].trim();
10
+ /**
11
+ * Spend-side check of a grant-minted id against what the tool is about to do.
12
+ * Ids without grant claims always pass (today's behaviour). Type is the ADT
13
+ * type code (`'DDLS/DF'` → `'DDLS'`); transport is the one the write uses
14
+ * (args, else the session transport).
15
+ */
16
+ export function grantClaimMismatch(payload, actual) {
17
+ const g = payload?.grant;
18
+ if (!g)
19
+ return null;
20
+ if (typeCode(actual.type) !== norm(g.type))
21
+ return 'type_mismatch';
22
+ if (g.package && actual.package !== undefined && norm(actual.package) !== norm(g.package))
23
+ return 'package_mismatch';
24
+ // Every grant id for an object carries the grant's transport; one without it
25
+ // never writes (it could otherwise land on any transport).
26
+ if (!g.transport)
27
+ return 'transport_mismatch';
28
+ if (norm(actual.transport) !== norm(g.transport))
29
+ return 'transport_mismatch';
30
+ return null;
31
+ }
32
+ /**
33
+ * sap_activate with a grant-minted id: every object activated must be one the
34
+ * id names, and a single-object activate must match the listed type. Ids
35
+ * without grant claims always pass (today's behaviour: only the first object
36
+ * is checked).
37
+ */
38
+ export function grantActivateMismatch(payload, objects) {
39
+ const g = payload?.grant;
40
+ if (!g)
41
+ return null;
42
+ const minted = String(payload?.object ?? '');
43
+ for (const o of objects) {
44
+ if (!o || !approvalObjectMatches(minted, String(o.name ?? '')))
45
+ return 'object_mismatch';
46
+ }
47
+ if (objects.length === 1) {
48
+ const o = objects[0];
49
+ const member = g.members?.find((m) => norm(m.name) === norm(o.name));
50
+ const want = member ? member.type : g.type;
51
+ if (typeCode(o.type) !== norm(want))
52
+ return 'type_mismatch';
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * The write was resolved to the transport that already holds the object. A
58
+ * grant id may only write there when that is the grant's transport.
59
+ */
60
+ export function grantOwningTransportMismatch(payload, owning) {
61
+ const g = payload?.grant;
62
+ if (!g || !owning)
63
+ return false;
64
+ return norm(owning) !== norm(g.transport);
65
+ }
66
+ /** The plain message a refused owning-transport write returns. */
67
+ export function grantOwningTransportDetail(objectName, owning) {
68
+ return `${objectName} is already in transport ${owning}, which is not this build's transport. `
69
+ + 'The plan approval does not cover it. Ask for approval for this change.';
70
+ }
@@ -62,6 +62,8 @@ export async function mintChangeApproval(change, sessionId, opts = {}) {
62
62
  op: change.op,
63
63
  session_id: sessionId,
64
64
  ...(opts.auto ? { auto: true } : {}),
65
+ ...(opts.oneShot ? { one_shot: true } : {}),
66
+ ...(opts.grant ? { grant: opts.grant } : {}),
65
67
  });
66
68
  }
67
69
  export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
@@ -128,7 +130,7 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
128
130
  // Single-use only for one-shot destructive ops. create/modify approvals are
129
131
  // reusable for their object's lifecycle ops (create→modify→activate) within
130
132
  // TTL — that IS the change the user approved, so re-spending is not replay.
131
- if (isOneShotOp(payload.op)) {
133
+ if (isOneShotOp(payload.op) || payload.one_shot === true) {
132
134
  if (usedNonces.has(payload.nonce))
133
135
  return { ok: false, reason: 'nonce_spent' };
134
136
  usedNonces.add(payload.nonce);
@@ -141,3 +143,21 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
141
143
  }
142
144
  return { ok: true, payload, matchKind };
143
145
  }
146
+ export async function mintPlanGrantToken(p) {
147
+ return new SignJWT({ ...p, kind: 'plan-grant' })
148
+ .setProtectedHeader({ alg: 'HS256' })
149
+ .setIssuedAt()
150
+ // A whole build fits; the key is per CLI start, so a restart always asks again.
151
+ .setExpirationTime('12h')
152
+ .sign(sessionKey);
153
+ }
154
+ /** The signed payload, or null for a token that is forged, expired or not a plan grant. */
155
+ export async function verifyPlanGrantToken(token) {
156
+ try {
157
+ const { payload } = await jwtVerify(String(token ?? '').trim(), sessionKey);
158
+ return payload.kind === 'plan-grant' ? payload : null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }