@cspeach/cli 1.1.52 → 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 (85) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +150 -17
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +17 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/auth/auth-file.js +3 -0
  9. package/dist/auth/key-resolver.js +8 -3
  10. package/dist/auth/me.js +6 -2
  11. package/dist/cli.js +22 -0
  12. package/dist/commands/login.js +22 -4
  13. package/dist/commands/plan-chain.js +2 -2
  14. package/dist/commands/plan-reground.js +5 -0
  15. package/dist/commands/plan-resume.js +2 -2
  16. package/dist/commands/plan-startup.js +3 -3
  17. package/dist/commands/sap-link.js +14 -1
  18. package/dist/commands/team-build-help.js +19 -0
  19. package/dist/commands/team-build.js +97 -0
  20. package/dist/commands/team-plan.js +2 -2
  21. package/dist/commands/team-spec.js +107 -0
  22. package/dist/commands/team.js +74 -4
  23. package/dist/docx/doc-meta.js +1 -0
  24. package/dist/docx/markdown-to-docx.js +2 -0
  25. package/dist/journey/chain.js +8 -0
  26. package/dist/journey/clean-core.js +2 -2
  27. package/dist/journey/rebuild.js +8 -0
  28. package/dist/journey/run.js +9 -0
  29. package/dist/lane/app-chain.js +13 -6
  30. package/dist/licence/paths.js +5 -0
  31. package/dist/licence/seat-kind.js +110 -0
  32. package/dist/licence/seat-state.js +4 -0
  33. package/dist/licence/terminal-seat.js +43 -0
  34. package/dist/licence/turn-gate.js +4 -0
  35. package/dist/one-shot.js +33 -0
  36. package/dist/project-context/conventions.js +8 -1
  37. package/dist/project-context/index-files.js +97 -34
  38. package/dist/project-context/index.js +1 -1
  39. package/dist/project-context/render.js +38 -4
  40. package/dist/projects/answer-blockers.js +133 -18
  41. package/dist/projects/blocker-answers-note.js +89 -0
  42. package/dist/projects/change-options.js +70 -0
  43. package/dist/projects/document-save.js +86 -0
  44. package/dist/projects/extract-design.js +4 -0
  45. package/dist/projects/extract-estimate.js +4 -0
  46. package/dist/projects/extract-spec-gap.js +8 -0
  47. package/dist/projects/index.js +1 -1
  48. package/dist/projects/plan-objects.js +24 -0
  49. package/dist/projects/plan-run.js +12 -2
  50. package/dist/projects/save-command.js +83 -20
  51. package/dist/projects/saved-lines.js +19 -17
  52. package/dist/projects/spec-share.js +239 -0
  53. package/dist/register/picture.js +2 -2
  54. package/dist/register/store.js +19 -0
  55. package/dist/renderer/change-card.js +14 -0
  56. package/dist/renderer/html-entities.js +25 -0
  57. package/dist/renderer/machine-blocks.js +54 -7
  58. package/dist/renderer/tool-labels.js +2 -0
  59. package/dist/renderer/tool-widget.js +20 -5
  60. package/dist/repl/builtin-commands.js +4 -3
  61. package/dist/repl/chat-lane.js +10 -1
  62. package/dist/repl/file-picker.js +52 -5
  63. package/dist/repl/mode-cycle.js +12 -0
  64. package/dist/repl/open-questions-gate.js +159 -0
  65. package/dist/repl/ready-phrase.js +108 -0
  66. package/dist/repl/startup-account.js +64 -1
  67. package/dist/repl/team-build-screen.js +116 -0
  68. package/dist/repl/team-build-start.js +178 -0
  69. package/dist/repl.js +216 -11
  70. package/dist/sap-link/policy.js +4 -0
  71. package/dist/sap-link/registry.js +9 -3
  72. package/dist/sap-link/server.js +4 -1
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/user-prompt.js +2 -0
  75. package/dist/tools/_team-work-path.js +1 -1
  76. package/dist/tools/approval.js +12 -1
  77. package/dist/tools/cap/sap-link-tool.js +2 -0
  78. package/dist/tools/project/spec_answers_record.js +243 -0
  79. package/dist/tools/project/team_findings.js +2 -2
  80. package/dist/tools/seat-gate.js +41 -0
  81. package/dist/tools/tool-loading.js +2 -0
  82. package/dist/ui/approval-modal.js +2 -1
  83. package/dist/ui/widgets/ask-card.js +171 -29
  84. package/dist/ui/widgets/files-card.js +19 -1
  85. package/package.json +2 -2
@@ -1,8 +1,8 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { extractSpecGap } from './extract-spec-gap.js';
4
- import { extractDesign } from './extract-design.js';
5
- import { extractEstimate } from './extract-estimate.js';
3
+ import { extractSpecGap, hasSpecGapSign } from './extract-spec-gap.js';
4
+ import { extractDesign, hasDesignSign } from './extract-design.js';
5
+ import { extractEstimate, hasEstimateSign } from './extract-estimate.js';
6
6
  import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from './extract-upgrade.js';
7
7
  import { extractCcaAssessment } from './extract-cca.js';
8
8
  import { extractModernizeResult } from './extract-modernize.js';
@@ -24,21 +24,33 @@ 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',
30
36
  extract: (md) => extractSpecGap(md),
31
37
  itemNoun: 'questions',
38
+ hasDocumentSign: hasSpecGapSign,
39
+ documentNoun: 'a spec-gap document',
32
40
  },
33
41
  'abap-design': {
34
42
  artefactType: 'design',
35
43
  extract: (md) => extractDesign(md),
36
44
  itemNoun: 'objects',
45
+ hasDocumentSign: hasDesignSign,
46
+ documentNoun: 'a design document',
37
47
  },
38
48
  'abap-estimate': {
39
49
  artefactType: 'estimate',
40
50
  extract: (md) => extractEstimate(md),
41
51
  itemNoun: 'components',
52
+ hasDocumentSign: hasEstimateSign,
53
+ documentNoun: 'an estimate document',
42
54
  },
43
55
  'abap-upgrade-scan': {
44
56
  artefactType: 'upgrade-scan',
@@ -231,6 +243,18 @@ export function resolvePriorPlanEnvelope(title, dir, opts = {}) {
231
243
  opts.log?.(`[save] More than one plan is titled '${pick.envelope.title}'; revising the newest: ${basename(pick.path)}`);
232
244
  return pick;
233
245
  }
246
+ /**
247
+ * Task 17 (F-N) — the person's message asks us to save the document:
248
+ * "save this/it/that", "save the spec-gap/design/estimate/document",
249
+ * "can you save", "please save". Ordinary wording about saving in the app
250
+ * ("validation on save", "the user clicks Save", "saving changes") is not.
251
+ */
252
+ const ASKED_TO_SAVE_RE = new RegExp([
253
+ String.raw `\b(?:can|could|would|will)\s+you\s+(?:please\s+)?save\b`,
254
+ String.raw `\bplease\s+save\b`,
255
+ String.raw `\bsave\s+(?:this|it|that|them)\b`,
256
+ String.raw `\bsave\s+(?:the|my|our)\s+(?:spec[\s-]*gaps?|design|estimate|document|doc|result|analysis|file|project\s+file)\b`,
257
+ ].join('|'), 'i');
234
258
  export async function runSaveCommand(args) {
235
259
  const spec = SKILL_REGISTRY[args.skillName];
236
260
  if (!spec)
@@ -323,22 +347,31 @@ export async function runSaveCommand(args) {
323
347
  return runSaveCommand({ ...args, skillName: detected });
324
348
  }
325
349
  }
326
- if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
327
- args.log('');
328
- args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
329
- args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
330
- args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
350
+ if (spec.manifestMarker) {
351
+ // Task 17 fix round 1 (F-N): the manifest skills send every turn here.
352
+ // A turn with no manifest block is a conversational turn, not a failed
353
+ // save: say nothing (the B5 contract on manifestMarker above). A block
354
+ // that is present but fails to parse keeps its loud lines.
355
+ if (args.skillOutput.includes(spec.manifestMarker)) {
356
+ args.log('');
357
+ args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
358
+ args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
359
+ args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
360
+ }
331
361
  }
332
- else {
333
- // 2026-09-17 — marker-less skills (design, estimate, spec-gap) used to
334
- // fail here in silence: no file, no line, and the next --from step had
335
- // nothing to start from (seen live: a design titled "# ABAP Design"
336
- // instead of "## ABAP Design"). Say what happened and what to do.
337
- args.log('');
338
- args.log(`[save] ${args.skillName}: could not read the output as a ${spec.artefactType} — nothing was saved.`);
339
- args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
340
- args.log(`[save] Ask CSPeach to "re-issue the ${spec.artefactType} in the standard structure" and it will be saved then.`);
362
+ else if (spec.hasDocumentSign && spec.documentNoun) {
363
+ // Task 17 (F-N, 2026-10-26) — the save step runs on every turn of the
364
+ // session skill. A turn with no sign of the document (a recap after a
365
+ // choice card) is not a failed save: say nothing, unless the person
366
+ // asked to save. A text that does look like the document gets one
367
+ // plain line.
368
+ if (spec.hasDocumentSign(args.skillOutput) || ASKED_TO_SAVE_RE.test(args.skillInput ?? '')) {
369
+ args.log('');
370
+ args.log(`We couldn't save this as ${spec.documentNoun}. Ask us to write it again in the usual layout.`);
371
+ }
341
372
  }
373
+ // (2026-09-17's "[save] … could not read the output" lines are gone: every
374
+ // registry skill now has a manifest marker or a document sign above.)
342
375
  args.onNotSaved?.('unreadable');
343
376
  return null;
344
377
  }
@@ -401,7 +434,8 @@ export async function runSaveCommand(args) {
401
434
  const answer = journey ? 'y' : await askYesNo({
402
435
  question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt,
403
436
  // F-A (live check 2026-10-26) — a plan is only built from its saved file.
404
- ...(spec.artefactType === 'plan' ? { defaultYes: true } : {}),
437
+ // Task 20 (owner 2026-10-26) — the spec-gap document defaults to Yes too.
438
+ ...(spec.artefactType === 'plan' || spec.artefactType === 'spec-gap' ? { defaultYes: true } : {}),
405
439
  });
406
440
  if (!isYes(answer)) {
407
441
  args.onNotSaved?.('declined');
@@ -461,9 +495,9 @@ export async function runSaveCommand(args) {
461
495
  try {
462
496
  // Project view Task 5: the view is built once here and answers each card's target.
463
497
  const { registerFor } = await import('../register/picture.js');
464
- const { readProjectFile } = await import('../register/store.js');
498
+ const { coreProject, readProjectFile } = await import('../register/store.js');
465
499
  const { projectView } = await import('@cspeach/register-core');
466
- 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)));
467
501
  targets = (key) => view.object(key.trim().toUpperCase())?.target;
468
502
  }
469
503
  catch {
@@ -524,6 +558,35 @@ export async function runSaveCommand(args) {
524
558
  promotedFrom: args.promotedFrom ?? null,
525
559
  });
526
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
+ }
527
590
  // Saves land in the workspace folder, not cwd — created only NOW, after
528
591
  // the user confirmed (declined saves and conversational turns leave no
529
592
  // mkdir/.gitignore side effects). Falls back to args.cwd if the workspace
@@ -5,14 +5,15 @@
5
5
  * below the line" block (subject, body, review steps, sign-off). It looked
6
6
  * cluttered and signed itself with the OS account name ("Admin"). Now:
7
7
  *
8
- * Saved: <file name> (in this project's .cspeach folder)
8
+ * Saved: <full path> (in this project's .cspeach folder)
9
9
  * To review or share: open cspeach.dev/edit and drop the file in.
10
10
  *
11
11
  * Never prints a user name.
12
12
  */
13
- import { basename, dirname, resolve } from 'node:path';
13
+ import { dirname, resolve } from 'node:path';
14
14
  import { resolveProjectFolderSync } from './project-folder.js';
15
- function editorLabel() {
15
+ import { isSharedSpecPath } from './spec-share.js';
16
+ export function editorLabel() {
16
17
  const url = process.env.CSPEACH_EDITOR_URL?.trim();
17
18
  return url ? url : 'cspeach.dev/edit';
18
19
  }
@@ -24,23 +25,24 @@ function sameDir(a, b) {
24
25
  return norm(a) === norm(b);
25
26
  }
26
27
  /**
27
- * `<file name> (in <where>)` — names the folder kind (2026-10-08): this
28
- * project's .cspeach folder (solo), your own working folder (team), or the
29
- * home fallback folder with a hint. Anywhere else: the folder itself.
28
+ * `<full path> (in <where>)` — names the folder kind (2026-10-08): this
29
+ * project's .cspeach folder (solo), the shared team folder (a team spec),
30
+ * your own working folder (team), or the home fallback folder with a hint. Anywhere else: the full path alone.
31
+ * 2026-10-27 — the full path, not the bare file name: a standalone customer
32
+ * could not find the file (it sits in .cspeach, the Word copy in cspeach-out).
30
33
  */
31
34
  export function savedLocation(savedPath, cwd) {
32
- const dir = dirname(savedPath);
35
+ const full = resolve(savedPath);
33
36
  const pf = resolveProjectFolderSync(cwd);
34
- let where = dir;
35
- if (sameDir(dir, pf.workDir)) {
36
- if (pf.kind === 'solo')
37
- where = "this project's .cspeach folder";
38
- else if (pf.kind === 'team')
39
- where = 'your own working folder for this team project, not shared';
40
- else
41
- where = `${dir} — start CSPeach in a project folder to keep projects apart`;
42
- }
43
- return `${basename(savedPath)} (in ${where})`;
37
+ if (pf.kind === 'team' && isSharedSpecPath(pf.projectDir, savedPath))
38
+ return `${full} (in the shared team folder, for the developer)`;
39
+ if (!sameDir(dirname(full), pf.workDir))
40
+ return full;
41
+ if (pf.kind === 'solo')
42
+ return `${full} (in this project's .cspeach folder)`;
43
+ if (pf.kind === 'team')
44
+ return `${full} (in your own working folder for this team project, not shared)`;
45
+ return `${full} (start CSPeach in a project folder to keep projects apart)`;
44
46
  }
45
47
  export function formatSavedLines(savedPath, cwd) {
46
48
  return [
@@ -0,0 +1,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) {
@@ -103,6 +103,20 @@ export function diffBody(card, differ = defaultDiffer) {
103
103
  for (const l of part.value.replace(/\r?\n$/, '').split(/\r?\n/))
104
104
  changed.push(`${part.added ? '+' : '-'} ${clean(l)}`);
105
105
  }
106
+ // 1.1.53 (live F-E): the first write after a create replaces SAP's template,
107
+ // whose removed lines are only comments. Myers puts those first and hides the
108
+ // new code, so in that case the added code leads, then added comment/blank
109
+ // lines, then the removals. Any removal that carries code keeps Myers order.
110
+ const isCommentOrBlank = (l) => {
111
+ const code = l.slice(2);
112
+ return code.trim() === '' || code.startsWith('*') || /^\s*"/.test(code);
113
+ };
114
+ const removed = changed.filter((l) => l.startsWith('-'));
115
+ const added = changed.filter((l) => l.startsWith('+'));
116
+ const addedCode = added.filter((l) => !isCommentOrBlank(l));
117
+ if (removed.length > 0 && removed.every(isCommentOrBlank) && addedCode.length > 0) {
118
+ changed.splice(0, changed.length, ...addedCode, ...added.filter(isCommentOrBlank), ...removed);
119
+ }
106
120
  return { lines: changed.slice(0, DIFF_LINES), hidden: Math.max(0, changed.length - DIFF_LINES) };
107
121
  }
108
122
  export function buildChangeCard(input, width, box = getBoxChars()) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 1.1.53 item 5 — the model sometimes writes `&lt;` / `&gt;` / `&amp;` in the
3
+ * text and diffs it puts on an approval card. Cards show the real characters.
4
+ * One pass only: `&amp;lt;` becomes `&lt;`, never `<`.
5
+ *
6
+ * Display only. Never apply this to anything written to SAP or saved.
7
+ */
8
+ const ENTITY = /&(lt|gt|amp);/g;
9
+ const CHAR = { lt: '<', gt: '>', amp: '&' };
10
+ export function decodeDisplayEntities(s) {
11
+ return s.replace(ENTITY, (_m, name) => CHAR[name]);
12
+ }
13
+ /** A raw field symbol or raw markup: `<` followed by a letter, `_`, `/` or `!`. */
14
+ const RAW_TAG = /<[A-Za-z_/!]/;
15
+ /**
16
+ * The same decode for a whole source or diff block, except when the block
17
+ * holds a raw field symbol or raw markup (`<fs>`, `<b>`, `</p>`, `<!--`).
18
+ * A model that escapes field symbols escapes them throughout; real source
19
+ * that holds `&lt;` in a literal almost always has a raw `<fs>` or markup
20
+ * too, so it stays as written. Operators and comparisons (`->`, `=>`, `>=`,
21
+ * `<=`, `<>`, ` < `) do not count. Display only, like decodeDisplayEntities.
22
+ */
23
+ export function decodeDisplayBlock(s) {
24
+ return RAW_TAG.test(s) ? s : decodeDisplayEntities(s);
25
+ }