@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.
- package/dist/agent/loop.js +120 -9
- package/dist/cli.js +3 -0
- package/dist/commands/team.js +37 -0
- package/dist/lane/ready-spec.js +34 -0
- package/dist/licence/account-name.js +31 -0
- package/dist/projects/plan-sources.js +3 -2
- package/dist/projects/promote-command.js +4 -2
- package/dist/projects/spec-share.js +44 -3
- package/dist/projects/team-build-context.js +50 -0
- package/dist/renderer/routing-line.js +11 -0
- package/dist/repl/open-questions-gate.js +70 -10
- package/dist/repl/team-build-screen.js +34 -10
- package/dist/repl/team-build-start.js +1 -0
- package/dist/repl.js +26 -5
- package/dist/tools/_team-work-path.js +24 -0
- package/dist/tools/filesystem/file-edit.js +2 -2
- package/dist/tools/filesystem/file-write.js +2 -2
- package/dist/tools/hand-off.js +16 -1
- package/dist/ui/when-listening.js +34 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/commands/team.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
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
|
|
6
|
-
* questions, and one line for each draft
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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: ${
|
|
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,
|
|
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: ${
|
|
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 {
|
package/dist/tools/hand-off.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|