@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
|
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
|
|
|
14
14
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
15
15
|
import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
|
|
16
16
|
import { fileSkillsCache } from './skills-cache.js';
|
|
17
|
+
import { fetchWithConnectRetry } from '../util/connect-retry.js';
|
|
17
18
|
// Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
|
|
18
19
|
export { SkillIntegrityError };
|
|
19
20
|
export class SkillNotInPlanError extends Error {
|
|
@@ -69,10 +70,10 @@ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch)
|
|
|
69
70
|
throw new SkillSignInError();
|
|
70
71
|
let r;
|
|
71
72
|
try {
|
|
72
|
-
r = await
|
|
73
|
+
r = await fetchWithConnectRetry(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, () => ({
|
|
73
74
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
74
75
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
75
|
-
});
|
|
76
|
+
}), { fetchImpl: doFetch });
|
|
76
77
|
}
|
|
77
78
|
catch {
|
|
78
79
|
throw new SkillsUnavailableError('could not reach CSPeach');
|
package/dist/tools/approval.js
CHANGED
|
@@ -6,6 +6,14 @@ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
|
6
6
|
import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
|
|
7
7
|
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
8
8
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
9
|
+
import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
|
|
10
|
+
/**
|
|
11
|
+
* LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
|
|
12
|
+
* card opens and no approval id is minted. The shape dispatchTool returns for
|
|
13
|
+
* a tool that never started (the tool_use stays paired), with a text that says
|
|
14
|
+
* nothing ran.
|
|
15
|
+
*/
|
|
16
|
+
const stoppedBeforeCard = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
|
|
9
17
|
/**
|
|
10
18
|
* Round 8 item 6 — the footer showed "no transport" while the approved card
|
|
11
19
|
* said "transport S4HK903731". An approval granted for an explicit transport
|
|
@@ -29,7 +37,7 @@ import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
|
29
37
|
import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
30
38
|
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
31
39
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
32
|
-
import { displayTransport } from '../approvals/op-labels.js';
|
|
40
|
+
import { displayTransport, knownTypeWord } from '../approvals/op-labels.js';
|
|
33
41
|
import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
|
|
34
42
|
import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
|
|
35
43
|
import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
|
|
@@ -223,7 +231,8 @@ registerTool({
|
|
|
223
231
|
type: 'object',
|
|
224
232
|
properties: {
|
|
225
233
|
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
|
|
226
|
-
|
|
234
|
+
// P-2 (1A fix wave): a group word ('DCLs') is not an object the plan grant can match.
|
|
235
|
+
object: { type: 'string', description: "One real object name per row (comma-join several only in an activate row), never a group word such as 'DCLs'." },
|
|
227
236
|
type: { type: 'string' },
|
|
228
237
|
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
229
238
|
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
@@ -298,6 +307,10 @@ registerTool({
|
|
|
298
307
|
// no role configured and no session override this is exactly
|
|
299
308
|
// cfg.write_mode — byte-identical behavior.
|
|
300
309
|
const role = cfg.sap[ctx.sapAlias]?.role;
|
|
310
|
+
// LT-15 D6 — stopped during the profile read above: no advisory card, no card.
|
|
311
|
+
const stopA = stoppedBeforeCard(ctx);
|
|
312
|
+
if (stopA)
|
|
313
|
+
return stopA;
|
|
301
314
|
// Advisory-only short-circuit — never mint approval_ids.
|
|
302
315
|
// The developer applies the change manually in ADT; the AI gets an
|
|
303
316
|
// ai_directive telling it not to attempt any SAP write tools.
|
|
@@ -313,11 +326,17 @@ registerTool({
|
|
|
313
326
|
return { content: JSON.stringify(releaseRefusal), is_error: true };
|
|
314
327
|
// Task 8 (B11) — the request that already holds each modified object, before any card.
|
|
315
328
|
await attachOwningTransport(args.changes ?? [], ctx);
|
|
329
|
+
// LT-15 D6 — stopped during the reads above: no card, no nag, no id minted
|
|
330
|
+
// (the plan-grant and auto-approve paths below mint without a card).
|
|
331
|
+
const stopB = stoppedBeforeCard(ctx);
|
|
332
|
+
if (stopB)
|
|
333
|
+
return stopB;
|
|
316
334
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
317
335
|
const riskCtx = {
|
|
318
336
|
sapAlias: ctx.sapAlias,
|
|
319
337
|
autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
|
|
320
338
|
syntaxErrorObjects: objectsWithSyntaxErrors(),
|
|
339
|
+
typeWord: knownTypeWord, // LT-10: the syntax-errors reason names the kind of object
|
|
321
340
|
};
|
|
322
341
|
const changes = args.changes;
|
|
323
342
|
const declared = args.risk;
|
|
@@ -421,6 +440,10 @@ registerTool({
|
|
|
421
440
|
sapCfg = cfg.sap[ctx.sapAlias];
|
|
422
441
|
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
423
442
|
}
|
|
443
|
+
// LT-15 D6 (review M3) — stopped while the nag was up: no card after it, no id.
|
|
444
|
+
const stopC = stoppedBeforeCard(ctx);
|
|
445
|
+
if (stopC)
|
|
446
|
+
return stopC;
|
|
424
447
|
// 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
|
|
425
448
|
// yes. Live, auto_approve="low" let the model's own "low" create package
|
|
426
449
|
// ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
|
|
@@ -478,12 +501,14 @@ registerTool({
|
|
|
478
501
|
// for one task" complaint. Review-each falls back to the per-change
|
|
479
502
|
// gate. Cancel rejects the whole plan.
|
|
480
503
|
//
|
|
481
|
-
// Task 9 (C8): only for more than one VISIBLE change
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
504
|
+
// Task 9 (C8): only for more than one VISIBLE change (an activate whose
|
|
505
|
+
// write is in the list is not a change a person sees). A modify + its
|
|
506
|
+
// activate on one class gets the per-change card only; the activate
|
|
507
|
+
// follows its approved write below. LT-12: the plan gate's LIST also
|
|
508
|
+
// hides a modify that a create of the same object covers (planRows), so
|
|
509
|
+
// it may show fewer rows than this count; this count is unchanged.
|
|
485
510
|
let skipPerChangeGates = false;
|
|
486
|
-
// Raw keys on purpose:
|
|
511
|
+
// Raw keys on purpose: activates collapsed, nothing else.
|
|
487
512
|
const visibleCount = collapseImplicitActivates(changes).length;
|
|
488
513
|
if (visibleCount > 1) {
|
|
489
514
|
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
|
|
@@ -549,7 +574,23 @@ registerTool({
|
|
|
549
574
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
550
575
|
}
|
|
551
576
|
else {
|
|
552
|
-
|
|
577
|
+
// LT-15 D6 (review M3) — stopped during the mint after the card before:
|
|
578
|
+
// the next card does not open; ids minted so far are dropped unused.
|
|
579
|
+
const stopD = stoppedBeforeCard(ctx);
|
|
580
|
+
if (stopD)
|
|
581
|
+
return stopD;
|
|
582
|
+
// 1.1.53 item 8 (live F-C): the batch risk put another object's reason
|
|
583
|
+
// on this card. Each card shows the risk of its own change, plus the
|
|
584
|
+
// activate of the same object that this card approves with it. One
|
|
585
|
+
// visible change keeps the batch risk, exactly as before. Display only:
|
|
586
|
+
// the gates above and the result fields keep `eff`.
|
|
587
|
+
const cardRisk = visibleCount <= 1 ? eff : effectiveRisk(declared, [
|
|
588
|
+
c,
|
|
589
|
+
...((c.op === 'modify' || c.op === 'create')
|
|
590
|
+
? changes.filter((a) => a.op === 'activate' && `${a.type}:${canonicalApprovalObject(a.object)}` === objKey)
|
|
591
|
+
: []),
|
|
592
|
+
], riskCtx);
|
|
593
|
+
outcome = await renderPerChangeApprovalV3(c, cardRisk, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
|
|
553
594
|
}
|
|
554
595
|
if (outcome.approved) {
|
|
555
596
|
if (c.op === 'modify' || c.op === 'create')
|
|
@@ -44,7 +44,7 @@ registerTool({
|
|
|
44
44
|
properties: {
|
|
45
45
|
command: {
|
|
46
46
|
type: 'string',
|
|
47
|
-
description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
|
|
47
|
+
description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`. Never put a transport description in it unless the person typed it.',
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
required: ['command'],
|
package/dist/tools/hand-off.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* chat turn then ends and the workflow runs with its own plan + approvals.
|
|
6
6
|
* No extra consent card (D5): the workflow asks before any write.
|
|
7
7
|
* Refused outside the chat lane; dispatch_skill stays the skills' own switch.
|
|
8
|
+
* Stage 1A (T1A.4): in an inline session (`ctx.inlineSkills`) a playbook turn
|
|
9
|
+
* may hand off too — still only queues — except while a plan is in charge or
|
|
10
|
+
* the slot already holds a step (the harness's next-phase hop wins).
|
|
8
11
|
*
|
|
9
12
|
* Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
|
|
10
13
|
* session never lists it and its tools array stays byte-identical to today.
|
|
@@ -14,7 +17,37 @@ import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
|
14
17
|
import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
|
|
15
18
|
/** Longest request hand_off queues (after whitespace is collapsed). */
|
|
16
19
|
export const HAND_OFF_REQUEST_MAX = 500;
|
|
20
|
+
/**
|
|
21
|
+
* I3 (owner, 2026-10-22) — one hand-off per person message. Refusal code when
|
|
22
|
+
* the running turn was itself started by a hand_off: no workflow-to-workflow
|
|
23
|
+
* chain of reads and credits without a new person message. A new person
|
|
24
|
+
* message resets it (the REPL passes who started each turn as `ctx.turnBy`).
|
|
25
|
+
* dispatch_skill after the person's pick is not capped.
|
|
26
|
+
*/
|
|
27
|
+
export const HAND_OFF_CHAIN_CAPPED = 'HAND_OFF_CHAIN_CAPPED';
|
|
17
28
|
export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
|
|
29
|
+
/** The success note when the model wrote nothing before the call in this turn. */
|
|
30
|
+
export const HAND_OFF_NOTE = 'The workflow runs next and asks before it writes. End the turn now. If you wrote a line before the call, add nothing. If you wrote nothing, add one short line saying the workflow takes it from here. Do not print the command.';
|
|
31
|
+
/**
|
|
32
|
+
* LT-3 (live check 2026-10-26) — the model often skipped the "if you wrote a
|
|
33
|
+
* line" test and announced the hand-off twice. The loop knows whether text
|
|
34
|
+
* came first in this turn and then gives this plain note instead.
|
|
35
|
+
*/
|
|
36
|
+
export const HAND_OFF_NOTE_ALREADY_SAID = 'The workflow runs next and asks before it writes. You already told the developer in this turn, so end the turn now and write nothing more. Do not print the command.';
|
|
37
|
+
/** The hand_off success content with the note for a turn that already said its line. Anything else unchanged. */
|
|
38
|
+
export function settleHandOffNote(content, saidBefore) {
|
|
39
|
+
if (!saidBefore)
|
|
40
|
+
return content;
|
|
41
|
+
try {
|
|
42
|
+
const r = JSON.parse(content);
|
|
43
|
+
if (r.ok !== true || r.note !== HAND_OFF_NOTE)
|
|
44
|
+
return content;
|
|
45
|
+
return JSON.stringify({ ...r, note: HAND_OFF_NOTE_ALREADY_SAID });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
18
51
|
export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
|
|
19
52
|
const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
|
|
20
53
|
/**
|
|
@@ -45,15 +78,25 @@ registerTool({
|
|
|
45
78
|
type: 'object',
|
|
46
79
|
properties: {
|
|
47
80
|
skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
|
|
48
|
-
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
|
|
81
|
+
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags. Never put a transport description in it unless the person typed it.' },
|
|
49
82
|
},
|
|
50
83
|
required: ['skill', 'request'],
|
|
51
84
|
},
|
|
52
85
|
handler: async (args, ctx) => {
|
|
53
86
|
const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
|
|
54
|
-
|
|
87
|
+
// Stage 1A (T1A.4) — in an inline session a playbook turn may hand off too.
|
|
88
|
+
const playbookTurn = ctx.session?.lane !== 'chat';
|
|
89
|
+
if (playbookTurn && !ctx.inlineSkills) {
|
|
55
90
|
return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
|
|
56
91
|
}
|
|
92
|
+
// The plan harness queues its next phase through the same slot; never race it.
|
|
93
|
+
if (playbookTurn && (ctx.session?.plan_in_flight || ctx.session?.skill === 'abap-plan')) {
|
|
94
|
+
return bad('HAND_OFF_PLAN_IN_FLIGHT', 'finish this step; after the plan, the person can ask for that in chat');
|
|
95
|
+
}
|
|
96
|
+
// I3 — one hand-off per person message.
|
|
97
|
+
if (ctx.turnBy === 'hand_off') {
|
|
98
|
+
return bad(HAND_OFF_CHAIN_CAPPED, "this workflow was already handed off from the person's message; one hand-off per message. Finish this workflow's answer, then let the person decide the next step: offer that workflow as a suggestion in your answer instead of calling hand_off. Do not call dispatch_skill either unless the person picks it.");
|
|
99
|
+
}
|
|
57
100
|
const skill = normaliseSkill(args.skill);
|
|
58
101
|
if (!skill) {
|
|
59
102
|
return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
|
|
@@ -74,13 +117,12 @@ registerTool({
|
|
|
74
117
|
if (!ctx.pendingDispatch) {
|
|
75
118
|
return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
|
|
76
119
|
}
|
|
120
|
+
if (playbookTurn && ctx.pendingDispatch.get() !== null) {
|
|
121
|
+
return bad('HAND_OFF_SLOT_TAKEN', 'a step is already queued; end the turn');
|
|
122
|
+
}
|
|
77
123
|
ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
|
|
78
124
|
return {
|
|
79
|
-
content: JSON.stringify({
|
|
80
|
-
ok: true,
|
|
81
|
-
queued: command,
|
|
82
|
-
note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
|
|
83
|
-
}),
|
|
125
|
+
content: JSON.stringify({ ok: true, queued: command, note: HAND_OFF_NOTE }),
|
|
84
126
|
};
|
|
85
127
|
},
|
|
86
128
|
});
|
|
@@ -36,6 +36,9 @@ import { askConfirm } from '../../ui/confirm-request.js';
|
|
|
36
36
|
import { isHeadless } from '../../renderer/tty.js';
|
|
37
37
|
import { loadConfig } from '../../config/loader.js';
|
|
38
38
|
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
39
|
+
import { STOPPED_BEFORE_START_RESULT } from '../../agent/repair-partial.js';
|
|
40
|
+
/** LT-15 D6 (review M2) — after a stop (Esc / Ctrl+C) no card opens and nothing is saved. */
|
|
41
|
+
const stopped = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
|
|
39
42
|
const fail = (message) => ({ content: `error: ${message}`, is_error: true });
|
|
40
43
|
const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
|
|
41
44
|
const STOP = 'STOP — lane needs your choice.';
|
|
@@ -200,6 +203,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
|
200
203
|
}
|
|
201
204
|
else {
|
|
202
205
|
// 7. The lane card.
|
|
206
|
+
const stopLane = stopped(ctx);
|
|
207
|
+
if (stopLane)
|
|
208
|
+
return stopLane;
|
|
203
209
|
const confirm = deps.confirm ?? ((req) => askConfirm(req));
|
|
204
210
|
const card = laneCardRequest(decision);
|
|
205
211
|
const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
|
|
@@ -224,6 +230,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
|
224
230
|
}
|
|
225
231
|
// 8. A CAP lane needs a runtime.
|
|
226
232
|
if (finalLane.backend === 'cap' && !runtime) {
|
|
233
|
+
const stopRuntime = stopped(ctx);
|
|
234
|
+
if (stopRuntime)
|
|
235
|
+
return stopRuntime;
|
|
227
236
|
const rr = await confirm(runtimeCardRequest());
|
|
228
237
|
const answer = rr.value;
|
|
229
238
|
if (!answer || !RUNTIMES.includes(answer))
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule 7a in code (2026-10-22, owner go).
|
|
3
|
+
*
|
|
4
|
+
* sap_set_source on an existing class replaces the whole source. When the
|
|
5
|
+
* only thing that changed is the inside of one or more method bodies, the
|
|
6
|
+
* write must go through sap_update_method instead (one call per method): a
|
|
7
|
+
* whole-class rewrite can silently drop parameters, alter untouched methods
|
|
8
|
+
* or lose local definitions.
|
|
9
|
+
*
|
|
10
|
+
* `methodOnlyChange(current, proposed)` splits both sources into
|
|
11
|
+
* - the frame: every line outside method bodies (definition part(s), the
|
|
12
|
+
* IMPLEMENTATION header, the `METHOD x.` / `ENDMETHOD.` lines, ENDCLASS);
|
|
13
|
+
* - each method body: the lines strictly between `METHOD x.` and `ENDMETHOD.`.
|
|
14
|
+
* It returns the names of the changed methods ONLY when the frame is
|
|
15
|
+
* identical, the methods are the same (names, order) and at least one body
|
|
16
|
+
* differs. Anything else returns undefined, and the write goes on as today.
|
|
17
|
+
*
|
|
18
|
+
* Normalised: line endings (CRLF / LF / CR) and trailing whitespace (per
|
|
19
|
+
* line, and blank lines at the end of the source). Nothing else.
|
|
20
|
+
*
|
|
21
|
+
* Conservative: when the split is not certain, return undefined (never
|
|
22
|
+
* refuse). That covers unbalanced METHOD / ENDMETHOD, a METHOD statement with
|
|
23
|
+
* more than its name (AMDP `BY DATABASE PROCEDURE …`), the words METHOD /
|
|
24
|
+
* ENDMETHOD anywhere else in code, macros (DEFINE), INCLUDE programs, an
|
|
25
|
+
* unterminated literal, and any changed method that sap_update_method could
|
|
26
|
+
* not address exactly (its name appears more than once as `METHOD name.`, or
|
|
27
|
+
* its body holds the text `ENDMETHOD.`).
|
|
28
|
+
*
|
|
29
|
+
* Never guarded, by design: a pragma or anything else after the name on the
|
|
30
|
+
* METHOD line (e.g. ADT's ZCX exception-class template), AMDP methods, a
|
|
31
|
+
* component or parameter named "method", and a class with a pending inactive
|
|
32
|
+
* version (anyone's: sap_update_method would refuse it with 409).
|
|
33
|
+
*/
|
|
34
|
+
/** The audit-line detail: at most 5 names, then "+N more" (the audit log keeps 200 chars). */
|
|
35
|
+
export function methodOnlyAuditDetail(methods) {
|
|
36
|
+
const shown = methods.slice(0, 5).join(',');
|
|
37
|
+
const more = methods.length > 5 ? ` +${methods.length - 5} more` : '';
|
|
38
|
+
return `refused: method_only_change (Rule 7a) methods=${shown}${more}`;
|
|
39
|
+
}
|
|
40
|
+
/** The model-facing refusal for a method-only change. */
|
|
41
|
+
export function methodOnlyRefusal(methods) {
|
|
42
|
+
return {
|
|
43
|
+
error: 'method_only_change',
|
|
44
|
+
detail: `Only the bodies of these methods changed: ${methods.join(', ')}. `
|
|
45
|
+
+ 'Use sap_update_method once per method instead of rewriting the whole class (Rule 7a). '
|
|
46
|
+
+ 'The class definition and the other methods stay untouched that way. '
|
|
47
|
+
+ 'Your approval id still works for those calls.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const METHOD_START = /^METHOD\s+([^\s.]+)\s*\.$/i;
|
|
51
|
+
const METHOD_END = /^ENDMETHOD\s*\.$/i;
|
|
52
|
+
/** METHOD / ENDMETHOD as a word: not part of a longer name (METHODS, ls_x-method, lo->method, if~method). */
|
|
53
|
+
const KEYWORD = /(?<![\w\-~>\/=%$])(END)?METHOD(?![\w\-~\/%$])/gi;
|
|
54
|
+
const DEFINE_STMT = /(^|\.)\s*DEFINE\s/i;
|
|
55
|
+
/** An INCLUDE program; `INCLUDE TYPE` / `INCLUDE STRUCTURE` inside TYPES / DATA are not. */
|
|
56
|
+
const INCLUDE_STMT = /(^|\.)\s*INCLUDE\s+(?!(TYPE|STRUCTURE)\s)/i;
|
|
57
|
+
/** Names (upper case) of the methods whose bodies alone changed; undefined when the write must go on. */
|
|
58
|
+
export function methodOnlyChange(current, proposed) {
|
|
59
|
+
const a = splitClassSource(current);
|
|
60
|
+
const b = splitClassSource(proposed);
|
|
61
|
+
if (!a || !b)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (a.methods.length === 0 || a.methods.length !== b.methods.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (!sameLines(a.frame, b.frame))
|
|
66
|
+
return undefined;
|
|
67
|
+
const changed = [];
|
|
68
|
+
for (let i = 0; i < a.methods.length; i++) {
|
|
69
|
+
const ma = a.methods[i];
|
|
70
|
+
const mb = b.methods[i];
|
|
71
|
+
if (ma.name.toUpperCase() !== mb.name.toUpperCase())
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!sameLines(ma.body, mb.body))
|
|
74
|
+
changed.push(ma.name.toUpperCase());
|
|
75
|
+
}
|
|
76
|
+
if (changed.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
for (const name of changed) {
|
|
79
|
+
if (!updateMethodCanAddress(current, name) || !updateMethodCanAddress(proposed, name))
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return changed;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* sap_update_method (sap-client replaceMethodBlock) takes the FIRST
|
|
86
|
+
* `METHOD name.` match anywhere in the source and the body up to the first
|
|
87
|
+
* `ENDMETHOD.` after it. Only point at it when that lands on our block.
|
|
88
|
+
*/
|
|
89
|
+
function updateMethodCanAddress(source, name) {
|
|
90
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
const starts = source.match(new RegExp(`\\bMETHOD\\s+${escaped}\\s*\\.`, 'gi')) ?? [];
|
|
92
|
+
if (starts.length !== 1)
|
|
93
|
+
return false;
|
|
94
|
+
const split = splitClassSource(source);
|
|
95
|
+
const block = split?.methods.find((m) => m.name.toUpperCase() === name);
|
|
96
|
+
return !!block && !block.body.some((l) => /\bENDMETHOD\s*\./i.test(l));
|
|
97
|
+
}
|
|
98
|
+
function sameLines(x, y) {
|
|
99
|
+
return x.length === y.length && x.every((l, i) => l === y[i]);
|
|
100
|
+
}
|
|
101
|
+
/** The frame / body split, or undefined when it is not certain. Exported for tests. */
|
|
102
|
+
export function splitClassSource(source) {
|
|
103
|
+
const lines = source.replace(/\s+$/, '').split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, ''));
|
|
104
|
+
const frame = [];
|
|
105
|
+
const methods = [];
|
|
106
|
+
let open;
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (line.startsWith('*') || line.trimStart().startsWith('"')) {
|
|
109
|
+
(open ? open.body : frame).push(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const code = codeOf(line);
|
|
113
|
+
if (code === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (DEFINE_STMT.test(code) || INCLUDE_STMT.test(code))
|
|
116
|
+
return undefined;
|
|
117
|
+
const stmt = code.trim();
|
|
118
|
+
const start = METHOD_START.exec(stmt);
|
|
119
|
+
if (start) {
|
|
120
|
+
if (open)
|
|
121
|
+
return undefined;
|
|
122
|
+
frame.push(line);
|
|
123
|
+
open = { name: start[1], body: [] };
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (METHOD_END.test(stmt)) {
|
|
127
|
+
if (!open)
|
|
128
|
+
return undefined;
|
|
129
|
+
methods.push(open);
|
|
130
|
+
open = undefined;
|
|
131
|
+
frame.push(line);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (hasStrayKeyword(code))
|
|
135
|
+
return undefined;
|
|
136
|
+
(open ? open.body : frame).push(line);
|
|
137
|
+
}
|
|
138
|
+
if (open)
|
|
139
|
+
return undefined;
|
|
140
|
+
return { frame, methods };
|
|
141
|
+
}
|
|
142
|
+
/** METHOD / ENDMETHOD in code other than a whole `METHOD x.` / `ENDMETHOD.` line. `CALL METHOD` is a call. */
|
|
143
|
+
function hasStrayKeyword(code) {
|
|
144
|
+
for (const m of code.matchAll(KEYWORD)) {
|
|
145
|
+
const isEnd = !!m[1];
|
|
146
|
+
if (!isEnd && /\bCALL\s+$/i.test(code.slice(0, m.index)))
|
|
147
|
+
continue;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The code part of one line: literal contents blanked, a trailing `"`
|
|
154
|
+
* comment dropped. Undefined when a literal does not close on the line.
|
|
155
|
+
*/
|
|
156
|
+
function codeOf(line) {
|
|
157
|
+
let out = '';
|
|
158
|
+
for (let i = 0; i < line.length; i++) {
|
|
159
|
+
const ch = line[i];
|
|
160
|
+
if (ch === '"')
|
|
161
|
+
break;
|
|
162
|
+
if (ch === "'" || ch === '`') {
|
|
163
|
+
let j = i + 1;
|
|
164
|
+
for (; j < line.length; j++) {
|
|
165
|
+
if (line[j] === ch) {
|
|
166
|
+
if (line[j + 1] === ch) {
|
|
167
|
+
j++;
|
|
168
|
+
continue;
|
|
169
|
+
} // doubled quote = escaped
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (j >= line.length)
|
|
174
|
+
return undefined;
|
|
175
|
+
out += ch + ' '.repeat(j - i - 1) + ch;
|
|
176
|
+
i = j;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '|') {
|
|
180
|
+
let j = i + 1;
|
|
181
|
+
for (; j < line.length; j++) {
|
|
182
|
+
if (line[j] === '\\') {
|
|
183
|
+
j++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (line[j] === '|')
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
if (j >= line.length)
|
|
190
|
+
return undefined;
|
|
191
|
+
out += '|' + ' '.repeat(j - i - 1) + '|';
|
|
192
|
+
i = j;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
out += ch;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Runs the guard for one sap_set_source call. Classes only (the caller checks
|
|
201
|
+
* the type). Returns `refuse` with the changed methods, or `go` with the read
|
|
202
|
+
* outcome for the snapshot to reuse (undefined when nothing was read).
|
|
203
|
+
*
|
|
204
|
+
* Before refusing, the class must have NO pending inactive version — the same
|
|
205
|
+
* check sap-client's updateMethod makes (it refuses with 409 when the class is
|
|
206
|
+
* listed). Otherwise refusing would leave no way to write the class at all.
|
|
207
|
+
* That second read happens only on the refusal path; if it fails, never refuse.
|
|
208
|
+
*/
|
|
209
|
+
export async function runMethodOnlyGuard(adt, g) {
|
|
210
|
+
if (typeof adt?.getSource !== 'function')
|
|
211
|
+
return { go: undefined };
|
|
212
|
+
let outcome;
|
|
213
|
+
try {
|
|
214
|
+
outcome = { source: await adt.getSource(g.type, g.name) };
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
outcome = { error };
|
|
218
|
+
}
|
|
219
|
+
if (!('source' in outcome) || typeof outcome.source !== 'string')
|
|
220
|
+
return { go: outcome };
|
|
221
|
+
const changed = methodOnlyChange(outcome.source, g.source);
|
|
222
|
+
if (!changed)
|
|
223
|
+
return { go: outcome };
|
|
224
|
+
if (await classIsInactive(adt, g.name) !== false)
|
|
225
|
+
return { go: outcome };
|
|
226
|
+
return { refuse: changed };
|
|
227
|
+
}
|
|
228
|
+
/** true / false from the inactive-objects list; undefined when it could not be checked. */
|
|
229
|
+
async function classIsInactive(adt, name) {
|
|
230
|
+
if (typeof adt.inactiveObjects !== 'function')
|
|
231
|
+
return undefined;
|
|
232
|
+
try {
|
|
233
|
+
const upper = name.toUpperCase();
|
|
234
|
+
const list = await adt.inactiveObjects();
|
|
235
|
+
return list.some((o) => String(o.name ?? '').toUpperCase() === upper
|
|
236
|
+
&& (o.type === '' || String(o.type ?? '').toUpperCase().startsWith('CLAS')));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|