@cspeach/cli 1.1.53 → 1.1.55
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 +90 -7
- 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 +25 -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 +111 -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/lane/ready-spec.js +32 -0
- package/dist/licence/account-name.js +31 -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 +270 -0
- package/dist/projects/team-build-context.js +49 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/routing-line.js +11 -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 +193 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +124 -0
- package/dist/repl/team-build-start.js +179 -0
- package/dist/repl.js +227 -10
- 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 +25 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/filesystem/file-edit.js +2 -2
- package/dist/tools/filesystem/file-write.js +2 -2
- package/dist/tools/hand-off.js +16 -1
- 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/when-listening.js +34 -0
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { basename } from 'node:path';
|
|
2
|
+
import { basename, dirname } from 'node:path';
|
|
3
3
|
import { dispatchTool } from './tool-dispatch.js';
|
|
4
4
|
import { listTools, getTool, isStandalone, toolsForContext, toAnthropicTools } from '../tools/index.js';
|
|
5
|
+
import { toolsForSeat } from '../tools/seat-gate.js';
|
|
6
|
+
import { getSeatKind } from '../licence/seat-state.js';
|
|
7
|
+
import { teamBuildContextBlock } from '../projects/team-build-context.js';
|
|
5
8
|
import { saveSession } from '../session/store.js';
|
|
6
9
|
import { tagSessionOnSave } from '../session/project-match.js';
|
|
7
10
|
import { recordCompletedToolCall } from '../session/pending.js';
|
|
@@ -103,6 +106,8 @@ import { ChatUnavailableError } from './chat-unavailable.js';
|
|
|
103
106
|
*
|
|
104
107
|
* Exported for unit testing.
|
|
105
108
|
*/
|
|
109
|
+
/** F2 — the read-only seat, told to the model so it never guesses a cause. */
|
|
110
|
+
export const FUNCTIONAL_SEAT_NOTE = '<seat>This is a read-only seat (functional): CSPeach reads the SAP system and never changes it, in every mode; restarting or changing settings does not change that, so never suggest restarting or enabling writes. When the person asks for a change, say what would change and offer to write it up as a request for a developer (in a team build: a spec).</seat>';
|
|
106
111
|
export function getAuthorIdentity() {
|
|
107
112
|
const name = process.env.CSPEACH_AUTHOR_NAME ??
|
|
108
113
|
process.env.USER ??
|
|
@@ -433,6 +438,19 @@ function localToday() {
|
|
|
433
438
|
* package) loads only here, inside the catch: a broken docx install costs
|
|
434
439
|
* one line after the save and never the save itself.
|
|
435
440
|
*/
|
|
441
|
+
/** A spec gap saved into a team build's shared specs/ folder (merge of main into feat/team-build). */
|
|
442
|
+
async function isSharedSpecSave(cwd, saveSkill, savedPath) {
|
|
443
|
+
if (saveSkill !== 'abap-spec-gap')
|
|
444
|
+
return false;
|
|
445
|
+
try {
|
|
446
|
+
const { sharedSpecTargetFor, isSharedSpecPath } = await import('../projects/spec-share.js');
|
|
447
|
+
const shared = sharedSpecTargetFor(cwd, 'spec-gap');
|
|
448
|
+
return !!shared && isSharedSpecPath(shared.teamDir, savedPath);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
436
454
|
async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
|
|
437
455
|
try {
|
|
438
456
|
const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
|
|
@@ -590,7 +608,7 @@ export async function maybeOfferSave(p) {
|
|
|
590
608
|
tokensUsed: p.tokensUsed,
|
|
591
609
|
model: p.model,
|
|
592
610
|
author: getAuthorIdentity(),
|
|
593
|
-
cwd: process.cwd(),
|
|
611
|
+
cwd: p.cwd ?? process.cwd(),
|
|
594
612
|
prompt,
|
|
595
613
|
// Piece 2 / Q3 — interactive saves ask a Yes/No card; headless passes
|
|
596
614
|
// no confirm so the blanket-'y' prompt above still answers (B5 contract).
|
|
@@ -605,6 +623,7 @@ export async function maybeOfferSave(p) {
|
|
|
605
623
|
...(p.latestSkeleton && p.skill === saveSkill ? { latestSkeleton: p.latestSkeleton } : {}),
|
|
606
624
|
onNotSaved: (reason) => { notSaved = reason; },
|
|
607
625
|
...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
|
|
626
|
+
...(p.sapSystem ? { sapSystem: p.sapSystem } : {}),
|
|
608
627
|
// Never headless: a headless journey queues nothing.
|
|
609
628
|
...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
|
|
610
629
|
});
|
|
@@ -612,7 +631,9 @@ export async function maybeOfferSave(p) {
|
|
|
612
631
|
// is a document (spec gaps, design, estimate, plan, upgrade scan/report)
|
|
613
632
|
// gets the same Word step as a review: `word_copy` decides, headless
|
|
614
633
|
// never asks. The project file itself is untouched. Sub-agent turns: no.
|
|
615
|
-
|
|
634
|
+
// A team build shared spec already has its Word copy beside it (save-command.ts).
|
|
635
|
+
if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]
|
|
636
|
+
&& !(await isSharedSpecSave(p.cwd ?? process.cwd(), saveSkill, savedPath))) {
|
|
616
637
|
await offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm);
|
|
617
638
|
}
|
|
618
639
|
// After a spec-gap file is saved, let a developer answer the open BLOCKER
|
|
@@ -624,6 +645,25 @@ export async function maybeOfferSave(p) {
|
|
|
624
645
|
// its next call (session.blockerAnswersNote, sent by runTurn). Main turns
|
|
625
646
|
// with a session only; declined / not offered / not saved → no note.
|
|
626
647
|
if (savedPath && saveSkill === 'abap-spec-gap') {
|
|
648
|
+
// Team build (Task 9): a spec in the shared specs/ folder is never
|
|
649
|
+
// rewritten — each answer is a record of its own, and the spec is read
|
|
650
|
+
// with the records applied. Found by the folder (R10), never activeProject().
|
|
651
|
+
const { sharedSpecTargetFor, isSharedSpecPath, composeSpec, readAnswers, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
|
|
652
|
+
const { readProjectFile } = await import('../projects/status.js');
|
|
653
|
+
const shared = sharedSpecTargetFor(p.cwd ?? process.cwd(), 'spec-gap');
|
|
654
|
+
const records = shared && isSharedSpecPath(shared.teamDir, savedPath)
|
|
655
|
+
? {
|
|
656
|
+
// R11: only the answers recorded for this envelope.
|
|
657
|
+
readEnvelope: (path) => composeSpec(readProjectFile(path), readAnswers(dirname(path)).filter((a) => a.file === basename(path))),
|
|
658
|
+
recordAnswer: (a) => {
|
|
659
|
+
const specDir = dirname(savedPath);
|
|
660
|
+
writeAnswerRecord(specDir, {
|
|
661
|
+
projectId: shared.projectId, slug: basename(specDir), file: basename(savedPath),
|
|
662
|
+
gapId: a.gapId, answer: a.answer, by: specAuthorFor(shared.projectId, a.identity.name), at: a.at,
|
|
663
|
+
});
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
: {};
|
|
627
667
|
const noteSession = p.session && !p.subagent ? p.session : undefined;
|
|
628
668
|
let outcomes;
|
|
629
669
|
await offerAnswerBlockers({
|
|
@@ -633,11 +673,15 @@ export async function maybeOfferSave(p) {
|
|
|
633
673
|
confirm: inkAwareConfirm,
|
|
634
674
|
choose: inkAwareChoose,
|
|
635
675
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
676
|
+
...records,
|
|
636
677
|
...(noteSession ? { onOutcomes: (o) => { outcomes = o; } } : {}),
|
|
637
678
|
});
|
|
638
679
|
if (noteSession && outcomes) {
|
|
639
680
|
// The path is shown against the read tool's base (ctx.cwd), so the model opens it with file_read.
|
|
640
|
-
const note = blockerAnswersNote({
|
|
681
|
+
const note = blockerAnswersNote({
|
|
682
|
+
path: savedPath, cwd: p.readCwd ?? process.cwd(), outcomes,
|
|
683
|
+
...('readEnvelope' in records ? { readEnvelope: records.readEnvelope } : {}),
|
|
684
|
+
});
|
|
641
685
|
if (note)
|
|
642
686
|
noteSession.blockerAnswersNote = note;
|
|
643
687
|
}
|
|
@@ -897,7 +941,34 @@ export async function runTurn(params) {
|
|
|
897
941
|
}
|
|
898
942
|
else {
|
|
899
943
|
clearAppFlow(session);
|
|
944
|
+
// F9c (2026-10-29): routed here from plain words ("let's start building
|
|
945
|
+
// it") in a team build folder with a ready spec — build that spec. The
|
|
946
|
+
// command is queued, so it passes the open-questions gate like a typed
|
|
947
|
+
// one; no spec-gap rerun on the sentence.
|
|
948
|
+
if (params.explicitSlash !== true && extractSpecToken(params.userMessage) === undefined && params.ctx.pendingDispatch) {
|
|
949
|
+
const { readySpecFor, notReadyLine } = await import('../lane/ready-spec.js');
|
|
950
|
+
const picked = await readySpecFor(params.ctx.cwd ?? process.cwd(), params.userMessage);
|
|
951
|
+
if (picked && 'notReady' in picked) {
|
|
952
|
+
emit(notReadyLine(picked.notReady));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (picked && 'ask' in picked) {
|
|
956
|
+
emit(`More than one spec here is ready to build: ${picked.ask.join(', ')}. Say which one.`);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (picked) {
|
|
960
|
+
emit(`Building the ready spec "${picked.title}".`);
|
|
961
|
+
params.ctx.pendingDispatch.set(picked.command, { by: 'app' });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
900
965
|
emit(chalk.yellow(spec.line));
|
|
966
|
+
// F9b (2026-10-29 live run): a slash command — typed, or queued by a
|
|
967
|
+
// hand-off — that names no usable spec ends here. Running on as
|
|
968
|
+
// spec-gap let the model improvise a build off the guided route. A
|
|
969
|
+
// request routed from plain words still gets the spec-gap check.
|
|
970
|
+
if (params.explicitSlash === true)
|
|
971
|
+
return;
|
|
901
972
|
}
|
|
902
973
|
params = { ...params, skill: 'abap-spec-gap' };
|
|
903
974
|
}
|
|
@@ -1128,7 +1199,15 @@ ${imageExpansion.error}`));
|
|
|
1128
1199
|
const blockerNote = mainTurn && session.blockerAnswersNote
|
|
1129
1200
|
? `<blocker_answers>\n${session.blockerAnswersNote}\n</blocker_answers>`
|
|
1130
1201
|
: '';
|
|
1131
|
-
|
|
1202
|
+
// F5 (2026-10-29 live run) — in a team build folder the model is told the
|
|
1203
|
+
// shared specs and the route for each request (hand off), every main turn.
|
|
1204
|
+
// Anywhere else: '' (unchanged).
|
|
1205
|
+
const teamBuildNote = mainTurn && isChatSkill(params.skill) ? teamBuildContextBlock(params.ctx.cwd ?? process.cwd(), getSeatKind()) : '';
|
|
1206
|
+
// F2 (2026-10-29 live run): a functional seat sees no write tools; told
|
|
1207
|
+
// nothing, the model guessed "restart CSPeach with writes enabled". Every
|
|
1208
|
+
// main turn on that seat says why, and the route for a change.
|
|
1209
|
+
const seatNote = mainTurn && getSeatKind() === 'functional' ? FUNCTIONAL_SEAT_NOTE : '';
|
|
1210
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
|
|
1132
1211
|
// Deterministically extract structured facts from the user's prompt
|
|
1133
1212
|
// (package, transport, object names, environment hint, create intent)
|
|
1134
1213
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -1144,7 +1223,7 @@ ${imageExpansion.error}`));
|
|
|
1144
1223
|
// Final batch 1 — a caller-enriched message still gets the switch note,
|
|
1145
1224
|
// inside its own <session_context> block.
|
|
1146
1225
|
// Stage 1A — the <playbook> line rides the same way ('' ⇒ unchanged).
|
|
1147
|
-
const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine]);
|
|
1226
|
+
const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
|
|
1148
1227
|
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
1149
1228
|
? (callerNotes ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${callerNotes}`) : userMessageForLLM)
|
|
1150
1229
|
: enrichUserMessage(userMessageForLLM, sessionContextBlock);
|
|
@@ -1260,7 +1339,9 @@ ${imageExpansion.error}`));
|
|
|
1260
1339
|
const hiddenSapTools = connectedTurn
|
|
1261
1340
|
? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
|
|
1262
1341
|
: [];
|
|
1263
|
-
|
|
1342
|
+
// Team build Task 6 — a functional seat is not offered the tools it may not
|
|
1343
|
+
// call (dispatch refuses them anyway). Developers: the same array.
|
|
1344
|
+
const contextTools = toolsForSeat(withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools), getSeatKind());
|
|
1264
1345
|
// Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
|
|
1265
1346
|
// when a reader would have no real read tool (standalone, file tools off).
|
|
1266
1347
|
const readerVisible = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
|
|
@@ -2366,6 +2447,8 @@ ${imageExpansion.error}`));
|
|
|
2366
2447
|
model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
|
|
2367
2448
|
emit,
|
|
2368
2449
|
sapSystem: session.sap_system,
|
|
2450
|
+
// Final review F6: the save and the team build folder follow this turn's folder.
|
|
2451
|
+
cwd: params.ctx.cwd,
|
|
2369
2452
|
...(params.operator ? { operator: params.operator } : {}),
|
|
2370
2453
|
sessionId: session.id,
|
|
2371
2454
|
...(params.ctx.subagent ? { subagent: true } : {}),
|
|
@@ -12,6 +12,8 @@ import { riskOf } from './risk-gate.js';
|
|
|
12
12
|
import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
|
|
13
13
|
import { INTERRUPTED_TOOL_RESULT, STOPPED_BEFORE_START_RESULT } from './repair-partial.js';
|
|
14
14
|
import { WRITE_TOOLS } from '../tools/write-mode.js';
|
|
15
|
+
import { seatRefusal } from '../tools/seat-gate.js';
|
|
16
|
+
import { getSeatKind } from '../licence/seat-state.js';
|
|
15
17
|
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
16
18
|
/** Chat lane (spec 2026-10-17 D4) — the model-facing reason; names hand_off. */
|
|
17
19
|
export const CHAT_READ_ONLY_REASON = 'This chat reads but never changes SAP. To make this change, call hand_off with the right workflow (for example abap-refactor, abap-generate, abap-transport) and a clear request — the workflow shows its plan and asks before it writes.';
|
|
@@ -70,6 +72,15 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
70
72
|
if (ctx.readerMode && !READER_TOOL_SET.has(name)) {
|
|
71
73
|
return { content: `error: read-only reader — ${name} is not allowed here`, is_error: true };
|
|
72
74
|
}
|
|
75
|
+
// Team build Task 6 — a functional seat reads SAP and never changes it, in
|
|
76
|
+
// every lane (skill, chat, and an agent_run child, which shares the process
|
|
77
|
+
// seat) whatever write_mode, role or /mode says. The refused tools are not
|
|
78
|
+
// offered (agent/loop.ts toolsForSeat); THIS is the guarantee for a call the
|
|
79
|
+
// model makes anyway. Before the chat gate and every card below. Developers:
|
|
80
|
+
// seatRefusal is null, so today's dispatch is untouched.
|
|
81
|
+
const seat = seatRefusal(getSeatKind(), tool, args);
|
|
82
|
+
if (seat)
|
|
83
|
+
return { content: JSON.stringify({ error: 'seat_read_only', tool: name, message: seat }), is_error: true };
|
|
73
84
|
// Chat lane (spec 2026-10-17 D4, §6.3) — chat reads freely and never changes
|
|
74
85
|
// SAP. The prompt says so; THIS is the guarantee: every SAP-mutating tool and
|
|
75
86
|
// request_approval are refused while the session's last turn ran in chat,
|
package/dist/auth/auth-file.js
CHANGED
|
@@ -40,6 +40,8 @@ export async function readAuthFile() {
|
|
|
40
40
|
out.apiKey = parsed.apiKey;
|
|
41
41
|
if (typeof parsed.savedAt === 'string')
|
|
42
42
|
out.savedAt = parsed.savedAt;
|
|
43
|
+
if (typeof parsed.signedInWith === 'string')
|
|
44
|
+
out.signedInWith = parsed.signedInWith;
|
|
43
45
|
return out;
|
|
44
46
|
}
|
|
45
47
|
catch {
|
|
@@ -54,6 +56,7 @@ export async function writeAuthFile(auth) {
|
|
|
54
56
|
...(auth.apiKey !== undefined ? { apiKey: auth.apiKey } : {}),
|
|
55
57
|
customerId: auth.customerId,
|
|
56
58
|
...(auth.savedAt !== undefined ? { savedAt: auth.savedAt } : {}),
|
|
59
|
+
...(auth.signedInWith !== undefined ? { signedInWith: auth.signedInWith } : {}),
|
|
57
60
|
};
|
|
58
61
|
await writeAtomic0600(p, JSON.stringify(body, null, 2));
|
|
59
62
|
}
|
|
@@ -27,6 +27,7 @@ import { promises as fs } from 'node:fs';
|
|
|
27
27
|
import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
|
|
28
28
|
import { readAuthFile, writeAuthFile, deleteAuthFile, authFileMtimeMs, authFileDir } from './auth-file.js';
|
|
29
29
|
import { licenceCachePath } from '../licence/paths.js';
|
|
30
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
30
31
|
export const KEYCHAIN_SERVICE = 'cspeach';
|
|
31
32
|
export const DEFAULT_ACCOUNT = 'api-key';
|
|
32
33
|
async function defaultIo() {
|
|
@@ -181,7 +182,8 @@ export async function migrateSignIn(deps = {}) {
|
|
|
181
182
|
return auth?.apiKey ? 'store-unavailable' : 'none';
|
|
182
183
|
}
|
|
183
184
|
try {
|
|
184
|
-
|
|
185
|
+
// Team build: signedInWith stays, so a new sign-in never turns "legacy".
|
|
186
|
+
await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}), ...(auth.signedInWith !== undefined ? { signedInWith: auth.signedInWith } : {}) });
|
|
185
187
|
}
|
|
186
188
|
catch {
|
|
187
189
|
return 'failed'; // the store holds the key; the next start removes the copy ('removed')
|
|
@@ -217,12 +219,15 @@ export async function saveSignIn(a, deps = {}) {
|
|
|
217
219
|
catch {
|
|
218
220
|
stored = false;
|
|
219
221
|
}
|
|
222
|
+
// Team build: signedInWith marks a sign-in made by this CLI or newer; an
|
|
223
|
+
// auth.json without it is a pre-release sign-in (a developer seat).
|
|
224
|
+
const signedInWith = CLI_VERSION;
|
|
220
225
|
if (!stored) {
|
|
221
|
-
await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt });
|
|
226
|
+
await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt, signedInWith });
|
|
222
227
|
return 'file';
|
|
223
228
|
}
|
|
224
229
|
await recordStoreWrite(account, savedAt).catch(() => { }); // bookkeeping only, no secret
|
|
225
|
-
await writeAuthFile({ customerId: a.customerId, savedAt });
|
|
230
|
+
await writeAuthFile({ customerId: a.customerId, savedAt, signedInWith });
|
|
226
231
|
return 'store';
|
|
227
232
|
}
|
|
228
233
|
/**
|
package/dist/auth/me.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Fetch /v1/me — customer profile + entitlements + usage.
|
|
3
3
|
* Result envelope so the REPL doesn't need try/catch.
|
|
4
4
|
*/
|
|
5
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
5
6
|
/** Default /v1/me budget. The panels brand line waits for this call (D5), so a
|
|
6
7
|
* hung proxy must not mean a blank screen. */
|
|
7
8
|
export const FETCH_ME_TIMEOUT_MS = 5000;
|
|
@@ -18,8 +19,11 @@ export async function fetchMe(opts) {
|
|
|
18
19
|
try {
|
|
19
20
|
return await Promise.race([
|
|
20
21
|
(async () => {
|
|
21
|
-
// No extra headers → exactly `{ Authorization }
|
|
22
|
-
|
|
22
|
+
// No extra headers → exactly `{ User-Agent, Authorization }`. Team build
|
|
23
|
+
// (R5): every /v1/me call names this CLI, so the proxy can tell a
|
|
24
|
+
// current CLI from one too old for a functional seat. Set after the
|
|
25
|
+
// caller's headers, so no caller can drop it.
|
|
26
|
+
const r = await fetch(url, { headers: { ...(opts.headers ?? {}), 'User-Agent': `cspeach/${CLI_VERSION}`, Authorization: `Bearer ${opts.apiKey}` }, signal });
|
|
23
27
|
const serverDate = r.headers.get('date');
|
|
24
28
|
if (!r.ok) {
|
|
25
29
|
const body = await r.json().catch(() => ({}));
|
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,13 @@ export async function main(argv) {
|
|
|
52
52
|
printHelp();
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
|
+
// Team build (Task 12): `cspeach help team-build` prints the how-to. Above the
|
|
56
|
+
// one-shot fallback, so the words are never sent to the model as a prompt.
|
|
57
|
+
if (args[0] === 'help' && args[1] === 'team-build' && args.length === 2) {
|
|
58
|
+
const { printTeamBuildHelp } = await import('./commands/team-build-help.js');
|
|
59
|
+
printTeamBuildHelp();
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
55
62
|
if (args[0] === '--version') {
|
|
56
63
|
console.log(CLI_VERSION);
|
|
57
64
|
return 0;
|
|
@@ -218,9 +225,12 @@ export async function main(argv) {
|
|
|
218
225
|
if (args[0] === 'team') {
|
|
219
226
|
const { runTeamCommand } = await import('./commands/team.js');
|
|
220
227
|
const { getAuthorIdentity } = await import('./agent/loop.js');
|
|
228
|
+
const { signedInAccount } = await import('./licence/account-name.js');
|
|
221
229
|
return runTeamCommand(args.slice(1), {
|
|
222
230
|
print: (s) => console.log(s),
|
|
223
231
|
identity: () => getAuthorIdentity(),
|
|
232
|
+
// S2 — `cspeach team join` of a team BUILD names the person by account.
|
|
233
|
+
account: () => signedInAccount(),
|
|
224
234
|
noSap,
|
|
225
235
|
// Standalone: no connection is offered at all, so nothing in `team` can reach for one.
|
|
226
236
|
connect: noSap ? undefined : async (alias) => {
|
|
@@ -248,6 +258,14 @@ export async function main(argv) {
|
|
|
248
258
|
const { input } = await import('@inquirer/prompts');
|
|
249
259
|
return input({ message: question });
|
|
250
260
|
},
|
|
261
|
+
// Team build (ruling R2, final review F2): the terminal verbs run no
|
|
262
|
+
// start-up, so the seat is resolved here — the verified record, else one
|
|
263
|
+
// /v1/me when there is none, else the offline rule. Asked only before a
|
|
264
|
+
// verb that records work.
|
|
265
|
+
seat: async () => {
|
|
266
|
+
const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
|
|
267
|
+
return resolveTerminalSeatNow();
|
|
268
|
+
},
|
|
251
269
|
});
|
|
252
270
|
}
|
|
253
271
|
// CAP phase 2 — run the SAP link in the foreground for a local CAP app.
|
|
@@ -256,6 +274,11 @@ export async function main(argv) {
|
|
|
256
274
|
return runSapLinkCommand(args.slice(2), {
|
|
257
275
|
print: (s) => console.log(s),
|
|
258
276
|
defaultAlias: sapAlias,
|
|
277
|
+
// Final review F3: resolved as the team verbs do; a functional seat's link reads and never writes.
|
|
278
|
+
seat: async () => {
|
|
279
|
+
const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
|
|
280
|
+
return resolveTerminalSeatNow();
|
|
281
|
+
},
|
|
259
282
|
standalone: async () => {
|
|
260
283
|
const { loadConfig, resolveStandalone } = await import('./config/loader.js');
|
|
261
284
|
return resolveStandalone(await loadConfig(), noSap);
|
|
@@ -334,10 +357,12 @@ Usage:
|
|
|
334
357
|
cspeach login azure Sign in to Microsoft for your company's Azure (--device-code: use a code instead of the browser)
|
|
335
358
|
cspeach logout Sign out (removes the CSPeach key from this home)
|
|
336
359
|
cspeach whoami Print current account info
|
|
360
|
+
cspeach help team-build How two people share one folder: the functional writes the spec, the developer builds it
|
|
337
361
|
cspeach team discover Read the whole estate from SAP into this folder (read only)
|
|
338
362
|
cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
|
|
339
363
|
cspeach team start --for "FI and SD" --developers "James Miller, me" Lead: set up by name from this folder's discovery; one lane each
|
|
340
364
|
cspeach team init <folder> Lead: pull the object list from SAP, start a team project
|
|
365
|
+
cspeach team spec list|ready [<slug>] Team build: the specs in this folder; mark one ready for the developer
|
|
341
366
|
cspeach team join <folder> [--as "<name>"] Record your results against a team project (on this laptop only), under the lane owner's name
|
|
342
367
|
cspeach team lane add|list Lead: who owns which packages
|
|
343
368
|
cspeach team lane move --packages <names> --to <developer> Lead: move packages by name (lanes of names only)
|
package/dist/commands/login.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* 4) Poll /v1/auth/device/poll every interval until verified or maxAttempts
|
|
9
9
|
* 5) Save api_key to the OS credential store (this CSPeach home's account);
|
|
10
10
|
* auth.json keeps customer_id + savedAt (the key only without a store)
|
|
11
|
-
* 6) Call GET /v1/me —
|
|
12
|
-
*
|
|
11
|
+
* 6) Call GET /v1/me — record the seat kind (seat.json, Team build); if
|
|
12
|
+
* trial_mode === 'byok', pull the org's Anthropic key, or prompt for the
|
|
13
|
+
* user's own
|
|
13
14
|
* 7) Store that key in the OS keychain (cspeach.anthropic/api-key) — never
|
|
14
15
|
* in auth.json (2026-10-02)
|
|
15
16
|
* 8) GET /v1/me/connection — Azure tenant mode: the company's Azure
|
|
@@ -30,6 +31,8 @@ import { getGlyphs } from '../renderer/glyphs.js';
|
|
|
30
31
|
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
31
32
|
import { runConnectionStep } from '../connection/login-step.js';
|
|
32
33
|
import { clientIdConfigured } from '../connection/azure-facts.js';
|
|
34
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
35
|
+
import { resolveSeatKind, makeSeatRecord, writeSeatRecord } from '../licence/seat-kind.js';
|
|
33
36
|
const ANTHROPIC_KEYCHAIN_SERVICE = 'cspeach.anthropic';
|
|
34
37
|
const ANTHROPIC_KEYCHAIN_ACCOUNT = 'api-key';
|
|
35
38
|
/** Server-supplied text (user code, URL, email, plan) is one line with no control characters. */
|
|
@@ -106,11 +109,24 @@ export async function runLoginCommand(deps) {
|
|
|
106
109
|
deps.print(done(savedTo === 'store'
|
|
107
110
|
? 'Saved your CSPeach key to your OS credential store'
|
|
108
111
|
: 'Saved your CSPeach key to auth.json in your CSPeach home (no OS credential store is available)'));
|
|
109
|
-
// 6) /v1/me —
|
|
112
|
+
// 6) /v1/me — the seat kind, and whether BYOK is required. Team build (R5):
|
|
113
|
+
// the user-agent names this CLI, so the proxy never takes a current CLI for
|
|
114
|
+
// one too old for a functional seat.
|
|
115
|
+
// Team build: the seat kind is recorded once, from this answer only. A
|
|
116
|
+
// failed call, or a word we do not know, records nothing (never a guess).
|
|
117
|
+
let seatRecorded = false;
|
|
110
118
|
try {
|
|
111
|
-
const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
|
|
119
|
+
const r = await fetch(`${proxy}/v1/me`, { headers: { 'User-Agent': `cspeach/${CLI_VERSION}`, Authorization: `Bearer ${polled.api_key}` } });
|
|
112
120
|
if (r.ok) {
|
|
113
121
|
const me = await r.json();
|
|
122
|
+
const seat = resolveSeatKind({ fresh: { ok: true, profile: me, serverDate: null }, stored: null, apiKey: polled.api_key, now: new Date(), legacySignIn: false });
|
|
123
|
+
if (seat.ok) {
|
|
124
|
+
try {
|
|
125
|
+
await writeSeatRecord(makeSeatRecord(polled.api_key, seat.kind, new Date()));
|
|
126
|
+
seatRecorded = true;
|
|
127
|
+
}
|
|
128
|
+
catch { /* reported by the one line below */ }
|
|
129
|
+
}
|
|
114
130
|
deps.print(done(`Signed in as ${serverText(me.email)} · ${serverText(me.plan)}`));
|
|
115
131
|
if (me.trial_mode === 'byok' || me.plan === 'byo-key') {
|
|
116
132
|
// Try to fetch the org's Anthropic key first — non-fatal if unavailable.
|
|
@@ -146,6 +162,8 @@ export async function runLoginCommand(deps) {
|
|
|
146
162
|
catch (err) {
|
|
147
163
|
deps.print(formatInfo(`Could not read your account details (${err instanceof Error ? err.message : String(err)}) · sign-in still worked`).trimEnd());
|
|
148
164
|
}
|
|
165
|
+
if (!seatRecorded)
|
|
166
|
+
deps.print(formatInfo('Could not record your seat kind · run cspeach login again when you are online').trimEnd());
|
|
149
167
|
// 8) Azure tenant mode — the company's connection settings (no secrets).
|
|
150
168
|
// { kind: 'none' } changes nothing; an Azure answer is confirmed on screen.
|
|
151
169
|
const step = await (deps.connectionStep ?? runConnectionStep)({
|
|
@@ -5,6 +5,7 @@ import { SAP_PAUSE_LINE } from './plan-sap-pause.js';
|
|
|
5
5
|
import { unreachableCode } from '../sap/unreachable.js';
|
|
6
6
|
import { unreachableCodeInText } from '../sap/reachability-state.js';
|
|
7
7
|
import { recordFullNotice } from '../renderer/notice-log.js';
|
|
8
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
8
9
|
const LOCK_WAIT_MS = 10_000;
|
|
9
10
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
11
|
/** Network code when SAP did not answer at all (an HTTP error means it answered). */
|
|
@@ -20,6 +21,10 @@ function errText(err) {
|
|
|
20
21
|
* network failure) — never blocks the step. Unreachable SAP is rethrown.
|
|
21
22
|
*/
|
|
22
23
|
async function probeLock(deps, type, name) {
|
|
24
|
+
// Team build Task 6 (ruling R8 I1) — a functional seat never takes a SAP
|
|
25
|
+
// lock, not even a probe: 'unknown' never blocks, and SAP is not touched.
|
|
26
|
+
if (isFunctionalSeat())
|
|
27
|
+
return 'unknown';
|
|
23
28
|
let l;
|
|
24
29
|
try {
|
|
25
30
|
l = await deps.lock(type, name);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// only plain registry messages, never raw exception text.
|
|
7
7
|
import { ensureSapLink, stopSapLink, SapLinkError } from '../sap-link/registry.js';
|
|
8
8
|
import { LinkConfigError } from '../sap-link/project.js';
|
|
9
|
+
import { seatSignInMessage } from '../licence/seat-kind.js';
|
|
9
10
|
function waitForSignal() {
|
|
10
11
|
return new Promise((resolve) => {
|
|
11
12
|
const done = () => {
|
|
@@ -29,10 +30,22 @@ export async function runSapLinkCommand(argv, deps) {
|
|
|
29
30
|
deps.print('Name the SAP system: cspeach sap link <alias>');
|
|
30
31
|
return 1;
|
|
31
32
|
}
|
|
33
|
+
// Asked once, before the link starts. Not confirmed asks to sign in and stops: a developer is
|
|
34
|
+
// never wrongly locked read-only. Only a functional seat starts the link read-only.
|
|
35
|
+
const seat = deps.seat ? await deps.seat().catch(() => 'unconfirmed') : undefined;
|
|
36
|
+
if (seat === 'unconfirmed') {
|
|
37
|
+
deps.print(seatSignInMessage());
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
const linkDeps = seat === undefined
|
|
41
|
+
? deps.linkDeps
|
|
42
|
+
: { ...deps.linkDeps, readOnlySeat: () => seat === 'functional' };
|
|
32
43
|
try {
|
|
33
|
-
const r = await ensureSapLink(projectDir, alias,
|
|
44
|
+
const r = await ensureSapLink(projectDir, alias, linkDeps);
|
|
34
45
|
deps.print(`SAP link on: ${r.handle.url} → ${alias}`);
|
|
35
46
|
deps.print(`Services: ${r.services.join(', ')}`);
|
|
47
|
+
if (r.seatNote)
|
|
48
|
+
deps.print(r.seatNote);
|
|
36
49
|
if (r.roleNote)
|
|
37
50
|
deps.print(r.roleNote);
|
|
38
51
|
if (r.tlsNote)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build (spec 2026-10-25, plan Task 12) — `cspeach help team-build`.
|
|
3
|
+
* The how-to for two people sharing one folder: the functional writes the
|
|
4
|
+
* spec, the developer builds it. Copy is fixed by the plan; change it only
|
|
5
|
+
* together with docs/guide/team-build.md.
|
|
6
|
+
*/
|
|
7
|
+
export const TEAM_BUILD_HOW_TO = Object.freeze([
|
|
8
|
+
'Team build — two people, one folder.',
|
|
9
|
+
'1. Make a folder inside your shared OneDrive, SharePoint or network folder.',
|
|
10
|
+
'2. Right-click it and choose "Open in Terminal".',
|
|
11
|
+
'3. Type cspeach. That is the only command. CSPeach sets the folder up the first time.',
|
|
12
|
+
'4. Talk through the requirement. Say when it is ready for the developer.',
|
|
13
|
+
'5. Tell the developer the folder name. They do steps 2 and 3 on their laptop.',
|
|
14
|
+
'Your SAP user decides what you may see in SAP. A functional seat reads SAP and never changes it.',
|
|
15
|
+
]);
|
|
16
|
+
export function printTeamBuildHelp(emit = console.log) {
|
|
17
|
+
for (const line of TEAM_BUILD_HOW_TO)
|
|
18
|
+
emit(line);
|
|
19
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build (spec 2026-10-25 §7) — the `build` project type: a spec made with
|
|
3
|
+
* the live system, then built from it. No baseline is pulled and nothing is
|
|
4
|
+
* measured, so the folder is made without a SAP connection.
|
|
5
|
+
*
|
|
6
|
+
* A team build folder is `formatVersion: 2`. Clean-core and upgrade folders
|
|
7
|
+
* stay `formatVersion: 1`, byte for byte; every branch here keys on
|
|
8
|
+
* `type === 'build' && formatVersion === 2`. A folder made with the old
|
|
9
|
+
* `build` type (formatVersion 1, a baseline) is a legacy build folder.
|
|
10
|
+
*
|
|
11
|
+
* Also here: the guard a functional seat meets on the terminal verbs that
|
|
12
|
+
* record work (spec §7.2: a functional seat joins any team project read-only).
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { RegisterError, TEAM_FILE, defaultRand, projectIdFor, } from '../register/store.js';
|
|
17
|
+
import { writeWhole } from '../register/write-whole.js';
|
|
18
|
+
export const LEGACY_BUILD_LINE = 'This folder was set up with the old build type. The build type changed: start a fresh folder for the team build.';
|
|
19
|
+
/**
|
|
20
|
+
* The verbs that measure, decide or report on a baseline: clean-core and
|
|
21
|
+
* upgrade projects only. Not `discover` (ruling R9): it writes to its own
|
|
22
|
+
* folder (`--out` or the folder you are in), never to the joined project.
|
|
23
|
+
*/
|
|
24
|
+
export const CLEAN_CORE_ONLY_VERBS = [
|
|
25
|
+
'lane', 'refresh', 'fixed', 'target', 'rebuild', 'decide', 'rule', 'sheet', 'report', 'export', 'focus', 'priority', 'plan', 'transport',
|
|
26
|
+
];
|
|
27
|
+
/** The terminal verbs that record work into a team project. A functional seat runs none of them. */
|
|
28
|
+
export const FUNCTIONAL_RECORDING_VERBS = [
|
|
29
|
+
'lane', 'refresh', 'fixed', 'target', 'rebuild', 'decide', 'rule', 'transport', 'plan', 'focus', 'priority',
|
|
30
|
+
];
|
|
31
|
+
export const FUNCTIONAL_VERB_LINE = 'Your seat reads the team project and never records work into it. Ask a developer on the project to run this.';
|
|
32
|
+
export function buildVerbRefusal(verb) {
|
|
33
|
+
return `This is a team build folder: a spec made with the live system, then built from it. "cspeach team ${verb}" belongs to clean-core and upgrade projects.`;
|
|
34
|
+
}
|
|
35
|
+
export function buildJoinLine(project, writerId) {
|
|
36
|
+
return `✓ Joined the team build "${project.name}" (lead ${project.lead.name}) as ${writerId}.`;
|
|
37
|
+
}
|
|
38
|
+
export function isBuildProject(p) {
|
|
39
|
+
return p.type === 'build' && p.formatVersion === 2;
|
|
40
|
+
}
|
|
41
|
+
export function isLegacyBuildFolder(p) {
|
|
42
|
+
return p.type === 'build' && p.formatVersion !== 2;
|
|
43
|
+
}
|
|
44
|
+
/** The `--type` a verb was given (`--type build` or `--type=build`); undefined when none. */
|
|
45
|
+
function typeOption(rest) {
|
|
46
|
+
for (let i = 0; i < rest.length; i++) {
|
|
47
|
+
const a = rest[i];
|
|
48
|
+
if (a.startsWith('--type='))
|
|
49
|
+
return a.slice('--type='.length);
|
|
50
|
+
if (a === '--type')
|
|
51
|
+
return rest[i + 1];
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const hasOption = (rest, name) => rest.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
56
|
+
/**
|
|
57
|
+
* True when a terminal verb records work: every verb in the list, and `init`
|
|
58
|
+
* and `start` (they pull, measure and write a clean-core or upgrade project)
|
|
59
|
+
* unless they set up a team build (`--type build`). The read-only forms are
|
|
60
|
+
* left out: `lane` alone or `lane list`, `lane propose` without `--apply`,
|
|
61
|
+
* `plan history`, `priority` alone or `priority show`, and `focus list`.
|
|
62
|
+
*/
|
|
63
|
+
export function isRecordingVerb(sub, rest) {
|
|
64
|
+
if (sub === 'init' || sub === 'start')
|
|
65
|
+
return typeOption(rest) !== 'build';
|
|
66
|
+
if (!sub || !FUNCTIONAL_RECORDING_VERBS.includes(sub))
|
|
67
|
+
return false;
|
|
68
|
+
const [verb] = rest;
|
|
69
|
+
switch (sub) {
|
|
70
|
+
case 'lane': return !(verb === undefined || verb === 'list' || (verb === 'propose' && !hasOption(rest, 'apply')));
|
|
71
|
+
case 'plan': return verb !== 'history';
|
|
72
|
+
case 'priority': return !(verb === undefined || verb === 'show');
|
|
73
|
+
case 'focus': return !(verb === 'list' && rest.length === 1);
|
|
74
|
+
default: return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The team build folder: the project file (written whole, then renamed),
|
|
79
|
+
* `specs/` for the developer and `writers/` for the records.
|
|
80
|
+
*/
|
|
81
|
+
export function createBuildProject(dir, a) {
|
|
82
|
+
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
83
|
+
throw new RegisterError('This folder already holds a team project.');
|
|
84
|
+
const project = {
|
|
85
|
+
kind: 'team-project',
|
|
86
|
+
formatVersion: 2,
|
|
87
|
+
projectId: projectIdFor(a.name, a.rand ?? defaultRand),
|
|
88
|
+
name: a.name,
|
|
89
|
+
type: 'build',
|
|
90
|
+
lead: a.lead.account ? { name: a.lead.name, account: a.lead.account } : { name: a.lead.name },
|
|
91
|
+
createdAt: a.now.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
writeWhole(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
94
|
+
fs.mkdirSync(path.join(dir, 'specs'), { recursive: true });
|
|
95
|
+
fs.mkdirSync(path.join(dir, 'writers'), { recursive: true });
|
|
96
|
+
return project;
|
|
97
|
+
}
|
|
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
21
|
import { buildRegister, DEFAULT_WEIGHTS, objectKey, proposeLanes, utcWords, } from '@cspeach/register-core';
|
|
22
|
-
import { RegisterError, collectRecords, readBaseline, readLanes, readProjectFile, writeLanes, } from '../register/store.js';
|
|
22
|
+
import { RegisterError, collectRecords, coreProject, readBaseline, readLanes, readProjectFile, writeLanes, } from '../register/store.js';
|
|
23
23
|
import { diffPlan, readPlanSheet } from '../register/plan-import.js';
|
|
24
24
|
import { writeLeadRecord } from '../register/records.js';
|
|
25
25
|
import { registerFor } from '../register/picture.js';
|
|
@@ -531,7 +531,7 @@ async function planImport(args, deps) {
|
|
|
531
531
|
// not handed in yet: a newer word of the lead's is still newer.
|
|
532
532
|
const folder = collectRecords(lead.projectDir).records;
|
|
533
533
|
const own = path.resolve(lead.outboxDir) !== path.resolve(lead.projectDir) ? collectRecords(lead.outboxDir).records : [];
|
|
534
|
-
const input = { project: lead.project, baseline: readBaseline(lead.projectDir), lanes: readLanes(lead.projectDir), progress: false };
|
|
534
|
+
const input = { project: coreProject(lead.project), baseline: readBaseline(lead.projectDir), lanes: readLanes(lead.projectDir), progress: false };
|
|
535
535
|
const register = buildRegister({ ...input, records: folder });
|
|
536
536
|
const atOrBefore = (r) => {
|
|
537
537
|
const at = r?.at;
|