@cspeach/cli 1.1.52 → 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 (44) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +101 -13
  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 +6 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/commands/plan-chain.js +2 -2
  9. package/dist/commands/plan-resume.js +2 -2
  10. package/dist/commands/plan-startup.js +3 -3
  11. package/dist/docx/doc-meta.js +1 -0
  12. package/dist/docx/markdown-to-docx.js +2 -0
  13. package/dist/lane/app-chain.js +13 -6
  14. package/dist/one-shot.js +1 -0
  15. package/dist/project-context/conventions.js +8 -1
  16. package/dist/project-context/index-files.js +97 -34
  17. package/dist/project-context/index.js +1 -1
  18. package/dist/project-context/render.js +38 -4
  19. package/dist/projects/answer-blockers.js +107 -16
  20. package/dist/projects/blocker-answers-note.js +89 -0
  21. package/dist/projects/change-options.js +70 -0
  22. package/dist/projects/document-save.js +86 -0
  23. package/dist/projects/extract-design.js +4 -0
  24. package/dist/projects/extract-estimate.js +4 -0
  25. package/dist/projects/extract-spec-gap.js +8 -0
  26. package/dist/projects/index.js +1 -1
  27. package/dist/projects/plan-objects.js +24 -0
  28. package/dist/projects/plan-run.js +12 -2
  29. package/dist/projects/save-command.js +46 -18
  30. package/dist/renderer/change-card.js +14 -0
  31. package/dist/renderer/html-entities.js +25 -0
  32. package/dist/renderer/machine-blocks.js +54 -7
  33. package/dist/renderer/tool-labels.js +2 -0
  34. package/dist/renderer/tool-widget.js +20 -5
  35. package/dist/repl/chat-lane.js +10 -1
  36. package/dist/repl.js +11 -2
  37. package/dist/session/fresh-start.js +4 -0
  38. package/dist/session/user-prompt.js +2 -0
  39. package/dist/tools/approval.js +12 -1
  40. package/dist/tools/project/spec_answers_record.js +190 -0
  41. package/dist/tools/tool-loading.js +2 -0
  42. package/dist/ui/approval-modal.js +2 -1
  43. package/dist/ui/widgets/ask-card.js +171 -29
  44. package/package.json +2 -2
@@ -35,6 +35,15 @@ import * as path from 'node:path';
35
35
  const MAX_FILES_TRAVERSED = 10_000;
36
36
  /** Cap on paths shipped in FileIndex. Selected by relevance. */
37
37
  const MAX_PATHS_IN_INDEX = 500;
38
+ /**
39
+ * 1.1.53 item 12 — budget on entries (files AND folders) the walk looks at.
40
+ * %TEMP% held ~283,000 empty folders: the file cap never fired and the walk
41
+ * opened every one (~10 s). Spent ⇒ the walk stops and the index is cut.
42
+ * Measured there: 20,000 took ~2.4 s, 5,000 ~0.95 s (with two root reads).
43
+ * Controller ruling: 12,000, so a real project (an abapGit export of
44
+ * thousands of files) keeps its full picture up to the 10,000-file cap.
45
+ */
46
+ export const MAX_ENTRIES_VISITED = 12_000;
38
47
  const ALWAYS_SKIPPED_DIRS = new Set([
39
48
  'node_modules',
40
49
  '.git',
@@ -71,14 +80,32 @@ const SOURCE_EXTENSIONS = new Set([
71
80
  * IO errors during the walk are silently skipped (the offending dir/file
72
81
  * just doesn't contribute to the count). Never throws.
73
82
  */
74
- export async function indexFiles(cwd) {
83
+ export async function indexFiles(cwd, options = {}) {
84
+ const maxEntries = options.maxEntries ?? MAX_ENTRIES_VISITED;
85
+ let entriesLeft = maxEntries;
86
+ let walkCut = false;
75
87
  const ignoreMatchers = await loadIgnoreMatchers(cwd);
76
- /** Files collected with their stat info — used to rank for the path list. */
88
+ /**
89
+ * Files collected with their stat info — used to rank for the path list.
90
+ * `order` is the file's place in a plain depth-first walk (the readdir
91
+ * index at each level), so the output keeps today's order although the
92
+ * walk now visits each level's files before its folders.
93
+ */
77
94
  const collected = [];
78
- const byExtension = {};
79
95
  let totalFiles = 0;
80
96
  let truncated = false;
81
- async function walk(dir, relPrefix) {
97
+ let rootEntries = null;
98
+ /** One entry of the budget; false (and the walk marked cut) once it is spent. */
99
+ function spend() {
100
+ if (entriesLeft <= 0) {
101
+ walkCut = true;
102
+ truncated = true;
103
+ return false;
104
+ }
105
+ entriesLeft--;
106
+ return true;
107
+ }
108
+ async function walk(dir, relPrefix, order) {
82
109
  if (truncated)
83
110
  return;
84
111
  let entries;
@@ -88,45 +115,68 @@ export async function indexFiles(cwd) {
88
115
  catch {
89
116
  return;
90
117
  }
91
- for (const ent of entries) {
118
+ // The root listing is kept for the top-level list (one read, not two).
119
+ if (relPrefix === '')
120
+ rootEntries = entries;
121
+ // 1.1.53 item 12 (fix round 3) — this level's files first, then its
122
+ // folders: one big early folder can no longer spend the entries budget
123
+ // before a spec or plan file at this level is seen.
124
+ for (let i = 0; i < entries.length; i++) {
125
+ const ent = entries[i];
126
+ if (ent.isDirectory())
127
+ continue;
92
128
  if (truncated)
93
129
  return;
130
+ if (!spend())
131
+ return;
132
+ if (!ent.isFile())
133
+ continue;
94
134
  const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
95
- if (ent.isDirectory()) {
96
- if (ALWAYS_SKIPPED_DIRS.has(ent.name))
97
- continue;
98
- if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
99
- continue;
100
- await walk(path.join(dir, ent.name), relPath);
135
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
136
+ continue;
137
+ if (totalFiles >= MAX_FILES_TRAVERSED) {
138
+ truncated = true;
139
+ return;
101
140
  }
102
- else if (ent.isFile()) {
103
- if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
104
- continue;
105
- if (totalFiles >= MAX_FILES_TRAVERSED) {
106
- truncated = true;
107
- return;
108
- }
109
- totalFiles++;
110
- const ext = extOf(ent.name);
111
- byExtension[ext] = (byExtension[ext] ?? 0) + 1;
112
- // Read mtime cheaply; on error use 0 so the file sorts late.
113
- let mtime = 0;
114
- try {
115
- const s = await fs.stat(path.join(dir, ent.name));
116
- mtime = s.mtimeMs;
117
- }
118
- catch {
119
- // ignore
120
- }
121
- collected.push({ path: relPath, ext, mtime });
141
+ totalFiles++;
142
+ // Read mtime cheaply; on error use 0 so the file sorts late.
143
+ let mtime = 0;
144
+ try {
145
+ const s = await fs.stat(path.join(dir, ent.name));
146
+ mtime = s.mtimeMs;
122
147
  }
148
+ catch {
149
+ // ignore
150
+ }
151
+ collected.push({ path: relPath, ext: extOf(ent.name), mtime, order: [...order, i] });
152
+ }
153
+ for (let i = 0; i < entries.length; i++) {
154
+ const ent = entries[i];
155
+ if (!ent.isDirectory())
156
+ continue;
157
+ if (truncated)
158
+ return;
159
+ if (!spend())
160
+ return;
161
+ if (ALWAYS_SKIPPED_DIRS.has(ent.name))
162
+ continue;
163
+ const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
164
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
165
+ continue;
166
+ await walk(path.join(dir, ent.name), relPath, [...order, i]);
123
167
  }
124
168
  }
125
- await walk(cwd, '');
169
+ await walk(cwd, '', []);
170
+ // Back to the plain depth-first order, so the counts and the ranking below
171
+ // see the files in exactly today's order.
172
+ collected.sort((a, b) => compareOrder(a.order, b.order));
173
+ const byExtension = {};
174
+ for (const f of collected)
175
+ byExtension[f.ext] = (byExtension[f.ext] ?? 0) + 1;
126
176
  // Top-level dirs that survived the walk (i.e. weren't skipped).
127
177
  let topLevelDirs = [];
128
178
  try {
129
- const topLevel = await fs.readdir(cwd, { withFileTypes: true });
179
+ const topLevel = rootEntries ?? await fs.readdir(cwd, { withFileTypes: true });
130
180
  topLevelDirs = topLevel
131
181
  .filter((e) => e.isDirectory() && !ALWAYS_SKIPPED_DIRS.has(e.name) && !matchesIgnore(ignoreMatchers, e.name, true))
132
182
  .map((e) => e.name)
@@ -144,13 +194,26 @@ export async function indexFiles(cwd) {
144
194
  return b.mtime - a.mtime;
145
195
  });
146
196
  const paths = collected.slice(0, MAX_PATHS_IN_INDEX).map((f) => f.path);
147
- return {
197
+ const index = {
148
198
  totalFiles,
149
199
  byExtension,
150
200
  topLevelDirs,
151
201
  paths,
152
202
  truncated,
153
203
  };
204
+ // Only a cut walk carries the field, so every other index is the same object as before.
205
+ if (walkCut)
206
+ index.walkCut = true;
207
+ return index;
208
+ }
209
+ /** Lexicographic order of two readdir-index paths (depth-first order). */
210
+ function compareOrder(a, b) {
211
+ const n = Math.min(a.length, b.length);
212
+ for (let i = 0; i < n; i++) {
213
+ if (a[i] !== b[i])
214
+ return a[i] - b[i];
215
+ }
216
+ return a.length - b.length;
154
217
  }
155
218
  async function loadIgnoreMatchers(cwd) {
156
219
  const patterns = [];
@@ -29,7 +29,7 @@ import { extractCap } from './domain/cap.js';
29
29
  import { extractFiori } from './domain/fiori.js';
30
30
  import { getGitState } from './git.js';
31
31
  import { indexFiles } from './index-files.js';
32
- export { renderProjectContext } from './render.js';
32
+ export { renderProjectContext, renderProjectContextCapped } from './render.js';
33
33
  /**
34
34
  * Compute the full project context for a directory. Best-effort: returns
35
35
  * null on hard failure (e.g. cwd doesn't exist), otherwise an envelope
@@ -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
+ }