@cspeach/cli 1.1.18 → 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 (224) 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 +58 -21
  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/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. 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
  }
@@ -22,9 +22,15 @@ export const DEFAULT_CONFIG = {
22
22
  // Default `auto` — Ink inline+Static is the production path as of 0.8.0.
23
23
  // Smoke-verified 2026-06-06 (A1, post-Hajj plan): smooth streaming on a
24
24
  // 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' },
25
+ // root-caused and fixed (485604b). Piped/CI/one-shot invocations fall back
26
+ // to plain output automatically. A stored 'classic' is retiring (2026-09-27):
27
+ // it is read as 'auto' — see renderer/rendering-mode.ts (CSPEACH_UI=classic
28
+ // is the one-launch escape hatch).
29
+ // 2026-09-26 — `look` = the conversation look (spec
30
+ // docs/superpowers/specs/2026-09-26-cli-look-piece1-design.md). 'panels' is
31
+ // the default; 'legacy' reproduces the pre-overhaul output exactly.
32
+ // `background` is left unset: getBackground() then reads COLORFGBG, else dark.
33
+ ui: { rendering: 'auto', look: 'panels' },
28
34
  // Default `approval-gated` — preserves current UX (user approves each write).
29
35
  write_mode: 'approval-gated',
30
36
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
@@ -218,6 +224,12 @@ export async function loadConfig() {
218
224
  // `config show` and saveConfig output clean — no `local_files = undefined`.
219
225
  if (merged.local_files === undefined)
220
226
  delete merged.local_files;
227
+ // 2026-09-26 — unknown values fall back instead of reaching the renderer.
228
+ if (merged.ui.look !== 'panels' && merged.ui.look !== 'legacy')
229
+ merged.ui.look = DEFAULT_CONFIG.ui.look;
230
+ if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
231
+ delete merged.ui.background;
232
+ }
221
233
  // Same for `local_build`: when the key is absent from disk we coerce it to
222
234
  // `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
223
235
  // key so it doesn't serialise as `local_build = undefined` and so callers
@@ -22,10 +22,12 @@
22
22
  * Unknown models → returns null + records the model name to a process-level
23
23
  * Set so we surface them as an obvious warning rather than silently zeroing.
24
24
  */
25
+ import { recordFullNotice } from '../renderer/notice-log.js';
25
26
  import { getServerModelConfig } from '../models/server-config.js';
26
27
  const PRICING = {
27
28
  // Claude 5 family (2026-09-15) — Opus 5 keeps the Opus 4.5+ rate ($5/$25);
28
29
  // Sonnet 5 is $2/$10. Cache read = 10% of input, cache write = 1.25x input.
30
+ 'claude-opus-5-5': { inputPer1M: 4.00, outputPer1M: 20.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 5.00 },
29
31
  'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
30
32
  'claude-sonnet-5': { inputPer1M: 2.00, outputPer1M: 10.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 2.50 },
31
33
  // Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
@@ -90,11 +92,10 @@ export function getRate(model) {
90
92
  return PRICING[stripDate];
91
93
  if (!seenUnknownModels.has(normalized)) {
92
94
  seenUnknownModels.add(normalized);
93
- // Best-effort warn; don't crash if the console is unavailable.
94
- try {
95
- console.warn(JSON.stringify({ msg: 'pricing_unknown_model', model: normalized }));
96
- }
97
- catch { /* noop */ }
95
+ // Diagnostics never reach the user's screen (fix round 3, item 1): a raw
96
+ // console.warn bypassed the Ink/classic writers and printed JSON into the
97
+ // chat, spinner, goodbye and one-shot stderr. Log file only.
98
+ recordFullNotice('debug', JSON.stringify({ msg: 'pricing_unknown_model', model: normalized }));
98
99
  }
99
100
  return null;
100
101
  }
@@ -19,6 +19,7 @@ import { loadConfig } from '../../config/loader.js';
19
19
  import { getConnection } from '../../sap/connection-manager.js';
20
20
  import { AdtClient, snapshots } from '@cspeach/sap-client';
21
21
  import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
22
+ import { verifySyntax } from '../../tools/verify.js';
22
23
  const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
23
24
  const PROBE_PACKAGE = '$TMP';
24
25
  const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
@@ -84,12 +85,21 @@ export async function checkForgeRules() {
84
85
  };
85
86
  }
86
87
  // Step 4: Verify Rule 10 fired — syntax was checked. Re-run syntaxCheck and confirm clean.
87
- const syntax = await adt.syntaxCheck('PROG', PROBE_NAME);
88
- if (syntax.hasErrors) {
88
+ // Re-review item 2 — through verifySyntax (fail closed): a check that did
89
+ // not run is ok:false with its reason, never a throw out of collectChecks.
90
+ const syntax = await verifySyntax(adt, PROBE_NAME, 'PROG');
91
+ if (syntax.didNotRun !== undefined) {
89
92
  return {
90
93
  name: 'forge-rules',
91
94
  ok: false,
92
- message: `Rule 10: syntax check returned errors on probe: ${syntax.messages[0]?.text ?? 'unknown'}`,
95
+ message: `Rule 10: syntax check did not run: ${syntax.didNotRun.replace(/^Syntax check did not run:?\s*/i, '')}`,
96
+ };
97
+ }
98
+ if (!syntax.ok) {
99
+ return {
100
+ name: 'forge-rules',
101
+ ok: false,
102
+ message: `Rule 10: syntax check returned errors on probe: ${syntax.errors[0] ?? 'unknown'}`,
93
103
  };
94
104
  }
95
105
  return {
@@ -1,10 +1,12 @@
1
- import keychain from '../../auth/keychain.js';
1
+ import keychain, { newProbeAccount } from '../../auth/keychain.js';
2
2
  export async function checkKeychain() {
3
3
  try {
4
+ // Round 4 item 7 — per-process probe account (see newProbeAccount).
5
+ const account = newProbeAccount();
4
6
  const probe = `probe-${Date.now()}`;
5
- await keychain.setPassword('cspeach', 'probe', probe);
6
- const ok = (await keychain.getPassword('cspeach', 'probe')) === probe;
7
- await keychain.deletePassword('cspeach', 'probe');
7
+ await keychain.setPassword('cspeach', account, probe);
8
+ const ok = (await keychain.getPassword('cspeach', account)) === probe;
9
+ await keychain.deletePassword('cspeach', account);
8
10
  if (ok)
9
11
  return { name: 'keychain', ok: true, message: 'OS keychain working' };
10
12
  return { name: 'keychain', ok: false, message: 'probe write/read mismatch', hint: 'Per-session fallback mode will be used' };