@cspeach/cli 1.1.51 → 1.1.53

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 (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -31,6 +31,12 @@ const CAP_CAP_ENTITIES = 60;
31
31
  const CAP_ABAP_OBJECTS = 80;
32
32
  const CAP_GIT_COMMITS = 5;
33
33
  const CAP_RECENT_OBJECTS = 10;
34
+ /** 1.1.53 item 12 — %TEMP% had ~283,000 top-level folders (a 4.37M-char line). */
35
+ export const CAP_TOP_LEVEL_DIRS = 40;
36
+ export const CAP_NAMING = 20;
37
+ /** Hard ceiling on the whole block, whatever the folder holds. */
38
+ export const PROJECT_CONTEXT_MAX_CHARS = 24_000;
39
+ const CUT_TAIL = '\n <cut>The folder is very large; this picture was shortened.</cut>\n</project_context>';
34
40
  /**
35
41
  * Render a ProjectContext as prompt text. The result is a self-contained
36
42
  * <project_context> XML-ish block ready to splice into a system prompt.
@@ -38,6 +44,28 @@ const CAP_RECENT_OBJECTS = 10;
38
44
  * unconditionally.
39
45
  */
40
46
  export function renderProjectContext(ctx) {
47
+ return renderProjectContextCapped(ctx).block;
48
+ }
49
+ /**
50
+ * The block plus whether it was cut. A block over PROJECT_CONTEXT_MAX_CHARS
51
+ * is cut at the last line end that keeps it (with the closing note) under
52
+ * the cap; a block within the cap is returned unchanged.
53
+ */
54
+ export function renderProjectContextCapped(ctx) {
55
+ const full = renderFull(ctx);
56
+ const walkCut = ctx?.index.walkCut === true;
57
+ if (full.length <= PROJECT_CONTEXT_MAX_CHARS) {
58
+ if (!walkCut)
59
+ return { block: full, cut: false };
60
+ // The walk stopped early: same note, in place of the closing tag.
61
+ return { block: full.slice(0, full.length - '\n</project_context>'.length) + CUT_TAIL, cut: true };
62
+ }
63
+ const room = PROJECT_CONTEXT_MAX_CHARS - CUT_TAIL.length;
64
+ const lastNl = full.lastIndexOf('\n', room);
65
+ const head = lastNl > 0 ? full.slice(0, lastNl) : full.slice(0, room);
66
+ return { block: head + CUT_TAIL, cut: true };
67
+ }
68
+ function renderFull(ctx) {
41
69
  if (!ctx)
42
70
  return '';
43
71
  const parts = [];
@@ -66,13 +94,13 @@ function renderConventions(c) {
66
94
  if (c.namingPatterns.classes.length || c.namingPatterns.interfaces.length || c.namingPatterns.cdsViews.length) {
67
95
  lines.push(' <naming>');
68
96
  if (c.namingPatterns.classes.length) {
69
- lines.push(` <classes>${escapeXml(c.namingPatterns.classes.join(', '))}</classes>`);
97
+ lines.push(` <classes>${escapeXml(cappedList(c.namingPatterns.classes, CAP_NAMING))}</classes>`);
70
98
  }
71
99
  if (c.namingPatterns.interfaces.length) {
72
- lines.push(` <interfaces>${escapeXml(c.namingPatterns.interfaces.join(', '))}</interfaces>`);
100
+ lines.push(` <interfaces>${escapeXml(cappedList(c.namingPatterns.interfaces, CAP_NAMING))}</interfaces>`);
73
101
  }
74
102
  if (c.namingPatterns.cdsViews.length) {
75
- lines.push(` <cds_views>${escapeXml(c.namingPatterns.cdsViews.join(', '))}</cds_views>`);
103
+ lines.push(` <cds_views>${escapeXml(cappedList(c.namingPatterns.cdsViews, CAP_NAMING))}</cds_views>`);
76
104
  }
77
105
  lines.push(' </naming>');
78
106
  }
@@ -149,7 +177,7 @@ function renderFileIndex(ctx) {
149
177
  ` <file_index total="${idx.totalFiles}"${idx.truncated ? ' truncated="true"' : ''}>`,
150
178
  ];
151
179
  if (idx.topLevelDirs.length) {
152
- lines.push(` <top_level_dirs>${escapeXml(idx.topLevelDirs.join(', '))}</top_level_dirs>`);
180
+ lines.push(` <top_level_dirs>${escapeXml(cappedList(idx.topLevelDirs, CAP_TOP_LEVEL_DIRS))}</top_level_dirs>`);
153
181
  }
154
182
  const extEntries = Object.entries(idx.byExtension).sort((a, b) => b[1] - a[1]);
155
183
  if (extEntries.length) {
@@ -293,6 +321,12 @@ function renderAbapCloud(objects, bdefs) {
293
321
  return lines.join('\n');
294
322
  }
295
323
  /* ── Helpers ───────────────────────────────────────────────────────── */
324
+ /** The first `cap` items joined, then ` (… N more)`; a list within the cap joins as before. */
325
+ function cappedList(items, cap) {
326
+ if (items.length <= cap)
327
+ return items.join(', ');
328
+ return `${items.slice(0, cap).join(', ')} (… ${items.length - cap} more)`;
329
+ }
296
330
  function escapeXml(text) {
297
331
  return text
298
332
  .replace(/&/g, '&amp;')
@@ -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
@@ -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,34 @@ 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('');
138
229
  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.`);
230
+ ? `No answer ${verb}. The blocker stays open. Answer it later at cspeach.dev/edit.`
231
+ : `No answers ${verb}. The ${blockers.length} blockers stay open. Answer them later at cspeach.dev/edit.`);
232
+ args.onOutcomes?.(outcomes);
141
233
  return null;
142
234
  }
143
235
  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.`);
236
+ const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
237
+ if (!saved.ok) {
238
+ args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
148
239
  return null;
149
240
  }
150
- writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
151
241
  const stillOpen = blockers.length - answers.size;
152
242
  args.log('');
153
243
  args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
154
244
  + (stillOpen === 0 ? ''
155
245
  : stillOpen === 1 ? ' 1 blocker is still open. Answer it later at cspeach.dev/edit.'
156
246
  : ` ${stillOpen} blockers are still open. Answer them later at cspeach.dev/edit.`));
247
+ args.onOutcomes?.(outcomes);
157
248
  return answers.size;
158
249
  }
@@ -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 = 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
  /**