@cspeach/cli 1.1.27 → 1.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/agent/loop.js +33 -4
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/agent/turn-error-ux.js +40 -0
  9. package/dist/approvals/grant-claims.js +70 -0
  10. package/dist/approvals/jwt.js +21 -1
  11. package/dist/approvals/plan-grant.js +297 -0
  12. package/dist/commands/config-set.js +12 -0
  13. package/dist/commands/deploy-card.js +10 -5
  14. package/dist/commands/export-audit.js +1 -1
  15. package/dist/commands/plan-audit-evidence.js +47 -1
  16. package/dist/commands/plan-audit.js +23 -8
  17. package/dist/commands/plan-batch-audit.js +349 -0
  18. package/dist/commands/plan-boundary.js +88 -0
  19. package/dist/commands/plan-chain.js +409 -566
  20. package/dist/commands/plan-continue.js +21 -7
  21. package/dist/commands/plan-gate.js +28 -49
  22. package/dist/commands/plan-reground.js +174 -0
  23. package/dist/commands/plan-resume.js +227 -103
  24. package/dist/commands/plan-sap-pause.js +105 -0
  25. package/dist/commands/plan-startup.js +218 -0
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/run.js +4 -4
  29. package/dist/lane/app-chain.js +88 -9
  30. package/dist/one-shot.js +1 -0
  31. package/dist/projects/extract-plan.js +34 -1
  32. package/dist/projects/handover-md.js +1 -1
  33. package/dist/projects/plan-objects.js +75 -0
  34. package/dist/projects/plan-run.js +74 -18
  35. package/dist/projects/plan-schema.js +6 -0
  36. package/dist/projects/plan-sources.js +199 -0
  37. package/dist/projects/promote-command.js +1 -1
  38. package/dist/projects/save-command.js +11 -13
  39. package/dist/projects/saved-lines.js +36 -0
  40. package/dist/renderer/checklist-format.js +5 -5
  41. package/dist/renderer/machine-blocks.js +141 -0
  42. package/dist/renderer/tool-labels.js +3 -0
  43. package/dist/repl/plan-hop.js +22 -0
  44. package/dist/repl/rule8-detector.js +16 -1
  45. package/dist/repl.js +320 -55
  46. package/dist/router/router-core.js +2 -2
  47. package/dist/sap/reachability-state.js +25 -0
  48. package/dist/session/audit-export.js +10 -16
  49. package/dist/session/recap.js +16 -26
  50. package/dist/skills/bundled-skills.js +81 -81
  51. package/dist/tools/_filesystem-shared.js +37 -0
  52. package/dist/tools/approval.js +48 -2
  53. package/dist/tools/dispatch-skill.js +3 -2
  54. package/dist/tools/filesystem/file-read.js +5 -3
  55. package/dist/tools/filesystem/glob.js +27 -4
  56. package/dist/tools/fiori/tools.js +6 -0
  57. package/dist/tools/lane/app-lane-resolve.js +1 -1
  58. package/dist/tools/sap-read.js +37 -3
  59. package/dist/tools/sap-write.js +65 -0
  60. package/dist/tools/text-elements/pool.js +250 -0
  61. package/dist/tools/text-elements/tools.js +433 -0
  62. package/dist/tools/tool-loading.js +3 -0
  63. package/dist/tools/transport.js +26 -5
  64. package/dist/tools/write-mode.js +4 -3
  65. package/dist/ui/app.js +4 -0
  66. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  67. package/package.json +120 -121
  68. package/dist/projects/email-template.js +0 -56
@@ -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';
@@ -317,6 +318,7 @@ export async function maybeOfferSave(p) {
317
318
  confirm: isHeadless() ? undefined : inkAwareConfirm,
318
319
  log: (...lines) => lines.forEach((l) => p.emit(l)),
319
320
  promotedFrom: p.promotedFrom ?? null,
321
+ ...(p.promotedFromPath ? { promotedFromPath: p.promotedFromPath } : {}),
320
322
  // Task 7 review round 2 — the app-flow lane only for the turn routed to
321
323
  // the skill it saves (the /abap-plan turn that got the lane block); a plan
322
324
  // found in another skill's output (detectArtifactSkill) never gets it.
@@ -530,6 +532,7 @@ export async function runTurn(params) {
530
532
  emit(STEP_3);
531
533
  }
532
534
  let promotedFromForSave = null;
535
+ let promotedFromPathForSave;
533
536
  let userMessageForLLM = params.userMessage;
534
537
  let userMessageForSave = params.userMessage;
535
538
  // Task 18 fix M2 — a reader's brief is plain text from the model: no
@@ -572,6 +575,7 @@ export async function runTurn(params) {
572
575
  return;
573
576
  }
574
577
  promotedFromForSave = result.promotedFrom;
578
+ promotedFromPathForSave = result.sourcePath;
575
579
  // 2026-05-08: thread the original @<filename> token into the LLM
576
580
  // prompt so skills that need to dispatch a follow-up command (e.g.
577
581
  // dispatch_skill calling /abap-rap --from @<same-file>) can use the
@@ -916,6 +920,7 @@ ${imageExpansion.error}`));
916
920
  }
917
921
  }
918
922
  let buffer = initialBufferState();
923
+ let machineBlocks = initialMachineBlockState();
919
924
  let writingCode = false; // piece 2 §11.2 — "writing code…" while a fence is held back
920
925
  let stream;
921
926
  // 2026-05-06: paint a "thinking" spinner during the LLM round-trip
@@ -1056,7 +1061,11 @@ ${imageExpansion.error}`));
1056
1061
  // Do NOT add `skill` to streamParams — it is not part of the Anthropic SDK body schema.
1057
1062
  // v0.6 — forward the per-turn abort signal so Esc / SIGINT-during-turn
1058
1063
  // tears down the in-flight stream cleanly.
1059
- 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
+ };
1060
1069
  sentParams = streamParams;
1061
1070
  sentHeaders = headers;
1062
1071
  return params.provider.createStream(streamParams, {
@@ -1198,7 +1207,11 @@ ${imageExpansion.error}`));
1198
1207
  const delta = event.delta;
1199
1208
  const last = currentAssistantContent[currentAssistantContent.length - 1];
1200
1209
  if (delta.type === 'text_delta') {
1201
- 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);
1202
1215
  buffer = state;
1203
1216
  const wa = nextWritingActivity(writingCode, buffer.pending);
1204
1217
  writingCode = wa.writingCode;
@@ -1334,6 +1347,20 @@ ${imageExpansion.error}`));
1334
1347
  }
1335
1348
  // Final flush: emit any pending partial line after the stream ends.
1336
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
+ }
1337
1364
  if (buffer.pending.length > 0) {
1338
1365
  const flushed = render(buffer.pending);
1339
1366
  if (params.chunkEmitter)
@@ -1583,6 +1610,7 @@ ${imageExpansion.error}`));
1583
1610
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
1584
1611
  emit,
1585
1612
  promotedFrom: promotedFromForSave,
1613
+ ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
1586
1614
  ...(() => {
1587
1615
  const flowLane = getAppFlow(session)?.lane;
1588
1616
  return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
@@ -1593,6 +1621,7 @@ ${imageExpansion.error}`));
1593
1621
  session,
1594
1622
  cwd: params.ctx.cwd,
1595
1623
  ...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
1624
+ ...(params.ctx.sapAlias && params.ctx.sapAlias !== 'none' ? { sapAlias: params.ctx.sapAlias } : {}),
1596
1625
  },
1597
1626
  } : {}),
1598
1627
  });
@@ -1683,7 +1712,7 @@ ${imageExpansion.error}`));
1683
1712
  session.messages.push({
1684
1713
  role: 'user',
1685
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. '
1686
- + '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. '
1687
1716
  + 'Keep narration brief to stay within the limit.',
1688
1717
  });
1689
1718
  continue;
@@ -1828,7 +1857,7 @@ ${imageExpansion.error}`));
1828
1857
  // modal for the Ink frame or raw-mode stdin.
1829
1858
  const willTripBatchGate = (() => {
1830
1859
  const t = getTool(block.name);
1831
- return !!t?.isMutating && shouldGateRule8();
1860
+ return !!t?.isMutating && shouldGateRule8(t);
1832
1861
  })();
1833
1862
  const isInteractiveTool = block.name === 'ask_question' ||
1834
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
  }
@@ -82,6 +82,33 @@ function describeApiError(err, msg) {
82
82
  const label = status ? `service error ${status}` : 'service error';
83
83
  return (text ? `${label}: ${text}` : label).slice(0, 200);
84
84
  }
85
+ /**
86
+ * The proxy's `409 {"error":"upgrade_required",min_cli_version,current}`, from
87
+ * the SDK error's parsed `.error` body or, failing that, its message text.
88
+ */
89
+ function upgradeRequired(err) {
90
+ const e = err;
91
+ let body = e && typeof e.error === 'object' ? e.error : null;
92
+ if (body?.error !== 'upgrade_required') {
93
+ const msg = typeof e?.message === 'string' ? e.message : '';
94
+ const brace = msg.indexOf('{');
95
+ if (!/^409\b/.test(msg) || brace < 0)
96
+ return null;
97
+ try {
98
+ body = JSON.parse(msg.slice(brace, msg.lastIndexOf('}') + 1));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ if (body?.error !== 'upgrade_required')
104
+ return null;
105
+ }
106
+ if (e?.status != null && e.status !== 409)
107
+ return null;
108
+ const min = typeof body.min_cli_version === 'string' ? body.min_cli_version : 'a newer version';
109
+ const current = typeof body.current === 'string' ? body.current : 'an older version';
110
+ return { min, current };
111
+ }
85
112
  /**
86
113
  * Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
87
114
  * Best-effort — failure here is silent, the user-facing block still renders.
@@ -164,6 +191,19 @@ export async function renderTurnError(input) {
164
191
  print('');
165
192
  return;
166
193
  }
194
+ // 1.1.29 — the proxy's 409 upgrade_required (skill needs a newer CLI). It
195
+ // reaches here, not loop.ts's create-time check: the SDK raises it while
196
+ // the stream is read. "type continue" would only repeat the 409.
197
+ const upgrade = upgradeRequired(input.err);
198
+ if (upgrade) {
199
+ printBlock(print, formatFailure({
200
+ what: 'This needs a newer CSPeach',
201
+ detail: [`needs ${upgrade.min} or later · you have ${upgrade.current}`],
202
+ next: input.inSession ? 'type /exit, then run: cspeach --upgrade' : 'cspeach --upgrade',
203
+ }));
204
+ print('');
205
+ return;
206
+ }
167
207
  const reason = describeError(input.err);
168
208
  const logPath = await writeErrorLog(id, input.err);
169
209
  const detail = [reason, `Session: ${id}`];
@@ -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
+ }