@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
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Team build (spec 2026-10-25) — open questions travel with a shared spec.
3
+ *
4
+ * Before `/abap-app --from @specs/<slug>/<file>` runs in a team build folder,
5
+ * the spec's open questions (the author's file with the answer records
6
+ * applied, R11) are shown, BLOCKER first, with one card: answer them now (each
7
+ * answer a record of its own; the author's file is never rewritten), go ahead
8
+ * with them open (they ride in the command as assumptions), or stop.
9
+ *
10
+ * Anything that is not `/abap-app --from @…` naming a file under the shared
11
+ * `specs/` returns null: the command goes on exactly as before. The build
12
+ * folder is the folder CSPeach runs in (R10).
13
+ */
14
+ import path from 'node:path';
15
+ const PREFIX = /^\/abap-app\s+--from\s+@/;
16
+ export const ANSWER = 'Answer them now';
17
+ export const GO_AHEAD = 'Go ahead with them open';
18
+ export const STOP = 'Stop';
19
+ export const STOPPED_LINE = 'Stopped. The spec stays ready for the developer.';
20
+ /**
21
+ * R14 — the specs this run marked "being built" from the start screen. A Stop
22
+ * at the gate puts such a spec back to "ready for build" (a new record, same
23
+ * `by`), so the Stopped line is true and the spec is offered again.
24
+ */
25
+ const markedThisRun = new Set();
26
+ const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
27
+ export function noteBeingBuiltThisRun(specDir) { markedThisRun.add(dirKey(specDir)); }
28
+ function openQuestions(env) {
29
+ if (env.artefactType !== 'spec-gap')
30
+ return [];
31
+ const open = new Set(env.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
32
+ const qs = [];
33
+ for (const s of env.content.sections)
34
+ for (const g of s.gaps)
35
+ if (open.has(g.id))
36
+ qs.push({ id: g.id, severity: g.severity, text: g.text });
37
+ return [...qs.filter((q) => q.severity === 'BLOCKER'), ...qs.filter((q) => q.severity !== 'BLOCKER')];
38
+ }
39
+ /** The start-up card (Ink or classic) as the gate's card: a choice index, or null. */
40
+ export function gateChooser(choose) {
41
+ return async (q) => {
42
+ const v = await choose({ title: q.title, facts: q.facts, choices: q.choices.map((label, i) => ({ value: String(i), label })) });
43
+ if (v === null)
44
+ return null;
45
+ const n = Number(v);
46
+ return Number.isInteger(n) ? n : null;
47
+ };
48
+ }
49
+ const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
50
+ export async function openQuestionsGate(a) {
51
+ if (!PREFIX.test(a.command))
52
+ return null;
53
+ const { parseFromFlag } = await import('../skills/promotion-dispatch.js');
54
+ const { fromPath } = parseFromFlag(a.command);
55
+ if (!fromPath)
56
+ return null;
57
+ const { buildFolderAt } = await import('../commands/team-spec.js');
58
+ const here = buildFolderAt(a.cwd);
59
+ if (!here)
60
+ return null;
61
+ const { resolveAtTokenAsync } = await import('../projects/workspace.js');
62
+ let abs;
63
+ try {
64
+ const r = await resolveAtTokenAsync(fromPath, a.cwd);
65
+ if (r.kind !== 'path')
66
+ return null;
67
+ abs = path.resolve(a.cwd, r.path);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ const share = await import('../projects/spec-share.js');
73
+ if (!share.isSharedSpecPath(here.dir, abs) || !abs.endsWith('.cspeach.json'))
74
+ return null;
75
+ const { readProjectFile } = await import('../projects/status.js');
76
+ const specDir = path.dirname(abs);
77
+ const file = path.basename(abs);
78
+ // R11: only the answers recorded for this envelope.
79
+ const read = (p) => share.composeSpec(readProjectFile(p), share.readAnswers(path.dirname(p)).filter((x) => x.file === path.basename(p)));
80
+ let env;
81
+ try {
82
+ env = read(abs);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ if (env.artefactType !== 'spec-gap')
88
+ return null;
89
+ // Final review F5: every return that lets the build go on forgets this run's
90
+ // "being built" mark, so a later hand-typed Stop on the same spec puts nothing back.
91
+ const goOn = (r) => { markedThisRun.delete(dirKey(specDir)); return r; };
92
+ const carried = (qs) => goOn({ command: `${a.command}\nOpen questions carried as assumptions:\n${qs.map((q) => `- ${q.id}: ${q.text}`).join('\n')}` });
93
+ for (;;) {
94
+ const qs = openQuestions(env);
95
+ if (!qs.length)
96
+ return goOn({ command: a.command });
97
+ if (a.headless)
98
+ return carried(qs);
99
+ for (const q of qs)
100
+ a.emit(` - [${q.severity}] ${q.text}`);
101
+ // Only blockers can be answered here: with none open, the card leaves that choice out.
102
+ const choices = qs.some((q) => q.severity === 'BLOCKER') ? [ANSWER, GO_AHEAD, STOP] : [GO_AHEAD, STOP];
103
+ const pick = await a.choose({ title: cardTitle(qs.length), facts: [], choices });
104
+ const label = pick === null ? undefined : choices[pick];
105
+ if (label === GO_AHEAD)
106
+ return carried(qs);
107
+ if (label !== ANSWER) {
108
+ putBackReady(share, specDir, a.now);
109
+ return { stop: true };
110
+ }
111
+ const { offerAnswerBlockers } = await import('../projects/answer-blockers.js');
112
+ await offerAnswerBlockers({
113
+ path: abs,
114
+ identity: a.identity,
115
+ prompt: a.prompt ?? (async () => ''),
116
+ // "Answer them now" was the yes: the flow does not ask it again.
117
+ confirm: async () => 'y',
118
+ ...(a.answer ? { choose: a.answer } : {}),
119
+ log: (...lines) => lines.forEach((l) => a.emit(l)),
120
+ ...(a.now ? { now: a.now } : {}),
121
+ readEnvelope: read,
122
+ recordAnswer: (r) => {
123
+ share.writeAnswerRecord(specDir, {
124
+ projectId: here.project.projectId, slug: path.basename(specDir), file,
125
+ gapId: r.gapId, answer: r.answer, by: share.specAuthorFor(here.project.projectId, r.identity.name), at: r.at,
126
+ });
127
+ },
128
+ });
129
+ try {
130
+ env = read(abs);
131
+ }
132
+ catch {
133
+ return goOn({ command: a.command });
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * R14 — Stop on a spec this run marked "being built": add a "ready for build"
139
+ * record back (records stay add-only), with the same `by`. A spec typed by
140
+ * hand, or marked by an earlier run, is left as it is.
141
+ */
142
+ function putBackReady(share, specDir, now) {
143
+ const k = dirKey(specDir);
144
+ if (!markedThisRun.has(k))
145
+ return;
146
+ try {
147
+ const st = share.readSpecStatus(specDir);
148
+ if (st.current !== 'being built' || !st.record)
149
+ return;
150
+ const r = st.record;
151
+ // Later than the "being built" record: at the same instant the later step would win.
152
+ const t = Math.max(Date.parse((now ?? (() => new Date().toISOString()))()), Date.parse(r.at) + 1);
153
+ share.writeStatusRecord(specDir, {
154
+ projectId: r.projectId, slug: r.slug, file: r.file, status: 'ready for build', by: r.by, at: new Date(t).toISOString(),
155
+ });
156
+ markedThisRun.delete(k);
157
+ }
158
+ catch { /* the Stop still stops */ }
159
+ }
@@ -0,0 +1,108 @@
1
+ // No static imports beyond types: repl.tsx imports this module at start-up,
2
+ // and the folder code loads only when the phrase matches.
3
+ const PHRASE = /\bready\s+for\s+(?:the\s+)?(?:developer|dev|build)\b|\bmark\s+(?:it|this|the\s+spec)\s+(?:as\s+)?ready\b/i;
4
+ const QUESTION_START = /^(?:is|are|can|could|should|when|what|do|does|how|why|will|would|has|have|if|whether|once|after|until)\b/i;
5
+ /** R13: "tell me when it is ready for build" asks to be told; it marks nothing. */
6
+ const ASK_START = /^(?:tell|let)\s+me\s+(?:know\s+)?when\b/i;
7
+ /**
8
+ * R13: a negation anywhere ("not ready", "don't mark it", "isn't ready") marks
9
+ * nothing — typed with no apostrophe too ("cant", "isnt", "dont", final review F5).
10
+ */
11
+ const NEGATION = /\b(?:not|never|no|cannot|cant|isnt|dont|wont|arent|aint)\b|n['’]t\b/i;
12
+ /** Final review F5: a leading "please" changes nothing; the checks read what follows it. */
13
+ const PLEASE = /^please\b[\s,]*/i;
14
+ export function isReadyForDeveloperPhrase(input) {
15
+ const typed = String(input ?? '').trim();
16
+ if (!typed || typed.length > 200)
17
+ return false;
18
+ if (typed.startsWith('/') || typed.endsWith('?'))
19
+ return false;
20
+ const s = typed.replace(PLEASE, '');
21
+ if (QUESTION_START.test(s) || ASK_START.test(s))
22
+ return false;
23
+ if (NEGATION.test(s))
24
+ return false;
25
+ return PHRASE.test(s);
26
+ }
27
+ export async function markReadyFlow(a) {
28
+ const { NO_DRAFT_LINE, buildFolderAt, draftSpecs, markSpecReady, markedReadyLine } = await import('../commands/team-spec.js');
29
+ const here = buildFolderAt(a.cwd);
30
+ if (!here)
31
+ return false;
32
+ const drafts = draftSpecs(here.dir);
33
+ if (!drafts.length) {
34
+ a.emit(NO_DRAFT_LINE);
35
+ return true;
36
+ }
37
+ let pick = 0;
38
+ if (drafts.length > 1) {
39
+ const i = await a.choose(drafts.map((s) => s.title));
40
+ if (i === null || !Number.isInteger(i) || i < 0 || i >= drafts.length) {
41
+ a.emit('Nothing marked.');
42
+ return true;
43
+ }
44
+ pick = i;
45
+ }
46
+ const spec = drafts[pick];
47
+ try {
48
+ markSpecReady(spec, here.project.projectId, a.by, (a.now ?? (() => new Date()))(), a.rand);
49
+ }
50
+ catch (e) {
51
+ a.emit(couldNotMarkLine(e));
52
+ return true;
53
+ }
54
+ a.emit(markedReadyLine(spec.title));
55
+ return true;
56
+ }
57
+ /**
58
+ * The REPL front door. False = not ours: the text goes on to the model as
59
+ * before. Ordinary text never reads the folder.
60
+ */
61
+ export async function readyForDeveloperDoor(input, a) {
62
+ if (!isReadyForDeveloperPhrase(input))
63
+ return false;
64
+ const { buildFolderAt } = await import('../commands/team-spec.js');
65
+ const here = buildFolderAt(a.cwd);
66
+ if (!here)
67
+ return false;
68
+ // From here the phrase is ours: any failure is one plain line, never a throw.
69
+ try {
70
+ const { specAuthorFor } = await import('../projects/spec-share.js');
71
+ return await markReadyFlow({
72
+ cwd: here.dir, by: specAuthorFor(here.project.projectId, a.identityName), emit: a.emit, choose: a.choose,
73
+ ...(a.now ? { now: a.now } : {}), ...(a.rand ? { rand: a.rand } : {}),
74
+ });
75
+ }
76
+ catch (e) {
77
+ a.emit(couldNotMarkLine(e));
78
+ return true;
79
+ }
80
+ }
81
+ function couldNotMarkLine(e) {
82
+ const reason = (e instanceof Error ? e.message : String(e)).split('\n')[0];
83
+ return `Could not mark it ready: ${reason}`;
84
+ }
85
+ const CHOOSE_TITLE = 'Which spec is ready for the developer?';
86
+ /** Ink: a question card, one choice per spec title. Cancelled = null. */
87
+ export function inkSpecChooser() {
88
+ return async (titles) => {
89
+ const { askConfirm } = await import('../ui/confirm-request.js');
90
+ const r = await askConfirm({
91
+ id: 'spec-ready', tag: 'QUESTION', title: CHOOSE_TITLE,
92
+ choices: titles.map((t, i) => ({ value: String(i), label: t })), initial: 0,
93
+ });
94
+ if (r.cancelled || r.value === null)
95
+ return null;
96
+ const n = Number(r.value);
97
+ return Number.isInteger(n) ? n : null;
98
+ };
99
+ }
100
+ /** Classic: numbered lines and one typed answer. Anything else = null. */
101
+ export function classicSpecChooser(prompt, log) {
102
+ return async (titles) => {
103
+ log(CHOOSE_TITLE);
104
+ titles.forEach((t, i) => log(` ${i + 1}. ${t}`));
105
+ const n = Number((await prompt(`Choose 1-${titles.length}: `)).trim());
106
+ return Number.isInteger(n) && n >= 1 && n <= titles.length ? n - 1 : null;
107
+ };
108
+ }
@@ -1,6 +1,24 @@
1
1
  import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
2
+ import { resolveSeatKind, seatSignInMessage, isLegacySignIn } from '../licence/seat-kind.js';
2
3
  export async function runStartupAccount(deps, io) {
3
4
  let licenceCheckedAtMs;
5
+ let seat;
6
+ // The seat for this start; false = not confirmed (the caller exits 1).
7
+ const settleSeat = async (fresh, apiKey, authFile) => {
8
+ const r = resolveSeatKind({
9
+ fresh, stored: await deps.readSeatRecord(), apiKey, now: new Date((deps.now ?? Date.now)()), legacySignIn: isLegacySignIn(authFile),
10
+ });
11
+ if (!r.ok) {
12
+ io.closeBrand();
13
+ io.print(seatSignInMessage());
14
+ return false;
15
+ }
16
+ if (r.write)
17
+ await deps.writeSeatRecord(r.write).catch(() => { });
18
+ deps.setSeatKind(r.kind);
19
+ seat = r.kind;
20
+ return true;
21
+ };
4
22
  try {
5
23
  const cfgEarly = await deps.loadConfig();
6
24
  if (cfgEarly.llm.mode === 'managed') {
@@ -14,9 +32,13 @@ export async function runStartupAccount(deps, io) {
14
32
  io.print(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
15
33
  return { exit: 1 };
16
34
  }
35
+ if (!(await settleSeat(meResult, startupKey, authFile)))
36
+ return { exit: 1 };
17
37
  io.notice(formatInfo(`Could not refresh your account (${meResult.error})`));
18
38
  }
19
39
  else {
40
+ if (!(await settleSeat(meResult, startupKey, authFile)))
41
+ return { exit: 1 };
20
42
  io.showProfile(meResult.profile);
21
43
  }
22
44
  }
@@ -31,6 +53,11 @@ export async function runStartupAccount(deps, io) {
31
53
  io.print(licence.message);
32
54
  return { exit: 1 };
33
55
  }
56
+ // The licence check IS the /v1/me call: an ok verdict carries the kind.
57
+ const fresh = licence.verdict === 'ok' && licence.profile
58
+ ? { ok: true, profile: licence.profile, serverDate: null } : null;
59
+ if (!(await settleSeat(fresh, startupKey, await deps.readAuthFile())))
60
+ return { exit: 1 };
34
61
  if (licence.verdict === 'unknown') {
35
62
  io.notice(formatInfo(licence.line));
36
63
  }
@@ -47,5 +74,41 @@ export async function runStartupAccount(deps, io) {
47
74
  }
48
75
  }
49
76
  catch { /* banner is best-effort — never block REPL startup */ }
50
- return licenceCheckedAtMs === undefined ? { exit: null } : { exit: null, licenceCheckedAtMs };
77
+ return {
78
+ exit: null,
79
+ ...(licenceCheckedAtMs === undefined ? {} : { licenceCheckedAtMs }),
80
+ ...(seat === undefined ? {} : { seat }),
81
+ };
82
+ }
83
+ /**
84
+ * Team build Task 5, ruling R7a — start-up resolved no seat but a key exists
85
+ * (managed without auth.json, with a stored key or one pasted just now): one
86
+ * /v1/me for that key (fetchMe sends the CLI's user-agent itself), then the
87
+ * stored record, then the legacy rule (R7b). Null = not confirmed (exit 1).
88
+ * Runs only when start-up made no /v1/me call, so never a second one.
89
+ */
90
+ export async function settleSeatAfterStartup(deps, a) {
91
+ let fresh = null;
92
+ try {
93
+ fresh = await deps.fetchMe({ proxyUrl: a.proxyUrl, apiKey: a.apiKey });
94
+ }
95
+ catch {
96
+ fresh = null;
97
+ }
98
+ let authFile = null;
99
+ try {
100
+ authFile = await deps.readAuthFile();
101
+ }
102
+ catch {
103
+ authFile = null;
104
+ }
105
+ const r = resolveSeatKind({
106
+ fresh, stored: await deps.readSeatRecord(), apiKey: a.apiKey, now: new Date((deps.now ?? Date.now)()), legacySignIn: isLegacySignIn(authFile),
107
+ });
108
+ if (!r.ok)
109
+ return null;
110
+ if (r.write)
111
+ await deps.writeSeatRecord(r.write).catch(() => { });
112
+ deps.setSeatKind(r.kind);
113
+ return r.kind;
51
114
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Team build (spec 2026-10-25) — the developer start screen.
3
+ *
4
+ * When CSPeach starts in a team build folder, it says what sits in the shared
5
+ * `specs/` folder: each spec ready for build with its summary and open
6
+ * questions, and one line for each draft or spec being built. A developer is
7
+ * then offered each ready spec: build it, ask about it first, or later. A
8
+ * functional seat sees the same lines and is never offered a build.
9
+ *
10
+ * Ruling R10: the build folder is the folder CSPeach runs in, never
11
+ * `activeProject()`. Ruling R12: the status record goes to the spec folder.
12
+ * Never blocks start-up: a problem is one line.
13
+ */
14
+ import path from 'node:path';
15
+ const day = (iso) => String(iso ?? '').slice(0, 10);
16
+ export function startScreenLines(specs, _seat) {
17
+ const out = [];
18
+ for (const s of specs) {
19
+ const who = `"${s.title}" — ${s.author}, ${day(s.createdAt)}`;
20
+ if (s.status === 'ready for build') {
21
+ out.push(`Ready to build: ${who}`, ` ${s.summary}`);
22
+ if (s.openQuestions.length) {
23
+ out.push(' Open questions:');
24
+ for (const q of s.openQuestions)
25
+ out.push(` - [${q.severity}] ${q.text}`);
26
+ }
27
+ else {
28
+ out.push(' No open questions.');
29
+ }
30
+ }
31
+ else {
32
+ out.push(`${s.status === 'draft' ? 'Draft' : 'Being built'}: ${who}`);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ const BUILD = 'Build this spec';
38
+ const ASK = 'Ask about it first';
39
+ const LATER = 'Later';
40
+ export const askSentence = (s) => `Tell me about the spec "${s.title}" in specs/${s.slug}/ before we build it.`;
41
+ async function chooseFor(choose, s) {
42
+ const n = s.openQuestions.length;
43
+ const v = await choose({
44
+ title: `Ready to build: "${s.title}"`,
45
+ facts: [`${s.author}, ${day(s.createdAt)}`, n === 0 ? 'No open questions.' : n === 1 ? '1 open question.' : `${n} open questions.`],
46
+ choices: [{ value: 'build', label: BUILD }, { value: 'ask', label: ASK }, { value: 'later', label: LATER }],
47
+ });
48
+ if (v === 'build')
49
+ return { kind: 'build', spec: s };
50
+ if (v === 'ask')
51
+ return { kind: 'ask', spec: s };
52
+ return { kind: 'later' };
53
+ }
54
+ /**
55
+ * One card per ready spec, developers only. Build or Ask submits one command
56
+ * and ends the offer (one turn at a time); Later moves to the next spec.
57
+ * Returns true when a command was submitted.
58
+ */
59
+ export async function offerBuild(a) {
60
+ if (a.seat !== 'developer')
61
+ return false;
62
+ for (const s of a.specs) {
63
+ if (s.status !== 'ready for build')
64
+ continue;
65
+ const c = await chooseFor(a.choose, s);
66
+ if (c.kind === 'later')
67
+ continue;
68
+ if (c.kind === 'ask') {
69
+ a.submit(askSentence(s));
70
+ return true;
71
+ }
72
+ const { chainFromToken } = await import('../lane/chain-token.js');
73
+ const t = await chainFromToken(a.teamDir, s.envelopePath);
74
+ if (!t) {
75
+ a.emit(`We could not start the build from specs/${s.slug}/. Run /abap-app --from @specs/${s.slug}/<file> yourself.`);
76
+ return false;
77
+ }
78
+ const { writeStatusRecord } = await import('../projects/spec-share.js');
79
+ writeStatusRecord(s.specDir, {
80
+ projectId: a.projectId, slug: s.slug, file: path.basename(s.envelopePath),
81
+ status: 'being built', by: a.by, at: (a.now ?? (() => new Date()))().toISOString(),
82
+ });
83
+ // R14: a Stop at the open-questions gate puts it back to ready for build.
84
+ const { noteBeingBuiltThisRun } = await import('./open-questions-gate.js');
85
+ noteBeingBuiltThisRun(s.specDir);
86
+ a.submit(`/abap-app --from ${t.token}`);
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ /**
92
+ * The start-up step: the build folder found by cwd (R10), its specs listed,
93
+ * the offer made. Never throws; at most one line on a problem.
94
+ */
95
+ export async function runStartScreen(a) {
96
+ try {
97
+ const { buildFolderAt } = await import('../commands/team-spec.js');
98
+ const here = buildFolderAt(a.cwd);
99
+ if (!here)
100
+ return { submitted: false };
101
+ const { listSharedSpecs, specAuthorFor } = await import('../projects/spec-share.js');
102
+ const specs = listSharedSpecs(here.dir);
103
+ for (const l of startScreenLines(specs, a.seat))
104
+ a.emit(l);
105
+ const submitted = await offerBuild({
106
+ specs, seat: a.seat, choose: a.choose, teamDir: here.dir, projectId: here.project.projectId,
107
+ by: specAuthorFor(here.project.projectId, a.identityName),
108
+ submit: a.submit, emit: a.emit, ...(a.now ? { now: a.now } : {}),
109
+ });
110
+ return { submitted };
111
+ }
112
+ catch (e) {
113
+ a.emit(`Could not show the team build specs: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
114
+ return { submitted: false };
115
+ }
116
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Team build (spec 2026-10-25 §7.2) — the folder sets itself up when someone
3
+ * types `cspeach`. Nobody types a team command:
4
+ *
5
+ * - a functional seat in a folder that is not a team project gets a team
6
+ * build, with one line. A folder that already holds files asks first
7
+ * (default No), so opening CSPeach in Documents by mistake changes nothing.
8
+ * - a developer is offered a team build once per folder (default No), never
9
+ * in the home folder. The answer is remembered, so a scratch folder sees
10
+ * the question once and nothing changes.
11
+ * - anyone in a team build folder they have not joined is joined, one line.
12
+ * - an old build folder gets the one "build type changed" line.
13
+ * - clean-core and upgrade folders: nothing at all, as today.
14
+ *
15
+ * Runs at start-up only in an interactive run, after the seat is settled.
16
+ * Never throws: a problem is one line, and CSPeach starts as usual.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { TEAM_FILE, activeProject, readProjectFile, } from '../register/store.js';
21
+ import { LEGACY_BUILD_LINE, createBuildProject, isBuildProject, isLegacyBuildFolder } from '../commands/team-build.js';
22
+ import { runTeamCommand } from '../commands/team.js';
23
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
24
+ import { normDir } from '../session/project-match.js';
25
+ import { cspeachRoot } from '../config/paths.js';
26
+ import { writeWhole } from '../register/write-whole.js';
27
+ export const SETUP_LINE = 'Set up as a team build folder. Your specs save here for the developer.';
28
+ export const FILES_QUESTION = 'This folder already has files in it. Set it up as a team build folder?';
29
+ export const NOT_SET_UP_LINE = 'Not set up. Start CSPeach in an empty folder for the team build.';
30
+ export const DEVELOPER_OFFER = 'Set this folder up as a team build (one shared folder for the spec and the build)?';
31
+ export const joinedLine = (name, lead) => `Joined the team build "${name}" (lead ${lead}).`;
32
+ /** What CSPeach itself leaves in a folder: none of it means "this folder holds files". */
33
+ const OURS = new Set(['.cspeach', 'cspeach-out', '.git', '.gitignore']);
34
+ export function folderState(cwd) {
35
+ const dir = path.resolve(cwd);
36
+ if (!fs.existsSync(path.join(dir, TEAM_FILE))) {
37
+ let entries = [];
38
+ try {
39
+ entries = fs.readdirSync(dir);
40
+ }
41
+ catch { /* unreadable folder: treat as empty of files */ }
42
+ return { kind: 'not-team', hasFiles: entries.some((e) => !OURS.has(e)), isHome: resolveProjectFolderSync(dir).kind === 'home' };
43
+ }
44
+ let project;
45
+ try {
46
+ project = readProjectFile(dir);
47
+ }
48
+ catch (e) {
49
+ return { kind: 'unreadable', line: e instanceof Error ? e.message : String(e) };
50
+ }
51
+ if (isBuildProject(project)) {
52
+ const joined = activeProject();
53
+ return { kind: 'build', project, joined: !!joined && joined.projectId === project.projectId && normDir(joined.projectDir) === normDir(dir) };
54
+ }
55
+ if (isLegacyBuildFolder(project))
56
+ return { kind: 'legacy-build' };
57
+ return { kind: 'other-team' };
58
+ }
59
+ const offersFile = () => path.join(cspeachRoot(), 'team-build-offers.json');
60
+ function readOffered() {
61
+ try {
62
+ const raw = fs.readFileSync(offersFile(), 'utf8');
63
+ const j = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
64
+ return Array.isArray(j?.offered) ? j.offered.filter((x) => typeof x === 'string') : [];
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ }
70
+ /** `<CSPEACH_HOME>/team-build-offers.json`: `{ offered: [normDir, …] }`. */
71
+ export function fileOfferMemory() {
72
+ return {
73
+ wasOffered: async (dir) => readOffered().includes(normDir(dir)),
74
+ markOffered: async (dir) => {
75
+ const offered = readOffered();
76
+ const k = normDir(dir);
77
+ if (offered.includes(k))
78
+ return;
79
+ writeWhole(offersFile(), `${JSON.stringify({ offered: [...offered, k] }, null, 2)}\n`);
80
+ },
81
+ };
82
+ }
83
+ /**
84
+ * Ruling R10: CSPeach joins a team build by itself only when no project is
85
+ * active, or the active one is a team build too. A clean-core or upgrade
86
+ * project this laptop works on stays the active one: the journey and the team
87
+ * tools read `activeProject()`. An active project we cannot read is left
88
+ * alone as well.
89
+ */
90
+ export function mayAutoJoin() {
91
+ try {
92
+ const active = activeProject();
93
+ if (!active)
94
+ return true;
95
+ return isBuildProject(readProjectFile(active.projectDir));
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ /** Join through the same route as `cspeach team join`, quietly. Failure: its own line(s). */
102
+ async function joinHere(dir, a) {
103
+ const said = [];
104
+ const deps = {
105
+ print: (l) => said.push(l),
106
+ identity: () => ({ name: a.identity.name }),
107
+ ...(a.now ? { now: a.now } : {}),
108
+ };
109
+ const code = await runTeamCommand(['join', dir, '--outbox', dir], deps);
110
+ if (code !== 0)
111
+ for (const l of said)
112
+ a.emit(l);
113
+ return code === 0;
114
+ }
115
+ async function setUp(dir, a) {
116
+ const lead = a.identity.account ? { name: a.identity.name, account: a.identity.account } : { name: a.identity.name };
117
+ const project = createBuildProject(dir, {
118
+ name: path.basename(dir) || 'Team build',
119
+ lead,
120
+ now: (a.now ?? (() => new Date()))(),
121
+ ...(a.rand ? { rand: a.rand } : {}),
122
+ });
123
+ if (mayAutoJoin() && !(await joinHere(dir, a)))
124
+ return null;
125
+ a.emit(SETUP_LINE);
126
+ return project;
127
+ }
128
+ export async function startTeamBuildFolder(a) {
129
+ const dir = path.resolve(a.cwd);
130
+ try {
131
+ const state = folderState(dir);
132
+ switch (state.kind) {
133
+ case 'other-team': return { project: null };
134
+ case 'unreadable':
135
+ a.emit(state.line);
136
+ return { project: null };
137
+ case 'legacy-build':
138
+ a.emit(LEGACY_BUILD_LINE);
139
+ return { project: null };
140
+ case 'build': {
141
+ if (state.joined || !mayAutoJoin())
142
+ return { project: state.project };
143
+ if (!(await joinHere(dir, a)))
144
+ return { project: null };
145
+ a.emit(joinedLine(state.project.name, state.project.lead.name));
146
+ return { project: state.project };
147
+ }
148
+ case 'not-team': {
149
+ if (a.seat === 'functional') {
150
+ // The home folder is never a team build: it is where CSPeach keeps its own files.
151
+ if (state.isHome) {
152
+ a.emit(NOT_SET_UP_LINE);
153
+ return { project: null };
154
+ }
155
+ if (state.hasFiles && (await a.confirm(FILES_QUESTION, { default: 'n' })) !== 'y') {
156
+ a.emit(NOT_SET_UP_LINE);
157
+ return { project: null };
158
+ }
159
+ return { project: await setUp(dir, a) };
160
+ }
161
+ if (state.isHome || (await a.memory.wasOffered(dir)))
162
+ return { project: null };
163
+ const answer = await a.confirm(DEVELOPER_OFFER, { default: 'n' });
164
+ try {
165
+ await a.memory.markOffered(dir);
166
+ }
167
+ catch { /* a failed offer write says nothing */ }
168
+ if (answer !== 'y')
169
+ return { project: null };
170
+ return { project: await setUp(dir, a) };
171
+ }
172
+ }
173
+ }
174
+ catch (e) {
175
+ a.emit(e instanceof Error ? e.message.split('\n')[0] : String(e));
176
+ return { project: null };
177
+ }
178
+ }