@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
@@ -16,6 +16,12 @@
16
16
  // at the START of a line. It ends at a `-->` on the opener's own line, or
17
17
  // else at a `-->` that starts a later line (one newline straight after it is
18
18
  // dropped too). An inline mention mid-line is prose and stays.
19
+ // 1.1.53: a line that starts with a closing marker (`<!-- /cspeach:…` or
20
+ // `<!-- /csforge:…`) is a block like an opener. A one-line change options
21
+ // marker (`<!-- csforge:change-options OBJECT=X -->`, `<!-- /csforge:change-options -->`)
22
+ // is removed wherever it sits on a line; the text around it stays. Other
23
+ // mid-line mentions stay prose, and so does a mid-line marker whose `-->` is
24
+ // not on the same line.
19
25
  // A chunk boundary can fall anywhere: a line that could still become an
20
26
  // opener is held until it can be decided; inside a block a tail that could
21
27
  // still become the closing `-->` is held the same way.
@@ -24,8 +30,10 @@ export function initialMachineBlockState() {
24
30
  }
25
31
  // A leading `\n` marks a line start (the probe prepends one when the text
26
32
  // begins at a line start) — no `^`, which would match a mid-line chunk start.
27
- const OPENER_RE = /\n[ \t]*<!--[ \t]*(?:cspeach|csforge):/;
28
- const PREFIXES = ['cspeach:', 'csforge:'];
33
+ const OPENER_RE = /\n[ \t]*<!--[ \t]*\/?(?:cspeach|csforge):/;
34
+ const PREFIXES = ['cspeach:', 'csforge:', '/cspeach:', '/csforge:'];
35
+ const INLINE_MARKER_RE = /<!--[ \t]*\/?(?:cspeach|csforge):change-options\b[^\n]*?-->/g;
36
+ const INLINE_PREFIXES = ['cspeach:change-options', 'csforge:change-options'];
29
37
  const CLOSER_RE = /\n[ \t]*-->/;
30
38
  const PARTIAL_CLOSER_RE = /^\n[ \t]*-{0,2}$/;
31
39
  /** Could `frag` (one incomplete line) still grow into a block opener? */
@@ -38,6 +46,27 @@ function couldBeOpener(frag) {
38
46
  const r = s.slice(4).replace(/^[ \t]*/, '');
39
47
  return PREFIXES.some((p) => p.startsWith(r));
40
48
  }
49
+ /**
50
+ * Could `frag` (text from a mid-line `<`, no newline yet) still grow into a
51
+ * one-line change options marker? A full prefix stays held until its `-->`
52
+ * or the end of the line decides it.
53
+ */
54
+ function couldBeInlineMarker(frag) {
55
+ if (frag.includes('-->') || frag.includes('\n'))
56
+ return false;
57
+ if (frag.length <= 4)
58
+ return '<!--'.startsWith(frag);
59
+ if (!frag.startsWith('<!--'))
60
+ return false;
61
+ let r = frag.slice(4).replace(/^[ \t]*/, '');
62
+ if (r.startsWith('/'))
63
+ r = r.slice(1);
64
+ return INLINE_PREFIXES.some((p) => p.startsWith(r) || r.startsWith(p));
65
+ }
66
+ /** Remove every complete one-line change options marker. */
67
+ function stripInlineMarkers(text) {
68
+ return text.replace(INLINE_MARKER_RE, '');
69
+ }
41
70
  export function filterMachineBlocks(chunk, prev) {
42
71
  const st = { ...prev };
43
72
  let work = st.held + chunk;
@@ -103,7 +132,7 @@ export function filterMachineBlocks(chunk, prev) {
103
132
  if (m) {
104
133
  const offset = st.lineStart ? 1 : 0;
105
134
  const lineBegin = m.index + 1 - offset; // index in `work`, just after the newline
106
- output += work.slice(0, lineBegin);
135
+ output += stripInlineMarkers(work.slice(0, lineBegin));
107
136
  work = work.slice(m.index + m[0].length - offset);
108
137
  st.inBlock = true;
109
138
  st.firstLine = true;
@@ -113,14 +142,32 @@ export function filterMachineBlocks(chunk, prev) {
113
142
  const lastNl = work.lastIndexOf('\n');
114
143
  const fragStart = lastNl >= 0 ? lastNl + 1 : st.lineStart ? 0 : -1;
115
144
  if (fragStart >= 0 && fragStart < work.length && couldBeOpener(work.slice(fragStart))) {
116
- output += work.slice(0, fragStart);
145
+ output += stripInlineMarkers(work.slice(0, fragStart));
117
146
  st.held = work.slice(fragStart);
118
147
  st.lineStart = true;
119
148
  }
120
149
  else {
121
- output += work;
122
- if (work.length > 0)
123
- st.lineStart = work.endsWith('\n');
150
+ // Remove complete one-line markers; hold a mid-line tail that could
151
+ // still become one until its `-->` or the newline decides it.
152
+ const shown = stripInlineMarkers(work);
153
+ const tailStart = shown.lastIndexOf('\n') + 1;
154
+ let holdAt = -1;
155
+ for (let i = shown.indexOf('<', tailStart); i >= 0; i = shown.indexOf('<', i + 1)) {
156
+ if (couldBeInlineMarker(shown.slice(i))) {
157
+ holdAt = i;
158
+ break;
159
+ }
160
+ }
161
+ if (holdAt >= 0) {
162
+ output += shown.slice(0, holdAt);
163
+ st.held = shown.slice(holdAt);
164
+ st.lineStart = holdAt === 0 ? st.lineStart : shown[holdAt - 1] === '\n';
165
+ }
166
+ else {
167
+ output += shown;
168
+ if (shown.length > 0)
169
+ st.lineStart = shown.endsWith('\n');
170
+ }
124
171
  }
125
172
  break;
126
173
  }
@@ -168,6 +168,7 @@ const RULES = {
168
168
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
169
169
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
170
170
  team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
171
+ spec_answers_record: (a) => ({ verb: 'Recorded answers in', object: shortLeft(str(a['path']) ?? '') }),
171
172
  team_lane_move: (a) => ({ verb: 'Move packages to another person', object: shortRight(Array.isArray(a['packages']) ? a['packages'].map(String).join(', ') : 'packages') }),
172
173
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
173
174
  sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
@@ -289,6 +290,7 @@ const PRESENT = {
289
290
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
290
291
  'Checked capability': 'Checking capability',
291
292
  'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Check a proposed decision rule': 'Checking a proposed decision rule', 'Read estate inventory': 'Reading estate inventory', 'Move packages to another person': 'Moving packages to another person', 'Wrote texts': 'Writing texts',
293
+ 'Recorded answers in': 'Recording answers in',
292
294
  };
293
295
  export function toolActivityLabel(name, args) {
294
296
  const l = toolLabel(name, args);
@@ -6,6 +6,16 @@ import { toolLabel, toolActivityLabel, foldOutput, clean } from './tool-labels.j
6
6
  import { getEffectiveBodyWidth } from './tty.js';
7
7
  import { flowFor, stdoutFlow, writeStdout, canAnimateInPlace } from './transcript-flow.js';
8
8
  import { humanErrorDetail } from './error-detail.js';
9
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
10
+ /**
11
+ * 1.1.53 item 10 — a call stopped before it started (Esc / Ctrl+C) changed
12
+ * nothing: the row is a neutral dim stop, not a red failure. The model still
13
+ * receives STOPPED_BEFORE_START_RESULT unchanged; this is display only.
14
+ */
15
+ const STOPPED_BEFORE_START_LINE = 'Stopped before it started. Nothing changed in SAP.';
16
+ function stoppedBeforeStart(isError, content) {
17
+ return isError && content === STOPPED_BEFORE_START_RESULT;
18
+ }
9
19
  export { canAnimateInPlace };
10
20
  /**
11
21
  * Tool-dispatch renderer — CC-style two-line format with peach spinner.
@@ -316,7 +326,8 @@ export function renderToolCallBottom(params) {
316
326
  write(panelsRow({ name: name ?? '', args: args ?? {}, isError, resultSummary, resultContent, durationMs }));
317
327
  return;
318
328
  }
319
- const icon = isError ? chalk.red('✗') : chalk.green('✓');
329
+ const stopped = stoppedBeforeStart(isError, resultContent);
330
+ const icon = stopped ? chalk.dim('·') : isError ? chalk.red('✗') : chalk.green('✓');
320
331
  // Label: plain (not bold — the top line keeps visual primacy) name +
321
332
  // dim arg summary, mirroring the top-line shape so orphaned rows still
322
333
  // read as "which tool, on what object".
@@ -326,9 +337,9 @@ export function renderToolCallBottom(params) {
326
337
  const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
327
338
  label = ` ${name}${wrapped}`;
328
339
  }
329
- const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
340
+ const detailRaw = stopped ? STOPPED_BEFORE_START_LINE : resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
330
341
  const detail = detailRaw
331
- ? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
342
+ ? (stopped ? chalk.dim(detailRaw) : isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
332
343
  : '';
333
344
  const timing = chalk.dim(formatDuration(durationMs));
334
345
  const sep = chalk.dim('·');
@@ -424,7 +435,8 @@ function panelsRow(p) {
424
435
  // Round 8 item 2 — a cancelled / rejected approval is not a success: no
425
436
  // green tick, and the row says what happened.
426
437
  const declined = !p.isError && p.name === 'request_approval' ? approvalDeclined(p.resultContent) : undefined;
427
- const icon = p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
438
+ const stopped = stoppedBeforeStart(p.isError, p.resultContent);
439
+ const icon = stopped ? paint('dim', '·') : p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
428
440
  const timing = p.durationMs >= 1000 ? formatDuration(p.durationMs) : '';
429
441
  // 1.1.31: a setting said yes, not a person — the row says so.
430
442
  const auto = !p.isError && !declined && p.name === 'request_approval' && approvalAutoApproved(p.resultContent);
@@ -440,7 +452,10 @@ function panelsRow(p) {
440
452
  // Live fix 2026-09-26: a JSON tool result (e.g. sap_activate's
441
453
  // activation_results) is turned into readable lines, never shown raw.
442
454
  const errorSource = p.resultContent?.trim().startsWith('{') ? p.resultContent : p.resultSummary;
443
- if (p.isError && errorSource) {
455
+ if (stopped) {
456
+ out.push(' ' + paint('dim', truncate(STOPPED_BEFORE_START_LINE, width - 4)));
457
+ }
458
+ else if (p.isError && errorSource) {
444
459
  // Raw error text can carry ANSI codes, \r redraws and tabs: clean() each
445
460
  // line (the one sanitiser, shared with the labels) before truncate + paint.
446
461
  for (const d of humanErrorDetail(errorSource).map(clean).filter(Boolean).slice(0, ERROR_DETAIL_LINES)) {
@@ -34,6 +34,7 @@ export const BUILTIN_ENTRIES = [
34
34
  { name: '/usage', get description() { return usageCommandDescription(); } },
35
35
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
36
36
  { name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy: of the last answer (/export docx, or /export docx <name>), of the whole conversation (/export docx conversation) or of a Markdown file (/export docx <file>.md)' },
37
+ { name: '/files', description: 'Pick a saved project file and what to do with it' },
37
38
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
38
39
  { name: '/exit', description: 'Exit CSPeach' },
39
40
  { name: '/quit', description: 'Exit CSPeach (alias)' },
@@ -56,9 +57,9 @@ export const BUILTIN_ENTRIES = [
56
57
  ];
57
58
  /**
58
59
  * Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
59
- * `/files` is a real REPL builtin (repl.tsx handles it in both paths) and
60
- * `/cancel` is still handled by the classic loop, but neither is listed in the
61
- * picker catalogue, so both are added here.
60
+ * `/cancel` is still handled by the classic loop but is not listed in the
61
+ * picker catalogue, so it is added here. (`/files` is listed since 2026-10-27;
62
+ * it stays here too, harmlessly.)
62
63
  */
63
64
  const BUILTIN_HEADS = new Set([
64
65
  ...BUILTIN_ENTRIES.map((e) => e.name),
@@ -85,9 +85,18 @@ const THANKS_PHRASES = new Set(['thanks', 'thank you', 'thank u', 'thx', 'tx', '
85
85
  function isThanksOnly(trimmed) {
86
86
  return THANKS_PHRASES.has(stripOkPrefix(trimmed).toLowerCase().replace(/[.!?,]+$/, ''));
87
87
  }
88
+ /** Change options (1.1.53): the reply names a level (1-4) AND the document's
89
+ * object (case-insensitive). */
90
+ export function isLevelDecisionFor(trimmed, last) {
91
+ return /\blevel\s*[1-4]\b/i.test(trimmed) && trimmed.toUpperCase().includes(last.object.toUpperCase());
92
+ }
88
93
  /** Today's continuation predicate (repl.tsx Ink + classic paths), as one
89
- * function: the skill that keeps the turn, or null. */
94
+ * function: the skill that keeps the turn, or null. A level decision for the
95
+ * last change options object goes back to the skill that showed it (only the
96
+ * skills-lane call sites pass lastChangeOptions). */
90
97
  export function wantsContinuation(i) {
98
+ if (i.lastChangeOptions && isLevelDecisionFor(i.trimmed, i.lastChangeOptions))
99
+ return i.lastChangeOptions.skill;
91
100
  if (!i.sessionSkill)
92
101
  return null;
93
102
  return (i.awaitingSkillAnswer || (i.resumedSkill !== null && isShortFollowUp(i.trimmed)) || isAckContinuation(i.trimmed, i.sessionSkill)) ? i.sessionSkill : null;
@@ -4,7 +4,7 @@
4
4
  * UX:
5
5
  * 1. Search-as-you-type list of files (filename + artefactType + version)
6
6
  * 2. Action menu tailored to the picked artefactType — pick one of:
7
- * spec-gap → --status, --from /abap-design
7
+ * spec-gap → answer the questions (viewer steps), --from /abap-design, --status
8
8
  * design → --status, --from /abap-estimate
9
9
  * estimate → --status only
10
10
  * unknown → all --status variants
@@ -16,10 +16,15 @@
16
16
  * slash-picker.ts so cancellation in either step doesn't drop the user
17
17
  * back to the parent shell.
18
18
  */
19
+ import path from 'node:path';
19
20
  import { search, select } from '@inquirer/prompts';
20
21
  import { withInquirer } from './inquirer-guard.js';
21
22
  import { listProjectFiles, detectDesignShape } from '../projects/workspace.js';
23
+ import { editorLabel } from '../projects/saved-lines.js';
24
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
22
25
  const CANCEL_VALUE = '__cspeach_cancel__';
26
+ /** Spec gap "answer the questions" (2026-10-27): prints the viewer steps, runs nothing. */
27
+ const ANSWER_VALUE = '__cspeach_answer__';
23
28
  /**
24
29
  * Compute the menu of valid actions for an envelope of a given
25
30
  * artefactType. Order: most-common first.
@@ -35,7 +40,8 @@ const CANCEL_VALUE = '__cspeach_cancel__';
35
40
  function envelopeActionsFor(type, designShape = 'unknown') {
36
41
  if (type === 'spec-gap') {
37
42
  return [
38
- { label: '/abap-design --from (promote → design)', command: '/abap-design --from' },
43
+ { label: 'Answer the questions (open in cspeach.dev/edit)', command: ANSWER_VALUE },
44
+ { label: '/abap-design --from (use the answers → design)', command: '/abap-design --from' },
39
45
  { label: '/abap-spec-gap --status (inspect)', command: '/abap-spec-gap --status' },
40
46
  ];
41
47
  }
@@ -153,6 +159,46 @@ function textActionsFor() {
153
159
  * then dumped the full file list every time the user cancelled. This
154
160
  * tagged result removes the conflation.
155
161
  */
162
+ /**
163
+ * The steps printed for "Answer the questions" (2026-10-27): a standalone
164
+ * customer did not know how to enter answers. The viewer's own button names
165
+ * (Pick folder, Save back to folder, Download updated file).
166
+ */
167
+ export function answerStepsText(file, workDir = workFolder()) {
168
+ const full = path.resolve(file.path);
169
+ // A file from an earlier location is read-only: its answers go to today's folder.
170
+ if (file.earlier) {
171
+ return [
172
+ 'To answer the questions:',
173
+ ` 1. Open ${editorLabel()} and drop ${full} on the page.`,
174
+ ' 2. Type each answer and mark it answered.',
175
+ ` 3. Choose Download updated file and put that file in ${workDir}`,
176
+ 'Then type /files and pick the new version to go on from your answers.',
177
+ ].join('\n');
178
+ }
179
+ const dir = path.dirname(full);
180
+ return [
181
+ 'To answer the questions:',
182
+ ` 1. Open ${editorLabel()}, choose Pick folder and pick ${dir}`,
183
+ ` 2. Open ${file.filename}, type each answer and mark it answered.`,
184
+ ' 3. Choose Save back to folder. It saves a new version next to this one.',
185
+ 'Then type /files and pick the new version to go on from your answers.',
186
+ `No Pick folder button (it needs Edge or Chrome)? Drop ${full} on the page instead, choose Download updated file and put that file in ${dir}.`,
187
+ ].join('\n');
188
+ }
189
+ /** Picked action → result: the answer action prints steps, every other one is a command. */
190
+ function resultFor(action, file) {
191
+ if (action === ANSWER_VALUE)
192
+ return { kind: 'info', text: answerStepsText(file) };
193
+ return { kind: 'command', command: `${action} @${file.filename}` };
194
+ }
195
+ /**
196
+ * This project's file folder, where saves go — not a file's folder: the list
197
+ * can hold files from earlier locations (marked `earlier`) after today's.
198
+ */
199
+ function workFolder() {
200
+ return path.resolve(resolveProjectFolderSync(process.cwd()).workDir);
201
+ }
156
202
  /** Action menu for a picked file (shared by the inquirer and Ink paths). */
157
203
  function actionsForFile(file) {
158
204
  const designShape = file.artefactType === 'design' ? detectDesignShape(file.path) : 'unknown';
@@ -185,7 +231,7 @@ export async function openFilePicker(opts = {}) {
185
231
  return await withInquirer(async () => {
186
232
  // Step 1 — pick a file.
187
233
  const fileResult = await search({
188
- message: 'Pick a project file',
234
+ message: `Pick a project file (in ${workFolder()})`,
189
235
  source: async (input) => {
190
236
  const filter = (input ?? '').toLowerCase();
191
237
  const filtered = filter.length === 0
@@ -223,7 +269,7 @@ export async function openFilePicker(opts = {}) {
223
269
  });
224
270
  if (action === CANCEL_VALUE)
225
271
  return { kind: 'cancelled' };
226
- return { kind: 'command', command: `${action} @${pickedFile.filename}` };
272
+ return resultFor(action, pickedFile);
227
273
  });
228
274
  }
229
275
  catch {
@@ -238,6 +284,7 @@ async function openFilePickerInk(files) {
238
284
  const picked = await withFocusClaim(() => filesCardEmitter.requestPick({
239
285
  id: 'files',
240
286
  title: 'Pick a project file',
287
+ folder: workFolder(),
241
288
  items: files.map((f) => ({ value: f.filename, label: f.filename, detail: `${f.artefactType} v${f.version ?? '?'}` })),
242
289
  }));
243
290
  if (picked.value == null)
@@ -257,5 +304,5 @@ async function openFilePickerInk(files) {
257
304
  }, { trace: false });
258
305
  if (res.value == null || res.value === CANCEL_VALUE)
259
306
  return { kind: 'cancelled' };
260
- return { kind: 'command', command: `${res.value} @${pickedFile.filename}` };
307
+ return resultFor(res.value, pickedFile);
261
308
  }
@@ -1,4 +1,5 @@
1
1
  import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
2
+ import { isFunctionalSeat } from '../licence/seat-state.js';
2
3
  /**
3
4
  * Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
4
5
  *
@@ -29,14 +30,23 @@ export function getSessionWriteMode() {
29
30
  }
30
31
  /** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
31
32
  export function getEffectiveWriteMode(cfgMode, role) {
33
+ // Team build Task 6 — a functional seat is pinned at advisory whatever the
34
+ // config, the role or a session override says.
35
+ if (isFunctionalSeat())
36
+ return 'advisory-only';
32
37
  return clampToCeiling(sessionWriteMode ?? cfgMode, role);
33
38
  }
39
+ /** Team build Task 6 — the /mode answer on a functional seat, for any argument. */
40
+ export const SEAT_MODE_LINE = 'mode stays advisory on this seat — it reads SAP and never changes it';
34
41
  /**
35
42
  * Cycle to the next mode the role allows (wraps). Sets the session override
36
43
  * so the new mode takes effect immediately. Returns the new mode AND whether
37
44
  * the requested step was capped (for the refusal line).
38
45
  */
39
46
  export function cycleWriteMode(cfgMode, role) {
47
+ // Functional seat: pinned; the session override is left alone.
48
+ if (isFunctionalSeat())
49
+ return { mode: 'advisory-only', capped: true };
40
50
  const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
41
51
  // Modes at or below the ceiling, in cycling order (most→least restricted).
42
52
  const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
@@ -77,6 +87,8 @@ function refusalLine(clamped, role, alias) {
77
87
  * mode (caller pushes it into the SapStateStore so the statusline is live).
78
88
  */
79
89
  export function runModeCommand(arg, cfgMode, role, alias) {
90
+ if (isFunctionalSeat())
91
+ return { line: SEAT_MODE_LINE, mode: 'advisory-only' };
80
92
  const prev = getEffectiveWriteMode(cfgMode, role);
81
93
  const normalized = arg?.trim().toLowerCase() ?? '';
82
94
  if (normalized === '') {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Team build (spec 2026-10-25) — open questions travel with a shared spec.
3
+ *
4
+ * Before `/abap-app --from @specs/<slug>/<file>` runs in a team build folder,
5
+ * the spec's open questions (the author's file with the answer records
6
+ * applied, R11) are shown, BLOCKER first, with one card: answer them now (each
7
+ * answer a record of its own; the author's file is never rewritten), go ahead
8
+ * with them open (they ride in the command as assumptions), or stop.
9
+ *
10
+ * Anything that is not `/abap-app --from @…` naming a file under the shared
11
+ * `specs/` returns null: the command goes on exactly as before. The build
12
+ * folder is the folder CSPeach runs in (R10).
13
+ */
14
+ import path from 'node:path';
15
+ const PREFIX = /^\/abap-app\s+--from\s+@/;
16
+ export const ANSWER = 'Answer them now';
17
+ export const GO_AHEAD = 'Go ahead with them open';
18
+ export const STOP = 'Stop';
19
+ export const STOPPED_LINE = 'Stopped. The spec stays ready for the developer.';
20
+ /**
21
+ * R14 — the specs this run marked "being built" from the start screen. A Stop
22
+ * at the gate puts such a spec back to "ready for build" (a new record, same
23
+ * `by`), so the Stopped line is true and the spec is offered again.
24
+ */
25
+ const markedThisRun = new Set();
26
+ const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
27
+ export function noteBeingBuiltThisRun(specDir) { markedThisRun.add(dirKey(specDir)); }
28
+ function openQuestions(env) {
29
+ if (env.artefactType !== 'spec-gap')
30
+ return [];
31
+ const open = new Set(env.interaction.items.filter((i) => i.status === 'open').map((i) => i.id));
32
+ const qs = [];
33
+ for (const s of env.content.sections)
34
+ for (const g of s.gaps)
35
+ if (open.has(g.id))
36
+ qs.push({ id: g.id, severity: g.severity, text: g.text });
37
+ return [...qs.filter((q) => q.severity === 'BLOCKER'), ...qs.filter((q) => q.severity !== 'BLOCKER')];
38
+ }
39
+ /** The start-up card (Ink or classic) as the gate's card: a choice index, or null. */
40
+ export function gateChooser(choose) {
41
+ return async (q) => {
42
+ const v = await choose({ title: q.title, facts: q.facts, choices: q.choices.map((label, i) => ({ value: String(i), label })) });
43
+ if (v === null)
44
+ return null;
45
+ const n = Number(v);
46
+ return Number.isInteger(n) ? n : null;
47
+ };
48
+ }
49
+ const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
50
+ export async function openQuestionsGate(a) {
51
+ if (!PREFIX.test(a.command))
52
+ return null;
53
+ const { parseFromFlag } = await import('../skills/promotion-dispatch.js');
54
+ const { fromPath } = parseFromFlag(a.command);
55
+ if (!fromPath)
56
+ return null;
57
+ const { buildFolderAt } = await import('../commands/team-spec.js');
58
+ const here = buildFolderAt(a.cwd);
59
+ if (!here)
60
+ return null;
61
+ const { resolveAtTokenAsync } = await import('../projects/workspace.js');
62
+ let abs;
63
+ try {
64
+ const r = await resolveAtTokenAsync(fromPath, a.cwd);
65
+ if (r.kind !== 'path')
66
+ return null;
67
+ abs = path.resolve(a.cwd, r.path);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ const share = await import('../projects/spec-share.js');
73
+ if (!share.isSharedSpecPath(here.dir, abs) || !abs.endsWith('.cspeach.json'))
74
+ return null;
75
+ const { readProjectFile } = await import('../projects/status.js');
76
+ const specDir = path.dirname(abs);
77
+ const file = path.basename(abs);
78
+ // R11: only the answers recorded for this envelope.
79
+ const read = (p) => share.composeSpec(readProjectFile(p), share.readAnswers(path.dirname(p)).filter((x) => x.file === path.basename(p)));
80
+ let env;
81
+ try {
82
+ env = read(abs);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ if (env.artefactType !== 'spec-gap')
88
+ return null;
89
+ // Final review F5: every return that lets the build go on forgets this run's
90
+ // "being built" mark, so a later hand-typed Stop on the same spec puts nothing back.
91
+ const goOn = (r) => { markedThisRun.delete(dirKey(specDir)); return r; };
92
+ const carried = (qs) => goOn({ command: `${a.command}\nOpen questions carried as assumptions:\n${qs.map((q) => `- ${q.id}: ${q.text}`).join('\n')}` });
93
+ for (;;) {
94
+ const qs = openQuestions(env);
95
+ if (!qs.length)
96
+ return goOn({ command: a.command });
97
+ if (a.headless)
98
+ return carried(qs);
99
+ for (const q of qs)
100
+ a.emit(` - [${q.severity}] ${q.text}`);
101
+ // Only blockers can be answered here: with none open, the card leaves that choice out.
102
+ const choices = qs.some((q) => q.severity === 'BLOCKER') ? [ANSWER, GO_AHEAD, STOP] : [GO_AHEAD, STOP];
103
+ const pick = await a.choose({ title: cardTitle(qs.length), facts: [], choices });
104
+ const label = pick === null ? undefined : choices[pick];
105
+ if (label === GO_AHEAD)
106
+ return carried(qs);
107
+ if (label !== ANSWER) {
108
+ putBackReady(share, specDir, a.now);
109
+ return { stop: true };
110
+ }
111
+ const { offerAnswerBlockers } = await import('../projects/answer-blockers.js');
112
+ await offerAnswerBlockers({
113
+ path: abs,
114
+ identity: a.identity,
115
+ prompt: a.prompt ?? (async () => ''),
116
+ // "Answer them now" was the yes: the flow does not ask it again.
117
+ confirm: async () => 'y',
118
+ ...(a.answer ? { choose: a.answer } : {}),
119
+ log: (...lines) => lines.forEach((l) => a.emit(l)),
120
+ ...(a.now ? { now: a.now } : {}),
121
+ readEnvelope: read,
122
+ recordAnswer: (r) => {
123
+ share.writeAnswerRecord(specDir, {
124
+ projectId: here.project.projectId, slug: path.basename(specDir), file,
125
+ gapId: r.gapId, answer: r.answer, by: share.specAuthorFor(here.project.projectId, r.identity.name), at: r.at,
126
+ });
127
+ },
128
+ });
129
+ try {
130
+ env = read(abs);
131
+ }
132
+ catch {
133
+ return goOn({ command: a.command });
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * R14 — Stop on a spec this run marked "being built": add a "ready for build"
139
+ * record back (records stay add-only), with the same `by`. A spec typed by
140
+ * hand, or marked by an earlier run, is left as it is.
141
+ */
142
+ function putBackReady(share, specDir, now) {
143
+ const k = dirKey(specDir);
144
+ if (!markedThisRun.has(k))
145
+ return;
146
+ try {
147
+ const st = share.readSpecStatus(specDir);
148
+ if (st.current !== 'being built' || !st.record)
149
+ return;
150
+ const r = st.record;
151
+ // Later than the "being built" record: at the same instant the later step would win.
152
+ const t = Math.max(Date.parse((now ?? (() => new Date().toISOString()))()), Date.parse(r.at) + 1);
153
+ share.writeStatusRecord(specDir, {
154
+ projectId: r.projectId, slug: r.slug, file: r.file, status: 'ready for build', by: r.by, at: new Date(t).toISOString(),
155
+ });
156
+ markedThisRun.delete(k);
157
+ }
158
+ catch { /* the Stop still stops */ }
159
+ }
@@ -0,0 +1,108 @@
1
+ // No static imports beyond types: repl.tsx imports this module at start-up,
2
+ // and the folder code loads only when the phrase matches.
3
+ const PHRASE = /\bready\s+for\s+(?:the\s+)?(?:developer|dev|build)\b|\bmark\s+(?:it|this|the\s+spec)\s+(?:as\s+)?ready\b/i;
4
+ const QUESTION_START = /^(?:is|are|can|could|should|when|what|do|does|how|why|will|would|has|have|if|whether|once|after|until)\b/i;
5
+ /** R13: "tell me when it is ready for build" asks to be told; it marks nothing. */
6
+ const ASK_START = /^(?:tell|let)\s+me\s+(?:know\s+)?when\b/i;
7
+ /**
8
+ * R13: a negation anywhere ("not ready", "don't mark it", "isn't ready") marks
9
+ * nothing — typed with no apostrophe too ("cant", "isnt", "dont", final review F5).
10
+ */
11
+ const NEGATION = /\b(?:not|never|no|cannot|cant|isnt|dont|wont|arent|aint)\b|n['’]t\b/i;
12
+ /** Final review F5: a leading "please" changes nothing; the checks read what follows it. */
13
+ const PLEASE = /^please\b[\s,]*/i;
14
+ export function isReadyForDeveloperPhrase(input) {
15
+ const typed = String(input ?? '').trim();
16
+ if (!typed || typed.length > 200)
17
+ return false;
18
+ if (typed.startsWith('/') || typed.endsWith('?'))
19
+ return false;
20
+ const s = typed.replace(PLEASE, '');
21
+ if (QUESTION_START.test(s) || ASK_START.test(s))
22
+ return false;
23
+ if (NEGATION.test(s))
24
+ return false;
25
+ return PHRASE.test(s);
26
+ }
27
+ export async function markReadyFlow(a) {
28
+ const { NO_DRAFT_LINE, buildFolderAt, draftSpecs, markSpecReady, markedReadyLine } = await import('../commands/team-spec.js');
29
+ const here = buildFolderAt(a.cwd);
30
+ if (!here)
31
+ return false;
32
+ const drafts = draftSpecs(here.dir);
33
+ if (!drafts.length) {
34
+ a.emit(NO_DRAFT_LINE);
35
+ return true;
36
+ }
37
+ let pick = 0;
38
+ if (drafts.length > 1) {
39
+ const i = await a.choose(drafts.map((s) => s.title));
40
+ if (i === null || !Number.isInteger(i) || i < 0 || i >= drafts.length) {
41
+ a.emit('Nothing marked.');
42
+ return true;
43
+ }
44
+ pick = i;
45
+ }
46
+ const spec = drafts[pick];
47
+ try {
48
+ markSpecReady(spec, here.project.projectId, a.by, (a.now ?? (() => new Date()))(), a.rand);
49
+ }
50
+ catch (e) {
51
+ a.emit(couldNotMarkLine(e));
52
+ return true;
53
+ }
54
+ a.emit(markedReadyLine(spec.title));
55
+ return true;
56
+ }
57
+ /**
58
+ * The REPL front door. False = not ours: the text goes on to the model as
59
+ * before. Ordinary text never reads the folder.
60
+ */
61
+ export async function readyForDeveloperDoor(input, a) {
62
+ if (!isReadyForDeveloperPhrase(input))
63
+ return false;
64
+ const { buildFolderAt } = await import('../commands/team-spec.js');
65
+ const here = buildFolderAt(a.cwd);
66
+ if (!here)
67
+ return false;
68
+ // From here the phrase is ours: any failure is one plain line, never a throw.
69
+ try {
70
+ const { specAuthorFor } = await import('../projects/spec-share.js');
71
+ return await markReadyFlow({
72
+ cwd: here.dir, by: specAuthorFor(here.project.projectId, a.identityName), emit: a.emit, choose: a.choose,
73
+ ...(a.now ? { now: a.now } : {}), ...(a.rand ? { rand: a.rand } : {}),
74
+ });
75
+ }
76
+ catch (e) {
77
+ a.emit(couldNotMarkLine(e));
78
+ return true;
79
+ }
80
+ }
81
+ function couldNotMarkLine(e) {
82
+ const reason = (e instanceof Error ? e.message : String(e)).split('\n')[0];
83
+ return `Could not mark it ready: ${reason}`;
84
+ }
85
+ const CHOOSE_TITLE = 'Which spec is ready for the developer?';
86
+ /** Ink: a question card, one choice per spec title. Cancelled = null. */
87
+ export function inkSpecChooser() {
88
+ return async (titles) => {
89
+ const { askConfirm } = await import('../ui/confirm-request.js');
90
+ const r = await askConfirm({
91
+ id: 'spec-ready', tag: 'QUESTION', title: CHOOSE_TITLE,
92
+ choices: titles.map((t, i) => ({ value: String(i), label: t })), initial: 0,
93
+ });
94
+ if (r.cancelled || r.value === null)
95
+ return null;
96
+ const n = Number(r.value);
97
+ return Number.isInteger(n) ? n : null;
98
+ };
99
+ }
100
+ /** Classic: numbered lines and one typed answer. Anything else = null. */
101
+ export function classicSpecChooser(prompt, log) {
102
+ return async (titles) => {
103
+ log(CHOOSE_TITLE);
104
+ titles.forEach((t, i) => log(` ${i + 1}. ${t}`));
105
+ const n = Number((await prompt(`Choose 1-${titles.length}: `)).trim());
106
+ return Number.isInteger(n) && n >= 1 && n <= titles.length ? n - 1 : null;
107
+ };
108
+ }