@cspeach/cli 1.1.53 → 1.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +90 -7
- package/dist/agent/tool-dispatch.js +11 -0
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +25 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +111 -4
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/ready-spec.js +32 -0
- package/dist/licence/account-name.js +31 -0
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +32 -0
- package/dist/projects/answer-blockers.js +31 -7
- package/dist/projects/blocker-answers-note.js +1 -1
- package/dist/projects/save-command.js +37 -2
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +270 -0
- package/dist/projects/team-build-context.js +49 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/routing-line.js +11 -0
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +193 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +124 -0
- package/dist/repl/team-build-start.js +179 -0
- package/dist/repl.js +227 -10
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/tools/_team-work-path.js +25 -1
- package/dist/tools/cap/sap-link-tool.js +2 -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/tools/project/spec_answers_record.js +62 -9
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/ui/when-listening.js +34 -0
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +1 -1
package/dist/one-shot.js
CHANGED
|
@@ -28,6 +28,10 @@ import { resolveRenderingMode } from './renderer/rendering-mode.js';
|
|
|
28
28
|
import { setLook, setBackground } from './renderer/look.js';
|
|
29
29
|
import { setAssistantStyle } from './agent/style-state.js';
|
|
30
30
|
import { setAssistantLevel } from './agent/level-state.js';
|
|
31
|
+
import { readAuthFile } from './auth/auth-file.js';
|
|
32
|
+
import { fetchMe } from './auth/me.js';
|
|
33
|
+
import { resolveSeatKind, readSeatRecord, writeSeatRecord, seatSignInMessage, isLegacySignIn } from './licence/seat-kind.js';
|
|
34
|
+
import { setSeatKind } from './licence/seat-state.js';
|
|
31
35
|
import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
|
|
32
36
|
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
33
37
|
import { AnswerTrim } from './renderer/answer-trim.js';
|
|
@@ -170,6 +174,31 @@ export async function runOneShot(prompt, opts) {
|
|
|
170
174
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
171
175
|
return 1;
|
|
172
176
|
}
|
|
177
|
+
// Team build Task 5 — the seat kind, set before the turn. A fresh answer
|
|
178
|
+
// wins (managed: one /v1/me for the seat, ruling R7a; other modes: the
|
|
179
|
+
// licence check below), else the verified seat.json, else a pre-release
|
|
180
|
+
// sign-in — no auth.json, or one without signedInWith (R7b) — is a
|
|
181
|
+
// developer, else the sign-in message and exit 1 — never a guess.
|
|
182
|
+
const settleSeat = async (fresh) => {
|
|
183
|
+
const r = resolveSeatKind({
|
|
184
|
+
fresh, stored: await readSeatRecord(), apiKey: (await store.get()) ?? '', now: new Date(),
|
|
185
|
+
legacySignIn: isLegacySignIn(await readAuthFile()),
|
|
186
|
+
});
|
|
187
|
+
if (!r.ok) {
|
|
188
|
+
console.error(seatSignInMessage());
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (r.write)
|
|
192
|
+
await writeSeatRecord(r.write).catch(() => { });
|
|
193
|
+
setSeatKind(r.kind);
|
|
194
|
+
// The developer default follows a functional seat; an explicit level stays.
|
|
195
|
+
const level = cfg.assistant?.level ?? 'developer';
|
|
196
|
+
setAssistantLevel(r.kind === 'functional' && level === 'developer' ? 'functional' : level);
|
|
197
|
+
return true;
|
|
198
|
+
};
|
|
199
|
+
if (cfg.llm.mode === 'managed'
|
|
200
|
+
&& !(await settleSeat(await fetchMe({ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? '' }))))
|
|
201
|
+
return 1;
|
|
173
202
|
// Security batch item 1 — licence check at one-shot start, byok / ai-hub /
|
|
174
203
|
// local only. Managed makes no new call: the proxy checks every request.
|
|
175
204
|
if (cfg.llm.mode !== 'managed') {
|
|
@@ -178,6 +207,9 @@ export async function runOneShot(prompt, opts) {
|
|
|
178
207
|
console.error(licence.message);
|
|
179
208
|
return 1;
|
|
180
209
|
}
|
|
210
|
+
// The licence check IS the /v1/me call: an ok verdict carries the kind.
|
|
211
|
+
if (!(await settleSeat(licence.verdict === 'ok' && licence.profile ? { ok: true, profile: licence.profile, serverDate: null } : null)))
|
|
212
|
+
return 1;
|
|
181
213
|
if (licence.verdict === 'unknown')
|
|
182
214
|
console.error(formatInfo(licence.line).trimEnd());
|
|
183
215
|
// Azure tenant mode — one-shot never asks: a changed endpoint, removed
|
|
@@ -127,7 +127,7 @@ function readsLikeQuestion(typed) {
|
|
|
127
127
|
export async function offerAnswerBlockers(args) {
|
|
128
128
|
let env;
|
|
129
129
|
try {
|
|
130
|
-
env = readProjectFile(args.path);
|
|
130
|
+
env = (args.readEnvelope ?? readProjectFile)(args.path);
|
|
131
131
|
}
|
|
132
132
|
catch {
|
|
133
133
|
return null;
|
|
@@ -226,19 +226,43 @@ export async function offerAnswerBlockers(args) {
|
|
|
226
226
|
// Typed replies were given but not saved: say "saved", not "entered".
|
|
227
227
|
const verb = [...outcomes.values()].includes('declined-question') ? 'saved' : 'entered';
|
|
228
228
|
args.log('');
|
|
229
|
+
// Record mode (team build): the spec is answered in CSPeach, not at cspeach.dev/edit.
|
|
230
|
+
const later = args.recordAnswer ? ''
|
|
231
|
+
: blockers.length === 1 ? ' Answer it later at cspeach.dev/edit.' : ' Answer them later at cspeach.dev/edit.';
|
|
229
232
|
args.log(blockers.length === 1
|
|
230
|
-
? `No answer ${verb}. The blocker stays open
|
|
231
|
-
: `No answers ${verb}. The ${blockers.length} blockers stay open
|
|
233
|
+
? `No answer ${verb}. The blocker stays open.${later}`
|
|
234
|
+
: `No answers ${verb}. The ${blockers.length} blockers stay open.${later}`);
|
|
232
235
|
args.onOutcomes?.(outcomes);
|
|
233
236
|
return null;
|
|
234
237
|
}
|
|
235
238
|
const nowIso = (args.now ?? (() => new Date().toISOString()))();
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
if (args.recordAnswer) {
|
|
240
|
+
// Team build (Task 9): check the revision, but never write the author's file.
|
|
241
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(applyBlockerAnswers(env, answers, args.identity, nowIso))));
|
|
242
|
+
if (!check.ok) {
|
|
243
|
+
args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
|
|
249
|
+
if (!saved.ok) {
|
|
250
|
+
args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
240
253
|
}
|
|
241
254
|
const stillOpen = blockers.length - answers.size;
|
|
255
|
+
if (args.recordAnswer) {
|
|
256
|
+
for (const [gapId, answer] of answers)
|
|
257
|
+
args.recordAnswer({ gapId, answer, identity: args.identity, at: nowIso });
|
|
258
|
+
args.log('');
|
|
259
|
+
args.log(`Recorded ${plural(answers.size, 'answer')} for the developer.`
|
|
260
|
+
+ (stillOpen === 0 ? ''
|
|
261
|
+
: stillOpen === 1 ? ' 1 blocker is still open.'
|
|
262
|
+
: ` ${stillOpen} blockers are still open.`));
|
|
263
|
+
args.onOutcomes?.(outcomes);
|
|
264
|
+
return answers.size;
|
|
265
|
+
}
|
|
242
266
|
args.log('');
|
|
243
267
|
args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
|
|
244
268
|
+ (stillOpen === 0 ? ''
|
|
@@ -24,6 +24,12 @@ import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, read
|
|
|
24
24
|
import { specGapRefForSave } from './plan-sources.js';
|
|
25
25
|
import { checkSkeletonDecision, hasSkeletonPhase } from './plan-skeleton.js';
|
|
26
26
|
import { laneRootFor } from '../lane/roots.js';
|
|
27
|
+
import { sharedSpecTargetFor, specAuthorFor, writeSharedSpec } from './spec-share.js';
|
|
28
|
+
/** YYYY-MM-DD in local time (the Word copy's date). */
|
|
29
|
+
function localDate(d) {
|
|
30
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
31
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
32
|
+
}
|
|
27
33
|
const SKILL_REGISTRY = {
|
|
28
34
|
'abap-spec-gap': {
|
|
29
35
|
artefactType: 'spec-gap',
|
|
@@ -489,9 +495,9 @@ export async function runSaveCommand(args) {
|
|
|
489
495
|
try {
|
|
490
496
|
// Project view Task 5: the view is built once here and answers each card's target.
|
|
491
497
|
const { registerFor } = await import('../register/picture.js');
|
|
492
|
-
const { readProjectFile } = await import('../register/store.js');
|
|
498
|
+
const { coreProject, readProjectFile } = await import('../register/store.js');
|
|
493
499
|
const { projectView } = await import('@cspeach/register-core');
|
|
494
|
-
const view = projectView(registerFor(project.projectDir, project.outboxDir).register, readProjectFile(project.projectDir));
|
|
500
|
+
const view = projectView(registerFor(project.projectDir, project.outboxDir).register, coreProject(readProjectFile(project.projectDir)));
|
|
495
501
|
targets = (key) => view.object(key.trim().toUpperCase())?.target;
|
|
496
502
|
}
|
|
497
503
|
catch {
|
|
@@ -552,6 +558,35 @@ export async function runSaveCommand(args) {
|
|
|
552
558
|
promotedFrom: args.promotedFrom ?? null,
|
|
553
559
|
});
|
|
554
560
|
}
|
|
561
|
+
// Team build (Task 9) — a spec-gap saved in a team build folder goes to the
|
|
562
|
+
// shared specs/<slug>/ folder, for the developer. Found by the folder
|
|
563
|
+
// CSPeach runs in, never by activeProject() (R10). There is no baseline and
|
|
564
|
+
// nothing to record into a register: the record block below is skipped, so
|
|
565
|
+
// a clean-core project that is active elsewhere is never touched.
|
|
566
|
+
// Final review F6: the caller's folder (args.cwd), never process.cwd().
|
|
567
|
+
const shared = sharedSpecTargetFor(args.cwd, spec.artefactType);
|
|
568
|
+
if (shared) {
|
|
569
|
+
try {
|
|
570
|
+
args.onProjectId?.(shared.projectId);
|
|
571
|
+
}
|
|
572
|
+
catch { /* tagging is best-effort */ }
|
|
573
|
+
const now = new Date();
|
|
574
|
+
const r = await writeSharedSpec({
|
|
575
|
+
teamDir: shared.teamDir,
|
|
576
|
+
projectId: shared.projectId,
|
|
577
|
+
env,
|
|
578
|
+
markdown: args.skillOutput,
|
|
579
|
+
by: specAuthorFor(shared.projectId, args.author.name),
|
|
580
|
+
...(args.sapSystem ? { sapSystem: args.sapSystem } : {}),
|
|
581
|
+
today: localDate(now),
|
|
582
|
+
now,
|
|
583
|
+
});
|
|
584
|
+
const slug = basename(r.specDir);
|
|
585
|
+
args.log(`Saved: specs/${slug}/${basename(r.jsonPath)} (in the shared team folder, for the developer)`);
|
|
586
|
+
if (r.docxPath)
|
|
587
|
+
args.log(`Word copy: specs/${slug}/${basename(r.docxPath)}`);
|
|
588
|
+
return r.jsonPath;
|
|
589
|
+
}
|
|
555
590
|
// Saves land in the workspace folder, not cwd — created only NOW, after
|
|
556
591
|
// the user confirmed (declined saves and conversational turns leave no
|
|
557
592
|
// mkdir/.gitignore side effects). Falls back to args.cwd if the workspace
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* below the line" block (subject, body, review steps, sign-off). It looked
|
|
6
6
|
* cluttered and signed itself with the OS account name ("Admin"). Now:
|
|
7
7
|
*
|
|
8
|
-
* Saved: <
|
|
8
|
+
* Saved: <full path> (in this project's .cspeach folder)
|
|
9
9
|
* To review or share: open cspeach.dev/edit and drop the file in.
|
|
10
10
|
*
|
|
11
11
|
* Never prints a user name.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
14
|
import { resolveProjectFolderSync } from './project-folder.js';
|
|
15
|
-
|
|
15
|
+
import { isSharedSpecPath } from './spec-share.js';
|
|
16
|
+
export function editorLabel() {
|
|
16
17
|
const url = process.env.CSPEACH_EDITOR_URL?.trim();
|
|
17
18
|
return url ? url : 'cspeach.dev/edit';
|
|
18
19
|
}
|
|
@@ -24,23 +25,24 @@ function sameDir(a, b) {
|
|
|
24
25
|
return norm(a) === norm(b);
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
|
-
* `<
|
|
28
|
-
* project's .cspeach folder (solo),
|
|
29
|
-
* home fallback folder with a hint. Anywhere else: the
|
|
28
|
+
* `<full path> (in <where>)` — names the folder kind (2026-10-08): this
|
|
29
|
+
* project's .cspeach folder (solo), the shared team folder (a team spec),
|
|
30
|
+
* your own working folder (team), or the home fallback folder with a hint. Anywhere else: the full path alone.
|
|
31
|
+
* 2026-10-27 — the full path, not the bare file name: a standalone customer
|
|
32
|
+
* could not find the file (it sits in .cspeach, the Word copy in cspeach-out).
|
|
30
33
|
*/
|
|
31
34
|
export function savedLocation(savedPath, cwd) {
|
|
32
|
-
const
|
|
35
|
+
const full = resolve(savedPath);
|
|
33
36
|
const pf = resolveProjectFolderSync(cwd);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
return `${basename(savedPath)} (in ${where})`;
|
|
37
|
+
if (pf.kind === 'team' && isSharedSpecPath(pf.projectDir, savedPath))
|
|
38
|
+
return `${full} (in the shared team folder, for the developer)`;
|
|
39
|
+
if (!sameDir(dirname(full), pf.workDir))
|
|
40
|
+
return full;
|
|
41
|
+
if (pf.kind === 'solo')
|
|
42
|
+
return `${full} (in this project's .cspeach folder)`;
|
|
43
|
+
if (pf.kind === 'team')
|
|
44
|
+
return `${full} (in your own working folder for this team project, not shared)`;
|
|
45
|
+
return `${full} (start CSPeach in a project folder to keep projects apart)`;
|
|
44
46
|
}
|
|
45
47
|
export function formatSavedLines(savedPath, cwd) {
|
|
46
48
|
return [
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build (spec 2026-10-25) — the spec lands in the shared team folder.
|
|
3
|
+
*
|
|
4
|
+
* A spec-gap saved in a team build folder goes to `<team>/specs/<slug>/`, for
|
|
5
|
+
* the developer, instead of the seat's private `.cspeach` folder:
|
|
6
|
+
*
|
|
7
|
+
* specs/<slug>/<envelope>.cspeach.json the author's file, never rewritten
|
|
8
|
+
* specs/<slug>/<slug>.md (+ .docx) the spec as people read it
|
|
9
|
+
* specs/<slug>/status/<stamp>-<status>-<rand>.json added-only status records
|
|
10
|
+
* specs/<slug>/answers/<stamp>-<gap>-<rand>.json added-only answer records
|
|
11
|
+
* specs/<slug>/files/ room for the files that go with it
|
|
12
|
+
*
|
|
13
|
+
* One writer per file, records only added: a second save of the same title
|
|
14
|
+
* lands beside the first (`-2`), an answer is a record of its own, and the
|
|
15
|
+
* spec people read is composed at read time (composeSpec). The model's file
|
|
16
|
+
* tools never write `specs/` (tools/_team-work-path.ts TEAM_OWNED_DIRS).
|
|
17
|
+
*
|
|
18
|
+
* Ruling R10: the build project is found by the folder CSPeach runs in, never
|
|
19
|
+
* by `activeProject()` (a developer on a clean-core wave keeps that active).
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { readProjectFile as readEnvelopeFile } from './status.js';
|
|
24
|
+
import { saveProject } from './save.js';
|
|
25
|
+
import { applyBlockerAnswers } from './answer-blockers.js';
|
|
26
|
+
import { resolveProjectFolderSync, key } from './project-folder.js';
|
|
27
|
+
import { isBuildProject } from '../commands/team-build.js';
|
|
28
|
+
import { readProjectFile, loadState, writerIdFor, defaultRand, stripBom } from '../register/store.js';
|
|
29
|
+
import { writeWhole } from '../register/write-whole.js';
|
|
30
|
+
import { fileStamp } from '../register/records.js';
|
|
31
|
+
import { writeNoClobber, writeDocxNextTo } from '../docx/write-docx.js';
|
|
32
|
+
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
33
|
+
import { signedInAccount } from '../licence/account-name.js';
|
|
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
|
+
}
|
|
60
|
+
const STATUSES = ['draft', 'ready for build', 'being built'];
|
|
61
|
+
/** Same instant: the later step in the life of a spec wins. */
|
|
62
|
+
const STATUS_RANK = { draft: 0, 'ready for build': 1, 'being built': 2 };
|
|
63
|
+
/** A folder name for a spec title: lower-case, `[^a-z0-9]+` → `-`, ≤ 60 chars, `spec` when empty. */
|
|
64
|
+
export function specSlug(title) {
|
|
65
|
+
const s = String(title ?? '')
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
68
|
+
.replace(/^-+|-+$/g, '')
|
|
69
|
+
.slice(0, 60)
|
|
70
|
+
.replace(/-+$/, '');
|
|
71
|
+
return s || 'spec';
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The team build folder a spec-gap save goes to, by the folder CSPeach runs
|
|
75
|
+
* in (R10). Null for any other artefact, folder kind or team project type.
|
|
76
|
+
*/
|
|
77
|
+
export function sharedSpecTargetFor(cwd, artefactType) {
|
|
78
|
+
if (artefactType !== 'spec-gap')
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const pf = resolveProjectFolderSync(cwd);
|
|
82
|
+
if (pf.kind !== 'team')
|
|
83
|
+
return null;
|
|
84
|
+
const project = readProjectFile(pf.projectDir);
|
|
85
|
+
if (!isBuildProject(project))
|
|
86
|
+
return null;
|
|
87
|
+
return { teamDir: pf.projectDir, projectId: project.projectId };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Who writes a spec record: the name this laptop records under for the build
|
|
95
|
+
* project (`team join --as`, kept in the register state by project id — not
|
|
96
|
+
* the active project, R10), else the author name; the writer id is
|
|
97
|
+
* `writerIdFor(name)`, as `cspeach team join` derives it.
|
|
98
|
+
*/
|
|
99
|
+
export function specAuthorFor(projectId, fallbackName) {
|
|
100
|
+
let saved;
|
|
101
|
+
try {
|
|
102
|
+
saved = loadState().projects[projectId]?.name;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
saved = undefined;
|
|
106
|
+
}
|
|
107
|
+
// S2 — no name kept for this project: the signed-in account, else the fallback.
|
|
108
|
+
const name = saved ?? signedInAccount() ?? fallbackName;
|
|
109
|
+
return { name, writer: writerIdFor(name) };
|
|
110
|
+
}
|
|
111
|
+
const statusSlug = (s) => s.replace(/\s+/g, '-');
|
|
112
|
+
const safePart = (s) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'x';
|
|
113
|
+
export function writeStatusRecord(specDir, rec, rand = defaultRand) {
|
|
114
|
+
const full = { kind: 'spec-status', formatVersion: 1, ...rec };
|
|
115
|
+
const file = path.join(specDir, 'status', `${fileStamp(new Date(rec.at))}-${statusSlug(rec.status)}-${rand(2)}.json`);
|
|
116
|
+
writeWhole(file, `${JSON.stringify(full, null, 2)}\n`);
|
|
117
|
+
return file;
|
|
118
|
+
}
|
|
119
|
+
export function writeAnswerRecord(specDir, rec, rand = defaultRand) {
|
|
120
|
+
const full = { kind: 'spec-answer', formatVersion: 1, ...rec };
|
|
121
|
+
const file = path.join(specDir, 'answers', `${fileStamp(new Date(rec.at))}-${safePart(rec.gapId)}-${rand(2)}.json`);
|
|
122
|
+
writeWhole(file, `${JSON.stringify(full, null, 2)}\n`);
|
|
123
|
+
return file;
|
|
124
|
+
}
|
|
125
|
+
/** Every `*.json` in `dir`, parsed; unreadable files are skipped. */
|
|
126
|
+
function readJsonDir(dir) {
|
|
127
|
+
let names;
|
|
128
|
+
try {
|
|
129
|
+
names = fs.readdirSync(dir);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const n of names.sort()) {
|
|
136
|
+
if (!n.endsWith('.json'))
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
out.push(JSON.parse(stripBom(fs.readFileSync(path.join(dir, n), 'utf8'))));
|
|
140
|
+
}
|
|
141
|
+
catch { /* skipped */ }
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
const isBy = (b) => !!b && typeof b.name === 'string' && typeof b.writer === 'string';
|
|
146
|
+
export function readSpecStatus(specDir) {
|
|
147
|
+
const history = readJsonDir(path.join(specDir, 'status')).filter((r) => r?.kind === 'spec-status' && STATUSES.includes(r.status) && typeof r.at === 'string' && isBy(r.by));
|
|
148
|
+
history.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : STATUS_RANK[a.status] - STATUS_RANK[b.status]));
|
|
149
|
+
const record = history.length ? history[history.length - 1] : null;
|
|
150
|
+
return { current: record?.status ?? 'draft', record, history };
|
|
151
|
+
}
|
|
152
|
+
export function readAnswers(specDir) {
|
|
153
|
+
return readJsonDir(path.join(specDir, 'answers')).filter((r) => r?.kind === 'spec-answer' && typeof r.gapId === 'string' && typeof r.answer === 'string' && typeof r.at === 'string' && isBy(r.by));
|
|
154
|
+
}
|
|
155
|
+
/** The spec as people read it: the author's envelope with every answer record applied, in time order. */
|
|
156
|
+
export function composeSpec(env, answers) {
|
|
157
|
+
const ordered = [...answers].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
158
|
+
let out = env;
|
|
159
|
+
for (const a of ordered) {
|
|
160
|
+
out = applyBlockerAnswers(out, new Map([[a.gapId, a.answer]]), { name: a.by.name, role: 'consultant' }, a.at);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
export async function writeSharedSpec(a) {
|
|
165
|
+
const slug = specSlug(a.env.title);
|
|
166
|
+
const specDir = path.join(a.teamDir, SPECS_DIR, slug);
|
|
167
|
+
fs.mkdirSync(path.join(specDir, 'files'), { recursive: true });
|
|
168
|
+
fs.mkdirSync(path.join(specDir, 'answers'), { recursive: true });
|
|
169
|
+
// saveProject never overwrites: a taken name gets -2, -3 … beside it.
|
|
170
|
+
const jsonPath = await saveProject(a.env, { cwd: specDir });
|
|
171
|
+
const mdPath = await writeNoClobber(path.join(specDir, `${slug}.md`), a.markdown);
|
|
172
|
+
let docxPath = null;
|
|
173
|
+
try {
|
|
174
|
+
docxPath = await writeDocxNextTo(mdPath, a.markdown, metaFromMarkdown(a.markdown, {
|
|
175
|
+
date: a.today, kind: 'document', ...(a.sapSystem ? { system: a.sapSystem } : {}),
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
docxPath = null;
|
|
180
|
+
}
|
|
181
|
+
const statusPath = writeStatusRecord(specDir, {
|
|
182
|
+
projectId: a.projectId, slug, file: path.basename(jsonPath), status: 'draft', by: a.by, at: a.now.toISOString(),
|
|
183
|
+
}, a.rand);
|
|
184
|
+
return { specDir, jsonPath, mdPath, docxPath, statusPath };
|
|
185
|
+
}
|
|
186
|
+
export function listSharedSpecs(teamDir) {
|
|
187
|
+
const root = path.join(teamDir, SPECS_DIR);
|
|
188
|
+
let dirs;
|
|
189
|
+
try {
|
|
190
|
+
dirs = fs.readdirSync(root, { withFileTypes: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const d of dirs.sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
|
|
197
|
+
if (!d.isDirectory())
|
|
198
|
+
continue;
|
|
199
|
+
const specDir = path.join(root, d.name);
|
|
200
|
+
let names;
|
|
201
|
+
try {
|
|
202
|
+
names = fs.readdirSync(specDir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
let best = null;
|
|
208
|
+
for (const n of names) {
|
|
209
|
+
if (!n.endsWith('.cspeach.json'))
|
|
210
|
+
continue;
|
|
211
|
+
const p = path.join(specDir, n);
|
|
212
|
+
try {
|
|
213
|
+
const env = readEnvelopeFile(p);
|
|
214
|
+
if (env.artefactType !== 'spec-gap')
|
|
215
|
+
continue;
|
|
216
|
+
const mtime = fs.statSync(p).mtimeMs;
|
|
217
|
+
if (!best || env.version > best.env.version || (env.version === best.env.version && mtime > best.mtime))
|
|
218
|
+
best = { path: p, env, mtime };
|
|
219
|
+
}
|
|
220
|
+
catch { /* unreadable envelope: skipped */ }
|
|
221
|
+
}
|
|
222
|
+
if (!best || best.env.artefactType !== 'spec-gap')
|
|
223
|
+
continue;
|
|
224
|
+
// R11: answers apply only to the envelope they were recorded for.
|
|
225
|
+
const bestFile = path.basename(best.path);
|
|
226
|
+
const answersHere = readAnswers(specDir).filter((a) => a.file === bestFile);
|
|
227
|
+
const composed = composeSpec(best.env, answersHere);
|
|
228
|
+
if (composed.artefactType !== 'spec-gap')
|
|
229
|
+
continue;
|
|
230
|
+
const open = new Set(composed.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
|
|
231
|
+
const questions = [];
|
|
232
|
+
for (const s of composed.content.sections) {
|
|
233
|
+
for (const g of s.gaps)
|
|
234
|
+
if (open.has(g.id))
|
|
235
|
+
questions.push({ id: g.id, severity: g.severity, text: g.text });
|
|
236
|
+
}
|
|
237
|
+
const openQuestions = [...questions.filter((q) => q.severity === 'BLOCKER'), ...questions.filter((q) => q.severity !== 'BLOCKER')];
|
|
238
|
+
const st = readSpecStatus(specDir);
|
|
239
|
+
out.push({
|
|
240
|
+
slug: d.name,
|
|
241
|
+
specDir,
|
|
242
|
+
envelopePath: best.path,
|
|
243
|
+
title: best.env.title,
|
|
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,
|
|
247
|
+
createdAt: best.env.createdAt,
|
|
248
|
+
summary: `${best.env.content.inputType} · ${best.env.content.inferredDomain}`,
|
|
249
|
+
openQuestions,
|
|
250
|
+
status: st.current,
|
|
251
|
+
statusBy: st.record?.by.name ?? null,
|
|
252
|
+
statusAt: st.record?.at ?? null,
|
|
253
|
+
changedAnswers: changedAnswersOf(best.env, answersHere),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
/** True when `p`, links followed, is inside `<teamDir>/specs/`. */
|
|
259
|
+
export function isSharedSpecPath(teamDir, p) {
|
|
260
|
+
try {
|
|
261
|
+
const root = key(path.join(teamDir, SPECS_DIR));
|
|
262
|
+
// A file not written yet: its folder, links followed, decides.
|
|
263
|
+
const target = fs.existsSync(p) ? key(p) : path.join(key(path.dirname(p)), process.platform === 'win32' ? path.basename(p).toLowerCase() : path.basename(p));
|
|
264
|
+
const rel = path.relative(root, target);
|
|
265
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/register/picture.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { buildRegister, narrowedLanes } from '@cspeach/register-core';
|
|
3
|
-
import { collectRecords, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
|
|
3
|
+
import { collectRecords, coreProject, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
|
|
4
4
|
/** `project`: the folder's project file when the caller has already read it (one read, one answer). */
|
|
5
5
|
export function registerFor(projectDir, outboxDir, project = readProjectFile(projectDir)) {
|
|
6
6
|
const collected = collectRecords(projectDir);
|
|
@@ -13,7 +13,7 @@ export function registerFor(projectDir, outboxDir, project = readProjectFile(pro
|
|
|
13
13
|
// The newest discovery, when the folder holds one: the estate around the team project.
|
|
14
14
|
const estate = readEstate(projectDir)?.estate;
|
|
15
15
|
const register = buildRegister({
|
|
16
|
-
project, baseline, lanes, records: [...collected.records, ...own.records], ...(estate !== undefined ? { estate } : {}),
|
|
16
|
+
project: coreProject(project), baseline, lanes, records: [...collected.records, ...own.records], ...(estate !== undefined ? { estate } : {}),
|
|
17
17
|
});
|
|
18
18
|
return {
|
|
19
19
|
register, files: collected.files + own.files,
|
package/dist/register/store.js
CHANGED
|
@@ -21,6 +21,19 @@ import { writeWhole } from './write-whole.js';
|
|
|
21
21
|
export const TEAM_FILE = 'cspeach-team.json';
|
|
22
22
|
export const BASELINE_FILE = path.join('baseline', 'objects.json');
|
|
23
23
|
export const LANES_FILE = 'lanes.json';
|
|
24
|
+
/**
|
|
25
|
+
* Team build (2026-10-25): 2 is the team build (`type: 'build'`, no baseline).
|
|
26
|
+
* Clean-core and upgrade folders stay formatVersion 1, untouched. A file
|
|
27
|
+
* newer than this is refused: it was made by a newer CSPeach.
|
|
28
|
+
*/
|
|
29
|
+
export const TEAM_FORMAT_VERSION = 2;
|
|
30
|
+
/**
|
|
31
|
+
* register-core types a project file as formatVersion 1 and never reads the
|
|
32
|
+
* field (its sheet makes a bare one with 1). Handing it a team build file
|
|
33
|
+
* (formatVersion 2) is safe; this says so in one place instead of a cast at
|
|
34
|
+
* every call.
|
|
35
|
+
*/
|
|
36
|
+
export const coreProject = (p) => p;
|
|
24
37
|
export class RegisterError extends Error {
|
|
25
38
|
}
|
|
26
39
|
export const defaultRand = (bytes) => randomBytes(bytes).toString('hex');
|
|
@@ -86,6 +99,9 @@ export function readProjectFile(projectDir) {
|
|
|
86
99
|
if (p?.kind !== 'team-project' || typeof p.projectId !== 'string' || typeof p.lead?.name !== 'string') {
|
|
87
100
|
throw new RegisterError(`${path.join(projectDir, TEAM_FILE)} is not a CSPeach team project file`);
|
|
88
101
|
}
|
|
102
|
+
if (typeof p.formatVersion === 'number' && p.formatVersion > TEAM_FORMAT_VERSION) {
|
|
103
|
+
throw new RegisterError('This team folder was made by a newer CSPeach. Update CSPeach: cspeach --upgrade');
|
|
104
|
+
}
|
|
89
105
|
return p;
|
|
90
106
|
}
|
|
91
107
|
/**
|
|
@@ -124,6 +140,9 @@ function readTextOrSay(file, missing) {
|
|
|
124
140
|
}
|
|
125
141
|
/** `project`: the folder's project file when the caller has already read it (one read, one answer). */
|
|
126
142
|
export function readBaseline(projectDir, project = readProjectFile(projectDir)) {
|
|
143
|
+
// A team build pulls no baseline: nothing to read, and baseline/ is never touched.
|
|
144
|
+
if (project.type === 'build' && project.formatVersion === 2)
|
|
145
|
+
return [];
|
|
127
146
|
const file = path.join(projectDir, project.baseline?.file ?? BASELINE_FILE);
|
|
128
147
|
const text = readTextOrSay(file, `The baseline file is missing: ${file}`);
|
|
129
148
|
if (project.baseline?.sha256 && sha256(text) !== project.baseline.sha256) {
|
|
@@ -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
|
+
}
|
|
@@ -34,6 +34,7 @@ export const BUILTIN_ENTRIES = [
|
|
|
34
34
|
{ name: '/usage', get description() { return usageCommandDescription(); } },
|
|
35
35
|
{ name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
|
|
36
36
|
{ name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy: of the last answer (/export docx, or /export docx <name>), of the whole conversation (/export docx conversation) or of a Markdown file (/export docx <file>.md)' },
|
|
37
|
+
{ name: '/files', description: 'Pick a saved project file and what to do with it' },
|
|
37
38
|
{ name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
|
|
38
39
|
{ name: '/exit', description: 'Exit CSPeach' },
|
|
39
40
|
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
@@ -56,9 +57,9 @@ export const BUILTIN_ENTRIES = [
|
|
|
56
57
|
];
|
|
57
58
|
/**
|
|
58
59
|
* Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
|
|
59
|
-
* `/
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* `/cancel` is still handled by the classic loop but is not listed in the
|
|
61
|
+
* picker catalogue, so it is added here. (`/files` is listed since 2026-10-27;
|
|
62
|
+
* it stays here too, harmlessly.)
|
|
62
63
|
*/
|
|
63
64
|
const BUILTIN_HEADS = new Set([
|
|
64
65
|
...BUILTIN_ENTRIES.map((e) => e.name),
|