@cspeach/cli 1.1.52 → 1.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +101 -13
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +6 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/commands/plan-chain.js +2 -2
  9. package/dist/commands/plan-resume.js +2 -2
  10. package/dist/commands/plan-startup.js +3 -3
  11. package/dist/docx/doc-meta.js +1 -0
  12. package/dist/docx/markdown-to-docx.js +2 -0
  13. package/dist/lane/app-chain.js +13 -6
  14. package/dist/one-shot.js +1 -0
  15. package/dist/project-context/conventions.js +8 -1
  16. package/dist/project-context/index-files.js +97 -34
  17. package/dist/project-context/index.js +1 -1
  18. package/dist/project-context/render.js +38 -4
  19. package/dist/projects/answer-blockers.js +107 -16
  20. package/dist/projects/blocker-answers-note.js +89 -0
  21. package/dist/projects/change-options.js +70 -0
  22. package/dist/projects/document-save.js +86 -0
  23. package/dist/projects/extract-design.js +4 -0
  24. package/dist/projects/extract-estimate.js +4 -0
  25. package/dist/projects/extract-spec-gap.js +8 -0
  26. package/dist/projects/index.js +1 -1
  27. package/dist/projects/plan-objects.js +24 -0
  28. package/dist/projects/plan-run.js +12 -2
  29. package/dist/projects/save-command.js +46 -18
  30. package/dist/renderer/change-card.js +14 -0
  31. package/dist/renderer/html-entities.js +25 -0
  32. package/dist/renderer/machine-blocks.js +54 -7
  33. package/dist/renderer/tool-labels.js +2 -0
  34. package/dist/renderer/tool-widget.js +20 -5
  35. package/dist/repl/chat-lane.js +10 -1
  36. package/dist/repl.js +11 -2
  37. package/dist/session/fresh-start.js +4 -0
  38. package/dist/session/user-prompt.js +2 -0
  39. package/dist/tools/approval.js +12 -1
  40. package/dist/tools/project/spec_answers_record.js +190 -0
  41. package/dist/tools/tool-loading.js +2 -0
  42. package/dist/ui/approval-modal.js +2 -1
  43. package/dist/ui/widgets/ask-card.js +171 -29
  44. package/package.json +2 -2
@@ -53,6 +53,92 @@ export async function offerDocumentSave(p) {
53
53
  });
54
54
  return docxPath ? { mdPath, docxPath } : { mdPath };
55
55
  }
56
+ /**
57
+ * The header the CLI writes above a saved change options document (never the
58
+ * model: the document itself never names the person). Parts that are not
59
+ * known are left out: no system in standalone, no person in a one-shot run.
60
+ */
61
+ export function changeOptionsHeader(p) {
62
+ const parts = [`Date: ${p.today}`];
63
+ if (p.sapSystem)
64
+ parts.push(`System: ${p.sapSystem}`);
65
+ if (p.sessionId)
66
+ parts.push(`Session: ${p.sessionId}`);
67
+ if (p.operator)
68
+ parts.push(`Prepared for: ${p.operator}`);
69
+ return `> **Saved by CSPeach** · ${parts.join(' · ')}\n\n`;
70
+ }
71
+ /**
72
+ * Change options (2026-10-23): the marked document(s) from refactor / impact.
73
+ * One "Save this change options document?" for the turn (headless saves
74
+ * without asking). Each document goes to
75
+ * cspeach-out/<date>-change-options-<OBJECT>.md, never overwritten (a second
76
+ * one is -2), with the CLI header on top. Then the one shared Word step for
77
+ * each file; in ask mode "Word copy too?" is asked once and covers all.
78
+ * The Word copy is made from the document alone; its title page carries the
79
+ * facts. Never throws: a failure is one line through `emit`.
80
+ */
81
+ export async function offerChangeOptionsSave(p) {
82
+ const docs = p.docs.filter((d) => d.markdown.trim().length > 0);
83
+ if (docs.length === 0)
84
+ return {};
85
+ if (!p.headless) {
86
+ // Defaults to Yes (live check 2026-10-26, same as F-A for plans): Enter
87
+ // must not lose the document.
88
+ const a = await askYesNo({ question: 'Save this change options document?', confirm: p.confirm, defaultYes: true });
89
+ if (!isYes(a))
90
+ return {};
91
+ }
92
+ else {
93
+ p.emit(chalk.dim('headless: saving the change options document without asking'));
94
+ }
95
+ const header = changeOptionsHeader(p);
96
+ const mdPaths = [];
97
+ const docxPaths = [];
98
+ let wordCopy = p.wordCopy;
99
+ for (const doc of docs) {
100
+ let mdPath;
101
+ try {
102
+ const target = path.join(p.cwd, OUT_DIR, documentFilename('change-options', doc.object, p.today));
103
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
104
+ mdPath = await writeNoClobber(target, `${header}${doc.markdown}\n`);
105
+ }
106
+ catch (e) {
107
+ p.emit(`Not saved. ${e.message}`);
108
+ continue;
109
+ }
110
+ p.emit(`Saved: ${path.resolve(mdPath)}`);
111
+ mdPaths.push(mdPath);
112
+ // The Word copy reads plain \n line endings (Task 5 review: CRLF answers).
113
+ const markdown = doc.markdown.replace(/\r\n?/g, '\n');
114
+ const fromText = metaFromMarkdown(markdown, { date: p.today, kind: 'change-options' });
115
+ const meta = {
116
+ title: fromText.title,
117
+ date: p.today,
118
+ ...(p.sapSystem ? { system: p.sapSystem } : {}),
119
+ ...(doc.object ? { object: doc.object } : {}),
120
+ kind: 'change-options',
121
+ ...(p.operator ? { preparedFor: p.operator } : {}),
122
+ };
123
+ let answer;
124
+ const docxPath = await offerWordCopy({
125
+ nextTo: mdPath, markdown, meta, headless: p.headless, wordCopy, emit: p.emit,
126
+ confirm: async (q, o) => { const a = await p.confirm(q, o); answer = isYes(a); return a; },
127
+ });
128
+ // One "Word copy too?" for the turn: its answer decides the next files.
129
+ if (answer !== undefined)
130
+ wordCopy = answer ? 'always' : 'never';
131
+ if (docxPath)
132
+ docxPaths.push(docxPath);
133
+ }
134
+ if (mdPaths.length === 0)
135
+ return {};
136
+ return {
137
+ mdPath: mdPaths[mdPaths.length - 1],
138
+ mdPaths,
139
+ ...(docxPaths.length ? { docxPath: docxPaths[docxPaths.length - 1], docxPaths } : {}),
140
+ };
141
+ }
56
142
  /**
57
143
  * The Word step, shared by every save of a document (Word copy everywhere,
58
144
  * 2026-10-23). `word_copy` decides: unset → ask "Word copy too?", on →
@@ -93,6 +93,10 @@ function parseDependencySequence(section, nameToId) {
93
93
  }
94
94
  return out;
95
95
  }
96
+ /** Task 17 (F-N) — the design's title heading is the sign of the document. */
97
+ export function hasDesignSign(markdown) {
98
+ return matchTitleHeading(markdown, TITLE_SPEC) !== null;
99
+ }
96
100
  export function extractDesign(markdown) {
97
101
  const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
98
102
  if (!titleMatch) {
@@ -60,6 +60,10 @@ function parseComponentsTable(section) {
60
60
  }
61
61
  return rows;
62
62
  }
63
+ /** Task 17 (F-N) — the estimate's title heading is the sign of the document. */
64
+ export function hasEstimateSign(markdown) {
65
+ return matchTitleHeading(markdown, TITLE_SPEC) !== null;
66
+ }
63
67
  export function extractEstimate(markdown) {
64
68
  const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
65
69
  if (!titleMatch) {
@@ -126,6 +126,14 @@ export function looksLikeSpecGap(markdown) {
126
126
  }
127
127
  return false;
128
128
  }
129
+ /**
130
+ * Task 17 (F-N) — any sign of a spec-gap document: its title heading or its
131
+ * body (looksLikeSpecGap). A recap turn carries neither, so the save step
132
+ * stays quiet on it.
133
+ */
134
+ export function hasSpecGapSign(markdown) {
135
+ return matchTitleHeading(markdown, TITLE_SPEC) !== null || looksLikeSpecGap(markdown);
136
+ }
129
137
  function parseGapsFromSection(sectionBody, nextId) {
130
138
  const out = [];
131
139
  const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
@@ -5,7 +5,7 @@ export { validateEnvelope } from './validate.js';
5
5
  export { canonicalSha256 } from './canonicalize.js';
6
6
  export { titleSlug, shortId, projectFilename } from './filename.js';
7
7
  export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
8
- export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
8
+ export { offerAnswerBlockers, openBlockers, applyBlockerAnswers, saveBlockerAnswers } from './answer-blockers.js';
9
9
  export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
10
10
  export { extractDesign } from './extract-design.js';
11
11
  export { extractEstimate } from './extract-estimate.js';
@@ -73,3 +73,27 @@ export function plannedObjectFacts(objs, max = 12) {
73
73
  export function planGrantFactLines() {
74
74
  return ['Writes to these objects need no further card.', 'Anything else stops and asks.'];
75
75
  }
76
+ /**
77
+ * True when the plan lists objects and every one is in a local package
78
+ * (`$TMP`, `$…`): nothing on the list goes into a transport.
79
+ */
80
+ export function localOnlyObjects(objs) {
81
+ return objs.length > 0 && objs.every((o) => typeof o.package === 'string' && o.package.startsWith('$'));
82
+ }
83
+ /** The card's transport line. Local-only objects with no transport say so plainly. */
84
+ export function planTransportFact(transport, objs) {
85
+ if (transport)
86
+ return `Transport: ${transport}`;
87
+ if (localOnlyObjects(objs))
88
+ return 'Transport: none (local objects, not transported)';
89
+ return 'Transport: a new one for this build';
90
+ }
91
+ /**
92
+ * Will the object-list grant cover these writes? It mirrors grantCovers
93
+ * (approvals/plan-grant.ts): with no bound transport no write is covered,
94
+ * and a local-only plan never creates one, so each write asks. Card text
95
+ * only; the grant itself is minted exactly as before.
96
+ */
97
+ export function planGrantCoversWrites(transport, objs) {
98
+ return transport !== undefined || !localOnlyObjects(objs);
99
+ }
@@ -373,7 +373,11 @@ export function findServiceBinding(phases) {
373
373
  export function planCompletionLines(phases,
374
374
  // Terminal callers follow the terminal's glyph set; file writers (the
375
375
  // handover markdown) pass UNICODE_GLYPHS so file content never varies.
376
- g = getGlyphs()) {
376
+ g = getGlyphs(),
377
+ // 1.1.53 item 11: the run's transport (terminal callers pass
378
+ // planBuildTransport). The handover file passes none, so its content
379
+ // depends only on the transport recorded in the plan file.
380
+ transport) {
377
381
  const lines = ['Plan complete — every phase validated.'];
378
382
  // Confidence-tiered redesign (2026-07-08): a completed plan that carried
379
383
  // non-blocking warn verdicts surfaces them in the FINAL summary — a warned
@@ -436,7 +440,13 @@ g = getGlyphs()) {
436
440
  ? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
437
441
  : `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build or record why not.`);
438
442
  }
439
- lines.push('Consider /abap-preflight on the produced transport(s) before release.');
443
+ // A run that produced no transport (all objects local, e.g. $TMP) has
444
+ // nothing to preflight before release, so the hint is left out.
445
+ const producedTransport = !!transport?.trim()
446
+ || phases.some((p) => typeof p.work?.transport === 'string' && p.work.transport.trim() !== '');
447
+ if (producedTransport) {
448
+ lines.push('Consider /abap-preflight on the produced transport(s) before release.');
449
+ }
440
450
  return lines;
441
451
  }
442
452
  /**
@@ -1,8 +1,8 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { extractSpecGap } from './extract-spec-gap.js';
4
- import { extractDesign } from './extract-design.js';
5
- import { extractEstimate } from './extract-estimate.js';
3
+ import { extractSpecGap, hasSpecGapSign } from './extract-spec-gap.js';
4
+ import { extractDesign, hasDesignSign } from './extract-design.js';
5
+ import { extractEstimate, hasEstimateSign } from './extract-estimate.js';
6
6
  import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from './extract-upgrade.js';
7
7
  import { extractCcaAssessment } from './extract-cca.js';
8
8
  import { extractModernizeResult } from './extract-modernize.js';
@@ -29,16 +29,22 @@ const SKILL_REGISTRY = {
29
29
  artefactType: 'spec-gap',
30
30
  extract: (md) => extractSpecGap(md),
31
31
  itemNoun: 'questions',
32
+ hasDocumentSign: hasSpecGapSign,
33
+ documentNoun: 'a spec-gap document',
32
34
  },
33
35
  'abap-design': {
34
36
  artefactType: 'design',
35
37
  extract: (md) => extractDesign(md),
36
38
  itemNoun: 'objects',
39
+ hasDocumentSign: hasDesignSign,
40
+ documentNoun: 'a design document',
37
41
  },
38
42
  'abap-estimate': {
39
43
  artefactType: 'estimate',
40
44
  extract: (md) => extractEstimate(md),
41
45
  itemNoun: 'components',
46
+ hasDocumentSign: hasEstimateSign,
47
+ documentNoun: 'an estimate document',
42
48
  },
43
49
  'abap-upgrade-scan': {
44
50
  artefactType: 'upgrade-scan',
@@ -231,6 +237,18 @@ export function resolvePriorPlanEnvelope(title, dir, opts = {}) {
231
237
  opts.log?.(`[save] More than one plan is titled '${pick.envelope.title}'; revising the newest: ${basename(pick.path)}`);
232
238
  return pick;
233
239
  }
240
+ /**
241
+ * Task 17 (F-N) — the person's message asks us to save the document:
242
+ * "save this/it/that", "save the spec-gap/design/estimate/document",
243
+ * "can you save", "please save". Ordinary wording about saving in the app
244
+ * ("validation on save", "the user clicks Save", "saving changes") is not.
245
+ */
246
+ const ASKED_TO_SAVE_RE = new RegExp([
247
+ String.raw `\b(?:can|could|would|will)\s+you\s+(?:please\s+)?save\b`,
248
+ String.raw `\bplease\s+save\b`,
249
+ String.raw `\bsave\s+(?:this|it|that|them)\b`,
250
+ String.raw `\bsave\s+(?:the|my|our)\s+(?:spec[\s-]*gaps?|design|estimate|document|doc|result|analysis|file|project\s+file)\b`,
251
+ ].join('|'), 'i');
234
252
  export async function runSaveCommand(args) {
235
253
  const spec = SKILL_REGISTRY[args.skillName];
236
254
  if (!spec)
@@ -323,22 +341,31 @@ export async function runSaveCommand(args) {
323
341
  return runSaveCommand({ ...args, skillName: detected });
324
342
  }
325
343
  }
326
- if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
327
- args.log('');
328
- args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
329
- args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
330
- args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
344
+ if (spec.manifestMarker) {
345
+ // Task 17 fix round 1 (F-N): the manifest skills send every turn here.
346
+ // A turn with no manifest block is a conversational turn, not a failed
347
+ // save: say nothing (the B5 contract on manifestMarker above). A block
348
+ // that is present but fails to parse keeps its loud lines.
349
+ if (args.skillOutput.includes(spec.manifestMarker)) {
350
+ args.log('');
351
+ args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
352
+ args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
353
+ args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
354
+ }
331
355
  }
332
- else {
333
- // 2026-09-17 — marker-less skills (design, estimate, spec-gap) used to
334
- // fail here in silence: no file, no line, and the next --from step had
335
- // nothing to start from (seen live: a design titled "# ABAP Design"
336
- // instead of "## ABAP Design"). Say what happened and what to do.
337
- args.log('');
338
- args.log(`[save] ${args.skillName}: could not read the output as a ${spec.artefactType} — nothing was saved.`);
339
- args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
340
- args.log(`[save] Ask CSPeach to "re-issue the ${spec.artefactType} in the standard structure" and it will be saved then.`);
356
+ else if (spec.hasDocumentSign && spec.documentNoun) {
357
+ // Task 17 (F-N, 2026-10-26) — the save step runs on every turn of the
358
+ // session skill. A turn with no sign of the document (a recap after a
359
+ // choice card) is not a failed save: say nothing, unless the person
360
+ // asked to save. A text that does look like the document gets one
361
+ // plain line.
362
+ if (spec.hasDocumentSign(args.skillOutput) || ASKED_TO_SAVE_RE.test(args.skillInput ?? '')) {
363
+ args.log('');
364
+ args.log(`We couldn't save this as ${spec.documentNoun}. Ask us to write it again in the usual layout.`);
365
+ }
341
366
  }
367
+ // (2026-09-17's "[save] … could not read the output" lines are gone: every
368
+ // registry skill now has a manifest marker or a document sign above.)
342
369
  args.onNotSaved?.('unreadable');
343
370
  return null;
344
371
  }
@@ -401,7 +428,8 @@ export async function runSaveCommand(args) {
401
428
  const answer = journey ? 'y' : await askYesNo({
402
429
  question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt,
403
430
  // F-A (live check 2026-10-26) — a plan is only built from its saved file.
404
- ...(spec.artefactType === 'plan' ? { defaultYes: true } : {}),
431
+ // Task 20 (owner 2026-10-26) — the spec-gap document defaults to Yes too.
432
+ ...(spec.artefactType === 'plan' || spec.artefactType === 'spec-gap' ? { defaultYes: true } : {}),
405
433
  });
406
434
  if (!isYes(answer)) {
407
435
  args.onNotSaved?.('declined');
@@ -103,6 +103,20 @@ export function diffBody(card, differ = defaultDiffer) {
103
103
  for (const l of part.value.replace(/\r?\n$/, '').split(/\r?\n/))
104
104
  changed.push(`${part.added ? '+' : '-'} ${clean(l)}`);
105
105
  }
106
+ // 1.1.53 (live F-E): the first write after a create replaces SAP's template,
107
+ // whose removed lines are only comments. Myers puts those first and hides the
108
+ // new code, so in that case the added code leads, then added comment/blank
109
+ // lines, then the removals. Any removal that carries code keeps Myers order.
110
+ const isCommentOrBlank = (l) => {
111
+ const code = l.slice(2);
112
+ return code.trim() === '' || code.startsWith('*') || /^\s*"/.test(code);
113
+ };
114
+ const removed = changed.filter((l) => l.startsWith('-'));
115
+ const added = changed.filter((l) => l.startsWith('+'));
116
+ const addedCode = added.filter((l) => !isCommentOrBlank(l));
117
+ if (removed.length > 0 && removed.every(isCommentOrBlank) && addedCode.length > 0) {
118
+ changed.splice(0, changed.length, ...addedCode, ...added.filter(isCommentOrBlank), ...removed);
119
+ }
106
120
  return { lines: changed.slice(0, DIFF_LINES), hidden: Math.max(0, changed.length - DIFF_LINES) };
107
121
  }
108
122
  export function buildChangeCard(input, width, box = getBoxChars()) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 1.1.53 item 5 — the model sometimes writes `&lt;` / `&gt;` / `&amp;` in the
3
+ * text and diffs it puts on an approval card. Cards show the real characters.
4
+ * One pass only: `&amp;lt;` becomes `&lt;`, never `<`.
5
+ *
6
+ * Display only. Never apply this to anything written to SAP or saved.
7
+ */
8
+ const ENTITY = /&(lt|gt|amp);/g;
9
+ const CHAR = { lt: '<', gt: '>', amp: '&' };
10
+ export function decodeDisplayEntities(s) {
11
+ return s.replace(ENTITY, (_m, name) => CHAR[name]);
12
+ }
13
+ /** A raw field symbol or raw markup: `<` followed by a letter, `_`, `/` or `!`. */
14
+ const RAW_TAG = /<[A-Za-z_/!]/;
15
+ /**
16
+ * The same decode for a whole source or diff block, except when the block
17
+ * holds a raw field symbol or raw markup (`<fs>`, `<b>`, `</p>`, `<!--`).
18
+ * A model that escapes field symbols escapes them throughout; real source
19
+ * that holds `&lt;` in a literal almost always has a raw `<fs>` or markup
20
+ * too, so it stays as written. Operators and comparisons (`->`, `=>`, `>=`,
21
+ * `<=`, `<>`, ` < `) do not count. Display only, like decodeDisplayEntities.
22
+ */
23
+ export function decodeDisplayBlock(s) {
24
+ return RAW_TAG.test(s) ? s : decodeDisplayEntities(s);
25
+ }
@@ -16,6 +16,12 @@
16
16
  // at the START of a line. It ends at a `-->` on the opener's own line, or
17
17
  // else at a `-->` that starts a later line (one newline straight after it is
18
18
  // dropped too). An inline mention mid-line is prose and stays.
19
+ // 1.1.53: a line that starts with a closing marker (`<!-- /cspeach:…` or
20
+ // `<!-- /csforge:…`) is a block like an opener. A one-line change options
21
+ // marker (`<!-- csforge:change-options OBJECT=X -->`, `<!-- /csforge:change-options -->`)
22
+ // is removed wherever it sits on a line; the text around it stays. Other
23
+ // mid-line mentions stay prose, and so does a mid-line marker whose `-->` is
24
+ // not on the same line.
19
25
  // A chunk boundary can fall anywhere: a line that could still become an
20
26
  // opener is held until it can be decided; inside a block a tail that could
21
27
  // still become the closing `-->` is held the same way.
@@ -24,8 +30,10 @@ export function initialMachineBlockState() {
24
30
  }
25
31
  // A leading `\n` marks a line start (the probe prepends one when the text
26
32
  // begins at a line start) — no `^`, which would match a mid-line chunk start.
27
- const OPENER_RE = /\n[ \t]*<!--[ \t]*(?:cspeach|csforge):/;
28
- const PREFIXES = ['cspeach:', 'csforge:'];
33
+ const OPENER_RE = /\n[ \t]*<!--[ \t]*\/?(?:cspeach|csforge):/;
34
+ const PREFIXES = ['cspeach:', 'csforge:', '/cspeach:', '/csforge:'];
35
+ const INLINE_MARKER_RE = /<!--[ \t]*\/?(?:cspeach|csforge):change-options\b[^\n]*?-->/g;
36
+ const INLINE_PREFIXES = ['cspeach:change-options', 'csforge:change-options'];
29
37
  const CLOSER_RE = /\n[ \t]*-->/;
30
38
  const PARTIAL_CLOSER_RE = /^\n[ \t]*-{0,2}$/;
31
39
  /** Could `frag` (one incomplete line) still grow into a block opener? */
@@ -38,6 +46,27 @@ function couldBeOpener(frag) {
38
46
  const r = s.slice(4).replace(/^[ \t]*/, '');
39
47
  return PREFIXES.some((p) => p.startsWith(r));
40
48
  }
49
+ /**
50
+ * Could `frag` (text from a mid-line `<`, no newline yet) still grow into a
51
+ * one-line change options marker? A full prefix stays held until its `-->`
52
+ * or the end of the line decides it.
53
+ */
54
+ function couldBeInlineMarker(frag) {
55
+ if (frag.includes('-->') || frag.includes('\n'))
56
+ return false;
57
+ if (frag.length <= 4)
58
+ return '<!--'.startsWith(frag);
59
+ if (!frag.startsWith('<!--'))
60
+ return false;
61
+ let r = frag.slice(4).replace(/^[ \t]*/, '');
62
+ if (r.startsWith('/'))
63
+ r = r.slice(1);
64
+ return INLINE_PREFIXES.some((p) => p.startsWith(r) || r.startsWith(p));
65
+ }
66
+ /** Remove every complete one-line change options marker. */
67
+ function stripInlineMarkers(text) {
68
+ return text.replace(INLINE_MARKER_RE, '');
69
+ }
41
70
  export function filterMachineBlocks(chunk, prev) {
42
71
  const st = { ...prev };
43
72
  let work = st.held + chunk;
@@ -103,7 +132,7 @@ export function filterMachineBlocks(chunk, prev) {
103
132
  if (m) {
104
133
  const offset = st.lineStart ? 1 : 0;
105
134
  const lineBegin = m.index + 1 - offset; // index in `work`, just after the newline
106
- output += work.slice(0, lineBegin);
135
+ output += stripInlineMarkers(work.slice(0, lineBegin));
107
136
  work = work.slice(m.index + m[0].length - offset);
108
137
  st.inBlock = true;
109
138
  st.firstLine = true;
@@ -113,14 +142,32 @@ export function filterMachineBlocks(chunk, prev) {
113
142
  const lastNl = work.lastIndexOf('\n');
114
143
  const fragStart = lastNl >= 0 ? lastNl + 1 : st.lineStart ? 0 : -1;
115
144
  if (fragStart >= 0 && fragStart < work.length && couldBeOpener(work.slice(fragStart))) {
116
- output += work.slice(0, fragStart);
145
+ output += stripInlineMarkers(work.slice(0, fragStart));
117
146
  st.held = work.slice(fragStart);
118
147
  st.lineStart = true;
119
148
  }
120
149
  else {
121
- output += work;
122
- if (work.length > 0)
123
- st.lineStart = work.endsWith('\n');
150
+ // Remove complete one-line markers; hold a mid-line tail that could
151
+ // still become one until its `-->` or the newline decides it.
152
+ const shown = stripInlineMarkers(work);
153
+ const tailStart = shown.lastIndexOf('\n') + 1;
154
+ let holdAt = -1;
155
+ for (let i = shown.indexOf('<', tailStart); i >= 0; i = shown.indexOf('<', i + 1)) {
156
+ if (couldBeInlineMarker(shown.slice(i))) {
157
+ holdAt = i;
158
+ break;
159
+ }
160
+ }
161
+ if (holdAt >= 0) {
162
+ output += shown.slice(0, holdAt);
163
+ st.held = shown.slice(holdAt);
164
+ st.lineStart = holdAt === 0 ? st.lineStart : shown[holdAt - 1] === '\n';
165
+ }
166
+ else {
167
+ output += shown;
168
+ if (shown.length > 0)
169
+ st.lineStart = shown.endsWith('\n');
170
+ }
124
171
  }
125
172
  break;
126
173
  }
@@ -168,6 +168,7 @@ const RULES = {
168
168
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
169
169
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
170
170
  team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
171
+ spec_answers_record: (a) => ({ verb: 'Recorded answers in', object: shortLeft(str(a['path']) ?? '') }),
171
172
  team_lane_move: (a) => ({ verb: 'Move packages to another person', object: shortRight(Array.isArray(a['packages']) ? a['packages'].map(String).join(', ') : 'packages') }),
172
173
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
173
174
  sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
@@ -289,6 +290,7 @@ const PRESENT = {
289
290
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
290
291
  'Checked capability': 'Checking capability',
291
292
  'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Check a proposed decision rule': 'Checking a proposed decision rule', 'Read estate inventory': 'Reading estate inventory', 'Move packages to another person': 'Moving packages to another person', 'Wrote texts': 'Writing texts',
293
+ 'Recorded answers in': 'Recording answers in',
292
294
  };
293
295
  export function toolActivityLabel(name, args) {
294
296
  const l = toolLabel(name, args);
@@ -6,6 +6,16 @@ import { toolLabel, toolActivityLabel, foldOutput, clean } from './tool-labels.j
6
6
  import { getEffectiveBodyWidth } from './tty.js';
7
7
  import { flowFor, stdoutFlow, writeStdout, canAnimateInPlace } from './transcript-flow.js';
8
8
  import { humanErrorDetail } from './error-detail.js';
9
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
10
+ /**
11
+ * 1.1.53 item 10 — a call stopped before it started (Esc / Ctrl+C) changed
12
+ * nothing: the row is a neutral dim stop, not a red failure. The model still
13
+ * receives STOPPED_BEFORE_START_RESULT unchanged; this is display only.
14
+ */
15
+ const STOPPED_BEFORE_START_LINE = 'Stopped before it started. Nothing changed in SAP.';
16
+ function stoppedBeforeStart(isError, content) {
17
+ return isError && content === STOPPED_BEFORE_START_RESULT;
18
+ }
9
19
  export { canAnimateInPlace };
10
20
  /**
11
21
  * Tool-dispatch renderer — CC-style two-line format with peach spinner.
@@ -316,7 +326,8 @@ export function renderToolCallBottom(params) {
316
326
  write(panelsRow({ name: name ?? '', args: args ?? {}, isError, resultSummary, resultContent, durationMs }));
317
327
  return;
318
328
  }
319
- const icon = isError ? chalk.red('✗') : chalk.green('✓');
329
+ const stopped = stoppedBeforeStart(isError, resultContent);
330
+ const icon = stopped ? chalk.dim('·') : isError ? chalk.red('✗') : chalk.green('✓');
320
331
  // Label: plain (not bold — the top line keeps visual primacy) name +
321
332
  // dim arg summary, mirroring the top-line shape so orphaned rows still
322
333
  // read as "which tool, on what object".
@@ -326,9 +337,9 @@ export function renderToolCallBottom(params) {
326
337
  const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
327
338
  label = ` ${name}${wrapped}`;
328
339
  }
329
- const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
340
+ const detailRaw = stopped ? STOPPED_BEFORE_START_LINE : resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
330
341
  const detail = detailRaw
331
- ? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
342
+ ? (stopped ? chalk.dim(detailRaw) : isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
332
343
  : '';
333
344
  const timing = chalk.dim(formatDuration(durationMs));
334
345
  const sep = chalk.dim('·');
@@ -424,7 +435,8 @@ function panelsRow(p) {
424
435
  // Round 8 item 2 — a cancelled / rejected approval is not a success: no
425
436
  // green tick, and the row says what happened.
426
437
  const declined = !p.isError && p.name === 'request_approval' ? approvalDeclined(p.resultContent) : undefined;
427
- const icon = p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
438
+ const stopped = stoppedBeforeStart(p.isError, p.resultContent);
439
+ const icon = stopped ? paint('dim', '·') : p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
428
440
  const timing = p.durationMs >= 1000 ? formatDuration(p.durationMs) : '';
429
441
  // 1.1.31: a setting said yes, not a person — the row says so.
430
442
  const auto = !p.isError && !declined && p.name === 'request_approval' && approvalAutoApproved(p.resultContent);
@@ -440,7 +452,10 @@ function panelsRow(p) {
440
452
  // Live fix 2026-09-26: a JSON tool result (e.g. sap_activate's
441
453
  // activation_results) is turned into readable lines, never shown raw.
442
454
  const errorSource = p.resultContent?.trim().startsWith('{') ? p.resultContent : p.resultSummary;
443
- if (p.isError && errorSource) {
455
+ if (stopped) {
456
+ out.push(' ' + paint('dim', truncate(STOPPED_BEFORE_START_LINE, width - 4)));
457
+ }
458
+ else if (p.isError && errorSource) {
444
459
  // Raw error text can carry ANSI codes, \r redraws and tabs: clean() each
445
460
  // line (the one sanitiser, shared with the labels) before truncate + paint.
446
461
  for (const d of humanErrorDetail(errorSource).map(clean).filter(Boolean).slice(0, ERROR_DETAIL_LINES)) {
@@ -85,9 +85,18 @@ const THANKS_PHRASES = new Set(['thanks', 'thank you', 'thank u', 'thx', 'tx', '
85
85
  function isThanksOnly(trimmed) {
86
86
  return THANKS_PHRASES.has(stripOkPrefix(trimmed).toLowerCase().replace(/[.!?,]+$/, ''));
87
87
  }
88
+ /** Change options (1.1.53): the reply names a level (1-4) AND the document's
89
+ * object (case-insensitive). */
90
+ export function isLevelDecisionFor(trimmed, last) {
91
+ return /\blevel\s*[1-4]\b/i.test(trimmed) && trimmed.toUpperCase().includes(last.object.toUpperCase());
92
+ }
88
93
  /** Today's continuation predicate (repl.tsx Ink + classic paths), as one
89
- * function: the skill that keeps the turn, or null. */
94
+ * function: the skill that keeps the turn, or null. A level decision for the
95
+ * last change options object goes back to the skill that showed it (only the
96
+ * skills-lane call sites pass lastChangeOptions). */
90
97
  export function wantsContinuation(i) {
98
+ if (i.lastChangeOptions && isLevelDecisionFor(i.trimmed, i.lastChangeOptions))
99
+ return i.lastChangeOptions.skill;
91
100
  if (!i.sessionSkill)
92
101
  return null;
93
102
  return (i.awaitingSkillAnswer || (i.resumedSkill !== null && isShortFollowUp(i.trimmed)) || isAckContinuation(i.trimmed, i.sessionSkill)) ? i.sessionSkill : null;
package/dist/repl.js CHANGED
@@ -205,6 +205,7 @@ import './tools/project/team_findings.js';
205
205
  import './tools/project/team_estate.js';
206
206
  import './tools/project/rules_propose.js';
207
207
  import './tools/project/team_lane_move.js';
208
+ import './tools/project/spec_answers_record.js';
208
209
  import './tools/subagent/_background-shared.js';
209
210
  import './tools/subagent/background_run.js';
210
211
  import './tools/subagent/monitor_emit.js';
@@ -1409,6 +1410,7 @@ export async function runRepl(opts) {
1409
1410
  userMessage: rr.prompt,
1410
1411
  skill: rr.skill,
1411
1412
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
1413
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
1412
1414
  chunkEmitter,
1413
1415
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1414
1416
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
@@ -1638,7 +1640,7 @@ export async function runRepl(opts) {
1638
1640
  // Round 6 item 4c / review I2 — after a resume, a short follow-up
1639
1641
  // ("continue", a number) stays in the session's skill; anything else
1640
1642
  // is classified (applyResumedSkill below).
1641
- const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
1643
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
1642
1644
  if (continuedSkill) {
1643
1645
  chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: continuedSkill }));
1644
1646
  skill = continuedSkill;
@@ -2116,6 +2118,7 @@ export async function runRepl(opts) {
2116
2118
  skill: skill,
2117
2119
  // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
2118
2120
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
2121
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
2119
2122
  chunkEmitter,
2120
2123
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2121
2124
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
@@ -2347,6 +2350,8 @@ export async function runRepl(opts) {
2347
2350
  tokensUsed: 0,
2348
2351
  model: session.model,
2349
2352
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2353
+ ...(signedInAs ? { operator: signedInAs } : {}),
2354
+ sessionId: session.id,
2350
2355
  });
2351
2356
  }
2352
2357
  else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
@@ -3526,6 +3531,7 @@ export async function runRepl(opts) {
3526
3531
  userMessage: rr.prompt,
3527
3532
  skill: rr.skill,
3528
3533
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
3534
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
3529
3535
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
3530
3536
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
3531
3537
  }),
@@ -3833,7 +3839,7 @@ export async function runRepl(opts) {
3833
3839
  continue;
3834
3840
  }
3835
3841
  // Round 6 item 4c / review I2 — see the Ink path.
3836
- const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
3842
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
3837
3843
  if (continuedSkill) {
3838
3844
  console.log(formatRoutingLine({ kind: 'continuing', skill: continuedSkill }).trimEnd());
3839
3845
  skill = continuedSkill;
@@ -4269,6 +4275,7 @@ export async function runRepl(opts) {
4269
4275
  skill: skill,
4270
4276
  // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
4271
4277
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
4278
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
4272
4279
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
4273
4280
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
4274
4281
  signal: turnAbort.signal,
@@ -4462,6 +4469,8 @@ export async function runRepl(opts) {
4462
4469
  tokensUsed: 0,
4463
4470
  model: session.model,
4464
4471
  emit: (line) => console.log(line),
4472
+ ...(signedInAs ? { operator: signedInAs } : {}),
4473
+ sessionId: session.id,
4465
4474
  });
4466
4475
  }
4467
4476
  else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {