@cspeach/cli 1.1.34 → 1.1.36
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/.gitattributes +1 -0
- package/dist/agent/loop.js +127 -31
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +12 -3
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +11 -2
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/generator-fact.js +21 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
package/.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
test/unit/recorded/golden/*.json -text
|
package/dist/agent/loop.js
CHANGED
|
@@ -44,6 +44,7 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
|
44
44
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
45
45
|
import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
46
46
|
import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
|
|
47
|
+
import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
|
|
47
48
|
import { afterRebuildTurn } from '../journey/rebuild.js';
|
|
48
49
|
import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
|
|
49
50
|
import { addJourneyLeftOut, addJourneySkips, getJourneyFlow } from '../journey/flow.js';
|
|
@@ -227,50 +228,140 @@ export async function inkAwareConfirm(q, opts) {
|
|
|
227
228
|
return 'n';
|
|
228
229
|
}
|
|
229
230
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
231
|
+
* The picker for one spec-gap blocker (Task 3, 2026-10-12). It asks through
|
|
232
|
+
* the normal question path: a one-question v2 form, the same shape the
|
|
233
|
+
* ask_question tool sends, drawn by AskCard.
|
|
233
234
|
*
|
|
234
|
-
* -
|
|
235
|
-
* -
|
|
236
|
-
* -
|
|
235
|
+
* - "Blocker 2 of 4" on the card frame's top edge (Task 4); the question is the bare text
|
|
236
|
+
* - Ctrl+C on the card → { kind: 'stop' }: the remaining blockers are not asked
|
|
237
|
+
* - options show in sentence case (the stored answer keeps the words as
|
|
238
|
+
* written), then a plain "Skip for now" option; the card's own inline
|
|
239
|
+
* "Type something else…" row takes a typed answer, so no second text card
|
|
240
|
+
* - a blocker with no options is a free-text form question (Esc skips)
|
|
241
|
+
* - asked under the card-slot focus claim; one scrollback line per answer
|
|
242
|
+
* ("Blocker 2 of 4 → <answer>"), so answered blockers stay on screen
|
|
243
|
+
* - headless → skip (never blocks); classic (non-Ink) → 'write' (free text)
|
|
237
244
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* options.
|
|
245
|
+
* Option values are index ids (o0, o1…) so an option with a comma survives a
|
|
246
|
+
* multi-select answer (comma-joined values).
|
|
241
247
|
*/
|
|
242
|
-
const CHOOSE_WRITE = '__cspeach_write__';
|
|
243
248
|
const CHOOSE_SKIP = '__cspeach_skip__';
|
|
249
|
+
const SKIP_LABEL = 'Skip for now';
|
|
250
|
+
const SKIP_DESCRIPTION = 'Stays open. Answer it later at cspeach.dev/edit.';
|
|
251
|
+
const SKIPPED_TRACE = 'skipped for now';
|
|
252
|
+
const STOPPED_TRACE = 'stopped';
|
|
253
|
+
/**
|
|
254
|
+
* A first word that is a name, not prose (Task 4, m1): it holds _ / digits /
|
|
255
|
+
* a slash (zcl_foo, s4hk903359, zfin/pkg), is all capitals (ZTEST_LAS, ERDAT)
|
|
256
|
+
* or has a capital after its first letter (eTag). Such a word is never recased.
|
|
257
|
+
*/
|
|
258
|
+
const Z_Y_ENGLISH = new Set(['yes', 'you', 'your', 'yours', 'yet', 'year', 'years', 'yearly', 'yield', 'zero', 'zone', 'zones', 'zoom']);
|
|
259
|
+
function looksLikeIdentifier(word) {
|
|
260
|
+
if (/[_\d/]/.test(word))
|
|
261
|
+
return true;
|
|
262
|
+
// A Z/Y customer-namespace name (zfin, ytools) unless it is a common English word.
|
|
263
|
+
if (/^[zy][a-z0-9_]{2,}$/i.test(word) && !Z_Y_ENGLISH.has(word.toLowerCase()))
|
|
264
|
+
return true;
|
|
265
|
+
if (/[A-Z]/.test(word.slice(1)))
|
|
266
|
+
return true;
|
|
267
|
+
return word.length > 1 && /[A-Z]/.test(word) && word === word.toUpperCase();
|
|
268
|
+
}
|
|
269
|
+
/** First letter upper case when it is a lower-case letter; the rest as written. Identifiers stay as written. */
|
|
270
|
+
function sentenceCase(label) {
|
|
271
|
+
const first = label.charAt(0);
|
|
272
|
+
if (first === first.toUpperCase())
|
|
273
|
+
return label;
|
|
274
|
+
if (looksLikeIdentifier(label.split(/\s/, 1)[0] ?? ''))
|
|
275
|
+
return label;
|
|
276
|
+
return first.toUpperCase() + label.slice(1);
|
|
277
|
+
}
|
|
244
278
|
export async function inkAwareChoose(a) {
|
|
245
279
|
const { shouldUseInk, isHeadless } = await import('../renderer/tty.js');
|
|
246
280
|
if (isHeadless())
|
|
247
281
|
return { kind: 'skip' };
|
|
248
282
|
if (!shouldUseInk())
|
|
249
283
|
return { kind: 'write' }; // classic REPL → free-text fallback
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{ value:
|
|
284
|
+
const header = `Blocker ${a.index + 1} of ${a.total}`;
|
|
285
|
+
const freeText = a.options.length === 0;
|
|
286
|
+
const options = freeText ? [] : [
|
|
287
|
+
...a.options.map((o, i) => ({ value: `o${i}`, label: sentenceCase(o) })),
|
|
288
|
+
{ value: CHOOSE_SKIP, label: SKIP_LABEL, description: SKIP_DESCRIPTION },
|
|
254
289
|
];
|
|
255
290
|
const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
291
|
+
const { withFocusClaim } = await import('../ui/card-slot.js');
|
|
292
|
+
const { emitTrace, formatAnswerTrace } = await import('../renderer/trace.js');
|
|
293
|
+
const { uiWidth } = await import('../renderer/ui-width.js');
|
|
294
|
+
let stopped = false;
|
|
295
|
+
const res = await withFocusClaim(() => askQuestionEmitter.requestForm({
|
|
296
|
+
formId: 'answer-blocker',
|
|
297
|
+
// Task 4 (m4): the counter sits on the frame's top edge.
|
|
298
|
+
frameHeader: header,
|
|
299
|
+
// Task 4 (m2): Ctrl+C stops the remaining blocker cards.
|
|
300
|
+
onInterrupt: () => { stopped = true; },
|
|
301
|
+
questions: [{
|
|
302
|
+
id: 'answer-blocker',
|
|
303
|
+
question: a.question,
|
|
304
|
+
...(freeText ? { context: 'Esc skips it for now' } : {}),
|
|
305
|
+
header,
|
|
306
|
+
multiSelect: !freeText && a.multiSelect,
|
|
307
|
+
freeText,
|
|
308
|
+
options,
|
|
309
|
+
}],
|
|
310
|
+
}));
|
|
311
|
+
const skip = () => {
|
|
312
|
+
emitTrace(formatAnswerTrace(header, SKIPPED_TRACE, uiWidth()));
|
|
264
313
|
return { kind: 'skip' };
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
272
|
-
return
|
|
273
|
-
|
|
314
|
+
};
|
|
315
|
+
if (stopped) {
|
|
316
|
+
emitTrace(formatAnswerTrace(header, STOPPED_TRACE, uiWidth()));
|
|
317
|
+
return { kind: 'stop' };
|
|
318
|
+
}
|
|
319
|
+
const ans = res.cancelled ? undefined : res.answers[0];
|
|
320
|
+
if (!ans || ans.answer == null)
|
|
321
|
+
return skip();
|
|
322
|
+
let answer;
|
|
323
|
+
let shown;
|
|
324
|
+
if (freeText) {
|
|
325
|
+
answer = ans.answer.trim();
|
|
326
|
+
shown = answer;
|
|
327
|
+
}
|
|
328
|
+
else if (ans.custom) {
|
|
329
|
+
// Task 4 (m3): a multi-select typed answer arrives as the ticked labels
|
|
330
|
+
// (in option order), then the typed words. The labels go back to the
|
|
331
|
+
// options as written; a ticked "Skip for now" gives way to the answer.
|
|
332
|
+
let rest = ans.answer.trim();
|
|
333
|
+
const parts = [];
|
|
334
|
+
const shownParts = [];
|
|
335
|
+
if (a.multiSelect) {
|
|
336
|
+
for (const o of options) {
|
|
337
|
+
if (!rest.startsWith(`${o.label}, `))
|
|
338
|
+
continue;
|
|
339
|
+
rest = rest.slice(o.label.length + 2);
|
|
340
|
+
const i = /^o(\d+)$/.exec(o.value);
|
|
341
|
+
if (i) {
|
|
342
|
+
parts.push(a.options[Number(i[1])]);
|
|
343
|
+
shownParts.push(o.label);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
answer = [...parts, rest].join(', ');
|
|
348
|
+
shown = [...shownParts, rest].join(', ');
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const values = ans.answer.split(',');
|
|
352
|
+
if (values.includes(CHOOSE_SKIP))
|
|
353
|
+
return skip();
|
|
354
|
+
const picked = values
|
|
355
|
+
.map((v) => /^o(\d+)$/.exec(v))
|
|
356
|
+
.map((m) => (m ? a.options[Number(m[1])] : undefined))
|
|
357
|
+
.filter((o) => o !== undefined);
|
|
358
|
+
answer = picked.join(', ');
|
|
359
|
+
shown = picked.map(sentenceCase).join(', ');
|
|
360
|
+
}
|
|
361
|
+
if (answer.length === 0)
|
|
362
|
+
return skip();
|
|
363
|
+
emitTrace(formatAnswerTrace(header, shown, uiWidth()));
|
|
364
|
+
return { kind: 'picked', answer };
|
|
274
365
|
}
|
|
275
366
|
export async function maybeOfferSave(p) {
|
|
276
367
|
if (p.assistantText.trim().length === 0)
|
|
@@ -337,6 +428,7 @@ export async function maybeOfferSave(p) {
|
|
|
337
428
|
// the skill it saves (the /abap-plan turn that got the lane block); a plan
|
|
338
429
|
// found in another skill's output (detectArtifactSkill) never gets it.
|
|
339
430
|
...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
|
|
431
|
+
...(p.latestSkeleton && p.skill === saveSkill ? { latestSkeleton: p.latestSkeleton } : {}),
|
|
340
432
|
onNotSaved: (reason) => { notSaved = reason; },
|
|
341
433
|
...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
|
|
342
434
|
// Never headless: a headless journey queues nothing.
|
|
@@ -1751,6 +1843,10 @@ ${imageExpansion.error}`));
|
|
|
1751
1843
|
const flowLane = getAppFlow(session)?.lane;
|
|
1752
1844
|
return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
|
|
1753
1845
|
})(),
|
|
1846
|
+
...(() => {
|
|
1847
|
+
const sk = getLatestSkeletonDecision(session);
|
|
1848
|
+
return sk ? { latestSkeleton: { path: sk.path, sha256: sk.sha256 } } : {};
|
|
1849
|
+
})(),
|
|
1754
1850
|
// Sessions/folders (Task 6) — the first save tags the session with
|
|
1755
1851
|
// its folder's project id, only when the session belongs to this
|
|
1756
1852
|
// folder (ruling 18). Saved to disk right after the hook.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Scope of "write-class" matches the spec for this bug fix and is narrower
|
|
23
23
|
* than tools/write-mode.ts WRITE_TOOLS: only the four tools that perform
|
|
24
|
-
* net-new source mutation are gated here.
|
|
24
|
+
* net-new source mutation (and the RAP generator, which creates objects) are gated here.
|
|
25
25
|
*/
|
|
26
26
|
/** Tools that mutate ABAP source. Narrower than WRITE_TOOLS — excludes
|
|
27
27
|
* activate / publish / transport_release / message_maintain. The bug
|
|
@@ -31,6 +31,8 @@ export const WRITE_CLASS_TOOLS = new Set([
|
|
|
31
31
|
'sap_update_method',
|
|
32
32
|
'sap_delete_object',
|
|
33
33
|
'sap_create_object',
|
|
34
|
+
// Final review I2 — SAP's RAP generator creates up to 9 objects in one call.
|
|
35
|
+
'sap_generate_objects',
|
|
34
36
|
]);
|
|
35
37
|
export function isWriteClassTool(name) {
|
|
36
38
|
return WRITE_CLASS_TOOLS.has(name);
|
|
@@ -44,8 +44,13 @@ import { checkpointsRoot } from '../agent/skill-checkpoint.js';
|
|
|
44
44
|
* contract branch depends on it — so it must NEVER be evicted by the row cap:
|
|
45
45
|
* PROTECTED, not the fillable existence tier. Matched EXACTLY (^…$ anchored) so
|
|
46
46
|
* a near-miss tool name can never masquerade as consent.
|
|
47
|
+
*
|
|
48
|
+
* RAP generator (2026-10-14): sap_generate_objects is a write (SAP's generator
|
|
49
|
+
* creates the whole stack) and rap_finish_check is the F1–F5 proof of a RAP
|
|
50
|
+
* phase — both PROTECTED in any phase. rap_finish_check is matched EXACTLY.
|
|
51
|
+
* The R26 rule for the fiori_* smokes (cap.* phases only) is unchanged.
|
|
47
52
|
*/
|
|
48
|
-
const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_object|activate|syntax_check|atc_run|inactive_objects|transport_create|transport_release|service_binding_publish|snapshot)|^request_approval$/;
|
|
53
|
+
const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_object|activate|syntax_check|atc_run|inactive_objects|transport_create|transport_release|service_binding_publish|snapshot|generate_objects)|^request_approval$|^rap_finish_check$/;
|
|
49
54
|
/**
|
|
50
55
|
* D-F (2026-07-05): EXISTENCE / VERIFY-READ evidence. A verify-only phase
|
|
51
56
|
* re-run (object already built in a prior attempt; this attempt proves it
|
|
@@ -64,9 +64,14 @@ let planPhaseActive = false;
|
|
|
64
64
|
* plan object-list grant covers requests only for THIS plan; a step started
|
|
65
65
|
* without an id (null) is never covered. Cleared with the flag. */
|
|
66
66
|
let activePlanId = null;
|
|
67
|
-
|
|
67
|
+
/* RAP generator final review M1 — the running plan's file, so a tool resolves
|
|
68
|
+
* the plan's pinned files (skeleton decision) from the plan's own root first,
|
|
69
|
+
* like the lane reader does. Cleared with the flag. */
|
|
70
|
+
let activePlanPath = null;
|
|
71
|
+
export function setPlanPhaseActive(active, planId, planPath) {
|
|
68
72
|
planPhaseActive = active;
|
|
69
73
|
activePlanId = active && typeof planId === 'string' && planId ? planId : null;
|
|
74
|
+
activePlanPath = active && typeof planPath === 'string' && planPath ? planPath : null;
|
|
70
75
|
}
|
|
71
76
|
export function isPlanPhaseActive() {
|
|
72
77
|
return planPhaseActive;
|
|
@@ -75,6 +80,10 @@ export function isPlanPhaseActive() {
|
|
|
75
80
|
export function getActivePlanId() {
|
|
76
81
|
return planPhaseActive ? activePlanId : null;
|
|
77
82
|
}
|
|
83
|
+
/** The running plan's file path, or null (no step, or started without one). */
|
|
84
|
+
export function getActivePlanPath() {
|
|
85
|
+
return planPhaseActive ? activePlanPath : null;
|
|
86
|
+
}
|
|
78
87
|
/* Task 7 — per-phase subagent dispatch counter. agent_run increments it on
|
|
79
88
|
* every dispatch made while a plan phase is active; the REPL reads it
|
|
80
89
|
* (read+reset) in the runTurn finally that clears the plan-phase flag and
|
|
@@ -59,6 +59,7 @@ import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
|
|
|
59
59
|
import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
|
|
60
60
|
import { askYesNo, isYes } from '../projects/yes-no.js';
|
|
61
61
|
import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
62
|
+
import { skeletonWriteBackConflict } from '../projects/plan-skeleton.js';
|
|
62
63
|
import { checkLaneConsistency, checkRequiredDeploy, isDeployPhase, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
|
|
63
64
|
import { askDeployCard } from './deploy-card.js';
|
|
64
65
|
import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.js';
|
|
@@ -925,6 +926,14 @@ export async function finishPlanResume(args) {
|
|
|
925
926
|
logNotSaved(args, laneConflict ? 'a step no longer fits the app type the plan was made for. Re-run /abap-plan to rebuild the plan.' : 'it dropped the deploy step this app needs', (laneConflict ?? deployConflict));
|
|
926
927
|
return null;
|
|
927
928
|
}
|
|
929
|
+
// RAP generator plan, Task 8 — the skeleton decision is fixed for the run:
|
|
930
|
+
// a write-back may not change, drop or add a phase's work.skeleton. A plan
|
|
931
|
+
// without one that stays without one is untouched (older plans, spec §8).
|
|
932
|
+
const skeletonConflict = priorParsed.ok ? skeletonWriteBackConflict(priorParsed.content, extract.content) : null;
|
|
933
|
+
if (skeletonConflict) {
|
|
934
|
+
logNotSaved(args, 'it changed the skeleton decision, which cannot change during the run. Re-run /abap-plan to change it.', skeletonConflict);
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
928
937
|
}
|
|
929
938
|
const nowIso = new Date().toISOString();
|
|
930
939
|
const statuses = extract.statuses;
|
|
@@ -31,6 +31,20 @@ function call(fn, self) {
|
|
|
31
31
|
return Promise.reject(e);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* 2026-10-13 — async work that must run on the way out, before the sockets
|
|
36
|
+
* close (ending the stateful SAP session, so its locks leave SM12). Capped as
|
|
37
|
+
* a whole; a task that throws or hangs never blocks the exit.
|
|
38
|
+
*/
|
|
39
|
+
const exitTasks = new Set();
|
|
40
|
+
/** Register an async exit task. Returns an unregister. */
|
|
41
|
+
export function registerExitTask(task) {
|
|
42
|
+
exitTasks.add(task);
|
|
43
|
+
return () => { exitTasks.delete(task); };
|
|
44
|
+
}
|
|
45
|
+
export function __resetExitTasksForTests() {
|
|
46
|
+
exitTasks.clear();
|
|
47
|
+
}
|
|
34
48
|
export async function gracefulExit(code, opts = {}) {
|
|
35
49
|
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
36
50
|
const graceMs = opts.graceMs ?? 250;
|
|
@@ -38,6 +52,9 @@ export async function gracefulExit(code, opts = {}) {
|
|
|
38
52
|
const guard = () => { };
|
|
39
53
|
process.on('unhandledRejection', guard);
|
|
40
54
|
try {
|
|
55
|
+
if (exitTasks.size > 0) {
|
|
56
|
+
await capped(Promise.allSettled([...exitTasks].map((t) => call(t, {}))), opts.exitTaskMs ?? 3_500);
|
|
57
|
+
}
|
|
41
58
|
const dispatcher = globalThis[UNDICI_DISPATCHER];
|
|
42
59
|
if (dispatcher) {
|
|
43
60
|
let done = false;
|
package/dist/one-shot.js
CHANGED
|
@@ -63,6 +63,9 @@ import './tools/cap/metadata-import.js';
|
|
|
63
63
|
import './tools/cap/sap-link-tool.js';
|
|
64
64
|
import './tools/cap/link-smoke.js';
|
|
65
65
|
import './tools/lane/app-lane-resolve.js';
|
|
66
|
+
import './tools/rap/skeleton-resolve.js';
|
|
67
|
+
import './tools/rap/generate-objects.js';
|
|
68
|
+
import './tools/rap/finish-check.js';
|
|
66
69
|
import './tools/extend-model/tool.js';
|
|
67
70
|
import './tools/text-elements/tools.js';
|
|
68
71
|
import './tools/capability/tool.js';
|
|
@@ -59,6 +59,9 @@ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
|
|
|
59
59
|
lastEditedBy: answerer,
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
function plural(n, word) {
|
|
63
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
64
|
+
}
|
|
62
65
|
function affirmative(reply) {
|
|
63
66
|
const v = reply.trim().toLowerCase();
|
|
64
67
|
return v === 'y' || v === 'yes';
|
|
@@ -83,7 +86,9 @@ export async function offerAnswerBlockers(args) {
|
|
|
83
86
|
if (blockers.length === 0)
|
|
84
87
|
return null;
|
|
85
88
|
const reply = await askYesNo({
|
|
86
|
-
question:
|
|
89
|
+
question: blockers.length === 1
|
|
90
|
+
? 'Answer the open blocker now?'
|
|
91
|
+
: `Answer the ${blockers.length} open blockers now?`,
|
|
87
92
|
confirm: args.confirm,
|
|
88
93
|
prompt: args.prompt,
|
|
89
94
|
});
|
|
@@ -92,16 +97,15 @@ export async function offerAnswerBlockers(args) {
|
|
|
92
97
|
const answers = new Map();
|
|
93
98
|
for (let i = 0; i < blockers.length; i++) {
|
|
94
99
|
const b = blockers[i];
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// Choices first, where the question has them and a picker is available.
|
|
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.
|
|
102
|
+
// The picker first, for every blocker (options or free text).
|
|
99
103
|
// 'write' falls through to the text box; 'skip' leaves the blocker open.
|
|
100
104
|
let askText = true;
|
|
101
|
-
if (
|
|
105
|
+
if (args.choose) {
|
|
102
106
|
const c = await args.choose({
|
|
103
107
|
question: b.text,
|
|
104
|
-
options: b.options,
|
|
108
|
+
options: b.options ?? [],
|
|
105
109
|
multiSelect: !!b.multiSelect,
|
|
106
110
|
index: i,
|
|
107
111
|
total: blockers.length,
|
|
@@ -114,19 +118,26 @@ export async function offerAnswerBlockers(args) {
|
|
|
114
118
|
else if (c.kind === 'skip') {
|
|
115
119
|
askText = false;
|
|
116
120
|
}
|
|
121
|
+
else if (c.kind === 'stop') {
|
|
122
|
+
// Ctrl+C (Task 4, m2): ask none of the rest. Answers already given
|
|
123
|
+
// are kept and saved below; the others stay open.
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
117
126
|
// c.kind === 'write' → askText stays true
|
|
118
127
|
}
|
|
119
128
|
if (askText) {
|
|
120
129
|
args.log('');
|
|
121
|
-
args.log(`Blocker ${i + 1}
|
|
122
|
-
const a = (await args.prompt('
|
|
130
|
+
args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
|
|
131
|
+
const a = (await args.prompt('Your answer (Enter to skip): ')).trim();
|
|
123
132
|
if (a.length > 0)
|
|
124
133
|
answers.set(b.id, a);
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
if (answers.size === 0) {
|
|
128
137
|
args.log('');
|
|
129
|
-
args.log(
|
|
138
|
+
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.`);
|
|
130
141
|
return null;
|
|
131
142
|
}
|
|
132
143
|
const nowIso = (args.now ?? (() => new Date().toISOString()))();
|
|
@@ -139,7 +150,9 @@ export async function offerAnswerBlockers(args) {
|
|
|
139
150
|
writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
|
|
140
151
|
const stillOpen = blockers.length - answers.size;
|
|
141
152
|
args.log('');
|
|
142
|
-
args.log(`Recorded ${answers.size
|
|
143
|
-
+ (stillOpen
|
|
153
|
+
args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
|
|
154
|
+
+ (stillOpen === 0 ? ''
|
|
155
|
+
: stillOpen === 1 ? ' 1 blocker is still open. Answer it later at cspeach.dev/edit.'
|
|
156
|
+
: ` ${stillOpen} blockers are still open. Answer them later at cspeach.dev/edit.`));
|
|
144
157
|
return answers.size;
|
|
145
158
|
}
|
|
@@ -84,6 +84,12 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
84
84
|
if (ow && typeof ow === 'object' && ow.plannedObjects === undefined && priorWork?.plannedObjects !== undefined) {
|
|
85
85
|
ow.plannedObjects = priorWork.plannedObjects;
|
|
86
86
|
}
|
|
87
|
+
// RAP generator plan, Task 8 — the skeleton decision is plan-create-only too:
|
|
88
|
+
// a step's write-back that omits it keeps it (plan-resume refuses a change).
|
|
89
|
+
const priorSkeleton = priorWork?.skeleton;
|
|
90
|
+
if (ow && typeof ow === 'object' && ow.skeleton === undefined && priorSkeleton !== undefined) {
|
|
91
|
+
ow.skeleton = priorSkeleton;
|
|
92
|
+
}
|
|
87
93
|
}
|
|
88
94
|
// 1:1 map over the PRIOR phase list — changed entries overlay their phase,
|
|
89
95
|
// everything else passes through verbatim from the envelope.
|
|
@@ -9,7 +9,7 @@ export const OBJECT_TYPE_LABELS = {
|
|
|
9
9
|
const NEVER_ON_LIST = new Set(['WAPA', 'CTS', 'DEVC']);
|
|
10
10
|
const TYPE_RE = /^[A-Z]{4}$/;
|
|
11
11
|
const NAME_RE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
|
|
12
|
-
const PKG_RE = /^(\$[A-Z0-9_]{1,29}|(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30})$/;
|
|
12
|
+
export const PKG_RE = /^(\$[A-Z0-9_]{1,29}|(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30})$/;
|
|
13
13
|
export const MAX_PLANNED_OBJECTS = 200;
|
|
14
14
|
/** ADT type code from a raw type: `'ddls/df'` → `'DDLS'`. */
|
|
15
15
|
export function normalizeObjectType(raw) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/plan-skeleton.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 8 — a RAP lane plan must carry the CLI's skeleton
|
|
4
|
+
// decision unchanged (spec §3.3). Checked at plan create; an older plan without
|
|
5
|
+
// a skeleton phase still resumes exactly as before (no regression, spec §8).
|
|
6
|
+
//
|
|
7
|
+
// - checkSkeletonDecision: at save. The phase whose `work.skeleton` holds the
|
|
8
|
+
// Plan block from rap_skeleton_resolve must name the builder the decision
|
|
9
|
+
// file (sha-pinned) records, and on the generator path list exactly the
|
|
10
|
+
// objects SAP's generator will create.
|
|
11
|
+
// - skeletonWriteBackConflict: at resume. A write-back may not change, drop or
|
|
12
|
+
// add a `work.skeleton` — the decision is fixed for the run.
|
|
13
|
+
import { cardLine } from '../lane/card.js';
|
|
14
|
+
import { readSkeletonDecision } from '../rap/skeleton-store.js';
|
|
15
|
+
import { toPlannedObject } from './plan-objects.js';
|
|
16
|
+
// Final review M3 — the developer reads these on screen ([save] lines, no
|
|
17
|
+
// detail split there): plain words only, no tool, field, layer or phase ids.
|
|
18
|
+
export const SKELETON_PHASE_MISSING = 'This RAP plan is missing its skeleton step. Run /abap-plan again.';
|
|
19
|
+
export const SKELETON_DECISION_STALE = 'This plan uses an older skeleton choice, not the newest one. Run /abap-plan again.';
|
|
20
|
+
export const SKELETON_CHANGED_ON_RESUME = 'The skeleton choice cannot change during a run. Run /abap-plan again.';
|
|
21
|
+
const SKELETON_TWO_PHASES = 'This plan has more than one skeleton step. Run /abap-plan again.';
|
|
22
|
+
const SKELETON_BUILDER_DIFFERS = 'This plan builds the skeleton differently from the choice on the skeleton card. Run /abap-plan again.';
|
|
23
|
+
const key = (o) => `${o.type} ${o.name} ${o.package ?? ''}`.trim();
|
|
24
|
+
const skeletonOf = (p) => p.work?.skeleton;
|
|
25
|
+
/** True when any phase carries a `work.skeleton`. */
|
|
26
|
+
export function hasSkeletonPhase(content) {
|
|
27
|
+
return content.phases.some((p) => !!skeletonOf(p));
|
|
28
|
+
}
|
|
29
|
+
export async function checkSkeletonDecision(content, rec, laneRoot, opts) {
|
|
30
|
+
if (rec.final.backend !== 'rap')
|
|
31
|
+
return null;
|
|
32
|
+
const phases = content.phases.filter((p) => !!skeletonOf(p));
|
|
33
|
+
if (phases.length === 0) {
|
|
34
|
+
// Fix round 1 — keyed on CLI-owned state only (`creating`, from the prior
|
|
35
|
+
// envelope), never on the model-written project.mode.
|
|
36
|
+
return opts.creating ? SKELETON_PHASE_MISSING : null;
|
|
37
|
+
}
|
|
38
|
+
if (phases.length > 1) {
|
|
39
|
+
return SKELETON_TWO_PHASES;
|
|
40
|
+
}
|
|
41
|
+
const phase = phases[0];
|
|
42
|
+
const work = phase.work;
|
|
43
|
+
const d = work.skeleton.decision;
|
|
44
|
+
// Fix round 1 — the plan must cite the decision rap_skeleton_resolve wrote
|
|
45
|
+
// last in this session (not an older sha-valid file, e.g. from before an
|
|
46
|
+
// owner override). No session decision (a later session): any sha-valid file.
|
|
47
|
+
if (opts.latest) {
|
|
48
|
+
const cited = String(d?.path ?? '').replace(/\\/g, '/');
|
|
49
|
+
if (cited !== opts.latest.path || String(d?.sha256 ?? '') !== opts.latest.sha256)
|
|
50
|
+
return SKELETON_DECISION_STALE;
|
|
51
|
+
}
|
|
52
|
+
const read = await readSkeletonDecision(laneRoot, String(d?.path ?? ''), String(d?.sha256 ?? ''));
|
|
53
|
+
if (!read.ok)
|
|
54
|
+
return read.message;
|
|
55
|
+
const decided = read.rec.decision.by;
|
|
56
|
+
if (work.skeleton.by !== decided) {
|
|
57
|
+
return SKELETON_BUILDER_DIFFERS;
|
|
58
|
+
}
|
|
59
|
+
if (decided === 'sap-generator') {
|
|
60
|
+
const norm = (list) => (Array.isArray(list) ? list : [])
|
|
61
|
+
.map(toPlannedObject).filter((o) => !!o).map(key);
|
|
62
|
+
const planned = new Set(norm(work.plannedObjects));
|
|
63
|
+
const wanted = new Set(norm(read.rec.objects));
|
|
64
|
+
const missing = [...wanted].find((k) => !planned.has(k));
|
|
65
|
+
const extra = [...planned].find((k) => !wanted.has(k));
|
|
66
|
+
if (missing || extra) {
|
|
67
|
+
const shown = (k) => cardLine(k.split(' ').slice(0, 2).join(' '));
|
|
68
|
+
return "This plan's skeleton step does not list exactly the objects SAP's generator will create. "
|
|
69
|
+
+ `${missing ? `Missing: ${shown(missing)}. ` : ''}${extra ? `Not created by the generator: ${shown(extra)}. ` : ''}Run /abap-plan again.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** JSON with object keys sorted, so a re-ordered but equal skeleton compares equal. */
|
|
75
|
+
function stable(v) {
|
|
76
|
+
if (Array.isArray(v))
|
|
77
|
+
return `[${v.map(stable).join(',')}]`;
|
|
78
|
+
if (v && typeof v === 'object') {
|
|
79
|
+
const o = v;
|
|
80
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stable(o[k])}`).join(',')}}`;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(v) ?? 'null';
|
|
83
|
+
}
|
|
84
|
+
const skeletonMap = (c) => stable(c.phases.filter((p) => !!skeletonOf(p)).map((p) => [p.id, skeletonOf(p)]));
|
|
85
|
+
/**
|
|
86
|
+
* Resume write-back guard: every phase's `work.skeleton` must be exactly as it
|
|
87
|
+
* was (none changed, dropped or added). A plan with no skeleton that stays
|
|
88
|
+
* without one is untouched (older plans resume unchanged). Returns the plain
|
|
89
|
+
* refusal, or null.
|
|
90
|
+
*/
|
|
91
|
+
export function skeletonWriteBackConflict(prior, next) {
|
|
92
|
+
return skeletonMap(prior) === skeletonMap(next) ? null : SKELETON_CHANGED_ON_RESUME;
|
|
93
|
+
}
|
|
@@ -22,6 +22,8 @@ import { resolveProjectFolderSync, ensureProjectId } from './project-folder.js';
|
|
|
22
22
|
import { askYesNo, isYes } from './yes-no.js';
|
|
23
23
|
import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
24
24
|
import { specGapRefForSave } from './plan-sources.js';
|
|
25
|
+
import { checkSkeletonDecision, hasSkeletonPhase } from './plan-skeleton.js';
|
|
26
|
+
import { laneRootFor } from '../lane/roots.js';
|
|
25
27
|
const SKILL_REGISTRY = {
|
|
26
28
|
'abap-spec-gap': {
|
|
27
29
|
artefactType: 'spec-gap',
|
|
@@ -369,6 +371,21 @@ export async function runSaveCommand(args) {
|
|
|
369
371
|
args.log('[save] The plan was not saved.');
|
|
370
372
|
return null;
|
|
371
373
|
}
|
|
374
|
+
// RAP generator plan, Task 8 — a RAP plan holds to the CLI's skeleton
|
|
375
|
+
// decision. The file was written under laneRootFor(cwd) by
|
|
376
|
+
// rap_skeleton_resolve, so it is read from the same root here. A new plan
|
|
377
|
+
// (or a revision of one that already carried a skeleton) must have it; a
|
|
378
|
+
// revision of an older plan without one saves as before (spec §8).
|
|
379
|
+
const skel = await checkSkeletonDecision(planExtract.content, lr.rec, await laneRootFor(args.lane?.cwd ?? args.cwd), {
|
|
380
|
+
creating: !prior || (priorContent !== undefined && hasSkeletonPhase(priorContent)),
|
|
381
|
+
...(args.latestSkeleton ? { latest: args.latestSkeleton } : {}),
|
|
382
|
+
});
|
|
383
|
+
if (skel) {
|
|
384
|
+
args.log('');
|
|
385
|
+
args.log(`[save] ${skel}`);
|
|
386
|
+
args.log('[save] The plan was not saved.');
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
372
389
|
}
|
|
373
390
|
// C1 review fix — name the revision target BEFORE the confirm so the user
|
|
374
391
|
// knows what a 'y' will do. Log line only, deliberately NOT a second
|