@cspeach/cli 1.1.53 → 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/loop.js +50 -5
- package/dist/agent/tool-dispatch.js +11 -0
- 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-reground.js +5 -0
- 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/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/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 +32 -0
- package/dist/projects/answer-blockers.js +31 -7
- package/dist/projects/blocker-answers-note.js +1 -1
- package/dist/projects/save-command.js +37 -2
- 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/repl/builtin-commands.js +4 -3
- 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 +205 -9
- 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/tools/_team-work-path.js +1 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +62 -9
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +1 -1
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';
|
|
@@ -469,6 +472,8 @@ export async function runRepl(opts) {
|
|
|
469
472
|
loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
|
|
470
473
|
// Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
|
|
471
474
|
startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
|
|
475
|
+
// Team build Task 5 — the seat kind for this run.
|
|
476
|
+
readSeatRecord, writeSeatRecord, setSeatKind,
|
|
472
477
|
}, {
|
|
473
478
|
print: (line) => console.log(line),
|
|
474
479
|
notice: (note) => {
|
|
@@ -496,6 +501,7 @@ export async function runRepl(opts) {
|
|
|
496
501
|
else
|
|
497
502
|
printKeytarFallbackBanner();
|
|
498
503
|
}
|
|
504
|
+
let seat = startupAccount.seat ?? 'developer';
|
|
499
505
|
if (!apiKey) {
|
|
500
506
|
const entered = await promptPassword({
|
|
501
507
|
message: 'Paste your CSPeach API key (from invite email):',
|
|
@@ -504,6 +510,17 @@ export async function runRepl(opts) {
|
|
|
504
510
|
await store.set(entered);
|
|
505
511
|
apiKey = entered;
|
|
506
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
|
+
}
|
|
507
524
|
let cfg = await loadConfig();
|
|
508
525
|
setLook(cfg.ui.look);
|
|
509
526
|
// Chat lane (spec 2026-10-17 D1/D2/D5/D9) — the process lane and talking
|
|
@@ -515,6 +532,10 @@ export async function runRepl(opts) {
|
|
|
515
532
|
applyLaneTools(laneSetting, !!process.stdin.isTTY);
|
|
516
533
|
setAssistantStyle(cfg.assistant.style);
|
|
517
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');
|
|
518
539
|
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
519
540
|
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
520
541
|
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
@@ -670,7 +691,18 @@ export async function runRepl(opts) {
|
|
|
670
691
|
// The config hint only makes sense when config CAUSED advisory.
|
|
671
692
|
next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
|
|
672
693
|
});
|
|
673
|
-
|
|
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) {
|
|
674
706
|
if (cfg.write_mode === 'advisory-only') {
|
|
675
707
|
if (getLook() === 'panels')
|
|
676
708
|
startupNotices.push(advisoryNotice(''));
|
|
@@ -747,7 +779,7 @@ export async function runRepl(opts) {
|
|
|
747
779
|
// byte-identical to pre-role behavior).
|
|
748
780
|
const aliasRole = cfg.sap[selectedAlias]?.role;
|
|
749
781
|
const startupWriteMode = getEffectiveWriteMode(cfg.write_mode, aliasRole);
|
|
750
|
-
if (anyRoleConfigured) {
|
|
782
|
+
if (anyRoleConfigured && !functionalSeat) {
|
|
751
783
|
const capped = startupWriteMode !== cfg.write_mode
|
|
752
784
|
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
753
785
|
: '';
|
|
@@ -871,6 +903,24 @@ export async function runRepl(opts) {
|
|
|
871
903
|
// No start-up check ran (no key yet, e.g. pasted at the prompt below):
|
|
872
904
|
// non-managed checks on the very first turn. Managed never reads it.
|
|
873
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
|
+
},
|
|
874
924
|
...(cfg.llm.mode === 'azure' ? {
|
|
875
925
|
extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
|
|
876
926
|
// Plan D13 — never a prompt between turns (no inquirer inside the Ink
|
|
@@ -1326,6 +1376,9 @@ export async function runRepl(opts) {
|
|
|
1326
1376
|
if (result.kind === 'command') {
|
|
1327
1377
|
chunkEmitter.emit('chunk', formatInfo(result.command));
|
|
1328
1378
|
}
|
|
1379
|
+
else if (result.kind === 'info') {
|
|
1380
|
+
chunkEmitter.emit('chunk', result.text + '\n');
|
|
1381
|
+
}
|
|
1329
1382
|
else if (result.kind === 'empty') {
|
|
1330
1383
|
// Empty workspace — print the listing so the user sees the
|
|
1331
1384
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -1529,6 +1582,41 @@ export async function runRepl(opts) {
|
|
|
1529
1582
|
effectiveInput = expansion.text;
|
|
1530
1583
|
}
|
|
1531
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
|
+
}
|
|
1532
1620
|
// Guided runs — the one front door for Ink, classic and one-shot:
|
|
1533
1621
|
// the /run verb (rewritten to its alias), then today's trigger. The
|
|
1534
1622
|
// trigger still decides (spec §4 step 2); the dispatch below is unchanged.
|
|
@@ -2783,6 +2871,41 @@ export async function runRepl(opts) {
|
|
|
2783
2871
|
// sits in this folder. Best-effort: never blocks the prompt.
|
|
2784
2872
|
if (!isHeadless()) {
|
|
2785
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 */ }
|
|
2786
2909
|
// 2026-10-08 — one-time offer to retire the old `workspace_folder`
|
|
2787
2910
|
// setting, before the plan offer so the two never overlap.
|
|
2788
2911
|
try {
|
|
@@ -3112,16 +3235,50 @@ export async function runRepl(opts) {
|
|
|
3112
3235
|
}, { input: pasteInput });
|
|
3113
3236
|
// 2026-10-08: one-time offer to retire the old `workspace_folder` setting
|
|
3114
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;
|
|
3115
3241
|
if (!isHeadless()) {
|
|
3242
|
+
// Team build Task 8 — the folder step, first (same as the Ink path).
|
|
3243
|
+
try {
|
|
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(),
|
|
3253
|
+
});
|
|
3254
|
+
}
|
|
3255
|
+
catch { /* best-effort */ }
|
|
3256
|
+
// Team build Task 11 — the start screen (same as the Ink path).
|
|
3116
3257
|
try {
|
|
3117
|
-
const {
|
|
3118
|
-
const {
|
|
3119
|
-
await
|
|
3120
|
-
|
|
3121
|
-
|
|
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),
|
|
3122
3268
|
});
|
|
3269
|
+
teamBuildSubmitted = screen.submitted;
|
|
3123
3270
|
}
|
|
3124
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 */ }
|
|
3125
3282
|
}
|
|
3126
3283
|
// 2026-06-15: one-time output-namespace migration. The customer-output
|
|
3127
3284
|
// root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
|
|
@@ -3146,7 +3303,7 @@ export async function runRepl(opts) {
|
|
|
3146
3303
|
}
|
|
3147
3304
|
// 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
|
|
3148
3305
|
// choices, Enter = the first). Continue queues the resume for the loop.
|
|
3149
|
-
if (!isHeadless()) {
|
|
3306
|
+
if (!isHeadless() && !teamBuildSubmitted) {
|
|
3150
3307
|
try {
|
|
3151
3308
|
const { offerContinueAtStartup, classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3152
3309
|
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
@@ -3460,6 +3617,9 @@ export async function runRepl(opts) {
|
|
|
3460
3617
|
console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
|
|
3461
3618
|
pendingPrefill = result.command;
|
|
3462
3619
|
}
|
|
3620
|
+
else if (result.kind === 'info') {
|
|
3621
|
+
console.log(result.text);
|
|
3622
|
+
}
|
|
3463
3623
|
else if (result.kind === 'empty') {
|
|
3464
3624
|
// Empty workspace — print the listing so the user sees the
|
|
3465
3625
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -3740,6 +3900,9 @@ export async function runRepl(opts) {
|
|
|
3740
3900
|
// UX-5 #2 (2026-06-12) — numbered-menu expansion (classic mirror of the
|
|
3741
3901
|
// Ink block above). Bare `2` after a plain-text numbered menu expands
|
|
3742
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;
|
|
3743
3906
|
{
|
|
3744
3907
|
const expansion = maybeExpandNumberSelection(resolvedTrimmed, lastAssistantText(session.messages));
|
|
3745
3908
|
if (expansion) {
|
|
@@ -3747,6 +3910,39 @@ export async function runRepl(opts) {
|
|
|
3747
3910
|
resolvedTrimmed = expansion.text;
|
|
3748
3911
|
}
|
|
3749
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
|
+
}
|
|
3750
3946
|
// Guided runs — the same front door as the Ink path: the /run verb
|
|
3751
3947
|
// (rewritten to its alias), then today's trigger. The trigger still
|
|
3752
3948
|
// decides (spec §4 step 2); the dispatch below is unchanged.
|
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
|
}
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* shared team `.cspeach` (teamReadTarget's fallback there is read-only), and
|
|
18
18
|
* only a spec-gap envelope.
|
|
19
19
|
*
|
|
20
|
+
* 2026-10-28: a spec shared in a team build (`specs/<slug>/<name>.cspeach.json`)
|
|
21
|
+
* is answered with the blocker form's record mode: the revision is validated,
|
|
22
|
+
* each answer becomes its own record in `specs/<slug>/answers/`, and the
|
|
23
|
+
* author's file is never written.
|
|
24
|
+
*
|
|
20
25
|
* Every refusal is one line and writes nothing: a sub-agent or reader child,
|
|
21
26
|
* a path outside the project, a missing file, a file that is not a spec-gap
|
|
22
27
|
* envelope in `.cspeach/`, the shared team `.cspeach`, an unknown id, a blank
|
|
@@ -34,7 +39,9 @@ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isReadO
|
|
|
34
39
|
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamReadTarget } from '../_team-work-path.js';
|
|
35
40
|
import { SPEC_ANSWERS_RECORD_TOOL } from '../../projects/blocker-answers-note.js';
|
|
36
41
|
import { readProjectFile } from '../../projects/status.js';
|
|
37
|
-
import { openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
|
|
42
|
+
import { applyBlockerAnswers, openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
|
|
43
|
+
import { validateEnvelope } from '../../projects/validate.js';
|
|
44
|
+
import { composeSpec, isSharedSpecPath, readAnswers, sharedSpecTargetFor, specAuthorFor, writeAnswerRecord, SPECS_DIR, } from '../../projects/spec-share.js';
|
|
38
45
|
export const SPEC_ANSWERS_RECORD = SPEC_ANSWERS_RECORD_TOOL;
|
|
39
46
|
/** Fix round 1 (item 4): the longest answer recorded. */
|
|
40
47
|
export const MAX_ANSWER = 2000;
|
|
@@ -58,6 +65,14 @@ function realOr(p) {
|
|
|
58
65
|
return path.resolve(p);
|
|
59
66
|
}
|
|
60
67
|
}
|
|
68
|
+
/** The build project when `real` is a spec right inside `<team>/specs/<slug>/`, else null. */
|
|
69
|
+
function sharedSpecFile(cwd, real) {
|
|
70
|
+
const target = sharedSpecTargetFor(cwd, 'spec-gap');
|
|
71
|
+
if (!target || !isSharedSpecPath(target.teamDir, real) || !/\.cspeach\.json$/i.test(real))
|
|
72
|
+
return null;
|
|
73
|
+
const rel = path.relative(realOr(path.join(target.teamDir, SPECS_DIR)), real);
|
|
74
|
+
return rel.split(path.sep).length === 2 ? { projectId: target.projectId } : null;
|
|
75
|
+
}
|
|
61
76
|
export async function specAnswersRecordHandler(args, ctx) {
|
|
62
77
|
// Fix round 1 (item 5): only the main conversation records answers (a child could race the form).
|
|
63
78
|
if (ctx.subagent || ctx.readerMode)
|
|
@@ -83,9 +98,13 @@ export async function specAnswersRecordHandler(args, ctx) {
|
|
|
83
98
|
// fallback to it is for reading only, as for the write tools.
|
|
84
99
|
if (pointsIntoSharedCspeach(ctx.cwd, real))
|
|
85
100
|
return fail(`${SHARED_CSPEACH_REFUSAL} ${NOTHING_SAVED}`);
|
|
101
|
+
// 2026-10-28 — a spec shared in a team build (specs/<slug>/<name>.cspeach.json)
|
|
102
|
+
// is answered like the blocker form answers it there: one answer record per
|
|
103
|
+
// question beside it, the author's file never rewritten.
|
|
104
|
+
const shared = sharedSpecFile(ctx.cwd, real);
|
|
86
105
|
// Fix round 1 (item 3): only a saved result the CLI writes (.cspeach/<name>.cspeach.json),
|
|
87
106
|
// the same shape file_read's denylist carve-out opens (lane files excluded).
|
|
88
|
-
if (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real)) {
|
|
107
|
+
if (!shared && (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real))) {
|
|
89
108
|
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
90
109
|
}
|
|
91
110
|
if (!(await fs.stat(real)).isFile())
|
|
@@ -93,6 +112,9 @@ export async function specAnswersRecordHandler(args, ctx) {
|
|
|
93
112
|
let env;
|
|
94
113
|
try {
|
|
95
114
|
env = readProjectFile(real);
|
|
115
|
+
// R11: only the answers recorded for this envelope.
|
|
116
|
+
if (shared)
|
|
117
|
+
env = composeSpec(env, readAnswers(path.dirname(real)).filter((a) => a.file === path.basename(real)));
|
|
96
118
|
}
|
|
97
119
|
catch {
|
|
98
120
|
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
@@ -138,15 +160,46 @@ export async function specAnswersRecordHandler(args, ctx) {
|
|
|
138
160
|
}
|
|
139
161
|
if (problems.length > 0)
|
|
140
162
|
return fail(`${problems.join('; ')}. ${NOTHING_SAVED}`);
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
163
|
+
const identity = await sessionIdentity();
|
|
164
|
+
const nowIso = new Date().toISOString();
|
|
165
|
+
let revised;
|
|
166
|
+
if (shared) {
|
|
167
|
+
// The blocker form's record mode: check the revision, write records, never the file.
|
|
168
|
+
revised = applyBlockerAnswers(env, answers, identity, nowIso);
|
|
169
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
|
|
170
|
+
if (!check.ok)
|
|
171
|
+
return fail(`the revised file did not validate (${check.error.message}). ${NOTHING_SAVED}`);
|
|
172
|
+
const specDir = path.dirname(real);
|
|
173
|
+
const by = specAuthorFor(shared.projectId, identity.name);
|
|
174
|
+
const done = [];
|
|
175
|
+
try {
|
|
176
|
+
for (const [gapId, answer] of answers) {
|
|
177
|
+
writeAnswerRecord(specDir, {
|
|
178
|
+
projectId: shared.projectId, slug: path.basename(specDir), file: path.basename(real), gapId, answer, by, at: nowIso,
|
|
179
|
+
});
|
|
180
|
+
done.push(gapId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// A shared folder can fail part-way (full, read-only, sync lock): say what was recorded.
|
|
185
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
186
|
+
return fail(done.length === 0
|
|
187
|
+
? `could not write the answer records (${why}). ${NOTHING_SAVED}`
|
|
188
|
+
: `could not write every answer record (${why}). Recorded: ${done.join(', ')}. The others were not saved.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// The form's own save step: apply with the session identity, validate, write in place.
|
|
193
|
+
const saved = saveBlockerAnswers(real, env, answers, identity, nowIso);
|
|
194
|
+
if (!saved.ok)
|
|
195
|
+
return fail(`the revised file did not validate (${saved.error}). ${NOTHING_SAVED}`);
|
|
196
|
+
revised = saved.revised;
|
|
197
|
+
}
|
|
145
198
|
const rel = path.relative(realOr(t.root), real).split(path.sep).join('/');
|
|
146
|
-
const open = openBlockers(
|
|
199
|
+
const open = openBlockers(revised);
|
|
147
200
|
return {
|
|
148
201
|
content: [
|
|
149
|
-
`Saved in ${rel}.`,
|
|
202
|
+
shared ? `Recorded for ${rel} (the spec file itself is unchanged).` : `Saved in ${rel}.`,
|
|
150
203
|
...order.map((id) => `${id}: recorded.`),
|
|
151
204
|
open.length === 0
|
|
152
205
|
? 'No blockers are still open.'
|
|
@@ -157,7 +210,7 @@ export async function specAnswersRecordHandler(args, ctx) {
|
|
|
157
210
|
registerTool({
|
|
158
211
|
name: SPEC_ANSWERS_RECORD,
|
|
159
212
|
description: 'Record answers the developer gives in chat to spec-gap questions, in the saved spec-gap file '
|
|
160
|
-
+ '(.cspeach/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
|
|
213
|
+
+ '(.cspeach/<name>.cspeach.json, or in a team build specs/<slug>/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
|
|
161
214
|
+ '(for example gap-004) with its answer, at most 2,000 characters. '
|
|
162
215
|
+ 'Answering a question that already has an answer replaces it. Nothing is saved if any id or answer is refused. '
|
|
163
216
|
+ 'Never edit .cspeach.json files with shell or file-edit tools. No SAP call.',
|
|
@@ -30,7 +30,7 @@ import { registerTool } from '../index.js';
|
|
|
30
30
|
import { findingsByObject, shownUpTo } from '../../register/findings.js';
|
|
31
31
|
import { registerFor } from '../../register/picture.js';
|
|
32
32
|
import { ruleProposalOf } from '../../register/decision-rules.js';
|
|
33
|
-
import { activeProject, readProjectFile } from '../../register/store.js';
|
|
33
|
+
import { activeProject, coreProject, readProjectFile } from '../../register/store.js';
|
|
34
34
|
import { shown } from '../../register/atc-file.js';
|
|
35
35
|
import { journeyPage } from '../../journey/flow.js';
|
|
36
36
|
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
@@ -98,7 +98,7 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
98
98
|
const laneId = args.lane?.trim();
|
|
99
99
|
const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
100
100
|
// The project view, built once: the scope, its parts and every row read it.
|
|
101
|
-
const view = projectView(register, project);
|
|
101
|
+
const view = projectView(register, coreProject(project));
|
|
102
102
|
let objects = view.all.objects;
|
|
103
103
|
let scope = { kind: 'project', name: project.name };
|
|
104
104
|
if (laneId) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { WRITE_TOOLS } from './write-mode.js';
|
|
2
|
+
export const SEAT_READ_ONLY_REASON = 'This seat reads SAP and never changes it. Describe the change in plain words, or write it up for the developer as cspeach-out/developer-request-<topic>.md. Do not call SAP write, transport, lock, run, test, ATC or approval tools.';
|
|
3
|
+
/**
|
|
4
|
+
* Refused beyond the generic rule: the two smokes write SAP data over OData
|
|
5
|
+
* although requiresSap is false; unit tests and ATC runs execute on SAP;
|
|
6
|
+
* team_lane_move writes the team project's lanes; fiori_deploy uploads an app.
|
|
7
|
+
* shell_exec and background_run (ruling R8): their safelist runs npm, pnpm,
|
|
8
|
+
* ui5 and node, a deploy route around the fiori_deploy refusal.
|
|
9
|
+
*/
|
|
10
|
+
export const SEAT_EXTRA_REFUSED = new Set([
|
|
11
|
+
'fiori_draft_smoke',
|
|
12
|
+
'cap_link_smoke',
|
|
13
|
+
'sap_run_unit_test',
|
|
14
|
+
'team_lane_move',
|
|
15
|
+
'sap_atc_run',
|
|
16
|
+
'fiori_deploy',
|
|
17
|
+
'shell_exec',
|
|
18
|
+
'background_run',
|
|
19
|
+
]);
|
|
20
|
+
export function isSeatRefusedTool(tool) {
|
|
21
|
+
return WRITE_TOOLS.has(tool.name) || tool.name === 'request_approval' || SEAT_EXTRA_REFUSED.has(tool.name)
|
|
22
|
+
|| (tool.isMutating === true && tool.requiresSap === true);
|
|
23
|
+
}
|
|
24
|
+
/** The model-facing refusal for this call on this seat, or null when it may run. */
|
|
25
|
+
export function seatRefusal(kind, tool, args) {
|
|
26
|
+
if (kind !== 'functional')
|
|
27
|
+
return null;
|
|
28
|
+
if (isSeatRefusedTool(tool))
|
|
29
|
+
return SEAT_READ_ONLY_REASON;
|
|
30
|
+
// No approval minted elsewhere can ever be replayed from a functional seat.
|
|
31
|
+
if (args && typeof args === 'object'
|
|
32
|
+
&& (Object.prototype.hasOwnProperty.call(args, 'approval_id') || Object.prototype.hasOwnProperty.call(args, 'approval_ids')))
|
|
33
|
+
return SEAT_READ_ONLY_REASON;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** The tools this seat is offered. Developers: the same array, untouched. */
|
|
37
|
+
export function toolsForSeat(tools, kind) {
|
|
38
|
+
if (kind !== 'functional')
|
|
39
|
+
return tools;
|
|
40
|
+
return tools.filter((t) => !isSeatRefusedTool(t));
|
|
41
|
+
}
|