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