@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.
Files changed (47) hide show
  1. package/.gitattributes +1 -0
  2. package/dist/agent/loop.js +127 -31
  3. package/dist/agent/parallel-write-guard.js +3 -1
  4. package/dist/commands/plan-audit-evidence.js +6 -1
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +9 -0
  7. package/dist/lib/graceful-exit.js +17 -0
  8. package/dist/one-shot.js +3 -0
  9. package/dist/projects/answer-blockers.js +25 -12
  10. package/dist/projects/extract-plan.js +6 -0
  11. package/dist/projects/plan-objects.js +1 -1
  12. package/dist/projects/plan-skeleton.js +93 -0
  13. package/dist/projects/save-command.js +17 -0
  14. package/dist/rap/finish-checks.js +389 -0
  15. package/dist/rap/generator-names.js +57 -0
  16. package/dist/rap/skeleton-card.js +31 -0
  17. package/dist/rap/skeleton-rules.js +38 -0
  18. package/dist/rap/skeleton-session.js +21 -0
  19. package/dist/rap/skeleton-store.js +40 -0
  20. package/dist/renderer/change-card.js +28 -4
  21. package/dist/renderer/notices.js +27 -13
  22. package/dist/renderer/progress-chatter.js +3 -1
  23. package/dist/renderer/tool-labels.js +12 -3
  24. package/dist/renderer/verify-chain.js +41 -18
  25. package/dist/renderer/wrap-rows.js +87 -0
  26. package/dist/repl/rule8-detector.js +3 -2
  27. package/dist/repl.js +11 -2
  28. package/dist/sap/connection-manager.js +15 -0
  29. package/dist/sap/generator-fact.js +21 -0
  30. package/dist/sap/session-end.js +66 -0
  31. package/dist/session/audit-export.js +19 -1
  32. package/dist/tools/ask-question.js +1 -1
  33. package/dist/tools/rap/finish-check.js +118 -0
  34. package/dist/tools/rap/generate-objects.js +317 -0
  35. package/dist/tools/rap/skeleton-resolve.js +283 -0
  36. package/dist/tools/sap-read.js +22 -22
  37. package/dist/tools/sap-write.js +249 -18
  38. package/dist/tools/snapshot.js +26 -0
  39. package/dist/tools/tool-loading.js +7 -1
  40. package/dist/tools/write-mode.js +2 -0
  41. package/dist/ui/app.js +19 -9
  42. package/dist/ui/approval-modal.js +37 -5
  43. package/dist/ui/live-area.js +14 -1
  44. package/dist/ui/widgets/ask-card.js +183 -107
  45. package/dist/ui/widgets/coaching-picker.js +22 -1
  46. package/dist/ui/widgets/confirm-card.js +102 -76
  47. package/package.json +2 -2
package/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ test/unit/recorded/golden/*.json -text
@@ -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
- * Choices picker for an answer-blocker that carries `options`. Renders the
231
- * native Ink AskCard (the same CC-style picker the ask_question tool
232
- * uses) with the candidate answers plus "Write my own" and "Skip" escapes.
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
- * - headless → skip (never blocks on stdin)
235
- * - classic (non-Ink) → 'write' so the caller falls back to the free-text box
236
- * - Ink → choice/multi modal; Esc or empty selection → skip
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
- * The two sentinels are filtered out of any real picked answer; for multi-select
239
- * the modal returns comma-joined values, so Write/Skip win over any co-selected
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 choices = [
251
- ...a.options.map((o) => ({ value: o, label: o })),
252
- { value: CHOOSE_WRITE, label: '✏️ Write my own…' },
253
- { value: CHOOSE_SKIP, label: '↷ Skip (leave for the viewer)' },
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 res = await askQuestionEmitter.request({
257
- id: 'answer-blocker',
258
- question: `Blocker ${a.index + 1}/${a.total}: ${a.question}`,
259
- context: a.multiSelect ? 'Space to toggle one or more · Enter to submit' : undefined,
260
- kind: a.multiSelect ? 'multi' : 'choice',
261
- choices,
262
- });
263
- if (res.cancelled || res.answer == null)
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
- const values = res.answer.split(',');
266
- if (values.includes(CHOOSE_WRITE))
267
- return { kind: 'write' };
268
- if (values.includes(CHOOSE_SKIP))
269
- return { kind: 'skip' };
270
- const real = values.filter((v) => v !== CHOOSE_WRITE && v !== CHOOSE_SKIP);
271
- if (real.length === 0)
272
- return { kind: 'skip' };
273
- return { kind: 'picked', answer: real.join(', ') };
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
- export function setPlanPhaseActive(active, planId) {
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: `Answer the ${blockers.length} unanswered blocker(s) now?`,
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
- // 2026-09-17 — the "Blocker i/n: …" line is printed only on the free-text
96
- // path below. When a picker handles the question it shows the same text
97
- // itself, and printing it here too showed every blocker twice.
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 (b.options && b.options.length > 0 && args.choose) {
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}/${blockers.length}: ${b.text}`);
122
- const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
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('No answers entered — all blockers left open for the viewer.');
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} answer(s) in the file`
143
- + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
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