@cspeach/cli 1.1.17 → 1.1.19

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 (225) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +80 -22
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,16 @@
1
+ // cspeach-cli/src/tools/fiori/ui5-version.ts
2
+ //
3
+ // THE one pinned UI5 version: the default both scaffolds write into
4
+ // manifest.json (minUI5Version) and the version the offline smoke harness
5
+ // serves (test-harness/fe-draft/routes.ts imports it as its "newest" pin).
6
+ //
7
+ // Why an exact, hosted patch and never an `x.y.0` guess: fiori-tools-proxy
8
+ // fetches `https://ui5.sap.com/<minUI5Version>/resources/...` verbatim, and SAP
9
+ // removes old patch releases from that CDN. The old default `1.120.0` was gone
10
+ // on 2026-09-21 (sap/m/library.js → 404): every freshly scaffolded app booted
11
+ // to a blank page. When SAP retires this patch too, bump the patch — the
12
+ // smoke-e2e run fails with a message pointing here.
13
+ //
14
+ // Why 1.120: the S/4HANA 2023-era LTS; it parses the hyphenated
15
+ // `data-sap-ui-on-init` bootstrap attribute the writers emit (1.71 does not).
16
+ export const PINNED_UI5_VERSION = '1.120.51';
@@ -20,6 +20,14 @@ export function listTools() {
20
20
  // always returned, preserving today's listTools() semantics exactly.
21
21
  return Array.from(registry.values()).filter((t) => !t.flagGated || isToolFlagOn(t.name));
22
22
  }
23
+ /**
24
+ * Every registered tool, flag-gated ones included (no visibility filter).
25
+ * For coverage checks (e.g. every tool has a transcript label); callers that
26
+ * send tools to the model must keep using listTools().
27
+ */
28
+ export function listAllTools() {
29
+ return Array.from(registry.values());
30
+ }
23
31
  /**
24
32
  * Reserved alias for standalone mode — CSPeach running with no SAP system.
25
33
  * Set at the single ctx-construction site in repl.tsx / one-shot.ts alongside
@@ -12,6 +12,9 @@
12
12
  * shell: shell_exec
13
13
  * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
14
14
  * fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
15
+ * fiori_preview (L1-6 — the preview server every browser check
16
+ * depends on), fiori_metadata_audit, fiori_draft_smoke
17
+ * (L1-5b — a SAP write, approval-gated like every write),
15
18
  * fiori_sample_search, fiori_sample_get
16
19
  * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
17
20
  * subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
@@ -63,6 +66,9 @@ export const LOCAL_BUILD_TOOLS = [
63
66
  'fiori_scaffold_fe',
64
67
  'fiori_fe_extend',
65
68
  'fiori_render_smoke',
69
+ 'fiori_preview',
70
+ 'fiori_metadata_audit',
71
+ 'fiori_draft_smoke',
66
72
  'fiori_sample_search',
67
73
  'fiori_sample_get',
68
74
  'extend_model_insert',
@@ -22,6 +22,8 @@
22
22
  * badiList(query, enhancementSpot?, maxResults?)
23
23
  */
24
24
  import { registerTool } from './index.js';
25
+ import { normaliseSyntaxResult, checkSyntax } from './verify.js';
26
+ import { recordSyntaxCheck } from './syntax-state.js';
25
27
  // -----------------------------------------------------------------------
26
28
  // 1. sap_get_source
27
29
  // -----------------------------------------------------------------------
@@ -455,7 +457,20 @@ registerTool({
455
457
  },
456
458
  handler: async (args, ctx) => {
457
459
  // syntaxCheck(type, name) — type first
458
- const result = await ctx.adt.syntaxCheck(args.type, args.name);
460
+ // Fails closed (fix round 7, item 1): the model must never read a
461
+ // syntax error as clean — see normaliseSyntaxResult.
462
+ let raw;
463
+ try {
464
+ raw = await checkSyntax(ctx.adt, args.type, args.name); // A-I2: notProcessed retry
465
+ }
466
+ catch (err) {
467
+ // Re-review note — a check that did not run is "not clean": never leave
468
+ // an older clean result in the syntax state.
469
+ recordSyntaxCheck(args.type, args.name, true);
470
+ throw err;
471
+ }
472
+ const result = normaliseSyntaxResult(raw);
473
+ recordSyntaxCheck(args.type, args.name, result.hasErrors); // round 7 item 8e
459
474
  return { content: JSON.stringify(result, null, 2) };
460
475
  },
461
476
  });
@@ -22,12 +22,17 @@
22
22
  * auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
23
23
  */
24
24
  import crypto from 'node:crypto';
25
+ import { readFileSync } from 'node:fs';
25
26
  import { registerTool } from './index.js';
26
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
27
28
  import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
28
29
  import { includeSnapshotType } from './include-snapshot.js';
29
30
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
31
+ import { buildChangeCard } from '../renderer/change-card.js';
32
+ import { getLook } from '../renderer/look.js';
33
+ import { getEffectiveBodyWidth } from '../renderer/tty.js';
30
34
  import { verifySyntax, verifyActive } from './verify.js';
35
+ import { hasRecordedSyntaxErrorsByName, clearSyntaxErrorsAfterActivation } from './syntax-state.js';
31
36
  import { appendPending, finalizeToolCall } from '../session/pending.js';
32
37
  import { auditLog } from '@cspeach/sap-client';
33
38
  import { ERR } from '../errors/codes.js';
@@ -79,9 +84,70 @@ function snapshotChainState(snap) {
79
84
  }
80
85
  return { snapshot: 'best_effort_failed' };
81
86
  }
82
- /** Compact ①②③④ pipeline summary — derived from the real gate results. */
87
+ /**
88
+ * Review r7 I-3 — sap-client 0.1.15's setSource/updateMethod run their own
89
+ * (correct) syntax check and, on errors, return a 2xx writeStatus with
90
+ * `syntaxResult.hasErrors` and `error: 'Syntax check found errors —
91
+ * activation skipped'`. The source IS written (an inactive version exists):
92
+ * that is written-with-errors — the normal Rule 10 gate below reports it —
93
+ * never write_failed.
94
+ */
95
+ function writtenWithSyntaxErrors(r) {
96
+ return r.writeStatus !== undefined && r.writeStatus >= 200 && r.writeStatus < 300 && r.syntaxResult?.hasErrors === true;
97
+ }
98
+ async function verifyWriteActivation(ctx, r, name, type) {
99
+ const a = r.activationResult;
100
+ if (!a)
101
+ return undefined;
102
+ const errors = (a.messages ?? []).filter((m) => m.severity === 'error').map((m) => m.shortText ?? '').filter(Boolean).slice(0, 5);
103
+ if (!a.success)
104
+ return { success: false, verified: false, errors };
105
+ const v = await verifyActive(ctx.adt, name, type);
106
+ if (v.verifyError !== undefined)
107
+ return { success: true, verified: false, errors, verifyError: v.verifyError };
108
+ return v.active ? { success: true, verified: true, errors } : { success: true, verified: false, errors, stillInactive: true };
109
+ }
110
+ /** Round 8 item 5 + A-I1 — the change-frame state from the verified activation. */
111
+ function chainActivation(w) {
112
+ if (!w)
113
+ return undefined;
114
+ if (!w.success)
115
+ return { state: 'failed', detail: w.errors[0] };
116
+ if (w.verified)
117
+ return { state: 'active' };
118
+ return w.stillInactive ? { state: 'still_inactive' } : { state: 'unverified' };
119
+ }
120
+ function activatedByWrite(w) {
121
+ if (!w)
122
+ return {};
123
+ return { activated_by_write: { success: w.success, verified: w.verified, errors: w.errors } };
124
+ }
125
+ /** A-I1 minor — after a verified activation on write, say so instead of "activation_allowed". */
126
+ function nextStepAfterWrite(syntaxOk, w) {
127
+ if (!syntaxOk)
128
+ return 'activation_blocked_until_syntax_errors_fixed';
129
+ if (w?.success && w.verified)
130
+ return 'already activated by the write — verify with sap_inactive_objects before reporting it active';
131
+ return 'activation_allowed';
132
+ }
133
+ /** Compact ①②③④ pipeline summary — derived from the real gate results.
134
+ * Panels look: a change card (same honest chain in its footer). */
83
135
  function emitWriteChain(ctx, input) {
84
- ctx.chunkEmitter?.emit('chunk', buildWriteChainLine(input) + '\n');
136
+ const text = getLook() === 'panels' && input.card
137
+ ? buildChangeCard(input, getEffectiveBodyWidth())
138
+ : buildWriteChainLine(input);
139
+ ctx.chunkEmitter?.emit('chunk', text + '\n');
140
+ }
141
+ /** Old source for the change card: ONLY from a snapshot actually taken. */
142
+ function snapshotSource(snap) {
143
+ if (!snap?.taken || !snap.entry?.filePath)
144
+ return undefined;
145
+ try {
146
+ return readFileSync(snap.entry.filePath, 'utf8');
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
85
151
  }
86
152
  registerTool({
87
153
  name: 'sap_set_source',
@@ -165,6 +231,7 @@ registerTool({
165
231
  what_will_happen: 'Overwrites full object source; snapshot is taken first.',
166
232
  rollback_available: true,
167
233
  transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
234
+ args: { ...args },
168
235
  });
169
236
  if (!r.confirmed) {
170
237
  return {
@@ -201,7 +268,7 @@ registerTool({
201
268
  is_error: true,
202
269
  };
203
270
  }
204
- if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
271
+ if ((writeResult.error && !writtenWithSyntaxErrors(writeResult)) || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
205
272
  const detail = writeResult.error ?? `Write returned HTTP ${writeResult.writeStatus} but did not throw`;
206
273
  await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
207
274
  return {
@@ -218,11 +285,22 @@ registerTool({
218
285
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
219
286
  const syntax = await verifySyntax(ctx.adt, args.name, args.type);
220
287
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
288
+ const writeActivation = await verifyWriteActivation(ctx, writeResult, args.name, args.type);
221
289
  // Tour slide-7 chain — derived from the gates that actually ran.
290
+ // Old source is read (once) only for the panels card; legacy reads nothing extra.
291
+ const oldSource = getLook() === 'panels' ? snapshotSource(snapOutcome) : undefined;
222
292
  emitWriteChain(ctx, {
223
293
  ...snapshotChainState(snapOutcome),
224
294
  written: true,
225
295
  syntax: { ok: syntax.ok, errors: syntax.errors },
296
+ activation: chainActivation(writeActivation),
297
+ card: {
298
+ object: args.name,
299
+ what: 'source',
300
+ transport: resolved.transport,
301
+ oldSource,
302
+ newSource: oldSource !== undefined ? args.source : undefined,
303
+ },
226
304
  });
227
305
  return {
228
306
  content: JSON.stringify({
@@ -230,10 +308,9 @@ registerTool({
230
308
  syntax_ok: syntax.ok,
231
309
  errors: syntax.errors,
232
310
  warnings: syntax.warnings,
311
+ ...activatedByWrite(writeActivation),
233
312
  ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
234
- next_step: syntax.ok
235
- ? 'activation_allowed'
236
- : 'activation_blocked_until_syntax_errors_fixed',
313
+ next_step: nextStepAfterWrite(syntax.ok, writeActivation),
237
314
  }),
238
315
  };
239
316
  },
@@ -395,7 +472,7 @@ registerTool({
395
472
  is_error: true,
396
473
  };
397
474
  }
398
- if (updateResult.error || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
475
+ if ((updateResult.error && !writtenWithSyntaxErrors(updateResult)) || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
399
476
  const detail = updateResult.error ?? `Update returned HTTP ${updateResult.writeStatus} but did not throw`;
400
477
  await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
401
478
  return {
@@ -412,11 +489,14 @@ registerTool({
412
489
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
413
490
  const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
414
491
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
492
+ const writeActivation = await verifyWriteActivation(ctx, updateResult, args.class_name, 'CLAS');
415
493
  // Tour slide-7 chain — derived from the gates that actually ran.
416
494
  emitWriteChain(ctx, {
417
495
  ...snapshotChainState(snapOutcome),
418
496
  written: true,
419
497
  syntax: { ok: syntax.ok, errors: syntax.errors },
498
+ activation: chainActivation(writeActivation),
499
+ card: { object: args.class_name, what: `method ${args.method_name}`, transport: resolved.transport },
420
500
  });
421
501
  return {
422
502
  content: JSON.stringify({
@@ -424,10 +504,9 @@ registerTool({
424
504
  syntax_ok: syntax.ok,
425
505
  errors: syntax.errors,
426
506
  warnings: syntax.warnings,
507
+ ...activatedByWrite(writeActivation),
427
508
  ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
428
- next_step: syntax.ok
429
- ? 'activation_allowed'
430
- : 'activation_blocked_until_syntax_errors_fixed',
509
+ next_step: nextStepAfterWrite(syntax.ok, writeActivation),
431
510
  }),
432
511
  };
433
512
  },
@@ -496,6 +575,7 @@ registerTool({
496
575
  what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
497
576
  rollback_available: true,
498
577
  transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
578
+ args: { ...args },
499
579
  });
500
580
  if (!r.confirmed) {
501
581
  return {
@@ -785,6 +865,7 @@ registerTool({
785
865
  ...snapshotChainState(snapOutcome),
786
866
  written: true,
787
867
  syntax: 'not_checked',
868
+ card: { object: args.className, what: `include ${args.includeType}`, transport: resolved.transport },
788
869
  });
789
870
  return {
790
871
  content: JSON.stringify({
@@ -1107,6 +1188,33 @@ registerTool({
1107
1188
  is_error: true,
1108
1189
  };
1109
1190
  }
1191
+ // Round 7 item 8e — an approval auto_approve minted (no human saw it)
1192
+ // never activates an object whose last syntax check found errors. Any
1193
+ // approval minted after the errors went through the card (risk floor).
1194
+ // Final review A M-1 — match by NAME, like the risk floor: a type word can
1195
+ // map to a different code (DDLS recorded, activated as VIEW). Over-block,
1196
+ // never under-block; an auto approval can always be asked for again.
1197
+ const broken = args.objects
1198
+ .filter((o) => hasRecordedSyntaxErrorsByName(o.name)).map((o) => o.name.toUpperCase());
1199
+ if (verify.payload.auto) {
1200
+ if (broken.length > 0) {
1201
+ ctx.chunkEmitter?.emit('warn', `Activation blocked · syntax errors in ${broken.join(', ')}`);
1202
+ return {
1203
+ content: JSON.stringify({
1204
+ error: 'activation_blocked_syntax_errors',
1205
+ objects: broken,
1206
+ detail: 'The last syntax check of these objects found errors and this activation was auto-approved before that. '
1207
+ + 'Fix the errors (sap_syntax_check shows them) or ask for approval again.',
1208
+ }),
1209
+ is_error: true,
1210
+ };
1211
+ }
1212
+ }
1213
+ // Review r7 M-6 — a human-approved activation stays allowed (a mass
1214
+ // activation can clear cross-object errors), but it is never silent.
1215
+ for (const n of broken) {
1216
+ ctx.chunkEmitter?.emit('warn', `Activating ${n} · its last syntax check found errors · SAP will refuse it unless they are resolved`);
1217
+ }
1110
1218
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1111
1219
  const startedAt = Date.now();
1112
1220
  // ── WAL: record pending entry ────────────────────────────────────────────
@@ -1149,6 +1257,10 @@ registerTool({
1149
1257
  else if (!v.active) {
1150
1258
  stillInactive.push(`${obj.type}/${obj.name}`);
1151
1259
  }
1260
+ else if (activationResults.find((r) => r.name === obj.name && r.type === obj.type)?.success) {
1261
+ // Final review A M-2 — activated AND verified: the source compiles.
1262
+ clearSyntaxErrorsAfterActivation(obj.name);
1263
+ }
1152
1264
  }
1153
1265
  const overallSuccess = !anyError && stillInactive.length === 0 && unverifiable.length === 0;
1154
1266
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ overallSuccess, activationResults, stillInactive, unverifiable }), anyError, Date.now() - startedAt);
@@ -45,7 +45,10 @@ import * as path from 'node:path';
45
45
  import { registerTool } from '../index.js';
46
46
  import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
47
47
  import { loadSafelist, buildSafeEnv, resolveExecutable, validateArgv, rejectPathSeparator, DEFAULT_SAFELIST, KILL_GRACE_MS, } from '../_command-shared.js';
48
+ import { killProcessTree } from '../fiori/preview/registry.js';
48
49
  const DEFAULT_TIMEOUT_MS = 30_000;
50
+ /** After the kill's grace: how long to wait for the pipes before returning anyway. */
51
+ const ORPHAN_WAIT_MS = 3_000;
49
52
  const MAX_TIMEOUT_MS = 300_000;
50
53
  const MAX_OUTPUT_BYTES = 1_000_000; // per stream
51
54
  export async function shellExecHandler(args, ctx) {
@@ -171,18 +174,38 @@ export async function shellExecHandler(args, ctx) {
171
174
  stderrTrunc = true;
172
175
  }
173
176
  });
177
+ const timedOutResult = () => {
178
+ const tail = (stdoutBuf ? `stdout (partial):\n${stdoutBuf}\n${stdoutTrunc ? '[truncated]\n' : ''}` : '') +
179
+ (stderrBuf ? `stderr (partial):\n${stderrBuf}\n${stderrTrunc ? '[truncated]\n' : ''}` : '');
180
+ return { content: `error: command timed out after ${timeoutMs} ms — child killed.\n${tail}`, is_error: true };
181
+ };
174
182
  const timer = setTimeout(() => {
175
183
  timedOut = true;
176
- try {
177
- child.kill('SIGTERM');
184
+ if (process.platform === 'win32') {
185
+ // Dry run 2026-09-25: `npm start` = npm.cmd → cmd.exe → node. Killing the
186
+ // direct child left the server alive holding our pipes; 'close' never
187
+ // fired and a 45 s call returned after 21 minutes. Kill the whole tree.
188
+ void killProcessTree(child.pid, child);
178
189
  }
179
- catch { /* ignore */ }
180
- setTimeout(() => {
190
+ else {
181
191
  try {
182
- child.kill('SIGKILL');
192
+ child.kill('SIGTERM');
183
193
  }
184
194
  catch { /* ignore */ }
185
- }, KILL_GRACE_MS).unref();
195
+ setTimeout(() => {
196
+ try {
197
+ child.kill('SIGKILL');
198
+ }
199
+ catch { /* ignore */ }
200
+ }, KILL_GRACE_MS).unref();
201
+ }
202
+ // Never wait on an orphan: if the pipes are still open after the kill
203
+ // had its grace, stop reading and return the timeout result anyway.
204
+ setTimeout(() => {
205
+ child.stdout?.destroy();
206
+ child.stderr?.destroy();
207
+ settle(timedOutResult());
208
+ }, KILL_GRACE_MS + ORPHAN_WAIT_MS).unref();
186
209
  }, timeoutMs);
187
210
  timer.unref();
188
211
  child.on('error', (err) => {
@@ -192,12 +215,7 @@ export async function shellExecHandler(args, ctx) {
192
215
  child.on('close', (code, signal) => {
193
216
  clearTimeout(timer);
194
217
  if (timedOut) {
195
- const tail = (stdoutBuf ? `stdout (partial):\n${stdoutBuf}\n${stdoutTrunc ? '[truncated]\n' : ''}` : '') +
196
- (stderrBuf ? `stderr (partial):\n${stderrBuf}\n${stderrTrunc ? '[truncated]\n' : ''}` : '');
197
- settle({
198
- content: `error: command timed out after ${timeoutMs} ms — child killed.\n${tail}`,
199
- is_error: true,
200
- });
218
+ settle(timedOutResult());
201
219
  return;
202
220
  }
203
221
  const stdoutSection = `stdout:\n${stdoutBuf}${stdoutTrunc ? '\n[truncated — exceeded 1 MB cap]' : ''}`;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Round 7 item 8e — which objects' LAST syntax check found errors (this CLI
3
+ * process). Written by every syntax check the CLI runs (the Rule 10 write
4
+ * gate and the sap_syntax_check tool); read by the approval risk floor (such
5
+ * an object never auto-approves) and by sap_activate (an automatically minted
6
+ * approval never activates it). A clean check clears the object.
7
+ *
8
+ * Review r7 M-3 — keyed by object TYPE and name: a PROG and a CLAS with the
9
+ * same name never share the flag. `PROG/P` and `PROG` are the same type.
10
+ *
11
+ * Re-review 2 item 2 — a name index of flagged objects. When either side's
12
+ * type is not a known ADT code (a recorded "ABAP CLASS", a queried "some
13
+ * thing"), the object is matched by name alone: over-match, never miss.
14
+ */
15
+ import { normaliseAdtType } from '../approvals/adt-type.js';
16
+ const UNKNOWN = '?';
17
+ /** name → the type codes (or UNKNOWN) flagged under that name. */
18
+ const byName = new Map();
19
+ const nameOf = (name) => name.trim().toUpperCase();
20
+ const codeOf = (type) => normaliseAdtType(type) ?? UNKNOWN;
21
+ export function recordSyntaxCheck(type, name, hasErrors) {
22
+ const n = nameOf(name);
23
+ const c = codeOf(type);
24
+ const set = byName.get(n) ?? new Set();
25
+ if (hasErrors)
26
+ set.add(c);
27
+ else {
28
+ set.delete(c);
29
+ // Final review A M-3 — a clean check under a KNOWN code also clears an
30
+ // entry recorded under an unknown type word for the same name.
31
+ if (c !== UNKNOWN)
32
+ set.delete(UNKNOWN);
33
+ }
34
+ if (set.size > 0)
35
+ byName.set(n, set);
36
+ else
37
+ byName.delete(n);
38
+ }
39
+ export function hasRecordedSyntaxErrors(type, name) {
40
+ const set = byName.get(nameOf(name));
41
+ if (!set || set.size === 0)
42
+ return false;
43
+ const c = codeOf(type);
44
+ return c === UNKNOWN || set.has(c) || set.has(UNKNOWN);
45
+ }
46
+ /** Final review A M-1 — any flagged entry with this name, whatever its type. */
47
+ export function hasRecordedSyntaxErrorsByName(name) {
48
+ return (byName.get(nameOf(name))?.size ?? 0) > 0;
49
+ }
50
+ /**
51
+ * Final review A M-2 — a successful activation verified against the
52
+ * inactive-objects list proves the current source compiles: clear the name.
53
+ */
54
+ export function clearSyntaxErrorsAfterActivation(name) {
55
+ byName.delete(nameOf(name));
56
+ }
57
+ /** `TYPE:NAME` keys (TYPE is an ADT code, or `?` when the type word was unknown). */
58
+ export function objectsWithSyntaxErrors() {
59
+ const out = [];
60
+ for (const [n, set] of byName)
61
+ for (const c of set)
62
+ out.push(`${c}:${n}`);
63
+ return out;
64
+ }
65
+ export function resetSyntaxStateForTest() {
66
+ byName.clear();
67
+ }
@@ -7,14 +7,14 @@
7
7
  *
8
8
  * Semantics: FULL-LIST REPLACE on every call — never a delta. 1..20 items.
9
9
  *
10
- * Transcript render (CC-style, 2026-07): on every successful set the
11
- * handler also emits the FULL checklist as a scrollback block via
12
- * ctx.chunkEmitter (renderer/todo-block.ts). The raw ⏺/⎿ todo_set rows are
13
- * widget-suppressed (renderer/tool-widget.ts) so the block is the sole
14
- * visible representation — the transcript now shows the whole plan
15
- * advancing, not a raw `⏺ todo_set({"todos":[…])` JSON line. This is the
16
- * Claude-Code parity the owner asked for; the turn-status strip (single ▶
17
- * row) and Ctrl+T panel stay exactly as they were.
10
+ * Transcript render (piece 2, spec §4 — was CC-style 2026-07): WHERE the
11
+ * checklist is drawn depends on the render mode (renderer/checklist-format.ts
12
+ * checklistRenderMode). Ink draws a LIVE checklist (ui/checklist.tsx) that
13
+ * redraws in place, with one close line at turn end; classic interactive
14
+ * prints the block once at turn end (repl/plan-turn-end.ts); only headless /
15
+ * one-shot emit the per-set scrollback block (renderer/todo-block.ts) via
16
+ * ctx.chunkEmitter, so CI logs keep the progression. The raw ⏺/⎿ todo_set
17
+ * rows are widget-suppressed (renderer/tool-widget.ts) in every mode.
18
18
  *
19
19
  * Session-write route (chosen with evidence, 2026-07-05): the tool mutates
20
20
  * `ctx.session.todos` directly. ToolContext.session IS the live
@@ -45,6 +45,8 @@
45
45
  */
46
46
  import { registerTool } from './index.js';
47
47
  import { formatTodoChecklistBlock } from '../renderer/todo-block.js';
48
+ import { checklistRenderMode } from '../renderer/checklist-format.js';
49
+ import { markTodoSetThisTurn } from '../ui/todo-emitter.js';
48
50
  /** Hard cap on list length — past this the list stops being a glanceable plan. */
49
51
  export const TODO_MAX_ITEMS = 20;
50
52
  const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed']);
@@ -85,16 +87,19 @@ export async function todoSetHandler(args, ctx) {
85
87
  // copy so a consumer mutating the payload can never corrupt the
86
88
  // persisted session state.
87
89
  ctx.todoEmitter?.emit('update', todos.map((t) => ({ ...t })));
88
- // Transcript render (CC-style, 2026-07): drop the FULL checklist into
89
- // scrollback on every successful set. The raw ⏺/⎿ todo_set rows are
90
- // widget-suppressed (renderer/tool-widget.ts), so this block is the sole
91
- // visible representation — mirroring ask_question. ctx.chunkEmitter routes
92
- // to Ink Static scrollback AND the classic 'chunk'→stdout path
93
- // (renderer/notices.ts). Subagent ctxs deliberately omit chunkEmitter
94
- // (tools/subagent/agent_run.ts), so a child turn's todo_set can never
95
- // print its checklist into the PARENT transcript — same seam as the
96
- // todoEmitter suppression above.
97
- ctx.chunkEmitter?.emit('chunk', formatTodoChecklistBlock(todos));
90
+ // Piece 2 (spec §4) — where the checklist is drawn depends on the mode.
91
+ // Ink: the live checklist (ui/checklist.tsx) redraws in place — no
92
+ // scrollback reprint per update; one close line lands at turn end
93
+ // (repl/plan-turn-end.ts). Classic interactive: the block prints once, at
94
+ // turn end. Headless / one-shot: the block still prints on EVERY update so
95
+ // CI logs keep the progression. The raw ⏺/⎿ todo_set rows stay
96
+ // widget-suppressed (renderer/tool-widget.ts). Subagent ctxs omit both
97
+ // emitters, so a child todo_set never reaches the parent's transcript.
98
+ if (checklistRenderMode() === 'per-update') {
99
+ ctx.chunkEmitter?.emit('chunk', formatTodoChecklistBlock(todos));
100
+ }
101
+ if (ctx.todoEmitter)
102
+ markTodoSetThisTurn();
98
103
  const result = {
99
104
  count: todos.length,
100
105
  // LAST-wins, matching the app strip and Ctrl+T panel (both `.at(-1)`).