@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.
- package/dist/agent/loop.js +41 -5
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/commands/whoami.js +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +4 -6
- package/dist/lane/app-chain.js +116 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +15 -14
- package/dist/projects/saved-lines.js +36 -0
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/sap/unreachable.js +4 -1
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +3 -4
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
package/dist/agent/loop.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/approvals/jwt.js
CHANGED
|
@@ -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
|
+
}
|