@cspeach/cli 1.1.50 → 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 (100) 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 +314 -44
  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 +11 -3
  24. package/dist/commands/compact.js +12 -1
  25. package/dist/commands/config-set.js +14 -1
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-audit.js +26 -3
  28. package/dist/commands/export-conversation.js +346 -0
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/help.js +3 -0
  31. package/dist/commands/plan-boundary.js +5 -0
  32. package/dist/commands/plan-continue.js +11 -0
  33. package/dist/commands/plan-reground.js +27 -4
  34. package/dist/commands/plan-resume.js +111 -1
  35. package/dist/commands/plan-startup.js +5 -1
  36. package/dist/config/loader.js +5 -1
  37. package/dist/cost/cost-log.js +6 -0
  38. package/dist/cost/system-block.js +6 -0
  39. package/dist/docx/doc-meta.js +9 -1
  40. package/dist/docx/markdown-to-docx.js +14 -1
  41. package/dist/lane/app-chain.js +24 -1
  42. package/dist/models/server-config.js +1 -1
  43. package/dist/projects/document-save.js +65 -8
  44. package/dist/projects/document-skills.js +32 -1
  45. package/dist/projects/save-command.js +5 -1
  46. package/dist/projects/yes-no.js +5 -1
  47. package/dist/renderer/change-card.js +3 -1
  48. package/dist/renderer/steering-echo.js +53 -8
  49. package/dist/renderer/tool-labels.js +3 -1
  50. package/dist/repl/builtin-commands.js +1 -1
  51. package/dist/repl/chat-lane.js +5 -2
  52. package/dist/repl/inline-mode.js +51 -0
  53. package/dist/repl/leftover.js +90 -0
  54. package/dist/repl/reroute-turn.js +7 -1
  55. package/dist/repl/slash-completer.js +4 -0
  56. package/dist/repl/turn-stop.js +64 -0
  57. package/dist/repl.js +178 -32
  58. package/dist/router/classifier.js +1 -1
  59. package/dist/sap/reachability-state.js +7 -0
  60. package/dist/sap/unreachable.js +2 -2
  61. package/dist/session/cli-record.js +165 -0
  62. package/dist/session/fresh-start.js +4 -0
  63. package/dist/session/queued-step.js +3 -1
  64. package/dist/session/user-prompt.js +10 -1
  65. package/dist/skills/manifest-client.js +3 -2
  66. package/dist/skills/preamble.js +6 -4
  67. package/dist/skills/source-authed.js +3 -2
  68. package/dist/tools/approval.js +37 -7
  69. package/dist/tools/dispatch-skill.js +1 -1
  70. package/dist/tools/hand-off.js +49 -7
  71. package/dist/tools/lane/app-lane-resolve.js +9 -0
  72. package/dist/tools/method-only-change.js +241 -0
  73. package/dist/tools/rap/skeleton-resolve.js +4 -0
  74. package/dist/tools/sap-write.js +78 -3
  75. package/dist/tools/snapshot.js +7 -2
  76. package/dist/tools/subagent/agent_run.js +14 -0
  77. package/dist/tools/tool-loading.js +16 -8
  78. package/dist/tools/transport-fit-check.js +70 -0
  79. package/dist/tools/transport.js +17 -8
  80. package/dist/tools/write-pipeline.js +46 -5
  81. package/dist/ui/app.js +112 -13
  82. package/dist/ui/approval-modal.js +11 -6
  83. package/dist/ui/ascii-chars.js +21 -3
  84. package/dist/ui/card-slot.js +18 -1
  85. package/dist/ui/footer.js +128 -7
  86. package/dist/ui/interrupt.js +35 -1
  87. package/dist/ui/live-area.js +21 -4
  88. package/dist/ui/queue-box-emitter.js +14 -0
  89. package/dist/ui/rewind-panel.js +2 -1
  90. package/dist/ui/session-timeline.js +2 -1
  91. package/dist/ui/text-input.js +31 -2
  92. package/dist/ui/widgets/bar-chart.js +2 -1
  93. package/dist/ui/widgets/confirm-card.js +2 -1
  94. package/dist/ui/widgets/dep-graph.js +2 -1
  95. package/dist/ui/widgets/diff-viewer.js +2 -1
  96. package/dist/ui/widgets/question-card.js +2 -1
  97. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  98. package/dist/ui/widgets/stack-frames.js +2 -1
  99. package/dist/util/connect-retry.js +65 -0
  100. package/package.json +1 -1
@@ -0,0 +1,165 @@
1
+ // cspeach-cli/src/session/cli-record.ts
2
+ //
3
+ // /export docx conversation, fix round 1 (2026-10-23). Two small records kept
4
+ // on the session for the conversation export, NEVER sent to the model:
5
+ //
6
+ // - cli_messages: which user-role messages CSPeach wrote itself (a plan step
7
+ // prompt, the output-limit continuation, the watchdog note, a /compact or
8
+ // plan-boundary summary, a queued next step, a mid-turn steer). Each entry is
9
+ // the sha of the message content, not an index: compaction drops and moves
10
+ // messages, a content hash still finds the same message. The messages
11
+ // themselves are untouched, so every model request stays byte-identical.
12
+ // - cli_cards: the CLI's own cards that matter for a change ticket (the plan
13
+ // gate "Build this now?", the plan startup "Continue", the lane card, the
14
+ // journey cards), anchored by turn_seq. Never the save or Word cards.
15
+ //
16
+ // Both are optional session fields. A session file is JSON.parse'd and written
17
+ // whole (store.ts), so an older CLI that resumes the file keeps them as they are.
18
+ import crypto from 'node:crypto';
19
+ export const CLI_RECORD_CAP = 500;
20
+ export const CONTINUE_LINE = 'CSPeach asked the model to continue a long answer.';
21
+ export const WATCHDOG_LINE = 'CSPeach asked the model to wrap up a long turn.';
22
+ export const COMPACT_LINE = 'Earlier turns were summarised to keep the session small.';
23
+ export const PLAN_BOUNDARY_LINE = 'CSPeach summarised the earlier plan steps.';
24
+ function shaOf(content) {
25
+ return crypto.createHash('sha256').update(typeof content === 'string' ? content : JSON.stringify(content ?? null)).digest('hex').slice(0, 32);
26
+ }
27
+ function capped(list) {
28
+ return list.length > CLI_RECORD_CAP ? list.slice(list.length - CLI_RECORD_CAP) : list;
29
+ }
30
+ /** Note that CSPeach wrote this user-role message. Never throws. */
31
+ export function markCliMessage(session, content, kind, line) {
32
+ if (!session)
33
+ return;
34
+ try {
35
+ const s = session;
36
+ s.cli_messages = capped([...(s.cli_messages ?? []), { sha: shaOf(content), kind, line }]);
37
+ }
38
+ catch { /* the record is best-effort */ }
39
+ }
40
+ /**
41
+ * Fixed-shape CLI texts, for sessions written before the record existed (or
42
+ * a mark the cap dropped). Prefixes only; the bodies are CSPeach's own words.
43
+ */
44
+ const KNOWN_PREFIXES = [
45
+ { prefix: 'Resume execution of the project plan', kind: 'plan-step', line: 'CSPeach ran the next plan step.' },
46
+ { prefix: 'Your previous response was cut off at the output-token limit.', kind: 'continue', line: CONTINUE_LINE },
47
+ { prefix: '[CSPeach watchdog]', kind: 'watchdog', line: WATCHDOG_LINE },
48
+ { prefix: '[Context summary auto-generated', kind: 'compact', line: COMPACT_LINE },
49
+ { prefix: '[Summary of the earlier steps of this plan', kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE },
50
+ ];
51
+ function prefixMark(message) {
52
+ const text = typeof message.content === 'string' ? message.content
53
+ : Array.isArray(message.content) ? message.content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n') : '';
54
+ const t = text.trimStart();
55
+ const known = KNOWN_PREFIXES.find((k) => t.startsWith(k.prefix));
56
+ return known ? { sha: '', kind: known.kind, line: known.line } : undefined;
57
+ }
58
+ /**
59
+ * The mark of each message (undefined for the developer's own words), in
60
+ * message order. Fix round 2 (m-8): each mark is used once, in order — a
61
+ * message takes the first unused mark at or after the last one used that has
62
+ * its sha. So a command the developer retypes after CSPeach queued it, or a
63
+ * typed "continue" equal to an earlier steer, stays the developer's: that mark
64
+ * is already used. Marks of messages a compaction dropped are simply passed
65
+ * over. The prefix fallback is only for sessions with no records at all.
66
+ */
67
+ export function resolveCliMarks(session, messages) {
68
+ const marks = session?.cli_messages;
69
+ if (!marks || marks.length === 0)
70
+ return messages.map((m) => (m.role === 'user' ? prefixMark(m) : undefined));
71
+ // Final-review fix (I-1): used once, any order. Look from the last mark used
72
+ // on; when nothing matches there, look again from the start (a file an older
73
+ // CLI compacted has the summary's mark last). A used mark is never reused.
74
+ const used = new Set();
75
+ let next = 0;
76
+ return messages.map((m) => {
77
+ if (m.role !== 'user')
78
+ return undefined;
79
+ const sha = shaOf(m.content);
80
+ const at = (from, to) => {
81
+ for (let j = from; j < to; j++)
82
+ if (!used.has(j) && marks[j].sha === sha)
83
+ return j;
84
+ return -1;
85
+ };
86
+ let j = at(next, marks.length);
87
+ if (j < 0)
88
+ j = at(0, Math.min(next, marks.length));
89
+ if (j < 0)
90
+ return undefined;
91
+ used.add(j);
92
+ next = j + 1;
93
+ return marks[j];
94
+ });
95
+ }
96
+ /**
97
+ * Final-review fix (I-1): after a compaction keeps `kept` (the tail of the old
98
+ * messages, from index `keepFrom`) behind a new summary message, rebuild the
99
+ * record in message order: the summary's mark first, then the marks of the
100
+ * kept messages (recomputed from their content, so a mark found by the prefix
101
+ * fallback in an older file becomes a real one). Marks of dropped messages go.
102
+ */
103
+ export function rebuildCliMarksAfterCompact(session, oldMessages, keepFrom, summaryContent, summary) {
104
+ try {
105
+ const resolved = resolveCliMarks(session, oldMessages);
106
+ const kept = [];
107
+ for (let i = keepFrom; i < oldMessages.length; i++) {
108
+ const m = resolved[i];
109
+ if (m)
110
+ kept.push({ sha: shaOf(oldMessages[i].content), kind: m.kind, line: m.line });
111
+ }
112
+ session.cli_messages = capped([{ sha: shaOf(summaryContent), kind: summary.kind, line: summary.line }, ...kept]);
113
+ }
114
+ catch { /* the record is best-effort */ }
115
+ }
116
+ /** A turn of its own (the developer's prompt, a plan step, a queued step) vs. a message inside a turn. */
117
+ export function startsTurn(mark) {
118
+ return !mark || mark.kind === 'plan-step' || mark.kind === 'queued';
119
+ }
120
+ /* ── cards ────────────────────────────────────────────────────────────── */
121
+ let bound = null;
122
+ /** The REPL (and each top-level turn) binds the session the cards belong to. */
123
+ export function bindCliCardSession(session) { bound = session; }
124
+ /**
125
+ * The card ids the card slot records for the export: plan startup, lane,
126
+ * journey. Never save or Word cards. "Build this now?" (app-start-build) is
127
+ * recorded by afterPlanSave itself, which knows the plan file and whether the
128
+ * object-list approval was installed (fix round 2, N-1).
129
+ */
130
+ export function isRecordedCard(id) {
131
+ return id === 'plan-continue' || id === 'app-lane' || id === 'app-lane-runtime' || id.startsWith('journey-');
132
+ }
133
+ /** Record one card on the bound session (or the one given), with the time. Never throws. */
134
+ export function recordCliCard(card, session = bound) {
135
+ if (!session)
136
+ return;
137
+ try {
138
+ const s = session;
139
+ const entry = { turn_seq: s.turn_seq ?? 0, at: new Date().toISOString(), ...card };
140
+ s.cli_cards = capped([...(s.cli_cards ?? []), entry]);
141
+ }
142
+ catch { /* the record is best-effort */ }
143
+ }
144
+ /* ── once-per-object document offers ──────────────────────────────────── */
145
+ /**
146
+ * Per session (in memory): the (skill, object) keys the newer document skills
147
+ * already offered "Save…?" for. Kept here, beside the records, so /new can
148
+ * clear it (final-review m-4).
149
+ */
150
+ const documentOffers = new WeakMap();
151
+ export function documentOffersFor(session) {
152
+ let s = documentOffers.get(session);
153
+ if (!s) {
154
+ s = new Set();
155
+ documentOffers.set(session, s);
156
+ }
157
+ return s;
158
+ }
159
+ /** /new: the once-per-object offers and the records of the cleared conversation go too. */
160
+ export function clearConversationRecords(session) {
161
+ documentOffers.delete(session);
162
+ const s = session;
163
+ delete s.cli_messages;
164
+ delete s.cli_cards;
165
+ }
@@ -1,3 +1,4 @@
1
+ import { clearConversationRecords } from './cli-record.js';
1
2
  export function startFreshConversation(session) {
2
3
  session.messages = [];
3
4
  session.turnInterrupted = false;
@@ -6,6 +7,9 @@ export function startFreshConversation(session) {
6
7
  delete session.lastAutoCompactTurn;
7
8
  // An open plan step does not survive: "continue" must not resume it.
8
9
  session.plan_in_flight = null;
10
+ // Word export (final-review m-4): the once-per-object save offers and the
11
+ // export records describe the cleared conversation; they go with it.
12
+ clearConversationRecords(session);
9
13
  }
10
14
  /** The one line /new prints. */
11
15
  export function freshStartText(lane) {
@@ -45,5 +45,7 @@ export function queuedStepView(origin) {
45
45
  if (!line)
46
46
  return { echo: false, line: null };
47
47
  // D11 — the chat-fallback line is dim; painted after shownUpTo flattened it.
48
- return { echo: false, line: origin.by === 'chat_fallback' ? paint('dim', line) : line };
48
+ // LT-4 (1A fix wave, owner ruling): the hand-off arrow line is dim too, kept in scrollback.
49
+ const dim = origin.by === 'chat_fallback' || origin.by === 'hand_off';
50
+ return { echo: false, line: dim ? paint('dim', line) : line };
49
51
  }
@@ -26,8 +26,17 @@ export function isUserPrompt(m) {
26
26
  * (`<session_context>` from the intent extractor / SAP system info,
27
27
  * `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
28
28
  * them, so a prompt preview must never show them (fix round 3, item 2).
29
+ * Stage 1A (M5) — `<skill>`, the playbook block an inline turn appends. The
30
+ * order matters: `<session_context>` first, so the bare `<skill>` word in a
31
+ * `<playbook>` line goes with the block it sits in — `skill` stays last.
32
+ * Shared with the conversation export (commands/export-conversation.ts).
29
33
  */
30
- const INJECTED_CONTEXT_TAGS = ['session_context', 'project_context'];
34
+ export const INJECTED_CONTEXT_TAGS = [
35
+ 'session_context', 'project_context',
36
+ // The /export docx conversation list (main, 2026-10-23): one list for both readers.
37
+ 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
38
+ 'skill',
39
+ ];
31
40
  /**
32
41
  * One-line preview of what the user typed: injected context blocks removed,
33
42
  * `<attached file="x">…</attached>` expansions shown as the original `@x`
@@ -4,6 +4,7 @@ import { sha512 } from '@noble/hashes/sha512';
4
4
  import { bytesToHex, concatBytes } from '@noble/hashes/utils';
5
5
  import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
6
6
  import { canonicalise } from './canonical.js';
7
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
7
8
  // @noble/ed25519 v2 requires an ed25519 sha512 hook.
8
9
  ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
9
10
  /**
@@ -39,7 +40,7 @@ export class ManifestClient {
39
40
  async fetchManifest() {
40
41
  let r;
41
42
  try {
42
- r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
43
+ r = await fetchWithConnectRetry(this.manifestUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
43
44
  }
44
45
  catch (e) {
45
46
  throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
@@ -99,7 +100,7 @@ export class ManifestClient {
99
100
  async fetchSkillBody(name, bodyUrl, opts) {
100
101
  let r;
101
102
  try {
102
- r = await fetch(bodyUrl, { signal: AbortSignal.timeout(10_000) });
103
+ r = await fetchWithConnectRetry(bodyUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
103
104
  }
104
105
  catch (e) {
105
106
  throw new Error(`skill body fetch failed (${bodyUrl}): ${e.message}`);
@@ -52,8 +52,10 @@ take precedence if a skill's instruction conflicts with them.
52
52
  revise and re-submit.
53
53
 
54
54
  ## Transport discipline
55
- - All writes go into the active session transport. Do not ask the user
56
- for a transport number per change.
55
+ - All writes go into the session transport once one is set. Do not ask
56
+ the user for a transport number per change. When a create says a
57
+ transport is needed, ask the user once: a new session transport
58
+ (recommended) or one of the open requests for the package.
57
59
  - Never use \$TMP unless the user explicitly requests local-only work.
58
60
 
59
61
  ## Escalate, don't fail silently
@@ -70,6 +72,6 @@ take precedence if a skill's instruction conflicts with them.
70
72
 
71
73
  ## One skill per turn
72
74
  - If the user's request is better served by a different skill, call
73
- switch_skill with a clear reason. Do not try to do the work of another
74
- skill yourself.
75
+ dispatch_skill after the person has picked the command. Do not try to do
76
+ the work of another skill yourself.
75
77
  `;
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
14
14
  import { bytesToHex } from '@noble/hashes/utils';
15
15
  import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
16
16
  import { fileSkillsCache } from './skills-cache.js';
17
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
17
18
  // Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
18
19
  export { SkillIntegrityError };
19
20
  export class SkillNotInPlanError extends Error {
@@ -69,10 +70,10 @@ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch)
69
70
  throw new SkillSignInError();
70
71
  let r;
71
72
  try {
72
- r = await doFetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
73
+ r = await fetchWithConnectRetry(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, () => ({
73
74
  headers: { Authorization: `Bearer ${bearer}` },
74
75
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
- });
76
+ }), { fetchImpl: doFetch });
76
77
  }
77
78
  catch {
78
79
  throw new SkillsUnavailableError('could not reach CSPeach');
@@ -6,6 +6,14 @@ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
6
6
  import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
7
7
  import { objectsWithSyntaxErrors } from './syntax-state.js';
8
8
  import { noteWriteTransport } from '../repl/current-transport.js';
9
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
10
+ /**
11
+ * LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
12
+ * card opens and no approval id is minted. The shape dispatchTool returns for
13
+ * a tool that never started (the tool_use stays paired), with a text that says
14
+ * nothing ran.
15
+ */
16
+ const stoppedBeforeCard = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
9
17
  /**
10
18
  * Round 8 item 6 — the footer showed "no transport" while the approved card
11
19
  * said "transport S4HK903731". An approval granted for an explicit transport
@@ -29,7 +37,7 @@ import { promptAdvisory } from '../approvals/advisory-prompt.js';
29
37
  import { markPlanGateApproved } from '../repl/rule8-detector.js';
30
38
  import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
31
39
  import { getCurrentTransport } from '../repl/current-transport.js';
32
- import { displayTransport } from '../approvals/op-labels.js';
40
+ import { displayTransport, knownTypeWord } from '../approvals/op-labels.js';
33
41
  import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
34
42
  import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
35
43
  import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
@@ -223,7 +231,8 @@ registerTool({
223
231
  type: 'object',
224
232
  properties: {
225
233
  op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
226
- object: { type: 'string' },
234
+ // P-2 (1A fix wave): a group word ('DCLs') is not an object the plan grant can match.
235
+ object: { type: 'string', description: "One real object name per row (comma-join several only in an activate row), never a group word such as 'DCLs'." },
227
236
  type: { type: 'string' },
228
237
  diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
229
238
  package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
@@ -298,6 +307,10 @@ registerTool({
298
307
  // no role configured and no session override this is exactly
299
308
  // cfg.write_mode — byte-identical behavior.
300
309
  const role = cfg.sap[ctx.sapAlias]?.role;
310
+ // LT-15 D6 — stopped during the profile read above: no advisory card, no card.
311
+ const stopA = stoppedBeforeCard(ctx);
312
+ if (stopA)
313
+ return stopA;
301
314
  // Advisory-only short-circuit — never mint approval_ids.
302
315
  // The developer applies the change manually in ADT; the AI gets an
303
316
  // ai_directive telling it not to attempt any SAP write tools.
@@ -313,11 +326,17 @@ registerTool({
313
326
  return { content: JSON.stringify(releaseRefusal), is_error: true };
314
327
  // Task 8 (B11) — the request that already holds each modified object, before any card.
315
328
  await attachOwningTransport(args.changes ?? [], ctx);
329
+ // LT-15 D6 — stopped during the reads above: no card, no nag, no id minted
330
+ // (the plan-grant and auto-approve paths below mint without a card).
331
+ const stopB = stoppedBeforeCard(ctx);
332
+ if (stopB)
333
+ return stopB;
316
334
  let sapCfg = cfg.sap[ctx.sapAlias];
317
335
  const riskCtx = {
318
336
  sapAlias: ctx.sapAlias,
319
337
  autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
320
338
  syntaxErrorObjects: objectsWithSyntaxErrors(),
339
+ typeWord: knownTypeWord, // LT-10: the syntax-errors reason names the kind of object
321
340
  };
322
341
  const changes = args.changes;
323
342
  const declared = args.risk;
@@ -421,6 +440,10 @@ registerTool({
421
440
  sapCfg = cfg.sap[ctx.sapAlias];
422
441
  riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
423
442
  }
443
+ // LT-15 D6 (review M3) — stopped while the nag was up: no card after it, no id.
444
+ const stopC = stoppedBeforeCard(ctx);
445
+ if (stopC)
446
+ return stopC;
424
447
  // 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
425
448
  // yes. Live, auto_approve="low" let the model's own "low" create package
426
449
  // ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
@@ -478,12 +501,14 @@ registerTool({
478
501
  // for one task" complaint. Review-each falls back to the per-change
479
502
  // gate. Cancel rejects the whole plan.
480
503
  //
481
- // Task 9 (C8): only for more than one VISIBLE change — the same count the
482
- // plan gate shows (an activate whose write is in the list is not a change
483
- // a person sees). A modify + its activate on one class gets the
484
- // per-change card only; the activate follows its approved write below.
504
+ // Task 9 (C8): only for more than one VISIBLE change (an activate whose
505
+ // write is in the list is not a change a person sees). A modify + its
506
+ // activate on one class gets the per-change card only; the activate
507
+ // follows its approved write below. LT-12: the plan gate's LIST also
508
+ // hides a modify that a create of the same object covers (planRows), so
509
+ // it may show fewer rows than this count; this count is unchanged.
485
510
  let skipPerChangeGates = false;
486
- // Raw keys on purpose: the same collapse (and count) the plan gate displays.
511
+ // Raw keys on purpose: activates collapsed, nothing else.
487
512
  const visibleCount = collapseImplicitActivates(changes).length;
488
513
  if (visibleCount > 1) {
489
514
  const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
@@ -549,6 +574,11 @@ registerTool({
549
574
  outcome = { approved: false, reason: 'sibling_write_skipped' };
550
575
  }
551
576
  else {
577
+ // LT-15 D6 (review M3) — stopped during the mint after the card before:
578
+ // the next card does not open; ids minted so far are dropped unused.
579
+ const stopD = stoppedBeforeCard(ctx);
580
+ if (stopD)
581
+ return stopD;
552
582
  outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
553
583
  }
554
584
  if (outcome.approved) {
@@ -44,7 +44,7 @@ registerTool({
44
44
  properties: {
45
45
  command: {
46
46
  type: 'string',
47
- description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
47
+ description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`. Never put a transport description in it unless the person typed it.',
48
48
  },
49
49
  },
50
50
  required: ['command'],
@@ -5,6 +5,9 @@
5
5
  * chat turn then ends and the workflow runs with its own plan + approvals.
6
6
  * No extra consent card (D5): the workflow asks before any write.
7
7
  * Refused outside the chat lane; dispatch_skill stays the skills' own switch.
8
+ * Stage 1A (T1A.4): in an inline session (`ctx.inlineSkills`) a playbook turn
9
+ * may hand off too — still only queues — except while a plan is in charge or
10
+ * the slot already holds a step (the harness's next-phase hop wins).
8
11
  *
9
12
  * Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
10
13
  * session never lists it and its tools array stays byte-identical to today.
@@ -14,7 +17,37 @@ import { isPlanResumeCommand } from '../commands/plan-resume.js';
14
17
  import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
15
18
  /** Longest request hand_off queues (after whitespace is collapsed). */
16
19
  export const HAND_OFF_REQUEST_MAX = 500;
20
+ /**
21
+ * I3 (owner, 2026-10-22) — one hand-off per person message. Refusal code when
22
+ * the running turn was itself started by a hand_off: no workflow-to-workflow
23
+ * chain of reads and credits without a new person message. A new person
24
+ * message resets it (the REPL passes who started each turn as `ctx.turnBy`).
25
+ * dispatch_skill after the person's pick is not capped.
26
+ */
27
+ export const HAND_OFF_CHAIN_CAPPED = 'HAND_OFF_CHAIN_CAPPED';
17
28
  export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
29
+ /** The success note when the model wrote nothing before the call in this turn. */
30
+ export const HAND_OFF_NOTE = 'The workflow runs next and asks before it writes. End the turn now. If you wrote a line before the call, add nothing. If you wrote nothing, add one short line saying the workflow takes it from here. Do not print the command.';
31
+ /**
32
+ * LT-3 (live check 2026-10-26) — the model often skipped the "if you wrote a
33
+ * line" test and announced the hand-off twice. The loop knows whether text
34
+ * came first in this turn and then gives this plain note instead.
35
+ */
36
+ export const HAND_OFF_NOTE_ALREADY_SAID = 'The workflow runs next and asks before it writes. You already told the developer in this turn, so end the turn now and write nothing more. Do not print the command.';
37
+ /** The hand_off success content with the note for a turn that already said its line. Anything else unchanged. */
38
+ export function settleHandOffNote(content, saidBefore) {
39
+ if (!saidBefore)
40
+ return content;
41
+ try {
42
+ const r = JSON.parse(content);
43
+ if (r.ok !== true || r.note !== HAND_OFF_NOTE)
44
+ return content;
45
+ return JSON.stringify({ ...r, note: HAND_OFF_NOTE_ALREADY_SAID });
46
+ }
47
+ catch {
48
+ return content;
49
+ }
50
+ }
18
51
  export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
19
52
  const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
20
53
  /**
@@ -45,15 +78,25 @@ registerTool({
45
78
  type: 'object',
46
79
  properties: {
47
80
  skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
48
- request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
81
+ request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags. Never put a transport description in it unless the person typed it.' },
49
82
  },
50
83
  required: ['skill', 'request'],
51
84
  },
52
85
  handler: async (args, ctx) => {
53
86
  const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
54
- if (ctx.session?.lane !== 'chat') {
87
+ // Stage 1A (T1A.4) — in an inline session a playbook turn may hand off too.
88
+ const playbookTurn = ctx.session?.lane !== 'chat';
89
+ if (playbookTurn && !ctx.inlineSkills) {
55
90
  return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
56
91
  }
92
+ // The plan harness queues its next phase through the same slot; never race it.
93
+ if (playbookTurn && (ctx.session?.plan_in_flight || ctx.session?.skill === 'abap-plan')) {
94
+ return bad('HAND_OFF_PLAN_IN_FLIGHT', 'finish this step; after the plan, the person can ask for that in chat');
95
+ }
96
+ // I3 — one hand-off per person message.
97
+ if (ctx.turnBy === 'hand_off') {
98
+ return bad(HAND_OFF_CHAIN_CAPPED, "this workflow was already handed off from the person's message; one hand-off per message. Finish this workflow's answer, then let the person decide the next step: offer that workflow as a suggestion in your answer instead of calling hand_off. Do not call dispatch_skill either unless the person picks it.");
99
+ }
57
100
  const skill = normaliseSkill(args.skill);
58
101
  if (!skill) {
59
102
  return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
@@ -74,13 +117,12 @@ registerTool({
74
117
  if (!ctx.pendingDispatch) {
75
118
  return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
76
119
  }
120
+ if (playbookTurn && ctx.pendingDispatch.get() !== null) {
121
+ return bad('HAND_OFF_SLOT_TAKEN', 'a step is already queued; end the turn');
122
+ }
77
123
  ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
78
124
  return {
79
- content: JSON.stringify({
80
- ok: true,
81
- queued: command,
82
- note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
83
- }),
125
+ content: JSON.stringify({ ok: true, queued: command, note: HAND_OFF_NOTE }),
84
126
  };
85
127
  },
86
128
  });
@@ -36,6 +36,9 @@ import { askConfirm } from '../../ui/confirm-request.js';
36
36
  import { isHeadless } from '../../renderer/tty.js';
37
37
  import { loadConfig } from '../../config/loader.js';
38
38
  import { getSapSystemInfo } from '../../sap/system-info.js';
39
+ import { STOPPED_BEFORE_START_RESULT } from '../../agent/repair-partial.js';
40
+ /** LT-15 D6 (review M2) — after a stop (Esc / Ctrl+C) no card opens and nothing is saved. */
41
+ const stopped = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
39
42
  const fail = (message) => ({ content: `error: ${message}`, is_error: true });
40
43
  const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
41
44
  const STOP = 'STOP — lane needs your choice.';
@@ -200,6 +203,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
200
203
  }
201
204
  else {
202
205
  // 7. The lane card.
206
+ const stopLane = stopped(ctx);
207
+ if (stopLane)
208
+ return stopLane;
203
209
  const confirm = deps.confirm ?? ((req) => askConfirm(req));
204
210
  const card = laneCardRequest(decision);
205
211
  const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
@@ -224,6 +230,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
224
230
  }
225
231
  // 8. A CAP lane needs a runtime.
226
232
  if (finalLane.backend === 'cap' && !runtime) {
233
+ const stopRuntime = stopped(ctx);
234
+ if (stopRuntime)
235
+ return stopRuntime;
227
236
  const rr = await confirm(runtimeCardRequest());
228
237
  const answer = rr.value;
229
238
  if (!answer || !RUNTIMES.includes(answer))