@cspeach/cli 1.1.52 → 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/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +101 -13
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +6 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- 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/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 +46 -18
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl.js +11 -2
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/approval.js +12 -1
- package/dist/tools/project/spec_answers_record.js +190 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- package/package.json +2 -2
|
@@ -26,6 +26,7 @@ How you talk
|
|
|
26
26
|
- Plain words, short sentences. Use headings, tables or lists only when they make the answer easier to read.
|
|
27
27
|
- Have a view. If something is a bad idea, say so and say why. If you're not sure, say what you'd check.
|
|
28
28
|
- When there is a next step, say what you would do in your own words. Do not end a reply with a word, phrase or command for the person to type to go on. If they ask which command does something, name it. If the next step is a workflow and they have asked for it, call hand_off now: the workflow shows its plan and asks before it writes. If it is a real choice, ask the question.
|
|
29
|
+
- When there is a clear next step, end with it as one question card (next step recommended + 'Not now'). A step that only saves local files or plans runs on yes without asking again. SAP writes still show their approval card. Use ask_question for the card.
|
|
29
30
|
- A playbook's report header (## [abap-x] — object, TL;DR, Headline, Top 3, Verdict, Next) goes only in a saved document and in the workflow's report after the person typed an /abap-… command. Not after your own hand_off, and not in other turns: there, answer as you would in conversation.
|
|
30
31
|
- After a write, say what changed and what is next. Do not explain approvals, risk ratings or activation mechanics unless asked. If something failed or stayed inactive, say so.
|
|
31
32
|
- No flattery. Apologise only when you got something wrong.
|
package/dist/agent/loop.js
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig } from '../config/loader.js';
|
|
|
9
9
|
import { resolveModelRole, resolveEffort, modelAcceptsEffort, resolveAutoCompactThreshold, callModelFor } from '../models/resolve.js';
|
|
10
10
|
import { getCustomerModelConfig, managedProxyServesNewShapes, servedToolLoading } from '../models/server-config.js';
|
|
11
11
|
import { familyOf, familyAcceptsEffort } from '../models/model-identity.js';
|
|
12
|
-
import { buildToolList, buildSkillToolAddition, placeSystemMessagesInPlace, resolveToolLoading, } from '../tools/tool-loading.js';
|
|
12
|
+
import { buildToolList, buildSkillToolAddition, buildToolAdditionMessage, deferredNames, surfacedToolNames, placeSystemMessagesInPlace, resolveToolLoading, } from '../tools/tool-loading.js';
|
|
13
13
|
import { settleSessionToolLoading } from './tool-loading-pin.js';
|
|
14
14
|
import { resolveSkillAlias } from '../skill-catalog.js';
|
|
15
15
|
import { retryWithBackoff } from './retry.js';
|
|
@@ -46,6 +46,7 @@ import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
|
46
46
|
import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
|
|
47
47
|
import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
48
48
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
49
|
+
import { blockerAnswersNote, SPEC_ANSWERS_RECORD_TOOL } from '../projects/blocker-answers-note.js';
|
|
49
50
|
import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
50
51
|
import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
|
|
51
52
|
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
@@ -70,7 +71,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
|
70
71
|
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
71
72
|
import { composeSessionContextBlock } from './session-context.js';
|
|
72
73
|
import { objectKeyFromInput } from './retry-key.js';
|
|
73
|
-
import { repairPartialBlocks, stripPreFallbackBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly } from './repair-partial.js';
|
|
74
|
+
import { repairPartialBlocks, stripPreFallbackBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly, closedOffStepsLine } from './repair-partial.js';
|
|
74
75
|
import { drainSteeringEntries, enqueueSteering } from './steering-queue.js';
|
|
75
76
|
import { steeringReadLine } from '../renderer/steering-echo.js';
|
|
76
77
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
@@ -448,9 +449,51 @@ async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
|
|
|
448
449
|
p.emit(formatInfo(`Word copy not written: ${e.message}`).trimEnd());
|
|
449
450
|
}
|
|
450
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Change options (2026-10-23) — an answer carrying the change-options marker
|
|
454
|
+
* pair gets its marked part saved (every complete pair, in order: a level
|
|
455
|
+
* picked in session adds the decided document), whatever the skill and even when the turn
|
|
456
|
+
* ends on the level question. Never on a sub-agent turn or inside a guided
|
|
457
|
+
* flow. Returns true when the marker check handled the turn (the question was
|
|
458
|
+
* asked or the document saved), so maybeOfferSave runs nothing else. An answer
|
|
459
|
+
* with no complete marker pair returns false and takes today's path unchanged.
|
|
460
|
+
*/
|
|
461
|
+
async function maybeOfferChangeOptions(p, confirm) {
|
|
462
|
+
if (p.subagent || p.guided || p.assistantText.includes('<!-- guided-run:'))
|
|
463
|
+
return false;
|
|
464
|
+
if (!p.assistantText.includes('csforge:change-options'))
|
|
465
|
+
return false;
|
|
466
|
+
const { extractChangeOptions } = await import('../projects/change-options.js');
|
|
467
|
+
// Every complete pair, in order: a level picked in session shows the
|
|
468
|
+
// document again with the decision, and that one is saved too (last).
|
|
469
|
+
const docs = extractChangeOptions(p.assistantText).filter((d) => d.markdown.trim().length > 0);
|
|
470
|
+
if (docs.length === 0)
|
|
471
|
+
return false;
|
|
472
|
+
// Set before the save question, so a declined save still routes the level reply.
|
|
473
|
+
if (p.session && !p.subagent)
|
|
474
|
+
p.session.lastChangeOptions = { object: docs[docs.length - 1].object, skill: p.skill };
|
|
475
|
+
try {
|
|
476
|
+
const { offerChangeOptionsSave } = await import('../projects/document-save.js');
|
|
477
|
+
const { loadConfig, resolveWordCopy } = await import('../config/loader.js');
|
|
478
|
+
await offerChangeOptionsSave({
|
|
479
|
+
docs: docs.map((d) => ({ object: d.object, markdown: d.markdown })), cwd: process.cwd(), today: localToday(),
|
|
480
|
+
sapSystem: p.sapSystem ?? null,
|
|
481
|
+
...(p.sessionId ? { sessionId: p.sessionId } : {}),
|
|
482
|
+
...(p.operator ? { operator: p.operator } : {}),
|
|
483
|
+
headless: isHeadless(), wordCopy: resolveWordCopy(await loadConfig()), confirm, emit: p.emit,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
p.emit(formatInfo(`Not saved. ${e.message}`).trimEnd());
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
451
491
|
export async function maybeOfferSave(p) {
|
|
452
492
|
if (p.assistantText.trim().length === 0)
|
|
453
493
|
return;
|
|
494
|
+
// Change options (2026-10-23): the marker check runs first, for every skill.
|
|
495
|
+
if (await maybeOfferChangeOptions(p, p.confirm ?? inkAwareConfirm))
|
|
496
|
+
return;
|
|
454
497
|
// Word export (2026-10-19): reviews and handovers are prose documents, not
|
|
455
498
|
// envelopes. They get a Markdown save under cspeach-out and the Word
|
|
456
499
|
// question (projects/document-save.ts). Nothing below this branch changes
|
|
@@ -577,7 +620,12 @@ export async function maybeOfferSave(p) {
|
|
|
577
620
|
// business questions stay open for a consultant to answer in the viewer.
|
|
578
621
|
// Uses inkAwarePrompt directly (NOT the save hook's blanket-'y' headless
|
|
579
622
|
// prompt): in headless it returns '' → declined, so nothing is written.
|
|
623
|
+
// Task 16 (F-M): what the form recorded reaches the model as one note on
|
|
624
|
+
// its next call (session.blockerAnswersNote, sent by runTurn). Main turns
|
|
625
|
+
// with a session only; declined / not offered / not saved → no note.
|
|
580
626
|
if (savedPath && saveSkill === 'abap-spec-gap') {
|
|
627
|
+
const noteSession = p.session && !p.subagent ? p.session : undefined;
|
|
628
|
+
let outcomes;
|
|
581
629
|
await offerAnswerBlockers({
|
|
582
630
|
path: savedPath,
|
|
583
631
|
identity: getAuthorIdentity(),
|
|
@@ -585,7 +633,14 @@ export async function maybeOfferSave(p) {
|
|
|
585
633
|
confirm: inkAwareConfirm,
|
|
586
634
|
choose: inkAwareChoose,
|
|
587
635
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
636
|
+
...(noteSession ? { onOutcomes: (o) => { outcomes = o; } } : {}),
|
|
588
637
|
});
|
|
638
|
+
if (noteSession && outcomes) {
|
|
639
|
+
// The path is shown against the read tool's base (ctx.cwd), so the model opens it with file_read.
|
|
640
|
+
const note = blockerAnswersNote({ path: savedPath, cwd: p.readCwd ?? process.cwd(), outcomes });
|
|
641
|
+
if (note)
|
|
642
|
+
noteSession.blockerAnswersNote = note;
|
|
643
|
+
}
|
|
589
644
|
}
|
|
590
645
|
// App lane routing (Task 7) — the harness-owned /abap-app chain. Built
|
|
591
646
|
// from the exact saved path, never from model text. Outside an app flow
|
|
@@ -1068,7 +1123,12 @@ ${imageExpansion.error}`));
|
|
|
1068
1123
|
if (mainTurn && !inlineTurn && historyHoldsInline(session.messages)) {
|
|
1069
1124
|
playbookLine = isChatSkill(params.skill) ? playbookLineSystemChat() : playbookLineSystem(params.skill);
|
|
1070
1125
|
}
|
|
1071
|
-
|
|
1126
|
+
// Task 16 (F-M) — what the blocker form recorded after the last turn: once,
|
|
1127
|
+
// on the next main turn (never a sub-agent or reader child), then dropped.
|
|
1128
|
+
const blockerNote = mainTurn && session.blockerAnswersNote
|
|
1129
|
+
? `<blocker_answers>\n${session.blockerAnswersNote}\n</blocker_answers>`
|
|
1130
|
+
: '';
|
|
1131
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine]);
|
|
1072
1132
|
// Deterministically extract structured facts from the user's prompt
|
|
1073
1133
|
// (package, transport, object names, environment hint, create intent)
|
|
1074
1134
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -1084,11 +1144,12 @@ ${imageExpansion.error}`));
|
|
|
1084
1144
|
// Final batch 1 — a caller-enriched message still gets the switch note,
|
|
1085
1145
|
// inside its own <session_context> block.
|
|
1086
1146
|
// Stage 1A — the <playbook> line rides the same way ('' ⇒ unchanged).
|
|
1087
|
-
const callerNotes = composeSessionContextBlock([switchNote, playbookLine]);
|
|
1147
|
+
const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine]);
|
|
1088
1148
|
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
1089
1149
|
? (callerNotes ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${callerNotes}`) : userMessageForLLM)
|
|
1090
1150
|
: enrichUserMessage(userMessageForLLM, sessionContextBlock);
|
|
1091
1151
|
const switchNoteUsed = switchNote !== '' && enriched.includes(switchNote);
|
|
1152
|
+
const blockerNoteUsed = blockerNote !== '' && enriched.includes(blockerNote);
|
|
1092
1153
|
// Phase 2 of Track B — opt-in project-context enrichment, gated by
|
|
1093
1154
|
// CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
|
|
1094
1155
|
// identical to today. When on, the rendered <project_context> block
|
|
@@ -1116,6 +1177,7 @@ ${imageExpansion.error}`));
|
|
|
1116
1177
|
projectContextBlock = await maybeBuildProjectContext({
|
|
1117
1178
|
cwd: params.ctx.cwd,
|
|
1118
1179
|
sapConnection,
|
|
1180
|
+
emit: (line) => emit(chalk.dim(line)),
|
|
1119
1181
|
});
|
|
1120
1182
|
}
|
|
1121
1183
|
const contextualContent = projectContextBlock
|
|
@@ -1137,6 +1199,9 @@ ${imageExpansion.error}`));
|
|
|
1137
1199
|
lane: session.lane,
|
|
1138
1200
|
tool_set_skill: session.tool_set_skill,
|
|
1139
1201
|
turn_seq: session.turn_seq,
|
|
1202
|
+
// Task 16 fix round 1 (I-1, R17): the one-time notes this turn consumes.
|
|
1203
|
+
systemSwitchNote: session.systemSwitchNote,
|
|
1204
|
+
blockerAnswersNote: session.blockerAnswersNote,
|
|
1140
1205
|
};
|
|
1141
1206
|
let round0Steers = [];
|
|
1142
1207
|
// Images (if any) ride as blocks ahead of the text; with none this is the
|
|
@@ -1152,6 +1217,8 @@ ${imageExpansion.error}`));
|
|
|
1152
1217
|
// it is in a pushed user message.
|
|
1153
1218
|
if (switchNoteUsed)
|
|
1154
1219
|
delete session.systemSwitchNote;
|
|
1220
|
+
if (blockerNoteUsed)
|
|
1221
|
+
delete session.blockerAnswersNote;
|
|
1155
1222
|
if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
|
|
1156
1223
|
// The local (OpenAI-compatible) provider keeps text blocks only.
|
|
1157
1224
|
emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
|
|
@@ -1196,9 +1263,14 @@ ${imageExpansion.error}`));
|
|
|
1196
1263
|
const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
|
|
1197
1264
|
// Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
|
|
1198
1265
|
// when a reader would have no real read tool (standalone, file tools off).
|
|
1199
|
-
const
|
|
1266
|
+
const readerVisible = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
|
|
1200
1267
|
? contextTools.filter((t) => t.name !== READ_AGENT)
|
|
1201
1268
|
: contextTools;
|
|
1269
|
+
// Task 18 fix round 1 (item 5) — an agent_run child never records spec
|
|
1270
|
+
// answers: it could race the blocker form on the same file.
|
|
1271
|
+
const visibleTools = params.ctx.subagent
|
|
1272
|
+
? readerVisible.filter((t) => t.name !== SPEC_ANSWERS_RECORD_TOOL)
|
|
1273
|
+
: readerVisible;
|
|
1202
1274
|
// Final review C1 (controller ruling) — deferred shapes only for a model on
|
|
1203
1275
|
// the probe-proven allow-list, decided by the model actually sent on the
|
|
1204
1276
|
// call (a plan-tier sonnet override, a haiku child or claude-opus-4-8 ⇒
|
|
@@ -1254,13 +1326,7 @@ ${imageExpansion.error}`));
|
|
|
1254
1326
|
// Stage 1A — skill_appended goes on the turn's first cost line (a call
|
|
1255
1327
|
// retried after a prompt-too-long recovery logs no line of its own).
|
|
1256
1328
|
let skillAppendedLogged = false;
|
|
1257
|
-
const
|
|
1258
|
-
const msg = buildSkillToolAddition({
|
|
1259
|
-
skill,
|
|
1260
|
-
toolLoading: session.tool_loading,
|
|
1261
|
-
toolList: turnTools,
|
|
1262
|
-
messages: session.messages,
|
|
1263
|
-
});
|
|
1329
|
+
const pushToolAddition = (msg) => {
|
|
1264
1330
|
if (msg)
|
|
1265
1331
|
session.messages.push(msg);
|
|
1266
1332
|
if (msg && inlineTurn) {
|
|
@@ -1270,10 +1336,24 @@ ${imageExpansion.error}`));
|
|
|
1270
1336
|
}
|
|
1271
1337
|
}
|
|
1272
1338
|
};
|
|
1339
|
+
const surfaceStarters = (skill) => {
|
|
1340
|
+
pushToolAddition(buildSkillToolAddition({
|
|
1341
|
+
skill,
|
|
1342
|
+
toolLoading: session.tool_loading,
|
|
1343
|
+
toolList: turnTools,
|
|
1344
|
+
messages: session.messages,
|
|
1345
|
+
}));
|
|
1346
|
+
};
|
|
1273
1347
|
// A turn on a different skill than the one the array was built for: the
|
|
1274
1348
|
// user message was just pushed, so the system message follows it.
|
|
1275
1349
|
if (params.skill !== session.tool_set_skill)
|
|
1276
1350
|
surfaceStarters(params.skill);
|
|
1351
|
+
// Task 18 fix round 1 (item 7) — the blocker note names spec_answers_record:
|
|
1352
|
+
// on the turn that carries it, surface the tool when this list defers it
|
|
1353
|
+
// (loaded already, static, or surfaced before: nothing is added).
|
|
1354
|
+
if (blockerNoteUsed && session.tool_loading !== 'static' && !surfacedToolNames(session.messages).has(SPEC_ANSWERS_RECORD_TOOL)) {
|
|
1355
|
+
pushToolAddition(buildToolAdditionMessage([SPEC_ANSWERS_RECORD_TOOL], deferredNames(turnTools)));
|
|
1356
|
+
}
|
|
1277
1357
|
/**
|
|
1278
1358
|
* key: `${tool_name}:${object}` — consecutive failures per (tool, target).
|
|
1279
1359
|
* For object-identifying tools (sap_set_source / sap_activate / etc.) the
|
|
@@ -1446,6 +1526,11 @@ ${imageExpansion.error}`));
|
|
|
1446
1526
|
session.lane = preTurnState.lane;
|
|
1447
1527
|
session.tool_set_skill = preTurnState.tool_set_skill;
|
|
1448
1528
|
session.turn_seq = preTurnState.turn_seq;
|
|
1529
|
+
// The re-run turn still gets the one-time notes (they were dropped on push).
|
|
1530
|
+
if (preTurnState.systemSwitchNote !== undefined)
|
|
1531
|
+
session.systemSwitchNote = preTurnState.systemSwitchNote;
|
|
1532
|
+
if (preTurnState.blockerAnswersNote !== undefined)
|
|
1533
|
+
session.blockerAnswersNote = preTurnState.blockerAnswersNote;
|
|
1449
1534
|
// Steering drained in round 0 goes back to the queue, ahead of any
|
|
1450
1535
|
// typed since, so the re-run turn still sees it.
|
|
1451
1536
|
// LT-15 Task 4 — the round-0 ones were shown as "↳ read": marked, so the
|
|
@@ -1551,7 +1636,7 @@ ${imageExpansion.error}`));
|
|
|
1551
1636
|
// that ended before the model answered the tool_addition.
|
|
1552
1637
|
placeSystemMessagesInPlace(session.messages);
|
|
1553
1638
|
if (healedOrphans > 0) {
|
|
1554
|
-
emit(chalk.dim(
|
|
1639
|
+
emit(chalk.dim(closedOffStepsLine(healedOrphans)));
|
|
1555
1640
|
}
|
|
1556
1641
|
// Task 4 — effort only from an explicit act (ruling F2); never to a
|
|
1557
1642
|
// model that rejects it (probe P1: claude-haiku-4-5* ⇒ 400).
|
|
@@ -2281,10 +2366,13 @@ ${imageExpansion.error}`));
|
|
|
2281
2366
|
model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
|
|
2282
2367
|
emit,
|
|
2283
2368
|
sapSystem: session.sap_system,
|
|
2369
|
+
...(params.operator ? { operator: params.operator } : {}),
|
|
2370
|
+
sessionId: session.id,
|
|
2284
2371
|
...(params.ctx.subagent ? { subagent: true } : {}),
|
|
2285
2372
|
...(session.awaitingSkillAnswer === true ? { awaitingAnswer: true } : {}),
|
|
2286
2373
|
...(getJourneyFlow(session) || getAppFlow(session) ? { guided: true } : {}),
|
|
2287
2374
|
session,
|
|
2375
|
+
readCwd: params.ctx.cwd,
|
|
2288
2376
|
promotedFrom: promotedFromForSave,
|
|
2289
2377
|
...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
|
|
2290
2378
|
...(() => {
|
|
@@ -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
|
|
@@ -210,6 +210,15 @@ export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the
|
|
|
210
210
|
* SAP change. Same shape and flags as INTERRUPTED_TOOL_RESULT (is_error: true).
|
|
211
211
|
*/
|
|
212
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
|
+
}
|
|
213
222
|
/**
|
|
214
223
|
* LT-15 D6 (review M5) — the same, for the write pipeline's second guard: by
|
|
215
224
|
* then the approval was spent and SAP was read (a local snapshot may exist),
|
|
@@ -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
|
|
@@ -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
|
*
|
|
@@ -103,7 +104,7 @@ export function affectsLine(change) {
|
|
|
103
104
|
// line comes from the change's op and type, never from a model-supplied field.
|
|
104
105
|
if (change.op === 'delete' && typeCode(change.type) === 'FUGR')
|
|
105
106
|
return FUGR_DELETE_LINE;
|
|
106
|
-
const t = sanitizeControlChars(String(change.affects ?? '')).trim();
|
|
107
|
+
const t = decodeDisplayEntities(sanitizeControlChars(String(change.affects ?? ''))).trim();
|
|
107
108
|
return t ? `Affects: ${t}` : null;
|
|
108
109
|
}
|
|
109
110
|
/** Task 7 — the longest behaviour text a card shows, and the most rows it takes. */
|
|
@@ -121,7 +122,8 @@ export function behaviourText(change) {
|
|
|
121
122
|
// shownUpTo drops whole escape sequences first (a lone sanitizeControlChars
|
|
122
123
|
// pass would leave their '[31m' tails), then every control character.
|
|
123
124
|
// The cut counts code points, so it never leaves half a surrogate pair.
|
|
124
|
-
|
|
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)));
|
|
125
127
|
const t = flat.length > BEHAVIOUR_MAX ? `${flat.slice(0, BEHAVIOUR_MAX).join('')}…` : flat.join('');
|
|
126
128
|
if (!t || NOTHING_CHANGES.test(t))
|
|
127
129
|
return null;
|
|
@@ -209,7 +211,8 @@ export function displayTransport(op, explicit, session, owning, project) {
|
|
|
209
211
|
* modal's [v] view and the classic prompt's View source branch.
|
|
210
212
|
*/
|
|
211
213
|
export function toSourceLines(content) {
|
|
212
|
-
|
|
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)
|
|
213
216
|
.split('\n')
|
|
214
217
|
.filter((l) => !l.startsWith('@@') && !l.startsWith('+++') && !l.startsWith('---'))
|
|
215
218
|
.map((l) => l.replace(/^[+-] ?/, ''));
|
package/dist/approvals/render.js
CHANGED
|
@@ -14,6 +14,7 @@ import { paint } from '../renderer/theme.js';
|
|
|
14
14
|
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
15
15
|
import { planRows, looksLikeUnifiedDiff } from './visible-changes.js';
|
|
16
16
|
import { withSpinnerSuspended } from '../renderer/tool-widget.js';
|
|
17
|
+
import { decodeDisplayEntities, decodeDisplayBlock } from '../renderer/html-entities.js';
|
|
17
18
|
// Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
|
|
18
19
|
const peach = (s) => paint('brand', s);
|
|
19
20
|
/**
|
|
@@ -294,11 +295,11 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
|
|
|
294
295
|
// proper diff content.
|
|
295
296
|
if (change.diff) {
|
|
296
297
|
if (looksLikeUnifiedDiff(change.diff)) {
|
|
297
|
-
console.log('\n' + colorizeDiff(change.diff) + '\n');
|
|
298
|
+
console.log('\n' + colorizeDiff(decodeDisplayBlock(change.diff)) + '\n');
|
|
298
299
|
}
|
|
299
300
|
else {
|
|
300
301
|
console.log('\n' + chalk.yellow(`${getGlyphs().attention} Model did not provide a unified diff — only a description:`));
|
|
301
|
-
console.log(chalk.dim(change.diff) + '\n');
|
|
302
|
+
console.log(chalk.dim(decodeDisplayEntities(change.diff)) + '\n');
|
|
302
303
|
}
|
|
303
304
|
}
|
|
304
305
|
else {
|
|
@@ -34,7 +34,7 @@ import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-obje
|
|
|
34
34
|
import { isDeployPhase } from '../projects/plan-lane.js';
|
|
35
35
|
import { askDeployCard } from './deploy-card.js';
|
|
36
36
|
import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
|
|
37
|
-
import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
|
|
37
|
+
import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, planBuildTransport, } from './plan-resume.js';
|
|
38
38
|
import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
39
39
|
import { clean } from '../renderer/tool-labels.js';
|
|
40
40
|
import { runBatchAudit, pendingAuditPhaseIds, selectPlanCardAction, } from './plan-batch-audit.js';
|
|
@@ -251,7 +251,7 @@ async function postPhaseChain(args) {
|
|
|
251
251
|
const built = after.content.phases.every((q) => isPhaseSatisfiedForRun(st[q.id], q.audit));
|
|
252
252
|
const nextAfter = computeNextPhaseForRun(after.content.phases, st);
|
|
253
253
|
if (built && !nextAfter) {
|
|
254
|
-
args.log('', ...planCompletionLines(after.content.phases));
|
|
254
|
+
args.log('', ...planCompletionLines(after.content.phases, undefined, planBuildTransport(after.content.phases)));
|
|
255
255
|
// Step 2 (C5): a rebuild whose plan this is now reads "built, not yet checked".
|
|
256
256
|
await afterPlanComplete(cur);
|
|
257
257
|
args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
|
|
@@ -521,7 +521,7 @@ export async function preparePlanResume(args) {
|
|
|
521
521
|
// C2 (D24): a finished UI-less backend stack chains to /abap-fiori-build
|
|
522
522
|
// with the exact binding name — the marketed idea→app story must not
|
|
523
523
|
// silently end at the service binding.
|
|
524
|
-
args.log(...planCompletionLines(content.phases));
|
|
524
|
+
args.log(...planCompletionLines(content.phases, undefined, planBuildTransport(content.phases)));
|
|
525
525
|
}
|
|
526
526
|
else {
|
|
527
527
|
const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => titleOfId(p.id));
|
|
@@ -1102,7 +1102,7 @@ export async function finishPlanResume(args) {
|
|
|
1102
1102
|
args.log(renderPlanProgressLine({ content: { ...extract.content, phases: provisionalPhases }, statuses, currentId: null }));
|
|
1103
1103
|
if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
|
|
1104
1104
|
// C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
|
|
1105
|
-
args.log(...planCompletionLines(provisionalPhases));
|
|
1105
|
+
args.log(...planCompletionLines(provisionalPhases, undefined, planBuildTransport(provisionalPhases)));
|
|
1106
1106
|
// Step 2 (C5): the live run finished — a rebuild whose plan this is reads built.
|
|
1107
1107
|
// Not at the re-opened complete plan above (preparePlanResume).
|
|
1108
1108
|
await afterPlanComplete(args.prepared.path);
|
|
@@ -17,7 +17,7 @@ import { basename, dirname } from 'node:path';
|
|
|
17
17
|
import { readProjectFile } from '../projects/status.js';
|
|
18
18
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
19
19
|
import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, statusesFromItems, } from '../projects/plan-run.js';
|
|
20
|
-
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
20
|
+
import { collectPlannedObjects, plannedObjectFacts, planGrantCoversWrites, planGrantFactLines, planTransportFact, } from '../projects/plan-objects.js';
|
|
21
21
|
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
|
|
22
22
|
import { newestPlanFamilyFiles } from '../session/recap.js';
|
|
23
23
|
import { recordCliCard } from '../session/cli-record.js';
|
|
@@ -100,8 +100,8 @@ function objectListFacts(objects, transport, grantable) {
|
|
|
100
100
|
return [
|
|
101
101
|
`It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`,
|
|
102
102
|
...plannedObjectFacts(objects).map((r) => ` ${r}`),
|
|
103
|
-
|
|
104
|
-
...(grantable ? planGrantFactLines() : ['CSPeach asks before each write.']),
|
|
103
|
+
planTransportFact(transport, objects),
|
|
104
|
+
...(grantable && planGrantCoversWrites(transport, objects) ? planGrantFactLines() : ['CSPeach asks before each write.']),
|
|
105
105
|
];
|
|
106
106
|
}
|
|
107
107
|
const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
|
package/dist/docx/doc-meta.js
CHANGED
|
@@ -11,6 +11,7 @@ const KIND_TITLE = {
|
|
|
11
11
|
'spec-gap': 'Spec gaps', design: 'Design', estimate: 'Estimate', plan: 'Plan',
|
|
12
12
|
'upgrade-scan': 'Upgrade scan', 'upgrade-report': 'Upgrade report',
|
|
13
13
|
answer: 'CSPeach answer', conversation: 'CSPeach conversation',
|
|
14
|
+
'change-options': 'Change options',
|
|
14
15
|
};
|
|
15
16
|
/** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
|
|
16
17
|
const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
|
|
@@ -67,6 +67,8 @@ function titlePage(meta) {
|
|
|
67
67
|
facts.push(`Session: ${meta.session}`);
|
|
68
68
|
if (meta.person)
|
|
69
69
|
facts.push(`Signed in as: ${meta.person}`);
|
|
70
|
+
if (meta.preparedFor)
|
|
71
|
+
facts.push(`Prepared for: ${meta.preparedFor}`);
|
|
70
72
|
return [
|
|
71
73
|
new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
|
|
72
74
|
...facts.map((f) => new Paragraph({ text: f })),
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -18,7 +18,7 @@ import { readProjectFile } from '../projects/status.js';
|
|
|
18
18
|
import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
|
|
19
19
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
20
20
|
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from '../commands/plan-resume.js';
|
|
21
|
-
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
21
|
+
import { collectPlannedObjects, plannedObjectFacts, planGrantCoversWrites, planGrantFactLines, planTransportFact, } from '../projects/plan-objects.js';
|
|
22
22
|
import { phaseDisplayTitle } from '../projects/plan-run.js';
|
|
23
23
|
import { isDeployPhase } from '../projects/plan-lane.js';
|
|
24
24
|
import { installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
|
|
@@ -255,12 +255,15 @@ export function buildStartCardFacts(content, opts = {}) {
|
|
|
255
255
|
// The plan's recorded transport, else the session's current one: the grant
|
|
256
256
|
// is minted with it bound, so the card names it.
|
|
257
257
|
const transport = planBuildTransport(content.phases);
|
|
258
|
+
// A local-only plan with no transport: the grant covers no write (it needs
|
|
259
|
+
// a bound transport), so the card says each write asks.
|
|
260
|
+
const covers = opts.grant !== false && planGrantCoversWrites(transport, objects);
|
|
258
261
|
const facts = [`Steps: ${content.phases.map((p) => phaseDisplayTitle(p, content.phases)).join(' · ')}`];
|
|
259
262
|
if (objects.length > 0) {
|
|
260
263
|
facts.push(`It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`);
|
|
261
264
|
facts.push(...plannedObjectFacts(objects).map((r) => ` ${r}`));
|
|
262
|
-
facts.push(
|
|
263
|
-
if (
|
|
265
|
+
facts.push(planTransportFact(transport, objects));
|
|
266
|
+
if (!covers)
|
|
264
267
|
facts.push('CSPeach asks before each write.');
|
|
265
268
|
else
|
|
266
269
|
facts.push(...planGrantFactLines());
|
|
@@ -273,7 +276,7 @@ export function buildStartCardFacts(content, opts = {}) {
|
|
|
273
276
|
}
|
|
274
277
|
// The closing fact follows what was granted: with no object-list approval
|
|
275
278
|
// every SAP write asks, so it never promises "only a write not on the list".
|
|
276
|
-
facts.push(
|
|
279
|
+
facts.push(covers && objects.length > 0
|
|
277
280
|
? 'It runs every step and stops only for your answers, a write not on the list, a failed check, or the deploy.'
|
|
278
281
|
: 'It runs every step and stops for your answers, each SAP write, a failed check, or the deploy.');
|
|
279
282
|
return { facts, objects, ...(transport ? { transport } : {}) };
|
|
@@ -314,8 +317,12 @@ export async function afterSpecGapSave(h, savedPath) {
|
|
|
314
317
|
h.queueNext(cmd, APP_ORIGIN);
|
|
315
318
|
h.emit(STEP_2);
|
|
316
319
|
}
|
|
317
|
-
/**
|
|
318
|
-
|
|
320
|
+
/**
|
|
321
|
+
* The result was written but could not be read as a spec-gap: the flow waits at step 1.
|
|
322
|
+
* Task 17 fix round 1: it follows the save step's plain "We couldn't save this…"
|
|
323
|
+
* line, so it says the same thing in the same voice, with no phrase to type.
|
|
324
|
+
*/
|
|
325
|
+
export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1 until the spec-gap result is saved. When we write it again in the usual layout, the run goes on. To start over, run /abap-app ${shownToken(specToken, 'spec file')}.`;
|
|
319
326
|
/** The user said No to the save: the run ends here. */
|
|
320
327
|
export const SPEC_GAP_DECLINED_LINE = (specToken) => `The /abap-app run stopped at step 1 because the spec-gap result was not saved. To start again, run /abap-app ${shownToken(specToken, 'spec file')}.`;
|
|
321
328
|
/**
|
package/dist/one-shot.js
CHANGED
|
@@ -82,6 +82,7 @@ import './tools/project/upgrade_merge_progress.js';
|
|
|
82
82
|
import './tools/project/team_findings.js';
|
|
83
83
|
import './tools/project/team_estate.js';
|
|
84
84
|
import './tools/project/team_lane_move.js';
|
|
85
|
+
import './tools/project/spec_answers_record.js';
|
|
85
86
|
import './tools/subagent/_background-shared.js';
|
|
86
87
|
import './tools/subagent/background_run.js';
|
|
87
88
|
import './tools/subagent/monitor_emit.js';
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { promises as fs } from 'node:fs';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
|
+
import { MAX_ENTRIES_VISITED } from './index-files.js';
|
|
28
29
|
/**
|
|
29
30
|
* Default conventions used when no source has anything to say. Picked
|
|
30
31
|
* to match the most common conventions in modern SAP / web projects:
|
|
@@ -268,8 +269,11 @@ async function inferStyleFromExistingFiles(cwd) {
|
|
|
268
269
|
* node_modules, .git, dist, build, .cspeach output dirs. Prefers files
|
|
269
270
|
* likely to reflect human-authored style (ts, js, abap, cds, xml).
|
|
270
271
|
*/
|
|
271
|
-
async function findSampleFiles(cwd, limit) {
|
|
272
|
+
export async function findSampleFiles(cwd, limit, maxEntries = MAX_ENTRIES_VISITED) {
|
|
272
273
|
const out = [];
|
|
274
|
+
// 1.1.53 item 12 — the same entries budget as the index walk: a folder of
|
|
275
|
+
// ~283,000 empty folders held no sample file and was walked to the end.
|
|
276
|
+
let entriesLeft = maxEntries;
|
|
273
277
|
const TEXT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.abap', '.cds', '.xml', '.yaml', '.yml'];
|
|
274
278
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.cspeach', '.abapforge']);
|
|
275
279
|
async function walk(d) {
|
|
@@ -285,6 +289,9 @@ async function findSampleFiles(cwd, limit) {
|
|
|
285
289
|
for (const ent of entries) {
|
|
286
290
|
if (out.length >= limit)
|
|
287
291
|
return;
|
|
292
|
+
if (entriesLeft <= 0)
|
|
293
|
+
return;
|
|
294
|
+
entriesLeft--;
|
|
288
295
|
if (ent.isFile()) {
|
|
289
296
|
if (TEXT_EXTENSIONS.some((ext) => ent.name.endsWith(ext))) {
|
|
290
297
|
out.push(path.join(d, ent.name));
|