@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.
Files changed (58) hide show
  1. package/dist/agent/loop.js +90 -7
  2. package/dist/agent/tool-dispatch.js +11 -0
  3. package/dist/auth/auth-file.js +3 -0
  4. package/dist/auth/key-resolver.js +8 -3
  5. package/dist/auth/me.js +6 -2
  6. package/dist/cli.js +25 -0
  7. package/dist/commands/login.js +22 -4
  8. package/dist/commands/plan-reground.js +5 -0
  9. package/dist/commands/sap-link.js +14 -1
  10. package/dist/commands/team-build-help.js +19 -0
  11. package/dist/commands/team-build.js +97 -0
  12. package/dist/commands/team-plan.js +2 -2
  13. package/dist/commands/team-spec.js +107 -0
  14. package/dist/commands/team.js +111 -4
  15. package/dist/journey/chain.js +8 -0
  16. package/dist/journey/clean-core.js +2 -2
  17. package/dist/journey/rebuild.js +8 -0
  18. package/dist/journey/run.js +9 -0
  19. package/dist/lane/ready-spec.js +32 -0
  20. package/dist/licence/account-name.js +31 -0
  21. package/dist/licence/paths.js +5 -0
  22. package/dist/licence/seat-kind.js +110 -0
  23. package/dist/licence/seat-state.js +4 -0
  24. package/dist/licence/terminal-seat.js +43 -0
  25. package/dist/licence/turn-gate.js +4 -0
  26. package/dist/one-shot.js +32 -0
  27. package/dist/projects/answer-blockers.js +31 -7
  28. package/dist/projects/blocker-answers-note.js +1 -1
  29. package/dist/projects/save-command.js +37 -2
  30. package/dist/projects/saved-lines.js +19 -17
  31. package/dist/projects/spec-share.js +270 -0
  32. package/dist/projects/team-build-context.js +49 -0
  33. package/dist/register/picture.js +2 -2
  34. package/dist/register/store.js +19 -0
  35. package/dist/renderer/routing-line.js +11 -0
  36. package/dist/repl/builtin-commands.js +4 -3
  37. package/dist/repl/file-picker.js +52 -5
  38. package/dist/repl/mode-cycle.js +12 -0
  39. package/dist/repl/open-questions-gate.js +193 -0
  40. package/dist/repl/ready-phrase.js +108 -0
  41. package/dist/repl/startup-account.js +64 -1
  42. package/dist/repl/team-build-screen.js +124 -0
  43. package/dist/repl/team-build-start.js +179 -0
  44. package/dist/repl.js +227 -10
  45. package/dist/sap-link/policy.js +4 -0
  46. package/dist/sap-link/registry.js +9 -3
  47. package/dist/sap-link/server.js +4 -1
  48. package/dist/tools/_team-work-path.js +25 -1
  49. package/dist/tools/cap/sap-link-tool.js +2 -0
  50. package/dist/tools/filesystem/file-edit.js +2 -2
  51. package/dist/tools/filesystem/file-write.js +2 -2
  52. package/dist/tools/hand-off.js +16 -1
  53. package/dist/tools/project/spec_answers_record.js +62 -9
  54. package/dist/tools/project/team_findings.js +2 -2
  55. package/dist/tools/seat-gate.js +41 -0
  56. package/dist/ui/when-listening.js +34 -0
  57. package/dist/ui/widgets/files-card.js +19 -1
  58. package/package.json +1 -1
@@ -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
- if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]) {
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({ path: savedPath, cwd: p.readCwd ?? process.cwd(), outcomes });
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
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine]);
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
- const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
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,
@@ -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
- await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}) });
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 }`, as before the batch.
22
- const r = await fetch(url, { headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${opts.apiKey}` }, signal });
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)
@@ -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 — if trial_mode === 'byok', pull the org's Anthropic key,
12
- * or prompt for the user's own
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 — figure out if BYOK is required.
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, deps.linkDeps);
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;