@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -28,6 +28,10 @@ import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndRese
28
28
  import { runPhaseAudit } from './plan-audit.js';
29
29
  import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
30
30
  import { appendCostLine, buildEntry } from '../cost/cost-log.js';
31
+ import { getGlyphs } from '../renderer/glyphs.js';
32
+ import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
33
+ import { paint } from '../renderer/theme.js';
34
+ import { clean } from '../renderer/tool-labels.js';
31
35
  import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
32
36
  import { computeCost, formatCost } from '../cost/pricing.js';
33
37
  import { creditsActive } from '../cost/credits.js';
@@ -47,7 +51,7 @@ export async function runPostPhaseChain(args) {
47
51
  return null;
48
52
  let phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
49
53
  if (!phase) {
50
- args.log(chalk.red(`[plan] phase '${args.executedPhaseId}' not found in ${basename(path)} — chain skipped.`));
54
+ args.log(formatFailure({ what: 'Plan phase not found', object: args.executedPhaseId, detail: [`${basename(path)} · follow-up steps skipped`] }).trimEnd());
51
55
  return path;
52
56
  }
53
57
  // Per-run flags survive the chain: an explicitly flagged run keeps its mode
@@ -89,7 +93,7 @@ export async function runPostPhaseChain(args) {
89
93
  clearAudit: true,
90
94
  log: args.log,
91
95
  });
92
- args.log(chalk.red(`✖ plan deviation in phase ${phase.id} — ${deviationDetail}`), stoppedBanner(`plan deviation — phase ${phase.id} blocked (undeclared write denied)`, resumeCmd(path)));
96
+ args.log(formatFailure({ what: 'Plan deviation', object: `phase ${phase.id}`, detail: [deviationDetail] }).trimEnd(), stoppedBanner(`plan deviation — phase ${phase.id} blocked (undeclared write denied)`, resumeCmd(path)));
93
97
  ringBell();
94
98
  return path;
95
99
  }
@@ -244,7 +248,7 @@ export async function runPostPhaseChain(args) {
244
248
  // finding against the phase, whose own exit gate (activation + ATC)
245
249
  // already passed. So we do NOT hard-stop verified work: exactly like a
246
250
  // warn/pass, render a LOUD, DISTINCT "could not run / NOT verified" note,
247
- // keep the infra_failed state recorded (tracker keeps ⚠ audit not run;
251
+ // keep the infra_failed state recorded (tracker keeps "! audit not run";
248
252
  // the final summary lists it as unverified) and CONTINUE to the gate +
249
253
  // offer below. isAuditResolved treats infra_failed as resolved, so
250
254
  // dependants unlock and the failure menu is never shown.
@@ -253,7 +257,7 @@ export async function runPostPhaseChain(args) {
253
257
  else if (verdict.state === 'failed') {
254
258
  renderFindings(args.log, phase.id, verdict.findings);
255
259
  if (forceBlock) {
256
- args.log(chalk.red(`✖ phase ${phase.id} failed its audit ${countedFailures} times in a row — marked blocked.`), stoppedBanner('two consecutive failed audits — phase blocked', resumeCmd(path)));
260
+ args.log(formatFailure({ what: `Phase ${phase.id} failed its audit ${countedFailures} times in a row`, detail: ['marked blocked'] }).trimEnd(), stoppedBanner('two consecutive failed audits — phase blocked', resumeCmd(path)));
257
261
  ringBell();
258
262
  return path;
259
263
  }
@@ -291,7 +295,7 @@ export async function runPostPhaseChain(args) {
291
295
  summary: `audit ${phase.id}: failed — user chose re-run (status → todo)`,
292
296
  log: args.log,
293
297
  });
294
- args.log(`↩ re-running phase ${phase.id} — it will be re-audited after execution.`);
298
+ args.log(formatInfo(`Re-running phase ${phase.id} · it will be re-audited after execution.`).trimEnd());
295
299
  args.queueDispatch(resumeCmd(path));
296
300
  return path;
297
301
  }
@@ -308,7 +312,7 @@ export async function runPostPhaseChain(args) {
308
312
  if (!loaded)
309
313
  return path;
310
314
  phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
311
- args.log(chalk.yellow(`✎ audit waived (${category}) for ${phase.id} — reason recorded in the envelope.`));
315
+ args.log(paint('dim', `${getGlyphs().info} audit waived (${category}) for ${phase.id} — reason recorded in the envelope.`));
312
316
  }
313
317
  else if (verdict.state === 'warn') {
314
318
  // Non-blocking tier: ALWAYS shown, concise (Open-Q decision — warns are
@@ -317,7 +321,7 @@ export async function runPostPhaseChain(args) {
317
321
  renderWarnings(args.log, phase.id, verdict.findings);
318
322
  }
319
323
  else {
320
- args.log(chalk.green(`✔ audit passed — ${phase.id} verified against the evidence.`));
324
+ args.log(paint('ok', `${getGlyphs().done} audit passed · ${phase.id} verified against the evidence.`));
321
325
  }
322
326
  }
323
327
  /* ── 2. gate + offer ──────────────────────────────────────────────────── */
@@ -383,6 +387,7 @@ export async function runPostPhaseChain(args) {
383
387
  nextDelegateTo: next.delegateTo,
384
388
  },
385
389
  prompt: args.prompt,
390
+ confirm: args.confirm,
386
391
  queueDispatch: args.queueDispatch,
387
392
  log: args.log,
388
393
  gate: decision,
@@ -439,8 +444,11 @@ export async function runReauditPass(args) {
439
444
  if (queued) {
440
445
  const remaining = phaseIds.slice(i + 1);
441
446
  if (remaining.length > 0) {
442
- args.log(chalk.yellow(`⚠ ${remaining.length} hand-edited phase(s) not re-audited yet: ${remaining.join(', ')} — `
443
- + 'they are not auto-detected after this write; verify them (or re-run their phases) once the queued dispatch completes.'));
447
+ args.log(formatAttention({
448
+ text: `${remaining.length} hand-edited phase(s) not re-audited yet`,
449
+ fact: remaining.join(', '),
450
+ next: 'they are not auto-detected after this write; verify them (or re-run their phases) once the queued dispatch completes',
451
+ }).trimEnd());
444
452
  }
445
453
  break;
446
454
  }
@@ -452,18 +460,18 @@ function loadPlan(path, log) {
452
460
  try {
453
461
  const envelope = readProjectFile(path);
454
462
  if (envelope.artefactType !== 'plan') {
455
- log(chalk.red(`[plan] ${basename(path)} is not a plan envelope — chain skipped.`));
463
+ log(formatFailure({ what: 'Not a plan file', object: basename(path), detail: ['follow-up steps skipped'] }).trimEnd());
456
464
  return null;
457
465
  }
458
466
  const pc = parsePlanContent(envelope.content);
459
467
  if (!pc.ok) {
460
- log(chalk.red(`[plan] ${basename(path)} failed content validation — chain skipped: ${pc.errors[0] ?? ''}`));
468
+ log(formatFailure({ what: 'Plan file failed validation', object: basename(path), detail: [pc.errors[0] ?? '', 'follow-up steps skipped'] }).trimEnd());
461
469
  return null;
462
470
  }
463
471
  return { envelope, content: pc.content };
464
472
  }
465
473
  catch (e) {
466
- log(chalk.red(`[plan] could not load ${basename(path)} — ${e instanceof Error ? e.message : String(e)}`));
474
+ log(formatFailure({ what: 'Could not load the plan file', object: basename(path), detail: [e instanceof Error ? e.message : String(e)] }).trimEnd());
467
475
  return null;
468
476
  }
469
477
  }
@@ -491,7 +499,7 @@ function clampInfra(msg) {
491
499
  return flat.length > INFRA_RENDER_CHARS ? flat.slice(0, INFRA_RENDER_CHARS - 1).trimEnd() + '…' : flat;
492
500
  }
493
501
  function renderFindings(log, phaseId, findings) {
494
- log('', chalk.red(`✖ audit FAILED for phase ${phaseId}:`));
502
+ log('', paint('bad', `${getGlyphs().failed} audit FAILED for phase ${phaseId}:`));
495
503
  const shown = findings.length > 0 ? findings : ['(auditor returned no findings text)'];
496
504
  shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
497
505
  }
@@ -503,7 +511,7 @@ function renderFindings(log, phaseId, findings) {
503
511
  */
504
512
  function renderWarnings(log, phaseId, findings) {
505
513
  const n = findings.length;
506
- log('', chalk.yellow(`⚠ audit passed with ${n} note${n === 1 ? '' : 's'} for phase ${phaseId}:`));
514
+ log('', paint('warn', `${getGlyphs().attention} audit passed with ${n} note${n === 1 ? '' : 's'} for phase ${phaseId}:`));
507
515
  const shown = n > 0 ? findings : ['(auditor returned a warn verdict with no note detail)'];
508
516
  shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
509
517
  }
@@ -517,51 +525,36 @@ function renderWarnings(log, phaseId, findings) {
517
525
  * nothing else surfaces this).
518
526
  */
519
527
  function renderAuditNotRun(log, phaseId, reason, resumeCommand) {
520
- log('', chalk.yellow(`⚠ audit could not run for phase ${phaseId} (audit infrastructure failed after 3 attempts — the model call returned no output).`), chalk.yellow(' This phase is NOT audit-verified; its own exit gate (activation + ATC) passed. Proceeding.'), chalk.dim(` reason: ${reason}`), chalk.dim(` If the auditor keeps failing, run with --no-audit (or set audit off in config) to stop retrying; resume as usual: ${resumeCommand}`));
528
+ log('', paint('warn', `${getGlyphs().attention} audit could not run for phase ${phaseId} (audit infrastructure failed after 3 attempts — the model call returned no output).`), paint('warn', ' This phase is NOT audit-verified; its own exit gate (activation + ATC) passed. Proceeding.'), paint('dim', ` reason: ${reason}`), paint('dim', ` If the auditor keeps failing, run with --no-audit (or set audit off in config) to stop retrying; resume as usual: ${resumeCommand}`));
521
529
  }
522
530
  /**
523
531
  * Shared menu surface for the audit-resolution selects. Mirrors
524
532
  * safety-confirm.ts: headless → no decision (fail-closed, nothing written);
525
- * Ink → askQuestionEmitter choice modal; classic → inquirer select.
533
+ * Ink → confirm card via askConfirm (piece 2 §5); classic → inquirer select.
526
534
  */
527
535
  async function selectAuditAction(args) {
528
536
  const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
529
537
  if (isHeadless())
530
538
  return 'none';
531
539
  if (shouldUseInk()) {
532
- // D-C — this modal opens in the POST-PHASE chain, after the phase turn
533
- // ended. Its sibling gate presentSafetyConfirmation (repl/safety-confirm.ts)
534
- // documents the exact failure this caused live: opening the askQuestion
535
- // modal while another render source (the in-place tool spinner, a ticking
536
- // turn-status row) still repaints the Ink frame lets the first keystroke
537
- // LEAK past the modal and auto-select its FIRST choice. Here the first
538
- // choice is "Re-run phase", so the menu never visibly rendered and the
539
- // phase silently re-ran — the reported symptom. Claim exclusive input
540
- // focus the same way safety-confirm does: stop the spinner, freeze the
541
- // status row, restore both in the finally.
542
- const { clearActiveSpinner } = await import('../renderer/tool-widget.js');
543
- const { turnStatusEmitter } = await import('../ui/turn-status-emitter.js');
544
- const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
545
- clearActiveSpinner();
546
- const statusWasActive = turnStatusEmitter.snapshot().active;
547
- if (statusWasActive)
548
- turnStatusEmitter.pause();
549
- try {
550
- const res = await askQuestionEmitter.request({
551
- id: args.id,
552
- question: args.question,
553
- context: args.context,
554
- kind: 'choice',
555
- choices: args.choices,
556
- });
557
- if (res.cancelled || res.answer == null)
558
- return 'none';
559
- return coerceAction(res.answer, args.choices);
560
- }
561
- finally {
562
- if (statusWasActive)
563
- turnStatusEmitter.resume();
564
- }
540
+ // D-C — this card opens in the POST-PHASE chain. A leaked keystroke used
541
+ // to auto-select choice 1 ("Re-run phase") and silently re-run the
542
+ // phase. The card slot claims input focus (spinner stopped, status row
543
+ // paused, restored after), digits only move the cursor, and typed-ahead
544
+ // keys are dropped — so no stray key can pick a choice.
545
+ const { askConfirm } = await import('../ui/confirm-request.js');
546
+ const res = await askConfirm({
547
+ id: args.id,
548
+ tag: 'SAFETY CHECK',
549
+ title: args.question,
550
+ facts: args.facts,
551
+ choices: args.choices,
552
+ initial: 0,
553
+ footnote: 'Plan audit',
554
+ });
555
+ if (res.cancelled || res.value == null)
556
+ return 'none';
557
+ return coerceAction(res.value, args.choices);
565
558
  }
566
559
  const { select } = await import('@inquirer/prompts');
567
560
  const { withInquirer } = await import('../repl/inquirer-guard.js');
@@ -583,12 +576,23 @@ async function selectAuditAction(args) {
583
576
  function coerceAction(answer, choices) {
584
577
  return choices.some((c) => c.value === answer) ? answer : 'none';
585
578
  }
586
- /** Production failed-verdict menu (see selectAuditAction for the surface). */
587
- async function defaultSelectFailureAction(a) {
579
+ /** Production failed-verdict menu (see selectAuditAction for the surface).
580
+ * The findings are already printed above by renderFindings; the card
581
+ * repeats only the count and the first three. */
582
+ export async function defaultSelectFailureAction(a) {
583
+ const n = a.findings.length;
584
+ const facts = [
585
+ `${n} ${n === 1 ? 'finding' : 'findings'}, listed above`,
586
+ // Findings are model text: display-clean them at the source (ANSI and
587
+ // control characters out, one line each), like the safety-card rows.
588
+ ...a.findings.slice(0, 3).map((f, i) => `${i + 1}. ${clean(f)}`),
589
+ ];
590
+ if (n > 3)
591
+ facts.push(`${getGlyphs().more} ${n - 3} more`);
588
592
  return selectAuditAction({
589
593
  id: 'plan-audit-failed',
590
594
  question: `Audit failed for phase ${a.phaseId} — what now?`,
591
- context: a.findings.map((f, i) => `${i + 1}. ${f}`).join('\n'),
595
+ facts,
592
596
  choices: [
593
597
  { value: 'rerun', label: 'Re-run phase (re-audited after execution)' },
594
598
  { value: 'waive', label: 'Waive audit (record a one-line reason)' },
@@ -604,7 +608,8 @@ async function defaultSelectFailureAction(a) {
604
608
  * a waive no human explicitly labelled a false-positive is recorded as
605
609
  * accepted-risk, so the measured FP rate never over-counts). Never hangs.
606
610
  */
607
- async function defaultSelectWaiveCategory(a) {
611
+ /** Exported for the piece-2 behaviour pins (plan-chain-focus-pin.test.ts). */
612
+ export async function defaultSelectWaiveCategory(a) {
608
613
  const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
609
614
  if (isHeadless())
610
615
  return 'accepted-risk';
@@ -615,30 +620,19 @@ async function defaultSelectWaiveCategory(a) {
615
620
  ];
616
621
  const coerce = (answer) => choices.some((c) => c.value === answer) ? answer : 'accepted-risk';
617
622
  if (shouldUseInk()) {
618
- // Same input-focus discipline as selectAuditAction — this modal opens in
619
- // the post-phase chain, so a leaked keystroke must not auto-select choice 1.
620
- const { clearActiveSpinner } = await import('../renderer/tool-widget.js');
621
- const { turnStatusEmitter } = await import('../ui/turn-status-emitter.js');
622
- const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
623
- clearActiveSpinner();
624
- const statusWasActive = turnStatusEmitter.snapshot().active;
625
- if (statusWasActive)
626
- turnStatusEmitter.pause();
627
- try {
628
- const res = await askQuestionEmitter.request({
629
- id: 'plan-waive-category',
630
- question,
631
- kind: 'choice',
632
- choices,
633
- });
634
- if (res.cancelled || res.answer == null)
635
- return 'accepted-risk';
636
- return coerce(res.answer);
637
- }
638
- finally {
639
- if (statusWasActive)
640
- turnStatusEmitter.resume();
641
- }
623
+ // Same input-focus discipline as selectAuditAction (askConfirm).
624
+ const { askConfirm } = await import('../ui/confirm-request.js');
625
+ const res = await askConfirm({
626
+ id: 'plan-waive-category',
627
+ tag: 'QUESTION',
628
+ title: question,
629
+ choices,
630
+ initial: 0,
631
+ footnote: 'Plan audit',
632
+ });
633
+ if (res.cancelled || res.value == null)
634
+ return 'accepted-risk';
635
+ return coerce(res.value);
642
636
  }
643
637
  const { select } = await import('@inquirer/prompts');
644
638
  const { withInquirer } = await import('../repl/inquirer-guard.js');
@@ -173,6 +173,7 @@ export async function runContinuationChain(args) {
173
173
  attemptStartedAt: args.marker.attemptStartedAt,
174
174
  subagentDispatches: args.subagentDispatches,
175
175
  prompt: args.prompt,
176
+ confirm: args.confirm,
176
177
  queueDispatch: args.queueDispatch,
177
178
  log: args.log,
178
179
  });
@@ -52,6 +52,8 @@ import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
52
52
  import { getAuthorIdentity } from '../agent/loop.js';
53
53
  import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
54
54
  import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
55
+ import { askYesNo, isYes } from '../projects/yes-no.js';
56
+ import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
55
57
  /**
56
58
  * Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
57
59
  * audit off-switch (`--audit` / `--no-audit`) flags from a plan-resume body.
@@ -182,7 +184,7 @@ export async function preparePlanResume(args) {
182
184
  // the auto-fire dispatch) should never have to name a version number.
183
185
  const newestOfFamily = pickNewestPlanVersion(resolved.matches.map((m) => m.path));
184
186
  if (newestOfFamily) {
185
- args.log(`↪ resolved @${tokens[0]} to the latest version ${basename(newestOfFamily)}`);
187
+ args.log(formatInfo(`Resolved @${tokens[0]} to the latest version ${basename(newestOfFamily)}`).trimEnd());
186
188
  path = newestOfFamily;
187
189
  }
188
190
  else {
@@ -206,7 +208,7 @@ export async function preparePlanResume(args) {
206
208
  // next to the picked file, silently redirect to it with a note.
207
209
  const newest = findNewestVersion(path, envelope);
208
210
  if (newest) {
209
- args.log(`↪ newer version found — resuming ${basename(newest.path)} (you picked ${basename(path)})`);
211
+ args.log(formatInfo(`Newer version found · resuming ${basename(newest.path)} (you picked ${basename(path)})`).trimEnd());
210
212
  path = newest.path;
211
213
  envelope = newest.envelope;
212
214
  }
@@ -252,11 +254,13 @@ export async function preparePlanResume(args) {
252
254
  const lease = acquireRunLease(path, args.sessionId, { log: (line) => args.log(line) });
253
255
  if (!lease.acquired && lease.holder) {
254
256
  const holder = lease.holder;
255
- args.log(`⚠ another session is already resuming this plan — session ${holder.sessionId} (pid ${holder.pid}${holder.at ? `, since ${holder.at}` : ''}).`);
256
- const answer = args.prompt
257
- ? (await args.prompt('Take over its run lease and resume here anyway? [y/N]: ')).trim().toLowerCase()
258
- : '';
259
- if (answer !== 'y' && answer !== 'yes') {
257
+ args.log(formatAttention({ text: 'Another session is already resuming this plan', fact: `session ${holder.sessionId} (pid ${holder.pid}${holder.at ? `, since ${holder.at}` : ''})` }).trimEnd());
258
+ const answer = await askYesNo({
259
+ question: 'Take over its run lease and resume here anyway?',
260
+ confirm: args.confirm,
261
+ prompt: args.prompt,
262
+ });
263
+ if (!isYes(answer)) {
260
264
  args.log('Resume refused — the other session keeps the lease. Re-run when it finishes (a dead session\'s lease is taken over automatically).');
261
265
  return null;
262
266
  }
@@ -300,7 +304,7 @@ export async function preparePlanResume(args) {
300
304
  const names = unresolved.map((p) => p.audit?.state === 'failed'
301
305
  ? `${p.id} (FAILED audit — ${p.audit.findings?.length ?? 0} finding${(p.audit.findings?.length ?? 0) === 1 ? '' : 's'} waived)`
302
306
  : `${p.id} (${p.audit?.state ?? 'unresolved'})`);
303
- args.log(`↪ audits off (--no-audit): waived ${unresolved.length} unresolved audit${unresolved.length === 1 ? '' : 's'} from an earlier run — ${names.join(', ')} (reason: audits turned off).`);
307
+ args.log(formatInfo(`audits off (--no-audit): waived ${unresolved.length} unresolved audit${unresolved.length === 1 ? '' : 's'} from an earlier run — ${names.join(', ')} (reason: audits turned off).`).trimEnd());
304
308
  }
305
309
  }
306
310
  // Task 6 — hand-edit detection: the newest history entry was NOT authored
@@ -322,7 +326,7 @@ export async function preparePlanResume(args) {
322
326
  ? { ...p, audit: { ...(p.audit ?? {}), state: 'not_audited_hand_edited' } }
323
327
  : p),
324
328
  };
325
- args.log(`⚠ envelope was last edited outside this harness (by ${lastHistory?.by?.name ?? 'unknown'}) — satisfied phases without a harness audit are shown as hand-edited (file not rewritten).`);
329
+ args.log(formatAttention({ text: `Plan was last edited outside CSPeach (by ${lastHistory?.by?.name ?? 'unknown'})`, fact: 'satisfied phases without an audit show as hand-edited · file not rewritten' }).trimEnd());
326
330
  }
327
331
  // Task 6 — re-audit-on-resume: an unresolved persisted audit (a crash
328
332
  // between phase save and verdict save leaves 'pending'; an auditor outage
@@ -636,12 +640,16 @@ export async function offerNextPhaseAutoRun(args) {
636
640
  return false;
637
641
  const delegate = args.outcome.nextDelegateTo ? ` (${args.outcome.nextDelegateTo})` : '';
638
642
  if (args.gate?.action === 'auto') {
639
- args.log(`▶ auto-running next phase ${args.outcome.nextPhaseId}${delegate} — guarded mode: declared non-write phase, prior audit clean.`);
643
+ args.log(formatInfo(`Auto-running next phase ${args.outcome.nextPhaseId}${delegate} · guarded mode: declared non-write phase, prior audit clean.`).trimEnd());
640
644
  args.queueDispatch(args.outcome.resumeCommand);
641
645
  return true;
642
646
  }
643
- const answer = (await args.prompt(`Run next phase ${args.outcome.nextPhaseId}${delegate} now in a fresh context? [y/N]: `)).trim().toLowerCase();
644
- if (answer !== 'y' && answer !== 'yes') {
647
+ const answer = await askYesNo({
648
+ question: `Run next phase ${args.outcome.nextPhaseId}${delegate} now in a fresh context?`,
649
+ confirm: args.confirm,
650
+ prompt: args.prompt,
651
+ });
652
+ if (!isYes(answer)) {
645
653
  args.log(`Exiting — resume later with: ${args.outcome.resumeCommand}`);
646
654
  return false;
647
655
  }
@@ -6,30 +6,44 @@
6
6
  */
7
7
  import { readAuthFile } from '../auth/auth-file.js';
8
8
  import { fetchMe } from '../auth/me.js';
9
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
10
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
11
+ import { paint } from '../renderer/theme.js';
12
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
13
+ /** Server-supplied text (email, plan, trial fields) is one line with no control characters. */
14
+ const serverText = (v) => sanitizeControlChars(String(v ?? '')).replace(/\s*\r?\n\s*/g, ' ').trim();
9
15
  export async function runWhoamiCommand(deps) {
10
16
  const auth = await readAuthFile();
11
17
  if (!auth) {
12
- deps.print('Not signed in via cspeach login (no ~/.cspeach/auth.json).');
13
- deps.print('Your REPL may still authenticate via the keychain-stored key — run `cspeach` to check.');
18
+ deps.print(formatInfo('Not signed in with cspeach login (no ~/.cspeach/auth.json)').trimEnd());
19
+ deps.print(` ${paint('dim', 'Next:')} cspeach login (the REPL may still use a keychain key — run cspeach to check)`);
14
20
  return 0;
15
21
  }
16
22
  const me = await fetchMe({ proxyUrl: deps.proxyUrl, apiKey: auth.apiKey });
17
23
  if (!me.ok) {
18
- deps.print(`Auth file present but /v1/me returned ${me.status} (${me.error}).`);
19
- deps.print('Run `cspeach login` to refresh.');
24
+ // Status 0 = the request never got an answer (network error or the /v1/me
25
+ // timeout): signing in again would not help. Any HTTP answer keeps the
26
+ // log-in hint, as before.
27
+ const unreachable = me.status === 0;
28
+ deps.print(formatFailure({
29
+ what: 'Could not read your account',
30
+ detail: [unreachable ? `could not reach the server (${me.error})` : `the server answered ${me.status} (${me.error})`],
31
+ next: unreachable ? 'check your network or VPN, then run cspeach whoami again' : 'cspeach login',
32
+ }).trimEnd());
20
33
  return 1;
21
34
  }
22
35
  const p = me.profile;
23
- deps.print(`Email: ${p.email}`);
24
- deps.print(`Plan: ${p.plan}`);
36
+ deps.print(panelHeading('Account'));
37
+ deps.print(panelRow('Email', serverText(p.email), 8));
38
+ deps.print(panelRow('Plan', serverText(p.plan), 8));
25
39
  if (p.trial_mode) {
26
- deps.print(`Trial: ${p.trial_mode}, ends ${p.trial_ends_at}`);
40
+ deps.print(panelRow('Trial', serverText(`${p.trial_mode}, ends ${p.trial_ends_at}`), 8));
27
41
  if (p.trial_token_cap_input) {
28
- deps.print(`Tokens: ${p.usage_input_tokens}/${p.trial_token_cap_input} in, ${p.usage_output_tokens}/${p.trial_token_cap_output ?? '∞'} out`);
42
+ deps.print(panelRow('Tokens', serverText(`${p.usage_input_tokens}/${p.trial_token_cap_input} in, ${p.usage_output_tokens}/${p.trial_token_cap_output ?? '∞'} out`), 8));
29
43
  }
30
44
  }
31
45
  if (p.entitlements.enabled_skills.length > 0) {
32
- deps.print(`Skills: ${p.entitlements.enabled_skills.join(', ')}`);
46
+ deps.print(panelRow('Skills', serverText(p.entitlements.enabled_skills.join(', ')), 8));
33
47
  }
34
48
  return 0;
35
49
  }
@@ -12,6 +12,11 @@ import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
12
12
  export function isValidWriteMode(v) {
13
13
  return v === 'auto' || v === 'approval-gated' || v === 'advisory-only';
14
14
  }
15
+ export const DEFAULT_KEEPALIVE = Object.freeze({
16
+ enabled: true,
17
+ in_turn_pings: 11,
18
+ idle_pings: 4,
19
+ });
15
20
  export const DEFAULT_CONFIG = {
16
21
  proxy_url: 'https://api.cspeach.dev',
17
22
  default_model: DEFAULT_SESSION_MODEL,
@@ -22,9 +27,15 @@ export const DEFAULT_CONFIG = {
22
27
  // Default `auto` — Ink inline+Static is the production path as of 0.8.0.
23
28
  // Smoke-verified 2026-06-06 (A1, post-Hajj plan): smooth streaming on a
24
29
  // big skill turn, no stutter/jumping/picker re-mount, phantom cursor #25
25
- // root-caused and fixed (485604b). Users can pin `ui.rendering = 'classic'`
26
- // to keep the chalk REPL; piped/CI invocations fall back automatically.
27
- ui: { rendering: 'auto' },
30
+ // root-caused and fixed (485604b). Piped/CI/one-shot invocations fall back
31
+ // to plain output automatically. A stored 'classic' is retiring (2026-09-27):
32
+ // it is read as 'auto' — see renderer/rendering-mode.ts (CSPEACH_UI=classic
33
+ // is the one-launch escape hatch).
34
+ // 2026-09-26 — `look` = the conversation look (spec
35
+ // docs/superpowers/specs/2026-09-26-cli-look-piece1-design.md). 'panels' is
36
+ // the default; 'legacy' reproduces the pre-overhaul output exactly.
37
+ // `background` is left unset: getBackground() then reads COLORFGBG, else dark.
38
+ ui: { rendering: 'auto', look: 'panels' },
28
39
  // Default `approval-gated` — preserves current UX (user approves each write).
29
40
  write_mode: 'approval-gated',
30
41
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
@@ -40,9 +51,10 @@ export const DEFAULT_CONFIG = {
40
51
  compact: {
41
52
  keep_recent_turns: 5,
42
53
  auto_enabled: true,
43
- auto_threshold_tokens: 1_000_000,
54
+ auto_threshold_tokens: 150_000,
44
55
  min_turns_between_auto: 5,
45
56
  },
57
+ keepalive: DEFAULT_KEEPALIVE,
46
58
  };
47
59
  /**
48
60
  * Coerce a possibly-bad `compact` block from disk into a safe CompactConfig.
@@ -72,6 +84,43 @@ function sanitiseCompact(raw) {
72
84
  min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
73
85
  };
74
86
  }
87
+ /** The old DEFAULT the pre-sparse full save wrote into every config file. */
88
+ const LEGACY_SAVED_AUTO_THRESHOLD = 1_000_000;
89
+ /**
90
+ * Task 19 (ruling F12) — does the FILE's `[compact]` table set the threshold by
91
+ * an explicit act? A numeric value counts, except 1_000_000: that is the old
92
+ * default a full save wrote, so it reads as unset (same reasoning as effort,
93
+ * F2). The stored value itself is left as-is (so a later save keeps writing
94
+ * 1_000_000, which stays "unset", instead of baking in a value that would beat
95
+ * the served `auto_compact_tokens`).
96
+ */
97
+ function fileSetsAutoThreshold(raw) {
98
+ if (!raw || typeof raw !== 'object')
99
+ return false;
100
+ const v = raw.auto_threshold_tokens;
101
+ return typeof v === 'number' && Number.isFinite(v) && Math.floor(v) !== LEGACY_SAVED_AUTO_THRESHOLD;
102
+ }
103
+ /**
104
+ * Coerce a possibly-bad `keepalive` block from disk (same defensive pattern as
105
+ * sanitiseCompact): missing / wrong type → defaults; counts floored and
106
+ * clamped (in_turn_pings 0..20, idle_pings 0..10).
107
+ */
108
+ function sanitiseKeepalive(raw) {
109
+ const d = DEFAULT_KEEPALIVE;
110
+ if (!raw || typeof raw !== 'object')
111
+ return { ...d };
112
+ const r = raw;
113
+ const intInRange = (v, fallback, lo, hi) => {
114
+ if (typeof v !== 'number' || !Number.isFinite(v))
115
+ return fallback;
116
+ return Math.min(hi, Math.max(lo, Math.floor(v)));
117
+ };
118
+ return {
119
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : d.enabled,
120
+ in_turn_pings: intInRange(r.in_turn_pings, d.in_turn_pings, 0, 20),
121
+ idle_pings: intInRange(r.idle_pings, d.idle_pings, 0, 10),
122
+ };
123
+ }
75
124
  /**
76
125
  * Resolve the effective TLS options for a SAP system, applying the legacy
77
126
  * `sslVerify: false` → insecure mapping. A configured `ca_cert_path` always
@@ -189,6 +238,13 @@ export async function loadConfig() {
189
238
  // `default_model` key (file presence, not the value). Lets an explicit user
190
239
  // choice beat a served roles.session_default. Stripped by saveConfig.
191
240
  default_model_set: Object.prototype.hasOwnProperty.call(parsed, 'default_model'),
241
+ // effort_set: INTERNAL flag (ruling F2) — true only when the FILE carries
242
+ // a string `effort` AND the `effort_explicit = true` marker that
243
+ // `config set effort` writes. Stripped by saveConfig.
244
+ effort_set: parsed.effort_explicit === true && typeof parsed.effort === 'string',
245
+ // compact_threshold_set: INTERNAL flag (Task 19) — only present when true,
246
+ // so a loaded config without it keeps its exact old shape.
247
+ ...(fileSetsAutoThreshold(parsed.compact) ? { compact_threshold_set: true } : {}),
192
248
  // sap: ALWAYS a fresh top-level object, never the shared DEFAULT_CONFIG.sap
193
249
  // reference. Without this, a file lacking `[sap.*]` would leave
194
250
  // `merged.sap === DEFAULT_CONFIG.sap`; the onboarding wizard then mutating
@@ -210,6 +266,7 @@ export async function loadConfig() {
210
266
  // field (e.g. just `keep_recent_turns = 10`) still gets the four
211
267
  // unspecified defaults — and a typo'd field falls through harmlessly.
212
268
  compact: sanitiseCompact(parsed.compact),
269
+ keepalive: sanitiseKeepalive(parsed.keepalive),
213
270
  shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
214
271
  };
215
272
  // Drop the legacy `local_files` key entirely when it isn't a genuine on
@@ -218,6 +275,12 @@ export async function loadConfig() {
218
275
  // `config show` and saveConfig output clean — no `local_files = undefined`.
219
276
  if (merged.local_files === undefined)
220
277
  delete merged.local_files;
278
+ // 2026-09-26 — unknown values fall back instead of reaching the renderer.
279
+ if (merged.ui.look !== 'panels' && merged.ui.look !== 'legacy')
280
+ merged.ui.look = DEFAULT_CONFIG.ui.look;
281
+ if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
282
+ delete merged.ui.background;
283
+ }
221
284
  // Same for `local_build`: when the key is absent from disk we coerce it to
222
285
  // `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
223
286
  // key so it doesn't serialise as `local_build = undefined` and so callers
@@ -357,7 +420,9 @@ export async function saveConfig(config) {
357
420
  const fileKeys = config.file_keys ?? [];
358
421
  // Strip the INTERNAL flags — both are derived on load (file presence /
359
422
  // file-key snapshot) and must never be written back into config.toml.
360
- const { default_model_set: _dropSet, file_keys: _dropKeys, ...toWrite } = config;
423
+ // effort_set is internal too (Task 4); the stored marker effort_explicit is kept.
424
+ // compact_threshold_set is internal too (Task 19).
425
+ const { default_model_set: _dropSet, effort_set: _dropEffortSet, compact_threshold_set: _dropThresholdSet, file_keys: _dropKeys, ...toWrite } = config;
361
426
  // Sparse serialisation: drop any key that the file did NOT carry AND whose
362
427
  // value is deep-equal to the built-in default. Keys absent from DEFAULT_CONFIG
363
428
  // (optional keys like audit_model) never deep-equal a defined value, so any