@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.
- package/dist/agent/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +150 -17
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +17 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +22 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +74 -4
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +33 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +133 -18
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +83 -20
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +239 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +159 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +116 -0
- package/dist/repl/team-build-start.js +178 -0
- package/dist/repl.js +216 -11
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/_team-work-path.js +1 -1
- package/dist/tools/approval.js +12 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +243 -0
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- package/dist/ui/widgets/files-card.js +19 -1
- 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]
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
* `/
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* `/cancel` is still handled by the classic loop but is not listed in the
|
|
61
|
+
* picker catalogue, so it is added here. (`/files` is listed since 2026-10-27;
|
|
62
|
+
* it stays here too, harmlessly.)
|
|
62
63
|
*/
|
|
63
64
|
const BUILTIN_HEADS = new Set([
|
|
64
65
|
...BUILTIN_ENTRIES.map((e) => e.name),
|
package/dist/repl/chat-lane.js
CHANGED
|
@@ -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;
|
package/dist/repl/file-picker.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* UX:
|
|
5
5
|
* 1. Search-as-you-type list of files (filename + artefactType + version)
|
|
6
6
|
* 2. Action menu tailored to the picked artefactType — pick one of:
|
|
7
|
-
* spec-gap →
|
|
7
|
+
* spec-gap → answer the questions (viewer steps), --from /abap-design, --status
|
|
8
8
|
* design → --status, --from /abap-estimate
|
|
9
9
|
* estimate → --status only
|
|
10
10
|
* unknown → all --status variants
|
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
* slash-picker.ts so cancellation in either step doesn't drop the user
|
|
17
17
|
* back to the parent shell.
|
|
18
18
|
*/
|
|
19
|
+
import path from 'node:path';
|
|
19
20
|
import { search, select } from '@inquirer/prompts';
|
|
20
21
|
import { withInquirer } from './inquirer-guard.js';
|
|
21
22
|
import { listProjectFiles, detectDesignShape } from '../projects/workspace.js';
|
|
23
|
+
import { editorLabel } from '../projects/saved-lines.js';
|
|
24
|
+
import { resolveProjectFolderSync } from '../projects/project-folder.js';
|
|
22
25
|
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
26
|
+
/** Spec gap "answer the questions" (2026-10-27): prints the viewer steps, runs nothing. */
|
|
27
|
+
const ANSWER_VALUE = '__cspeach_answer__';
|
|
23
28
|
/**
|
|
24
29
|
* Compute the menu of valid actions for an envelope of a given
|
|
25
30
|
* artefactType. Order: most-common first.
|
|
@@ -35,7 +40,8 @@ const CANCEL_VALUE = '__cspeach_cancel__';
|
|
|
35
40
|
function envelopeActionsFor(type, designShape = 'unknown') {
|
|
36
41
|
if (type === 'spec-gap') {
|
|
37
42
|
return [
|
|
38
|
-
{ label: '
|
|
43
|
+
{ label: 'Answer the questions (open in cspeach.dev/edit)', command: ANSWER_VALUE },
|
|
44
|
+
{ label: '/abap-design --from (use the answers → design)', command: '/abap-design --from' },
|
|
39
45
|
{ label: '/abap-spec-gap --status (inspect)', command: '/abap-spec-gap --status' },
|
|
40
46
|
];
|
|
41
47
|
}
|
|
@@ -153,6 +159,46 @@ function textActionsFor() {
|
|
|
153
159
|
* then dumped the full file list every time the user cancelled. This
|
|
154
160
|
* tagged result removes the conflation.
|
|
155
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* The steps printed for "Answer the questions" (2026-10-27): a standalone
|
|
164
|
+
* customer did not know how to enter answers. The viewer's own button names
|
|
165
|
+
* (Pick folder, Save back to folder, Download updated file).
|
|
166
|
+
*/
|
|
167
|
+
export function answerStepsText(file, workDir = workFolder()) {
|
|
168
|
+
const full = path.resolve(file.path);
|
|
169
|
+
// A file from an earlier location is read-only: its answers go to today's folder.
|
|
170
|
+
if (file.earlier) {
|
|
171
|
+
return [
|
|
172
|
+
'To answer the questions:',
|
|
173
|
+
` 1. Open ${editorLabel()} and drop ${full} on the page.`,
|
|
174
|
+
' 2. Type each answer and mark it answered.',
|
|
175
|
+
` 3. Choose Download updated file and put that file in ${workDir}`,
|
|
176
|
+
'Then type /files and pick the new version to go on from your answers.',
|
|
177
|
+
].join('\n');
|
|
178
|
+
}
|
|
179
|
+
const dir = path.dirname(full);
|
|
180
|
+
return [
|
|
181
|
+
'To answer the questions:',
|
|
182
|
+
` 1. Open ${editorLabel()}, choose Pick folder and pick ${dir}`,
|
|
183
|
+
` 2. Open ${file.filename}, type each answer and mark it answered.`,
|
|
184
|
+
' 3. Choose Save back to folder. It saves a new version next to this one.',
|
|
185
|
+
'Then type /files and pick the new version to go on from your answers.',
|
|
186
|
+
`No Pick folder button (it needs Edge or Chrome)? Drop ${full} on the page instead, choose Download updated file and put that file in ${dir}.`,
|
|
187
|
+
].join('\n');
|
|
188
|
+
}
|
|
189
|
+
/** Picked action → result: the answer action prints steps, every other one is a command. */
|
|
190
|
+
function resultFor(action, file) {
|
|
191
|
+
if (action === ANSWER_VALUE)
|
|
192
|
+
return { kind: 'info', text: answerStepsText(file) };
|
|
193
|
+
return { kind: 'command', command: `${action} @${file.filename}` };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* This project's file folder, where saves go — not a file's folder: the list
|
|
197
|
+
* can hold files from earlier locations (marked `earlier`) after today's.
|
|
198
|
+
*/
|
|
199
|
+
function workFolder() {
|
|
200
|
+
return path.resolve(resolveProjectFolderSync(process.cwd()).workDir);
|
|
201
|
+
}
|
|
156
202
|
/** Action menu for a picked file (shared by the inquirer and Ink paths). */
|
|
157
203
|
function actionsForFile(file) {
|
|
158
204
|
const designShape = file.artefactType === 'design' ? detectDesignShape(file.path) : 'unknown';
|
|
@@ -185,7 +231,7 @@ export async function openFilePicker(opts = {}) {
|
|
|
185
231
|
return await withInquirer(async () => {
|
|
186
232
|
// Step 1 — pick a file.
|
|
187
233
|
const fileResult = await search({
|
|
188
|
-
message:
|
|
234
|
+
message: `Pick a project file (in ${workFolder()})`,
|
|
189
235
|
source: async (input) => {
|
|
190
236
|
const filter = (input ?? '').toLowerCase();
|
|
191
237
|
const filtered = filter.length === 0
|
|
@@ -223,7 +269,7 @@ export async function openFilePicker(opts = {}) {
|
|
|
223
269
|
});
|
|
224
270
|
if (action === CANCEL_VALUE)
|
|
225
271
|
return { kind: 'cancelled' };
|
|
226
|
-
return
|
|
272
|
+
return resultFor(action, pickedFile);
|
|
227
273
|
});
|
|
228
274
|
}
|
|
229
275
|
catch {
|
|
@@ -238,6 +284,7 @@ async function openFilePickerInk(files) {
|
|
|
238
284
|
const picked = await withFocusClaim(() => filesCardEmitter.requestPick({
|
|
239
285
|
id: 'files',
|
|
240
286
|
title: 'Pick a project file',
|
|
287
|
+
folder: workFolder(),
|
|
241
288
|
items: files.map((f) => ({ value: f.filename, label: f.filename, detail: `${f.artefactType} v${f.version ?? '?'}` })),
|
|
242
289
|
}));
|
|
243
290
|
if (picked.value == null)
|
|
@@ -257,5 +304,5 @@ async function openFilePickerInk(files) {
|
|
|
257
304
|
}, { trace: false });
|
|
258
305
|
if (res.value == null || res.value === CANCEL_VALUE)
|
|
259
306
|
return { kind: 'cancelled' };
|
|
260
|
-
return
|
|
307
|
+
return resultFor(res.value, pickedFile);
|
|
261
308
|
}
|
package/dist/repl/mode-cycle.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
|
|
2
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
2
3
|
/**
|
|
3
4
|
* Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
|
|
4
5
|
*
|
|
@@ -29,14 +30,23 @@ export function getSessionWriteMode() {
|
|
|
29
30
|
}
|
|
30
31
|
/** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
|
|
31
32
|
export function getEffectiveWriteMode(cfgMode, role) {
|
|
33
|
+
// Team build Task 6 — a functional seat is pinned at advisory whatever the
|
|
34
|
+
// config, the role or a session override says.
|
|
35
|
+
if (isFunctionalSeat())
|
|
36
|
+
return 'advisory-only';
|
|
32
37
|
return clampToCeiling(sessionWriteMode ?? cfgMode, role);
|
|
33
38
|
}
|
|
39
|
+
/** Team build Task 6 — the /mode answer on a functional seat, for any argument. */
|
|
40
|
+
export const SEAT_MODE_LINE = 'mode stays advisory on this seat — it reads SAP and never changes it';
|
|
34
41
|
/**
|
|
35
42
|
* Cycle to the next mode the role allows (wraps). Sets the session override
|
|
36
43
|
* so the new mode takes effect immediately. Returns the new mode AND whether
|
|
37
44
|
* the requested step was capped (for the refusal line).
|
|
38
45
|
*/
|
|
39
46
|
export function cycleWriteMode(cfgMode, role) {
|
|
47
|
+
// Functional seat: pinned; the session override is left alone.
|
|
48
|
+
if (isFunctionalSeat())
|
|
49
|
+
return { mode: 'advisory-only', capped: true };
|
|
40
50
|
const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
|
|
41
51
|
// Modes at or below the ceiling, in cycling order (most→least restricted).
|
|
42
52
|
const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
|
|
@@ -77,6 +87,8 @@ function refusalLine(clamped, role, alias) {
|
|
|
77
87
|
* mode (caller pushes it into the SapStateStore so the statusline is live).
|
|
78
88
|
*/
|
|
79
89
|
export function runModeCommand(arg, cfgMode, role, alias) {
|
|
90
|
+
if (isFunctionalSeat())
|
|
91
|
+
return { line: SEAT_MODE_LINE, mode: 'advisory-only' };
|
|
80
92
|
const prev = getEffectiveWriteMode(cfgMode, role);
|
|
81
93
|
const normalized = arg?.trim().toLowerCase() ?? '';
|
|
82
94
|
if (normalized === '') {
|
|
@@ -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
|
+
}
|