@cspeach/cli 1.1.51 → 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 (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -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
  }
@@ -398,7 +425,12 @@ export async function runSaveCommand(args) {
398
425
  // maybeOfferSave never does headless — so the blanket 'y' never reaches it.)
399
426
  // Inside a journey a triage is saved without the question: the journey needs the rows.
400
427
  const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
401
- const answer = journey ? 'y' : await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
428
+ const answer = journey ? 'y' : await askYesNo({
429
+ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt,
430
+ // F-A (live check 2026-10-26) — a plan is only built from its saved file.
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 } : {}),
433
+ });
402
434
  if (!isYes(answer)) {
403
435
  args.onNotSaved?.('declined');
404
436
  return null;
@@ -1,8 +1,12 @@
1
1
  export async function askYesNo(a) {
2
2
  if (a.confirm)
3
- return a.confirm(a.question, { default: 'n' });
3
+ return a.confirm(a.question, { default: a.defaultYes ? 'y' : 'n' });
4
4
  if (!a.prompt)
5
5
  return '';
6
+ if (a.defaultYes) {
7
+ const reply = (await a.prompt(`${a.question} [Y/n]: `)).trim().toLowerCase();
8
+ return reply === '' ? 'y' : reply;
9
+ }
6
10
  return (await a.prompt(`${a.question} [y/N]: `)).trim().toLowerCase();
7
11
  }
8
12
  /** Today's decision rule at all six sites. */
@@ -14,7 +14,9 @@ const DIFF_LINES = 6;
14
14
  /** Myers diff is O(N·D) and synchronous: past this budget we show no diff
15
15
  * at all (object + footer only), never a partial one. */
16
16
  const DIFF_TIMEOUT_MS = 200;
17
- const defaultDiffer = (a, b) => diffLines(a, b, { timeout: DIFF_TIMEOUT_MS });
17
+ // LT-7: a last line that only gains a newline (because lines follow it) is
18
+ // the same line, never a -/+ pair.
19
+ const defaultDiffer = (a, b) => diffLines(a, b, { timeout: DIFF_TIMEOUT_MS, ignoreNewlineAtEof: true });
18
20
  // eslint-disable-next-line no-control-regex
19
21
  const ANSI = /\x1b\[[0-9;]*m/g;
20
22
  const visibleLen = (s) => s.replace(ANSI, '').length;
@@ -101,6 +103,20 @@ export function diffBody(card, differ = defaultDiffer) {
101
103
  for (const l of part.value.replace(/\r?\n$/, '').split(/\r?\n/))
102
104
  changed.push(`${part.added ? '+' : '-'} ${clean(l)}`);
103
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
+ }
104
120
  return { lines: changed.slice(0, DIFF_LINES), hidden: Math.max(0, changed.length - DIFF_LINES) };
105
121
  }
106
122
  export function buildChangeCard(input, width, box = getBoxChars()) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 1.1.53 item 5 — the model sometimes writes `<` / `>` / `&` 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
  }
@@ -1,8 +1,8 @@
1
1
  // src/renderer/steering-echo.ts
2
2
  //
3
- // The receipt a mid-turn message leaves in scrollback (spec §7). ONE themed
4
- // helper for both echo paths: App.beginSubmit (ui/app.tsx) and the fallback
5
- // in repl.tsx handleSubmit for callers that bypass beginSubmit.
3
+ // How a mid-turn message is shown (spec §7). LT-15 Feature 1 (D5): typing it
4
+ // leaves no scrollback receipt any more; the App's live queue row shows it
5
+ // while it waits, and the loop prints "↳ read · …" when the model reads it.
6
6
  import { paint } from './theme.js';
7
7
  import { sanitizeControlChars } from './sanitize.js';
8
8
  /** Wave1 fast-follow — only the first line, clipped, with a line count. */
@@ -20,12 +20,57 @@ export function formatSteeringEcho(text) {
20
20
  return out;
21
21
  }
22
22
  /**
23
- * `↳ queued · <first line…>` in the dim role, newline-terminated. The text may
24
- * come from a paste, so control characters are neutralised before it reaches
25
- * scrollback.
23
+ * One waiting or read message as shown: first line, clipped, no control
24
+ * characters, and each tab as one space (a tab is 0 wide to string-width, so
25
+ * Ink's truncate-end would cut too late and the line could wrap).
26
26
  */
27
- export function steeringEchoLine(text) {
28
- return paint('dim', `↳ queued · ${sanitizeControlChars(formatSteeringEcho(text))}`) + '\n';
27
+ function shownSteer(text) {
28
+ return sanitizeControlChars(formatSteeringEcho(text)).replace(/\t/g, ' ');
29
+ }
30
+ /**
31
+ * LT-15 Feature 1 (D5, F7) — the receipt the loop prints when the model reads
32
+ * a waiting message: `↳ read · <first line…>` in the dim role, one line per
33
+ * message (the caller adds the newline). The text may come from a paste, so
34
+ * it is clipped and control characters are neutralised.
35
+ */
36
+ export function steeringReadLine(text) {
37
+ return paint('dim', `↳ read · ${shownSteer(text)}`);
38
+ }
39
+ /** The queue row shows at most this many messages; the rest are counted. */
40
+ export const QUEUE_ROW_MAX_ITEMS = 3;
41
+ /**
42
+ * LT-15 Feature 1 — the live row's lines (plain text, one terminal row each;
43
+ * App paints them dim and truncates to the width). Empty queue → no lines.
44
+ *
45
+ * 4 waiting · use ZPKG
46
+ * · also check the tests… (+1 line)
47
+ * · and the docs
48
+ * + 1 more
49
+ *
50
+ * `maxLines` is the room left in the live area. When the full row does not
51
+ * fit it folds to ONE line, never fewer: `4 waiting · use ZPKG + 3 more`.
52
+ *
53
+ * LT-15 Feature 2 (review I6) — `canEdit` adds `· ↑ to edit` right after the
54
+ * count, only while ↑ would really take the last message back (the App
55
+ * decides). It sits before the messages so a long message never cuts it off.
56
+ * Without it the bytes are exactly the Task 2 ones.
57
+ */
58
+ export function formatQueueRow(items, maxLines = Infinity, opts = {}) {
59
+ if (items.length === 0)
60
+ return [];
61
+ const count = `${items.length} waiting${opts.canEdit ? ' · ↑ to edit' : ''}`;
62
+ const full = Math.min(items.length, QUEUE_ROW_MAX_ITEMS) + (items.length > QUEUE_ROW_MAX_ITEMS ? 1 : 0);
63
+ if (full > Math.max(1, maxLines)) {
64
+ return [`${count} · ${shownSteer(items[0])} + ${items.length - 1} more`];
65
+ }
66
+ const head = `${count} `;
67
+ const pad = ' '.repeat(head.length);
68
+ const shown = items.slice(0, QUEUE_ROW_MAX_ITEMS);
69
+ const lines = shown.map((t, i) => `${i === 0 ? head : pad}· ${shownSteer(t)}`);
70
+ const rest = items.length - shown.length;
71
+ if (rest > 0)
72
+ lines.push(`${pad}+ ${rest} more`);
73
+ return lines;
29
74
  }
30
75
  export function classifyMidTurnLine(text) {
31
76
  const first = text.trim().split(/\s+/)[0] ?? '';
@@ -1,4 +1,5 @@
1
1
  import { spillLabelFor } from '../lib/spill-labels.js';
2
+ import { writtenObjectName } from '../approvals/op-labels.js';
2
3
  const OBJECT_MAX = 60;
3
4
  /**
4
5
  * Every value shown in a label goes through here: ANSI escape sequences and
@@ -108,7 +109,8 @@ const RULES = {
108
109
  },
109
110
  background_run: (a) => ({ verb: 'Started', object: shortRight(commandLine(a)) }),
110
111
  sap_get_source: sapObject('Read'),
111
- sap_set_source: sapObject('Wrote'),
112
+ // LT-11 — an access control, metadata extension or behavior definition names its kind.
113
+ sap_set_source: (a) => ({ verb: 'Wrote', object: shortRight(writtenObjectName(str(a['type']), str(a['name']) ?? '')) }),
112
114
  sap_activate: (a) => ({ verb: 'Activated', object: shortRight(activateObject(a)) }),
113
115
  sap_syntax_check: sapObject('Checked syntax'),
114
116
  sap_atc_run: sapObject('Ran ATC on'),
@@ -166,6 +168,7 @@ const RULES = {
166
168
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
167
169
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
168
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']) ?? '') }),
169
172
  team_lane_move: (a) => ({ verb: 'Move packages to another person', object: shortRight(Array.isArray(a['packages']) ? a['packages'].map(String).join(', ') : 'packages') }),
170
173
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
171
174
  sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
@@ -287,6 +290,7 @@ const PRESENT = {
287
290
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
288
291
  'Checked capability': 'Checking capability',
289
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',
290
294
  };
291
295
  export function toolActivityLabel(name, args) {
292
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;
@@ -113,8 +122,11 @@ export function turnStatusLabel(skill) {
113
122
  }
114
123
  /** D11 — the one dim line shown in place of the echo when the message re-runs through today's router. */
115
124
  export const CHAT_UNAVAILABLE_LINE = "Chat isn't available on this server yet — using skill routing.";
116
- export function chatFallbackOrigin() {
117
- return { by: 'chat_fallback', line: CHAT_UNAVAILABLE_LINE };
125
+ export function chatFallbackOrigin(prose = false) {
126
+ // LT-15 Task 4 fix round 1 — a withdrawn prose replay re-runs as prose.
127
+ return prose
128
+ ? { by: 'chat_fallback', line: CHAT_UNAVAILABLE_LINE, prose: true }
129
+ : { by: 'chat_fallback', line: CHAT_UNAVAILABLE_LINE };
118
130
  }
119
131
  /**
120
132
  * The one place a process lane's tool side effect is applied (D5, §6.1).
@@ -0,0 +1,51 @@
1
+ // cspeach-cli/src/repl/inline-mode.ts
2
+ //
3
+ // Stage 1A, Task 1A.1 — when `assistant.skills = inline` is active.
4
+ //
5
+ // Decided at EVERY submit from the inputs as they are then (rev 4 M4), never
6
+ // cached, never stored in the session file: a resumed session re-decides from
7
+ // today's config and today's proxy. The history is NOT an input (rev 5 S-B):
8
+ // `assistant.skills = system`, `/chat off`, a non-TTY and a non-serving proxy
9
+ // always win. The REPL passes the result to the loop as `ctx.inlineSkills`.
10
+ // Piped stdin and one-shot never get it; reader and agent_run children never
11
+ // carry it (their ctx is built field by field).
12
+ import { servedInlineSkills } from '../models/server-config.js';
13
+ /** True only for `skills = inline` in the chat lane, on a TTY, where the provider can serve it. */
14
+ export function inlineActive({ skills, lane, interactive, served }) {
15
+ return interactive && served && skills === 'inline' && lane === 'chat';
16
+ }
17
+ /**
18
+ * Can this provider serve the inline system block? Managed: only when the
19
+ * proxy's served config says `inline_skills: true` (an old proxy, the kill
20
+ * switch or a stale cache ⇒ false). byok / ai-hub / azure / local build the
21
+ * system block on the laptop ⇒ true. Read at submit time: the served config
22
+ * loads after the config is read at startup.
23
+ */
24
+ export function inlineServed(mode) {
25
+ if (mode === undefined)
26
+ return false;
27
+ return mode === 'managed' ? servedInlineSkills() : true;
28
+ }
29
+ /**
30
+ * The `config show` comment for a stored `inline` that would not be active in
31
+ * an interactive session; null when there is nothing to say.
32
+ */
33
+ export function inlineInactiveReason(i) {
34
+ if (i.skills !== 'inline')
35
+ return null;
36
+ if (i.lane !== 'chat')
37
+ return 'not active: skill routing is on';
38
+ if (!i.served)
39
+ return 'not active: this server does not serve it';
40
+ return null;
41
+ }
42
+ /**
43
+ * Stage 1A, Task 1A.2 (S5) — an interactive chat-lane process: lane `chat` and
44
+ * a TTY, the condition applyLaneTools uses, whatever `assistant.skills` and the
45
+ * proxy say. In such a process the loop neutralises `<skill`, `<playbook` and
46
+ * `<session_context` in what the person sends (ctx.chatLaneProcess). Decided
47
+ * at every turn site like inlineActive.
48
+ */
49
+ export function isChatLaneProcess(lane, interactive) {
50
+ return lane === 'chat' && interactive;
51
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * LT-15 Feature 4 — messages typed while CSPeach worked that are still
3
+ * waiting when a dispatch ends (Ink REPL only; classic, headless and one-shot
4
+ * never queue anything).
5
+ *
6
+ * The runner holds the per-dispatch flags (plan review I4): a Ctrl+C press,
7
+ * a turn error, a scheduled hop. They are set during the dispatch, read ONCE
8
+ * by `settle` at that dispatch's end (handleSubmit's finally, after release,
9
+ * or a restore's end), and reset right there, so a stale flag can never touch
10
+ * the next hop or turn.
11
+ *
12
+ * `settle` acts on leftoverDecision:
13
+ * - run: on the next tick, drain the queue THERE (a message typed
14
+ * between the end and the tick rides along) and submit it once
15
+ * as the person's prose. Nothing when a dispatch is already busy
16
+ * (that turn reads the queue at its round 0, or its own end
17
+ * decides) or the session is ending (I4.1, I4.3).
18
+ * - to-input: ask the App to put the text back into the input box (D1, I5).
19
+ * - wait / report-unsent / none: touch nothing. A queued hop reads the
20
+ * message at its round 0 (D3); the exit reports what is left.
21
+ * Every message ends in exactly one place (I1.1).
22
+ */
23
+ import { drainSteering, drainSteeringText, steeringSnapshot } from '../agent/steering-queue.js';
24
+ import { leftoverDecision, unsentLine } from '../ui/interrupt.js';
25
+ import { formatInfo } from '../renderer/notice-shape.js';
26
+ import { parseShortcut } from '../router/classifier.js';
27
+ export function createLeftoverRunner(deps) {
28
+ let ctrlCStop = false;
29
+ let errorEnd = false;
30
+ let dispatchScheduled = false;
31
+ const reset = () => {
32
+ ctrlCStop = false;
33
+ errorEnd = false;
34
+ dispatchScheduled = false;
35
+ };
36
+ return {
37
+ noteCtrlC() { ctrlCStop = true; },
38
+ noteError() { errorEnd = true; },
39
+ noteDispatchScheduled() { dispatchScheduled = true; },
40
+ reset,
41
+ settle(s) {
42
+ const decision = leftoverDecision({
43
+ done: deps.isDone(),
44
+ exitRequested: s.exitRequested,
45
+ forceExit: s.forceExit === true,
46
+ ctrlCStop,
47
+ braked: s.braked === true,
48
+ errorEnd,
49
+ dispatchScheduled,
50
+ restoreActive: s.restoreActive === true,
51
+ waiting: steeringSnapshot().length,
52
+ });
53
+ reset();
54
+ if (decision === 'run') {
55
+ deps.schedule(() => {
56
+ if (deps.isDone() || deps.isBusy())
57
+ return;
58
+ const text = drainSteeringText();
59
+ if (text !== null)
60
+ deps.submit(text);
61
+ });
62
+ }
63
+ else if (decision === 'to-input') {
64
+ deps.toBox();
65
+ }
66
+ return decision;
67
+ },
68
+ toBoxOrReport() {
69
+ if (steeringSnapshot().length === 0)
70
+ return;
71
+ deps.toBox();
72
+ const left = drainSteering();
73
+ if (left.length > 0)
74
+ deps.print(formatInfo(unsentLine(left.length)));
75
+ },
76
+ };
77
+ }
78
+ /** At exit: drain what still waits and return the one line to print, or null. */
79
+ export function takeUnsentReport() {
80
+ const left = drainSteering();
81
+ return left.length > 0 ? formatInfo(unsentLine(left.length)) : null;
82
+ }
83
+ /**
84
+ * LT-15 F4 — how a submitted line is read. A replayed waiting message is
85
+ * prose: never a slash command or an @-shortcut, so "/sap/bc/adt/x is slow"
86
+ * reaches the model as text. Anything typed at the prompt is read as today.
87
+ */
88
+ export function parseSubmitted(input, prose) {
89
+ return prose ? { prompt: input } : parseShortcut(input);
90
+ }