@cspeach/cli 1.1.52 → 1.1.54
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 +150 -17
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +17 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +22 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +74 -4
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +33 -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 +133 -18
- 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 +83 -20
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +239 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- 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/builtin-commands.js +4 -3
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +159 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +116 -0
- package/dist/repl/team-build-start.js +178 -0
- package/dist/repl.js +216 -11
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/_team-work-path.js +1 -1
- package/dist/tools/approval.js +12 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +243 -0
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -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/dist/ui/widgets/files-card.js +19 -1
- package/package.json +2 -2
package/dist/repl.js
CHANGED
|
@@ -39,9 +39,11 @@ import { getStore } from './auth/api-key.js';
|
|
|
39
39
|
import { readAuthFile } from './auth/auth-file.js';
|
|
40
40
|
import { resolveApiKey } from './auth/key-resolver.js';
|
|
41
41
|
import { fetchMe } from './auth/me.js';
|
|
42
|
+
import { resolveSeatKind, readSeatRecord, writeSeatRecord, seatSignInMessage, isLegacySignIn, READ_ONLY_SEAT_LINE } from './licence/seat-kind.js';
|
|
43
|
+
import { setSeatKind, isFunctionalSeat, getSeatKind } from './licence/seat-state.js';
|
|
42
44
|
import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
|
|
43
45
|
import { LicenceTurnGate } from './licence/turn-gate.js';
|
|
44
|
-
import { runStartupAccount } from './repl/startup-account.js';
|
|
46
|
+
import { runStartupAccount, settleSeatAfterStartup } from './repl/startup-account.js';
|
|
45
47
|
import { startSkillsCacheWarmup } from './skills/source-authed.js';
|
|
46
48
|
import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
|
|
47
49
|
import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
|
|
@@ -70,6 +72,7 @@ import { createProviderForMode } from './agent/providers/factory.js';
|
|
|
70
72
|
import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
|
|
71
73
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
72
74
|
import { runTurn } from './agent/loop.js';
|
|
75
|
+
import { isReadyForDeveloperPhrase } from './repl/ready-phrase.js';
|
|
73
76
|
import { IdleKeepalive } from './agent/cache-keepalive.js';
|
|
74
77
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
75
78
|
import { runRerouteTurn } from './repl/reroute-turn.js';
|
|
@@ -205,6 +208,7 @@ import './tools/project/team_findings.js';
|
|
|
205
208
|
import './tools/project/team_estate.js';
|
|
206
209
|
import './tools/project/rules_propose.js';
|
|
207
210
|
import './tools/project/team_lane_move.js';
|
|
211
|
+
import './tools/project/spec_answers_record.js';
|
|
208
212
|
import './tools/subagent/_background-shared.js';
|
|
209
213
|
import './tools/subagent/background_run.js';
|
|
210
214
|
import './tools/subagent/monitor_emit.js';
|
|
@@ -468,6 +472,8 @@ export async function runRepl(opts) {
|
|
|
468
472
|
loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
|
|
469
473
|
// Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
|
|
470
474
|
startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
|
|
475
|
+
// Team build Task 5 — the seat kind for this run.
|
|
476
|
+
readSeatRecord, writeSeatRecord, setSeatKind,
|
|
471
477
|
}, {
|
|
472
478
|
print: (line) => console.log(line),
|
|
473
479
|
notice: (note) => {
|
|
@@ -495,6 +501,7 @@ export async function runRepl(opts) {
|
|
|
495
501
|
else
|
|
496
502
|
printKeytarFallbackBanner();
|
|
497
503
|
}
|
|
504
|
+
let seat = startupAccount.seat ?? 'developer';
|
|
498
505
|
if (!apiKey) {
|
|
499
506
|
const entered = await promptPassword({
|
|
500
507
|
message: 'Paste your CSPeach API key (from invite email):',
|
|
@@ -503,6 +510,17 @@ export async function runRepl(opts) {
|
|
|
503
510
|
await store.set(entered);
|
|
504
511
|
apiKey = entered;
|
|
505
512
|
}
|
|
513
|
+
// Team build Task 5, ruling R7a — a key and no seat yet (managed without
|
|
514
|
+
// auth.json: a stored key, or one pasted just now): settle it here. Only
|
|
515
|
+
// when start-up resolved nothing, so it never repeats start-up's /v1/me.
|
|
516
|
+
if (startupAccount.seat === undefined && apiKey) {
|
|
517
|
+
const settled = await settleSeatAfterStartup({ fetchMe, readAuthFile, readSeatRecord, writeSeatRecord, setSeatKind }, { proxyUrl: (await loadConfig()).proxy_url, apiKey });
|
|
518
|
+
if (settled === null) {
|
|
519
|
+
console.log(seatSignInMessage());
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
seat = settled;
|
|
523
|
+
}
|
|
506
524
|
let cfg = await loadConfig();
|
|
507
525
|
setLook(cfg.ui.look);
|
|
508
526
|
// Chat lane (spec 2026-10-17 D1/D2/D5/D9) — the process lane and talking
|
|
@@ -514,6 +532,10 @@ export async function runRepl(opts) {
|
|
|
514
532
|
applyLaneTools(laneSetting, !!process.stdin.isTTY);
|
|
515
533
|
setAssistantStyle(cfg.assistant.style);
|
|
516
534
|
setAssistantLevel(cfg.assistant.level);
|
|
535
|
+
// Team build Task 5 — the developer default follows a functional seat; an
|
|
536
|
+
// explicit level stays, and /level still works for the session.
|
|
537
|
+
if (seat === 'functional' && cfg.assistant.level === 'developer')
|
|
538
|
+
setAssistantLevel('functional');
|
|
517
539
|
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
518
540
|
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
519
541
|
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
@@ -669,7 +691,18 @@ export async function runRepl(opts) {
|
|
|
669
691
|
// The config hint only makes sense when config CAUSED advisory.
|
|
670
692
|
next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
|
|
671
693
|
});
|
|
672
|
-
|
|
694
|
+
// Team build Task 6 — a functional seat gets the seat line once, instead of
|
|
695
|
+
// the write-mode banner and its `cspeach config set` hint (config cannot
|
|
696
|
+
// lift it); both the no-role path here and the role path below skip it.
|
|
697
|
+
const functionalSeat = isFunctionalSeat();
|
|
698
|
+
if (functionalSeat) {
|
|
699
|
+
const seatLine = formatInfo(READ_ONLY_SEAT_LINE);
|
|
700
|
+
if (getLook() === 'panels')
|
|
701
|
+
startupNotices.push(seatLine);
|
|
702
|
+
else
|
|
703
|
+
console.log(seatLine);
|
|
704
|
+
}
|
|
705
|
+
else if (!anyRoleConfigured) {
|
|
673
706
|
if (cfg.write_mode === 'advisory-only') {
|
|
674
707
|
if (getLook() === 'panels')
|
|
675
708
|
startupNotices.push(advisoryNotice(''));
|
|
@@ -746,7 +779,7 @@ export async function runRepl(opts) {
|
|
|
746
779
|
// byte-identical to pre-role behavior).
|
|
747
780
|
const aliasRole = cfg.sap[selectedAlias]?.role;
|
|
748
781
|
const startupWriteMode = getEffectiveWriteMode(cfg.write_mode, aliasRole);
|
|
749
|
-
if (anyRoleConfigured) {
|
|
782
|
+
if (anyRoleConfigured && !functionalSeat) {
|
|
750
783
|
const capped = startupWriteMode !== cfg.write_mode
|
|
751
784
|
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
752
785
|
: '';
|
|
@@ -870,6 +903,24 @@ export async function runRepl(opts) {
|
|
|
870
903
|
// No start-up check ran (no key yet, e.g. pasted at the prompt below):
|
|
871
904
|
// non-managed checks on the very first turn. Managed never reads it.
|
|
872
905
|
lastCheckAtMs: licenceCheckedAtMs ?? (cfg.llm.mode === 'managed' ? Date.now() : -Infinity),
|
|
906
|
+
// Team build Task 5 — the seat refreshes with the licence every 4 hours: a
|
|
907
|
+
// fresh answer wins (a developer→functional flip applies at the next
|
|
908
|
+
// turn; a fresh developer lifts a stale functional lock), else the stored
|
|
909
|
+
// record. An unconfirmed seat mid-session changes nothing.
|
|
910
|
+
onSeat: async (d) => {
|
|
911
|
+
const key = await store.get();
|
|
912
|
+
if (!key)
|
|
913
|
+
return;
|
|
914
|
+
const r = resolveSeatKind({
|
|
915
|
+
fresh: d.allow && d.verdict === 'ok' && d.profile ? { ok: true, profile: d.profile, serverDate: null } : null,
|
|
916
|
+
stored: await readSeatRecord(), apiKey: key, now: new Date(), legacySignIn: isLegacySignIn(await readAuthFile()),
|
|
917
|
+
});
|
|
918
|
+
if (!r.ok)
|
|
919
|
+
return;
|
|
920
|
+
if (r.write)
|
|
921
|
+
await writeSeatRecord(r.write).catch(() => { });
|
|
922
|
+
setSeatKind(r.kind);
|
|
923
|
+
},
|
|
873
924
|
...(cfg.llm.mode === 'azure' ? {
|
|
874
925
|
extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
|
|
875
926
|
// Plan D13 — never a prompt between turns (no inquirer inside the Ink
|
|
@@ -1325,6 +1376,9 @@ export async function runRepl(opts) {
|
|
|
1325
1376
|
if (result.kind === 'command') {
|
|
1326
1377
|
chunkEmitter.emit('chunk', formatInfo(result.command));
|
|
1327
1378
|
}
|
|
1379
|
+
else if (result.kind === 'info') {
|
|
1380
|
+
chunkEmitter.emit('chunk', result.text + '\n');
|
|
1381
|
+
}
|
|
1328
1382
|
else if (result.kind === 'empty') {
|
|
1329
1383
|
// Empty workspace — print the listing so the user sees the
|
|
1330
1384
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -1409,6 +1463,7 @@ export async function runRepl(opts) {
|
|
|
1409
1463
|
userMessage: rr.prompt,
|
|
1410
1464
|
skill: rr.skill,
|
|
1411
1465
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
|
|
1466
|
+
...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
|
|
1412
1467
|
chunkEmitter,
|
|
1413
1468
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
1414
1469
|
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
@@ -1527,6 +1582,41 @@ export async function runRepl(opts) {
|
|
|
1527
1582
|
effectiveInput = expansion.text;
|
|
1528
1583
|
}
|
|
1529
1584
|
}
|
|
1585
|
+
// Team build Task 10 — "this is ready for the developer" in a team build
|
|
1586
|
+
// folder marks the spec ready for build; no model turn. The phrase is
|
|
1587
|
+
// checked first (no file read); anything else goes on exactly as before.
|
|
1588
|
+
if (isReadyForDeveloperPhrase(trimmed)) {
|
|
1589
|
+
const { readyForDeveloperDoor, inkSpecChooser } = await import('./repl/ready-phrase.js');
|
|
1590
|
+
const { getAuthorIdentity } = await import('./agent/loop.js');
|
|
1591
|
+
if (await readyForDeveloperDoor(trimmed, {
|
|
1592
|
+
cwd: process.cwd(), identityName: getAuthorIdentity().name,
|
|
1593
|
+
emit: (line) => chunkEmitter.emit('chunk', line + '\n'), choose: inkSpecChooser(),
|
|
1594
|
+
}))
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
// Team build Task 11 — a shared spec's open questions travel with it
|
|
1598
|
+
// into the build. Anything not under the shared specs/ goes on unchanged.
|
|
1599
|
+
if (/^\/abap-app\s+--from\b/.test(effectiveInput)) {
|
|
1600
|
+
try {
|
|
1601
|
+
const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
|
|
1602
|
+
const { inkStartupChooser } = await import('./commands/plan-startup.js');
|
|
1603
|
+
const { getAuthorIdentity, inkAwareChoose, inkAwarePrompt } = await import('./agent/loop.js');
|
|
1604
|
+
const g = await openQuestionsGate({
|
|
1605
|
+
cwd: process.cwd(), command: effectiveInput, identity: getAuthorIdentity(),
|
|
1606
|
+
emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1607
|
+
choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
1608
|
+
});
|
|
1609
|
+
if (g && 'stop' in g) {
|
|
1610
|
+
chunkEmitter.emit('chunk', STOPPED_LINE + '\n');
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (g)
|
|
1614
|
+
effectiveInput = g.command;
|
|
1615
|
+
}
|
|
1616
|
+
catch (e) {
|
|
1617
|
+
chunkEmitter.emit('chunk', `${(e instanceof Error ? e.message : String(e)).split('\n')[0]}\n`);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1530
1620
|
// Guided runs — the one front door for Ink, classic and one-shot:
|
|
1531
1621
|
// the /run verb (rewritten to its alias), then today's trigger. The
|
|
1532
1622
|
// trigger still decides (spec §4 step 2); the dispatch below is unchanged.
|
|
@@ -1638,7 +1728,7 @@ export async function runRepl(opts) {
|
|
|
1638
1728
|
// Round 6 item 4c / review I2 — after a resume, a short follow-up
|
|
1639
1729
|
// ("continue", a number) stays in the session's skill; anything else
|
|
1640
1730
|
// is classified (applyResumedSkill below).
|
|
1641
|
-
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
|
|
1731
|
+
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
|
|
1642
1732
|
if (continuedSkill) {
|
|
1643
1733
|
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: continuedSkill }));
|
|
1644
1734
|
skill = continuedSkill;
|
|
@@ -2116,6 +2206,7 @@ export async function runRepl(opts) {
|
|
|
2116
2206
|
skill: skill,
|
|
2117
2207
|
// Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
|
|
2118
2208
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
|
|
2209
|
+
...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
|
|
2119
2210
|
chunkEmitter,
|
|
2120
2211
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
2121
2212
|
explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
|
|
@@ -2347,6 +2438,8 @@ export async function runRepl(opts) {
|
|
|
2347
2438
|
tokensUsed: 0,
|
|
2348
2439
|
model: session.model,
|
|
2349
2440
|
emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
2441
|
+
...(signedInAs ? { operator: signedInAs } : {}),
|
|
2442
|
+
sessionId: session.id,
|
|
2350
2443
|
});
|
|
2351
2444
|
}
|
|
2352
2445
|
else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
|
|
@@ -2778,6 +2871,41 @@ export async function runRepl(opts) {
|
|
|
2778
2871
|
// sits in this folder. Best-effort: never blocks the prompt.
|
|
2779
2872
|
if (!isHeadless()) {
|
|
2780
2873
|
void (async () => {
|
|
2874
|
+
// Team build Task 8 — the folder sets itself up (spec 2026-10-25 §7.2):
|
|
2875
|
+
// first, so its question never overlaps the offers below. The seat is
|
|
2876
|
+
// settled by now (runStartupAccount / settleSeatAfterStartup above).
|
|
2877
|
+
try {
|
|
2878
|
+
const { startTeamBuildFolder, fileOfferMemory } = await import('./repl/team-build-start.js');
|
|
2879
|
+
const { inkAwareConfirm, getAuthorIdentity } = await import('./agent/loop.js');
|
|
2880
|
+
await startTeamBuildFolder({
|
|
2881
|
+
cwd: process.cwd(),
|
|
2882
|
+
seat: getSeatKind(),
|
|
2883
|
+
identity: { name: getAuthorIdentity().name, ...(signedInAs ? { account: signedInAs } : {}) },
|
|
2884
|
+
confirm: inkAwareConfirm,
|
|
2885
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
2886
|
+
memory: fileOfferMemory(),
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
catch { /* the folder step is best-effort */ }
|
|
2890
|
+
// Team build Task 11 — the start screen: the shared specs of the build
|
|
2891
|
+
// folder CSPeach runs in (R10), and the build offer (developers only).
|
|
2892
|
+
// A submitted build or question is the next turn: the offers below wait.
|
|
2893
|
+
try {
|
|
2894
|
+
const { runStartScreen } = await import('./repl/team-build-screen.js');
|
|
2895
|
+
const { inkStartupChooser } = await import('./commands/plan-startup.js');
|
|
2896
|
+
const { getAuthorIdentity } = await import('./agent/loop.js');
|
|
2897
|
+
const screen = await runStartScreen({
|
|
2898
|
+
cwd: process.cwd(),
|
|
2899
|
+
seat: getSeatKind(),
|
|
2900
|
+
identityName: getAuthorIdentity().name,
|
|
2901
|
+
choose: inkStartupChooser(),
|
|
2902
|
+
submit: (cmd) => { void handleSubmit(cmd); },
|
|
2903
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
2904
|
+
});
|
|
2905
|
+
if (screen.submitted)
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
catch { /* the start screen is best-effort */ }
|
|
2781
2909
|
// 2026-10-08 — one-time offer to retire the old `workspace_folder`
|
|
2782
2910
|
// setting, before the plan offer so the two never overlap.
|
|
2783
2911
|
try {
|
|
@@ -3107,16 +3235,50 @@ export async function runRepl(opts) {
|
|
|
3107
3235
|
}, { input: pasteInput });
|
|
3108
3236
|
// 2026-10-08: one-time offer to retire the old `workspace_folder` setting
|
|
3109
3237
|
// (files now save in the folder you start in). Best-effort, never blocks.
|
|
3238
|
+
// Team build Task 11 — a build or question the start screen queued is the
|
|
3239
|
+
// next line; the retire and plan offers then stay quiet.
|
|
3240
|
+
let teamBuildSubmitted = false;
|
|
3110
3241
|
if (!isHeadless()) {
|
|
3242
|
+
// Team build Task 8 — the folder step, first (same as the Ink path).
|
|
3111
3243
|
try {
|
|
3112
|
-
const {
|
|
3113
|
-
const { inkAwareConfirm } = await import('./agent/loop.js');
|
|
3114
|
-
await
|
|
3115
|
-
|
|
3116
|
-
|
|
3244
|
+
const { startTeamBuildFolder, fileOfferMemory } = await import('./repl/team-build-start.js');
|
|
3245
|
+
const { inkAwareConfirm, getAuthorIdentity } = await import('./agent/loop.js');
|
|
3246
|
+
await startTeamBuildFolder({
|
|
3247
|
+
cwd: process.cwd(),
|
|
3248
|
+
seat: getSeatKind(),
|
|
3249
|
+
identity: { name: getAuthorIdentity().name, ...(signedInAs ? { account: signedInAs } : {}) },
|
|
3250
|
+
confirm: inkAwareConfirm,
|
|
3251
|
+
emit: (l) => console.log(l),
|
|
3252
|
+
memory: fileOfferMemory(),
|
|
3117
3253
|
});
|
|
3118
3254
|
}
|
|
3119
3255
|
catch { /* best-effort */ }
|
|
3256
|
+
// Team build Task 11 — the start screen (same as the Ink path).
|
|
3257
|
+
try {
|
|
3258
|
+
const { runStartScreen } = await import('./repl/team-build-screen.js');
|
|
3259
|
+
const { classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3260
|
+
const { getAuthorIdentity, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3261
|
+
const screen = await runStartScreen({
|
|
3262
|
+
cwd: process.cwd(),
|
|
3263
|
+
seat: getSeatKind(),
|
|
3264
|
+
identityName: getAuthorIdentity().name,
|
|
3265
|
+
choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
|
|
3266
|
+
submit: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3267
|
+
emit: (l) => console.log(l),
|
|
3268
|
+
});
|
|
3269
|
+
teamBuildSubmitted = screen.submitted;
|
|
3270
|
+
}
|
|
3271
|
+
catch { /* best-effort */ }
|
|
3272
|
+
if (!teamBuildSubmitted)
|
|
3273
|
+
try {
|
|
3274
|
+
const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
|
|
3275
|
+
const { inkAwareConfirm } = await import('./agent/loop.js');
|
|
3276
|
+
await maybeOfferWorkspaceRetire({
|
|
3277
|
+
confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
|
|
3278
|
+
log: (line) => console.log(chalk.dim(line)),
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
catch { /* best-effort */ }
|
|
3120
3282
|
}
|
|
3121
3283
|
// 2026-06-15: one-time output-namespace migration. The customer-output
|
|
3122
3284
|
// root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
|
|
@@ -3141,7 +3303,7 @@ export async function runRepl(opts) {
|
|
|
3141
3303
|
}
|
|
3142
3304
|
// 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
|
|
3143
3305
|
// choices, Enter = the first). Continue queues the resume for the loop.
|
|
3144
|
-
if (!isHeadless()) {
|
|
3306
|
+
if (!isHeadless() && !teamBuildSubmitted) {
|
|
3145
3307
|
try {
|
|
3146
3308
|
const { offerContinueAtStartup, classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3147
3309
|
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
@@ -3455,6 +3617,9 @@ export async function runRepl(opts) {
|
|
|
3455
3617
|
console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
|
|
3456
3618
|
pendingPrefill = result.command;
|
|
3457
3619
|
}
|
|
3620
|
+
else if (result.kind === 'info') {
|
|
3621
|
+
console.log(result.text);
|
|
3622
|
+
}
|
|
3458
3623
|
else if (result.kind === 'empty') {
|
|
3459
3624
|
// Empty workspace — print the listing so the user sees the
|
|
3460
3625
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -3526,6 +3691,7 @@ export async function runRepl(opts) {
|
|
|
3526
3691
|
userMessage: rr.prompt,
|
|
3527
3692
|
skill: rr.skill,
|
|
3528
3693
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
|
|
3694
|
+
...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
|
|
3529
3695
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
3530
3696
|
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
3531
3697
|
}),
|
|
@@ -3734,6 +3900,9 @@ export async function runRepl(opts) {
|
|
|
3734
3900
|
// UX-5 #2 (2026-06-12) — numbered-menu expansion (classic mirror of the
|
|
3735
3901
|
// Ink block above). Bare `2` after a plain-text numbered menu expands
|
|
3736
3902
|
// to that item's text with a dim echo; everything else passes through.
|
|
3903
|
+
// Team build Task 10 — the ready door checks what was typed, before a
|
|
3904
|
+
// menu number is expanded (the same text as the Ink door's `trimmed`).
|
|
3905
|
+
const typedText = resolvedTrimmed;
|
|
3737
3906
|
{
|
|
3738
3907
|
const expansion = maybeExpandNumberSelection(resolvedTrimmed, lastAssistantText(session.messages));
|
|
3739
3908
|
if (expansion) {
|
|
@@ -3741,6 +3910,39 @@ export async function runRepl(opts) {
|
|
|
3741
3910
|
resolvedTrimmed = expansion.text;
|
|
3742
3911
|
}
|
|
3743
3912
|
}
|
|
3913
|
+
// Team build Task 10 — the ready phrase (classic mirror of the Ink door).
|
|
3914
|
+
if (isReadyForDeveloperPhrase(typedText)) {
|
|
3915
|
+
const { readyForDeveloperDoor, classicSpecChooser } = await import('./repl/ready-phrase.js');
|
|
3916
|
+
const { getAuthorIdentity, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3917
|
+
if (await readyForDeveloperDoor(typedText, {
|
|
3918
|
+
cwd: process.cwd(), identityName: getAuthorIdentity().name,
|
|
3919
|
+
emit: (line) => console.log(line), choose: classicSpecChooser(inkAwarePrompt, (line) => console.log(line)),
|
|
3920
|
+
}))
|
|
3921
|
+
continue;
|
|
3922
|
+
}
|
|
3923
|
+
// Team build Task 11 — the open-questions gate (classic mirror of the Ink path).
|
|
3924
|
+
if (/^\/abap-app\s+--from\b/.test(resolvedTrimmed)) {
|
|
3925
|
+
try {
|
|
3926
|
+
const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
|
|
3927
|
+
const { classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3928
|
+
const { getAuthorIdentity, inkAwareChoose, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3929
|
+
const g = await openQuestionsGate({
|
|
3930
|
+
cwd: process.cwd(), command: resolvedTrimmed, identity: getAuthorIdentity(),
|
|
3931
|
+
emit: (line) => console.log(line),
|
|
3932
|
+
choose: gateChooser(classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x)))),
|
|
3933
|
+
answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
3934
|
+
});
|
|
3935
|
+
if (g && 'stop' in g) {
|
|
3936
|
+
console.log(STOPPED_LINE);
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3939
|
+
if (g)
|
|
3940
|
+
resolvedTrimmed = g.command;
|
|
3941
|
+
}
|
|
3942
|
+
catch (e) {
|
|
3943
|
+
console.log((e instanceof Error ? e.message : String(e)).split('\n')[0]);
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3744
3946
|
// Guided runs — the same front door as the Ink path: the /run verb
|
|
3745
3947
|
// (rewritten to its alias), then today's trigger. The trigger still
|
|
3746
3948
|
// decides (spec §4 step 2); the dispatch below is unchanged.
|
|
@@ -3833,7 +4035,7 @@ export async function runRepl(opts) {
|
|
|
3833
4035
|
continue;
|
|
3834
4036
|
}
|
|
3835
4037
|
// Round 6 item 4c / review I2 — see the Ink path.
|
|
3836
|
-
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
|
|
4038
|
+
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
|
|
3837
4039
|
if (continuedSkill) {
|
|
3838
4040
|
console.log(formatRoutingLine({ kind: 'continuing', skill: continuedSkill }).trimEnd());
|
|
3839
4041
|
skill = continuedSkill;
|
|
@@ -4269,6 +4471,7 @@ export async function runRepl(opts) {
|
|
|
4269
4471
|
skill: skill,
|
|
4270
4472
|
// Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
|
|
4271
4473
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
|
|
4474
|
+
...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
|
|
4272
4475
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
4273
4476
|
explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
|
|
4274
4477
|
signal: turnAbort.signal,
|
|
@@ -4462,6 +4665,8 @@ export async function runRepl(opts) {
|
|
|
4462
4665
|
tokensUsed: 0,
|
|
4463
4666
|
model: session.model,
|
|
4464
4667
|
emit: (line) => console.log(line),
|
|
4668
|
+
...(signedInAs ? { operator: signedInAs } : {}),
|
|
4669
|
+
sessionId: session.id,
|
|
4465
4670
|
});
|
|
4466
4671
|
}
|
|
4467
4672
|
else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
|
package/dist/sap-link/policy.js
CHANGED
|
@@ -112,6 +112,8 @@ export function writesBlockedMessage(alias, role) {
|
|
|
112
112
|
const now = role === undefined ? 'has no role set' : `has role ${role}`;
|
|
113
113
|
return `Writes to ${alias} need role dev. ${alias} ${now}. Set it with: cspeach config set sap.${alias}.role dev`;
|
|
114
114
|
}
|
|
115
|
+
/** Team build (final review F3): the one line a read-only seat's link says, at start and on every refused write. */
|
|
116
|
+
export const READ_ONLY_LINK_LINE = 'Read-only seat: the SAP link reads and never writes.';
|
|
115
117
|
export function decideRequest(input) {
|
|
116
118
|
const m = effectiveMethod(input.method, input.headers);
|
|
117
119
|
if ('error' in m)
|
|
@@ -178,6 +180,8 @@ export function decideRequest(input) {
|
|
|
178
180
|
};
|
|
179
181
|
}
|
|
180
182
|
const write = verbIsWrite || isBatchPath(url.pathname);
|
|
183
|
+
if (write && input.seatReadOnly)
|
|
184
|
+
return { allow: false, status: 403, message: READ_ONLY_LINK_LINE };
|
|
181
185
|
if (write && input.role !== 'dev') {
|
|
182
186
|
return { allow: false, status: 403, message: writesBlockedMessage(input.alias, input.role) };
|
|
183
187
|
}
|
|
@@ -26,6 +26,8 @@ import { readLinkTargets } from './project.js';
|
|
|
26
26
|
import { startSapLink, SapLinkStartError } from './server.js';
|
|
27
27
|
import { UpstreamSession } from './upstream.js';
|
|
28
28
|
import { registerShutdownHook } from '../lib/shutdown-hooks.js';
|
|
29
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
30
|
+
import { READ_ONLY_LINK_LINE } from './policy.js';
|
|
29
31
|
/** A registry error. Its message is always a fixed, plain, secret-free sentence. */
|
|
30
32
|
export class SapLinkError extends Error {
|
|
31
33
|
}
|
|
@@ -113,6 +115,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
113
115
|
password: (a, s) => resolveSapPassword(a, s, { headless: () => true }),
|
|
114
116
|
readCa: (p) => readFileSync(p),
|
|
115
117
|
start: startSapLink,
|
|
118
|
+
readOnlySeat: isFunctionalSeat,
|
|
116
119
|
...partial,
|
|
117
120
|
};
|
|
118
121
|
// package.json first: its errors are plain LinkConfigErrors and need no SAP settings.
|
|
@@ -134,7 +137,9 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
134
137
|
await stopUnlocked(projectDir);
|
|
135
138
|
throw new SapLinkError(`No SAP system named ${alias}. Add it with: cspeach config add-sap`);
|
|
136
139
|
}
|
|
137
|
-
|
|
140
|
+
// A read-only seat: one line of its own; the role note would point at a setting that changes nothing.
|
|
141
|
+
const seatNote = deps.readOnlySeat() ? READ_ONLY_LINK_LINE : undefined;
|
|
142
|
+
const roleNote = seatNote ? undefined : roleNoteFor(alias, sys.role);
|
|
138
143
|
const fingerprint = fingerprintFor(sys);
|
|
139
144
|
const existing = links.get(k);
|
|
140
145
|
if (existing && existing.alias === alias && existing.fingerprint === fingerprint &&
|
|
@@ -142,7 +147,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
142
147
|
// A link whose upstream got a 401 refuses every send: start means restart (new session, new password).
|
|
143
148
|
!existing.upstream.isLoginRejected()) {
|
|
144
149
|
// TLS/http notes describe the running link; the role is read fresh (the server does too).
|
|
145
|
-
return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}) };
|
|
150
|
+
return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}), ...(seatNote ? { seatNote } : {}) };
|
|
146
151
|
}
|
|
147
152
|
// Another alias, connection, auth type, TLS posture, port or service list, or a rejected login: the old link goes first.
|
|
148
153
|
if (existing)
|
|
@@ -199,6 +204,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
199
204
|
upstream,
|
|
200
205
|
secret: { password, username: sys.username },
|
|
201
206
|
writeLogFile: writeLogFileFor(projectDir),
|
|
207
|
+
readOnlySeat: () => deps.readOnlySeat(),
|
|
202
208
|
});
|
|
203
209
|
}
|
|
204
210
|
catch (e) {
|
|
@@ -213,7 +219,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
213
219
|
const postureNotes = postureNotesFor(alias, sys);
|
|
214
220
|
links.set(k, { projectDir: path.resolve(projectDir), alias, prefixes, requestedPort: targets.port, fingerprint, postureNotes, handle, upstream });
|
|
215
221
|
installSapLinkExitHandler();
|
|
216
|
-
return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}) };
|
|
222
|
+
return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}), ...(seatNote ? { seatNote } : {}) };
|
|
217
223
|
}
|
|
218
224
|
export function stopSapLink(projectDir) {
|
|
219
225
|
return serial(() => stopUnlocked(projectDir));
|
package/dist/sap-link/server.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* failed pass-through never says "OK".
|
|
40
40
|
*/
|
|
41
41
|
import * as http from 'node:http';
|
|
42
|
-
import { decideRequest, writesBlockedMessage } from './policy.js';
|
|
42
|
+
import { decideRequest, READ_ONLY_LINK_LINE, writesBlockedMessage } from './policy.js';
|
|
43
43
|
import { appendWriteRow } from './write-log.js';
|
|
44
44
|
export class SapLinkStartError extends Error {
|
|
45
45
|
code;
|
|
@@ -210,6 +210,7 @@ export async function startSapLink(opts) {
|
|
|
210
210
|
method: req.method ?? 'GET', rawUrl: req.url ?? '/',
|
|
211
211
|
headers: req.headers,
|
|
212
212
|
allowedPrefixes: opts.allowedPrefixes, alias: opts.alias, role,
|
|
213
|
+
...(opts.readOnlySeat?.() ? { seatReadOnly: true } : {}),
|
|
213
214
|
});
|
|
214
215
|
if (!d.allow)
|
|
215
216
|
return plain(res, d.status, d.message);
|
|
@@ -233,6 +234,8 @@ export async function startSapLink(opts) {
|
|
|
233
234
|
catch {
|
|
234
235
|
return plain(res, 503, SETTINGS_UNREADABLE);
|
|
235
236
|
}
|
|
237
|
+
if (opts.readOnlySeat?.())
|
|
238
|
+
return plain(res, 403, READ_ONLY_LINK_LINE);
|
|
236
239
|
if (again !== 'dev')
|
|
237
240
|
return plain(res, 403, writesBlockedMessage(opts.alias, again));
|
|
238
241
|
}
|
|
@@ -10,6 +10,10 @@ export function startFreshConversation(session) {
|
|
|
10
10
|
// Word export (final-review m-4): the once-per-object save offers and the
|
|
11
11
|
// export records describe the cleared conversation; they go with it.
|
|
12
12
|
clearConversationRecords(session);
|
|
13
|
+
// Change options (1.1.53): the last document goes too; a level reply then routes as today.
|
|
14
|
+
session.lastChangeOptions = null;
|
|
15
|
+
// Task 16: a pending blocker-form note describes the cleared conversation.
|
|
16
|
+
delete session.blockerAnswersNote;
|
|
13
17
|
}
|
|
14
18
|
/** The one line /new prints. */
|
|
15
19
|
export function freshStartText(lane) {
|
|
@@ -35,6 +35,8 @@ export const INJECTED_CONTEXT_TAGS = [
|
|
|
35
35
|
'session_context', 'project_context',
|
|
36
36
|
// The /export docx conversation list (main, 2026-10-23): one list for both readers.
|
|
37
37
|
'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
|
|
38
|
+
// Task 16 (F-M): the blocker form's note (it rides inside <session_context>).
|
|
39
|
+
'blocker_answers',
|
|
38
40
|
'skill',
|
|
39
41
|
];
|
|
40
42
|
/**
|
|
@@ -138,7 +138,7 @@ export function pointsIntoSharedCspeach(cwd, realTarget) {
|
|
|
138
138
|
* Windows opens them, so `LANES.JSON`, `plan\x.json` or `lanes.json.` count.
|
|
139
139
|
*/
|
|
140
140
|
const TEAM_OWNED_FILES = ['cspeach-team.json', 'lanes.json'];
|
|
141
|
-
const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline'];
|
|
141
|
+
const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline', 'specs'];
|
|
142
142
|
export function isTeamOwnedRel(rel) {
|
|
143
143
|
const segs = rel.split(/[\\/]+/).filter((s) => s !== '' && s !== '.');
|
|
144
144
|
if (!segs.length)
|
package/dist/tools/approval.js
CHANGED
|
@@ -579,7 +579,18 @@ registerTool({
|
|
|
579
579
|
const stopD = stoppedBeforeCard(ctx);
|
|
580
580
|
if (stopD)
|
|
581
581
|
return stopD;
|
|
582
|
-
|
|
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));
|
|
583
594
|
}
|
|
584
595
|
if (outcome.approved) {
|
|
585
596
|
if (c.op === 'modify' || c.op === 'create')
|
|
@@ -115,6 +115,8 @@ registerTool({
|
|
|
115
115
|
`SAP link ${r.reused ? 'already on' : 'on'}: ${r.handle.url} → ${ctx.sapAlias}`,
|
|
116
116
|
`Services: ${r.services.join(', ')}`,
|
|
117
117
|
];
|
|
118
|
+
if (r.seatNote)
|
|
119
|
+
lines.push(r.seatNote);
|
|
118
120
|
if (r.roleNote)
|
|
119
121
|
lines.push(r.roleNote);
|
|
120
122
|
if (r.tlsNote)
|