@cspeach/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -5,9 +5,10 @@ import { listTools, getTool, toAnthropicTools } from '../tools/index.js';
5
5
  import { saveSession } from '../session/store.js';
6
6
  import { recordCompletedToolCall } from '../session/pending.js';
7
7
  import { loadConfig } from '../config/loader.js';
8
+ import { resolveModelRole } from '../models/resolve.js';
8
9
  import { retryWithBackoff } from './retry.js';
9
10
  import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
10
- import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner } from '../renderer/tool-widget.js';
11
+ import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool } from '../renderer/tool-widget.js';
11
12
  import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
12
13
  import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
13
14
  // 2026-06-07 — status-row narration. Safe to import in every mode: the
@@ -520,8 +521,10 @@ export async function runTurn(params) {
520
521
  healSessionMessagesInPlace(session.messages);
521
522
  const streamParams = {
522
523
  // A4 — per-turn override (plan model tiering) wins over the
523
- // configured default; absent on every non-plan turn.
524
- model: params.modelOverride ?? cfg.default_model,
524
+ // configured default; absent on every non-plan turn. model-governance
525
+ // step 2d — the session-default model now resolves env > local >
526
+ // server > built-in (byte-identical to cfg.default_model when nothing set).
527
+ model: params.modelOverride ?? resolveModelRole('session_default', cfg),
525
528
  // v0.3.1 — was 8192. Bumped because code-gen skills (abap-generate
526
529
  // etc.) kept running out of budget mid-turn: adaptive thinking +
527
530
  // multiple tool-result prompts + TL;DR mandate + summary prose
@@ -764,7 +767,8 @@ export async function runTurn(params) {
764
767
  // never let a save error crash the turn.
765
768
  if (params.onPlanManifest) {
766
769
  const turnText = collectTurnAssistantText(session.messages, turnStartMessageCount);
767
- const blocks = turnText.match(/<!--\s*csforge:plan-manifest\s*\n[\s\S]*?\n\s*-->/g);
770
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
771
+ const blocks = turnText.match(/<!--\s*(?:csforge|cspeach):plan-manifest\s*\n[\s\S]*?\n\s*-->/g);
768
772
  const latest = blocks ? blocks[blocks.length - 1] : null;
769
773
  if (latest && latest !== lastFiredManifest) {
770
774
  lastFiredManifest = latest;
@@ -872,7 +876,7 @@ export async function runTurn(params) {
872
876
  // A4 — record the actual turn model if an override ran the stream
873
877
  // (unreachable today: plan-resume turns suppress this hook, and
874
878
  // they are the only modelOverride caller — kept honest anyway).
875
- model: params.modelOverride ?? cfg.default_model ?? session.model ?? 'unknown',
879
+ model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
876
880
  emit,
877
881
  promotedFrom: promotedFromForSave,
878
882
  });
@@ -885,14 +889,17 @@ export async function runTurn(params) {
885
889
  // throttle live in config.compact — see config/loader.ts:CompactConfig.
886
890
  try {
887
891
  const { maybeAutoCompact } = await import('../commands/auto-compact.js');
888
- const { buildSummarisationPrompt, serialiseForSummariser, COMPACTION_MODEL } = await import('../commands/compact.js');
892
+ const { buildSummarisationPrompt, serialiseForSummariser } = await import('../commands/compact.js');
889
893
  const { summariseViaProvider } = await import('./summarise-via-provider.js');
890
894
  await maybeAutoCompact({
891
895
  session,
892
896
  config: cfg.compact,
893
897
  turnNumber,
894
898
  emit,
895
- summarise: async (toSummarise) => summariseViaProvider(params.provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } }),
899
+ summarise: async (toSummarise) => summariseViaProvider(params.provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
900
+ // model-governance step 2d — compact model resolves env > local >
901
+ // server > built-in (== COMPACTION_MODEL when nothing set).
902
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } }),
896
903
  });
897
904
  }
898
905
  catch (err) {
@@ -943,6 +950,8 @@ export async function runTurn(params) {
943
950
  for (const block of currentAssistantContent) {
944
951
  if (block.type === 'tool_use') {
945
952
  // Phase 1: print the dispatch line (CC-style: ⏺ name(args)).
953
+ // No-op for widget-suppressed tools (ask_question) — the form/modal
954
+ // is the visible representation; see tool-widget.ts.
946
955
  renderToolCallTop({
947
956
  name: block.name,
948
957
  args: (block.input ?? {}),
@@ -951,8 +960,12 @@ export async function runTurn(params) {
951
960
  // Phase 2a: start the peach-themed spinner. No-op outside TTY / Ink
952
961
  // / CI — the result line still prints, just without the in-place
953
962
  // animation. Spinner is purely a "still working" cue, not
954
- // load-bearing for output.
955
- const spinner = startToolSpinner({ chunkEmitter: params.chunkEmitter });
963
+ // load-bearing for output. Skipped entirely for widget-suppressed
964
+ // tools (ask_question): with the ⏺ line gone the spinner would
965
+ // orphan on its own row above the form.
966
+ const spinner = isWidgetSuppressedTool(block.name)
967
+ ? { stop: () => undefined }
968
+ : startToolSpinner({ chunkEmitter: params.chunkEmitter });
956
969
  // 2026-06-06 (turn-liveness, B5 smoke feedback) — the in-place tool
957
970
  // spinner self-disables under Ink (phantom cursor #25), which left
958
971
  // slow tool calls (SAP over VPN: 10-60s) as DEAD AIR between the ⏺
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Human-readable operation + object labels for the approval surfaces.
3
+ *
4
+ * Replaces the cryptic op-code glyph (`A`/`M`/`D`/`>`/`^`) the live review
5
+ * found dishonest and information-poor. Every approval box, plan gate, and
6
+ * classic prompt now speaks a verb ("Create table ZMNT_NOTE") instead of a
7
+ * one-letter code where a word belongs.
8
+ */
9
+ const VERBS = {
10
+ create: 'Create',
11
+ modify: 'Change',
12
+ delete: 'Delete',
13
+ activate: 'Activate',
14
+ release: 'Release',
15
+ };
16
+ /**
17
+ * SAP ADT type codes → plain words. The `type` field is free text supplied by
18
+ * the model, so an unknown/decorated value (e.g. "testclasses include") falls
19
+ * through unchanged rather than being mangled.
20
+ */
21
+ const TYPE_WORDS = {
22
+ TABL: 'table',
23
+ CLAS: 'class',
24
+ INTF: 'interface',
25
+ PROG: 'program',
26
+ FUGR: 'function group',
27
+ DDLS: 'CDS view',
28
+ DDLX: 'metadata extension',
29
+ DCLS: 'access control',
30
+ BDEF: 'behavior definition',
31
+ SRVD: 'service definition',
32
+ SRVB: 'service binding',
33
+ DTEL: 'data element',
34
+ DOMA: 'domain',
35
+ STRU: 'structure',
36
+ TTYP: 'table type',
37
+ MSAG: 'message class',
38
+ DEVC: 'package',
39
+ ENHO: 'enhancement',
40
+ XSLT: 'transformation',
41
+ SHLP: 'search help',
42
+ };
43
+ export function humanVerb(op) {
44
+ return VERBS[op] ?? op.charAt(0).toUpperCase() + op.slice(1);
45
+ }
46
+ export function humanType(type) {
47
+ if (!type)
48
+ return '';
49
+ const key = type.trim().toUpperCase();
50
+ if (TYPE_WORDS[key])
51
+ return TYPE_WORDS[key];
52
+ // Already a word (has a lowercase letter or a space)? use it verbatim.
53
+ if (/[a-z ]/.test(type))
54
+ return type;
55
+ return type.toLowerCase();
56
+ }
57
+ /** e.g. "Create table ZMNT_NOTE", "Change class ZCL_X", "Delete table ZOLD". */
58
+ export function verbLine(change) {
59
+ return [humanVerb(change.op), humanType(change.type), change.object]
60
+ .filter(Boolean)
61
+ .join(' ');
62
+ }
63
+ /**
64
+ * The transport to DISPLAY on the approval surface for one change — mirrors
65
+ * how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
66
+ * review):
67
+ * - create-family (sap_create_object / set_class_include / create_domain /
68
+ * create_data_element): `args.transport ?? session current` — so the
69
+ * session fallback is honest to show.
70
+ * - modify/delete (sap_set_source / sap_update_method / sap_delete_object):
71
+ * resolveWriteTransport(args only, + owning-TR override) — NO session
72
+ * fallback in the actual write, so we only show a transport the request
73
+ * explicitly carries.
74
+ * - activate/release: the tools take no transport arg at all — never show
75
+ * a Transport segment.
76
+ * When nothing is resolvable, return undefined and the caller OMITS the
77
+ * segment — no "new (session)" promise (a $TMP write never creates one).
78
+ */
79
+ export function displayTransport(op, explicit, session) {
80
+ switch (op) {
81
+ case 'create':
82
+ return explicit ?? session ?? undefined;
83
+ case 'modify':
84
+ case 'delete':
85
+ return explicit;
86
+ default: // activate / release
87
+ return undefined;
88
+ }
89
+ }
90
+ /**
91
+ * A create/delete "diff" is really a source dump (all-adds, or plain source).
92
+ * Strip unified-diff scaffolding (hunk + file headers) and the leading +/-
93
+ * marker so View source shows clean source — no +/- colouring that would
94
+ * imply a before/after that does not exist for a create. Shared by the Ink
95
+ * modal's [v] view and the classic prompt's View source branch.
96
+ */
97
+ export function toSourceLines(content) {
98
+ return content
99
+ .split('\n')
100
+ .filter((l) => !l.startsWith('@@') && !l.startsWith('+++') && !l.startsWith('---'))
101
+ .map((l) => l.replace(/^[+-] ?/, ''));
102
+ }
103
+ /**
104
+ * The review key that MATCHES the operation — honesty over a blanket "Show
105
+ * diff":
106
+ * - modify → [d] Show diff (there IS a before/after to diff)
107
+ * - create → [v] View source, only when the new source is carried
108
+ * - delete → [v] View current source, only when the source is carried
109
+ * - activate/release → nothing to review (no source in the payload)
110
+ */
111
+ export function viewAffordance(change) {
112
+ switch (change.op) {
113
+ case 'modify':
114
+ // Existing behaviour: [d] always offered; the renderer warns when the
115
+ // model supplied a description rather than a real unified diff.
116
+ return { key: 'd', label: 'Show diff' };
117
+ case 'create':
118
+ return change.diff ? { key: 'v', label: 'View source' } : null;
119
+ case 'delete':
120
+ return change.diff ? { key: 'v', label: 'View current source' } : null;
121
+ default:
122
+ return null;
123
+ }
124
+ }
@@ -1,5 +1,6 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
+ import { verbLine, viewAffordance, toSourceLines } from './op-labels.js';
3
4
  import { shouldUseInk } from '../renderer/tty.js';
4
5
  import { approvalEmitter } from '../ui/approval-emitter.js';
5
6
  import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
@@ -7,26 +8,6 @@ import { withInquirer } from '../repl/inquirer-guard.js';
7
8
  import { inquirerTheme } from '../repl/inquirer-theme.js';
8
9
  import { PEACH } from '../renderer/banners.js';
9
10
  const peach = (s) => chalk.hex(PEACH)(s);
10
- /**
11
- * Op-row glyph. 2026-05-01: dropped the per-op color spread (yellow M,
12
- * green A, blue >, magenta ^). Using 5 different hues for routine plan
13
- * rows trains the user to ignore color as semantic signal — by the time
14
- * an actual delete shows up, red has already appeared on three other
15
- * lines and lost its punch. Now: only `D` (delete) gets red, because
16
- * delete is the only op that's destructive enough to deserve a hot
17
- * accent. Everything else renders dim — the row layout already tells
18
- * the user it's a change row.
19
- */
20
- function opIcon(op) {
21
- switch (op) {
22
- case 'create': return chalk.dim('A');
23
- case 'modify': return chalk.dim('M');
24
- case 'delete': return chalk.red('D');
25
- case 'activate': return chalk.dim('>');
26
- case 'release': return chalk.dim('^');
27
- default: return chalk.dim('?');
28
- }
29
- }
30
11
  /**
31
12
  * Risk tag. 2026-05-01: yellow used to fire on `medium` too, which
32
13
  * meant most plans had a yellow tag — defeating yellow's purpose as
@@ -119,12 +100,14 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
119
100
  if (effectiveRisk.raisedBy.length > 0) {
120
101
  ctx.push(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`);
121
102
  }
122
- for (const c of visibleChanges)
123
- ctx.push(` ${opIcon(c.op)} ${c.object} (${c.type})`);
103
+ // Numbered, operation-aware verb rows ("1. Create table ZMNT_NOTE") —
104
+ // no cryptic op-code glyph. The Ink modal renders `context` itself, so
105
+ // no chalk here.
106
+ visibleChanges.forEach((c, i) => ctx.push(` ${i + 1}. ${verbLine(c)}`));
124
107
  const result = await askQuestionEmitter.request({
125
108
  id: 'plan-gate',
126
109
  question: 'Apply this plan?',
127
- // Strip chalk ANSI from opIcon/riskTag — the Ink modal renders `context`
110
+ // Strip chalk ANSI from riskTag — the Ink modal renders `context`
128
111
  // itself (dimColor), so embedded escape codes would show as raw text.
129
112
  context: ctx.join('\n').replace(/\x1b\[[0-9;]*m/g, ''),
130
113
  kind: 'choice',
@@ -149,9 +132,9 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
149
132
  if (effectiveRisk.raisedBy.length > 0) {
150
133
  console.log(` ${chalk.dim(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`)}`);
151
134
  }
152
- for (const c of visibleChanges) {
153
- console.log(` ${opIcon(c.op)} ${c.object.padEnd(30)} ${chalk.dim(`(${c.type})`)}`);
154
- }
135
+ visibleChanges.forEach((c, i) => {
136
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
137
+ });
155
138
  console.log('');
156
139
  const choice = await withInquirer(() => select({
157
140
  message: 'Apply?',
@@ -173,17 +156,20 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
173
156
  // fires standalone (single-change request_approval) AND risk is non-low.
174
157
  void effectiveRisk;
175
158
  void transport;
176
- const objLabel = `${opIcon(change.op)} ${change.object} (${change.type})`;
159
+ // Verb-first label ("Create table ZMNT_NOTE — apply?") — no op-code glyph.
160
+ const objLabel = verbLine(change);
161
+ // Op-aware review choice: [d] Show diff for modify, [v] View source for
162
+ // create/delete-with-source, omitted for activate/release.
163
+ const aff = viewAffordance(change);
164
+ const choices = [{ value: 'apply', name: 'Apply' }];
165
+ if (aff)
166
+ choices.push({ value: 'diff', name: aff.label });
167
+ choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
177
168
  const choice = await withInquirer(() => select({
178
169
  message: `${objLabel} — apply?`,
179
170
  default: 'apply',
180
171
  theme: inquirerTheme,
181
- choices: [
182
- { value: 'apply', name: 'Apply' },
183
- { value: 'diff', name: 'Show diff' },
184
- { value: 'skip', name: 'Skip (tell model why)' },
185
- { value: 'cancel', name: 'Cancel turn' },
186
- ],
172
+ choices,
187
173
  }));
188
174
  if (choice === 'apply')
189
175
  return { approved: true };
@@ -193,9 +179,29 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
193
179
  const reason = await withInquirer(() => input({ message: 'Why skip?', theme: inquirerTheme }));
194
180
  return { approved: false, reason };
195
181
  }
196
- // 'diff' — print colorized + lines (green) / - lines (red) / hunk headers
197
- // (peach) so the user can scan the change at a glance instead of squinting
198
- // at flat-gray text.
182
+ // Review choice.
183
+ //
184
+ // I3 (2026-07-05 review): a 'v' affordance (create/delete View source) is
185
+ // NOT a diff — the payload is plain/all-adds source. Routing it through the
186
+ // diff printer fired the "⚠ Model did not provide a unified diff" warning
187
+ // under a View source label. Print numbered plain source instead — never
188
+ // the diff path or its warning. (The affordance is only offered when
189
+ // change.diff is present, so the fallback line is belt-and-braces.)
190
+ if (aff?.key === 'v') {
191
+ if (change.diff) {
192
+ const numbered = toSourceLines(change.diff)
193
+ .map((line, i) => `${chalk.dim(String(i + 1).padStart(4) + ' ')}${line}`)
194
+ .join('\n');
195
+ console.log('\n' + numbered + '\n');
196
+ }
197
+ else {
198
+ console.log(chalk.dim('(No source provided by the model)'));
199
+ }
200
+ return renderPerChangeApproval(change, effectiveRisk, transport);
201
+ }
202
+ // 'd' (modify) — print colorized + lines (green) / - lines (red) / hunk
203
+ // headers (peach) so the user can scan the change at a glance instead of
204
+ // squinting at flat-gray text.
199
205
  //
200
206
  // 2026-05-01: detect "model sent a description, not a diff" — when the
201
207
  // payload has no +/-/@ lines and no `--- /+++ ` headers, colorizeDiff
package/dist/cli.js CHANGED
@@ -62,6 +62,20 @@ export async function main(argv) {
62
62
  }
63
63
  return 0;
64
64
  }
65
+ if (args[0] === 'config' && args[1] === 'unset') {
66
+ const { runConfigUnset, ConfigSetError } = await import('./commands/config-set.js');
67
+ try {
68
+ await runConfigUnset(args.slice(2));
69
+ }
70
+ catch (e) {
71
+ if (e instanceof ConfigSetError) {
72
+ console.error(chalk.red(`✗ ${e.message}`));
73
+ process.exit(e.exitCode);
74
+ }
75
+ throw e;
76
+ }
77
+ return 0;
78
+ }
65
79
  if (args[0] === 'config' && args[1] === 'show') {
66
80
  const { runConfigShow } = await import('./commands/config-show.js');
67
81
  await runConfigShow(args.slice(2));
@@ -134,6 +148,7 @@ Usage:
134
148
  cspeach config add-sap Add a SAP system interactively
135
149
  cspeach configure Alias for \`cspeach config add-sap\`
136
150
  cspeach config set <key> <value> Update a config key
151
+ cspeach config unset <key> Remove a config key (revert to default)
137
152
  cspeach config show [key] Show config (full or single key)
138
153
  cspeach doctor Diagnose install + connectivity
139
154
  cspeach login Sign in via device-code flow
@@ -161,6 +161,24 @@ export async function backupBeforeCompact(session) {
161
161
  await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8');
162
162
  return filePath;
163
163
  }
164
+ /**
165
+ * UX Wave 2 / Task 4 — render session.todos as a plain-text,
166
+ * status-prefixed block for the compacted context. Returns '' when there
167
+ * is no list (undefined or empty) so callers can append unconditionally.
168
+ *
169
+ * Format (explicit status words, not glyphs — the reader is the NEXT
170
+ * turn's model, and `[in_progress]` is unambiguous where `[>]` isn't):
171
+ *
172
+ * Current task list:
173
+ * - [completed] Create table ZORDER
174
+ * - [in_progress] Write behavior definition
175
+ * - [pending] Publish service binding
176
+ */
177
+ export function renderTodoSection(todos) {
178
+ if (!todos || todos.length === 0)
179
+ return '';
180
+ return ['', 'Current task list:', ...todos.map((t) => `- [${t.status}] ${t.text}`)].join('\n');
181
+ }
164
182
  /**
165
183
  * The compacted "synthesised earlier history" message that replaces the
166
184
  * summarised slice in session.messages. Role=user because (a) Anthropic
@@ -168,9 +186,16 @@ export async function backupBeforeCompact(session) {
168
186
  * makes it visually distinct from the model's prior assistant outputs, and
169
187
  * (c) the explicit "context compacted by /compact" framing tells the next
170
188
  * turn's model exactly what it's looking at.
189
+ *
190
+ * UX Wave 2 / Task 4: when `todos` is provided (runCompact passes
191
+ * session.todos), a `Current task list:` section is appended after the
192
+ * summary so the model retains its plan across /compact — the summarised
193
+ * turns are where the list was built, and Haiku isn't trusted to
194
+ * reproduce it verbatim.
171
195
  */
172
- export function buildSummaryMessage(summary, droppedTurns) {
196
+ export function buildSummaryMessage(summary, droppedTurns, todos) {
173
197
  const ts = new Date().toISOString();
198
+ const todoSection = renderTodoSection(todos);
174
199
  return {
175
200
  role: 'user',
176
201
  content: [
@@ -180,6 +205,7 @@ export function buildSummaryMessage(summary, droppedTurns) {
180
205
  `to ~/.cspeach/sessions/${'<session-id>'}.pre-compact-<timestamp>.json]`,
181
206
  '',
182
207
  summary,
208
+ ...(todoSection ? [todoSection] : []),
183
209
  ].join('\n'),
184
210
  };
185
211
  }
@@ -228,7 +254,7 @@ export async function runCompact(opts) {
228
254
  // which it will be unless the session is very long. (For very long sessions
229
255
  // we accept losing the original first prompt because the summary should
230
256
  // mention what the goal was.)
231
- const summaryMsg = buildSummaryMessage(summary, plan.toSummarise.length);
257
+ const summaryMsg = buildSummaryMessage(summary, plan.toSummarise.length, session.todos);
232
258
  session.messages = [summaryMsg, ...plan.toKeep];
233
259
  await saveSession(session);
234
260
  return {