@cspeach/cli 1.1.51 → 1.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/chat-prompt.js +4 -1
- package/dist/agent/conversation-prompt.js +31 -13
- package/dist/agent/inline-skill-block.js +341 -0
- package/dist/agent/loop.js +304 -49
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/provider-shape.js +8 -4
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/azure-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +2 -2
- package/dist/agent/repair-partial.js +21 -0
- package/dist/agent/skill-block-key.js +134 -0
- package/dist/agent/steering-queue.js +50 -14
- package/dist/agent/system-prompt.js +22 -0
- package/dist/agent/tool-dispatch.js +25 -9
- package/dist/agent/turn-watchdog.js +1 -1
- package/dist/approvals/advisory-render.js +6 -3
- package/dist/approvals/approval-prompt.js +9 -4
- package/dist/approvals/op-labels.js +20 -3
- package/dist/approvals/plan-grant.js +8 -0
- package/dist/approvals/render.js +22 -10
- package/dist/approvals/risk-floor.js +32 -4
- package/dist/approvals/visible-changes.js +50 -0
- package/dist/cli.js +10 -2
- package/dist/commands/compact.js +6 -1
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +16 -4
- package/dist/commands/export-conversation.js +7 -4
- package/dist/commands/finish-check-guard.js +124 -0
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-reground.js +27 -4
- package/dist/commands/plan-resume.js +113 -3
- package/dist/commands/plan-startup.js +3 -3
- package/dist/config/loader.js +5 -1
- package/dist/cost/cost-log.js +6 -0
- package/dist/cost/system-block.js +6 -0
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/models/server-config.js +1 -1
- package/dist/one-shot.js +1 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +107 -16
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +50 -18
- package/dist/projects/yes-no.js +5 -1
- package/dist/renderer/change-card.js +17 -1
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/steering-echo.js +53 -8
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/chat-lane.js +15 -3
- package/dist/repl/inline-mode.js +51 -0
- package/dist/repl/leftover.js +90 -0
- package/dist/repl/reroute-turn.js +7 -1
- package/dist/repl/turn-stop.js +64 -0
- package/dist/repl.js +155 -24
- package/dist/router/classifier.js +1 -1
- package/dist/sap/reachability-state.js +7 -0
- package/dist/sap/unreachable.js +2 -2
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/queued-step.js +3 -1
- package/dist/session/user-prompt.js +12 -1
- package/dist/skills/manifest-client.js +3 -2
- package/dist/skills/preamble.js +6 -4
- package/dist/skills/source-authed.js +3 -2
- package/dist/tools/approval.js +49 -8
- package/dist/tools/dispatch-skill.js +1 -1
- package/dist/tools/hand-off.js +49 -7
- package/dist/tools/lane/app-lane-resolve.js +9 -0
- package/dist/tools/method-only-change.js +241 -0
- package/dist/tools/project/spec_answers_record.js +190 -0
- package/dist/tools/rap/skeleton-resolve.js +4 -0
- package/dist/tools/sap-write.js +78 -3
- package/dist/tools/snapshot.js +7 -2
- package/dist/tools/subagent/agent_run.js +14 -0
- package/dist/tools/tool-loading.js +18 -8
- package/dist/tools/transport-fit-check.js +70 -0
- package/dist/tools/transport.js +17 -8
- package/dist/tools/write-pipeline.js +46 -5
- package/dist/ui/app.js +112 -13
- package/dist/ui/approval-modal.js +13 -7
- package/dist/ui/ascii-chars.js +21 -3
- package/dist/ui/footer.js +128 -7
- package/dist/ui/interrupt.js +35 -1
- package/dist/ui/live-area.js +21 -4
- package/dist/ui/queue-box-emitter.js +14 -0
- package/dist/ui/rewind-panel.js +2 -1
- package/dist/ui/session-timeline.js +2 -1
- package/dist/ui/text-input.js +31 -2
- package/dist/ui/widgets/ask-card.js +171 -29
- package/dist/ui/widgets/bar-chart.js +2 -1
- package/dist/ui/widgets/confirm-card.js +2 -1
- package/dist/ui/widgets/dep-graph.js +2 -1
- package/dist/ui/widgets/diff-viewer.js +2 -1
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/shortcuts-sheet.js +2 -2
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/util/connect-retry.js +65 -0
- package/package.json +2 -2
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* Phase 3. Today the cache is process-lifetime; users get a fresh
|
|
31
31
|
* context next CLI invocation.
|
|
32
32
|
*/
|
|
33
|
-
import { getProjectContext,
|
|
33
|
+
import { getProjectContext, renderProjectContextCapped } from '../project-context/index.js';
|
|
34
34
|
/**
|
|
35
35
|
* The env-var name that gates this enrichment. Treated case-insensitively
|
|
36
36
|
* but value comparison is exact ("on" only — anything else is OFF).
|
|
@@ -70,6 +70,8 @@ export function resetProjectContextCache() {
|
|
|
70
70
|
export function __resetProjectContextCacheForTests() {
|
|
71
71
|
resetProjectContextCache();
|
|
72
72
|
}
|
|
73
|
+
/** The one plain line shown when the folder picture was shortened. */
|
|
74
|
+
export const PROJECT_CONTEXT_CUT_LINE = 'This folder is very large, so we work from a shortened picture of it.';
|
|
73
75
|
/**
|
|
74
76
|
* Returns the rendered <project_context> block for splicing into the
|
|
75
77
|
* user message, or '' when the flag is off / no context was detectable.
|
|
@@ -94,7 +96,14 @@ export async function maybeBuildProjectContext(options) {
|
|
|
94
96
|
const ctx = await getProjectContext(options.cwd, {
|
|
95
97
|
sapConnection: options.sapConnection ?? null,
|
|
96
98
|
});
|
|
97
|
-
|
|
99
|
+
const rendered = ctx ? renderProjectContextCapped(ctx) : { block: '', cut: false };
|
|
100
|
+
cachedBlock = rendered.block;
|
|
101
|
+
if (rendered.cut) {
|
|
102
|
+
try {
|
|
103
|
+
options.emit?.(PROJECT_CONTEXT_CUT_LINE);
|
|
104
|
+
}
|
|
105
|
+
catch { /* a display failure never drops the block */ }
|
|
106
|
+
}
|
|
98
107
|
}
|
|
99
108
|
catch (err) {
|
|
100
109
|
// getProjectContext is documented as never-throws; reaching this
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TOOL_CHANGES_BETA, isToolSearchEntry } from '../tools/tool-loading.js';
|
|
2
|
-
import { DELEGATE_SKILL_HEADER } from './system-prompt.js';
|
|
2
|
+
import { DELEGATE_SKILL_HEADER, SKILL_INLINE_HEADER } from './system-prompt.js';
|
|
3
3
|
import { STYLE_HEADER } from './style.js';
|
|
4
4
|
import { LEVEL_HEADER } from './level.js';
|
|
5
5
|
/** Betas the proxy sends on every managed turn (messages.ts `betas: [...]`). */
|
|
@@ -270,14 +270,14 @@ export function withoutInternalHeaders(headers) {
|
|
|
270
270
|
/**
|
|
271
271
|
* Task 9 — the role header is new proxy plumbing; AI-hub keeps today's header
|
|
272
272
|
* set, so X-CSPeach-Model-Role (and, 2026-10-04, the delegate header; and,
|
|
273
|
-
* 2026-10-17, the talking-style and level headers — applied locally
|
|
274
|
-
* removed.
|
|
273
|
+
* 2026-10-17, the talking-style and level headers — applied locally; and,
|
|
274
|
+
* Stage 1A, the inline header — applied locally) are removed.
|
|
275
275
|
*/
|
|
276
276
|
export function withoutRoleHeader(headers) {
|
|
277
277
|
const out = {};
|
|
278
278
|
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
279
279
|
const lk = k.toLowerCase();
|
|
280
|
-
if (lk === 'x-cspeach-model-role' || lk === DELEGATE_SKILL_HEADER.toLowerCase() || lk === STYLE_HEADER.toLowerCase() || lk === LEVEL_HEADER.toLowerCase())
|
|
280
|
+
if (lk === 'x-cspeach-model-role' || lk === DELEGATE_SKILL_HEADER.toLowerCase() || lk === STYLE_HEADER.toLowerCase() || lk === LEVEL_HEADER.toLowerCase() || lk === SKILL_INLINE_HEADER.toLowerCase())
|
|
281
281
|
continue;
|
|
282
282
|
out[k] = v;
|
|
283
283
|
}
|
|
@@ -291,6 +291,10 @@ export function headersForProfile(profile, headers) {
|
|
|
291
291
|
case 'none': return { ...(headers ?? {}) };
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
+
/** Stage 1A — true when the loop sent X-CSPeach-Skill-Inline: '1' (a direct provider then builds the one block locally). */
|
|
295
|
+
export function skillInlineFromHeaders(options) {
|
|
296
|
+
return options.headers?.[SKILL_INLINE_HEADER] === '1';
|
|
297
|
+
}
|
|
294
298
|
/** 2026-10-04 — the plan phase's delegate skill, when the loop sent one. */
|
|
295
299
|
export function delegateSkillName(options) {
|
|
296
300
|
const v = options.headers?.[DELEGATE_SKILL_HEADER];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
2
|
import keychain from '../../auth/keychain.js';
|
|
3
3
|
import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
|
|
4
|
-
import { delegateSkillName, requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
|
|
4
|
+
import { delegateSkillName, requireSkillName, skillInlineFromHeaders, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
|
|
5
5
|
import { systemPromptForSkill } from '../system-prompt.js';
|
|
6
6
|
import { styleFromHeaders } from '../style.js';
|
|
7
7
|
import { levelFromHeaders } from '../level.js';
|
|
@@ -28,7 +28,7 @@ export function createAiHubProvider(cfg, getBearer = defaultBearer) {
|
|
|
28
28
|
const skillName = requireSkillName(options, 'AI Hub');
|
|
29
29
|
const token = await getToken();
|
|
30
30
|
// Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
|
|
31
|
-
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: AI_HUB_PROFILE.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
31
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: AI_HUB_PROFILE.cacheMarkers, inline: skillInlineFromHeaders(options) }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
32
32
|
const request = {
|
|
33
33
|
...toLegacyShape(params),
|
|
34
34
|
system,
|
|
@@ -15,7 +15,7 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
15
15
|
import { sendsEffortFor } from '../connection-profile.js';
|
|
16
16
|
import { AZURE_PROFILE } from '../azure-profile.js';
|
|
17
17
|
import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
|
|
18
|
-
import { delegateSkillName, headersForProfile, requireSkillName, withoutToolChanges, withSkillPrompt } from '../provider-shape.js';
|
|
18
|
+
import { delegateSkillName, headersForProfile, requireSkillName, skillInlineFromHeaders, withoutToolChanges, withSkillPrompt } from '../provider-shape.js';
|
|
19
19
|
import { systemPromptForSkill } from '../system-prompt.js';
|
|
20
20
|
import { styleFromHeaders } from '../style.js';
|
|
21
21
|
import { levelFromHeaders } from '../level.js';
|
|
@@ -66,7 +66,7 @@ export function createAzureProvider(cfg, getBearer = defaultBearer, deps = {}) {
|
|
|
66
66
|
throw new AzureConnectionRefusedError(v.message);
|
|
67
67
|
const conn = cached.connection;
|
|
68
68
|
const skillName = requireSkillName(options, 'Azure');
|
|
69
|
-
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: profile.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
69
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: profile.cacheMarkers, inline: skillInlineFromHeaders(options) }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
70
70
|
const base = profile.toolChanges ? params : withoutToolChanges(params);
|
|
71
71
|
const family = familyFromTable(params.model, conn.deployments);
|
|
72
72
|
const body = withAzureEffort(shapeForAzure(withSkillPrompt(base, system, { cacheMarkers: profile.cacheMarkers }), profile), profile, roleOf(options.headers), family);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
2
|
import keychain from '../../auth/keychain.js';
|
|
3
3
|
import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
|
|
4
|
-
import { delegateSkillName, modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
|
|
4
|
+
import { delegateSkillName, skillInlineFromHeaders, modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
|
|
5
5
|
import { systemPromptForSkill } from '../system-prompt.js';
|
|
6
6
|
import { styleFromHeaders } from '../style.js';
|
|
7
7
|
import { levelFromHeaders } from '../level.js';
|
|
@@ -21,7 +21,7 @@ export function createByokProvider(cfg, getBearer = defaultBearer) {
|
|
|
21
21
|
const skillName = requireSkillName(options, 'BYOK');
|
|
22
22
|
const key = await getKey();
|
|
23
23
|
// Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
|
|
24
|
-
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: BYOK_PROFILE.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
24
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: BYOK_PROFILE.cacheMarkers, inline: skillInlineFromHeaders(options) }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
25
25
|
// Task 6 — opt into server-side fallback, only for the families probe
|
|
26
26
|
// P3 proved (allow-list); requiredBetas adds its beta.
|
|
27
27
|
const prompted = withSkillPrompt(params, system, { cacheMarkers: BYOK_PROFILE.cacheMarkers });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const loadOpenAI = async () => (await import('openai')).default;
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
|
|
5
|
-
import { delegateSkillName, requireSkillName } from '../provider-shape.js';
|
|
5
|
+
import { delegateSkillName, requireSkillName, skillInlineFromHeaders } from '../provider-shape.js';
|
|
6
6
|
import { systemPromptForSkill } from '../system-prompt.js';
|
|
7
7
|
import { styleFromHeaders } from '../style.js';
|
|
8
8
|
import { levelFromHeaders } from '../level.js';
|
|
@@ -29,7 +29,7 @@ export function createLocalProvider(cfg, getBearer = defaultBearer) {
|
|
|
29
29
|
// message (no cache markers). anthropicParamsToOpenAI copies only
|
|
30
30
|
// max_tokens / messages / system, so newer fields never reach the endpoint.
|
|
31
31
|
const skillName = requireSkillName(options, 'Local');
|
|
32
|
-
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
32
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false, inline: skillInlineFromHeaders(options) }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
|
|
33
33
|
const OpenAI = await loadOpenAI();
|
|
34
34
|
const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
|
|
35
35
|
const openAiParams = anthropicParamsToOpenAI({ ...params, system }, model);
|
|
@@ -204,6 +204,27 @@ export function isPartialJsonApiError(err) {
|
|
|
204
204
|
/** Result text for a tool call whose real result was lost to an interruption. */
|
|
205
205
|
export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the turn was interrupted before its result was recorded. ' +
|
|
206
206
|
'Its effect is unknown: check before relying on it, and re-run it if needed.';
|
|
207
|
+
/**
|
|
208
|
+
* LT-15 D6 — result text for a tool call stopped (Esc / Ctrl+C) at a point
|
|
209
|
+
* where the tool KNOWS nothing ran yet: before its card, its WAL entry and any
|
|
210
|
+
* SAP change. Same shape and flags as INTERRUPTED_TOOL_RESULT (is_error: true).
|
|
211
|
+
*/
|
|
212
|
+
export const STOPPED_BEFORE_START_RESULT = 'This tool call was stopped before anything ran — no change was made in SAP. Ask again if it is still needed.';
|
|
213
|
+
/**
|
|
214
|
+
* 1.1.53 item 10 — the dim line shown when healOrphanToolUses closed off steps
|
|
215
|
+
* cut by an earlier stop. Plain words for the person; no internals.
|
|
216
|
+
*/
|
|
217
|
+
export function closedOffStepsLine(n) {
|
|
218
|
+
return n === 1
|
|
219
|
+
? '(we closed off a step that was stopped earlier)'
|
|
220
|
+
: `(we closed off ${n} steps that were stopped earlier)`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* LT-15 D6 (review M5) — the same, for the write pipeline's second guard: by
|
|
224
|
+
* then the approval was spent and SAP was read (a local snapshot may exist),
|
|
225
|
+
* but the change itself was not made. Same shape and flags.
|
|
226
|
+
*/
|
|
227
|
+
export const STOPPED_BEFORE_WRITE_RESULT = 'This tool call was stopped before the change was made — nothing was written to SAP. Ask again if it is still needed.';
|
|
207
228
|
/**
|
|
208
229
|
* 2026-09-24 — heal tool_use blocks that have no tool_result after them.
|
|
209
230
|
*
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1A, Task 1A.2 fix round 1 — the per-install key that proves a
|
|
3
|
+
* `<skill>` block in the history was written by this CLI.
|
|
4
|
+
*
|
|
5
|
+
* A content hash proves only that a block agrees with itself: anyone can
|
|
6
|
+
* compute sha256(body). So each block the loop writes also carries an
|
|
7
|
+
* HMAC-SHA256 tag keyed by a random 32-byte secret kept under the CSPeach
|
|
8
|
+
* home (`<home>/skill-block.key`, 0600 on POSIX, CSPEACH_HOME respected).
|
|
9
|
+
* Only a block whose tag verifies counts as loaded.
|
|
10
|
+
*
|
|
11
|
+
* - The key is created on the first WRITE of a block (`skillBlockKeyForWrite`),
|
|
12
|
+
* never by a read: a process that never runs inline creates no file.
|
|
13
|
+
* - A missing or unreadable key means nothing verifies: the body is simply
|
|
14
|
+
* sent again (cost only, never a failed turn).
|
|
15
|
+
* - A resumed session on the same install still verifies. On another machine
|
|
16
|
+
* (another key) every block reads as not loaded, is neutralised in place,
|
|
17
|
+
* and each body is sent once more.
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
23
|
+
const KEY_FILE = 'skill-block.key';
|
|
24
|
+
const KEY_HEX = /^[0-9a-f]{64}$/;
|
|
25
|
+
/** Keys found or made in this process, by the file they live in. */
|
|
26
|
+
const cache = new Map();
|
|
27
|
+
/** Test seam: a fixed key (Buffer), no key (null), or the real file (undefined). */
|
|
28
|
+
let override;
|
|
29
|
+
function keyPath() {
|
|
30
|
+
return path.join(cspeachRoot(), KEY_FILE);
|
|
31
|
+
}
|
|
32
|
+
function readKeyFile(p) {
|
|
33
|
+
try {
|
|
34
|
+
const hex = fs.readFileSync(p, 'utf-8').trim();
|
|
35
|
+
return KEY_HEX.test(hex) ? Buffer.from(hex, 'hex') : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The key, if this install has one. Never creates it. */
|
|
42
|
+
export function skillBlockKeyForRead() {
|
|
43
|
+
if (override !== undefined)
|
|
44
|
+
return override;
|
|
45
|
+
const p = keyPath();
|
|
46
|
+
const hit = cache.get(p);
|
|
47
|
+
if (hit)
|
|
48
|
+
return hit;
|
|
49
|
+
const k = readKeyFile(p);
|
|
50
|
+
if (k)
|
|
51
|
+
cache.set(p, k);
|
|
52
|
+
return k;
|
|
53
|
+
}
|
|
54
|
+
function chmod600(p) {
|
|
55
|
+
if (process.platform === 'win32')
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
fs.chmodSync(p, 0o600);
|
|
59
|
+
}
|
|
60
|
+
catch { /* best effort */ }
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The key, made on first use. A key file that exists but is not a 32-byte hex
|
|
64
|
+
* key (corrupt, truncated) is REPLACED here, atomically (temp file + rename),
|
|
65
|
+
* with one debug line, so inline turns do not re-send bodies forever. Only
|
|
66
|
+
* this write path creates or replaces the file; reads never do. null when it
|
|
67
|
+
* cannot be written (the block then never verifies: cost only).
|
|
68
|
+
*/
|
|
69
|
+
export function skillBlockKeyForWrite() {
|
|
70
|
+
if (override !== undefined)
|
|
71
|
+
return override;
|
|
72
|
+
const existing = skillBlockKeyForRead();
|
|
73
|
+
if (existing)
|
|
74
|
+
return existing;
|
|
75
|
+
const p = keyPath();
|
|
76
|
+
const fresh = `${randomBytes(32).toString('hex')}\n`;
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
79
|
+
if (!fs.existsSync(p)) {
|
|
80
|
+
// First use. 'wx': another process making it at the same time wins.
|
|
81
|
+
fs.writeFileSync(p, fresh, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
82
|
+
chmod600(p);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const tmp = `${p}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(tmp, fresh, { encoding: 'utf-8', mode: 0o600 });
|
|
88
|
+
chmod600(tmp);
|
|
89
|
+
fs.renameSync(tmp, p);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
94
|
+
}
|
|
95
|
+
catch { /* renamed */ }
|
|
96
|
+
}
|
|
97
|
+
if (process.env['CSPEACH_DEBUG'] === '1') {
|
|
98
|
+
process.stderr.write(`[inline] the skill block key at ${p} was not readable; a new key replaced it (earlier blocks are sent again once)\n`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Another process made it first (EEXIST), or the home is not writable:
|
|
104
|
+
// read whatever is there now.
|
|
105
|
+
}
|
|
106
|
+
return skillBlockKeyForRead();
|
|
107
|
+
}
|
|
108
|
+
/** HMAC-SHA256 (hex) of the parts, joined with NUL. '' when there is no key. */
|
|
109
|
+
export function skillBlockTag(key, parts) {
|
|
110
|
+
if (!key)
|
|
111
|
+
return '';
|
|
112
|
+
return createHmac('sha256', key).update(parts.join('\0'), 'utf8').digest('hex');
|
|
113
|
+
}
|
|
114
|
+
/** Constant-time comparison of two hex tags; a length difference (or a missing tag) is simply false. */
|
|
115
|
+
export function skillBlockTagsEqual(a, b) {
|
|
116
|
+
const x = Buffer.from(a, 'hex');
|
|
117
|
+
const y = Buffer.from(b, 'hex');
|
|
118
|
+
if (x.length === 0 || x.length !== y.length)
|
|
119
|
+
return false;
|
|
120
|
+
return timingSafeEqual(x, y);
|
|
121
|
+
}
|
|
122
|
+
/** A short id of the key in use ('none' without one), so a saved scan is redone when the key changes. */
|
|
123
|
+
export function skillBlockKeyId(key) {
|
|
124
|
+
return key ? createHmac('sha256', key).update('id').digest('hex').slice(0, 16) : 'none';
|
|
125
|
+
}
|
|
126
|
+
/** Test seam. */
|
|
127
|
+
export function __setSkillBlockKeyForTests(k) {
|
|
128
|
+
override = k;
|
|
129
|
+
cache.clear();
|
|
130
|
+
}
|
|
131
|
+
/** Test seam: forget keys read in this process (the file stays). */
|
|
132
|
+
export function __clearSkillBlockKeyCache() {
|
|
133
|
+
cache.clear();
|
|
134
|
+
}
|
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mid-turn steering queue (2026-06-08).
|
|
3
|
-
*
|
|
4
|
-
* While a turn is in flight the user can type a correction and press Enter;
|
|
5
|
-
* the Footer enqueues it here instead of bouncing it. The agent loop drains
|
|
6
|
-
* this at each round boundary and injects the text as a user message before
|
|
7
|
-
* the next createStream, so the model reads the correction and adapts —
|
|
8
|
-
* CC-style "type any time". Module-level singleton, mirroring the
|
|
9
|
-
* pendingDispatch accessor pattern; there is exactly one interactive REPL
|
|
10
|
-
* turn loop at a time.
|
|
11
|
-
*/
|
|
12
1
|
const queue = [];
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
/** Tell every listener the queue changed. A listener that throws is ignored. */
|
|
4
|
+
function notify() {
|
|
5
|
+
if (listeners.size === 0)
|
|
6
|
+
return;
|
|
7
|
+
const snap = steeringSnapshot();
|
|
8
|
+
for (const fn of [...listeners]) {
|
|
9
|
+
try {
|
|
10
|
+
fn(snap);
|
|
11
|
+
}
|
|
12
|
+
catch { /* a UI listener must never break the queue */ }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
13
15
|
/** Add a steer. Trims; ignores empty / whitespace-only input. */
|
|
14
|
-
export function enqueueSteering(text) {
|
|
16
|
+
export function enqueueSteering(text, opts) {
|
|
15
17
|
const t = text.trim();
|
|
16
18
|
if (t.length === 0)
|
|
17
19
|
return;
|
|
18
|
-
queue.push(t);
|
|
20
|
+
queue.push({ text: t, alreadyRead: opts?.alreadyRead === true });
|
|
21
|
+
notify();
|
|
19
22
|
}
|
|
20
23
|
/** Return all queued steers (FIFO) and clear the queue. */
|
|
21
24
|
export function drainSteering() {
|
|
22
|
-
return
|
|
25
|
+
return drainSteeringEntries().map((e) => e.text);
|
|
26
|
+
}
|
|
27
|
+
/** LT-15 Task 4 — drain with each message's already-read mark (the loop's round top). */
|
|
28
|
+
export function drainSteeringEntries() {
|
|
29
|
+
const out = queue.splice(0, queue.length);
|
|
30
|
+
if (out.length > 0)
|
|
31
|
+
notify();
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** LT-15 Task 4 — drain every waiting message as one text (joined with newlines), or null. */
|
|
35
|
+
export function drainSteeringText() {
|
|
36
|
+
const all = drainSteering();
|
|
37
|
+
return all.length > 0 ? all.join('\n') : null;
|
|
23
38
|
}
|
|
24
39
|
/** True when at least one steer is queued. */
|
|
25
40
|
export function hasSteering() {
|
|
26
41
|
return queue.length > 0;
|
|
27
42
|
}
|
|
43
|
+
/** A copy of the waiting messages (FIFO). Changing it never changes the queue. */
|
|
44
|
+
export function steeringSnapshot() {
|
|
45
|
+
return queue.map((e) => e.text);
|
|
46
|
+
}
|
|
47
|
+
/** Take back the LAST waiting message, or null when nothing waits. */
|
|
48
|
+
export function withdrawLastSteering() {
|
|
49
|
+
if (queue.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const t = queue.pop();
|
|
52
|
+
notify();
|
|
53
|
+
return t.text;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Call `fn` with a fresh snapshot after every change (enqueue, drain,
|
|
57
|
+
* withdraw). Not called for a change that changed nothing. Returns the
|
|
58
|
+
* unsubscribe function.
|
|
59
|
+
*/
|
|
60
|
+
export function subscribeSteering(fn) {
|
|
61
|
+
listeners.add(fn);
|
|
62
|
+
return () => { listeners.delete(fn); };
|
|
63
|
+
}
|
|
@@ -12,8 +12,15 @@ import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-pro
|
|
|
12
12
|
import { CHAT_SKILL, CHAT_SYSTEM_PROMPT } from './chat-prompt.js';
|
|
13
13
|
import { appendStyleText, DEFAULT_STYLE } from './style.js';
|
|
14
14
|
import { appendLevelText, DEFAULT_LEVEL } from './level.js';
|
|
15
|
+
import { CONVERSATION_SYSTEM_PROMPT } from './conversation-prompt.js';
|
|
15
16
|
/** 2026-10-04 (continuous run) — the phase's delegate skill travels as this header. */
|
|
16
17
|
export const DELEGATE_SKILL_HEADER = 'X-CSPeach-Delegate-Skill';
|
|
18
|
+
/**
|
|
19
|
+
* Stage 1A, Task 1A.2 — '1' on a main call of an inline session: the system
|
|
20
|
+
* block is the one conversation prompt and the playbook rides in the
|
|
21
|
+
* conversation as a <skill> block (agent/inline-skill-block.ts).
|
|
22
|
+
*/
|
|
23
|
+
export const SKILL_INLINE_HEADER = 'X-CSPeach-Skill-Inline';
|
|
17
24
|
/** The line that tells the model how the two bodies relate. Same text in cspeach-proxy. */
|
|
18
25
|
export function delegatePreface(name) {
|
|
19
26
|
return `This plan step is delegated to the ${name} playbook below. Follow it for HOW to build. `
|
|
@@ -54,6 +61,14 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
|
|
|
54
61
|
// D-K order: style, then level, one block.
|
|
55
62
|
return appendLevelText(appendStyleText([{ type: 'text', text: CHAT_SYSTEM_PROMPT, ...marker }], style), level);
|
|
56
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Stage 1A — the inline session's one system block: the conversation prompt,
|
|
66
|
+
* then style, then level, in one block (the same order as the chat prompt).
|
|
67
|
+
*/
|
|
68
|
+
export function buildConversationSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
|
|
69
|
+
const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
|
|
70
|
+
return appendLevelText(appendStyleText([{ type: 'text', text: CONVERSATION_SYSTEM_PROMPT, ...marker }], style), level);
|
|
71
|
+
}
|
|
57
72
|
/**
|
|
58
73
|
* The system prompt for the skill named in the X-CSForge-Skill header, plus —
|
|
59
74
|
* on a plan phase turn — the delegate playbook named in X-CSPeach-Delegate-Skill.
|
|
@@ -65,10 +80,17 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
|
|
|
65
80
|
* reader (which reports facts) never gets style. A non-default `level`
|
|
66
81
|
* (§10 D14) follows the style text; developer changes nothing; a reader never
|
|
67
82
|
* gets it.
|
|
83
|
+
*
|
|
84
|
+
* Stage 1A — `opts.inline` (the call carried X-CSPeach-Skill-Inline): one
|
|
85
|
+
* block, the conversation prompt, plus style and level, for every skill name
|
|
86
|
+
* but `_reader`. No body is fetched (the loop put it in the conversation) and
|
|
87
|
+
* the delegate is not composed. Absent or false: today's three cases.
|
|
68
88
|
*/
|
|
69
89
|
export async function systemPromptForSkill(skillName, skillSource, opts, delegateName, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
|
|
70
90
|
if (skillName === READER_SKILL)
|
|
71
91
|
return buildReaderSystemPrompt(opts);
|
|
92
|
+
if (opts.inline === true)
|
|
93
|
+
return buildConversationSystemPrompt(opts, style, level);
|
|
72
94
|
if (skillName === CHAT_SKILL)
|
|
73
95
|
return buildChatSystemPrompt(opts, style, level);
|
|
74
96
|
const skill = await skillSource.getSkillBody(skillName);
|
|
@@ -2,7 +2,7 @@ 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
4
|
import { unreachableCode } from '../sap/unreachable.js';
|
|
5
|
-
import { noteSapUnreachable, unreachableCodeInText, SAP_UNREACHABLE_DIRECTIVE } from '../sap/reachability-state.js';
|
|
5
|
+
import { noteSapUnreachable, unreachableCodeInText, SAP_UNREACHABLE_DIRECTIVE, SAP_UNREACHABLE_NOTE } from '../sap/reachability-state.js';
|
|
6
6
|
import { isPlanPhaseActive } from '../commands/plan-gate.js';
|
|
7
7
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
8
8
|
import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
|
|
@@ -10,7 +10,7 @@ import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '.
|
|
|
10
10
|
import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
|
|
11
11
|
import { riskOf } from './risk-gate.js';
|
|
12
12
|
import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
|
|
13
|
-
import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
|
|
13
|
+
import { INTERRUPTED_TOOL_RESULT, STOPPED_BEFORE_START_RESULT } from './repair-partial.js';
|
|
14
14
|
import { WRITE_TOOLS } from '../tools/write-mode.js';
|
|
15
15
|
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
16
16
|
/** Chat lane (spec 2026-10-17 D4) — the model-facing reason; names hand_off. */
|
|
@@ -113,8 +113,12 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
113
113
|
}
|
|
114
114
|
// Final review I-3 — the D11 re-check can take seconds of SAP reads; an Esc
|
|
115
115
|
// in that window must stop the call here, not let the handler run "as unknown".
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
// 1.1.53 item 10 — the handler has not run (the gate only reads). A tool
|
|
117
|
+
// that cannot change SAP (hand_off, reads, local files) says so plainly; a
|
|
118
|
+
// tool that can change SAP keeps the "effect unknown, check" safety text.
|
|
119
|
+
if (ctx.signal?.aborted) {
|
|
120
|
+
return { content: isChatRefusedTool(tool) ? INTERRUPTED_TOOL_RESULT : STOPPED_BEFORE_START_RESULT, is_error: true };
|
|
121
|
+
}
|
|
118
122
|
// L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
|
|
119
123
|
// login to a local child process ALWAYS asks a human, once per session per
|
|
120
124
|
// alias, even as the first mutating call of a turn (where Rule 8 below stays
|
|
@@ -133,6 +137,11 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
133
137
|
is_error: true,
|
|
134
138
|
};
|
|
135
139
|
}
|
|
140
|
+
// LT-15 D6 (review M1) — the handover check above can await file reads; a
|
|
141
|
+
// stop there opens no handover, risky-command or Rule 8 card, and the
|
|
142
|
+
// handler never runs.
|
|
143
|
+
if (ctx.signal?.aborted)
|
|
144
|
+
return { content: STOPPED_BEFORE_START_RESULT, is_error: true };
|
|
136
145
|
const sessionId = ctx.session?.id;
|
|
137
146
|
if (handover && !isCredentialHandoverApproved(sessionId, handover.alias, handover.scope)) {
|
|
138
147
|
const r = await presentSafetyConfirmation({
|
|
@@ -210,22 +219,29 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
210
219
|
// 2026-10-04 (continuous run, spec §3.5) — a SAP tool that failed because
|
|
211
220
|
// SAP is not reachable from this machine: note it (the REPL pauses the
|
|
212
221
|
// plan after the turn) and tell the model to stop without a manifest.
|
|
213
|
-
// Only inside a plan step
|
|
214
|
-
|
|
222
|
+
// Only inside a plan step. LT-14 (1A fix wave): elsewhere a short note
|
|
223
|
+
// rides on the error (no pause, no flag), so the model never invents
|
|
224
|
+
// network advice for the person.
|
|
225
|
+
if (tool.requiresSap && (result.is_error ?? false)) {
|
|
215
226
|
const code = unreachableCodeInText(result.content);
|
|
216
|
-
if (code) {
|
|
227
|
+
if (code && isPlanPhaseActive()) {
|
|
217
228
|
noteSapUnreachable(code);
|
|
218
229
|
return { content: `${result.content}\n\n${SAP_UNREACHABLE_DIRECTIVE}`, is_error: true };
|
|
219
230
|
}
|
|
231
|
+
if (code)
|
|
232
|
+
return { content: `${result.content}\n\n${SAP_UNREACHABLE_NOTE}`, is_error: true };
|
|
220
233
|
}
|
|
221
234
|
return { content: result.content, is_error: result.is_error ?? false };
|
|
222
235
|
}
|
|
223
236
|
catch (err) {
|
|
224
|
-
const code = tool.requiresSap
|
|
225
|
-
if (code) {
|
|
237
|
+
const code = tool.requiresSap ? (unreachableCode(err) ?? unreachableCodeInText(String(err))) : null;
|
|
238
|
+
if (code && isPlanPhaseActive()) {
|
|
226
239
|
noteSapUnreachable(code);
|
|
227
240
|
return { content: JSON.stringify({ error: String(err), sap_unreachable: true, directive: SAP_UNREACHABLE_DIRECTIVE }), is_error: true };
|
|
228
241
|
}
|
|
242
|
+
// LT-14 — outside a plan step: the short note only.
|
|
243
|
+
if (code)
|
|
244
|
+
return { content: JSON.stringify({ error: String(err), note: SAP_UNREACHABLE_NOTE }), is_error: true };
|
|
229
245
|
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
230
246
|
}
|
|
231
247
|
}
|
|
@@ -42,7 +42,7 @@ export function evaluateWatchdog(input, now = Date.now()) {
|
|
|
42
42
|
if (!input.config.enabled || input.alreadyWarned) {
|
|
43
43
|
return { shouldWarn: false, reason: '', message: '' };
|
|
44
44
|
}
|
|
45
|
-
const elapsedMs = Math.max(0, now - input.startedAt);
|
|
45
|
+
const elapsedMs = Math.max(0, now - input.startedAt - Math.max(0, input.pausedMs ?? 0));
|
|
46
46
|
const wallExceeded = elapsedMs >= input.config.wallBudgetMs;
|
|
47
47
|
const tokensExceeded = input.outputTokensThisTurn >= input.config.tokenBudget;
|
|
48
48
|
if (!wallExceeded && !tokensExceeded) {
|
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { paint } from '../renderer/theme.js';
|
|
3
3
|
import { uiWidth } from '../renderer/ui-width.js';
|
|
4
4
|
import { sanitizeControlChars as clean } from '../renderer/sanitize.js';
|
|
5
|
+
import { getBoxChars, DOUBLE_BOX } from '../ui/ascii-chars.js';
|
|
5
6
|
const ADVISORY_TITLE = ' ADVISORY MODE — proposed change (no SAP write will occur) ';
|
|
6
7
|
export const ADVISORY_KEY_HINT = 'Press c to copy to clipboard, y when applied, s to skip, n to abort.';
|
|
7
8
|
export function renderAdvisoryProposal(args, opts = {}) {
|
|
@@ -12,9 +13,11 @@ export function renderAdvisoryProposal(args, opts = {}) {
|
|
|
12
13
|
// Borders line up: top and bottom share one width (§1.4 rule, widened
|
|
13
14
|
// only if the title itself would not fit).
|
|
14
15
|
const w = Math.max(uiWidth(), ADVISORY_TITLE.length + 6);
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
// Old Windows console: the classic ASCII frame (getBoxChars), same width.
|
|
17
|
+
const b = getBoxChars(DOUBLE_BOX);
|
|
18
|
+
const top = b.tl + b.h + b.h + ADVISORY_TITLE + b.h.repeat(w - 4 - ADVISORY_TITLE.length) + b.tr;
|
|
19
|
+
const bottom = b.bl + b.h.repeat(w - 2) + b.br;
|
|
20
|
+
const rule = paint('dim', getBoxChars().h.repeat(w));
|
|
18
21
|
displayParts.push(chalk.bold(paint('warn', top)));
|
|
19
22
|
displayParts.push(chalk.bold(`Summary: ${clean(args.summary)}`));
|
|
20
23
|
displayParts.push(`Risk: ${args.risk}`);
|
|
@@ -6,14 +6,19 @@ import { uiWidth } from '../renderer/ui-width.js';
|
|
|
6
6
|
import { paint } from '../renderer/theme.js';
|
|
7
7
|
import { emitTrace } from '../renderer/trace.js';
|
|
8
8
|
import { askConfirm } from '../ui/confirm-request.js';
|
|
9
|
+
import { getBoxChars, SQUARE_BOX } from '../ui/ascii-chars.js';
|
|
9
10
|
// Classic box: width from the §1.4 rule (was a fixed 62); padding counts
|
|
10
11
|
// VISIBLE cells (string-width ignores ANSI) so coloured rows line up with
|
|
11
12
|
// plain ones. (strip-ansi is a devDependency only — not usable here.)
|
|
13
|
+
// Old Windows console: the classic ASCII frame (getBoxChars), same width.
|
|
12
14
|
const boxWidth = () => uiWidth() - 2;
|
|
13
|
-
const top = () =>
|
|
14
|
-
const bottom = () =>
|
|
15
|
-
const pad = (s) =>
|
|
16
|
-
const
|
|
15
|
+
const top = () => { const b = getBoxChars(SQUARE_BOX); return b.tl + b.h.repeat(boxWidth()) + b.tr; };
|
|
16
|
+
const bottom = () => { const b = getBoxChars(SQUARE_BOX); return b.bl + b.h.repeat(boxWidth()) + b.br; };
|
|
17
|
+
const pad = (s) => {
|
|
18
|
+
const b = getBoxChars(SQUARE_BOX);
|
|
19
|
+
return b.v + ' ' + s + ' '.repeat(Math.max(0, boxWidth() - 2 - stringWidth(s))) + b.v;
|
|
20
|
+
};
|
|
21
|
+
const blank = () => { const b = getBoxChars(SQUARE_BOX); return b.v + ' '.repeat(boxWidth()) + b.v; };
|
|
17
22
|
/**
|
|
18
23
|
* Capture a single keypress for the y/n/d answer. Raw-mode readline so the
|
|
19
24
|
* hand-drawn box stays the only visible affordance.
|
|
@@ -3,6 +3,7 @@ import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
|
3
3
|
import { wrapRows } from '../renderer/wrap-rows.js';
|
|
4
4
|
import { shownUpTo } from '../register/atc-file.js';
|
|
5
5
|
import { transportForWrite } from '../register/project-transport.js';
|
|
6
|
+
import { decodeDisplayEntities, decodeDisplayBlock } from '../renderer/html-entities.js';
|
|
6
7
|
/**
|
|
7
8
|
* Human-readable operation + object labels for the approval surfaces.
|
|
8
9
|
*
|
|
@@ -71,6 +72,20 @@ export function humanType(type) {
|
|
|
71
72
|
return type;
|
|
72
73
|
return type.toLowerCase();
|
|
73
74
|
}
|
|
75
|
+
/** LT-10 (1A fix wave) — the word for a known type code ('CLAS/OC' -> 'class'); undefined otherwise. */
|
|
76
|
+
export function knownTypeWord(type) {
|
|
77
|
+
return Object.prototype.hasOwnProperty.call(TYPE_WORDS, typeCode(type)) ? TYPE_WORDS[typeCode(type)] : undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* LT-11 (1A fix wave) — the types that share their view's name on SAP's
|
|
81
|
+
* generator path. A write to one names its kind ("access control ZR_X"), so
|
|
82
|
+
* it never reads as a write to the view. Every other type: the name alone.
|
|
83
|
+
*/
|
|
84
|
+
const SHARES_VIEW_NAME = new Set(['DDLX', 'DCLS', 'BDEF']);
|
|
85
|
+
export function writtenObjectName(type, name) {
|
|
86
|
+
const code = typeCode(type);
|
|
87
|
+
return SHARES_VIEW_NAME.has(code) ? `${TYPE_WORDS[code]} ${name}` : name;
|
|
88
|
+
}
|
|
74
89
|
/** e.g. "Create table ZMNT_NOTE", "Change class ZCL_X", "Delete table ZOLD". */
|
|
75
90
|
export function verbLine(change) {
|
|
76
91
|
return [humanVerb(change.op), humanType(change.type), change.object]
|
|
@@ -89,7 +104,7 @@ export function affectsLine(change) {
|
|
|
89
104
|
// line comes from the change's op and type, never from a model-supplied field.
|
|
90
105
|
if (change.op === 'delete' && typeCode(change.type) === 'FUGR')
|
|
91
106
|
return FUGR_DELETE_LINE;
|
|
92
|
-
const t = sanitizeControlChars(String(change.affects ?? '')).trim();
|
|
107
|
+
const t = decodeDisplayEntities(sanitizeControlChars(String(change.affects ?? ''))).trim();
|
|
93
108
|
return t ? `Affects: ${t}` : null;
|
|
94
109
|
}
|
|
95
110
|
/** Task 7 — the longest behaviour text a card shows, and the most rows it takes. */
|
|
@@ -107,7 +122,8 @@ export function behaviourText(change) {
|
|
|
107
122
|
// shownUpTo drops whole escape sequences first (a lone sanitizeControlChars
|
|
108
123
|
// pass would leave their '[31m' tails), then every control character.
|
|
109
124
|
// The cut counts code points, so it never leaves half a surrogate pair.
|
|
110
|
-
|
|
125
|
+
// 1.1.53 item 5: entities decode before the cut, so the cut counts what shows.
|
|
126
|
+
const flat = Array.from(decodeDisplayEntities(shownUpTo(String(change.behaviour ?? ''), Number.POSITIVE_INFINITY)));
|
|
111
127
|
const t = flat.length > BEHAVIOUR_MAX ? `${flat.slice(0, BEHAVIOUR_MAX).join('')}…` : flat.join('');
|
|
112
128
|
if (!t || NOTHING_CHANGES.test(t))
|
|
113
129
|
return null;
|
|
@@ -195,7 +211,8 @@ export function displayTransport(op, explicit, session, owning, project) {
|
|
|
195
211
|
* modal's [v] view and the classic prompt's View source branch.
|
|
196
212
|
*/
|
|
197
213
|
export function toSourceLines(content) {
|
|
198
|
-
|
|
214
|
+
// 1.1.53 item 5: display decode of the whole block (kept as written when it has a raw field symbol or markup).
|
|
215
|
+
return decodeDisplayBlock(content)
|
|
199
216
|
.split('\n')
|
|
200
217
|
.filter((l) => !l.startsWith('@@') && !l.startsWith('+++') && !l.startsWith('---'))
|
|
201
218
|
.map((l) => l.replace(/^[+-] ?/, ''));
|