@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.
- package/dist/agent/loop.js +50 -5
- 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 +22 -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 +74 -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/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 +239 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -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 +159 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +116 -0
- package/dist/repl/team-build-start.js +178 -0
- package/dist/repl.js +205 -9
- 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 +1 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- 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/widgets/files-card.js +19 -1
- 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: <
|
|
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,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
|
+
}
|
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) {
|
|
@@ -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),
|
package/dist/repl/file-picker.js
CHANGED
|
@@ -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 →
|
|
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: '
|
|
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:
|
|
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
|
|
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
|
|
307
|
+
return resultFor(res.value, pickedFile);
|
|
261
308
|
}
|
package/dist/repl/mode-cycle.js
CHANGED
|
@@ -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 === '') {
|