@cspeach/cli 1.1.54 → 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.
@@ -4,6 +4,7 @@ import { dispatchTool } from './tool-dispatch.js';
4
4
  import { listTools, getTool, isStandalone, toolsForContext, toAnthropicTools } from '../tools/index.js';
5
5
  import { toolsForSeat } from '../tools/seat-gate.js';
6
6
  import { getSeatKind } from '../licence/seat-state.js';
7
+ import { teamBuildContextBlock } from '../projects/team-build-context.js';
7
8
  import { saveSession } from '../session/store.js';
8
9
  import { tagSessionOnSave } from '../session/project-match.js';
9
10
  import { recordCompletedToolCall } from '../session/pending.js';
@@ -105,6 +106,8 @@ import { ChatUnavailableError } from './chat-unavailable.js';
105
106
  *
106
107
  * Exported for unit testing.
107
108
  */
109
+ /** F2 — the read-only seat, told to the model so it never guesses a cause. */
110
+ export const FUNCTIONAL_SEAT_NOTE = '<seat>This is a read-only seat (functional): CSPeach reads the SAP system and never changes it, in every mode; restarting or changing settings does not change that, so never suggest restarting or enabling writes. When the person asks for a change, say what would change and offer to write it up as a request for a developer (in a team build: a spec).</seat>';
108
111
  export function getAuthorIdentity() {
109
112
  const name = process.env.CSPEACH_AUTHOR_NAME ??
110
113
  process.env.USER ??
@@ -938,7 +941,34 @@ export async function runTurn(params) {
938
941
  }
939
942
  else {
940
943
  clearAppFlow(session);
944
+ // F9c (2026-10-29): routed here from plain words ("let's start building
945
+ // it") in a team build folder with a ready spec — build that spec. The
946
+ // command is queued, so it passes the open-questions gate like a typed
947
+ // one; no spec-gap rerun on the sentence.
948
+ if (params.explicitSlash !== true && extractSpecToken(params.userMessage) === undefined && params.ctx.pendingDispatch) {
949
+ const { readySpecFor, notReadyLine } = await import('../lane/ready-spec.js');
950
+ const picked = await readySpecFor(params.ctx.cwd ?? process.cwd(), params.userMessage);
951
+ if (picked && 'notReady' in picked) {
952
+ emit(notReadyLine(picked.notReady));
953
+ return;
954
+ }
955
+ if (picked && 'ask' in picked) {
956
+ emit(`More than one spec here is ready to build: ${picked.ask.join(', ')}. Say which one.`);
957
+ return;
958
+ }
959
+ if (picked) {
960
+ emit(`Building the ready spec "${picked.title}".`);
961
+ params.ctx.pendingDispatch.set(picked.command, { by: 'app' });
962
+ return;
963
+ }
964
+ }
941
965
  emit(chalk.yellow(spec.line));
966
+ // F9b (2026-10-29 live run): a slash command — typed, or queued by a
967
+ // hand-off — that names no usable spec ends here. Running on as
968
+ // spec-gap let the model improvise a build off the guided route. A
969
+ // request routed from plain words still gets the spec-gap check.
970
+ if (params.explicitSlash === true)
971
+ return;
942
972
  }
943
973
  params = { ...params, skill: 'abap-spec-gap' };
944
974
  }
@@ -1169,7 +1199,15 @@ ${imageExpansion.error}`));
1169
1199
  const blockerNote = mainTurn && session.blockerAnswersNote
1170
1200
  ? `<blocker_answers>\n${session.blockerAnswersNote}\n</blocker_answers>`
1171
1201
  : '';
1172
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine]);
1202
+ // F5 (2026-10-29 live run) — in a team build folder the model is told the
1203
+ // shared specs and the route for each request (hand off), every main turn.
1204
+ // Anywhere else: '' (unchanged).
1205
+ const teamBuildNote = mainTurn && isChatSkill(params.skill) ? teamBuildContextBlock(params.ctx.cwd ?? process.cwd(), getSeatKind()) : '';
1206
+ // F2 (2026-10-29 live run): a functional seat sees no write tools; told
1207
+ // nothing, the model guessed "restart CSPeach with writes enabled". Every
1208
+ // main turn on that seat says why, and the route for a change.
1209
+ const seatNote = mainTurn && getSeatKind() === 'functional' ? FUNCTIONAL_SEAT_NOTE : '';
1210
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
1173
1211
  // Deterministically extract structured facts from the user's prompt
1174
1212
  // (package, transport, object names, environment hint, create intent)
1175
1213
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -1185,7 +1223,7 @@ ${imageExpansion.error}`));
1185
1223
  // Final batch 1 — a caller-enriched message still gets the switch note,
1186
1224
  // inside its own <session_context> block.
1187
1225
  // Stage 1A — the <playbook> line rides the same way ('' ⇒ unchanged).
1188
- const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine]);
1226
+ const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
1189
1227
  const enriched = userMessageForLLM.startsWith('<session_context>')
1190
1228
  ? (callerNotes ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${callerNotes}`) : userMessageForLLM)
1191
1229
  : enrichUserMessage(userMessageForLLM, sessionContextBlock);
package/dist/cli.js CHANGED
@@ -225,9 +225,12 @@ export async function main(argv) {
225
225
  if (args[0] === 'team') {
226
226
  const { runTeamCommand } = await import('./commands/team.js');
227
227
  const { getAuthorIdentity } = await import('./agent/loop.js');
228
+ const { signedInAccount } = await import('./licence/account-name.js');
228
229
  return runTeamCommand(args.slice(1), {
229
230
  print: (s) => console.log(s),
230
231
  identity: () => getAuthorIdentity(),
232
+ // S2 — `cspeach team join` of a team BUILD names the person by account.
233
+ account: () => signedInAccount(),
231
234
  noSap,
232
235
  // Standalone: no connection is offered at all, so nothing in `team` can reach for one.
233
236
  connect: noSap ? undefined : async (alias) => {
@@ -129,6 +129,28 @@ function matches(pattern, value) {
129
129
  const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
130
130
  return re.test(value);
131
131
  }
132
+ /** Verbs that choose or leave a project themselves: no folder note. */
133
+ const FOLDER_NOTE_SKIP = new Set(['init', 'start', 'join', 'leave', 'help', '--help', '-h']);
134
+ /**
135
+ * F11 — one line when `cwd` holds a team project other than the active one.
136
+ * Null when it matches, when the folder is no team project, or on any read
137
+ * problem (the verb then runs exactly as before).
138
+ */
139
+ export function folderMismatchLine(cwd) {
140
+ try {
141
+ if (!fs.existsSync(path.join(cwd, TEAM_FILE)))
142
+ return null;
143
+ const here = readProjectFile(cwd);
144
+ const active = activeProject();
145
+ if (!active || active.projectId === here.projectId)
146
+ return null;
147
+ const activeName = readProjectFile(active.projectDir).name;
148
+ return `This folder is the team project "${here.name}", but CSPeach is recording against "${activeName}". To work on "${here.name}": cspeach team join .`;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
132
154
  export async function runTeamCommand(args, deps) {
133
155
  const [sub, ...rest] = args;
134
156
  try {
@@ -148,6 +170,14 @@ export async function runTeamCommand(args, deps) {
148
170
  return 1;
149
171
  }
150
172
  }
173
+ // F11 (2026-10-29 live run): verbs follow the active project (R10). When
174
+ // the folder they run in is a different team project, say so first — a
175
+ // status in the ZTEST_LAS folder silently reported another project.
176
+ if (sub && !FOLDER_NOTE_SKIP.has(sub)) {
177
+ const note = folderMismatchLine((deps.cwd ?? (() => process.cwd()))());
178
+ if (note)
179
+ deps.print(note);
180
+ }
151
181
  // A team build folder has no baseline: the clean-core verbs are refused in one line.
152
182
  if (sub && CLEAN_CORE_ONLY_VERBS.includes(sub) && activeIsBuild()) {
153
183
  deps.print(buildVerbRefusal(sub));
@@ -756,6 +786,13 @@ function join(args, deps, asName) {
756
786
  const state = loadState();
757
787
  if (opts.as !== undefined && asName === undefined)
758
788
  asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
789
+ // F4/F8 — a team build is named by the signed-in account, once (a name
790
+ // already kept for this project stays).
791
+ if (asName === undefined && isBuildProject(project) && state.projects[project.projectId]?.name === undefined) {
792
+ const account = deps.account?.()?.trim();
793
+ if (account)
794
+ asName = account;
795
+ }
759
796
  const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
760
797
  // A name given once is kept: joining again must not turn the lead back into
761
798
  // whatever the machine calls them.
@@ -0,0 +1,32 @@
1
+ export async function readySpecFor(cwd, request) {
2
+ try {
3
+ const { buildFolderAt } = await import('../commands/team-spec.js');
4
+ const here = buildFolderAt(cwd);
5
+ if (!here)
6
+ return undefined;
7
+ const { listSharedSpecs } = await import('../projects/spec-share.js');
8
+ const all = listSharedSpecs(here.dir);
9
+ const said = request.toLowerCase();
10
+ const isNamed = (s) => said.includes(s.title.toLowerCase()) || said.includes(s.slug.replace(/-/g, ' '));
11
+ const namedAll = all.filter(isNamed);
12
+ const ready = all.filter((s) => s.status === 'ready for build');
13
+ const named = ready.filter(isNamed);
14
+ // S1 — the request names a spec, and it is not one that is ready.
15
+ if (named.length === 0 && namedAll.length === 1) {
16
+ return { notReady: { title: namedAll[0].title, status: namedAll[0].status } };
17
+ }
18
+ if (!ready.length)
19
+ return undefined;
20
+ const one = named.length === 1 ? named[0] : ready.length === 1 && namedAll.length === 0 ? ready[0] : undefined;
21
+ if (!one)
22
+ return { ask: ready.map((s) => `"${s.title}"`) };
23
+ const { chainFromToken } = await import('./chain-token.js');
24
+ const t = await chainFromToken(here.dir, one.envelopePath);
25
+ return t ? { command: `/abap-app --from ${t.token}`, title: one.title } : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /** One plain line for a named spec that is not ready to build. */
32
+ export const notReadyLine = (n) => `"${n.title}" is ${n.status}, not ready for build. The author marks it ready when it is.`;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Final review S2 (2026-10-29): the signed-in CSPeach account, remembered on
3
+ * this laptop after a successful start-up (/v1/me), so a team build names a
4
+ * person by their account on every path — the typed `cspeach team join` and
5
+ * records written without a join — and two people on one laptop with one
6
+ * Windows user stay two people. Only the email; never sent anywhere. Kept in
7
+ * its own file so auth.json (which drives the key logic) is never touched.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { cspeachRoot } from '../config/paths.js';
12
+ const file = () => path.join(cspeachRoot(), 'account.json');
13
+ export function rememberAccount(email) {
14
+ const e = email.trim();
15
+ if (!e)
16
+ return;
17
+ try {
18
+ fs.mkdirSync(cspeachRoot(), { recursive: true });
19
+ fs.writeFileSync(file(), JSON.stringify({ email: e }) + '\n', 'utf8');
20
+ }
21
+ catch { /* best-effort: the Windows name is used instead */ }
22
+ }
23
+ export function signedInAccount() {
24
+ try {
25
+ const j = JSON.parse(fs.readFileSync(file(), 'utf8'));
26
+ return typeof j.email === 'string' && j.email.trim() ? j.email.trim() : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
@@ -30,7 +30,33 @@ import { writeWhole } from '../register/write-whole.js';
30
30
  import { fileStamp } from '../register/records.js';
31
31
  import { writeNoClobber, writeDocxNextTo } from '../docx/write-docx.js';
32
32
  import { metaFromMarkdown } from '../docx/doc-meta.js';
33
+ import { signedInAccount } from '../licence/account-name.js';
33
34
  export const SPECS_DIR = 'specs';
35
+ /** Per question: the latest answer, when it replaced one given by someone else. */
36
+ export function changedAnswersOf(env, answers) {
37
+ if (env.artefactType !== 'spec-gap')
38
+ return [];
39
+ const question = new Map();
40
+ for (const s of env.content.sections)
41
+ for (const g of s.gaps)
42
+ question.set(g.id, g.text);
43
+ const byGap = new Map();
44
+ for (const a of [...answers].sort((x, y) => (x.at < y.at ? -1 : x.at > y.at ? 1 : 0))) {
45
+ byGap.set(a.gapId, [...(byGap.get(a.gapId) ?? []), a]);
46
+ }
47
+ const out = [];
48
+ for (const [gapId, list] of byGap) {
49
+ if (list.length < 2)
50
+ continue;
51
+ const last = list[list.length - 1];
52
+ const prev = list[list.length - 2];
53
+ const samePerson = last.by.writer === prev.by.writer && last.by.name === prev.by.name;
54
+ if (samePerson || last.answer.trim() === prev.answer.trim())
55
+ continue;
56
+ out.push({ gapId, question: question.get(gapId) ?? gapId, answer: last.answer, by: last.by.name, at: last.at, was: prev.answer, wasBy: prev.by.name });
57
+ }
58
+ return out;
59
+ }
34
60
  const STATUSES = ['draft', 'ready for build', 'being built'];
35
61
  /** Same instant: the later step in the life of a spec wins. */
36
62
  const STATUS_RANK = { draft: 0, 'ready for build': 1, 'being built': 2 };
@@ -78,7 +104,8 @@ export function specAuthorFor(projectId, fallbackName) {
78
104
  catch {
79
105
  saved = undefined;
80
106
  }
81
- const name = saved ?? fallbackName;
107
+ // S2 — no name kept for this project: the signed-in account, else the fallback.
108
+ const name = saved ?? signedInAccount() ?? fallbackName;
82
109
  return { name, writer: writerIdFor(name) };
83
110
  }
84
111
  const statusSlug = (s) => s.replace(/\s+/g, '-');
@@ -196,7 +223,8 @@ export function listSharedSpecs(teamDir) {
196
223
  continue;
197
224
  // R11: answers apply only to the envelope they were recorded for.
198
225
  const bestFile = path.basename(best.path);
199
- const composed = composeSpec(best.env, readAnswers(specDir).filter((a) => a.file === bestFile));
226
+ const answersHere = readAnswers(specDir).filter((a) => a.file === bestFile);
227
+ const composed = composeSpec(best.env, answersHere);
200
228
  if (composed.artefactType !== 'spec-gap')
201
229
  continue;
202
230
  const open = new Set(composed.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
@@ -213,13 +241,16 @@ export function listSharedSpecs(teamDir) {
213
241
  specDir,
214
242
  envelopePath: best.path,
215
243
  title: best.env.title,
216
- author: best.env.createdBy.name,
244
+ // F4: the person who shared it (first status record, the account in a
245
+ // team build), else the file's author.
246
+ author: st.history[0]?.by.name ?? best.env.createdBy.name,
217
247
  createdAt: best.env.createdAt,
218
248
  summary: `${best.env.content.inputType} · ${best.env.content.inferredDomain}`,
219
249
  openQuestions,
220
250
  status: st.current,
221
251
  statusBy: st.record?.by.name ?? null,
222
252
  statusAt: st.record?.at ?? null,
253
+ changedAnswers: changedAnswersOf(best.env, answersHere),
223
254
  });
224
255
  }
225
256
  return out;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * F5 (2026-10-29 live run) — the conversation in a team build folder.
3
+ *
4
+ * The model did not know the folder's shared specs: asked for a spec it wrote
5
+ * Markdown itself (refused in specs/, then saved to cspeach-out/), and asked
6
+ * to build it read the spec files one by one and tried a script to parse
7
+ * them. Every turn in a build folder now carries one <team_build> fact: the
8
+ * specs, their state, and the route for each request. CSPeach code writes and
9
+ * reads specs/; the model only hands off.
10
+ *
11
+ * The folder is the one CSPeach runs in (R10). Never throws: a folder it
12
+ * cannot read gives no block.
13
+ */
14
+ import { buildFolderAt } from '../commands/team-spec.js';
15
+ import { listSharedSpecs } from './spec-share.js';
16
+ function questionsLine(open) {
17
+ if (!open.length)
18
+ return 'no open questions';
19
+ const blockers = open.filter((q) => q.severity === 'BLOCKER').length;
20
+ const n = `${open.length} open question${open.length === 1 ? '' : 's'}`;
21
+ return blockers ? `${n} (${blockers} blocker${blockers === 1 ? '' : 's'})` : n;
22
+ }
23
+ export function teamBuildContextBlock(cwd, seat) {
24
+ try {
25
+ const here = buildFolderAt(cwd);
26
+ if (!here)
27
+ return '';
28
+ const specs = listSharedSpecs(here.dir);
29
+ const lines = [
30
+ '<team_build>',
31
+ `This folder is the team build "${here.project.name}". A functional colleague writes specs here and a developer builds them.`,
32
+ specs.length ? 'Specs in this folder:' : 'No specs yet.',
33
+ ...specs.map((s) => `- "${s.title}" — ${s.status} — ${questionsLine(s.openQuestions)}`),
34
+ 'CSPeach keeps these specs and their answers: never read, parse or write the files under specs/ yourself.',
35
+ 'To write a new spec or change one: hand_off with skill "abap-spec-gap" and what the spec is about.',
36
+ ];
37
+ if (seat === 'functional') {
38
+ lines.push('When the person says a spec is ready for the developer, CSPeach records it; this seat does not build.');
39
+ }
40
+ else {
41
+ lines.push('To build a ready spec: hand_off with skill "abap-app" and the spec\'s title in the request. CSPeach attaches the spec and shows its open questions first.');
42
+ }
43
+ lines.push('</team_build>');
44
+ return lines.join('\n');
45
+ }
46
+ catch {
47
+ return '';
48
+ }
49
+ }
@@ -12,3 +12,14 @@ export function formatRoutingLine(e) {
12
12
  case 'cancelled': return formatInfo('Cancelled');
13
13
  }
14
14
  }
15
+ /**
16
+ * F6 (2026-10-29 live run) — a slash command the person typed starts its
17
+ * skill without a word on screen; a routed one says "· Using /<skill>". Same
18
+ * line for the typed one. A queued command (hand-off, app chain) and a plan
19
+ * phase already have their own line: null.
20
+ */
21
+ export function typedSkillLine(a) {
22
+ if (!a.skill || a.queued || a.planPhase)
23
+ return null;
24
+ return formatRoutingLine({ kind: 'using', skill: a.skill });
25
+ }
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  const PREFIX = /^\/abap-app\s+--from\s+@/;
16
+ /** F10 — `/abap-app @…` with no --from. */
17
+ const TYPED_SPEC = /^\/abap-app\s+@/;
16
18
  export const ANSWER = 'Answer them now';
17
19
  export const GO_AHEAD = 'Go ahead with them open';
18
20
  export const STOP = 'Stop';
@@ -48,10 +50,27 @@ export function gateChooser(choose) {
48
50
  }
49
51
  const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
50
52
  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);
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
+ }
55
74
  if (!fromPath)
56
75
  return null;
57
76
  const { buildFolderAt } = await import('../commands/team-spec.js');
@@ -88,12 +107,27 @@ export async function openQuestionsGate(a) {
88
107
  return null;
89
108
  // Final review F5: every return that lets the build go on forgets this run's
90
109
  // "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')}` });
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')}` });
93
127
  for (;;) {
94
128
  const qs = openQuestions(env);
95
129
  if (!qs.length)
96
- return goOn({ command: a.command });
130
+ return goOn({ command });
97
131
  if (a.headless)
98
132
  return carried(qs);
99
133
  for (const q of qs)
@@ -130,7 +164,7 @@ export async function openQuestionsGate(a) {
130
164
  env = read(abs);
131
165
  }
132
166
  catch {
133
- return goOn({ command: a.command });
167
+ return goOn({ command });
134
168
  }
135
169
  }
136
170
  }
@@ -31,6 +31,14 @@ export function startScreenLines(specs, _seat) {
31
31
  else {
32
32
  out.push(`${s.status === 'draft' ? 'Draft' : 'Being built'}: ${who}`);
33
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
+ }
34
42
  }
35
43
  return out;
36
44
  }
@@ -104,6 +104,7 @@ async function joinHere(dir, a) {
104
104
  const deps = {
105
105
  print: (l) => said.push(l),
106
106
  identity: () => ({ name: a.identity.name }),
107
+ account: () => a.identity.account,
107
108
  ...(a.now ? { now: a.now } : {}),
108
109
  };
109
110
  const code = await runTeamCommand(['join', dir, '--outbox', dir], deps);
package/dist/repl.js CHANGED
@@ -14,7 +14,7 @@ import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, pan
14
14
  import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
15
15
  import { formatPanel } from './renderer/panel-rows.js';
16
16
  import { sanitizeControlChars } from './renderer/sanitize.js';
17
- import { formatRoutingLine } from './renderer/routing-line.js';
17
+ import { formatRoutingLine, typedSkillLine } from './renderer/routing-line.js';
18
18
  import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
19
19
  import { handleStyleCommand, pickStyleClassic, pickStyleInk } from './repl/style-command.js';
20
20
  import { handleLevelCommand, pickLevelClassic, pickLevelInk } from './repl/level-command.js';
@@ -456,6 +456,9 @@ export async function runRepl(opts) {
456
456
  let signedInAs;
457
457
  const showProfile = (profile) => {
458
458
  signedInAs = profile.email;
459
+ // S2 — remember the account so a team build names this person by it.
460
+ if (profile.email)
461
+ void import('./licence/account-name.js').then((m) => m.rememberAccount(profile.email)).catch(() => { });
459
462
  const banner = formatLoginBanner(profile);
460
463
  if (getLook() === 'panels') {
461
464
  signIn = signInFragment(profile);
@@ -1596,7 +1599,8 @@ export async function runRepl(opts) {
1596
1599
  }
1597
1600
  // Team build Task 11 — a shared spec's open questions travel with it
1598
1601
  // into the build. Anything not under the shared specs/ goes on unchanged.
1599
- if (/^\/abap-app\s+--from\b/.test(effectiveInput)) {
1602
+ // F10 — a typed `/abap-app @<saved spec-gap>` goes through the gate too.
1603
+ if (/^\/abap-app\s+(?:--from\b|@)/.test(effectiveInput)) {
1600
1604
  try {
1601
1605
  const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
1602
1606
  const { inkStartupChooser } = await import('./commands/plan-startup.js');
@@ -2199,6 +2203,10 @@ export async function runRepl(opts) {
2199
2203
  }
2200
2204
  // 2026-10-04 — the phase's expert skill rides along in the system prompt.
2201
2205
  const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
2206
+ // F6 — a slash command the person typed says which skill started.
2207
+ const typedLine = typedSkillLine({ skill: parsed.skill, queued: !!submitOpts?.queued, planPhase: !!planPhaseCtx });
2208
+ if (typedLine)
2209
+ chunkEmitter.emit('chunk', typedLine);
2202
2210
  lastTurnBy = submitOpts?.queued?.by ?? 'person'; // Task 3
2203
2211
  await runTurn({
2204
2212
  provider,
@@ -2871,6 +2879,14 @@ export async function runRepl(opts) {
2871
2879
  // sits in this folder. Best-effort: never blocks the prompt.
2872
2880
  if (!isHeadless()) {
2873
2881
  void (async () => {
2882
+ // F3 (2026-10-29 live run): wait until the screen listens. Without
2883
+ // this the team-build lines vanished and its cards resolved
2884
+ // "cancelled" before the screen had subscribed. Never hangs.
2885
+ try {
2886
+ const { whenInkReady } = await import('./ui/when-listening.js');
2887
+ await whenInkReady(chunkEmitter);
2888
+ }
2889
+ catch { /* best-effort: carry on as before */ }
2874
2890
  // Team build Task 8 — the folder sets itself up (spec 2026-10-25 §7.2):
2875
2891
  // first, so its question never overlaps the offers below. The seat is
2876
2892
  // settled by now (runStartupAccount / settleSeatAfterStartup above).
@@ -3921,7 +3937,8 @@ export async function runRepl(opts) {
3921
3937
  continue;
3922
3938
  }
3923
3939
  // Team build Task 11 — the open-questions gate (classic mirror of the Ink path).
3924
- if (/^\/abap-app\s+--from\b/.test(resolvedTrimmed)) {
3940
+ // F10 — a typed `/abap-app @<saved spec-gap>` goes through the gate too.
3941
+ if (/^\/abap-app\s+(?:--from\b|@)/.test(resolvedTrimmed)) {
3925
3942
  try {
3926
3943
  const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
3927
3944
  const { classicStartupChooser } = await import('./commands/plan-startup.js');
@@ -4464,6 +4481,10 @@ export async function runRepl(opts) {
4464
4481
  }
4465
4482
  // 2026-10-04 — the phase's expert skill rides along in the system prompt.
4466
4483
  const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
4484
+ // F6 — a slash command the person typed says which skill started.
4485
+ const typedLine = typedSkillLine({ skill: parsed.skill, queued: classicLineBy !== 'person', planPhase: !!planPhaseCtx });
4486
+ if (typedLine)
4487
+ console.log(typedLine.trimEnd());
4467
4488
  lastTurnBy = classicLineBy; // Task 3
4468
4489
  await runTurn({
4469
4490
  provider,
@@ -150,6 +150,30 @@ export function isTeamOwnedRel(rel) {
150
150
  }
151
151
  /** The refusal for a model write to one of the team project's own files. */
152
152
  export const TEAM_FILE_REFUSAL = 'This file belongs to the project and CSPeach writes it. Say what you want changed instead.';
153
+ /**
154
+ * F5 — a write into a team build's shared specs/ folder: the refusal names
155
+ * the route, so the model takes it on the same turn instead of saving the
156
+ * spec somewhere else.
157
+ */
158
+ export const SPECS_REFUSAL = 'Specs in this folder are written and shared by CSPeach, never by a file write. For a new or changed spec, hand_off with skill "abap-spec-gap"; to build a ready spec, hand_off with skill "abap-app".';
159
+ /** The refusal for a team-owned target: the specs/ route, or today's line. */
160
+ export function teamFileRefusalFor(cwd, realTarget) {
161
+ const root = teamWorkRoot(cwd);
162
+ if (root === null)
163
+ return TEAM_FILE_REFUSAL;
164
+ const realOf = (p) => { try {
165
+ return realpathSync.native(p);
166
+ }
167
+ catch {
168
+ return p;
169
+ } };
170
+ for (const specs of new Set([path.join(path.resolve(cwd), 'specs'), realOf(path.join(path.resolve(cwd), 'specs'))])) {
171
+ const rel = path.relative(specs, realTarget);
172
+ if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel))
173
+ return SPECS_REFUSAL;
174
+ }
175
+ return TEAM_FILE_REFUSAL;
176
+ }
153
177
  /**
154
178
  * True when `cwd` is a team folder and the REAL landing path of a write lies
155
179
  * on one of its own files (isTeamOwnedRel), whatever spelling, link or short
@@ -15,7 +15,7 @@ import { promises as fs } from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { registerTool } from '../index.js';
17
17
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
18
- import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
18
+ import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamFileRefusalFor, teamWorkTarget } from '../_team-work-path.js';
19
19
  export async function fileEditHandler(args, ctx) {
20
20
  // 1. Validate args before any IO
21
21
  if (args.old_string === args.new_string) {
@@ -79,7 +79,7 @@ export async function fileEditHandler(args, ctx) {
79
79
  // write. Judged on the real landing path. A folder without a team
80
80
  // project never gets here.
81
81
  if (pointsAtTeamFile(ctx.cwd, realAbs)) {
82
- return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
82
+ return { content: `error: ${teamFileRefusalFor(ctx.cwd, realAbs)}`, is_error: true };
83
83
  }
84
84
  // 5. Read the file
85
85
  let content;
@@ -19,7 +19,7 @@ import { promises as fs, readFileSync } from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import { registerTool } from '../index.js';
21
21
  import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
22
- import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
22
+ import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamFileRefusalFor, teamWorkTarget } from '../_team-work-path.js';
23
23
  /**
24
24
  * 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
25
25
  * nothing about it. He opened it in a text editor, then tried to drop it into
@@ -112,7 +112,7 @@ export async function fileWriteHandler(args, ctx) {
112
112
  // write. Judged on the real landing path. A folder without a team
113
113
  // project never gets here.
114
114
  if (pointsAtTeamFile(ctx.cwd, target)) {
115
- return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
115
+ return { content: `error: ${teamFileRefusalFor(ctx.cwd, target)}`, is_error: true };
116
116
  }
117
117
  // 4. Ensure parent directory exists (no-op if it already does)
118
118
  try {
@@ -107,7 +107,22 @@ registerTool({
107
107
  if (request.length > HAND_OFF_REQUEST_MAX) {
108
108
  return bad('HAND_OFF_INVALID', `request is too long (${request.length} chars; at most ${HAND_OFF_REQUEST_MAX}). Say it in one short sentence with the object names.`);
109
109
  }
110
- const command = buildHandOffCommand(skill, request);
110
+ let command = buildHandOffCommand(skill, request);
111
+ // F9a (2026-10-29 live run): in a team build folder a hand-off to the
112
+ // build workflow carries the ready spec, so the app flow starts at the
113
+ // lane step instead of refusing for want of an @file.
114
+ if (skill === 'abap-app' && !request.includes('@')) {
115
+ const { readySpecFor, notReadyLine } = await import('../lane/ready-spec.js');
116
+ const picked = await readySpecFor(ctx.cwd ?? process.cwd(), request);
117
+ if (picked && 'notReady' in picked) {
118
+ return bad('HAND_OFF_SPEC_NOT_READY', `${notReadyLine(picked.notReady)} Tell the person; do not build another spec instead.`);
119
+ }
120
+ if (picked && 'ask' in picked) {
121
+ return bad('HAND_OFF_SPEC_NEEDED', `more than one spec here is ready to build: ${picked.ask.join('; ')}. Ask which one, then hand off with its title in the request.`);
122
+ }
123
+ if (picked)
124
+ command = picked.command;
125
+ }
111
126
  if (isPlanResumeCommand(command)) {
112
127
  return bad('PLAN_RESUME_HARNESS_OWNED', 'Plan continuation is owned by CSPeach; never hand off "/abap-plan --resume".');
113
128
  }
@@ -0,0 +1,34 @@
1
+ /** Resolves true once `event` has a listener on `emitter`, false after `timeoutMs`. */
2
+ export function whenListening(emitter, event, timeoutMs = 3000) {
3
+ if (emitter.listenerCount(event) > 0)
4
+ return Promise.resolve(true);
5
+ return new Promise((resolve) => {
6
+ let timer;
7
+ const onNew = (name) => {
8
+ if (name !== event)
9
+ return;
10
+ done(true);
11
+ };
12
+ const done = (ok) => {
13
+ emitter.off('newListener', onNew);
14
+ if (timer)
15
+ clearTimeout(timer);
16
+ // 'newListener' fires just before the listener is added: settle on the
17
+ // next turn so an emit made after awaiting this reaches it.
18
+ setImmediate(() => resolve(ok));
19
+ };
20
+ timer = setTimeout(() => done(false), timeoutMs);
21
+ emitter.on('newListener', onNew);
22
+ });
23
+ }
24
+ /** The Ink screen is ready for start-up output: it takes lines, cards and questions. */
25
+ export async function whenInkReady(chunkEmitter, timeoutMs = 3000) {
26
+ const { cardSlotEmitter } = await import('./card-slot.js');
27
+ const { askQuestionEmitter } = await import('./ask-question-emitter.js');
28
+ const ready = await Promise.all([
29
+ whenListening(chunkEmitter, 'chunk', timeoutMs),
30
+ whenListening(cardSlotEmitter, 'confirm', timeoutMs),
31
+ whenListening(askQuestionEmitter, 'request', timeoutMs),
32
+ ]);
33
+ return ready.every(Boolean);
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.54",
3
+ "version": "1.1.55",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",