@cspeach/cli 1.1.52 → 1.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +150 -17
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +17 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/auth/auth-file.js +3 -0
  9. package/dist/auth/key-resolver.js +8 -3
  10. package/dist/auth/me.js +6 -2
  11. package/dist/cli.js +22 -0
  12. package/dist/commands/login.js +22 -4
  13. package/dist/commands/plan-chain.js +2 -2
  14. package/dist/commands/plan-reground.js +5 -0
  15. package/dist/commands/plan-resume.js +2 -2
  16. package/dist/commands/plan-startup.js +3 -3
  17. package/dist/commands/sap-link.js +14 -1
  18. package/dist/commands/team-build-help.js +19 -0
  19. package/dist/commands/team-build.js +97 -0
  20. package/dist/commands/team-plan.js +2 -2
  21. package/dist/commands/team-spec.js +107 -0
  22. package/dist/commands/team.js +74 -4
  23. package/dist/docx/doc-meta.js +1 -0
  24. package/dist/docx/markdown-to-docx.js +2 -0
  25. package/dist/journey/chain.js +8 -0
  26. package/dist/journey/clean-core.js +2 -2
  27. package/dist/journey/rebuild.js +8 -0
  28. package/dist/journey/run.js +9 -0
  29. package/dist/lane/app-chain.js +13 -6
  30. package/dist/licence/paths.js +5 -0
  31. package/dist/licence/seat-kind.js +110 -0
  32. package/dist/licence/seat-state.js +4 -0
  33. package/dist/licence/terminal-seat.js +43 -0
  34. package/dist/licence/turn-gate.js +4 -0
  35. package/dist/one-shot.js +33 -0
  36. package/dist/project-context/conventions.js +8 -1
  37. package/dist/project-context/index-files.js +97 -34
  38. package/dist/project-context/index.js +1 -1
  39. package/dist/project-context/render.js +38 -4
  40. package/dist/projects/answer-blockers.js +133 -18
  41. package/dist/projects/blocker-answers-note.js +89 -0
  42. package/dist/projects/change-options.js +70 -0
  43. package/dist/projects/document-save.js +86 -0
  44. package/dist/projects/extract-design.js +4 -0
  45. package/dist/projects/extract-estimate.js +4 -0
  46. package/dist/projects/extract-spec-gap.js +8 -0
  47. package/dist/projects/index.js +1 -1
  48. package/dist/projects/plan-objects.js +24 -0
  49. package/dist/projects/plan-run.js +12 -2
  50. package/dist/projects/save-command.js +83 -20
  51. package/dist/projects/saved-lines.js +19 -17
  52. package/dist/projects/spec-share.js +239 -0
  53. package/dist/register/picture.js +2 -2
  54. package/dist/register/store.js +19 -0
  55. package/dist/renderer/change-card.js +14 -0
  56. package/dist/renderer/html-entities.js +25 -0
  57. package/dist/renderer/machine-blocks.js +54 -7
  58. package/dist/renderer/tool-labels.js +2 -0
  59. package/dist/renderer/tool-widget.js +20 -5
  60. package/dist/repl/builtin-commands.js +4 -3
  61. package/dist/repl/chat-lane.js +10 -1
  62. package/dist/repl/file-picker.js +52 -5
  63. package/dist/repl/mode-cycle.js +12 -0
  64. package/dist/repl/open-questions-gate.js +159 -0
  65. package/dist/repl/ready-phrase.js +108 -0
  66. package/dist/repl/startup-account.js +64 -1
  67. package/dist/repl/team-build-screen.js +116 -0
  68. package/dist/repl/team-build-start.js +178 -0
  69. package/dist/repl.js +216 -11
  70. package/dist/sap-link/policy.js +4 -0
  71. package/dist/sap-link/registry.js +9 -3
  72. package/dist/sap-link/server.js +4 -1
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/user-prompt.js +2 -0
  75. package/dist/tools/_team-work-path.js +1 -1
  76. package/dist/tools/approval.js +12 -1
  77. package/dist/tools/cap/sap-link-tool.js +2 -0
  78. package/dist/tools/project/spec_answers_record.js +243 -0
  79. package/dist/tools/project/team_findings.js +2 -2
  80. package/dist/tools/seat-gate.js +41 -0
  81. package/dist/tools/tool-loading.js +2 -0
  82. package/dist/ui/approval-modal.js +2 -1
  83. package/dist/ui/widgets/ask-card.js +171 -29
  84. package/dist/ui/widgets/files-card.js +19 -1
  85. package/package.json +2 -2
@@ -1,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
- for (let i = 0; i < blockers.length; i++) {
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 only on the free-text fallback
101
- // below. The picker shows the question itself and leaves its own trace.
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
- if (c.answer.trim().length > 0)
115
- answers.set(b.id, c.answer.trim());
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
- answers.set(b.id, a);
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
- ? 'No answer entered. The blocker stays open. Answer it later at cspeach.dev/edit.'
140
- : `No answers entered. The ${blockers.length} blockers stay open. Answer them later at cspeach.dev/edit.`);
233
+ ? `No answer ${verb}. The blocker stays open.${later}`
234
+ : `No answers ${verb}. The ${blockers.length} blockers stay open.${later}`);
235
+ args.onOutcomes?.(outcomes);
141
236
  return null;
142
237
  }
143
238
  const nowIso = (args.now ?? (() => new Date().toISOString()))();
144
- const revised = applyBlockerAnswers(env, answers, args.identity, nowIso);
145
- const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
146
- if (!check.ok) {
147
- args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
148
- return null;
239
+ if (args.recordAnswer) {
240
+ // Team build (Task 9): check the revision, but never write the author's file.
241
+ const check = validateEnvelope(JSON.parse(JSON.stringify(applyBlockerAnswers(env, answers, args.identity, nowIso))));
242
+ if (!check.ok) {
243
+ args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
244
+ return null;
245
+ }
246
+ }
247
+ else {
248
+ const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
249
+ if (!saved.ok) {
250
+ args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
251
+ return null;
252
+ }
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
@@ -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
- lines.push('Consider /abap-preflight on the produced transport(s) before release.');
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
  /**