@cspeach/cli 1.1.52 → 1.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +150 -17
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +17 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/auth/auth-file.js +3 -0
  9. package/dist/auth/key-resolver.js +8 -3
  10. package/dist/auth/me.js +6 -2
  11. package/dist/cli.js +22 -0
  12. package/dist/commands/login.js +22 -4
  13. package/dist/commands/plan-chain.js +2 -2
  14. package/dist/commands/plan-reground.js +5 -0
  15. package/dist/commands/plan-resume.js +2 -2
  16. package/dist/commands/plan-startup.js +3 -3
  17. package/dist/commands/sap-link.js +14 -1
  18. package/dist/commands/team-build-help.js +19 -0
  19. package/dist/commands/team-build.js +97 -0
  20. package/dist/commands/team-plan.js +2 -2
  21. package/dist/commands/team-spec.js +107 -0
  22. package/dist/commands/team.js +74 -4
  23. package/dist/docx/doc-meta.js +1 -0
  24. package/dist/docx/markdown-to-docx.js +2 -0
  25. package/dist/journey/chain.js +8 -0
  26. package/dist/journey/clean-core.js +2 -2
  27. package/dist/journey/rebuild.js +8 -0
  28. package/dist/journey/run.js +9 -0
  29. package/dist/lane/app-chain.js +13 -6
  30. package/dist/licence/paths.js +5 -0
  31. package/dist/licence/seat-kind.js +110 -0
  32. package/dist/licence/seat-state.js +4 -0
  33. package/dist/licence/terminal-seat.js +43 -0
  34. package/dist/licence/turn-gate.js +4 -0
  35. package/dist/one-shot.js +33 -0
  36. package/dist/project-context/conventions.js +8 -1
  37. package/dist/project-context/index-files.js +97 -34
  38. package/dist/project-context/index.js +1 -1
  39. package/dist/project-context/render.js +38 -4
  40. package/dist/projects/answer-blockers.js +133 -18
  41. package/dist/projects/blocker-answers-note.js +89 -0
  42. package/dist/projects/change-options.js +70 -0
  43. package/dist/projects/document-save.js +86 -0
  44. package/dist/projects/extract-design.js +4 -0
  45. package/dist/projects/extract-estimate.js +4 -0
  46. package/dist/projects/extract-spec-gap.js +8 -0
  47. package/dist/projects/index.js +1 -1
  48. package/dist/projects/plan-objects.js +24 -0
  49. package/dist/projects/plan-run.js +12 -2
  50. package/dist/projects/save-command.js +83 -20
  51. package/dist/projects/saved-lines.js +19 -17
  52. package/dist/projects/spec-share.js +239 -0
  53. package/dist/register/picture.js +2 -2
  54. package/dist/register/store.js +19 -0
  55. package/dist/renderer/change-card.js +14 -0
  56. package/dist/renderer/html-entities.js +25 -0
  57. package/dist/renderer/machine-blocks.js +54 -7
  58. package/dist/renderer/tool-labels.js +2 -0
  59. package/dist/renderer/tool-widget.js +20 -5
  60. package/dist/repl/builtin-commands.js +4 -3
  61. package/dist/repl/chat-lane.js +10 -1
  62. package/dist/repl/file-picker.js +52 -5
  63. package/dist/repl/mode-cycle.js +12 -0
  64. package/dist/repl/open-questions-gate.js +159 -0
  65. package/dist/repl/ready-phrase.js +108 -0
  66. package/dist/repl/startup-account.js +64 -1
  67. package/dist/repl/team-build-screen.js +116 -0
  68. package/dist/repl/team-build-start.js +178 -0
  69. package/dist/repl.js +216 -11
  70. package/dist/sap-link/policy.js +4 -0
  71. package/dist/sap-link/registry.js +9 -3
  72. package/dist/sap-link/server.js +4 -1
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/user-prompt.js +2 -0
  75. package/dist/tools/_team-work-path.js +1 -1
  76. package/dist/tools/approval.js +12 -1
  77. package/dist/tools/cap/sap-link-tool.js +2 -0
  78. package/dist/tools/project/spec_answers_record.js +243 -0
  79. package/dist/tools/project/team_findings.js +2 -2
  80. package/dist/tools/seat-gate.js +41 -0
  81. package/dist/tools/tool-loading.js +2 -0
  82. package/dist/ui/approval-modal.js +2 -1
  83. package/dist/ui/widgets/ask-card.js +171 -29
  84. package/dist/ui/widgets/files-card.js +19 -1
  85. package/package.json +2 -2
@@ -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)({
@@ -34,7 +34,7 @@ import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-obje
34
34
  import { isDeployPhase } from '../projects/plan-lane.js';
35
35
  import { askDeployCard } from './deploy-card.js';
36
36
  import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
37
- import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
37
+ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, planBuildTransport, } from './plan-resume.js';
38
38
  import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
39
39
  import { clean } from '../renderer/tool-labels.js';
40
40
  import { runBatchAudit, pendingAuditPhaseIds, selectPlanCardAction, } from './plan-batch-audit.js';
@@ -251,7 +251,7 @@ async function postPhaseChain(args) {
251
251
  const built = after.content.phases.every((q) => isPhaseSatisfiedForRun(st[q.id], q.audit));
252
252
  const nextAfter = computeNextPhaseForRun(after.content.phases, st);
253
253
  if (built && !nextAfter) {
254
- args.log('', ...planCompletionLines(after.content.phases));
254
+ args.log('', ...planCompletionLines(after.content.phases, undefined, planBuildTransport(after.content.phases)));
255
255
  // Step 2 (C5): a rebuild whose plan this is now reads "built, not yet checked".
256
256
  await afterPlanComplete(cur);
257
257
  args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
@@ -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);
@@ -521,7 +521,7 @@ export async function preparePlanResume(args) {
521
521
  // C2 (D24): a finished UI-less backend stack chains to /abap-fiori-build
522
522
  // with the exact binding name — the marketed idea→app story must not
523
523
  // silently end at the service binding.
524
- args.log(...planCompletionLines(content.phases));
524
+ args.log(...planCompletionLines(content.phases, undefined, planBuildTransport(content.phases)));
525
525
  }
526
526
  else {
527
527
  const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => titleOfId(p.id));
@@ -1102,7 +1102,7 @@ export async function finishPlanResume(args) {
1102
1102
  args.log(renderPlanProgressLine({ content: { ...extract.content, phases: provisionalPhases }, statuses, currentId: null }));
1103
1103
  if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
1104
1104
  // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
1105
- args.log(...planCompletionLines(provisionalPhases));
1105
+ args.log(...planCompletionLines(provisionalPhases, undefined, planBuildTransport(provisionalPhases)));
1106
1106
  // Step 2 (C5): the live run finished — a rebuild whose plan this is reads built.
1107
1107
  // Not at the re-opened complete plan above (preparePlanResume).
1108
1108
  await afterPlanComplete(args.prepared.path);
@@ -17,7 +17,7 @@ import { basename, dirname } from 'node:path';
17
17
  import { readProjectFile } from '../projects/status.js';
18
18
  import { parsePlanContent } from '../projects/plan-schema.js';
19
19
  import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, statusesFromItems, } from '../projects/plan-run.js';
20
- import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
20
+ import { collectPlannedObjects, plannedObjectFacts, planGrantCoversWrites, planGrantFactLines, planTransportFact, } from '../projects/plan-objects.js';
21
21
  import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
22
22
  import { newestPlanFamilyFiles } from '../session/recap.js';
23
23
  import { recordCliCard } from '../session/cli-record.js';
@@ -100,8 +100,8 @@ function objectListFacts(objects, transport, grantable) {
100
100
  return [
101
101
  `It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`,
102
102
  ...plannedObjectFacts(objects).map((r) => ` ${r}`),
103
- `Transport: ${transport ?? 'a new one for this build'}`,
104
- ...(grantable ? planGrantFactLines() : ['CSPeach asks before each write.']),
103
+ planTransportFact(transport, objects),
104
+ ...(grantable && planGrantCoversWrites(transport, objects) ? planGrantFactLines() : ['CSPeach asks before each write.']),
105
105
  ];
106
106
  }
107
107
  const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
@@ -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
+ }
@@ -23,7 +23,7 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
25
25
  import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
26
- import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
26
+ import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, coreProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
27
27
  import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
28
28
  import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
29
29
  import { writeLeadRecord, writeRecord } from '../register/records.js';
@@ -39,7 +39,11 @@ import { lookupVariant, variantLookupLine } from '../register/variant.js';
39
39
  import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
40
40
  import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
41
41
  import { rulesCommand } from './team-rules.js';
42
+ import { specCommand } from './team-spec.js';
42
43
  import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
44
+ import { CLEAN_CORE_ONLY_VERBS, FUNCTIONAL_VERB_LINE, buildJoinLine, buildVerbRefusal, createBuildProject, isBuildProject, isRecordingVerb, } from './team-build.js';
45
+ import { seatSignInMessage } from '../licence/seat-kind.js';
46
+ import { getSeatKind } from '../licence/seat-state.js';
43
47
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
44
48
  const TYPES = ['clean-core', 'upgrade', 'build'];
45
49
  /**
@@ -128,6 +132,27 @@ function matches(pattern, value) {
128
132
  export async function runTeamCommand(args, deps) {
129
133
  const [sub, ...rest] = args;
130
134
  try {
135
+ // Team build (spec §7.2): a functional seat reads a team project and never
136
+ // records work into it. Read-only verbs never ask for the seat. On by
137
+ // default (final review F1): without deps.seat the process seat decides
138
+ // (seat-state.ts — developer unless start-up resolved functional), so a
139
+ // caller that builds its own TeamDeps (the clean-core journey) is held too.
140
+ if (isRecordingVerb(sub, rest)) {
141
+ const seat = await (deps.seat ?? (async () => getSeatKind()))();
142
+ if (seat === 'functional') {
143
+ deps.print(FUNCTIONAL_VERB_LINE);
144
+ return 1;
145
+ }
146
+ if (seat === 'unconfirmed') {
147
+ deps.print(seatSignInMessage());
148
+ return 1;
149
+ }
150
+ }
151
+ // A team build folder has no baseline: the clean-core verbs are refused in one line.
152
+ if (sub && CLEAN_CORE_ONLY_VERBS.includes(sub) && activeIsBuild()) {
153
+ deps.print(buildVerbRefusal(sub));
154
+ return 1;
155
+ }
131
156
  switch (sub) {
132
157
  case 'init': return await init(rest, deps);
133
158
  case 'start': return await start(rest, deps);
@@ -151,8 +176,9 @@ export async function runTeamCommand(args, deps) {
151
176
  case 'priority': return priorityCommand(rest, deps);
152
177
  case 'plan': return await planCommand(rest, deps);
153
178
  case 'rebuild': return await rebuildVerb(rest, deps);
179
+ case 'spec': return await specCommand(rest, deps);
154
180
  default:
155
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
181
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules|spec (cspeach --help for details)');
156
182
  return sub ? 1 : 0;
157
183
  }
158
184
  }
@@ -170,6 +196,28 @@ export async function runTeamCommand(args, deps) {
170
196
  return 1;
171
197
  }
172
198
  }
199
+ /** The joined project is a team build. Unreadable or none: no guard (the verb says why itself). */
200
+ function activeIsBuild() {
201
+ try {
202
+ const joined = activeProject();
203
+ return !!joined && isBuildProject(readProjectFile(joined.projectDir));
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ /**
210
+ * The team build folder (Task 7): no SAP connection, nothing pulled or
211
+ * measured. The lead joins with the outbox in the folder itself.
212
+ */
213
+ function initBuild(dir, name, lead, deps, asName) {
214
+ createBuildProject(dir, { name, lead: { name: lead }, now: (deps.now ?? (() => new Date()))(), ...(deps.rand ? { rand: deps.rand } : {}) });
215
+ const quiet = { ...deps, print: () => { } };
216
+ join([dir, '--outbox', dir], quiet, asName);
217
+ deps.print(`✓ Team build "${name}" set up in this folder. Lead: ${lead}.`);
218
+ deps.print(' Specs save to specs/ for the developer. Start CSPeach here: cspeach');
219
+ return 0;
220
+ }
173
221
  /** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
174
222
  export function isLead(project, deps) {
175
223
  return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
@@ -191,6 +239,8 @@ const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<
191
239
  + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
192
240
  /** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
193
241
  const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
242
+ /** What `init --type build` takes: a team build has no packages, no check and no discovery to read. */
243
+ const INIT_BUILD_OPTIONS = ['name', 'lead', 'type'];
194
244
  /** The two that take no value, so neither can eat the folder name. */
195
245
  const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
196
246
  /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
@@ -220,7 +270,9 @@ function unknownInitOption(name) {
220
270
  }
221
271
  const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
222
272
  + '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
223
- const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
273
+ const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport', 'type'];
274
+ /** What `start --type build` takes besides the type: a team build has no packages, no check and no transport yet. */
275
+ const START_BUILD_OPTIONS = ['type', 'name', 'folder'];
224
276
  /** The check a project measures with when nobody names one. */
225
277
  export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
226
278
  /** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
@@ -288,6 +340,13 @@ async function start(args, deps) {
288
340
  if (!START_OPTIONS.includes(name))
289
341
  throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
290
342
  }
343
+ // Team build (Task 7): the folder you are in, its name, you as the lead. Nothing to pull.
344
+ if (opts.type !== undefined) {
345
+ if (opts.type !== 'build' || positional.length || Object.keys(opts).some((o) => !START_BUILD_OPTIONS.includes(o)))
346
+ throw new RegisterError(START_USAGE);
347
+ const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
348
+ return initBuild(dir, opts.name && opts.name !== 'true' ? opts.name : path.basename(dir), deps.identity().name, deps);
349
+ }
291
350
  const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
292
351
  if (typed.some(isPatternWord))
293
352
  throw new RegisterError(NOT_A_PATTERN);
@@ -432,6 +491,12 @@ async function init(args, deps) {
432
491
  const type = (opts.type ?? 'clean-core');
433
492
  if (!TYPES.includes(type))
434
493
  throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
494
+ if (type === 'build') {
495
+ // A team build pulls, measures and discovers nothing: those options are refused, as on `start --type build`.
496
+ if (Object.keys(opts).some((o) => !INIT_BUILD_OPTIONS.includes(o)))
497
+ throw new RegisterError(INIT_USAGE);
498
+ return initBuild(dir, name, lead, deps, given || undefined);
499
+ }
435
500
  // One variant per team project (D6), named here or never. A name SAP could
436
501
  // not accept is refused before a folder exists, not after it is written in.
437
502
  const atcVariant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'].trim().toUpperCase() : '';
@@ -700,6 +765,11 @@ function join(args, deps, asName) {
700
765
  state.projects[project.projectId] = { projectDir, outboxDir, writerId, ...(saved ? { name: saved } : {}) };
701
766
  state.active = project.projectId;
702
767
  saveState(state);
768
+ // A team build has no objects to own: one line.
769
+ if (isBuildProject(project)) {
770
+ deps.print(buildJoinLine(project, writerId));
771
+ return 0;
772
+ }
703
773
  deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
704
774
  // Outbox is the team project folder itself: records land where everyone reads them.
705
775
  if (path.relative(projectDir, outboxDir) === '') {
@@ -2494,7 +2564,7 @@ const heldExportLine = (name) => ` ! "${name}" is open in another program, so i
2494
2564
  /** The CSV export of the team project folder, as the team page reads it, into `dir`. */
2495
2565
  export function writeExports(projectDir, project, dir, deps) {
2496
2566
  const { register } = registerFor(projectDir, outboxFor(projectDir));
2497
- return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
2567
+ return writeExportFiles(register, coreProject(project), dir, (deps.now?.() ?? new Date()).toISOString());
2498
2568
  }
2499
2569
  /**
2500
2570
  * `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
@@ -11,6 +11,7 @@ const KIND_TITLE = {
11
11
  'spec-gap': 'Spec gaps', design: 'Design', estimate: 'Estimate', plan: 'Plan',
12
12
  'upgrade-scan': 'Upgrade scan', 'upgrade-report': 'Upgrade report',
13
13
  answer: 'CSPeach answer', conversation: 'CSPeach conversation',
14
+ 'change-options': 'Change options',
14
15
  };
15
16
  /** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
16
17
  const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
@@ -67,6 +67,8 @@ function titlePage(meta) {
67
67
  facts.push(`Session: ${meta.session}`);
68
68
  if (meta.person)
69
69
  facts.push(`Signed in as: ${meta.person}`);
70
+ if (meta.preparedFor)
71
+ facts.push(`Prepared for: ${meta.preparedFor}`);
70
72
  return [
71
73
  new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
72
74
  ...facts.map((f) => new Paragraph({ text: f })),
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { estateSummaryOf, viewWords } from '@cspeach/register-core';
17
17
  import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, isLead, ruleObject, runTeamCommand, whoAmI } from '../commands/team.js';
18
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
19
+ import { isFunctionalSeat } from '../licence/seat-state.js';
18
20
  import { activeProject, readBaseline, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
19
21
  import { findingsByObject } from '../register/findings.js';
20
22
  import { shown, shownUpTo } from '../register/atc-file.js';
@@ -245,6 +247,12 @@ export async function enterJourney(trigger, h) {
245
247
  h.emit(trigger.line);
246
248
  return;
247
249
  }
250
+ // Final review F1: the clean-core and upgrade steps record work and run
251
+ // SAP's checks. A functional seat is told so once, before any card or read.
252
+ if (isFunctionalSeat()) {
253
+ h.emit(FUNCTIONAL_VERB_LINE);
254
+ return;
255
+ }
248
256
  startJourneyFlow(h.session, trigger.kind === 'start' ? trigger.packages : []);
249
257
  // B8: a start sentence is a new run: its DONE never reads as "nothing left" against an earlier one.
250
258
  if (trigger.kind === 'start')
@@ -26,7 +26,7 @@
26
26
  import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { projectView, provenForTarget as provenForTargetCore } from '@cspeach/register-core';
29
- import { DISCOVERY_DIR, TEAM_FILE, activeProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
29
+ import { DISCOVERY_DIR, TEAM_FILE, activeProject, coreProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
30
30
  import { registerFor } from '../register/picture.js';
31
31
  import { reportFileNames } from '../register/workbooks.js';
32
32
  import { samePath } from '../commands/team-discover.js';
@@ -250,7 +250,7 @@ export function readJourneyFacts(cwd, me, skip) {
250
250
  const joined = activeProject();
251
251
  // The project file folderRead already read (joined means its folder is this one).
252
252
  facts.register = registerFor(joined.projectDir, joined.outboxDir, project).register;
253
- facts.view = projectView(facts.register, project, skip?.decide ? { skipped: skip.decide } : {});
253
+ facts.view = projectView(facts.register, coreProject(project), skip?.decide ? { skipped: skip.decide } : {});
254
254
  facts.reportsAt = reportsAt(cwd, folder.name);
255
255
  }
256
256
  catch {