@cspeach/cli 1.1.27 → 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 +33 -4
- 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/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- 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 +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- 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/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 +120 -121
- 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';
|
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|