@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.
- package/dist/agent/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +101 -13
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +6 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/one-shot.js +1 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +107 -16
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +46 -18
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl.js +11 -2
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/approval.js +12 -1
- package/dist/tools/project/spec_answers_record.js +190 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- package/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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, '&')
|
|
@@ -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
|
-
|
|
148
|
+
const outcomes = new Map(blockers.map((b) => [b.id, 'not-asked']));
|
|
149
|
+
let stop = false;
|
|
150
|
+
for (let i = 0; i < blockers.length && !stop; i++) {
|
|
99
151
|
const b = blockers[i];
|
|
100
|
-
// The "Blocker i of n: …" line is printed
|
|
101
|
-
// below
|
|
152
|
+
// The "Blocker i of n: …" line is printed on the free-text fallback
|
|
153
|
+
// below, and once when a typed question-like reply is not saved (Task 15).
|
|
154
|
+
// Otherwise the picker shows the question itself and leaves its own trace.
|
|
102
155
|
// The picker first, for every blocker (options or free text).
|
|
103
156
|
// 'write' falls through to the text box; 'skip' leaves the blocker open.
|
|
104
157
|
let askText = true;
|
|
158
|
+
let shownQuestion = false;
|
|
159
|
+
outcomes.set(b.id, 'skipped');
|
|
160
|
+
// A typed reply (not a picked option) that ends in "?" is checked once
|
|
161
|
+
// before it is saved. On No it stays open: one "Not saved" line, then the
|
|
162
|
+
// full question once. The Yes/No card answers '' only when cancelled
|
|
163
|
+
// (Ctrl+C; Enter gives its default, No), which stops the form like the
|
|
164
|
+
// picker's 'stop'. Without a card (prompt only), '' is Enter = No.
|
|
165
|
+
const keepTyped = async (typed) => {
|
|
166
|
+
if (!readsLikeQuestion(typed))
|
|
167
|
+
return true;
|
|
168
|
+
const reply = await askYesNo({
|
|
169
|
+
question: 'That reads like a question, not an answer. Save it as the answer?',
|
|
170
|
+
confirm: args.confirm,
|
|
171
|
+
prompt: args.prompt,
|
|
172
|
+
});
|
|
173
|
+
if (isYes(reply))
|
|
174
|
+
return true;
|
|
175
|
+
if (args.confirm && reply === '') {
|
|
176
|
+
outcomes.set(b.id, 'stopped');
|
|
177
|
+
stop = true;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
outcomes.set(b.id, 'declined-question');
|
|
181
|
+
args.log('');
|
|
182
|
+
args.log('Not saved. The blocker stays open.');
|
|
183
|
+
if (!shownQuestion) {
|
|
184
|
+
args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
|
|
185
|
+
shownQuestion = true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
189
|
+
const save = (a) => { answers.set(b.id, a); outcomes.set(b.id, 'answered'); };
|
|
105
190
|
if (args.choose) {
|
|
106
191
|
const c = await args.choose({
|
|
107
192
|
question: b.text,
|
|
@@ -111,8 +196,10 @@ export async function offerAnswerBlockers(args) {
|
|
|
111
196
|
total: blockers.length,
|
|
112
197
|
});
|
|
113
198
|
if (c.kind === 'picked') {
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
const a = c.answer.trim();
|
|
200
|
+
const typed = !isOnlyOptions(a, b.options ?? [], !!b.multiSelect);
|
|
201
|
+
if (a.length > 0 && (!typed || await keepTyped(a)))
|
|
202
|
+
save(a);
|
|
116
203
|
askText = false;
|
|
117
204
|
}
|
|
118
205
|
else if (c.kind === 'skip') {
|
|
@@ -121,6 +208,7 @@ export async function offerAnswerBlockers(args) {
|
|
|
121
208
|
else if (c.kind === 'stop') {
|
|
122
209
|
// Ctrl+C (Task 4, m2): ask none of the rest. Answers already given
|
|
123
210
|
// are kept and saved below; the others stay open.
|
|
211
|
+
outcomes.set(b.id, 'stopped');
|
|
124
212
|
break;
|
|
125
213
|
}
|
|
126
214
|
// c.kind === 'write' → askText stays true
|
|
@@ -128,31 +216,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
|
-
|
|
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
|
-
?
|
|
140
|
-
: `No answers
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|