@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
@@ -103,6 +103,14 @@ export function getPlanGrant() { return active; }
103
103
  export function isPlanGrantInScope() {
104
104
  return active !== null && approvalsMode === 'plan' && inScope(active);
105
105
  }
106
+ /**
107
+ * 2026-10-23 — the grant of THIS plan is in force: installed, not expired, and
108
+ * approvals = plan. The same test as isPlanGrantInScope without "a step of the
109
+ * plan is running": the step prompt is built just before its step starts.
110
+ */
111
+ export function isPlanGrantInForceFor(planId) {
112
+ return active !== null && active.planId === planId && approvalsMode === 'plan' && isLive(active);
113
+ }
106
114
  /**
107
115
  * The claims a grant-minted id carries for one covered change: the listed
108
116
  * type, the listed row's package, and the bound transport.
@@ -1,6 +1,6 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
- import { riskReasonText, shownRiskReasons } from './risk-floor.js';
3
+ import { riskReasonTexts, shownRiskReasons } from './risk-floor.js';
4
4
  import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourText, behaviourLines } from './op-labels.js';
5
5
  import { uiWidth } from '../renderer/ui-width.js';
6
6
  import { shouldUseInk } from '../renderer/tty.js';
@@ -12,8 +12,9 @@ import { emitTrace } from '../renderer/trace.js';
12
12
  import { getGlyphs } from '../renderer/glyphs.js';
13
13
  import { paint } from '../renderer/theme.js';
14
14
  import { sanitizeControlChars } from '../renderer/sanitize.js';
15
- import { collapseImplicitActivates, looksLikeUnifiedDiff } from './visible-changes.js';
15
+ import { planRows, looksLikeUnifiedDiff } from './visible-changes.js';
16
16
  import { withSpinnerSuspended } from '../renderer/tool-widget.js';
17
+ import { decodeDisplayEntities, decodeDisplayBlock } from '../renderer/html-entities.js';
17
18
  // Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
18
19
  const peach = (s) => paint('brand', s);
19
20
  /**
@@ -75,11 +76,21 @@ function colorizeDiff(diff) {
75
76
  * compressed. This is what fixes the "what 2 changes?" complaint when the
76
77
  * model declares modify+activate of the same object.
77
78
  */
78
- // collapseImplicitActivates lives in ./visible-changes.ts (Task 9: request_approval uses it too).
79
+ // collapseImplicitActivates and planRows (LT-12) live in ./visible-changes.ts (Task 9: request_approval uses the first for its gate count).
80
+ /**
81
+ * LT-12 fix round 1 — the apply choice. One shown row (a create + modify of
82
+ * one object, the gate still opens on the raw count) reads "Apply this
83
+ * change", never "Apply all 1 change".
84
+ */
85
+ function applyAllLabel(visibleCount, noun) {
86
+ return visibleCount === 1 ? 'Apply this change' : `Apply all ${visibleCount} ${noun}`;
87
+ }
79
88
  export async function renderPlanGate(summary, changes, transport, effectiveRisk, system) {
80
- const visibleChanges = collapseImplicitActivates(changes);
89
+ // LT-12: one row per new object (display only; the changes behind the card are untouched).
90
+ const visibleChanges = planRows(changes);
81
91
  const visibleCount = visibleChanges.length;
82
92
  const noun = visibleCount === 1 ? 'change' : 'changes';
93
+ const applyLabel = applyAllLabel(visibleCount, noun);
83
94
  // Ink path (piece 2 §5): the plan gate is an APPROVAL confirm card in the
84
95
  // card slot. Same three values, same order, cursor on Apply all; anything
85
96
  // else (Esc, no listener) is 'cancel'. Digits pick on an APPROVAL card
@@ -102,7 +113,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
102
113
  ];
103
114
  const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
104
115
  if (shownReasons.length > 0) {
105
- facts.push(`Risk raised to ${effectiveRisk.level}: ${shownReasons.map(riskReasonText).join('; ')}`);
116
+ facts.push(`Risk raised to ${effectiveRisk.level}: ${riskReasonTexts(effectiveRisk).join('; ')}`);
106
117
  }
107
118
  // Part 2b D7 — a step that names the process it runs in (a BAdI
108
119
  // implementation) carries its "Affects:" row right under it. The card's
@@ -158,7 +169,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
158
169
  title: system ? `Apply this plan on ${system}?` : 'Apply this plan?',
159
170
  facts,
160
171
  choices: [
161
- { value: 'apply_all', label: `Apply all ${visibleCount} ${noun}` },
172
+ { value: 'apply_all', label: applyLabel },
162
173
  { value: 'review', label: 'Review each change one by one' },
163
174
  { value: 'cancel', label: 'Cancel' },
164
175
  ],
@@ -178,6 +189,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
178
189
  async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk) {
179
190
  const visibleCount = visibleChanges.length;
180
191
  const noun = visibleCount === 1 ? 'change' : 'changes';
192
+ const applyLabel = applyAllLabel(visibleCount, noun);
181
193
  console.log('');
182
194
  console.log(`${peach('Plan')} ${summary}`);
183
195
  const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
@@ -186,7 +198,7 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
186
198
  console.log(` ${chalk.dim(meta.join(' · '))}`);
187
199
  const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
188
200
  if (shownReasons.length > 0) {
189
- console.log(` ${chalk.dim(`(risk raised: ${shownReasons.map(riskReasonText).join('; ')})`)}`);
201
+ console.log(` ${chalk.dim(`(risk raised: ${riskReasonTexts(effectiveRisk).join('; ')})`)}`);
190
202
  }
191
203
  visibleChanges.forEach((c, i) => {
192
204
  console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
@@ -207,7 +219,7 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
207
219
  default: 'apply_all',
208
220
  theme: inquirerTheme,
209
221
  choices: [
210
- { value: 'apply_all', name: `Apply all ${visibleCount} ${noun}` },
222
+ { value: 'apply_all', name: applyLabel },
211
223
  { value: 'review', name: 'Review each change individually' },
212
224
  { value: 'cancel', name: 'Cancel' },
213
225
  ],
@@ -283,11 +295,11 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
283
295
  // proper diff content.
284
296
  if (change.diff) {
285
297
  if (looksLikeUnifiedDiff(change.diff)) {
286
- console.log('\n' + colorizeDiff(change.diff) + '\n');
298
+ console.log('\n' + colorizeDiff(decodeDisplayBlock(change.diff)) + '\n');
287
299
  }
288
300
  else {
289
301
  console.log('\n' + chalk.yellow(`${getGlyphs().attention} Model did not provide a unified diff — only a description:`));
290
- console.log(chalk.dim(change.diff) + '\n');
302
+ console.log(chalk.dim(decodeDisplayEntities(change.diff)) + '\n');
291
303
  }
292
304
  }
293
305
  else {
@@ -100,8 +100,13 @@ export function signatureChange(diff) {
100
100
  return false;
101
101
  let state = 'unknown';
102
102
  for (const raw of diff.split('\n')) {
103
- if (raw.startsWith('+++') || raw.startsWith('---'))
103
+ // LT-10 (1A fix wave) — a file header starts another file (a diff can
104
+ // cover several includes): a declaration still open in the file before
105
+ // it never carries over.
106
+ if (raw.startsWith('+++') || raw.startsWith('---')) {
107
+ state = 'unknown';
104
108
  continue;
109
+ }
105
110
  if (raw.startsWith('@@')) {
106
111
  // Task 7 (R2) / fix round 1 — a header naming the block
107
112
  // (`@@ METHOD to_internal. @@`) hints that the hunk starts in its body.
@@ -157,6 +162,7 @@ export function effectiveRisk(declared, changes, ctx) {
157
162
  // a body-only change read "may alter a method signature" when activation
158
163
  // was the true reason.
159
164
  const applied = [];
165
+ let syntaxObjectWord;
160
166
  const raise = (r, label, always = false) => {
161
167
  applied.push({ r, label, always });
162
168
  };
@@ -176,6 +182,16 @@ export function effectiveRisk(declared, changes, ctx) {
176
182
  // Final review A M-4 — the reason is always shown, even when the floor
177
183
  // was already high (policy never, or declared high).
178
184
  raise('high', 'syntax_errors', true);
185
+ // LT-10 — "this class" / "this program" from the RECORDED keys' types
186
+ // for the hit names (the match is by name, so the row's type may not be
187
+ // what was checked). Only when they all agree on one known word.
188
+ const hitNames = new Set(changes.filter(hit).map((c) => c.object.trim().split(/\s+/)[0].toUpperCase()));
189
+ const words = new Set(ctx.syntaxErrorObjects
190
+ .filter((k) => hitNames.has(k.slice(k.lastIndexOf(':') + 1).toUpperCase()))
191
+ .map((k) => ctx.typeWord?.(k.slice(0, k.lastIndexOf(':')))));
192
+ const [only] = [...words];
193
+ if (words.size === 1 && only)
194
+ syntaxObjectWord = only;
179
195
  }
180
196
  }
181
197
  // Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
@@ -231,7 +247,7 @@ export function effectiveRisk(declared, changes, ctx) {
231
247
  raisedBy.splice(act, 1);
232
248
  raisedBy.splice(sig, 0, 'activation');
233
249
  }
234
- return { level: floor, raisedBy };
250
+ return syntaxObjectWord ? { level: floor, raisedBy, syntaxObjectWord } : { level: floor, raisedBy };
235
251
  }
236
252
  /** Plain words for each risk reason code — the codes stay in audit data only. */
237
253
  const RISK_REASON_TEXT = {
@@ -252,15 +268,17 @@ const RISK_REASON_TEXT = {
252
268
  * Task 7 (R2) — one sentence on every request_approval result (and once in
253
269
  * the journey fix block): the risk line is CSPeach's reading of the diff, so
254
270
  * the model relays it rather than telling the person it is wrong.
271
+ * LT-6 (1A fix wave): the card already shows it, so the model neither repeats
272
+ * nor explains it unless asked (live: "rated medium only because…").
255
273
  */
256
- export const RISK_NOTE = "The risk line is CSPeach's reading of the diff; relay it, and if you think it is off, say why.";
274
+ export const RISK_NOTE = "The risk line is CSPeach's reading of the diff. Do not repeat, explain or dispute it unless the person asks.";
257
275
  /**
258
276
  * Task 7 (R2) — the risk fields of every request_approval result:
259
277
  * effective_risk and raised_by as before (older readers), plus risk { level,
260
278
  * reasons } in plain words and the note.
261
279
  */
262
280
  export function riskResultFields(eff) {
263
- const reasons = eff.raisedBy.map(riskReasonText); // round 8 item 4: plain words, never codes
281
+ const reasons = eff.raisedBy.map((c) => reasonTextFor(c, eff)); // round 8 item 4: plain words, never codes
264
282
  return { effective_risk: eff.level, raised_by: reasons, risk: { level: eff.level, reasons: [...reasons] }, note: RISK_NOTE };
265
283
  }
266
284
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
@@ -279,3 +297,13 @@ const NOT_A_RISK_REASON = new Set(['non_prod_override']);
279
297
  export function shownRiskReasons(raisedBy) {
280
298
  return raisedBy.filter((c) => !NOT_A_RISK_REASON.has(c));
281
299
  }
300
+ /** LT-10 — one reason in plain words; the syntax-errors one names the kind of object when known. */
301
+ function reasonTextFor(code, eff) {
302
+ if (code === 'syntax_errors' && eff.syntaxObjectWord)
303
+ return `the last syntax check of this ${eff.syntaxObjectWord} found errors`;
304
+ return riskReasonText(code);
305
+ }
306
+ /** LT-10 — the shown reasons of a result, in plain words (every approval surface). */
307
+ export function riskReasonTexts(eff) {
308
+ return shownRiskReasons(eff.raisedBy).map((c) => reasonTextFor(c, eff));
309
+ }
@@ -21,6 +21,56 @@ export function collapseImplicitActivates(changes) {
21
21
  return !writeKeys.has(`${c.type}:${c.object}`);
22
22
  });
23
23
  }
24
+ /**
25
+ * LT-12 (1A fix wave) — the rows a plan list shows. On top of the collapse
26
+ * above, a `modify` of an object that also has a `create` in the list is
27
+ * hidden: the create already covers its source write and activation
28
+ * (approvals/jwt.ts OP_COVERAGE), so "Create X" + "Change X" read as two
29
+ * changes for one new object. Fix round 1: only a modify with no diff of its
30
+ * own, or the same diff as the create, is hidden; a modify carrying other
31
+ * content stays, so the person sees it before "Apply all". When the create
32
+ * row has no behaviour (or affects) and a hidden modify has one, the row
33
+ * shows the modify's — a display copy, never the caller's object. Display
34
+ * only: the `changes[]` behind the card (approval ids, grants) and the gate
35
+ * count (collapseImplicitActivates) are untouched.
36
+ */
37
+ export function planRows(changes) {
38
+ const rows = collapseImplicitActivates(changes);
39
+ const createOf = new Map();
40
+ for (const c of rows)
41
+ if (c.op === 'create' && !createOf.has(`${c.type}:${c.object}`))
42
+ createOf.set(`${c.type}:${c.object}`, c);
43
+ const hidden = new Set();
44
+ const hiddenOf = new Map();
45
+ for (const c of rows) {
46
+ if (c.op !== 'modify')
47
+ continue;
48
+ const key = `${c.type}:${c.object}`;
49
+ const cr = createOf.get(key);
50
+ if (cr && (!c.diff || c.diff === cr.diff)) {
51
+ hidden.add(c);
52
+ if (!hiddenOf.has(key))
53
+ hiddenOf.set(key, c);
54
+ }
55
+ }
56
+ const out = [];
57
+ for (const c of rows) {
58
+ if (hidden.has(c))
59
+ continue;
60
+ const m = c.op === 'create' ? hiddenOf.get(`${c.type}:${c.object}`) : undefined;
61
+ if (!m) {
62
+ out.push(c);
63
+ continue;
64
+ }
65
+ const row = { ...c };
66
+ if (row.behaviour === undefined && m.behaviour !== undefined)
67
+ row.behaviour = m.behaviour;
68
+ if (row.affects === undefined && m.affects !== undefined)
69
+ row.affects = m.affects;
70
+ out.push(row);
71
+ }
72
+ return out;
73
+ }
24
74
  /**
25
75
  * Heuristic: a real unified diff has at least one line starting with `+`,
26
76
  * `-`, or `@@`. A summary or change description won't. Used to gate
package/dist/cli.js CHANGED
@@ -157,12 +157,20 @@ export async function main(argv) {
157
157
  // Task 12 — the full dump ends with "# model set by your account admin"
158
158
  // when /v1/me/model-config says the admin's model is enforced. Capped at
159
159
  // 1.5 s and fail-open: offline / signed out ⇒ the output is unchanged.
160
- const [{ loadConfig }, { getStore }, { loadCustomerModelConfig }] = await Promise.all([
160
+ const [{ loadConfig }, { getStore }, { loadCustomerModelConfig, loadServerModelConfig }] = await Promise.all([
161
161
  import('./config/loader.js'),
162
162
  import('./auth/api-key.js'),
163
163
  import('./models/server-config.js'),
164
164
  ]);
165
- await loadCustomerModelConfig(await loadConfig(), async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { });
165
+ const showCfg = await loadConfig();
166
+ await Promise.all([
167
+ loadCustomerModelConfig(showCfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { }),
168
+ // Stage 1A — only a stored `assistant.skills = inline` needs the served
169
+ // flag (the "not active" comment). Managed only, same cap, fail-open.
170
+ showCfg.assistant?.skills === 'inline'
171
+ ? loadServerModelConfig(showCfg, { timeoutMs: 1500 }).catch(() => { })
172
+ : Promise.resolve(),
173
+ ]);
166
174
  }
167
175
  await runConfigShow(args.slice(2));
168
176
  return 0;
@@ -33,6 +33,7 @@ import { paint } from '../renderer/theme.js';
33
33
  import { getGlyphs } from '../renderer/glyphs.js';
34
34
  import { saveSession } from '../session/store.js';
35
35
  import { isUserPrompt } from '../session/user-prompt.js';
36
+ import { stripSkillBlocksInMessage } from '../agent/inline-skill-block.js';
36
37
  import { rebuildCliMarksAfterCompact, COMPACT_LINE } from '../session/cli-record.js';
37
38
  import { getActiveDeployments } from '../models/model-identity.js';
38
39
  /** Keep this many recent user-prompt anchored turns verbatim. */
@@ -131,7 +132,11 @@ export function buildSummarisationPrompt() {
131
132
  */
132
133
  export function serialiseForSummariser(messages) {
133
134
  const out = [];
134
- for (const msg of messages) {
135
+ for (const original of messages) {
136
+ // Stage 1A (S-A, M-3) — a playbook body never reaches the summariser: a
137
+ // hash-passing <skill> block in user text becomes [skill block: <name>].
138
+ // A message without one is the same object (byte-identical output).
139
+ const msg = stripSkillBlocksInMessage(original);
135
140
  if (typeof msg.content === 'string') {
136
141
  out.push({ role: msg.role, content: msg.content });
137
142
  }
@@ -19,6 +19,7 @@ const MUTABLE_KEYS = new Set([
19
19
  'assistant.lane',
20
20
  'assistant.style',
21
21
  'assistant.level',
22
+ 'assistant.skills',
22
23
  'local_build',
23
24
  'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
24
25
  'show_cogs',
@@ -83,6 +84,7 @@ const PLAN_MODE_VALUES = ['step', 'guarded'];
83
84
  const PLAN_AUDIT_VALUES = ['on', 'off'];
84
85
  const USAGE_REPORTING_VALUES = ['on', 'off'];
85
86
  const ASSISTANT_LANE_VALUES = ['chat', 'skills'];
87
+ const ASSISTANT_SKILLS_VALUES = ['system', 'inline'];
86
88
  const APPROVALS_VALUES = ['plan', 'per-write'];
87
89
  /**
88
90
  * Thrown on validation failure. The outer CLI dispatcher converts to
@@ -547,6 +549,15 @@ export async function runConfigSet(args) {
547
549
  cfg.assistant.lane = lane;
548
550
  break;
549
551
  }
552
+ // Stage 1A — explicit, like the lane. markExplicit below keeps the table.
553
+ case 'assistant.skills': {
554
+ const skills = value.trim().toLowerCase();
555
+ if (skills !== 'system' && skills !== 'inline') {
556
+ err(`assistant.skills must be one of: ${ASSISTANT_SKILLS_VALUES.join(', ')}`);
557
+ }
558
+ cfg.assistant.skills = skills;
559
+ break;
560
+ }
550
561
  case 'assistant.style': {
551
562
  const style = parseStyle(value);
552
563
  if (!style)
@@ -800,6 +811,8 @@ export function validateValue(key, value) {
800
811
  return value === 'true' || value === 'false';
801
812
  case 'assistant.lane':
802
813
  return ASSISTANT_LANE_VALUES.includes(value.trim().toLowerCase());
814
+ case 'assistant.skills':
815
+ return ASSISTANT_SKILLS_VALUES.includes(value.trim().toLowerCase());
803
816
  case 'assistant.style':
804
817
  return parseStyle(value) !== null;
805
818
  case 'assistant.level':
@@ -3,6 +3,7 @@ import { loadConfig } from '../config/loader.js';
3
3
  import { paint } from '../renderer/theme.js';
4
4
  import { formatAttention } from '../renderer/notice-shape.js';
5
5
  import { getCustomerModelConfig } from '../models/server-config.js';
6
+ import { inlineInactiveReason, inlineServed } from '../repl/inline-mode.js';
6
7
  /**
7
8
  * Dotted paths of keys that must never appear in plaintext config output.
8
9
  * SAP passwords live in the keychain and should never land in config.toml,
@@ -74,7 +75,11 @@ function printLlmMissingHints(llm) {
74
75
  console.log(`ai_hub_router_model = ${paint('dim', '<not set — skill routing uses ai_hub_model_alias>')}`);
75
76
  }
76
77
  }
77
- function printConfigAsToml(cfg, prefix = '') {
78
+ /**
79
+ * `notes` maps a dotted key to a trailing comment (Stage 1A: a stored
80
+ * `assistant.skills = inline` that would not be active).
81
+ */
82
+ function printConfigAsToml(cfg, prefix = '', notes = {}) {
78
83
  // Emit scalars before nested objects — otherwise a top-level scalar that
79
84
  // follows a nested table (e.g. `write_mode` declared after `ui` in the
80
85
  // config shape) would render underneath `[ui]` in TOML, which reparses as
@@ -91,7 +96,8 @@ function printConfigAsToml(cfg, prefix = '') {
91
96
  console.log(`${k} = [REDACTED]`);
92
97
  }
93
98
  else {
94
- console.log(`${k} = ${formatTomlValue(v)}`);
99
+ const note = notes[fullKey];
100
+ console.log(`${k} = ${formatTomlValue(v)}` + (note ? chalk.gray(` # ${note}`) : ''));
95
101
  // Surface an explanatory hint for advisory-only mode so users understand
96
102
  // the implication (writes proposed, never executed) without having to
97
103
  // read the docs.
@@ -104,7 +110,7 @@ function printConfigAsToml(cfg, prefix = '') {
104
110
  const fullKey = prefix ? `${prefix}.${k}` : k;
105
111
  console.log('');
106
112
  console.log(chalk.bold(`[${fullKey}]`));
107
- printConfigAsToml(v, fullKey);
113
+ printConfigAsToml(v, fullKey, notes);
108
114
  // Mode-aware extras for the `[llm]` section — print required keys that
109
115
  // are empty as `<not set>` so a user who ran `cspeach config set llm.mode
110
116
  // ai-hub` without also setting the base URL can see what is still
@@ -165,7 +171,13 @@ export async function runConfigShow(args) {
165
171
  // Full config dump
166
172
  console.log('');
167
173
  console.log(chalk.gray('# ~/.cspeach/config.toml'));
168
- printConfigAsToml(cfg);
174
+ // Stage 1A — say when a stored `inline` would not be active in an
175
+ // interactive session (the served flag is loaded by cli.ts).
176
+ // Read defensively: a config without an [assistant] table means the defaults.
177
+ const inlineNote = cfg.assistant?.skills === 'inline'
178
+ ? inlineInactiveReason({ skills: 'inline', lane: cfg.assistant.lane, served: inlineServed(cfg.llm?.mode) })
179
+ : null;
180
+ printConfigAsToml(cfg, '', inlineNote ? { 'assistant.skills': inlineNote } : {});
169
181
  console.log('');
170
182
  // Task 12 — one trailing line when the account admin's model is enforced
171
183
  // by the proxy (/v1/me/model-config). No snapshot ⇒ output unchanged.
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { metaFromMarkdown } from '../docx/doc-meta.js';
21
21
  import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
22
22
  import { normalizeAskInput } from '../tools/ask-question.js';
23
- import { isUserPrompt, userPromptText } from '../session/user-prompt.js';
23
+ import { INJECTED_CONTEXT_TAGS, isUserPrompt, userPromptText } from '../session/user-prompt.js';
24
24
  import { resolveCliMarks, startsTurn } from '../session/cli-record.js';
25
25
  import { docxFileFromReplArgs, runExportDocxCommand } from './export-docx.js';
26
26
  // The usage line lives in the light export-audit module (final-review I-2); re-exported here.
@@ -30,11 +30,14 @@ const OUT_DIR = 'cspeach-out';
30
30
  function readerText(text) {
31
31
  return text.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
32
32
  }
33
- /** Context blocks the CLI adds to a prompt; the developer never typed them. */
34
- const INJECTED_TAGS = ['session_context', 'project_context', 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection'];
33
+ /**
34
+ * Context blocks the CLI adds to a prompt (the developer never typed them) are
35
+ * dropped with the shared INJECTED_CONTEXT_TAGS list (session/user-prompt.ts),
36
+ * which carries Stage 1A's inline `<skill>` playbook blocks too.
37
+ */
35
38
  function typedText(text) {
36
39
  let out = text;
37
- for (const tag of INJECTED_TAGS) {
40
+ for (const tag of INJECTED_CONTEXT_TAGS) {
38
41
  out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), '');
39
42
  }
40
43
  out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
@@ -0,0 +1,124 @@
1
+ // cspeach-cli/src/commands/finish-check-guard.ts
2
+ //
3
+ // Finish-check guard (2026-10-23, inline-waiver findings §6 Option A).
4
+ // A plan step may not be saved "validated" while that turn's LAST
5
+ // rap_finish_check said fewer than 5 of 5 passed. The write-back
6
+ // downgrades it to "blocked" (never refuses: a refusal would cost another
7
+ // model turn). "validated-with-waiver" is kept only when the step's notes
8
+ // name every failed item id.
9
+ //
10
+ // The guard acts only when it reads the check confidently. No check this
11
+ // turn, an unreadable last check, a 5 of 5, or any other status → null
12
+ // (today's behaviour).
13
+ const TOOL_NAME = 'rap_finish_check';
14
+ // The real handler's shape (tools/rap/finish-check.ts formatFinish):
15
+ // Finish check: <n> of 5 passed
16
+ // F<k> <title> — passed | needs work: <problems>
17
+ const HEADER_RE = /^Finish check: (\d+) of (\d+) passed$/;
18
+ const ITEM_RE = /^(F[1-5]) .*? — (passed|needs work:.*)$/;
19
+ const ITEM_IDS = ['F1', 'F2', 'F3', 'F4', 'F5'];
20
+ function resultText(content) {
21
+ if (typeof content === 'string')
22
+ return content;
23
+ if (!Array.isArray(content))
24
+ return null;
25
+ const parts = [];
26
+ for (const b of content) {
27
+ if (b?.type === 'text' && typeof b.text === 'string')
28
+ parts.push(b.text);
29
+ }
30
+ return parts.length > 0 ? parts.join('\n') : null;
31
+ }
32
+ /** The text of this turn's LAST rap_finish_check tool result, or null when there is none. */
33
+ export function lastFinishCheckText(messages, messagesStart) {
34
+ const checkIds = new Set();
35
+ let last = null;
36
+ for (let i = Math.max(0, messagesStart); i < messages.length; i++) {
37
+ const m = messages[i];
38
+ if (!Array.isArray(m.content))
39
+ continue;
40
+ for (const b of m.content) {
41
+ if (!b || typeof b !== 'object')
42
+ continue;
43
+ if (m.role === 'assistant' && b.type === 'tool_use' && b.name === TOOL_NAME && typeof b.id === 'string') {
44
+ checkIds.add(b.id);
45
+ }
46
+ else if (m.role === 'user' && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && checkIds.has(b.tool_use_id)) {
47
+ // An unreadable last result still counts as the last one: it makes the guard stand down.
48
+ last = resultText(b.content) ?? '';
49
+ }
50
+ }
51
+ }
52
+ return last;
53
+ }
54
+ /** Parses a rap_finish_check result; null unless every line agrees with the handler's shape. */
55
+ export function parseFinishCheck(text) {
56
+ const lines = text.split(/\r?\n/).map((l) => l.trimEnd());
57
+ const head = HEADER_RE.exec(lines[0] ?? '');
58
+ if (!head)
59
+ return null;
60
+ const passed = Number(head[1]);
61
+ const total = Number(head[2]);
62
+ if (total !== ITEM_IDS.length || passed > total)
63
+ return null;
64
+ const seen = [];
65
+ const failed = [];
66
+ for (const line of lines.slice(1)) {
67
+ const m = ITEM_RE.exec(line);
68
+ if (!m)
69
+ continue;
70
+ seen.push(m[1]);
71
+ if (m[2] !== 'passed')
72
+ failed.push(m[1]);
73
+ }
74
+ if (seen.join(',') !== ITEM_IDS.join(','))
75
+ return null;
76
+ if (failed.length !== total - passed)
77
+ return null;
78
+ return { passed, total, failed };
79
+ }
80
+ const namesId = (notes, id) => new RegExp(`\\b${id}\\b`).test(notes);
81
+ /**
82
+ * The guard's note: one place for its wording. It goes into the step's
83
+ * work.notes and is shown as the run line.
84
+ * <step>: finish check 3 of 5, F1, F4 failed. Saved as blocked.
85
+ * <step>: finish check 3 of 5, F1, F4 failed. The notes do not waive F4. Saved as blocked.
86
+ */
87
+ const GUARD_NOTE_TAIL = 'Saved as blocked.';
88
+ const GUARD_NOTE_HEAD = 'finish check';
89
+ const GUARD_NOTE_RE = new RegExp(`: ${GUARD_NOTE_HEAD} \\d+ of \\d+, .* ${GUARD_NOTE_TAIL.replace(/\./g, '\\.')}$`);
90
+ export function formatFinishGuardNote(a) {
91
+ const waiver = a.notWaived && a.notWaived.length > 0 ? ` The notes do not waive ${a.notWaived.join(', ')}.` : '';
92
+ return `${a.step}: ${GUARD_NOTE_HEAD} ${a.score.passed} of ${a.score.total}, ${a.score.failed.join(', ')} failed.${waiver} ${GUARD_NOTE_TAIL}`;
93
+ }
94
+ /** True for a line the guard wrote itself (it never counts as a waiver). */
95
+ export function isFinishGuardNoteLine(line) {
96
+ return GUARD_NOTE_RE.test(line.trim());
97
+ }
98
+ /** The notes without the guard's own lines: only what the model or developer wrote. */
99
+ function notesWithoutGuardLines(notes) {
100
+ return notes.split(/\r?\n/).filter((l) => !isFinishGuardNoteLine(l)).join('\n');
101
+ }
102
+ /**
103
+ * The guard's ruling for the executed step: `{ status: 'blocked', note }` when
104
+ * the claimed status must be downgraded, otherwise null (save as written).
105
+ * A waiver counts from any turn's notes, but never from the guard's own lines.
106
+ */
107
+ export function finishCheckGuard(args) {
108
+ if (args.status !== 'validated' && args.status !== 'validated-with-waiver')
109
+ return null;
110
+ const text = lastFinishCheckText(args.messages, args.messagesStart);
111
+ if (text === null)
112
+ return null;
113
+ const score = parseFinishCheck(text);
114
+ if (!score || score.failed.length === 0)
115
+ return null;
116
+ let notWaived;
117
+ if (args.status === 'validated-with-waiver') {
118
+ const notes = notesWithoutGuardLines(args.notes ?? '');
119
+ notWaived = score.failed.filter((id) => !namesId(notes, id));
120
+ if (notWaived.length === 0)
121
+ return null;
122
+ }
123
+ return { status: 'blocked', note: formatFinishGuardNote({ step: args.step, score, notWaived }) };
124
+ }
@@ -34,7 +34,7 @@ import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-obje
34
34
  import { isDeployPhase } from '../projects/plan-lane.js';
35
35
  import { askDeployCard } from './deploy-card.js';
36
36
  import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
37
- import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
37
+ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, planBuildTransport, } from './plan-resume.js';
38
38
  import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
39
39
  import { clean } from '../renderer/tool-labels.js';
40
40
  import { runBatchAudit, pendingAuditPhaseIds, selectPlanCardAction, } from './plan-batch-audit.js';
@@ -251,7 +251,7 @@ async function postPhaseChain(args) {
251
251
  const built = after.content.phases.every((q) => isPhaseSatisfiedForRun(st[q.id], q.audit));
252
252
  const nextAfter = computeNextPhaseForRun(after.content.phases, st);
253
253
  if (built && !nextAfter) {
254
- args.log('', ...planCompletionLines(after.content.phases));
254
+ args.log('', ...planCompletionLines(after.content.phases, undefined, planBuildTransport(after.content.phases)));
255
255
  // Step 2 (C5): a rebuild whose plan this is now reads "built, not yet checked".
256
256
  await afterPlanComplete(cur);
257
257
  args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
@@ -99,10 +99,28 @@ export async function regroundPlan(a) {
99
99
  stillLocked.push(o.name);
100
100
  }
101
101
  let transportClosed = false;
102
+ let listed = null;
103
+ const isListed = (t, tr) => t.list.some((x) => x.trim().toUpperCase() === tr.trim().toUpperCase());
102
104
  if (transport) {
103
105
  const t = await a.deps.openTransports();
104
- transportClosed = t.complete && !t.list.some((x) => x.trim().toUpperCase() === transport.trim().toUpperCase());
106
+ listed = t;
107
+ transportClosed = t.complete && !isListed(t, transport);
105
108
  }
109
+ // The step transport counts as open only when SAP lists it. Any failure
110
+ // here keeps the old text and a "not open" verdict.
111
+ const step = a.stepTransport?.trim();
112
+ let stepOpen = false;
113
+ if (step) {
114
+ try {
115
+ listed = listed ?? await a.deps.openTransports();
116
+ stepOpen = isListed(listed, step);
117
+ }
118
+ catch (err) {
119
+ recordFullNotice('plan', `Transport check before a plan step failed: ${errText(err)}`);
120
+ }
121
+ }
122
+ const openStepTransport = !transport && stepOpen ? step : null;
123
+ const verdict = step ? { stepTransportOpen: stepOpen } : {};
106
124
  // An object left inactive gets the inactive instruction only, never the
107
125
  // "do not rewrite" one as well.
108
126
  const inactiveNames = new Set(inactive.map((o) => o.name));
@@ -124,7 +142,9 @@ export async function regroundPlan(a) {
124
142
  ? (transportClosed
125
143
  ? `- The plan's transport ${transport} is no longer open. Do not write to it. Creating a new transport needs the developer's approval (it is not on the approved list).`
126
144
  : `- Transport ${transport} is still open: use it.`)
127
- : '- No transport is recorded yet: the first writing step creates one.',
145
+ : openStepTransport
146
+ ? `- Transport ${openStepTransport} is open: use it.`
147
+ : '- No transport is recorded yet: the first writing step creates one.',
128
148
  '</sap_state>',
129
149
  ];
130
150
  if (stillLocked.length > 0) {
@@ -133,12 +153,14 @@ export async function regroundPlan(a) {
133
153
  ok: false, reason: 'locked',
134
154
  lines: [`${stillLocked.join(', ')} ${one ? 'is' : 'are'} still locked by another session. SAP frees ${one ? 'it' : 'them'} when that session ends or times out, usually within a few minutes.`],
135
155
  sapStateBlock: block,
156
+ ...verdict,
136
157
  };
137
158
  }
138
159
  const n = inactive.length;
139
160
  const summary = `Checked SAP before continuing: ${n} object${n === 1 ? '' : 's'} left inactive`
140
- + (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '') + '.';
141
- return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block };
161
+ + (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '')
162
+ + (openStepTransport ? `, transport ${openStepTransport} open` : '') + '.';
163
+ return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block, ...verdict };
142
164
  }
143
165
  catch (err) {
144
166
  const code = unreachableOf(err);
@@ -158,6 +180,7 @@ export async function regroundPlan(a) {
158
180
  built.length > 0 ? `- Already built by earlier steps of this plan: ${built.join(', ')}.` : '- No earlier step of this plan has built SAP objects yet.',
159
181
  '</sap_state>',
160
182
  ],
183
+ ...(a.stepTransport?.trim() ? { stepTransportOpen: false } : {}),
161
184
  };
162
185
  }
163
186
  }