@cspeach/cli 1.1.53 → 1.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/agent/loop.js +90 -7
  2. package/dist/agent/tool-dispatch.js +11 -0
  3. package/dist/auth/auth-file.js +3 -0
  4. package/dist/auth/key-resolver.js +8 -3
  5. package/dist/auth/me.js +6 -2
  6. package/dist/cli.js +25 -0
  7. package/dist/commands/login.js +22 -4
  8. package/dist/commands/plan-reground.js +5 -0
  9. package/dist/commands/sap-link.js +14 -1
  10. package/dist/commands/team-build-help.js +19 -0
  11. package/dist/commands/team-build.js +97 -0
  12. package/dist/commands/team-plan.js +2 -2
  13. package/dist/commands/team-spec.js +107 -0
  14. package/dist/commands/team.js +111 -4
  15. package/dist/journey/chain.js +8 -0
  16. package/dist/journey/clean-core.js +2 -2
  17. package/dist/journey/rebuild.js +8 -0
  18. package/dist/journey/run.js +9 -0
  19. package/dist/lane/ready-spec.js +32 -0
  20. package/dist/licence/account-name.js +31 -0
  21. package/dist/licence/paths.js +5 -0
  22. package/dist/licence/seat-kind.js +110 -0
  23. package/dist/licence/seat-state.js +4 -0
  24. package/dist/licence/terminal-seat.js +43 -0
  25. package/dist/licence/turn-gate.js +4 -0
  26. package/dist/one-shot.js +32 -0
  27. package/dist/projects/answer-blockers.js +31 -7
  28. package/dist/projects/blocker-answers-note.js +1 -1
  29. package/dist/projects/save-command.js +37 -2
  30. package/dist/projects/saved-lines.js +19 -17
  31. package/dist/projects/spec-share.js +270 -0
  32. package/dist/projects/team-build-context.js +49 -0
  33. package/dist/register/picture.js +2 -2
  34. package/dist/register/store.js +19 -0
  35. package/dist/renderer/routing-line.js +11 -0
  36. package/dist/repl/builtin-commands.js +4 -3
  37. package/dist/repl/file-picker.js +52 -5
  38. package/dist/repl/mode-cycle.js +12 -0
  39. package/dist/repl/open-questions-gate.js +193 -0
  40. package/dist/repl/ready-phrase.js +108 -0
  41. package/dist/repl/startup-account.js +64 -1
  42. package/dist/repl/team-build-screen.js +124 -0
  43. package/dist/repl/team-build-start.js +179 -0
  44. package/dist/repl.js +227 -10
  45. package/dist/sap-link/policy.js +4 -0
  46. package/dist/sap-link/registry.js +9 -3
  47. package/dist/sap-link/server.js +4 -1
  48. package/dist/tools/_team-work-path.js +25 -1
  49. package/dist/tools/cap/sap-link-tool.js +2 -0
  50. package/dist/tools/filesystem/file-edit.js +2 -2
  51. package/dist/tools/filesystem/file-write.js +2 -2
  52. package/dist/tools/hand-off.js +16 -1
  53. package/dist/tools/project/spec_answers_record.js +62 -9
  54. package/dist/tools/project/team_findings.js +2 -2
  55. package/dist/tools/seat-gate.js +41 -0
  56. package/dist/ui/when-listening.js +34 -0
  57. package/dist/ui/widgets/files-card.js +19 -1
  58. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * UX:
5
5
  * 1. Search-as-you-type list of files (filename + artefactType + version)
6
6
  * 2. Action menu tailored to the picked artefactType — pick one of:
7
- * spec-gap → --status, --from /abap-design
7
+ * spec-gap → answer the questions (viewer steps), --from /abap-design, --status
8
8
  * design → --status, --from /abap-estimate
9
9
  * estimate → --status only
10
10
  * unknown → all --status variants
@@ -16,10 +16,15 @@
16
16
  * slash-picker.ts so cancellation in either step doesn't drop the user
17
17
  * back to the parent shell.
18
18
  */
19
+ import path from 'node:path';
19
20
  import { search, select } from '@inquirer/prompts';
20
21
  import { withInquirer } from './inquirer-guard.js';
21
22
  import { listProjectFiles, detectDesignShape } from '../projects/workspace.js';
23
+ import { editorLabel } from '../projects/saved-lines.js';
24
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
22
25
  const CANCEL_VALUE = '__cspeach_cancel__';
26
+ /** Spec gap "answer the questions" (2026-10-27): prints the viewer steps, runs nothing. */
27
+ const ANSWER_VALUE = '__cspeach_answer__';
23
28
  /**
24
29
  * Compute the menu of valid actions for an envelope of a given
25
30
  * artefactType. Order: most-common first.
@@ -35,7 +40,8 @@ const CANCEL_VALUE = '__cspeach_cancel__';
35
40
  function envelopeActionsFor(type, designShape = 'unknown') {
36
41
  if (type === 'spec-gap') {
37
42
  return [
38
- { label: '/abap-design --from (promote → design)', command: '/abap-design --from' },
43
+ { label: 'Answer the questions (open in cspeach.dev/edit)', command: ANSWER_VALUE },
44
+ { label: '/abap-design --from (use the answers → design)', command: '/abap-design --from' },
39
45
  { label: '/abap-spec-gap --status (inspect)', command: '/abap-spec-gap --status' },
40
46
  ];
41
47
  }
@@ -153,6 +159,46 @@ function textActionsFor() {
153
159
  * then dumped the full file list every time the user cancelled. This
154
160
  * tagged result removes the conflation.
155
161
  */
162
+ /**
163
+ * The steps printed for "Answer the questions" (2026-10-27): a standalone
164
+ * customer did not know how to enter answers. The viewer's own button names
165
+ * (Pick folder, Save back to folder, Download updated file).
166
+ */
167
+ export function answerStepsText(file, workDir = workFolder()) {
168
+ const full = path.resolve(file.path);
169
+ // A file from an earlier location is read-only: its answers go to today's folder.
170
+ if (file.earlier) {
171
+ return [
172
+ 'To answer the questions:',
173
+ ` 1. Open ${editorLabel()} and drop ${full} on the page.`,
174
+ ' 2. Type each answer and mark it answered.',
175
+ ` 3. Choose Download updated file and put that file in ${workDir}`,
176
+ 'Then type /files and pick the new version to go on from your answers.',
177
+ ].join('\n');
178
+ }
179
+ const dir = path.dirname(full);
180
+ return [
181
+ 'To answer the questions:',
182
+ ` 1. Open ${editorLabel()}, choose Pick folder and pick ${dir}`,
183
+ ` 2. Open ${file.filename}, type each answer and mark it answered.`,
184
+ ' 3. Choose Save back to folder. It saves a new version next to this one.',
185
+ 'Then type /files and pick the new version to go on from your answers.',
186
+ `No Pick folder button (it needs Edge or Chrome)? Drop ${full} on the page instead, choose Download updated file and put that file in ${dir}.`,
187
+ ].join('\n');
188
+ }
189
+ /** Picked action → result: the answer action prints steps, every other one is a command. */
190
+ function resultFor(action, file) {
191
+ if (action === ANSWER_VALUE)
192
+ return { kind: 'info', text: answerStepsText(file) };
193
+ return { kind: 'command', command: `${action} @${file.filename}` };
194
+ }
195
+ /**
196
+ * This project's file folder, where saves go — not a file's folder: the list
197
+ * can hold files from earlier locations (marked `earlier`) after today's.
198
+ */
199
+ function workFolder() {
200
+ return path.resolve(resolveProjectFolderSync(process.cwd()).workDir);
201
+ }
156
202
  /** Action menu for a picked file (shared by the inquirer and Ink paths). */
157
203
  function actionsForFile(file) {
158
204
  const designShape = file.artefactType === 'design' ? detectDesignShape(file.path) : 'unknown';
@@ -185,7 +231,7 @@ export async function openFilePicker(opts = {}) {
185
231
  return await withInquirer(async () => {
186
232
  // Step 1 — pick a file.
187
233
  const fileResult = await search({
188
- message: 'Pick a project file',
234
+ message: `Pick a project file (in ${workFolder()})`,
189
235
  source: async (input) => {
190
236
  const filter = (input ?? '').toLowerCase();
191
237
  const filtered = filter.length === 0
@@ -223,7 +269,7 @@ export async function openFilePicker(opts = {}) {
223
269
  });
224
270
  if (action === CANCEL_VALUE)
225
271
  return { kind: 'cancelled' };
226
- return { kind: 'command', command: `${action} @${pickedFile.filename}` };
272
+ return resultFor(action, pickedFile);
227
273
  });
228
274
  }
229
275
  catch {
@@ -238,6 +284,7 @@ async function openFilePickerInk(files) {
238
284
  const picked = await withFocusClaim(() => filesCardEmitter.requestPick({
239
285
  id: 'files',
240
286
  title: 'Pick a project file',
287
+ folder: workFolder(),
241
288
  items: files.map((f) => ({ value: f.filename, label: f.filename, detail: `${f.artefactType} v${f.version ?? '?'}` })),
242
289
  }));
243
290
  if (picked.value == null)
@@ -257,5 +304,5 @@ async function openFilePickerInk(files) {
257
304
  }, { trace: false });
258
305
  if (res.value == null || res.value === CANCEL_VALUE)
259
306
  return { kind: 'cancelled' };
260
- return { kind: 'command', command: `${res.value} @${pickedFile.filename}` };
307
+ return resultFor(res.value, pickedFile);
261
308
  }
@@ -1,4 +1,5 @@
1
1
  import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
2
+ import { isFunctionalSeat } from '../licence/seat-state.js';
2
3
  /**
3
4
  * Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
4
5
  *
@@ -29,14 +30,23 @@ export function getSessionWriteMode() {
29
30
  }
30
31
  /** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
31
32
  export function getEffectiveWriteMode(cfgMode, role) {
33
+ // Team build Task 6 — a functional seat is pinned at advisory whatever the
34
+ // config, the role or a session override says.
35
+ if (isFunctionalSeat())
36
+ return 'advisory-only';
32
37
  return clampToCeiling(sessionWriteMode ?? cfgMode, role);
33
38
  }
39
+ /** Team build Task 6 — the /mode answer on a functional seat, for any argument. */
40
+ export const SEAT_MODE_LINE = 'mode stays advisory on this seat — it reads SAP and never changes it';
34
41
  /**
35
42
  * Cycle to the next mode the role allows (wraps). Sets the session override
36
43
  * so the new mode takes effect immediately. Returns the new mode AND whether
37
44
  * the requested step was capped (for the refusal line).
38
45
  */
39
46
  export function cycleWriteMode(cfgMode, role) {
47
+ // Functional seat: pinned; the session override is left alone.
48
+ if (isFunctionalSeat())
49
+ return { mode: 'advisory-only', capped: true };
40
50
  const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
41
51
  // Modes at or below the ceiling, in cycling order (most→least restricted).
42
52
  const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
@@ -77,6 +87,8 @@ function refusalLine(clamped, role, alias) {
77
87
  * mode (caller pushes it into the SapStateStore so the statusline is live).
78
88
  */
79
89
  export function runModeCommand(arg, cfgMode, role, alias) {
90
+ if (isFunctionalSeat())
91
+ return { line: SEAT_MODE_LINE, mode: 'advisory-only' };
80
92
  const prev = getEffectiveWriteMode(cfgMode, role);
81
93
  const normalized = arg?.trim().toLowerCase() ?? '';
82
94
  if (normalized === '') {
@@ -0,0 +1,193 @@
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
+ /** F10 — `/abap-app @…` with no --from. */
17
+ const TYPED_SPEC = /^\/abap-app\s+@/;
18
+ export const ANSWER = 'Answer them now';
19
+ export const GO_AHEAD = 'Go ahead with them open';
20
+ export const STOP = 'Stop';
21
+ export const STOPPED_LINE = 'Stopped. The spec stays ready for the developer.';
22
+ /**
23
+ * R14 — the specs this run marked "being built" from the start screen. A Stop
24
+ * at the gate puts such a spec back to "ready for build" (a new record, same
25
+ * `by`), so the Stopped line is true and the spec is offered again.
26
+ */
27
+ const markedThisRun = new Set();
28
+ const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
29
+ export function noteBeingBuiltThisRun(specDir) { markedThisRun.add(dirKey(specDir)); }
30
+ function openQuestions(env) {
31
+ if (env.artefactType !== 'spec-gap')
32
+ return [];
33
+ const open = new Set(env.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
34
+ const qs = [];
35
+ for (const s of env.content.sections)
36
+ for (const g of s.gaps)
37
+ if (open.has(g.id))
38
+ qs.push({ id: g.id, severity: g.severity, text: g.text });
39
+ return [...qs.filter((q) => q.severity === 'BLOCKER'), ...qs.filter((q) => q.severity !== 'BLOCKER')];
40
+ }
41
+ /** The start-up card (Ink or classic) as the gate's card: a choice index, or null. */
42
+ export function gateChooser(choose) {
43
+ return async (q) => {
44
+ const v = await choose({ title: q.title, facts: q.facts, choices: q.choices.map((label, i) => ({ value: String(i), label })) });
45
+ if (v === null)
46
+ return null;
47
+ const n = Number(v);
48
+ return Number.isInteger(n) ? n : null;
49
+ };
50
+ }
51
+ const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
52
+ export async function openQuestionsGate(a) {
53
+ // F10 (2026-10-29): a typed `/abap-app @<saved spec-gap>` (no --from) is
54
+ // the same request — the spec is already analysed — so it goes to the lane
55
+ // step as `--from`, never back through step 1. Anything else typed with @
56
+ // (a document, a Markdown copy) is left for step 1, as before.
57
+ let command = a.command;
58
+ let fromPath;
59
+ if (PREFIX.test(command)) {
60
+ const { parseFromFlag } = await import('../skills/promotion-dispatch.js');
61
+ fromPath = parseFromFlag(command).fromPath ?? undefined;
62
+ }
63
+ else if (TYPED_SPEC.test(command)) {
64
+ const { extractSpecToken, cleanSpecToken } = await import('../lane/app-chain.js');
65
+ const raw = extractSpecToken(command);
66
+ const tok = raw === undefined ? '' : cleanSpecToken(raw);
67
+ if (!tok.endsWith('.cspeach.json'))
68
+ return null;
69
+ fromPath = tok;
70
+ // Final review M3: words typed after the spec ("use RAP") ride along.
71
+ const rest = /^\/abap-app\s+@(?:"[^"\r\n]*"|'[^'\r\n]*'|\S+)\s*([\s\S]*)$/.exec(command)?.[1]?.trim() ?? '';
72
+ command = `/abap-app --from @${/\s/.test(tok) ? `"${tok}"` : tok}${rest ? ` ${rest}` : ''}`;
73
+ }
74
+ if (!fromPath)
75
+ return null;
76
+ const { buildFolderAt } = await import('../commands/team-spec.js');
77
+ const here = buildFolderAt(a.cwd);
78
+ if (!here)
79
+ return null;
80
+ const { resolveAtTokenAsync } = await import('../projects/workspace.js');
81
+ let abs;
82
+ try {
83
+ const r = await resolveAtTokenAsync(fromPath, a.cwd);
84
+ if (r.kind !== 'path')
85
+ return null;
86
+ abs = path.resolve(a.cwd, r.path);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ const share = await import('../projects/spec-share.js');
92
+ if (!share.isSharedSpecPath(here.dir, abs) || !abs.endsWith('.cspeach.json'))
93
+ return null;
94
+ const { readProjectFile } = await import('../projects/status.js');
95
+ const specDir = path.dirname(abs);
96
+ const file = path.basename(abs);
97
+ // R11: only the answers recorded for this envelope.
98
+ const read = (p) => share.composeSpec(readProjectFile(p), share.readAnswers(path.dirname(p)).filter((x) => x.file === path.basename(p)));
99
+ let env;
100
+ try {
101
+ env = read(abs);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (env.artefactType !== 'spec-gap')
107
+ return null;
108
+ // Final review F5: every return that lets the build go on forgets this run's
109
+ // "being built" mark, so a later hand-typed Stop on the same spec puts nothing back.
110
+ // F10 — the build really starts here: a spec still "ready for build" (typed
111
+ // by hand, or handed off from the conversation) is recorded "being built" by
112
+ // this developer. The start screen records it itself, before the gate.
113
+ const markBeingBuilt = () => {
114
+ try {
115
+ if (share.readSpecStatus(specDir).current !== 'ready for build')
116
+ return;
117
+ share.writeStatusRecord(specDir, {
118
+ projectId: here.project.projectId, slug: path.basename(specDir), file, status: 'being built',
119
+ by: share.specAuthorFor(here.project.projectId, a.identity.name),
120
+ at: (a.now ?? (() => new Date().toISOString()))(),
121
+ });
122
+ }
123
+ catch { /* the build still goes on */ }
124
+ };
125
+ const goOn = (r) => { markedThisRun.delete(dirKey(specDir)); markBeingBuilt(); return r; };
126
+ const carried = (qs) => goOn({ command: `${command}\nOpen questions carried as assumptions:\n${qs.map((q) => `- ${q.id}: ${q.text}`).join('\n')}` });
127
+ for (;;) {
128
+ const qs = openQuestions(env);
129
+ if (!qs.length)
130
+ return goOn({ command });
131
+ if (a.headless)
132
+ return carried(qs);
133
+ for (const q of qs)
134
+ a.emit(` - [${q.severity}] ${q.text}`);
135
+ // Only blockers can be answered here: with none open, the card leaves that choice out.
136
+ const choices = qs.some((q) => q.severity === 'BLOCKER') ? [ANSWER, GO_AHEAD, STOP] : [GO_AHEAD, STOP];
137
+ const pick = await a.choose({ title: cardTitle(qs.length), facts: [], choices });
138
+ const label = pick === null ? undefined : choices[pick];
139
+ if (label === GO_AHEAD)
140
+ return carried(qs);
141
+ if (label !== ANSWER) {
142
+ putBackReady(share, specDir, a.now);
143
+ return { stop: true };
144
+ }
145
+ const { offerAnswerBlockers } = await import('../projects/answer-blockers.js');
146
+ await offerAnswerBlockers({
147
+ path: abs,
148
+ identity: a.identity,
149
+ prompt: a.prompt ?? (async () => ''),
150
+ // "Answer them now" was the yes: the flow does not ask it again.
151
+ confirm: async () => 'y',
152
+ ...(a.answer ? { choose: a.answer } : {}),
153
+ log: (...lines) => lines.forEach((l) => a.emit(l)),
154
+ ...(a.now ? { now: a.now } : {}),
155
+ readEnvelope: read,
156
+ recordAnswer: (r) => {
157
+ share.writeAnswerRecord(specDir, {
158
+ projectId: here.project.projectId, slug: path.basename(specDir), file,
159
+ gapId: r.gapId, answer: r.answer, by: share.specAuthorFor(here.project.projectId, r.identity.name), at: r.at,
160
+ });
161
+ },
162
+ });
163
+ try {
164
+ env = read(abs);
165
+ }
166
+ catch {
167
+ return goOn({ command });
168
+ }
169
+ }
170
+ }
171
+ /**
172
+ * R14 — Stop on a spec this run marked "being built": add a "ready for build"
173
+ * record back (records stay add-only), with the same `by`. A spec typed by
174
+ * hand, or marked by an earlier run, is left as it is.
175
+ */
176
+ function putBackReady(share, specDir, now) {
177
+ const k = dirKey(specDir);
178
+ if (!markedThisRun.has(k))
179
+ return;
180
+ try {
181
+ const st = share.readSpecStatus(specDir);
182
+ if (st.current !== 'being built' || !st.record)
183
+ return;
184
+ const r = st.record;
185
+ // Later than the "being built" record: at the same instant the later step would win.
186
+ const t = Math.max(Date.parse((now ?? (() => new Date().toISOString()))()), Date.parse(r.at) + 1);
187
+ share.writeStatusRecord(specDir, {
188
+ projectId: r.projectId, slug: r.slug, file: r.file, status: 'ready for build', by: r.by, at: new Date(t).toISOString(),
189
+ });
190
+ markedThisRun.delete(k);
191
+ }
192
+ catch { /* the Stop still stops */ }
193
+ }
@@ -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,124 @@
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
+ // Owner, 2026-10-29: an answer someone else replaced is said, so the
35
+ // spec's author learns the developer overrode it.
36
+ const changed = s.changedAnswers ?? [];
37
+ if (changed.length) {
38
+ out.push(' Answers changed by someone else:');
39
+ for (const c of changed)
40
+ out.push(` - "${c.question}" now "${c.answer}" (${c.by}, ${day(c.at)}), was "${c.was}" (${c.wasBy})`);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ const BUILD = 'Build this spec';
46
+ const ASK = 'Ask about it first';
47
+ const LATER = 'Later';
48
+ export const askSentence = (s) => `Tell me about the spec "${s.title}" in specs/${s.slug}/ before we build it.`;
49
+ async function chooseFor(choose, s) {
50
+ const n = s.openQuestions.length;
51
+ const v = await choose({
52
+ title: `Ready to build: "${s.title}"`,
53
+ facts: [`${s.author}, ${day(s.createdAt)}`, n === 0 ? 'No open questions.' : n === 1 ? '1 open question.' : `${n} open questions.`],
54
+ choices: [{ value: 'build', label: BUILD }, { value: 'ask', label: ASK }, { value: 'later', label: LATER }],
55
+ });
56
+ if (v === 'build')
57
+ return { kind: 'build', spec: s };
58
+ if (v === 'ask')
59
+ return { kind: 'ask', spec: s };
60
+ return { kind: 'later' };
61
+ }
62
+ /**
63
+ * One card per ready spec, developers only. Build or Ask submits one command
64
+ * and ends the offer (one turn at a time); Later moves to the next spec.
65
+ * Returns true when a command was submitted.
66
+ */
67
+ export async function offerBuild(a) {
68
+ if (a.seat !== 'developer')
69
+ return false;
70
+ for (const s of a.specs) {
71
+ if (s.status !== 'ready for build')
72
+ continue;
73
+ const c = await chooseFor(a.choose, s);
74
+ if (c.kind === 'later')
75
+ continue;
76
+ if (c.kind === 'ask') {
77
+ a.submit(askSentence(s));
78
+ return true;
79
+ }
80
+ const { chainFromToken } = await import('../lane/chain-token.js');
81
+ const t = await chainFromToken(a.teamDir, s.envelopePath);
82
+ if (!t) {
83
+ a.emit(`We could not start the build from specs/${s.slug}/. Run /abap-app --from @specs/${s.slug}/<file> yourself.`);
84
+ return false;
85
+ }
86
+ const { writeStatusRecord } = await import('../projects/spec-share.js');
87
+ writeStatusRecord(s.specDir, {
88
+ projectId: a.projectId, slug: s.slug, file: path.basename(s.envelopePath),
89
+ status: 'being built', by: a.by, at: (a.now ?? (() => new Date()))().toISOString(),
90
+ });
91
+ // R14: a Stop at the open-questions gate puts it back to ready for build.
92
+ const { noteBeingBuiltThisRun } = await import('./open-questions-gate.js');
93
+ noteBeingBuiltThisRun(s.specDir);
94
+ a.submit(`/abap-app --from ${t.token}`);
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ /**
100
+ * The start-up step: the build folder found by cwd (R10), its specs listed,
101
+ * the offer made. Never throws; at most one line on a problem.
102
+ */
103
+ export async function runStartScreen(a) {
104
+ try {
105
+ const { buildFolderAt } = await import('../commands/team-spec.js');
106
+ const here = buildFolderAt(a.cwd);
107
+ if (!here)
108
+ return { submitted: false };
109
+ const { listSharedSpecs, specAuthorFor } = await import('../projects/spec-share.js');
110
+ const specs = listSharedSpecs(here.dir);
111
+ for (const l of startScreenLines(specs, a.seat))
112
+ a.emit(l);
113
+ const submitted = await offerBuild({
114
+ specs, seat: a.seat, choose: a.choose, teamDir: here.dir, projectId: here.project.projectId,
115
+ by: specAuthorFor(here.project.projectId, a.identityName),
116
+ submit: a.submit, emit: a.emit, ...(a.now ? { now: a.now } : {}),
117
+ });
118
+ return { submitted };
119
+ }
120
+ catch (e) {
121
+ a.emit(`Could not show the team build specs: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
122
+ return { submitted: false };
123
+ }
124
+ }