@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.
Files changed (58) hide show
  1. package/dist/agent/loop.js +90 -7
  2. package/dist/agent/tool-dispatch.js +11 -0
  3. package/dist/auth/auth-file.js +3 -0
  4. package/dist/auth/key-resolver.js +8 -3
  5. package/dist/auth/me.js +6 -2
  6. package/dist/cli.js +25 -0
  7. package/dist/commands/login.js +22 -4
  8. package/dist/commands/plan-reground.js +5 -0
  9. package/dist/commands/sap-link.js +14 -1
  10. package/dist/commands/team-build-help.js +19 -0
  11. package/dist/commands/team-build.js +97 -0
  12. package/dist/commands/team-plan.js +2 -2
  13. package/dist/commands/team-spec.js +107 -0
  14. package/dist/commands/team.js +111 -4
  15. package/dist/journey/chain.js +8 -0
  16. package/dist/journey/clean-core.js +2 -2
  17. package/dist/journey/rebuild.js +8 -0
  18. package/dist/journey/run.js +9 -0
  19. package/dist/lane/ready-spec.js +32 -0
  20. package/dist/licence/account-name.js +31 -0
  21. package/dist/licence/paths.js +5 -0
  22. package/dist/licence/seat-kind.js +110 -0
  23. package/dist/licence/seat-state.js +4 -0
  24. package/dist/licence/terminal-seat.js +43 -0
  25. package/dist/licence/turn-gate.js +4 -0
  26. package/dist/one-shot.js +32 -0
  27. package/dist/projects/answer-blockers.js +31 -7
  28. package/dist/projects/blocker-answers-note.js +1 -1
  29. package/dist/projects/save-command.js +37 -2
  30. package/dist/projects/saved-lines.js +19 -17
  31. package/dist/projects/spec-share.js +270 -0
  32. package/dist/projects/team-build-context.js +49 -0
  33. package/dist/register/picture.js +2 -2
  34. package/dist/register/store.js +19 -0
  35. package/dist/renderer/routing-line.js +11 -0
  36. package/dist/repl/builtin-commands.js +4 -3
  37. package/dist/repl/file-picker.js +52 -5
  38. package/dist/repl/mode-cycle.js +12 -0
  39. package/dist/repl/open-questions-gate.js +193 -0
  40. package/dist/repl/ready-phrase.js +108 -0
  41. package/dist/repl/startup-account.js +64 -1
  42. package/dist/repl/team-build-screen.js +124 -0
  43. package/dist/repl/team-build-start.js +179 -0
  44. package/dist/repl.js +227 -10
  45. package/dist/sap-link/policy.js +4 -0
  46. package/dist/sap-link/registry.js +9 -3
  47. package/dist/sap-link/server.js +4 -1
  48. package/dist/tools/_team-work-path.js +25 -1
  49. package/dist/tools/cap/sap-link-tool.js +2 -0
  50. package/dist/tools/filesystem/file-edit.js +2 -2
  51. package/dist/tools/filesystem/file-write.js +2 -2
  52. package/dist/tools/hand-off.js +16 -1
  53. package/dist/tools/project/spec_answers_record.js +62 -9
  54. package/dist/tools/project/team_findings.js +2 -2
  55. package/dist/tools/seat-gate.js +41 -0
  56. package/dist/ui/when-listening.js +34 -0
  57. package/dist/ui/widgets/files-card.js +19 -1
  58. 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. Answer it later at cspeach.dev/edit.`
231
- : `No answers ${verb}. The ${blockers.length} blockers stay open. Answer them later at cspeach.dev/edit.`);
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
- const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
237
- if (!saved.ok) {
238
- args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
239
- return null;
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 ? ''
@@ -47,7 +47,7 @@ export function blockerAnswersNote(a) {
47
47
  return null;
48
48
  let env;
49
49
  try {
50
- env = readProjectFile(a.path);
50
+ env = (a.readEnvelope ?? readProjectFile)(a.path);
51
51
  }
52
52
  catch {
53
53
  return null;
@@ -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: <file name> (in this project's .cspeach folder)
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 { basename, dirname, resolve } from 'node:path';
13
+ import { dirname, resolve } from 'node:path';
14
14
  import { resolveProjectFolderSync } from './project-folder.js';
15
- function editorLabel() {
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
- * `<file name> (in <where>)` — names the folder kind (2026-10-08): this
28
- * project's .cspeach folder (solo), your own working folder (team), or the
29
- * home fallback folder with a hint. Anywhere else: the folder itself.
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 dir = dirname(savedPath);
35
+ const full = resolve(savedPath);
33
36
  const pf = resolveProjectFolderSync(cwd);
34
- let where = dir;
35
- if (sameDir(dir, pf.workDir)) {
36
- if (pf.kind === 'solo')
37
- where = "this project's .cspeach folder";
38
- else if (pf.kind === 'team')
39
- where = 'your own working folder for this team project, not shared';
40
- else
41
- where = `${dir} — start CSPeach in a project folder to keep projects apart`;
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
+ }
@@ -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,
@@ -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
- * `/files` is a real REPL builtin (repl.tsx handles it in both paths) and
60
- * `/cancel` is still handled by the classic loop, but neither is listed in the
61
- * picker catalogue, so both are added here.
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),