@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
@@ -23,7 +23,7 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
25
25
  import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
26
- import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
26
+ import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, coreProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
27
27
  import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
28
28
  import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
29
29
  import { writeLeadRecord, writeRecord } from '../register/records.js';
@@ -39,7 +39,11 @@ import { lookupVariant, variantLookupLine } from '../register/variant.js';
39
39
  import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
40
40
  import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
41
41
  import { rulesCommand } from './team-rules.js';
42
+ import { specCommand } from './team-spec.js';
42
43
  import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
44
+ import { CLEAN_CORE_ONLY_VERBS, FUNCTIONAL_VERB_LINE, buildJoinLine, buildVerbRefusal, createBuildProject, isBuildProject, isRecordingVerb, } from './team-build.js';
45
+ import { seatSignInMessage } from '../licence/seat-kind.js';
46
+ import { getSeatKind } from '../licence/seat-state.js';
43
47
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
44
48
  const TYPES = ['clean-core', 'upgrade', 'build'];
45
49
  /**
@@ -128,6 +132,27 @@ function matches(pattern, value) {
128
132
  export async function runTeamCommand(args, deps) {
129
133
  const [sub, ...rest] = args;
130
134
  try {
135
+ // Team build (spec §7.2): a functional seat reads a team project and never
136
+ // records work into it. Read-only verbs never ask for the seat. On by
137
+ // default (final review F1): without deps.seat the process seat decides
138
+ // (seat-state.ts — developer unless start-up resolved functional), so a
139
+ // caller that builds its own TeamDeps (the clean-core journey) is held too.
140
+ if (isRecordingVerb(sub, rest)) {
141
+ const seat = await (deps.seat ?? (async () => getSeatKind()))();
142
+ if (seat === 'functional') {
143
+ deps.print(FUNCTIONAL_VERB_LINE);
144
+ return 1;
145
+ }
146
+ if (seat === 'unconfirmed') {
147
+ deps.print(seatSignInMessage());
148
+ return 1;
149
+ }
150
+ }
151
+ // A team build folder has no baseline: the clean-core verbs are refused in one line.
152
+ if (sub && CLEAN_CORE_ONLY_VERBS.includes(sub) && activeIsBuild()) {
153
+ deps.print(buildVerbRefusal(sub));
154
+ return 1;
155
+ }
131
156
  switch (sub) {
132
157
  case 'init': return await init(rest, deps);
133
158
  case 'start': return await start(rest, deps);
@@ -151,8 +176,9 @@ export async function runTeamCommand(args, deps) {
151
176
  case 'priority': return priorityCommand(rest, deps);
152
177
  case 'plan': return await planCommand(rest, deps);
153
178
  case 'rebuild': return await rebuildVerb(rest, deps);
179
+ case 'spec': return await specCommand(rest, deps);
154
180
  default:
155
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
181
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules|spec (cspeach --help for details)');
156
182
  return sub ? 1 : 0;
157
183
  }
158
184
  }
@@ -170,6 +196,28 @@ export async function runTeamCommand(args, deps) {
170
196
  return 1;
171
197
  }
172
198
  }
199
+ /** The joined project is a team build. Unreadable or none: no guard (the verb says why itself). */
200
+ function activeIsBuild() {
201
+ try {
202
+ const joined = activeProject();
203
+ return !!joined && isBuildProject(readProjectFile(joined.projectDir));
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ /**
210
+ * The team build folder (Task 7): no SAP connection, nothing pulled or
211
+ * measured. The lead joins with the outbox in the folder itself.
212
+ */
213
+ function initBuild(dir, name, lead, deps, asName) {
214
+ createBuildProject(dir, { name, lead: { name: lead }, now: (deps.now ?? (() => new Date()))(), ...(deps.rand ? { rand: deps.rand } : {}) });
215
+ const quiet = { ...deps, print: () => { } };
216
+ join([dir, '--outbox', dir], quiet, asName);
217
+ deps.print(`✓ Team build "${name}" set up in this folder. Lead: ${lead}.`);
218
+ deps.print(' Specs save to specs/ for the developer. Start CSPeach here: cspeach');
219
+ return 0;
220
+ }
173
221
  /** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
174
222
  export function isLead(project, deps) {
175
223
  return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
@@ -191,6 +239,8 @@ const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<
191
239
  + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
192
240
  /** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
193
241
  const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
242
+ /** What `init --type build` takes: a team build has no packages, no check and no discovery to read. */
243
+ const INIT_BUILD_OPTIONS = ['name', 'lead', 'type'];
194
244
  /** The two that take no value, so neither can eat the folder name. */
195
245
  const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
196
246
  /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
@@ -220,7 +270,9 @@ function unknownInitOption(name) {
220
270
  }
221
271
  const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
222
272
  + '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
223
- const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
273
+ const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport', 'type'];
274
+ /** What `start --type build` takes besides the type: a team build has no packages, no check and no transport yet. */
275
+ const START_BUILD_OPTIONS = ['type', 'name', 'folder'];
224
276
  /** The check a project measures with when nobody names one. */
225
277
  export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
226
278
  /** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
@@ -288,6 +340,13 @@ async function start(args, deps) {
288
340
  if (!START_OPTIONS.includes(name))
289
341
  throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
290
342
  }
343
+ // Team build (Task 7): the folder you are in, its name, you as the lead. Nothing to pull.
344
+ if (opts.type !== undefined) {
345
+ if (opts.type !== 'build' || positional.length || Object.keys(opts).some((o) => !START_BUILD_OPTIONS.includes(o)))
346
+ throw new RegisterError(START_USAGE);
347
+ const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
348
+ return initBuild(dir, opts.name && opts.name !== 'true' ? opts.name : path.basename(dir), deps.identity().name, deps);
349
+ }
291
350
  const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
292
351
  if (typed.some(isPatternWord))
293
352
  throw new RegisterError(NOT_A_PATTERN);
@@ -432,6 +491,12 @@ async function init(args, deps) {
432
491
  const type = (opts.type ?? 'clean-core');
433
492
  if (!TYPES.includes(type))
434
493
  throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
494
+ if (type === 'build') {
495
+ // A team build pulls, measures and discovers nothing: those options are refused, as on `start --type build`.
496
+ if (Object.keys(opts).some((o) => !INIT_BUILD_OPTIONS.includes(o)))
497
+ throw new RegisterError(INIT_USAGE);
498
+ return initBuild(dir, name, lead, deps, given || undefined);
499
+ }
435
500
  // One variant per team project (D6), named here or never. A name SAP could
436
501
  // not accept is refused before a folder exists, not after it is written in.
437
502
  const atcVariant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'].trim().toUpperCase() : '';
@@ -700,6 +765,11 @@ function join(args, deps, asName) {
700
765
  state.projects[project.projectId] = { projectDir, outboxDir, writerId, ...(saved ? { name: saved } : {}) };
701
766
  state.active = project.projectId;
702
767
  saveState(state);
768
+ // A team build has no objects to own: one line.
769
+ if (isBuildProject(project)) {
770
+ deps.print(buildJoinLine(project, writerId));
771
+ return 0;
772
+ }
703
773
  deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
704
774
  // Outbox is the team project folder itself: records land where everyone reads them.
705
775
  if (path.relative(projectDir, outboxDir) === '') {
@@ -2494,7 +2564,7 @@ const heldExportLine = (name) => ` ! "${name}" is open in another program, so i
2494
2564
  /** The CSV export of the team project folder, as the team page reads it, into `dir`. */
2495
2565
  export function writeExports(projectDir, project, dir, deps) {
2496
2566
  const { register } = registerFor(projectDir, outboxFor(projectDir));
2497
- return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
2567
+ return writeExportFiles(register, coreProject(project), dir, (deps.now?.() ?? new Date()).toISOString());
2498
2568
  }
2499
2569
  /**
2500
2570
  * `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { estateSummaryOf, viewWords } from '@cspeach/register-core';
17
17
  import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, isLead, ruleObject, runTeamCommand, whoAmI } from '../commands/team.js';
18
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
19
+ import { isFunctionalSeat } from '../licence/seat-state.js';
18
20
  import { activeProject, readBaseline, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
19
21
  import { findingsByObject } from '../register/findings.js';
20
22
  import { shown, shownUpTo } from '../register/atc-file.js';
@@ -245,6 +247,12 @@ export async function enterJourney(trigger, h) {
245
247
  h.emit(trigger.line);
246
248
  return;
247
249
  }
250
+ // Final review F1: the clean-core and upgrade steps record work and run
251
+ // SAP's checks. A functional seat is told so once, before any card or read.
252
+ if (isFunctionalSeat()) {
253
+ h.emit(FUNCTIONAL_VERB_LINE);
254
+ return;
255
+ }
248
256
  startJourneyFlow(h.session, trigger.kind === 'start' ? trigger.packages : []);
249
257
  // B8: a start sentence is a new run: its DONE never reads as "nothing left" against an earlier one.
250
258
  if (trigger.kind === 'start')
@@ -26,7 +26,7 @@
26
26
  import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { projectView, provenForTarget as provenForTargetCore } from '@cspeach/register-core';
29
- import { DISCOVERY_DIR, TEAM_FILE, activeProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
29
+ import { DISCOVERY_DIR, TEAM_FILE, activeProject, coreProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
30
30
  import { registerFor } from '../register/picture.js';
31
31
  import { reportFileNames } from '../register/workbooks.js';
32
32
  import { samePath } from '../commands/team-discover.js';
@@ -250,7 +250,7 @@ export function readJourneyFacts(cwd, me, skip) {
250
250
  const joined = activeProject();
251
251
  // The project file folderRead already read (joined means its folder is this one).
252
252
  facts.register = registerFor(joined.projectDir, joined.outboxDir, project).register;
253
- facts.view = projectView(facts.register, project, skip?.decide ? { skipped: skip.decide } : {});
253
+ facts.view = projectView(facts.register, coreProject(project), skip?.decide ? { skipped: skip.decide } : {});
254
254
  facts.reportsAt = reportsAt(cwd, folder.name);
255
255
  }
256
256
  catch {
@@ -36,6 +36,8 @@ import { shown } from '../register/atc-file.js';
36
36
  import { cleanReason, OBJECT_NAME, OBJECT_TYPE, objectShown } from './run.js';
37
37
  import { clearJourneyFlow } from './flow.js';
38
38
  import { whoAmI } from '../commands/team.js';
39
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
40
+ import { isFunctionalSeat } from '../licence/seat-state.js';
39
41
  import { setRebuildRunActive } from '../commands/plan-gate.js';
40
42
  /* ── words ──────────────────────────────────────────────────────────────── */
41
43
  export const KIND_WORDS = {
@@ -139,6 +141,9 @@ async function seatName() {
139
141
  }
140
142
  /** One evidence record: the `rebuild` row on each OLD object's key (additive; older readers drop it). */
141
143
  export function writeRebuildRows(joined, keys, row, by, now = new Date()) {
144
+ // Final review F1: a functional seat records nothing into the project (every caller catches this).
145
+ if (isFunctionalSeat())
146
+ throw new Error(FUNCTIONAL_VERB_LINE);
142
147
  const at = now.toISOString();
143
148
  return writeRecord(joined, 'evidence', keys.map((key) => ({ key, rebuild: { ...(typeof row === 'function' ? row(key) : row), by, at } })), { name: by }, { now });
144
149
  }
@@ -262,6 +267,9 @@ export function handOffRebuild(h, g, kind) {
262
267
  }
263
268
  const now = (h.now ?? (() => new Date()))();
264
269
  try {
270
+ // Final review F1: no brief into the shared folder either, on a functional seat.
271
+ if (isFunctionalSeat())
272
+ throw new Error(FUNCTIONAL_VERB_LINE);
265
273
  if (kind !== 'class') {
266
274
  writeRebuildBrief(joined.projectDir, g.app, rebuildBrief({
267
275
  kind, ...(h.sid ? { sid: h.sid } : {}), target: first.target ?? null,
@@ -14,6 +14,8 @@
14
14
  import path from 'node:path';
15
15
  import { estateSummaryOf, PART_ORDER, PART_WORDS, provenForTargetWords, usageLineWithWindow } from '@cspeach/register-core';
16
16
  import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, NO_CHECK_ALL_LINE, ownerMatch, runTeamCommand, severalLine, whoAmI, writeExports, writeReports } from '../commands/team.js';
17
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
18
+ import { isFunctionalSeat } from '../licence/seat-state.js';
17
19
  import { EXPORTS_DIR, exportsOff, exportsOn, exportsPresent, setExportsAlways } from '../register/export-files.js';
18
20
  import { shown, shownUpTo } from '../register/atc-file.js';
19
21
  import { withoutRuleId } from '../register/records.js';
@@ -162,6 +164,11 @@ const IS_PATTERN = /[*?%]/;
162
164
  */
163
165
  export async function runJourneyStage(stage, ctx) {
164
166
  try {
167
+ // Final review F1: a functional seat reads a team project and never records
168
+ // into it — no start, no decision, no fix, no check, no report. Said before
169
+ // any card and before SAP is reached, headless too.
170
+ if (FUNCTIONAL_STAGES.has(stage) && isFunctionalSeat())
171
+ return result(stage, [FUNCTIONAL_VERB_LINE], { kind: 'stop' });
165
172
  if (ctx.headless)
166
173
  return nextStepOnly(stage, ctx);
167
174
  // Said before any card: a step that needs SAP, in a session without one.
@@ -189,6 +196,8 @@ export async function runJourneyStage(stage, ctx) {
189
196
  }
190
197
  const me = (ctx) => whoAmI(ctx.team.deps);
191
198
  const facts = (ctx) => readJourneyFacts(ctx.cwd, me(ctx), ctx.skips);
199
+ /** The stages that record work into the project or run SAP's checks: refused on a functional seat. */
200
+ const FUNCTIONAL_STAGES = new Set(['START', 'DECIDE', 'FIX', 'PROVE', 'REPORT']);
192
201
  /** Run a team body with its output kept back; `said` holds only this run's lines. */
193
202
  async function quiet(ctx, args) {
194
203
  const { said } = ctx.team;
@@ -4,3 +4,8 @@ import { cspeachRoot } from '../config/paths.js';
4
4
  export function licenceCachePath() {
5
5
  return path.join(cspeachRoot(), 'licence.json');
6
6
  }
7
+ /** <CSPeach home>/seat.json — the last verified seat kind (Team build). Holds
8
+ * no secret: the MAC is keyed by the API key, which is not in the file. */
9
+ export function seatRecordPath() {
10
+ return path.join(cspeachRoot(), 'seat.json');
11
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Team build — the seat record and the resolution rule.
3
+ *
4
+ * A developer is never wrongly locked read-only, and a functional seat never
5
+ * turns writable:
6
+ * 1. a fresh /v1/me answer wins (no field = an older proxy = developer);
7
+ * 2. else the last verified record in seat.json (HMAC over
8
+ * `keyPrefix\nkind\nverifiedAt`, keyed by the full API key);
9
+ * 3. else a pre-release sign-in (auth.json without `signedInWith`) is a
10
+ * developer;
11
+ * 4. else "not signed in" — never a guess.
12
+ * A record that is present but fails verification is `invalid`, never a
13
+ * fall-through to the legacy rule.
14
+ */
15
+ import crypto from 'node:crypto';
16
+ import { promises as fs } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { formatFailure } from '../renderer/notice-shape.js';
19
+ import { keyPrefixOf } from './licence-check.js';
20
+ import { seatRecordPath } from './paths.js';
21
+ const isSeatKind = (v) => v === 'developer' || v === 'functional';
22
+ export function seatMac(apiKey, keyPrefix, kind, verifiedAt) {
23
+ return crypto.createHmac('sha256', apiKey).update(`${keyPrefix}\n${kind}\n${verifiedAt}`).digest('hex');
24
+ }
25
+ export function makeSeatRecord(apiKey, kind, now) {
26
+ const keyPrefix = keyPrefixOf(apiKey);
27
+ const verifiedAt = now.toISOString();
28
+ return { keyPrefix, kind, verifiedAt, mac: seatMac(apiKey, keyPrefix, kind, verifiedAt) };
29
+ }
30
+ export function verifySeatRecord(raw, apiKey) {
31
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
32
+ return null;
33
+ const r = raw;
34
+ if (typeof r.keyPrefix !== 'string' || typeof r.verifiedAt !== 'string' || typeof r.mac !== 'string')
35
+ return null;
36
+ if (!isSeatKind(r.kind))
37
+ return null;
38
+ if (r.keyPrefix !== keyPrefixOf(apiKey))
39
+ return null;
40
+ const expected = Buffer.from(seatMac(apiKey, r.keyPrefix, r.kind, r.verifiedAt), 'utf-8');
41
+ const given = Buffer.from(r.mac, 'utf-8');
42
+ // Lengths first: timingSafeEqual throws on unequal lengths.
43
+ if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
44
+ return null;
45
+ return { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
46
+ }
47
+ /** Ruling R7b: the legacy rule's input. No auth.json (a pasted or env key, or
48
+ * a pre-login keytar seat) or an auth.json without `signedInWith` is a
49
+ * pre-release sign-in; only a sign-in made by this CLI or newer is not. */
50
+ export function isLegacySignIn(authFile) {
51
+ return !authFile || authFile.signedInWith === undefined;
52
+ }
53
+ export function resolveSeatKind(a) {
54
+ if (a.fresh?.ok) {
55
+ const word = a.fresh.profile.seat_kind;
56
+ const kind = word === undefined ? 'developer' : isSeatKind(word) ? word : null;
57
+ if (kind === null)
58
+ return { ok: false, reason: 'invalid' };
59
+ return { ok: true, kind, source: 'fresh', write: makeSeatRecord(a.apiKey, kind, a.now) };
60
+ }
61
+ if (a.stored !== undefined && a.stored !== null) {
62
+ const rec = verifySeatRecord(a.stored, a.apiKey);
63
+ return rec ? { ok: true, kind: rec.kind, source: 'stored', write: null } : { ok: false, reason: 'invalid' };
64
+ }
65
+ if (a.legacySignIn)
66
+ return { ok: true, kind: 'developer', source: 'legacy', write: null };
67
+ return { ok: false, reason: 'missing' };
68
+ }
69
+ /** seat.json as stored: null when there is no file; `{ corrupt: true }` when
70
+ * it cannot be read or parsed, so it resolves `invalid`, never `missing`. */
71
+ export async function readSeatRecord() {
72
+ let text;
73
+ try {
74
+ text = await fs.readFile(seatRecordPath(), 'utf-8');
75
+ }
76
+ catch (err) {
77
+ if (err.code === 'ENOENT')
78
+ return null;
79
+ return { corrupt: true };
80
+ }
81
+ try {
82
+ const parsed = JSON.parse(text);
83
+ return parsed === null ? { corrupt: true } : parsed;
84
+ }
85
+ catch {
86
+ return { corrupt: true };
87
+ }
88
+ }
89
+ export async function writeSeatRecord(r) {
90
+ const p = seatRecordPath();
91
+ await fs.mkdir(path.dirname(p), { recursive: true });
92
+ // Only the known fields; temp file + rename, as writeLicenceCache.
93
+ const body = { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
94
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
95
+ try {
96
+ await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf-8');
97
+ await fs.rename(tmp, p);
98
+ }
99
+ finally {
100
+ await fs.rm(tmp, { force: true }).catch(() => { });
101
+ }
102
+ }
103
+ export function seatSignInMessage() {
104
+ return formatFailure({
105
+ what: 'We could not confirm your seat',
106
+ detail: ['Your SAP work, sessions and snapshots are untouched.'],
107
+ next: 'cspeach login',
108
+ }).trimEnd();
109
+ }
110
+ export const READ_ONLY_SEAT_LINE = 'Read-only seat: CSPeach reads your SAP system and never changes it. Specs, notes and Word documents still save to your folder.';
@@ -0,0 +1,4 @@
1
+ let current = 'developer';
2
+ export function getSeatKind() { return current; }
3
+ export function setSeatKind(k) { current = k; }
4
+ export function isFunctionalSeat() { return current === 'functional'; }
@@ -0,0 +1,43 @@
1
+ import { isLegacySignIn, resolveSeatKind, verifySeatRecord } from './seat-kind.js';
2
+ export async function resolveTerminalSeat(d) {
3
+ try {
4
+ const apiKey = (await d.resolveApiKey().catch(() => ({ key: null }))).key ?? '';
5
+ const stored = await d.readSeatRecord().catch(() => ({ corrupt: true }));
6
+ const authFile = await d.readAuthFile().catch(() => null);
7
+ const verified = stored !== null && stored !== undefined && apiKey !== '' && verifySeatRecord(stored, apiKey) !== null;
8
+ let fresh = null;
9
+ if (apiKey && !verified) {
10
+ try {
11
+ fresh = await d.fetchMe({ proxyUrl: await d.proxyUrl(), apiKey });
12
+ }
13
+ catch {
14
+ fresh = null;
15
+ }
16
+ if (fresh && !fresh.ok && fresh.status === 401)
17
+ return 'unconfirmed';
18
+ }
19
+ const r = resolveSeatKind({ fresh, stored, apiKey, now: d.now?.() ?? new Date(), legacySignIn: isLegacySignIn(authFile) });
20
+ if (!r.ok)
21
+ return 'unconfirmed';
22
+ if (r.write)
23
+ await d.writeSeatRecord(r.write).catch(() => { });
24
+ return r.kind;
25
+ }
26
+ catch {
27
+ return 'unconfirmed';
28
+ }
29
+ }
30
+ /** The real wiring for cli.ts: the stored key, seat.json, auth.json, the configured proxy. */
31
+ export async function resolveTerminalSeatNow() {
32
+ const [{ resolveApiKey }, { readAuthFile }, { fetchMe }, { loadConfig }, seat] = await Promise.all([
33
+ import('../auth/key-resolver.js'), import('../auth/auth-file.js'), import('../auth/me.js'), import('../config/loader.js'), import('./seat-kind.js'),
34
+ ]);
35
+ return resolveTerminalSeat({
36
+ resolveApiKey: () => resolveApiKey(),
37
+ readSeatRecord: seat.readSeatRecord,
38
+ writeSeatRecord: seat.writeSeatRecord,
39
+ readAuthFile: () => readAuthFile(),
40
+ fetchMe: (a) => fetchMe(a),
41
+ proxyUrl: async () => (await loadConfig()).proxy_url,
42
+ });
43
+ }
@@ -61,6 +61,10 @@ export class LicenceTurnGate {
61
61
  this.lastCheckAtMs = -Infinity;
62
62
  return { allow: false, message: d.message };
63
63
  }
64
+ try {
65
+ await this.o.onSeat?.(d);
66
+ }
67
+ catch { /* a seat refresh failure never blocks a turn */ }
64
68
  return d.verdict === 'unknown' ? { allow: true, line: d.line } : { allow: true };
65
69
  }
66
70
  }
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