@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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { writeFileSync } from 'node:fs';
|
|
1
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { readProjectFile } from './status.js';
|
|
3
3
|
import { validateEnvelope } from './validate.js';
|
|
4
|
-
import { askYesNo } from './yes-no.js';
|
|
4
|
+
import { askYesNo, isYes } from './yes-no.js';
|
|
5
5
|
/**
|
|
6
6
|
* The still-open BLOCKER-severity questions of a spec-gap envelope, in item
|
|
7
7
|
* order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
|
|
@@ -59,6 +59,33 @@ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
|
|
|
59
59
|
lastEditedBy: answerer,
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Task 18 (F-O, 2026-10-26) — the one save step shared by the blocker form
|
|
64
|
+
* (offerAnswerBlockers) and the spec_answers_record tool: fold the answers in
|
|
65
|
+
* (applyBlockerAnswers), validate the revised envelope, then write it in
|
|
66
|
+
* place. Nothing is written when validation fails.
|
|
67
|
+
*/
|
|
68
|
+
export function saveBlockerAnswers(path, env, answers, answerer, nowIso) {
|
|
69
|
+
const revised = applyBlockerAnswers(env, answers, answerer, nowIso);
|
|
70
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
|
|
71
|
+
if (!check.ok)
|
|
72
|
+
return { ok: false, error: check.error.message };
|
|
73
|
+
// Fix round 1 (item 6): atomic — a temp file in the same folder, then a
|
|
74
|
+
// rename over the spec file, so a crash never leaves half a file.
|
|
75
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tmp, JSON.stringify(revised, null, 2), 'utf8');
|
|
78
|
+
renameSync(tmp, path);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
try {
|
|
82
|
+
rmSync(tmp, { force: true });
|
|
83
|
+
}
|
|
84
|
+
catch { /* best effort */ }
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, revised };
|
|
88
|
+
}
|
|
62
89
|
function plural(n, word) {
|
|
63
90
|
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
64
91
|
}
|
|
@@ -66,6 +93,29 @@ function affirmative(reply) {
|
|
|
66
93
|
const v = reply.trim().toLowerCase();
|
|
67
94
|
return v === 'y' || v === 'yes';
|
|
68
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Task 15 (F-K, 2026-10-26): whether a picked answer is made only of the
|
|
98
|
+
* blocker's own options (one option, or for a multi-select the ticked
|
|
99
|
+
* options joined by ", " in any order). Anything else was typed.
|
|
100
|
+
*/
|
|
101
|
+
function isOnlyOptions(answer, options, multiSelect) {
|
|
102
|
+
if (options.includes(answer))
|
|
103
|
+
return true;
|
|
104
|
+
if (!multiSelect)
|
|
105
|
+
return false;
|
|
106
|
+
let rest = answer;
|
|
107
|
+
while (rest.length > 0) {
|
|
108
|
+
const o = options.find((x) => rest === x || rest.startsWith(`${x}, `));
|
|
109
|
+
if (!o)
|
|
110
|
+
return false;
|
|
111
|
+
rest = rest === o ? '' : rest.slice(o.length + 2);
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** A typed reply that ends in "?" reads like a question back to us, not an answer. */
|
|
116
|
+
function readsLikeQuestion(typed) {
|
|
117
|
+
return typed.trim().endsWith('?');
|
|
118
|
+
}
|
|
69
119
|
/**
|
|
70
120
|
* Offer to answer the open blockers of the just-saved spec-gap file inline.
|
|
71
121
|
* No-op (no prompt) when the file isn't a spec-gap or has no open blockers, so
|
|
@@ -77,7 +127,7 @@ function affirmative(reply) {
|
|
|
77
127
|
export async function offerAnswerBlockers(args) {
|
|
78
128
|
let env;
|
|
79
129
|
try {
|
|
80
|
-
env = readProjectFile(args.path);
|
|
130
|
+
env = (args.readEnvelope ?? readProjectFile)(args.path);
|
|
81
131
|
}
|
|
82
132
|
catch {
|
|
83
133
|
return null;
|
|
@@ -95,13 +145,48 @@ export async function offerAnswerBlockers(args) {
|
|
|
95
145
|
if (!affirmative(reply))
|
|
96
146
|
return null;
|
|
97
147
|
const answers = new Map();
|
|
98
|
-
|
|
148
|
+
const outcomes = new Map(blockers.map((b) => [b.id, 'not-asked']));
|
|
149
|
+
let stop = false;
|
|
150
|
+
for (let i = 0; i < blockers.length && !stop; i++) {
|
|
99
151
|
const b = blockers[i];
|
|
100
|
-
// The "Blocker i of n: …" line is printed
|
|
101
|
-
// below
|
|
152
|
+
// The "Blocker i of n: …" line is printed on the free-text fallback
|
|
153
|
+
// below, and once when a typed question-like reply is not saved (Task 15).
|
|
154
|
+
// Otherwise the picker shows the question itself and leaves its own trace.
|
|
102
155
|
// The picker first, for every blocker (options or free text).
|
|
103
156
|
// 'write' falls through to the text box; 'skip' leaves the blocker open.
|
|
104
157
|
let askText = true;
|
|
158
|
+
let shownQuestion = false;
|
|
159
|
+
outcomes.set(b.id, 'skipped');
|
|
160
|
+
// A typed reply (not a picked option) that ends in "?" is checked once
|
|
161
|
+
// before it is saved. On No it stays open: one "Not saved" line, then the
|
|
162
|
+
// full question once. The Yes/No card answers '' only when cancelled
|
|
163
|
+
// (Ctrl+C; Enter gives its default, No), which stops the form like the
|
|
164
|
+
// picker's 'stop'. Without a card (prompt only), '' is Enter = No.
|
|
165
|
+
const keepTyped = async (typed) => {
|
|
166
|
+
if (!readsLikeQuestion(typed))
|
|
167
|
+
return true;
|
|
168
|
+
const reply = await askYesNo({
|
|
169
|
+
question: 'That reads like a question, not an answer. Save it as the answer?',
|
|
170
|
+
confirm: args.confirm,
|
|
171
|
+
prompt: args.prompt,
|
|
172
|
+
});
|
|
173
|
+
if (isYes(reply))
|
|
174
|
+
return true;
|
|
175
|
+
if (args.confirm && reply === '') {
|
|
176
|
+
outcomes.set(b.id, 'stopped');
|
|
177
|
+
stop = true;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
outcomes.set(b.id, 'declined-question');
|
|
181
|
+
args.log('');
|
|
182
|
+
args.log('Not saved. The blocker stays open.');
|
|
183
|
+
if (!shownQuestion) {
|
|
184
|
+
args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
|
|
185
|
+
shownQuestion = true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
189
|
+
const save = (a) => { answers.set(b.id, a); outcomes.set(b.id, 'answered'); };
|
|
105
190
|
if (args.choose) {
|
|
106
191
|
const c = await args.choose({
|
|
107
192
|
question: b.text,
|
|
@@ -111,8 +196,10 @@ export async function offerAnswerBlockers(args) {
|
|
|
111
196
|
total: blockers.length,
|
|
112
197
|
});
|
|
113
198
|
if (c.kind === 'picked') {
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
const a = c.answer.trim();
|
|
200
|
+
const typed = !isOnlyOptions(a, b.options ?? [], !!b.multiSelect);
|
|
201
|
+
if (a.length > 0 && (!typed || await keepTyped(a)))
|
|
202
|
+
save(a);
|
|
116
203
|
askText = false;
|
|
117
204
|
}
|
|
118
205
|
else if (c.kind === 'skip') {
|
|
@@ -121,6 +208,7 @@ export async function offerAnswerBlockers(args) {
|
|
|
121
208
|
else if (c.kind === 'stop') {
|
|
122
209
|
// Ctrl+C (Task 4, m2): ask none of the rest. Answers already given
|
|
123
210
|
// are kept and saved below; the others stay open.
|
|
211
|
+
outcomes.set(b.id, 'stopped');
|
|
124
212
|
break;
|
|
125
213
|
}
|
|
126
214
|
// c.kind === 'write' → askText stays true
|
|
@@ -128,31 +216,58 @@ export async function offerAnswerBlockers(args) {
|
|
|
128
216
|
if (askText) {
|
|
129
217
|
args.log('');
|
|
130
218
|
args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
|
|
219
|
+
shownQuestion = true;
|
|
131
220
|
const a = (await args.prompt('Your answer (Enter to skip): ')).trim();
|
|
132
|
-
if (a.length > 0)
|
|
133
|
-
|
|
221
|
+
if (a.length > 0 && await keepTyped(a))
|
|
222
|
+
save(a);
|
|
134
223
|
}
|
|
135
224
|
}
|
|
136
225
|
if (answers.size === 0) {
|
|
226
|
+
// Typed replies were given but not saved: say "saved", not "entered".
|
|
227
|
+
const verb = [...outcomes.values()].includes('declined-question') ? 'saved' : 'entered';
|
|
137
228
|
args.log('');
|
|
229
|
+
// Record mode (team build): the spec is answered in CSPeach, not at cspeach.dev/edit.
|
|
230
|
+
const later = args.recordAnswer ? ''
|
|
231
|
+
: blockers.length === 1 ? ' Answer it later at cspeach.dev/edit.' : ' Answer them later at cspeach.dev/edit.';
|
|
138
232
|
args.log(blockers.length === 1
|
|
139
|
-
?
|
|
140
|
-
: `No answers
|
|
233
|
+
? `No answer ${verb}. The blocker stays open.${later}`
|
|
234
|
+
: `No answers ${verb}. The ${blockers.length} blockers stay open.${later}`);
|
|
235
|
+
args.onOutcomes?.(outcomes);
|
|
141
236
|
return null;
|
|
142
237
|
}
|
|
143
238
|
const nowIso = (args.now ?? (() => new Date().toISOString()))();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
239
|
+
if (args.recordAnswer) {
|
|
240
|
+
// Team build (Task 9): check the revision, but never write the author's file.
|
|
241
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(applyBlockerAnswers(env, answers, args.identity, nowIso))));
|
|
242
|
+
if (!check.ok) {
|
|
243
|
+
args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
|
|
249
|
+
if (!saved.ok) {
|
|
250
|
+
args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
149
253
|
}
|
|
150
|
-
writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
|
|
151
254
|
const stillOpen = blockers.length - answers.size;
|
|
255
|
+
if (args.recordAnswer) {
|
|
256
|
+
for (const [gapId, answer] of answers)
|
|
257
|
+
args.recordAnswer({ gapId, answer, identity: args.identity, at: nowIso });
|
|
258
|
+
args.log('');
|
|
259
|
+
args.log(`Recorded ${plural(answers.size, 'answer')} for the developer.`
|
|
260
|
+
+ (stillOpen === 0 ? ''
|
|
261
|
+
: stillOpen === 1 ? ' 1 blocker is still open.'
|
|
262
|
+
: ` ${stillOpen} blockers are still open.`));
|
|
263
|
+
args.onOutcomes?.(outcomes);
|
|
264
|
+
return answers.size;
|
|
265
|
+
}
|
|
152
266
|
args.log('');
|
|
153
267
|
args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
|
|
154
268
|
+ (stillOpen === 0 ? ''
|
|
155
269
|
: stillOpen === 1 ? ' 1 blocker is still open. Answer it later at cspeach.dev/edit.'
|
|
156
270
|
: ` ${stillOpen} blockers are still open. Answer them later at cspeach.dev/edit.`));
|
|
271
|
+
args.onOutcomes?.(outcomes);
|
|
157
272
|
return answers.size;
|
|
158
273
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative, sep } from 'node:path';
|
|
2
|
+
import { INJECTED_CONTEXT_TAGS } from '../session/user-prompt.js';
|
|
3
|
+
import { teamWorkRoot } from '../tools/_team-work-path.js';
|
|
4
|
+
import { readProjectFile } from './status.js';
|
|
5
|
+
/**
|
|
6
|
+
* Task 16 (F-M, 2026-10-26) — the note the model gets on its next call after
|
|
7
|
+
* the blocker form ran. The form runs after the model's turn and writes only
|
|
8
|
+
* to the spec file, so without this the model offers to ask the same
|
|
9
|
+
* blockers again. One short note: each blocker with its answer or "still
|
|
10
|
+
* open", the spec file's name and folder-relative path (so the model reads it
|
|
11
|
+
* with the normal read tool, not a shell walk), and how further answers are
|
|
12
|
+
* recorded. The agent loop wraps it in `<blocker_answers>` inside
|
|
13
|
+
* `<session_context>` (CLI context: never shown, left out of exports).
|
|
14
|
+
*/
|
|
15
|
+
/** The tool that records further answers (Task 18); loop.ts surfaces it on the note turn. */
|
|
16
|
+
export const SPEC_ANSWERS_RECORD_TOOL = 'spec_answers_record';
|
|
17
|
+
/** The closing sentence. Task 18 adds the spec_answers_record tool and checks this text. */
|
|
18
|
+
export const SPEC_ANSWERS_RECORD_SENTENCE = 'Further answers can be recorded with the spec_answers_record tool, never by editing the file.';
|
|
19
|
+
const QUESTION_MAX = 80;
|
|
20
|
+
const ANSWER_MAX = 200;
|
|
21
|
+
/** One line, clipped to `max` characters with an ellipsis. */
|
|
22
|
+
function clip(text, max) {
|
|
23
|
+
const one = text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
24
|
+
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
|
|
25
|
+
}
|
|
26
|
+
/** Every tag the CLI writes into a prompt (fix round 1, M-2): the shared list plus <playbook>. */
|
|
27
|
+
const CLI_TAG = new RegExp(`<(/?)(${[...INJECTED_CONTEXT_TAGS, 'playbook'].join('|')})(?=[\\s>/])`, 'gi');
|
|
28
|
+
/** A question or answer cannot close the note block or open a block the CLI writes. */
|
|
29
|
+
function neutral(text) {
|
|
30
|
+
return text.replace(CLI_TAG, '‹$1$2');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The path as the read tool opens it from `cwd` (fix round 1). In a team
|
|
34
|
+
* folder a save lands in the seat (<CSPEACH_HOME>/work/<id>/.cspeach), and
|
|
35
|
+
* file_read maps `.cspeach/<name>.cspeach.json` there (teamReadTarget), so the
|
|
36
|
+
* path is given relative to the seat root. Otherwise relative to `cwd`.
|
|
37
|
+
*/
|
|
38
|
+
function readToolPath(cwd, file) {
|
|
39
|
+
const seat = teamWorkRoot(cwd);
|
|
40
|
+
const fromSeat = seat ? relative(seat, file) : '';
|
|
41
|
+
const inSeat = fromSeat !== '' && !fromSeat.startsWith('..') && !isAbsolute(fromSeat);
|
|
42
|
+
return (inSeat ? fromSeat : relative(cwd, file)).split(sep).join('/');
|
|
43
|
+
}
|
|
44
|
+
/** The note text, or null when there is nothing to say (no outcomes, unreadable file). */
|
|
45
|
+
export function blockerAnswersNote(a) {
|
|
46
|
+
if (a.outcomes.size === 0)
|
|
47
|
+
return null;
|
|
48
|
+
let env;
|
|
49
|
+
try {
|
|
50
|
+
env = (a.readEnvelope ?? readProjectFile)(a.path);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (env.artefactType !== 'spec-gap')
|
|
56
|
+
return null;
|
|
57
|
+
const gaps = new Map();
|
|
58
|
+
for (const s of env.content.sections)
|
|
59
|
+
for (const g of s.gaps)
|
|
60
|
+
gaps.set(g.id, g);
|
|
61
|
+
const items = new Map(env.interaction.items.map((it) => [it.id, it]));
|
|
62
|
+
const lines = [];
|
|
63
|
+
const open = [];
|
|
64
|
+
let n = 0;
|
|
65
|
+
for (const [id, outcome] of a.outcomes) {
|
|
66
|
+
n++;
|
|
67
|
+
const q = neutral(clip(gaps.get(id)?.text ?? id, QUESTION_MAX));
|
|
68
|
+
const item = items.get(id);
|
|
69
|
+
const answer = outcome === 'answered' && item?.status === 'answered' && typeof item.answer === 'string'
|
|
70
|
+
? item.answer : null;
|
|
71
|
+
// Final review I-1: each line carries the file's id, so spec_answers_record
|
|
72
|
+
// gets the id the person means (the form's numbers count only the asked blockers).
|
|
73
|
+
if (answer === null)
|
|
74
|
+
open.push(`${n} (${id})`);
|
|
75
|
+
lines.push(`${n}. ${id} — ${q} → ${answer === null ? 'still open' : neutral(clip(answer, ANSWER_MAX))}`);
|
|
76
|
+
}
|
|
77
|
+
const name = basename(a.path);
|
|
78
|
+
const rel = readToolPath(a.cwd, a.path);
|
|
79
|
+
const where = `${name} (path: ${rel})`;
|
|
80
|
+
const lead = open.length < n
|
|
81
|
+
? `The developer answered spec-gap blockers in the form. The answers are saved in ${where}.`
|
|
82
|
+
: `The developer saw the spec-gap blockers in the form and saved no answers. The spec file is ${where}.`;
|
|
83
|
+
return [
|
|
84
|
+
lead,
|
|
85
|
+
...lines,
|
|
86
|
+
...(open.length > 0 ? [`Still open: ${open.join(', ')}.`] : []),
|
|
87
|
+
SPEC_ANSWERS_RECORD_SENTENCE,
|
|
88
|
+
].join('\n');
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/change-options.ts
|
|
2
|
+
//
|
|
3
|
+
// Change options (2026-10-23): /abap-refactor and /abap-impact wrap the change
|
|
4
|
+
// options document in a marker pair, each on its own line:
|
|
5
|
+
//
|
|
6
|
+
// <!-- csforge:change-options OBJECT=<NAME> -->
|
|
7
|
+
// ...the document...
|
|
8
|
+
// <!-- /csforge:change-options -->
|
|
9
|
+
//
|
|
10
|
+
// The CLI saves only the text between the markers (the TL;DR and the level
|
|
11
|
+
// question stay out). Markers inside a fenced code block are quotes, not a
|
|
12
|
+
// document, and are skipped. An opening with no closing (a cut-off answer)
|
|
13
|
+
// finds nothing, so the turn behaves exactly as it does without a marker.
|
|
14
|
+
// The object name is returned as written; file-name cleanup is the caller's
|
|
15
|
+
// (documentFilename).
|
|
16
|
+
const OPEN_RE = /^<!--\s*csforge:change-options\s+OBJECT=(\S+)\s*-->$/;
|
|
17
|
+
const CLOSE_RE = /^<!--\s*\/csforge:change-options\s*-->$/;
|
|
18
|
+
// 1.1.53: the closing marker may be glued to the end of the last text line
|
|
19
|
+
// ("…cache only.<!-- /csforge:change-options -->"). The text before it stays
|
|
20
|
+
// in the document. Only at the very end of the line, and not inside an open
|
|
21
|
+
// inline code span, so a marker quoted in prose stays a quote.
|
|
22
|
+
const GLUED_CLOSE_RE = /<!--\s*\/csforge:change-options\s*-->$/;
|
|
23
|
+
const FENCE_RE = /^(`{3,}|~{3,})/;
|
|
24
|
+
/**
|
|
25
|
+
* Every complete marked document in the text, in order. A turn where the
|
|
26
|
+
* developer picks a level in session carries two: the options, then the same
|
|
27
|
+
* document shown again with the decision (final review C1). An opening with
|
|
28
|
+
* no closing finds nothing for that one. No marker: an empty list.
|
|
29
|
+
*/
|
|
30
|
+
export function extractChangeOptions(text) {
|
|
31
|
+
const lines = (text ?? '').split('\n');
|
|
32
|
+
let fence = null;
|
|
33
|
+
let object = null;
|
|
34
|
+
let start = -1;
|
|
35
|
+
const found = [];
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const line = lines[i].trim();
|
|
38
|
+
const f = line.match(FENCE_RE);
|
|
39
|
+
if (f) {
|
|
40
|
+
const mark = f[1];
|
|
41
|
+
if (fence === null)
|
|
42
|
+
fence = mark;
|
|
43
|
+
else if (mark[0] === fence[0] && mark.length >= fence.length && line === mark)
|
|
44
|
+
fence = null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (fence !== null)
|
|
48
|
+
continue;
|
|
49
|
+
if (object === null) {
|
|
50
|
+
const m = line.match(OPEN_RE);
|
|
51
|
+
if (m) {
|
|
52
|
+
object = m[1];
|
|
53
|
+
start = i + 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (CLOSE_RE.test(line)) {
|
|
57
|
+
found.push({ object, markdown: lines.slice(start, i).join('\n').trim() });
|
|
58
|
+
object = null;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const g = GLUED_CLOSE_RE.exec(line);
|
|
62
|
+
const before = g ? line.slice(0, g.index) : '';
|
|
63
|
+
if (g && (before.match(/`/g) ?? []).length % 2 === 0) {
|
|
64
|
+
found.push({ object, markdown: [...lines.slice(start, i), before].join('\n').trim() });
|
|
65
|
+
object = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
@@ -53,6 +53,92 @@ export async function offerDocumentSave(p) {
|
|
|
53
53
|
});
|
|
54
54
|
return docxPath ? { mdPath, docxPath } : { mdPath };
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The header the CLI writes above a saved change options document (never the
|
|
58
|
+
* model: the document itself never names the person). Parts that are not
|
|
59
|
+
* known are left out: no system in standalone, no person in a one-shot run.
|
|
60
|
+
*/
|
|
61
|
+
export function changeOptionsHeader(p) {
|
|
62
|
+
const parts = [`Date: ${p.today}`];
|
|
63
|
+
if (p.sapSystem)
|
|
64
|
+
parts.push(`System: ${p.sapSystem}`);
|
|
65
|
+
if (p.sessionId)
|
|
66
|
+
parts.push(`Session: ${p.sessionId}`);
|
|
67
|
+
if (p.operator)
|
|
68
|
+
parts.push(`Prepared for: ${p.operator}`);
|
|
69
|
+
return `> **Saved by CSPeach** · ${parts.join(' · ')}\n\n`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Change options (2026-10-23): the marked document(s) from refactor / impact.
|
|
73
|
+
* One "Save this change options document?" for the turn (headless saves
|
|
74
|
+
* without asking). Each document goes to
|
|
75
|
+
* cspeach-out/<date>-change-options-<OBJECT>.md, never overwritten (a second
|
|
76
|
+
* one is -2), with the CLI header on top. Then the one shared Word step for
|
|
77
|
+
* each file; in ask mode "Word copy too?" is asked once and covers all.
|
|
78
|
+
* The Word copy is made from the document alone; its title page carries the
|
|
79
|
+
* facts. Never throws: a failure is one line through `emit`.
|
|
80
|
+
*/
|
|
81
|
+
export async function offerChangeOptionsSave(p) {
|
|
82
|
+
const docs = p.docs.filter((d) => d.markdown.trim().length > 0);
|
|
83
|
+
if (docs.length === 0)
|
|
84
|
+
return {};
|
|
85
|
+
if (!p.headless) {
|
|
86
|
+
// Defaults to Yes (live check 2026-10-26, same as F-A for plans): Enter
|
|
87
|
+
// must not lose the document.
|
|
88
|
+
const a = await askYesNo({ question: 'Save this change options document?', confirm: p.confirm, defaultYes: true });
|
|
89
|
+
if (!isYes(a))
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
p.emit(chalk.dim('headless: saving the change options document without asking'));
|
|
94
|
+
}
|
|
95
|
+
const header = changeOptionsHeader(p);
|
|
96
|
+
const mdPaths = [];
|
|
97
|
+
const docxPaths = [];
|
|
98
|
+
let wordCopy = p.wordCopy;
|
|
99
|
+
for (const doc of docs) {
|
|
100
|
+
let mdPath;
|
|
101
|
+
try {
|
|
102
|
+
const target = path.join(p.cwd, OUT_DIR, documentFilename('change-options', doc.object, p.today));
|
|
103
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
104
|
+
mdPath = await writeNoClobber(target, `${header}${doc.markdown}\n`);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
p.emit(`Not saved. ${e.message}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
p.emit(`Saved: ${path.resolve(mdPath)}`);
|
|
111
|
+
mdPaths.push(mdPath);
|
|
112
|
+
// The Word copy reads plain \n line endings (Task 5 review: CRLF answers).
|
|
113
|
+
const markdown = doc.markdown.replace(/\r\n?/g, '\n');
|
|
114
|
+
const fromText = metaFromMarkdown(markdown, { date: p.today, kind: 'change-options' });
|
|
115
|
+
const meta = {
|
|
116
|
+
title: fromText.title,
|
|
117
|
+
date: p.today,
|
|
118
|
+
...(p.sapSystem ? { system: p.sapSystem } : {}),
|
|
119
|
+
...(doc.object ? { object: doc.object } : {}),
|
|
120
|
+
kind: 'change-options',
|
|
121
|
+
...(p.operator ? { preparedFor: p.operator } : {}),
|
|
122
|
+
};
|
|
123
|
+
let answer;
|
|
124
|
+
const docxPath = await offerWordCopy({
|
|
125
|
+
nextTo: mdPath, markdown, meta, headless: p.headless, wordCopy, emit: p.emit,
|
|
126
|
+
confirm: async (q, o) => { const a = await p.confirm(q, o); answer = isYes(a); return a; },
|
|
127
|
+
});
|
|
128
|
+
// One "Word copy too?" for the turn: its answer decides the next files.
|
|
129
|
+
if (answer !== undefined)
|
|
130
|
+
wordCopy = answer ? 'always' : 'never';
|
|
131
|
+
if (docxPath)
|
|
132
|
+
docxPaths.push(docxPath);
|
|
133
|
+
}
|
|
134
|
+
if (mdPaths.length === 0)
|
|
135
|
+
return {};
|
|
136
|
+
return {
|
|
137
|
+
mdPath: mdPaths[mdPaths.length - 1],
|
|
138
|
+
mdPaths,
|
|
139
|
+
...(docxPaths.length ? { docxPath: docxPaths[docxPaths.length - 1], docxPaths } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
56
142
|
/**
|
|
57
143
|
* The Word step, shared by every save of a document (Word copy everywhere,
|
|
58
144
|
* 2026-10-23). `word_copy` decides: unset → ask "Word copy too?", on →
|
|
@@ -93,6 +93,10 @@ function parseDependencySequence(section, nameToId) {
|
|
|
93
93
|
}
|
|
94
94
|
return out;
|
|
95
95
|
}
|
|
96
|
+
/** Task 17 (F-N) — the design's title heading is the sign of the document. */
|
|
97
|
+
export function hasDesignSign(markdown) {
|
|
98
|
+
return matchTitleHeading(markdown, TITLE_SPEC) !== null;
|
|
99
|
+
}
|
|
96
100
|
export function extractDesign(markdown) {
|
|
97
101
|
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
98
102
|
if (!titleMatch) {
|
|
@@ -60,6 +60,10 @@ function parseComponentsTable(section) {
|
|
|
60
60
|
}
|
|
61
61
|
return rows;
|
|
62
62
|
}
|
|
63
|
+
/** Task 17 (F-N) — the estimate's title heading is the sign of the document. */
|
|
64
|
+
export function hasEstimateSign(markdown) {
|
|
65
|
+
return matchTitleHeading(markdown, TITLE_SPEC) !== null;
|
|
66
|
+
}
|
|
63
67
|
export function extractEstimate(markdown) {
|
|
64
68
|
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
65
69
|
if (!titleMatch) {
|
|
@@ -126,6 +126,14 @@ export function looksLikeSpecGap(markdown) {
|
|
|
126
126
|
}
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Task 17 (F-N) — any sign of a spec-gap document: its title heading or its
|
|
131
|
+
* body (looksLikeSpecGap). A recap turn carries neither, so the save step
|
|
132
|
+
* stays quiet on it.
|
|
133
|
+
*/
|
|
134
|
+
export function hasSpecGapSign(markdown) {
|
|
135
|
+
return matchTitleHeading(markdown, TITLE_SPEC) !== null || looksLikeSpecGap(markdown);
|
|
136
|
+
}
|
|
129
137
|
function parseGapsFromSection(sectionBody, nextId) {
|
|
130
138
|
const out = [];
|
|
131
139
|
const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
|
package/dist/projects/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { validateEnvelope } from './validate.js';
|
|
|
5
5
|
export { canonicalSha256 } from './canonicalize.js';
|
|
6
6
|
export { titleSlug, shortId, projectFilename } from './filename.js';
|
|
7
7
|
export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
|
|
8
|
-
export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
|
|
8
|
+
export { offerAnswerBlockers, openBlockers, applyBlockerAnswers, saveBlockerAnswers } from './answer-blockers.js';
|
|
9
9
|
export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
|
|
10
10
|
export { extractDesign } from './extract-design.js';
|
|
11
11
|
export { extractEstimate } from './extract-estimate.js';
|
|
@@ -73,3 +73,27 @@ export function plannedObjectFacts(objs, max = 12) {
|
|
|
73
73
|
export function planGrantFactLines() {
|
|
74
74
|
return ['Writes to these objects need no further card.', 'Anything else stops and asks.'];
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* True when the plan lists objects and every one is in a local package
|
|
78
|
+
* (`$TMP`, `$…`): nothing on the list goes into a transport.
|
|
79
|
+
*/
|
|
80
|
+
export function localOnlyObjects(objs) {
|
|
81
|
+
return objs.length > 0 && objs.every((o) => typeof o.package === 'string' && o.package.startsWith('$'));
|
|
82
|
+
}
|
|
83
|
+
/** The card's transport line. Local-only objects with no transport say so plainly. */
|
|
84
|
+
export function planTransportFact(transport, objs) {
|
|
85
|
+
if (transport)
|
|
86
|
+
return `Transport: ${transport}`;
|
|
87
|
+
if (localOnlyObjects(objs))
|
|
88
|
+
return 'Transport: none (local objects, not transported)';
|
|
89
|
+
return 'Transport: a new one for this build';
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Will the object-list grant cover these writes? It mirrors grantCovers
|
|
93
|
+
* (approvals/plan-grant.ts): with no bound transport no write is covered,
|
|
94
|
+
* and a local-only plan never creates one, so each write asks. Card text
|
|
95
|
+
* only; the grant itself is minted exactly as before.
|
|
96
|
+
*/
|
|
97
|
+
export function planGrantCoversWrites(transport, objs) {
|
|
98
|
+
return transport !== undefined || !localOnlyObjects(objs);
|
|
99
|
+
}
|
|
@@ -373,7 +373,11 @@ export function findServiceBinding(phases) {
|
|
|
373
373
|
export function planCompletionLines(phases,
|
|
374
374
|
// Terminal callers follow the terminal's glyph set; file writers (the
|
|
375
375
|
// handover markdown) pass UNICODE_GLYPHS so file content never varies.
|
|
376
|
-
g = getGlyphs()
|
|
376
|
+
g = getGlyphs(),
|
|
377
|
+
// 1.1.53 item 11: the run's transport (terminal callers pass
|
|
378
|
+
// planBuildTransport). The handover file passes none, so its content
|
|
379
|
+
// depends only on the transport recorded in the plan file.
|
|
380
|
+
transport) {
|
|
377
381
|
const lines = ['Plan complete — every phase validated.'];
|
|
378
382
|
// Confidence-tiered redesign (2026-07-08): a completed plan that carried
|
|
379
383
|
// non-blocking warn verdicts surfaces them in the FINAL summary — a warned
|
|
@@ -436,7 +440,13 @@ g = getGlyphs()) {
|
|
|
436
440
|
? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
|
|
437
441
|
: `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build or record why not.`);
|
|
438
442
|
}
|
|
439
|
-
|
|
443
|
+
// A run that produced no transport (all objects local, e.g. $TMP) has
|
|
444
|
+
// nothing to preflight before release, so the hint is left out.
|
|
445
|
+
const producedTransport = !!transport?.trim()
|
|
446
|
+
|| phases.some((p) => typeof p.work?.transport === 'string' && p.work.transport.trim() !== '');
|
|
447
|
+
if (producedTransport) {
|
|
448
|
+
lines.push('Consider /abap-preflight on the produced transport(s) before release.');
|
|
449
|
+
}
|
|
440
450
|
return lines;
|
|
441
451
|
}
|
|
442
452
|
/**
|