@cspeach/cli 1.1.54 → 1.1.56

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.
@@ -1,9 +1,10 @@
1
1
  import chalk from 'chalk';
2
- import { basename, dirname } from 'node:path';
2
+ import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path';
3
3
  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';
@@ -53,7 +54,7 @@ import { planLaneBlockForTurn } from '../projects/plan-lane.js';
53
54
  import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
54
55
  import { metaFromMarkdown } from '../docx/doc-meta.js';
55
56
  import { bindCliCardSession, documentOffersFor, markCliMessage, CONTINUE_LINE, WATCHDOG_LINE } from '../session/cli-record.js';
56
- import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
57
+ import { clearAppFlow, getAppFlow, setGapConfirmed, setSpecGap, startAppFlow } from '../lane/app-flow.js';
57
58
  import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
58
59
  import { afterRebuildTurn } from '../journey/rebuild.js';
59
60
  import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
@@ -63,6 +64,7 @@ import { shownToken } from '../lane/card.js';
63
64
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
64
65
  import { input } from '@inquirer/prompts';
65
66
  import { withInquirer } from '../repl/inquirer-guard.js';
67
+ import { takeGateGoAhead } from '../repl/open-questions-gate.js';
66
68
  import { expandTextFileAttachments } from '../projects/workspace.js';
67
69
  import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
68
70
  import { expandImageChips } from '../ui/clipboard-image.js';
@@ -105,6 +107,8 @@ import { ChatUnavailableError } from './chat-unavailable.js';
105
107
  *
106
108
  * Exported for unit testing.
107
109
  */
110
+ /** F2 — the read-only seat, told to the model so it never guesses a cause. */
111
+ 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
112
  export function getAuthorIdentity() {
109
113
  const name = process.env.CSPEACH_AUTHOR_NAME ??
110
114
  process.env.USER ??
@@ -448,6 +452,51 @@ async function isSharedSpecSave(cwd, saveSkill, savedPath) {
448
452
  return false;
449
453
  }
450
454
  }
455
+ /**
456
+ * F14 — the spec text the lane step quotes from: the Markdown copy next to the
457
+ * shared spec-gap (`<slug>.md`, else the one .md there), cwd-relative. The
458
+ * lane tool holds the model to the flow's spec, and the spec-gap file is never
459
+ * that spec. Undefined when there is none.
460
+ */
461
+ async function sharedSpecText(cwd, gapAbs) {
462
+ const { readdir } = await import('node:fs/promises');
463
+ const dir = dirname(gapAbs);
464
+ const mds = (await readdir(dir).catch(() => [])).filter((f) => f.toLowerCase().endsWith('.md'));
465
+ const pick = mds.find((f) => f === `${basename(dir)}.md`) ?? (mds.length === 1 ? mds[0] : undefined);
466
+ return pick === undefined ? undefined : relative(cwd, resolvePath(dir, pick)).split(sep).join('/');
467
+ }
468
+ /**
469
+ * F14 (2026-10-30 live retest) — `/abap-app --from @specs/<slug>/<file>` in a
470
+ * team build folder with no flow for that file: start the flow at the lane
471
+ * step, so the lane tool queues `/abap-plan --from @specs/<slug>/<file>`.
472
+ * A flow already at the lane step for the same real file is kept as it is.
473
+ * True when the source is a shared spec here. Never throws: a folder it
474
+ * cannot read starts nothing.
475
+ */
476
+ async function startSharedSpecFlow(session, cwd, token, source, yes) {
477
+ try {
478
+ const { buildFolderAt } = await import('../commands/team-spec.js');
479
+ const here = buildFolderAt(cwd);
480
+ if (!here)
481
+ return false;
482
+ const abs = resolvePath(cwd, source);
483
+ const { isSharedSpecPath } = await import('../projects/spec-share.js');
484
+ if (!isSharedSpecPath(here.dir, abs))
485
+ return false;
486
+ const flow = getAppFlow(session);
487
+ if (flow?.expect === 'lane' && flow.specGapPath && await sameRealFile(flow.specGapPath, abs))
488
+ return true;
489
+ startAppFlow(session, await sharedSpecText(cwd, abs) ?? token);
490
+ setSpecGap(session, abs);
491
+ if (yes !== undefined && await sameRealFile(yes, abs))
492
+ setGapConfirmed(session, abs);
493
+ return true;
494
+ }
495
+ catch {
496
+ // no flow: the lane step says no plan was queued, as before
497
+ return false;
498
+ }
499
+ }
451
500
  async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
452
501
  try {
453
502
  const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
@@ -645,13 +694,12 @@ export async function maybeOfferSave(p) {
645
694
  // Team build (Task 9): a spec in the shared specs/ folder is never
646
695
  // rewritten — each answer is a record of its own, and the spec is read
647
696
  // with the records applied. Found by the folder (R10), never activeProject().
648
- const { sharedSpecTargetFor, isSharedSpecPath, composeSpec, readAnswers, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
649
- const { readProjectFile } = await import('../projects/status.js');
697
+ const { sharedSpecTargetFor, isSharedSpecPath, readComposedSpec, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
650
698
  const shared = sharedSpecTargetFor(p.cwd ?? process.cwd(), 'spec-gap');
651
699
  const records = shared && isSharedSpecPath(shared.teamDir, savedPath)
652
700
  ? {
653
701
  // R11: only the answers recorded for this envelope.
654
- readEnvelope: (path) => composeSpec(readProjectFile(path), readAnswers(dirname(path)).filter((a) => a.file === basename(path))),
702
+ readEnvelope: readComposedSpec,
655
703
  recordAnswer: (a) => {
656
704
  const specDir = dirname(savedPath);
657
705
  writeAnswerRecord(specDir, {
@@ -938,7 +986,34 @@ export async function runTurn(params) {
938
986
  }
939
987
  else {
940
988
  clearAppFlow(session);
989
+ // F9c (2026-10-29): routed here from plain words ("let's start building
990
+ // it") in a team build folder with a ready spec — build that spec. The
991
+ // command is queued, so it passes the open-questions gate like a typed
992
+ // one; no spec-gap rerun on the sentence.
993
+ if (params.explicitSlash !== true && extractSpecToken(params.userMessage) === undefined && params.ctx.pendingDispatch) {
994
+ const { readySpecFor, notReadyLine } = await import('../lane/ready-spec.js');
995
+ const picked = await readySpecFor(params.ctx.cwd ?? process.cwd(), params.userMessage);
996
+ if (picked && 'notReady' in picked) {
997
+ emit(notReadyLine(picked.notReady));
998
+ return;
999
+ }
1000
+ if (picked && 'ask' in picked) {
1001
+ emit(`More than one spec here is ready to build: ${picked.ask.join(', ')}. Say which one.`);
1002
+ return;
1003
+ }
1004
+ if (picked) {
1005
+ emit(`Building the ready spec "${picked.title}".`);
1006
+ params.ctx.pendingDispatch.set(picked.command, { by: 'app' });
1007
+ return;
1008
+ }
1009
+ }
941
1010
  emit(chalk.yellow(spec.line));
1011
+ // F9b (2026-10-29 live run): a slash command — typed, or queued by a
1012
+ // hand-off — that names no usable spec ends here. Running on as
1013
+ // spec-gap let the model improvise a build off the guided route. A
1014
+ // request routed from plain words still gets the spec-gap check.
1015
+ if (params.explicitSlash === true)
1016
+ return;
942
1017
  }
943
1018
  params = { ...params, skill: 'abap-spec-gap' };
944
1019
  }
@@ -990,6 +1065,13 @@ export async function runTurn(params) {
990
1065
  // (plan step) skips it when it promotes the same real file.
991
1066
  const flowNow = !params.ctx.subagent ? getAppFlow(session) : undefined;
992
1067
  const confirmedGap = flowNow?.gapConfirmed;
1068
+ // F12 (2026-10-30 live retest) — the open-questions gate's go-ahead on a
1069
+ // shared spec was the yes: this turn's /abap-app promote of that same
1070
+ // real file asks nothing more. It counts for the plan step too.
1071
+ const gateYes = !params.ctx.subagent && params.skill === 'abap-app' ? takeGateGoAhead(session) : undefined;
1072
+ // F14 (2026-10-30 live retest) — the yes given this turn, kept for a flow
1073
+ // this promote starts (a shared spec built with no flow yet).
1074
+ let yesThisTurn;
993
1075
  const result = await runPromoteCommand({
994
1076
  sourcePath: fromPath,
995
1077
  targetSkill: params.skill,
@@ -999,9 +1081,22 @@ export async function runTurn(params) {
999
1081
  // Final residual 4 — the same folder gateAppFlowForTurn resolves @X against.
1000
1082
  cwd: params.ctx.cwd,
1001
1083
  ...(flowNow?.expect === 'lane' && params.skill === 'abap-app'
1002
- ? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) } : {}),
1084
+ ? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) }
1085
+ : !params.ctx.subagent && params.skill === 'abap-app'
1086
+ ? { onOpenWarningsConfirmed: (p) => { yesThisTurn = p; } } : {}),
1003
1087
  ...(flowNow?.expect === 'plan' && params.skill === 'abap-plan' && confirmedGap
1004
1088
  ? { skipOpenWarnings: (p) => sameRealFile(p, confirmedGap) } : {}),
1089
+ ...(gateYes
1090
+ ? {
1091
+ skipOpenWarnings: async (p) => {
1092
+ const same = await gateYes(p);
1093
+ if (same && getAppFlow(session)?.expect === 'lane')
1094
+ setGapConfirmed(session, p);
1095
+ if (same)
1096
+ yesThisTurn = p;
1097
+ return same;
1098
+ },
1099
+ } : {}),
1005
1100
  });
1006
1101
  if (!result) {
1007
1102
  // User declined, source invalid, or unsupported target — skip the turn.
@@ -1010,6 +1105,12 @@ export async function runTurn(params) {
1010
1105
  }
1011
1106
  promotedFromForSave = result.promotedFrom;
1012
1107
  promotedFromPathForSave = result.sourcePath;
1108
+ // F14 (2026-10-30 live retest) — a build started from a shared spec
1109
+ // (start screen, gate, F9c hand-off or typed) had no app flow, so the
1110
+ // lane step queued no plan. Start one here, at the lane step, as after a
1111
+ // chained spec-gap save. Team build folders only; the gate above has run.
1112
+ const sharedSpec = !params.ctx.subagent && params.skill === 'abap-app' && result.promotedFrom.artefactType === 'spec-gap'
1113
+ && await startSharedSpecFlow(session, params.ctx.cwd ?? process.cwd(), fromPath, result.sourcePath, yesThisTurn);
1013
1114
  // 2026-05-08: thread the original @<filename> token into the LLM
1014
1115
  // prompt so skills that need to dispatch a follow-up command (e.g.
1015
1116
  // dispatch_skill calling /abap-rap --from @<same-file>) can use the
@@ -1019,7 +1120,9 @@ export async function runTurn(params) {
1019
1120
  // "foo-v1"), producing "no file matching" auto-routes. Use basename
1020
1121
  // so absolute / relative paths normalise to the workspace @<token>
1021
1122
  // form the picker resolves.
1022
- const sourceToken = `@${basename(fromPath)}`;
1123
+ // F14: a shared spec keeps the path as typed (`specs/<slug>/<file>`); its
1124
+ // bare name resolves against the seat's work folder and fails.
1125
+ const sourceToken = sharedSpec ? `@${/\s/.test(fromPath) ? `"${fromPath}"` : fromPath}` : `@${basename(fromPath)}`;
1023
1126
  userMessageForLLM = `Source file: ${sourceToken}\n${result.extendedSkillInput}\n\n---\n\n${rest}`;
1024
1127
  userMessageForSave = rest;
1025
1128
  }
@@ -1169,7 +1272,15 @@ ${imageExpansion.error}`));
1169
1272
  const blockerNote = mainTurn && session.blockerAnswersNote
1170
1273
  ? `<blocker_answers>\n${session.blockerAnswersNote}\n</blocker_answers>`
1171
1274
  : '';
1172
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine]);
1275
+ // F5 (2026-10-29 live run) — in a team build folder the model is told the
1276
+ // shared specs and the route for each request (hand off), every main turn.
1277
+ // Anywhere else: '' (unchanged).
1278
+ const teamBuildNote = mainTurn && isChatSkill(params.skill) ? teamBuildContextBlock(params.ctx.cwd ?? process.cwd(), getSeatKind()) : '';
1279
+ // F2 (2026-10-29 live run): a functional seat sees no write tools; told
1280
+ // nothing, the model guessed "restart CSPeach with writes enabled". Every
1281
+ // main turn on that seat says why, and the route for a change.
1282
+ const seatNote = mainTurn && getSeatKind() === 'functional' ? FUNCTIONAL_SEAT_NOTE : '';
1283
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
1173
1284
  // Deterministically extract structured facts from the user's prompt
1174
1285
  // (package, transport, object names, environment hint, create intent)
1175
1286
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -1185,7 +1296,7 @@ ${imageExpansion.error}`));
1185
1296
  // Final batch 1 — a caller-enriched message still gets the switch note,
1186
1297
  // inside its own <session_context> block.
1187
1298
  // Stage 1A — the <playbook> line rides the same way ('' ⇒ unchanged).
1188
- const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine]);
1299
+ const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine, teamBuildNote, seatNote]);
1189
1300
  const enriched = userMessageForLLM.startsWith('<session_context>')
1190
1301
  ? (callerNotes ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${callerNotes}`) : userMessageForLLM)
1191
1302
  : 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,34 @@
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
+ // F13 (2026-10-30 live retest): a spec being built stays buildable, so a
13
+ // build stopped after the gate, or a closed window, can be continued.
14
+ const ready = all.filter((s) => s.status === 'ready for build' || s.status === 'being built');
15
+ const named = ready.filter(isNamed);
16
+ // S1 — the request names a spec, and it is not one that is ready.
17
+ if (named.length === 0 && namedAll.length === 1) {
18
+ return { notReady: { title: namedAll[0].title, status: namedAll[0].status } };
19
+ }
20
+ if (!ready.length)
21
+ return undefined;
22
+ const one = named.length === 1 ? named[0] : ready.length === 1 && namedAll.length === 0 ? ready[0] : undefined;
23
+ if (!one)
24
+ return { ask: ready.map((s) => `"${s.title}"`) };
25
+ const { chainFromToken } = await import('./chain-token.js');
26
+ const t = await chainFromToken(here.dir, one.envelopePath);
27
+ return t ? { command: `/abap-app --from ${t.token}`, title: one.title } : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** One plain line for a named spec that is not ready to build. */
34
+ 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
+ }
@@ -22,7 +22,6 @@
22
22
  // continuous run) calls resolvePlanSources + renderPlanSourcesBlock.
23
23
  import { promises as fs } from 'node:fs';
24
24
  import path from 'node:path';
25
- import { readProjectFile } from './status.js';
26
25
  import { buildPromotedFromSnapshot } from './promote.js';
27
26
  import { assertRealPathContained } from '../tools/_filesystem-shared.js';
28
27
  import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
@@ -92,7 +91,9 @@ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord, planPath)
92
91
  continue;
93
92
  let env;
94
93
  try {
95
- env = readProjectFile(abs);
94
+ // F12 — a shared spec's answer records reach the phase too.
95
+ const { readComposedSpec } = await import('./spec-share.js');
96
+ env = readComposedSpec(abs);
96
97
  }
97
98
  catch {
98
99
  continue;
@@ -1,5 +1,4 @@
1
1
  import { realpathSync } from 'node:fs';
2
- import { readProjectFile } from './status.js';
3
2
  import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
4
3
  import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
5
4
  import { askYesNo } from './yes-no.js';
@@ -105,9 +104,12 @@ export async function runPromoteCommand(args) {
105
104
  args.log(`--from: ${resolvedPath} is inside CSPeach's own folder and cannot be read.`);
106
105
  return null;
107
106
  }
107
+ // 2026-10-30 live retest F12: a shared spec is read with its answer
108
+ // records applied, so answered questions do not count as open.
109
+ const { readComposedSpec } = await import('./spec-share.js');
108
110
  let env;
109
111
  try {
110
- env = readProjectFile(resolvedPath);
112
+ env = readComposedSpec(resolvedPath);
111
113
  }
112
114
  catch (e) {
113
115
  args.log(`--from source error: ${String(e instanceof Error ? e.message : e)}`);
@@ -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, '-');
@@ -134,6 +161,16 @@ export function composeSpec(env, answers) {
134
161
  }
135
162
  return out;
136
163
  }
164
+ /**
165
+ * The spec at `absPath` as people read it: the file with the answers recorded
166
+ * for it applied (R11). A folder with no `answers/` gives the plain file.
167
+ * 2026-10-30 live retest F12: `--from` read the raw file and saw 25 answered
168
+ * questions as open.
169
+ */
170
+ export function readComposedSpec(absPath) {
171
+ const file = path.basename(absPath);
172
+ return composeSpec(readEnvelopeFile(absPath), readAnswers(path.dirname(absPath)).filter((a) => a.file === file));
173
+ }
137
174
  export async function writeSharedSpec(a) {
138
175
  const slug = specSlug(a.env.title);
139
176
  const specDir = path.join(a.teamDir, SPECS_DIR, slug);
@@ -196,7 +233,8 @@ export function listSharedSpecs(teamDir) {
196
233
  continue;
197
234
  // R11: answers apply only to the envelope they were recorded for.
198
235
  const bestFile = path.basename(best.path);
199
- const composed = composeSpec(best.env, readAnswers(specDir).filter((a) => a.file === bestFile));
236
+ const answersHere = readAnswers(specDir).filter((a) => a.file === bestFile);
237
+ const composed = composeSpec(best.env, answersHere);
200
238
  if (composed.artefactType !== 'spec-gap')
201
239
  continue;
202
240
  const open = new Set(composed.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
@@ -213,13 +251,16 @@ export function listSharedSpecs(teamDir) {
213
251
  specDir,
214
252
  envelopePath: best.path,
215
253
  title: best.env.title,
216
- author: best.env.createdBy.name,
254
+ // F4: the person who shared it (first status record, the account in a
255
+ // team build), else the file's author.
256
+ author: st.history[0]?.by.name ?? best.env.createdBy.name,
217
257
  createdAt: best.env.createdAt,
218
258
  summary: `${best.env.content.inputType} · ${best.env.content.inferredDomain}`,
219
259
  openQuestions,
220
260
  status: st.current,
221
261
  statusBy: st.record?.by.name ?? null,
222
262
  statusAt: st.record?.at ?? null,
263
+ changedAnswers: changedAnswersOf(best.env, answersHere),
223
264
  });
224
265
  }
225
266
  return out;
@@ -0,0 +1,50 @@
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
+ // F13 (2026-10-30 live retest): a spec being built can be continued.
42
+ lines.push('To build a ready spec, or continue one being built: hand_off with skill "abap-app" and the spec\'s title in the request. CSPeach attaches the spec and shows its open questions first.');
43
+ }
44
+ lines.push('</team_build>');
45
+ return lines.join('\n');
46
+ }
47
+ catch {
48
+ return '';
49
+ }
50
+ }
@@ -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';
@@ -25,6 +27,24 @@ export const STOPPED_LINE = 'Stopped. The spec stays ready for the developer.';
25
27
  const markedThisRun = new Set();
26
28
  const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
27
29
  export function noteBeingBuiltThisRun(specDir) { markedThisRun.add(dirKey(specDir)); }
30
+ /**
31
+ * 2026-10-30 live retest F12 — the gate letting the build go on ("Go ahead",
32
+ * answered, or nothing open) is the developer's yes to the open items. The
33
+ * spec file is kept per session, and the promote in that same turn takes it
34
+ * (once) so it does not ask "still open … Promote anyway?" again.
35
+ */
36
+ const goAhead = new WeakMap();
37
+ /** The gate's yes for this session, taken once: a check that the promote source is the same real file. */
38
+ export function takeGateGoAhead(session) {
39
+ const abs = goAhead.get(session);
40
+ if (abs === undefined)
41
+ return undefined;
42
+ goAhead.delete(session);
43
+ return async (p) => {
44
+ const { sameRealFile } = await import('../lane/app-chain.js');
45
+ return sameRealFile(p, abs);
46
+ };
47
+ }
28
48
  function openQuestions(env) {
29
49
  if (env.artefactType !== 'spec-gap')
30
50
  return [];
@@ -48,10 +68,30 @@ export function gateChooser(choose) {
48
68
  }
49
69
  const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
50
70
  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);
71
+ // F12: a yes from an earlier gate never carries over to this command.
72
+ if (a.session)
73
+ goAhead.delete(a.session);
74
+ // F10 (2026-10-29): a typed `/abap-app @<saved spec-gap>` (no --from) is
75
+ // the same request — the spec is already analysed — so it goes to the lane
76
+ // step as `--from`, never back through step 1. Anything else typed with @
77
+ // (a document, a Markdown copy) is left for step 1, as before.
78
+ let command = a.command;
79
+ let fromPath;
80
+ if (PREFIX.test(command)) {
81
+ const { parseFromFlag } = await import('../skills/promotion-dispatch.js');
82
+ fromPath = parseFromFlag(command).fromPath ?? undefined;
83
+ }
84
+ else if (TYPED_SPEC.test(command)) {
85
+ const { extractSpecToken, cleanSpecToken } = await import('../lane/app-chain.js');
86
+ const raw = extractSpecToken(command);
87
+ const tok = raw === undefined ? '' : cleanSpecToken(raw);
88
+ if (!tok.endsWith('.cspeach.json'))
89
+ return null;
90
+ fromPath = tok;
91
+ // Final review M3: words typed after the spec ("use RAP") ride along.
92
+ const rest = /^\/abap-app\s+@(?:"[^"\r\n]*"|'[^'\r\n]*'|\S+)\s*([\s\S]*)$/.exec(command)?.[1]?.trim() ?? '';
93
+ command = `/abap-app --from @${/\s/.test(tok) ? `"${tok}"` : tok}${rest ? ` ${rest}` : ''}`;
94
+ }
55
95
  if (!fromPath)
56
96
  return null;
57
97
  const { buildFolderAt } = await import('../commands/team-spec.js');
@@ -72,11 +112,10 @@ export async function openQuestionsGate(a) {
72
112
  const share = await import('../projects/spec-share.js');
73
113
  if (!share.isSharedSpecPath(here.dir, abs) || !abs.endsWith('.cspeach.json'))
74
114
  return null;
75
- const { readProjectFile } = await import('../projects/status.js');
76
115
  const specDir = path.dirname(abs);
77
116
  const file = path.basename(abs);
78
117
  // 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)));
118
+ const read = share.readComposedSpec;
80
119
  let env;
81
120
  try {
82
121
  env = read(abs);
@@ -88,12 +127,33 @@ export async function openQuestionsGate(a) {
88
127
  return null;
89
128
  // Final review F5: every return that lets the build go on forgets this run's
90
129
  // "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')}` });
130
+ // F10 — the build really starts here: a spec still "ready for build" (typed
131
+ // by hand, or handed off from the conversation) is recorded "being built" by
132
+ // this developer. The start screen records it itself, before the gate.
133
+ const markBeingBuilt = () => {
134
+ try {
135
+ if (share.readSpecStatus(specDir).current !== 'ready for build')
136
+ return;
137
+ share.writeStatusRecord(specDir, {
138
+ projectId: here.project.projectId, slug: path.basename(specDir), file, status: 'being built',
139
+ by: share.specAuthorFor(here.project.projectId, a.identity.name),
140
+ at: (a.now ?? (() => new Date().toISOString()))(),
141
+ });
142
+ }
143
+ catch { /* the build still goes on */ }
144
+ };
145
+ const goOn = (r) => {
146
+ markedThisRun.delete(dirKey(specDir));
147
+ markBeingBuilt();
148
+ if (a.session)
149
+ goAhead.set(a.session, abs);
150
+ return r;
151
+ };
152
+ const carried = (qs) => goOn({ command: `${command}\nOpen questions carried as assumptions:\n${qs.map((q) => `- ${q.id}: ${q.text}`).join('\n')}` });
93
153
  for (;;) {
94
154
  const qs = openQuestions(env);
95
155
  if (!qs.length)
96
- return goOn({ command: a.command });
156
+ return goOn({ command });
97
157
  if (a.headless)
98
158
  return carried(qs);
99
159
  for (const q of qs)
@@ -130,7 +190,7 @@ export async function openQuestionsGate(a) {
130
190
  env = read(abs);
131
191
  }
132
192
  catch {
133
- return goOn({ command: a.command });
193
+ return goOn({ command });
134
194
  }
135
195
  }
136
196
  }
@@ -2,10 +2,11 @@
2
2
  * Team build (spec 2026-10-25) — the developer start screen.
3
3
  *
4
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.
5
+ * `specs/` folder: each spec ready for build or being built with its summary
6
+ * and open questions, and one line for each draft. A developer is then
7
+ * offered each ready spec (build it, ask about it first, or later) and each
8
+ * spec being built (continue building it, F13). A functional seat sees the
9
+ * same lines and is never offered a build.
9
10
  *
10
11
  * Ruling R10: the build folder is the folder CSPeach runs in, never
11
12
  * `activeProject()`. Ruling R12: the status record goes to the spec folder.
@@ -13,12 +14,18 @@
13
14
  */
14
15
  import path from 'node:path';
15
16
  const day = (iso) => String(iso ?? '').slice(0, 10);
17
+ const trimQuestion = (q) => (q.length > 100 ? `${q.slice(0, 100)}…` : q);
16
18
  export function startScreenLines(specs, _seat) {
17
19
  const out = [];
18
20
  for (const s of specs) {
19
21
  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.status === 'ready for build' || s.status === 'being built') {
23
+ // F13 (2026-10-30 live retest): a spec being built reads like a ready
24
+ // one, with who started it, so the developer can continue it.
25
+ const head = s.status === 'ready for build'
26
+ ? `Ready to build: ${who}`
27
+ : `Being built: ${who}${s.statusBy ? ` · started by ${s.statusBy}, ${day(s.statusAt ?? '')}` : ''}`;
28
+ out.push(head, ` ${s.summary}`);
22
29
  if (s.openQuestions.length) {
23
30
  out.push(' Open questions:');
24
31
  for (const q of s.openQuestions)
@@ -29,21 +36,32 @@ export function startScreenLines(specs, _seat) {
29
36
  }
30
37
  }
31
38
  else {
32
- out.push(`${s.status === 'draft' ? 'Draft' : 'Being built'}: ${who}`);
39
+ out.push(`Draft: ${who}`);
40
+ }
41
+ // Owner, 2026-10-29: an answer someone else replaced is said, so the
42
+ // spec's author learns the developer overrode it.
43
+ const changed = s.changedAnswers ?? [];
44
+ if (changed.length) {
45
+ out.push(' Answers changed by someone else:');
46
+ // M7 (2026-10-30 live retest): a long question took 6 lines; cut at 100.
47
+ for (const c of changed)
48
+ out.push(` - "${trimQuestion(c.question)}" now "${c.answer}" (${c.by}, ${day(c.at)}), was "${c.was}" (${c.wasBy})`);
33
49
  }
34
50
  }
35
51
  return out;
36
52
  }
37
53
  const BUILD = 'Build this spec';
54
+ const CONTINUE = 'Continue building';
38
55
  const ASK = 'Ask about it first';
39
56
  const LATER = 'Later';
40
57
  export const askSentence = (s) => `Tell me about the spec "${s.title}" in specs/${s.slug}/ before we build it.`;
41
58
  async function chooseFor(choose, s) {
42
59
  const n = s.openQuestions.length;
60
+ const going = s.status === 'being built';
43
61
  const v = await choose({
44
- title: `Ready to build: "${s.title}"`,
62
+ title: going ? `Continue building: "${s.title}"` : `Ready to build: "${s.title}"`,
45
63
  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 }],
64
+ choices: [{ value: 'build', label: going ? CONTINUE : BUILD }, { value: 'ask', label: ASK }, { value: 'later', label: LATER }],
47
65
  });
48
66
  if (v === 'build')
49
67
  return { kind: 'build', spec: s };
@@ -60,7 +78,7 @@ export async function offerBuild(a) {
60
78
  if (a.seat !== 'developer')
61
79
  return false;
62
80
  for (const s of a.specs) {
63
- if (s.status !== 'ready for build')
81
+ if (s.status !== 'ready for build' && s.status !== 'being built')
64
82
  continue;
65
83
  const c = await chooseFor(a.choose, s);
66
84
  if (c.kind === 'later')
@@ -75,6 +93,12 @@ export async function offerBuild(a) {
75
93
  a.emit(`We could not start the build from specs/${s.slug}/. Run /abap-app --from @specs/${s.slug}/<file> yourself.`);
76
94
  return false;
77
95
  }
96
+ // F13: continuing a spec already being built writes no record, and it is
97
+ // not marked for this run, so a Stop at the gate leaves it being built.
98
+ if (s.status === 'being built') {
99
+ a.submit(`/abap-app --from ${t.token}`);
100
+ return true;
101
+ }
78
102
  const { writeStatusRecord } = await import('../projects/spec-share.js');
79
103
  writeStatusRecord(s.specDir, {
80
104
  projectId: a.projectId, slug: s.slug, file: path.basename(s.envelopePath),
@@ -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');
@@ -1604,7 +1608,7 @@ export async function runRepl(opts) {
1604
1608
  const g = await openQuestionsGate({
1605
1609
  cwd: process.cwd(), command: effectiveInput, identity: getAuthorIdentity(),
1606
1610
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1607
- choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
1611
+ choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(), session,
1608
1612
  });
1609
1613
  if (g && 'stop' in g) {
1610
1614
  chunkEmitter.emit('chunk', STOPPED_LINE + '\n');
@@ -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');
@@ -3930,7 +3947,7 @@ export async function runRepl(opts) {
3930
3947
  cwd: process.cwd(), command: resolvedTrimmed, identity: getAuthorIdentity(),
3931
3948
  emit: (line) => console.log(line),
3932
3949
  choose: gateChooser(classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x)))),
3933
- answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
3950
+ answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(), session,
3934
3951
  });
3935
3952
  if (g && 'stop' in g) {
3936
3953
  console.log(STOPPED_LINE);
@@ -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.56",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",