@cspeach/cli 1.1.51 → 1.1.52

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 (85) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +30 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +208 -41
  5. package/dist/agent/provider-shape.js +8 -4
  6. package/dist/agent/providers/ai-hub-provider.js +2 -2
  7. package/dist/agent/providers/azure-provider.js +2 -2
  8. package/dist/agent/providers/byok-provider.js +2 -2
  9. package/dist/agent/providers/local-provider.js +2 -2
  10. package/dist/agent/repair-partial.js +12 -0
  11. package/dist/agent/skill-block-key.js +134 -0
  12. package/dist/agent/steering-queue.js +50 -14
  13. package/dist/agent/system-prompt.js +22 -0
  14. package/dist/agent/tool-dispatch.js +19 -7
  15. package/dist/agent/turn-watchdog.js +1 -1
  16. package/dist/approvals/advisory-render.js +6 -3
  17. package/dist/approvals/approval-prompt.js +9 -4
  18. package/dist/approvals/op-labels.js +14 -0
  19. package/dist/approvals/plan-grant.js +8 -0
  20. package/dist/approvals/render.js +19 -8
  21. package/dist/approvals/risk-floor.js +32 -4
  22. package/dist/approvals/visible-changes.js +50 -0
  23. package/dist/cli.js +10 -2
  24. package/dist/commands/compact.js +6 -1
  25. package/dist/commands/config-set.js +13 -0
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-conversation.js +7 -4
  28. package/dist/commands/finish-check-guard.js +124 -0
  29. package/dist/commands/plan-reground.js +27 -4
  30. package/dist/commands/plan-resume.js +111 -1
  31. package/dist/config/loader.js +5 -1
  32. package/dist/cost/cost-log.js +6 -0
  33. package/dist/cost/system-block.js +6 -0
  34. package/dist/models/server-config.js +1 -1
  35. package/dist/projects/save-command.js +5 -1
  36. package/dist/projects/yes-no.js +5 -1
  37. package/dist/renderer/change-card.js +3 -1
  38. package/dist/renderer/steering-echo.js +53 -8
  39. package/dist/renderer/tool-labels.js +3 -1
  40. package/dist/repl/chat-lane.js +5 -2
  41. package/dist/repl/inline-mode.js +51 -0
  42. package/dist/repl/leftover.js +90 -0
  43. package/dist/repl/reroute-turn.js +7 -1
  44. package/dist/repl/turn-stop.js +64 -0
  45. package/dist/repl.js +144 -22
  46. package/dist/router/classifier.js +1 -1
  47. package/dist/sap/reachability-state.js +7 -0
  48. package/dist/sap/unreachable.js +2 -2
  49. package/dist/session/queued-step.js +3 -1
  50. package/dist/session/user-prompt.js +10 -1
  51. package/dist/skills/manifest-client.js +3 -2
  52. package/dist/skills/preamble.js +6 -4
  53. package/dist/skills/source-authed.js +3 -2
  54. package/dist/tools/approval.js +37 -7
  55. package/dist/tools/dispatch-skill.js +1 -1
  56. package/dist/tools/hand-off.js +49 -7
  57. package/dist/tools/lane/app-lane-resolve.js +9 -0
  58. package/dist/tools/method-only-change.js +241 -0
  59. package/dist/tools/rap/skeleton-resolve.js +4 -0
  60. package/dist/tools/sap-write.js +78 -3
  61. package/dist/tools/snapshot.js +7 -2
  62. package/dist/tools/subagent/agent_run.js +14 -0
  63. package/dist/tools/tool-loading.js +16 -8
  64. package/dist/tools/transport-fit-check.js +70 -0
  65. package/dist/tools/transport.js +17 -8
  66. package/dist/tools/write-pipeline.js +46 -5
  67. package/dist/ui/app.js +112 -13
  68. package/dist/ui/approval-modal.js +11 -6
  69. package/dist/ui/ascii-chars.js +21 -3
  70. package/dist/ui/footer.js +128 -7
  71. package/dist/ui/interrupt.js +35 -1
  72. package/dist/ui/live-area.js +21 -4
  73. package/dist/ui/queue-box-emitter.js +14 -0
  74. package/dist/ui/rewind-panel.js +2 -1
  75. package/dist/ui/session-timeline.js +2 -1
  76. package/dist/ui/text-input.js +31 -2
  77. package/dist/ui/widgets/bar-chart.js +2 -1
  78. package/dist/ui/widgets/confirm-card.js +2 -1
  79. package/dist/ui/widgets/dep-graph.js +2 -1
  80. package/dist/ui/widgets/diff-viewer.js +2 -1
  81. package/dist/ui/widgets/question-card.js +2 -1
  82. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  83. package/dist/ui/widgets/stack-frames.js +2 -1
  84. package/dist/util/connect-retry.js +65 -0
  85. package/package.json +1 -1
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
+ }
@@ -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
  }
@@ -45,6 +45,7 @@ import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../
45
45
  import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
46
46
  import { statusesFromItems, computeNextPhase, computeNextPhaseForRun, pendingAuditPhaseIds, renderPlanTracker, renderPlanProgressLine, buildPlanRevision, isPhaseSatisfied, isAuditResolved, planCompletionLines, findServiceBinding, phaseDisplayTitle, } from '../projects/plan-run.js';
47
47
  import { extractPlan, NO_PLAN_MANIFEST_MESSAGE } from '../projects/extract-plan.js';
48
+ import { finishCheckGuard } from './finish-check-guard.js';
48
49
  import { recordFullNotice } from '../renderer/notice-log.js';
49
50
  import { isSapUnreachableNoted } from '../sap/reachability-state.js';
50
51
  import { saveProject } from '../projects/save.js';
@@ -66,6 +67,8 @@ import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.j
66
67
  import { FIORI_DEPLOY_HANDOVER_SCOPE } from '../tools/fiori/deploy/scope.js';
67
68
  import { resolvePlanSources, renderPlanSourcesBlock } from '../projects/plan-sources.js';
68
69
  import { regroundPlan } from './plan-reground.js';
70
+ import { getGrantTransport, isPlanGrantInForceFor } from '../approvals/plan-grant.js';
71
+ import { projectTransport, transportForWrite } from '../register/project-transport.js';
69
72
  import { PAUSED_LINE, makeSapProbe, pauseUntilSapReachable, promptLockedChoice, resumeCommandFor } from './plan-sap-pause.js';
70
73
  /**
71
74
  * Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
@@ -166,6 +169,51 @@ export function buildUiPhaseContext(phases, next) {
166
169
  }
167
170
  return lines;
168
171
  }
172
+ const sameTransport = (a, b) => !!a && !!b && a.trim().toUpperCase() === b.trim().toUpperCase();
173
+ /**
174
+ * 2026-10-23 (owner-approved fix, review round 1) — the transport a create in
175
+ * this step would go to, by the write path's own order, so the step prompt can
176
+ * name it. Never the step's own work.transport ahead of that order.
177
+ * - A plan grant of THIS plan is in force: its transport (writes under the
178
+ * grant are held to it; a grant whose new transport is not bound yet names
179
+ * none).
180
+ * - Guarded mode, no grant yet: the grant offered for this step would bind
181
+ * the plan's recorded transport, else the session one; if it is not
182
+ * installed, writes take transportForWrite's order. Named only when the
183
+ * two agree.
184
+ * - Otherwise (step mode): transportForWrite for a create: the joined team
185
+ * project's request, else the session transport (an explicit /transport).
186
+ * Resolve-mode writes (set_source, update_method, delete) never fall back to
187
+ * the session transport, and an object's owning lock still wins over any
188
+ * transport passed. Pure.
189
+ */
190
+ export function resolveStepWriteTransport(i) {
191
+ if (i.grantInstalled)
192
+ return i.grantTransport?.trim() || undefined;
193
+ const perWrite = transportForWrite({ op: 'create', project: i.project, session: i.session }).request;
194
+ if (i.mode !== 'guarded')
195
+ return perWrite || undefined;
196
+ const planned = recordedPlanTransport(i.phases) ?? i.session ?? undefined;
197
+ return sameTransport(planned, perWrite) ? perWrite : undefined;
198
+ }
199
+ /**
200
+ * The step-prompt line naming the transport. `transport` is passed only when
201
+ * it is the one the write path uses AND the pre-step SAP check listed it as
202
+ * open. Returns [] (prompt unchanged) with no transport, for read-only steps
203
+ * (writes:false), design steps, and UI/CAP steps (local files; a UI deploy
204
+ * names its own).
205
+ */
206
+ export function buildStepTransportLine(next, transport) {
207
+ if (!transport)
208
+ return [];
209
+ if (!phaseWrites(next))
210
+ return [];
211
+ if (next.delegateTo === 'abap-design')
212
+ return [];
213
+ if (next.layer === 'ui' || next.layer.startsWith('cap.'))
214
+ return [];
215
+ return [`Transport for this step: ${transport} (already settled: use it, do not ask the developer which transport).`];
216
+ }
169
217
  /** Compact-example work keys for ui phases — the write-back contract
170
218
  * (review C3). Returns an OBJECT fragment because compactExample renders
171
219
  * via JSON.stringify: it is spread into the `work` object exactly like the
@@ -528,17 +576,31 @@ export async function preparePlanResume(args) {
528
576
  // what this plan built, what a dead run left inactive (a stale lock is
529
577
  // waited out, then reported), and whether the transport is still open.
530
578
  let sapStateBlock = [];
579
+ // 2026-10-23 — the transport this step's writes would use (write-path
580
+ // order). It is named in the prompt only when the re-check lists it open.
581
+ // A grant counts only while it is in force (this plan, not expired, approvals
582
+ // = plan); otherwise writes take the per-write order, and so does the line.
583
+ const stepWriteTransport = resolveStepWriteTransport({
584
+ phases: content.phases,
585
+ mode: effectiveMode,
586
+ grantInstalled: isPlanGrantInForceFor(envelope.id),
587
+ grantTransport: getGrantTransport(),
588
+ project: projectTransport(args.cwd),
589
+ session: getCurrentTransport(),
590
+ });
591
+ let stepTransportOpen = false;
531
592
  if (args.reground) {
532
593
  const regroundDeps = args.reground;
533
594
  const canAsk = args.interactive === true && !!args.prompt;
534
595
  const resumeCommand = resumeCommandFor({ path, mode: flags.mode, auditFlag: flags.audit });
535
596
  for (;;) {
536
- const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log });
597
+ const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log, ...(stepWriteTransport ? { stepTransport: stepWriteTransport } : {}) });
537
598
  if (r.ok) {
538
599
  // An in-run hop stays quiet unless something needs attention.
539
600
  if (!r.quiet || args.boardStyle !== 'line')
540
601
  args.log(formatInfo(r.summary).trimEnd());
541
602
  sapStateBlock = r.sapStateBlock;
603
+ stepTransportOpen = r.stepTransportOpen === true;
542
604
  break;
543
605
  }
544
606
  if (r.reason === 'unreachable') {
@@ -562,6 +624,7 @@ export async function preparePlanResume(args) {
562
624
  continue;
563
625
  if (choice === 'continue') {
564
626
  sapStateBlock = r.sapStateBlock;
627
+ stepTransportOpen = r.stepTransportOpen === true;
565
628
  break;
566
629
  }
567
630
  args.log(PAUSED_LINE);
@@ -631,6 +694,10 @@ export async function preparePlanResume(args) {
631
694
  // '' already ends the prior element). Spread the blank AFTER the block so
632
695
  // context keeps single-blank separation on both sides.
633
696
  const uiContext = buildUiPhaseContext(content.phases, next);
697
+ // Named only when it is the transport the write uses, it is listed open, and
698
+ // it does not contradict the plan's recorded transport in <sap_state>.
699
+ const recordedTr = recordedPlanTransport(content.phases);
700
+ const stepTransport = buildStepTransportLine(next, stepTransportOpen && (!recordedTr || sameTransport(recordedTr, stepWriteTransport)) ? stepWriteTransport : undefined);
634
701
  // 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
635
702
  // from the file the plan was made from (or the plan's creation snapshot).
636
703
  // A phase must never have to hunt the folder for its gap answers.
@@ -671,6 +738,7 @@ export async function preparePlanResume(args) {
671
738
  'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — CSPeach accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
672
739
  '',
673
740
  ...(uiContext.length ? [...uiContext, ''] : []),
741
+ ...(stepTransport.length ? [...stepTransport, ''] : []),
674
742
  PLAN_RESUME_HARNESS_OVERRIDE,
675
743
  ].join('\n');
676
744
  return {
@@ -935,6 +1003,12 @@ export async function finishPlanResume(args) {
935
1003
  return null;
936
1004
  }
937
1005
  }
1006
+ // 2026-10-23 — finish-check guard (inline-waiver findings §6 Option A): a
1007
+ // step claimed "validated" while this turn's LAST rap_finish_check is below
1008
+ // 5 of 5 is saved "blocked" (a waiver only when its notes name every failed
1009
+ // item). Downgrade, never refuse. No check this turn, a 5 of 5, an
1010
+ // unreadable result or any other status → saved exactly as written.
1011
+ applyFinishCheckGuard(extract, args);
938
1012
  const nowIso = new Date().toISOString();
939
1013
  const statuses = extract.statuses;
940
1014
  const executedId = args.prepared.nextPhaseId;
@@ -1056,6 +1130,42 @@ export async function finishPlanResume(args) {
1056
1130
  nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
1057
1131
  };
1058
1132
  }
1133
+ /**
1134
+ * The finish-check guard's write: when finishCheckGuard rules the executed
1135
+ * step down, set it to 'blocked' in the status map, the item list and the
1136
+ * summary, append the note to its work.notes and log the same line.
1137
+ */
1138
+ function applyFinishCheckGuard(extract, args) {
1139
+ const executedId = args.prepared.nextPhaseId;
1140
+ const executed = extract.content.phases.find((p) => p.id === executedId);
1141
+ if (!executed)
1142
+ return;
1143
+ const ruling = finishCheckGuard({
1144
+ messages: args.messages,
1145
+ messagesStart: args.messagesStart,
1146
+ status: extract.statuses[executedId],
1147
+ step: phaseDisplayTitle(executed, extract.content.phases),
1148
+ notes: executed.work?.notes,
1149
+ });
1150
+ if (!ruling)
1151
+ return;
1152
+ extract.statuses[executedId] = ruling.status;
1153
+ for (const it of extract.items)
1154
+ if (it.id === executedId)
1155
+ it.status = ruling.status;
1156
+ const prevNotes = executed.work?.notes;
1157
+ executed.work = { ...(executed.work ?? {}), notes: prevNotes ? `${prevNotes}\n${ruling.note}` : ruling.note };
1158
+ const phases = extract.content.phases;
1159
+ const next = computeNextPhase(phases, extract.statuses);
1160
+ extract.content.summary = {
1161
+ ...extract.content.summary,
1162
+ validated: phases.filter((p) => isPhaseSatisfied(extract.statuses[p.id], p.audit)).length,
1163
+ blocked: phases.filter((p) => extract.statuses[p.id] === 'blocked').length,
1164
+ next: next?.id ?? null,
1165
+ };
1166
+ args.log('');
1167
+ args.log(ruling.note);
1168
+ }
1059
1169
  /**
1060
1170
  * Final fix wave, item 5 — save a new plan version beside the plan file. When
1061
1171
  * that write fails (a read-only or missing folder), save it in the working
@@ -45,7 +45,8 @@ export const DEFAULT_CONFIG = {
45
45
  // (staged rollout, owner 2026-10-17; users opt in with /chat on, a later
46
46
  // release flips this); 'professional' sends no style; 'developer' sends no
47
47
  // level (§10 D13).
48
- assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL },
48
+ // Stage 1A — 'system' is today's request; 'inline' is opt-in (config set).
49
+ assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL, skills: 'system' },
49
50
  // Default `approval-gated` — preserves current UX (user approves each write).
50
51
  write_mode: 'approval-gated',
51
52
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
@@ -321,6 +322,9 @@ async function readConfig() {
321
322
  merged.assistant.lane = DEFAULT_CONFIG.assistant.lane;
322
323
  merged.assistant.style = parseStyle(merged.assistant.style) ?? DEFAULT_CONFIG.assistant.style;
323
324
  merged.assistant.level = parseLevel(merged.assistant.level) ?? DEFAULT_CONFIG.assistant.level;
325
+ // Stage 1A — unknown values fall back, like the lane.
326
+ if (merged.assistant.skills !== 'system' && merged.assistant.skills !== 'inline')
327
+ merged.assistant.skills = DEFAULT_CONFIG.assistant.skills;
324
328
  if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
325
329
  delete merged.ui.background;
326
330
  }
@@ -87,6 +87,12 @@ export function buildEntry(opts) {
87
87
  entry.skill = opts.skill;
88
88
  if (opts.system_block !== undefined)
89
89
  entry.system_block = opts.system_block;
90
+ if (opts.system_mode !== undefined)
91
+ entry.system_mode = opts.system_mode;
92
+ if (opts.skill_appended !== undefined && opts.skill_appended.length > 0)
93
+ entry.skill_appended = opts.skill_appended;
94
+ if (opts.tools_surfaced !== undefined && opts.tools_surfaced.length > 0)
95
+ entry.tools_surfaced = opts.tools_surfaced;
90
96
  return entry;
91
97
  }
92
98
  /**
@@ -11,6 +11,12 @@
11
11
  * Kept in a module WeakMap, never in the session file (no schema change). A
12
12
  * resumed session therefore starts at 'first' again.
13
13
  */
14
+ /**
15
+ * Stage 1A, Task 1A.2 (M2) — the skill part of the key on an inline call: the
16
+ * system block is the one conversation prompt whatever the skill header says,
17
+ * and no delegate is composed into it (the delegate rides in the conversation).
18
+ */
19
+ export const INLINE_SYSTEM_BLOCK_SKILL = '_inline';
14
20
  export function systemBlockKey(p) {
15
21
  return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
16
22
  }
@@ -284,7 +284,7 @@ export function managedProxyServesNewShapes() {
284
284
  * conversation prompt? True only when its served config says
285
285
  * `inline_skills: true`; false with no snapshot or an older proxy. Managed
286
286
  * snapshot only: BYOK builds its own system prompt and never needs it.
287
- * Dormant — nothing calls it until stage 2 (pinned by
287
+ * Read only by repl/inline-mode.ts `inlineServed` (Stage 1A; pinned by
288
288
  * __tests__/served-inline-skills-pin.test.ts). Pure sync read.
289
289
  */
290
290
  export function servedInlineSkills() {
@@ -398,7 +398,11 @@ export async function runSaveCommand(args) {
398
398
  // maybeOfferSave never does headless — so the blanket 'y' never reaches it.)
399
399
  // Inside a journey a triage is saved without the question: the journey needs the rows.
400
400
  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 });
401
+ const answer = journey ? 'y' : await askYesNo({
402
+ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt,
403
+ // F-A (live check 2026-10-26) — a plan is only built from its saved file.
404
+ ...(spec.artefactType === 'plan' ? { defaultYes: true } : {}),
405
+ });
402
406
  if (!isYes(answer)) {
403
407
  args.onNotSaved?.('declined');
404
408
  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;