@cspeach/cli 1.1.53 → 1.1.54

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 (50) hide show
  1. package/dist/agent/loop.js +50 -5
  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 +22 -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 +74 -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/licence/paths.js +5 -0
  20. package/dist/licence/seat-kind.js +110 -0
  21. package/dist/licence/seat-state.js +4 -0
  22. package/dist/licence/terminal-seat.js +43 -0
  23. package/dist/licence/turn-gate.js +4 -0
  24. package/dist/one-shot.js +32 -0
  25. package/dist/projects/answer-blockers.js +31 -7
  26. package/dist/projects/blocker-answers-note.js +1 -1
  27. package/dist/projects/save-command.js +37 -2
  28. package/dist/projects/saved-lines.js +19 -17
  29. package/dist/projects/spec-share.js +239 -0
  30. package/dist/register/picture.js +2 -2
  31. package/dist/register/store.js +19 -0
  32. package/dist/repl/builtin-commands.js +4 -3
  33. package/dist/repl/file-picker.js +52 -5
  34. package/dist/repl/mode-cycle.js +12 -0
  35. package/dist/repl/open-questions-gate.js +159 -0
  36. package/dist/repl/ready-phrase.js +108 -0
  37. package/dist/repl/startup-account.js +64 -1
  38. package/dist/repl/team-build-screen.js +116 -0
  39. package/dist/repl/team-build-start.js +178 -0
  40. package/dist/repl.js +205 -9
  41. package/dist/sap-link/policy.js +4 -0
  42. package/dist/sap-link/registry.js +9 -3
  43. package/dist/sap-link/server.js +4 -1
  44. package/dist/tools/_team-work-path.js +1 -1
  45. package/dist/tools/cap/sap-link-tool.js +2 -0
  46. package/dist/tools/project/spec_answers_record.js +62 -9
  47. package/dist/tools/project/team_findings.js +2 -2
  48. package/dist/tools/seat-gate.js +41 -0
  49. package/dist/ui/widgets/files-card.js +19 -1
  50. package/package.json +1 -1
@@ -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,239 @@
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
+ export const SPECS_DIR = 'specs';
34
+ const STATUSES = ['draft', 'ready for build', 'being built'];
35
+ /** Same instant: the later step in the life of a spec wins. */
36
+ const STATUS_RANK = { draft: 0, 'ready for build': 1, 'being built': 2 };
37
+ /** A folder name for a spec title: lower-case, `[^a-z0-9]+` → `-`, ≤ 60 chars, `spec` when empty. */
38
+ export function specSlug(title) {
39
+ const s = String(title ?? '')
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, '-')
42
+ .replace(/^-+|-+$/g, '')
43
+ .slice(0, 60)
44
+ .replace(/-+$/, '');
45
+ return s || 'spec';
46
+ }
47
+ /**
48
+ * The team build folder a spec-gap save goes to, by the folder CSPeach runs
49
+ * in (R10). Null for any other artefact, folder kind or team project type.
50
+ */
51
+ export function sharedSpecTargetFor(cwd, artefactType) {
52
+ if (artefactType !== 'spec-gap')
53
+ return null;
54
+ try {
55
+ const pf = resolveProjectFolderSync(cwd);
56
+ if (pf.kind !== 'team')
57
+ return null;
58
+ const project = readProjectFile(pf.projectDir);
59
+ if (!isBuildProject(project))
60
+ return null;
61
+ return { teamDir: pf.projectDir, projectId: project.projectId };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Who writes a spec record: the name this laptop records under for the build
69
+ * project (`team join --as`, kept in the register state by project id — not
70
+ * the active project, R10), else the author name; the writer id is
71
+ * `writerIdFor(name)`, as `cspeach team join` derives it.
72
+ */
73
+ export function specAuthorFor(projectId, fallbackName) {
74
+ let saved;
75
+ try {
76
+ saved = loadState().projects[projectId]?.name;
77
+ }
78
+ catch {
79
+ saved = undefined;
80
+ }
81
+ const name = saved ?? fallbackName;
82
+ return { name, writer: writerIdFor(name) };
83
+ }
84
+ const statusSlug = (s) => s.replace(/\s+/g, '-');
85
+ const safePart = (s) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'x';
86
+ export function writeStatusRecord(specDir, rec, rand = defaultRand) {
87
+ const full = { kind: 'spec-status', formatVersion: 1, ...rec };
88
+ const file = path.join(specDir, 'status', `${fileStamp(new Date(rec.at))}-${statusSlug(rec.status)}-${rand(2)}.json`);
89
+ writeWhole(file, `${JSON.stringify(full, null, 2)}\n`);
90
+ return file;
91
+ }
92
+ export function writeAnswerRecord(specDir, rec, rand = defaultRand) {
93
+ const full = { kind: 'spec-answer', formatVersion: 1, ...rec };
94
+ const file = path.join(specDir, 'answers', `${fileStamp(new Date(rec.at))}-${safePart(rec.gapId)}-${rand(2)}.json`);
95
+ writeWhole(file, `${JSON.stringify(full, null, 2)}\n`);
96
+ return file;
97
+ }
98
+ /** Every `*.json` in `dir`, parsed; unreadable files are skipped. */
99
+ function readJsonDir(dir) {
100
+ let names;
101
+ try {
102
+ names = fs.readdirSync(dir);
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ const out = [];
108
+ for (const n of names.sort()) {
109
+ if (!n.endsWith('.json'))
110
+ continue;
111
+ try {
112
+ out.push(JSON.parse(stripBom(fs.readFileSync(path.join(dir, n), 'utf8'))));
113
+ }
114
+ catch { /* skipped */ }
115
+ }
116
+ return out;
117
+ }
118
+ const isBy = (b) => !!b && typeof b.name === 'string' && typeof b.writer === 'string';
119
+ export function readSpecStatus(specDir) {
120
+ const history = readJsonDir(path.join(specDir, 'status')).filter((r) => r?.kind === 'spec-status' && STATUSES.includes(r.status) && typeof r.at === 'string' && isBy(r.by));
121
+ history.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : STATUS_RANK[a.status] - STATUS_RANK[b.status]));
122
+ const record = history.length ? history[history.length - 1] : null;
123
+ return { current: record?.status ?? 'draft', record, history };
124
+ }
125
+ export function readAnswers(specDir) {
126
+ 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));
127
+ }
128
+ /** The spec as people read it: the author's envelope with every answer record applied, in time order. */
129
+ export function composeSpec(env, answers) {
130
+ const ordered = [...answers].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
131
+ let out = env;
132
+ for (const a of ordered) {
133
+ out = applyBlockerAnswers(out, new Map([[a.gapId, a.answer]]), { name: a.by.name, role: 'consultant' }, a.at);
134
+ }
135
+ return out;
136
+ }
137
+ export async function writeSharedSpec(a) {
138
+ const slug = specSlug(a.env.title);
139
+ const specDir = path.join(a.teamDir, SPECS_DIR, slug);
140
+ fs.mkdirSync(path.join(specDir, 'files'), { recursive: true });
141
+ fs.mkdirSync(path.join(specDir, 'answers'), { recursive: true });
142
+ // saveProject never overwrites: a taken name gets -2, -3 … beside it.
143
+ const jsonPath = await saveProject(a.env, { cwd: specDir });
144
+ const mdPath = await writeNoClobber(path.join(specDir, `${slug}.md`), a.markdown);
145
+ let docxPath = null;
146
+ try {
147
+ docxPath = await writeDocxNextTo(mdPath, a.markdown, metaFromMarkdown(a.markdown, {
148
+ date: a.today, kind: 'document', ...(a.sapSystem ? { system: a.sapSystem } : {}),
149
+ }));
150
+ }
151
+ catch {
152
+ docxPath = null;
153
+ }
154
+ const statusPath = writeStatusRecord(specDir, {
155
+ projectId: a.projectId, slug, file: path.basename(jsonPath), status: 'draft', by: a.by, at: a.now.toISOString(),
156
+ }, a.rand);
157
+ return { specDir, jsonPath, mdPath, docxPath, statusPath };
158
+ }
159
+ export function listSharedSpecs(teamDir) {
160
+ const root = path.join(teamDir, SPECS_DIR);
161
+ let dirs;
162
+ try {
163
+ dirs = fs.readdirSync(root, { withFileTypes: true });
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ const out = [];
169
+ for (const d of dirs.sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
170
+ if (!d.isDirectory())
171
+ continue;
172
+ const specDir = path.join(root, d.name);
173
+ let names;
174
+ try {
175
+ names = fs.readdirSync(specDir);
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ let best = null;
181
+ for (const n of names) {
182
+ if (!n.endsWith('.cspeach.json'))
183
+ continue;
184
+ const p = path.join(specDir, n);
185
+ try {
186
+ const env = readEnvelopeFile(p);
187
+ if (env.artefactType !== 'spec-gap')
188
+ continue;
189
+ const mtime = fs.statSync(p).mtimeMs;
190
+ if (!best || env.version > best.env.version || (env.version === best.env.version && mtime > best.mtime))
191
+ best = { path: p, env, mtime };
192
+ }
193
+ catch { /* unreadable envelope: skipped */ }
194
+ }
195
+ if (!best || best.env.artefactType !== 'spec-gap')
196
+ continue;
197
+ // R11: answers apply only to the envelope they were recorded for.
198
+ const bestFile = path.basename(best.path);
199
+ const composed = composeSpec(best.env, readAnswers(specDir).filter((a) => a.file === bestFile));
200
+ if (composed.artefactType !== 'spec-gap')
201
+ continue;
202
+ const open = new Set(composed.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
203
+ const questions = [];
204
+ for (const s of composed.content.sections) {
205
+ for (const g of s.gaps)
206
+ if (open.has(g.id))
207
+ questions.push({ id: g.id, severity: g.severity, text: g.text });
208
+ }
209
+ const openQuestions = [...questions.filter((q) => q.severity === 'BLOCKER'), ...questions.filter((q) => q.severity !== 'BLOCKER')];
210
+ const st = readSpecStatus(specDir);
211
+ out.push({
212
+ slug: d.name,
213
+ specDir,
214
+ envelopePath: best.path,
215
+ title: best.env.title,
216
+ author: best.env.createdBy.name,
217
+ createdAt: best.env.createdAt,
218
+ summary: `${best.env.content.inputType} · ${best.env.content.inferredDomain}`,
219
+ openQuestions,
220
+ status: st.current,
221
+ statusBy: st.record?.by.name ?? null,
222
+ statusAt: st.record?.at ?? null,
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+ /** True when `p`, links followed, is inside `<teamDir>/specs/`. */
228
+ export function isSharedSpecPath(teamDir, p) {
229
+ try {
230
+ const root = key(path.join(teamDir, SPECS_DIR));
231
+ // A file not written yet: its folder, links followed, decides.
232
+ const target = fs.existsSync(p) ? key(p) : path.join(key(path.dirname(p)), process.platform === 'win32' ? path.basename(p).toLowerCase() : path.basename(p));
233
+ const rel = path.relative(root, target);
234
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
@@ -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) {
@@ -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),
@@ -4,7 +4,7 @@
4
4
  * UX:
5
5
  * 1. Search-as-you-type list of files (filename + artefactType + version)
6
6
  * 2. Action menu tailored to the picked artefactType — pick one of:
7
- * spec-gap → --status, --from /abap-design
7
+ * spec-gap → answer the questions (viewer steps), --from /abap-design, --status
8
8
  * design → --status, --from /abap-estimate
9
9
  * estimate → --status only
10
10
  * unknown → all --status variants
@@ -16,10 +16,15 @@
16
16
  * slash-picker.ts so cancellation in either step doesn't drop the user
17
17
  * back to the parent shell.
18
18
  */
19
+ import path from 'node:path';
19
20
  import { search, select } from '@inquirer/prompts';
20
21
  import { withInquirer } from './inquirer-guard.js';
21
22
  import { listProjectFiles, detectDesignShape } from '../projects/workspace.js';
23
+ import { editorLabel } from '../projects/saved-lines.js';
24
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
22
25
  const CANCEL_VALUE = '__cspeach_cancel__';
26
+ /** Spec gap "answer the questions" (2026-10-27): prints the viewer steps, runs nothing. */
27
+ const ANSWER_VALUE = '__cspeach_answer__';
23
28
  /**
24
29
  * Compute the menu of valid actions for an envelope of a given
25
30
  * artefactType. Order: most-common first.
@@ -35,7 +40,8 @@ const CANCEL_VALUE = '__cspeach_cancel__';
35
40
  function envelopeActionsFor(type, designShape = 'unknown') {
36
41
  if (type === 'spec-gap') {
37
42
  return [
38
- { label: '/abap-design --from (promote → design)', command: '/abap-design --from' },
43
+ { label: 'Answer the questions (open in cspeach.dev/edit)', command: ANSWER_VALUE },
44
+ { label: '/abap-design --from (use the answers → design)', command: '/abap-design --from' },
39
45
  { label: '/abap-spec-gap --status (inspect)', command: '/abap-spec-gap --status' },
40
46
  ];
41
47
  }
@@ -153,6 +159,46 @@ function textActionsFor() {
153
159
  * then dumped the full file list every time the user cancelled. This
154
160
  * tagged result removes the conflation.
155
161
  */
162
+ /**
163
+ * The steps printed for "Answer the questions" (2026-10-27): a standalone
164
+ * customer did not know how to enter answers. The viewer's own button names
165
+ * (Pick folder, Save back to folder, Download updated file).
166
+ */
167
+ export function answerStepsText(file, workDir = workFolder()) {
168
+ const full = path.resolve(file.path);
169
+ // A file from an earlier location is read-only: its answers go to today's folder.
170
+ if (file.earlier) {
171
+ return [
172
+ 'To answer the questions:',
173
+ ` 1. Open ${editorLabel()} and drop ${full} on the page.`,
174
+ ' 2. Type each answer and mark it answered.',
175
+ ` 3. Choose Download updated file and put that file in ${workDir}`,
176
+ 'Then type /files and pick the new version to go on from your answers.',
177
+ ].join('\n');
178
+ }
179
+ const dir = path.dirname(full);
180
+ return [
181
+ 'To answer the questions:',
182
+ ` 1. Open ${editorLabel()}, choose Pick folder and pick ${dir}`,
183
+ ` 2. Open ${file.filename}, type each answer and mark it answered.`,
184
+ ' 3. Choose Save back to folder. It saves a new version next to this one.',
185
+ 'Then type /files and pick the new version to go on from your answers.',
186
+ `No Pick folder button (it needs Edge or Chrome)? Drop ${full} on the page instead, choose Download updated file and put that file in ${dir}.`,
187
+ ].join('\n');
188
+ }
189
+ /** Picked action → result: the answer action prints steps, every other one is a command. */
190
+ function resultFor(action, file) {
191
+ if (action === ANSWER_VALUE)
192
+ return { kind: 'info', text: answerStepsText(file) };
193
+ return { kind: 'command', command: `${action} @${file.filename}` };
194
+ }
195
+ /**
196
+ * This project's file folder, where saves go — not a file's folder: the list
197
+ * can hold files from earlier locations (marked `earlier`) after today's.
198
+ */
199
+ function workFolder() {
200
+ return path.resolve(resolveProjectFolderSync(process.cwd()).workDir);
201
+ }
156
202
  /** Action menu for a picked file (shared by the inquirer and Ink paths). */
157
203
  function actionsForFile(file) {
158
204
  const designShape = file.artefactType === 'design' ? detectDesignShape(file.path) : 'unknown';
@@ -185,7 +231,7 @@ export async function openFilePicker(opts = {}) {
185
231
  return await withInquirer(async () => {
186
232
  // Step 1 — pick a file.
187
233
  const fileResult = await search({
188
- message: 'Pick a project file',
234
+ message: `Pick a project file (in ${workFolder()})`,
189
235
  source: async (input) => {
190
236
  const filter = (input ?? '').toLowerCase();
191
237
  const filtered = filter.length === 0
@@ -223,7 +269,7 @@ export async function openFilePicker(opts = {}) {
223
269
  });
224
270
  if (action === CANCEL_VALUE)
225
271
  return { kind: 'cancelled' };
226
- return { kind: 'command', command: `${action} @${pickedFile.filename}` };
272
+ return resultFor(action, pickedFile);
227
273
  });
228
274
  }
229
275
  catch {
@@ -238,6 +284,7 @@ async function openFilePickerInk(files) {
238
284
  const picked = await withFocusClaim(() => filesCardEmitter.requestPick({
239
285
  id: 'files',
240
286
  title: 'Pick a project file',
287
+ folder: workFolder(),
241
288
  items: files.map((f) => ({ value: f.filename, label: f.filename, detail: `${f.artefactType} v${f.version ?? '?'}` })),
242
289
  }));
243
290
  if (picked.value == null)
@@ -257,5 +304,5 @@ async function openFilePickerInk(files) {
257
304
  }, { trace: false });
258
305
  if (res.value == null || res.value === CANCEL_VALUE)
259
306
  return { kind: 'cancelled' };
260
- return { kind: 'command', command: `${res.value} @${pickedFile.filename}` };
307
+ return resultFor(res.value, pickedFile);
261
308
  }
@@ -1,4 +1,5 @@
1
1
  import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
2
+ import { isFunctionalSeat } from '../licence/seat-state.js';
2
3
  /**
3
4
  * Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
4
5
  *
@@ -29,14 +30,23 @@ export function getSessionWriteMode() {
29
30
  }
30
31
  /** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
31
32
  export function getEffectiveWriteMode(cfgMode, role) {
33
+ // Team build Task 6 — a functional seat is pinned at advisory whatever the
34
+ // config, the role or a session override says.
35
+ if (isFunctionalSeat())
36
+ return 'advisory-only';
32
37
  return clampToCeiling(sessionWriteMode ?? cfgMode, role);
33
38
  }
39
+ /** Team build Task 6 — the /mode answer on a functional seat, for any argument. */
40
+ export const SEAT_MODE_LINE = 'mode stays advisory on this seat — it reads SAP and never changes it';
34
41
  /**
35
42
  * Cycle to the next mode the role allows (wraps). Sets the session override
36
43
  * so the new mode takes effect immediately. Returns the new mode AND whether
37
44
  * the requested step was capped (for the refusal line).
38
45
  */
39
46
  export function cycleWriteMode(cfgMode, role) {
47
+ // Functional seat: pinned; the session override is left alone.
48
+ if (isFunctionalSeat())
49
+ return { mode: 'advisory-only', capped: true };
40
50
  const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
41
51
  // Modes at or below the ceiling, in cycling order (most→least restricted).
42
52
  const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
@@ -77,6 +87,8 @@ function refusalLine(clamped, role, alias) {
77
87
  * mode (caller pushes it into the SapStateStore so the statusline is live).
78
88
  */
79
89
  export function runModeCommand(arg, cfgMode, role, alias) {
90
+ if (isFunctionalSeat())
91
+ return { line: SEAT_MODE_LINE, mode: 'advisory-only' };
80
92
  const prev = getEffectiveWriteMode(cfgMode, role);
81
93
  const normalized = arg?.trim().toLowerCase() ?? '';
82
94
  if (normalized === '') {