@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.
Files changed (50) hide show
  1. package/dist/agent/loop.js +50 -5
  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 +22 -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 +74 -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/licence/paths.js +5 -0
  20. package/dist/licence/seat-kind.js +110 -0
  21. package/dist/licence/seat-state.js +4 -0
  22. package/dist/licence/terminal-seat.js +43 -0
  23. package/dist/licence/turn-gate.js +4 -0
  24. package/dist/one-shot.js +32 -0
  25. package/dist/projects/answer-blockers.js +31 -7
  26. package/dist/projects/blocker-answers-note.js +1 -1
  27. package/dist/projects/save-command.js +37 -2
  28. package/dist/projects/saved-lines.js +19 -17
  29. package/dist/projects/spec-share.js +239 -0
  30. package/dist/register/picture.js +2 -2
  31. package/dist/register/store.js +19 -0
  32. package/dist/repl/builtin-commands.js +4 -3
  33. package/dist/repl/file-picker.js +52 -5
  34. package/dist/repl/mode-cycle.js +12 -0
  35. package/dist/repl/open-questions-gate.js +159 -0
  36. package/dist/repl/ready-phrase.js +108 -0
  37. package/dist/repl/startup-account.js +64 -1
  38. package/dist/repl/team-build-screen.js +116 -0
  39. package/dist/repl/team-build-start.js +178 -0
  40. package/dist/repl.js +205 -9
  41. package/dist/sap-link/policy.js +4 -0
  42. package/dist/sap-link/registry.js +9 -3
  43. package/dist/sap-link/server.js +4 -1
  44. package/dist/tools/_team-work-path.js +1 -1
  45. package/dist/tools/cap/sap-link-tool.js +2 -0
  46. package/dist/tools/project/spec_answers_record.js +62 -9
  47. package/dist/tools/project/team_findings.js +2 -2
  48. package/dist/tools/seat-gate.js +41 -0
  49. package/dist/ui/widgets/files-card.js +19 -1
  50. package/package.json +1 -1
@@ -1,7 +1,9 @@
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';
5
7
  import { saveSession } from '../session/store.js';
6
8
  import { tagSessionOnSave } from '../session/project-match.js';
7
9
  import { recordCompletedToolCall } from '../session/pending.js';
@@ -433,6 +435,19 @@ function localToday() {
433
435
  * package) loads only here, inside the catch: a broken docx install costs
434
436
  * one line after the save and never the save itself.
435
437
  */
438
+ /** A spec gap saved into a team build's shared specs/ folder (merge of main into feat/team-build). */
439
+ async function isSharedSpecSave(cwd, saveSkill, savedPath) {
440
+ if (saveSkill !== 'abap-spec-gap')
441
+ return false;
442
+ try {
443
+ const { sharedSpecTargetFor, isSharedSpecPath } = await import('../projects/spec-share.js');
444
+ const shared = sharedSpecTargetFor(cwd, 'spec-gap');
445
+ return !!shared && isSharedSpecPath(shared.teamDir, savedPath);
446
+ }
447
+ catch {
448
+ return false;
449
+ }
450
+ }
436
451
  async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
437
452
  try {
438
453
  const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
@@ -590,7 +605,7 @@ export async function maybeOfferSave(p) {
590
605
  tokensUsed: p.tokensUsed,
591
606
  model: p.model,
592
607
  author: getAuthorIdentity(),
593
- cwd: process.cwd(),
608
+ cwd: p.cwd ?? process.cwd(),
594
609
  prompt,
595
610
  // Piece 2 / Q3 — interactive saves ask a Yes/No card; headless passes
596
611
  // no confirm so the blanket-'y' prompt above still answers (B5 contract).
@@ -605,6 +620,7 @@ export async function maybeOfferSave(p) {
605
620
  ...(p.latestSkeleton && p.skill === saveSkill ? { latestSkeleton: p.latestSkeleton } : {}),
606
621
  onNotSaved: (reason) => { notSaved = reason; },
607
622
  ...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
623
+ ...(p.sapSystem ? { sapSystem: p.sapSystem } : {}),
608
624
  // Never headless: a headless journey queues nothing.
609
625
  ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
610
626
  });
@@ -612,7 +628,9 @@ export async function maybeOfferSave(p) {
612
628
  // is a document (spec gaps, design, estimate, plan, upgrade scan/report)
613
629
  // gets the same Word step as a review: `word_copy` decides, headless
614
630
  // never asks. The project file itself is untouched. Sub-agent turns: no.
615
- if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]) {
631
+ // A team build shared spec already has its Word copy beside it (save-command.ts).
632
+ if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]
633
+ && !(await isSharedSpecSave(p.cwd ?? process.cwd(), saveSkill, savedPath))) {
616
634
  await offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm);
617
635
  }
618
636
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
@@ -624,6 +642,25 @@ export async function maybeOfferSave(p) {
624
642
  // its next call (session.blockerAnswersNote, sent by runTurn). Main turns
625
643
  // with a session only; declined / not offered / not saved → no note.
626
644
  if (savedPath && saveSkill === 'abap-spec-gap') {
645
+ // Team build (Task 9): a spec in the shared specs/ folder is never
646
+ // rewritten — each answer is a record of its own, and the spec is read
647
+ // with the records applied. Found by the folder (R10), never activeProject().
648
+ const { sharedSpecTargetFor, isSharedSpecPath, composeSpec, readAnswers, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
649
+ const { readProjectFile } = await import('../projects/status.js');
650
+ const shared = sharedSpecTargetFor(p.cwd ?? process.cwd(), 'spec-gap');
651
+ const records = shared && isSharedSpecPath(shared.teamDir, savedPath)
652
+ ? {
653
+ // R11: only the answers recorded for this envelope.
654
+ readEnvelope: (path) => composeSpec(readProjectFile(path), readAnswers(dirname(path)).filter((a) => a.file === basename(path))),
655
+ recordAnswer: (a) => {
656
+ const specDir = dirname(savedPath);
657
+ writeAnswerRecord(specDir, {
658
+ projectId: shared.projectId, slug: basename(specDir), file: basename(savedPath),
659
+ gapId: a.gapId, answer: a.answer, by: specAuthorFor(shared.projectId, a.identity.name), at: a.at,
660
+ });
661
+ },
662
+ }
663
+ : {};
627
664
  const noteSession = p.session && !p.subagent ? p.session : undefined;
628
665
  let outcomes;
629
666
  await offerAnswerBlockers({
@@ -633,11 +670,15 @@ export async function maybeOfferSave(p) {
633
670
  confirm: inkAwareConfirm,
634
671
  choose: inkAwareChoose,
635
672
  log: (...lines) => lines.forEach((l) => p.emit(l)),
673
+ ...records,
636
674
  ...(noteSession ? { onOutcomes: (o) => { outcomes = o; } } : {}),
637
675
  });
638
676
  if (noteSession && outcomes) {
639
677
  // 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 });
678
+ const note = blockerAnswersNote({
679
+ path: savedPath, cwd: p.readCwd ?? process.cwd(), outcomes,
680
+ ...('readEnvelope' in records ? { readEnvelope: records.readEnvelope } : {}),
681
+ });
641
682
  if (note)
642
683
  noteSession.blockerAnswersNote = note;
643
684
  }
@@ -1260,7 +1301,9 @@ ${imageExpansion.error}`));
1260
1301
  const hiddenSapTools = connectedTurn
1261
1302
  ? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
1262
1303
  : [];
1263
- const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
1304
+ // Team build Task 6 — a functional seat is not offered the tools it may not
1305
+ // call (dispatch refuses them anyway). Developers: the same array.
1306
+ const contextTools = toolsForSeat(withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools), getSeatKind());
1264
1307
  // Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
1265
1308
  // when a reader would have no real read tool (standalone, file tools off).
1266
1309
  const readerVisible = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
@@ -2366,6 +2409,8 @@ ${imageExpansion.error}`));
2366
2409
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
2367
2410
  emit,
2368
2411
  sapSystem: session.sap_system,
2412
+ // Final review F6: the save and the team build folder follow this turn's folder.
2413
+ cwd: params.ctx.cwd,
2369
2414
  ...(params.operator ? { operator: params.operator } : {}),
2370
2415
  sessionId: session.id,
2371
2416
  ...(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;
@@ -248,6 +255,14 @@ export async function main(argv) {
248
255
  const { input } = await import('@inquirer/prompts');
249
256
  return input({ message: question });
250
257
  },
258
+ // Team build (ruling R2, final review F2): the terminal verbs run no
259
+ // start-up, so the seat is resolved here — the verified record, else one
260
+ // /v1/me when there is none, else the offline rule. Asked only before a
261
+ // verb that records work.
262
+ seat: async () => {
263
+ const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
264
+ return resolveTerminalSeatNow();
265
+ },
251
266
  });
252
267
  }
253
268
  // CAP phase 2 — run the SAP link in the foreground for a local CAP app.
@@ -256,6 +271,11 @@ export async function main(argv) {
256
271
  return runSapLinkCommand(args.slice(2), {
257
272
  print: (s) => console.log(s),
258
273
  defaultAlias: sapAlias,
274
+ // Final review F3: resolved as the team verbs do; a functional seat's link reads and never writes.
275
+ seat: async () => {
276
+ const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
277
+ return resolveTerminalSeatNow();
278
+ },
259
279
  standalone: async () => {
260
280
  const { loadConfig, resolveStandalone } = await import('./config/loader.js');
261
281
  return resolveStandalone(await loadConfig(), noSap);
@@ -334,10 +354,12 @@ Usage:
334
354
  cspeach login azure Sign in to Microsoft for your company's Azure (--device-code: use a code instead of the browser)
335
355
  cspeach logout Sign out (removes the CSPeach key from this home)
336
356
  cspeach whoami Print current account info
357
+ cspeach help team-build How two people share one folder: the functional writes the spec, the developer builds it
337
358
  cspeach team discover Read the whole estate from SAP into this folder (read only)
338
359
  cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
339
360
  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
361
  cspeach team init <folder> Lead: pull the object list from SAP, start a team project
362
+ cspeach team spec list|ready [<slug>] Team build: the specs in this folder; mark one ready for the developer
341
363
  cspeach team join <folder> [--as "<name>"] Record your results against a team project (on this laptop only), under the lane owner's name
342
364
  cspeach team lane add|list Lead: who owns which packages
343
365
  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;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Team build (spec 2026-10-25) — `cspeach team spec list|ready [<slug>]`.
3
+ *
4
+ * list every spec in this team build: status, title, author, date, open questions
5
+ * ready [<slug>] mark a draft "ready for build" for the developer
6
+ *
7
+ * The same marking runs from the plain phrase "this is ready for the
8
+ * developer" in the REPL (repl/ready-phrase.ts).
9
+ *
10
+ * Ruling R10: the build project is the folder CSPeach runs in, never
11
+ * `activeProject()` — a developer on a clean-core wave keeps that one active.
12
+ * Ruling R12: status belongs to the spec folder (slug), an added-only record
13
+ * in `specs/<slug>/status/`, not to one envelope version.
14
+ *
15
+ * Both seats run it: the functional authors the spec, the developer reads it.
16
+ * `spec` is neither a recording verb nor a clean-core-only verb.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { TEAM_FILE, readProjectFile } from '../register/store.js';
21
+ import { isBuildProject } from './team-build.js';
22
+ import { listSharedSpecs, specAuthorFor, writeStatusRecord } from '../projects/spec-share.js';
23
+ export const NOT_BUILD_SPEC_LINE = 'This folder is not a team build. "cspeach team spec" works in a team build folder.';
24
+ export const NO_DRAFT_LINE = 'No draft spec here yet. Work the requirement through spec gap first, then say when it is ready.';
25
+ export const markedReadyLine = (title) => `Marked ready for the developer: "${title}". They will see it when they open CSPeach in this folder.`;
26
+ const USAGE = 'Usage: cspeach team spec list|ready [<slug>]';
27
+ /**
28
+ * The team build in exactly this folder, or null. Cheap outside a team
29
+ * folder: one file-exists check. An unreadable project file is null too.
30
+ */
31
+ export function buildFolderAt(cwd) {
32
+ const dir = path.resolve(cwd);
33
+ if (!fs.existsSync(path.join(dir, TEAM_FILE)))
34
+ return null;
35
+ try {
36
+ const project = readProjectFile(dir);
37
+ return isBuildProject(project) ? { dir, project } : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export const draftSpecs = (teamDir) => listSharedSpecs(teamDir).filter((s) => s.status === 'draft');
44
+ /** Add the "ready for build" record to the spec's folder. */
45
+ export function markSpecReady(spec, projectId, by, now, rand) {
46
+ return writeStatusRecord(spec.specDir, {
47
+ projectId, slug: spec.slug, file: path.basename(spec.envelopePath), status: 'ready for build', by, at: now.toISOString(),
48
+ }, rand);
49
+ }
50
+ export async function specCommand(args, deps, cwd = process.cwd()) {
51
+ const [verb, ...rest] = args;
52
+ const here = buildFolderAt(cwd);
53
+ if (!here) {
54
+ deps.print(NOT_BUILD_SPEC_LINE);
55
+ return 1;
56
+ }
57
+ switch (verb) {
58
+ case 'list': {
59
+ const specs = listSharedSpecs(here.dir);
60
+ if (!specs.length) {
61
+ deps.print('No specs here yet.');
62
+ return 0;
63
+ }
64
+ for (const s of specs) {
65
+ deps.print(`${s.status.padEnd(16)} ${s.title} (${s.author}, ${s.createdAt.slice(0, 10)}) — ${s.openQuestions.length} open`);
66
+ }
67
+ return 0;
68
+ }
69
+ case 'ready': {
70
+ const slug = rest[0];
71
+ const all = listSharedSpecs(here.dir);
72
+ let spec;
73
+ if (slug) {
74
+ spec = all.find((s) => s.slug === slug);
75
+ if (!spec) {
76
+ deps.print(`No spec "${slug}" here. Specs: ${all.length ? all.map((s) => s.slug).join(', ') : 'none yet'}`);
77
+ return 1;
78
+ }
79
+ if (spec.status !== 'draft') {
80
+ deps.print(`"${spec.title}" is already ${spec.status}.`);
81
+ return 0;
82
+ }
83
+ }
84
+ else {
85
+ const drafts = all.filter((s) => s.status === 'draft');
86
+ if (!drafts.length) {
87
+ deps.print(NO_DRAFT_LINE);
88
+ return 1;
89
+ }
90
+ if (drafts.length > 1) {
91
+ deps.print('Say which: cspeach team spec ready <slug>');
92
+ for (const s of drafts)
93
+ deps.print(` ${s.slug} ${s.title}`);
94
+ return 1;
95
+ }
96
+ spec = drafts[0];
97
+ }
98
+ const by = specAuthorFor(here.project.projectId, deps.identity().name);
99
+ markSpecReady(spec, here.project.projectId, by, (deps.now ?? (() => new Date()))(), deps.rand);
100
+ deps.print(markedReadyLine(spec.title));
101
+ return 0;
102
+ }
103
+ default:
104
+ deps.print(USAGE);
105
+ return 1;
106
+ }
107
+ }