@cspeach/cli 1.1.27 → 1.1.29

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 (68) hide show
  1. package/dist/agent/loop.js +33 -4
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/agent/turn-error-ux.js +40 -0
  9. package/dist/approvals/grant-claims.js +70 -0
  10. package/dist/approvals/jwt.js +21 -1
  11. package/dist/approvals/plan-grant.js +297 -0
  12. package/dist/commands/config-set.js +12 -0
  13. package/dist/commands/deploy-card.js +10 -5
  14. package/dist/commands/export-audit.js +1 -1
  15. package/dist/commands/plan-audit-evidence.js +47 -1
  16. package/dist/commands/plan-audit.js +23 -8
  17. package/dist/commands/plan-batch-audit.js +349 -0
  18. package/dist/commands/plan-boundary.js +88 -0
  19. package/dist/commands/plan-chain.js +409 -566
  20. package/dist/commands/plan-continue.js +21 -7
  21. package/dist/commands/plan-gate.js +28 -49
  22. package/dist/commands/plan-reground.js +174 -0
  23. package/dist/commands/plan-resume.js +227 -103
  24. package/dist/commands/plan-sap-pause.js +105 -0
  25. package/dist/commands/plan-startup.js +218 -0
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/run.js +4 -4
  29. package/dist/lane/app-chain.js +88 -9
  30. package/dist/one-shot.js +1 -0
  31. package/dist/projects/extract-plan.js +34 -1
  32. package/dist/projects/handover-md.js +1 -1
  33. package/dist/projects/plan-objects.js +75 -0
  34. package/dist/projects/plan-run.js +74 -18
  35. package/dist/projects/plan-schema.js +6 -0
  36. package/dist/projects/plan-sources.js +199 -0
  37. package/dist/projects/promote-command.js +1 -1
  38. package/dist/projects/save-command.js +11 -13
  39. package/dist/projects/saved-lines.js +36 -0
  40. package/dist/renderer/checklist-format.js +5 -5
  41. package/dist/renderer/machine-blocks.js +141 -0
  42. package/dist/renderer/tool-labels.js +3 -0
  43. package/dist/repl/plan-hop.js +22 -0
  44. package/dist/repl/rule8-detector.js +16 -1
  45. package/dist/repl.js +320 -55
  46. package/dist/router/router-core.js +2 -2
  47. package/dist/sap/reachability-state.js +25 -0
  48. package/dist/session/audit-export.js +10 -16
  49. package/dist/session/recap.js +16 -26
  50. package/dist/skills/bundled-skills.js +81 -81
  51. package/dist/tools/_filesystem-shared.js +37 -0
  52. package/dist/tools/approval.js +48 -2
  53. package/dist/tools/dispatch-skill.js +3 -2
  54. package/dist/tools/filesystem/file-read.js +5 -3
  55. package/dist/tools/filesystem/glob.js +27 -4
  56. package/dist/tools/fiori/tools.js +6 -0
  57. package/dist/tools/lane/app-lane-resolve.js +1 -1
  58. package/dist/tools/sap-read.js +37 -3
  59. package/dist/tools/sap-write.js +65 -0
  60. package/dist/tools/text-elements/pool.js +250 -0
  61. package/dist/tools/text-elements/tools.js +433 -0
  62. package/dist/tools/tool-loading.js +3 -0
  63. package/dist/tools/transport.js +26 -5
  64. package/dist/tools/write-mode.js +4 -3
  65. package/dist/ui/app.js +4 -0
  66. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  67. package/package.json +120 -121
  68. package/dist/projects/email-template.js +0 -56
@@ -0,0 +1,349 @@
1
+ // cspeach-cli/src/commands/plan-batch-audit.ts
2
+ //
3
+ // 2026-10-04 (continuous run, spec §3.4) — the independent audit runs ONCE,
4
+ // after the last step (or over the steps done when a run stops early). Each
5
+ // step is judged against its own evidence window [attemptStartedAt,
6
+ // attemptEndedAt). Quiet: one line, no token counters, no timers, retries
7
+ // silent (they live inside runPhaseAudit). An audit that cannot run leaves the
8
+ // step 'pending' — "not independently checked yet" — and the next resume runs it.
9
+ import { readProjectFile } from '../projects/status.js';
10
+ import { parsePlanContent } from '../projects/plan-schema.js';
11
+ import { phaseDisplayTitle } from '../projects/plan-run.js';
12
+ import { runPhaseAudit } from './plan-audit.js';
13
+ import { applyAuditResult, applyPhaseResolution } from './plan-resume.js';
14
+ import { getGlyphs } from '../renderer/glyphs.js';
15
+ import { paint } from '../renderer/theme.js';
16
+ import { clean } from '../renderer/tool-labels.js';
17
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
18
+ import { appendCostLine, buildEntry } from '../cost/cost-log.js';
19
+ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
20
+ /** The one pending scan lives in plan-run.ts; re-exported for the batch callers. */
21
+ export { pendingAuditPhaseIds } from '../projects/plan-run.js';
22
+ /** Two consecutive failed verdicts on the same phase force status 'blocked'. */
23
+ export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
24
+ function load(path) {
25
+ try {
26
+ const envelope = readProjectFile(path);
27
+ const pc = parsePlanContent(envelope.content);
28
+ return pc.ok ? { envelope, content: pc.content } : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** One line on screen; the status row carries the "(step i of n)" count. */
35
+ function defaultProgress(log) {
36
+ let started = false;
37
+ return {
38
+ update(text) {
39
+ if (!started) {
40
+ log('', paint('dim', 'Checking the work independently…'));
41
+ turnStatusEmitter.start(text);
42
+ started = true;
43
+ }
44
+ else {
45
+ turnStatusEmitter.activity(text);
46
+ }
47
+ },
48
+ done() {
49
+ if (started)
50
+ turnStatusEmitter.stop();
51
+ },
52
+ };
53
+ }
54
+ function recordCost(sessionId, cost) {
55
+ try {
56
+ void appendCostLine(sessionId, {
57
+ ...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
58
+ label: 'audit',
59
+ });
60
+ }
61
+ catch {
62
+ // observability only — never on screen (spec §3.4: no token counters)
63
+ }
64
+ }
65
+ /**
66
+ * The evidence window for one step. Both ends, or only the start (an older
67
+ * record with no save moment). An end WITHOUT a start passes no window at
68
+ * all: an until-only window would hide later rows yet still admit every
69
+ * earlier phase's evidence, so the whole session is the honest fallback.
70
+ */
71
+ function windowOf(audit) {
72
+ if (!audit?.attemptStartedAt)
73
+ return {};
74
+ return {
75
+ sinceIso: audit.attemptStartedAt,
76
+ ...(audit.attemptEndedAt ? { untilIso: audit.attemptEndedAt } : {}),
77
+ };
78
+ }
79
+ /** The finding text without its auditor tag ("[B1] ", "[BLOCK] "). */
80
+ function findingText(f) {
81
+ return clean(f).replace(/^\[[A-Z0-9_-]+\]\s*/, '');
82
+ }
83
+ /** F32 — the suggested fix names THIS step and what its own check found. */
84
+ function suggestedFix(title, findings) {
85
+ if (findings.length === 0)
86
+ return `Suggested fix: run ${title} again; it is redone and checked again.`;
87
+ const first = findingText(findings[0]);
88
+ if (findings.length === 1)
89
+ return `Suggested fix: run ${title} again so it fixes this: ${first}`;
90
+ return `Suggested fix: run ${title} again so it fixes the ${findings.length} points above, starting with: ${first}`;
91
+ }
92
+ export async function runBatchAudit(args) {
93
+ const res = { path: args.savedPath, confirmed: [], failed: [], blocked: [], notChecked: [], rerun: false };
94
+ if (args.phaseIds.length === 0)
95
+ return res;
96
+ const auditFn = args.deps?.runPhaseAuditFn ?? runPhaseAudit;
97
+ const progress = args.deps?.progress ?? defaultProgress(args.log);
98
+ const total = args.phaseIds.length;
99
+ const findingsById = new Map();
100
+ const notesById = new Map();
101
+ try {
102
+ for (let i = 0; i < total; i++) {
103
+ const id = args.phaseIds[i];
104
+ const loaded = load(res.path);
105
+ if (!loaded) {
106
+ // The plan file cannot be read back: nothing more can be judged or
107
+ // written. The rest are not independently checked yet.
108
+ res.notChecked.push(...args.phaseIds.slice(i));
109
+ break;
110
+ }
111
+ const phase = loaded.content.phases.find((p) => p.id === id);
112
+ if (!phase)
113
+ continue;
114
+ const state = phase.audit?.state;
115
+ if (!args.force && state !== 'pending' && state !== 'failed')
116
+ continue;
117
+ progress.update(`Checking the work independently… (step ${i + 1} of ${total})`);
118
+ const verdict = await auditFn({
119
+ envelope: loaded.envelope,
120
+ phaseId: id,
121
+ sessionId: phase.audit?.sessionId ?? args.sessionId,
122
+ cwd: args.cwd,
123
+ ...windowOf(phase.audit),
124
+ onCost: (c) => recordCost(args.sessionId, c),
125
+ });
126
+ if (verdict.state === 'infra_failed') {
127
+ // The check could not run: write nothing. 'pending' stays on disk —
128
+ // "not independently checked yet" — and the next resume runs it.
129
+ res.notChecked.push(id);
130
+ continue;
131
+ }
132
+ // A persisted 'failed' (or an infra_failed that carries a failure
133
+ // count) re-judged here is the SAME failure — the step never re-ran —
134
+ // so the counter holds instead of climbing (plan-chain's rule).
135
+ const prior = phase.audit?.consecutiveFailures ?? 0;
136
+ const reconfirming = state === 'failed' || (state === 'infra_failed' && prior >= 1);
137
+ const counted = reconfirming ? Math.max(prior, 1) : prior + 1;
138
+ const forceBlock = verdict.state === 'failed' && counted >= MAX_CONSECUTIVE_AUDIT_FAILURES;
139
+ res.path = await applyAuditResult(res.path, id, { state: verdict.state, findings: verdict.findings }, {
140
+ ...(verdict.state === 'failed'
141
+ ? { lastStop: { phaseId: id, reason: 'audit-failed', detail: (verdict.findings[0] ?? '').slice(0, 200), at: new Date().toISOString() } }
142
+ : {}),
143
+ ...(forceBlock ? { statusOverride: 'blocked' } : {}),
144
+ ...(reconfirming ? { reconfirmedFailure: true } : {}),
145
+ log: args.log,
146
+ });
147
+ if (verdict.state === 'failed') {
148
+ (forceBlock ? res.blocked : res.failed).push(id);
149
+ findingsById.set(id, verdict.findings);
150
+ }
151
+ else {
152
+ res.confirmed.push(id);
153
+ if (verdict.state === 'warn')
154
+ notesById.set(id, verdict.findings);
155
+ }
156
+ }
157
+ }
158
+ finally {
159
+ progress.done();
160
+ }
161
+ const after = load(res.path);
162
+ const phases = after?.content.phases ?? [];
163
+ const titleOf = (id) => {
164
+ const p = phases.find((q) => q.id === id);
165
+ // Never a raw phase id on screen: a plain fallback when the plan cannot be read.
166
+ return p ? sanitizeControlChars(phaseDisplayTitle(p, phases)) : 'a step';
167
+ };
168
+ const g = getGlyphs();
169
+ // F7 — the "all confirmed" line only when this pass really confirmed a step;
170
+ // a pass that checked nothing prints nothing.
171
+ const allClear = res.failed.length === 0 && res.blocked.length === 0 && res.notChecked.length === 0;
172
+ if (res.confirmed.length > 0) {
173
+ if (allClear) {
174
+ const n = res.confirmed.length;
175
+ args.log(paint('ok', `${g.done} Independent check: all ${n} step${n === 1 ? '' : 's'} confirmed.`));
176
+ }
177
+ else {
178
+ args.log(paint('ok', `${g.done} Independent check confirmed: ${res.confirmed.map(titleOf).join(', ')}.`));
179
+ }
180
+ }
181
+ for (const [id, notes] of notesById) {
182
+ args.log(paint('warn', `${g.attention} ${titleOf(id)} — notes from the check:`));
183
+ notes.forEach((n, i) => args.log(` ${i + 1}. ${clean(n)}`));
184
+ }
185
+ if (res.notChecked.length > 0) {
186
+ args.log(paint('warn', `${g.attention} The independent check could not run for: ${res.notChecked.map(titleOf).join(', ')}. `
187
+ + 'The build is done; these steps are not independently checked yet — CSPeach checks them on the next resume.'));
188
+ }
189
+ /** What the check found for one step, then that step's own suggested fix. */
190
+ const showMissing = (id, title) => {
191
+ const findings = findingsById.get(id) ?? [];
192
+ args.log('', paint('bad', `${g.failed} ${title} — what is missing:`));
193
+ (findings.length > 0 ? findings : ['(the check gave no detail)']).forEach((f, i) => args.log(` ${i + 1}. ${clean(f)}`));
194
+ args.log(paint('dim', ` ${suggestedFix(title, findings)}`));
195
+ return findings;
196
+ };
197
+ for (const id of res.blocked) {
198
+ const title = titleOf(id);
199
+ showMissing(id, title);
200
+ args.log(paint('bad', `${g.failed} ${title} failed its independent check twice in a row — marked blocked.`));
201
+ }
202
+ const select = args.deps?.selectFailureAction ?? defaultSelectFailureAction;
203
+ for (const id of res.failed) {
204
+ const title = titleOf(id);
205
+ const findings = showMissing(id, title);
206
+ const action = await select({ phaseId: id, title, findings });
207
+ if (action === 'rerun') {
208
+ res.path = await applyPhaseResolution(res.path, id, {
209
+ status: 'todo', summary: `audit ${id}: failed — user chose re-run (status → todo)`, log: args.log,
210
+ });
211
+ res.rerun = true;
212
+ }
213
+ else if (action === 'blocked') {
214
+ res.path = await applyPhaseResolution(res.path, id, {
215
+ status: 'blocked',
216
+ lastStop: { phaseId: id, reason: 'audit-failed', detail: 'user marked the phase blocked after a failed audit', at: new Date().toISOString() },
217
+ summary: `audit ${id}: failed — user marked phase blocked`, log: args.log,
218
+ });
219
+ }
220
+ else if (action === 'waive') {
221
+ const reasonRaw = (await args.prompt(`Why waive the check for ${title}? (one line): `)).trim();
222
+ const reason = reasonRaw.length > 0 ? reasonRaw : 'waived by user (no reason given)';
223
+ const category = await (args.deps?.selectWaiveCategory ?? defaultSelectWaiveCategory)({ phaseId: id, title });
224
+ res.path = await applyAuditResult(res.path, id, { state: 'waived', waivedReason: reason, waiveCategory: category }, { log: args.log });
225
+ args.log(paint('dim', `${g.info} Check waived for ${title} (${category}) — reason recorded in the plan file.`));
226
+ }
227
+ // 'none' (headless / cancelled): fail closed — the failure stays recorded;
228
+ // the next resume re-checks it and asks again.
229
+ }
230
+ return res;
231
+ }
232
+ /**
233
+ * Shared menu surface for the plan-run selects (audit resolution, and the
234
+ * failed-check stop card in plan-chain.ts). Mirrors safety-confirm.ts:
235
+ * headless → no decision ('none': fail-closed, nothing written); Ink →
236
+ * confirm card via askConfirm (piece 2 §5); classic → inquirer select.
237
+ */
238
+ export async function selectPlanCardAction(args) {
239
+ const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
240
+ if (isHeadless())
241
+ return 'none';
242
+ if (shouldUseInk()) {
243
+ // D-C — this card opens in the POST-PHASE chain. A leaked keystroke used
244
+ // to auto-select choice 1 ("Re-run phase") and silently re-run the
245
+ // phase. The card slot claims input focus (spinner stopped, status row
246
+ // paused, restored after), digits only move the cursor, and typed-ahead
247
+ // keys are dropped — so no stray key can pick a choice.
248
+ const { askConfirm } = await import('../ui/confirm-request.js');
249
+ const res = await askConfirm({
250
+ id: args.id,
251
+ tag: args.tag ?? 'SAFETY CHECK',
252
+ title: args.question,
253
+ facts: args.facts,
254
+ choices: args.choices,
255
+ initial: 0,
256
+ footnote: args.footnote ?? 'Plan audit',
257
+ });
258
+ if (res.cancelled || res.value == null)
259
+ return 'none';
260
+ return coerceAction(res.value, args.choices);
261
+ }
262
+ const { select } = await import('@inquirer/prompts');
263
+ const { withInquirer } = await import('../repl/inquirer-guard.js');
264
+ const { inquirerTheme } = await import('../repl/inquirer-theme.js');
265
+ const picked = await withInquirer(() => select({
266
+ message: args.question,
267
+ theme: inquirerTheme,
268
+ choices: args.choices.map((c) => ({ value: c.value, name: c.label })),
269
+ }));
270
+ return coerceAction(picked, args.choices);
271
+ }
272
+ /**
273
+ * D-C — fail CLOSED on any answer that is not one of this menu's own choice
274
+ * values. `'' ?? 'none'` did NOT catch an empty-string answer (nullish
275
+ * coalescing only guards null/undefined), so a stray/blank answer used to fall
276
+ * through the runPostPhaseChain switch to a silent default. Anything the menu
277
+ * did not explicitly offer maps to 'none' (stop, no auto-action).
278
+ */
279
+ function coerceAction(answer, choices) {
280
+ return choices.some((c) => c.value === answer) ? answer : 'none';
281
+ }
282
+ /** Production failed-verdict menu (see selectPlanCardAction for the surface).
283
+ * The findings are already printed above by renderFindings; the card
284
+ * repeats only the count and the first three. */
285
+ export async function defaultSelectFailureAction(a) {
286
+ const n = a.findings.length;
287
+ const facts = [
288
+ `${n} ${n === 1 ? 'finding' : 'findings'}, listed above`,
289
+ // Findings are model text: display-clean them at the source (ANSI and
290
+ // control characters out, one line each), like the safety-card rows.
291
+ ...a.findings.slice(0, 3).map((f, i) => `${i + 1}. ${clean(f)}`),
292
+ ];
293
+ if (n > 3)
294
+ facts.push(`${getGlyphs().more} ${n - 3} more`);
295
+ return selectPlanCardAction({
296
+ id: 'plan-audit-failed',
297
+ question: `${a.title ?? a.phaseId} failed its independent check — what now?`,
298
+ facts,
299
+ choices: [
300
+ { value: 'rerun', label: 'Fix it now (CSPeach re-runs this step; it is checked again after)' },
301
+ { value: 'waive', label: 'Waive the check (record a one-line reason)' },
302
+ { value: 'blocked', label: 'Mark this step blocked' },
303
+ ],
304
+ });
305
+ }
306
+ /**
307
+ * §10 Q2 (audit-confidence redesign) — production waive-category menu. A LIGHT
308
+ * 2-choice shown right after the waive-reason prompt so the audit's real
309
+ * false-positive rate is measurable. Same surface as selectPlanCardAction, but
310
+ * headless / cancel / stray answer → 'accepted-risk' (the CONSERVATIVE default:
311
+ * a waive no human explicitly labelled a false-positive is recorded as
312
+ * accepted-risk, so the measured FP rate never over-counts). Never hangs.
313
+ */
314
+ /** Exported for the piece-2 behaviour pins (plan-chain-focus-pin.test.ts). */
315
+ export async function defaultSelectWaiveCategory(a) {
316
+ const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
317
+ if (isHeadless())
318
+ return 'accepted-risk';
319
+ const question = `Waiving the check for ${a.title ?? a.phaseId} — why? (measures how often the check is wrong)`;
320
+ const choices = [
321
+ { value: 'accepted-risk', label: 'Accepted risk — the finding is real, I accept it and proceed' },
322
+ { value: 'false-positive', label: 'False positive — the audit was wrong, the work is actually fine' },
323
+ ];
324
+ const coerce = (answer) => choices.some((c) => c.value === answer) ? answer : 'accepted-risk';
325
+ if (shouldUseInk()) {
326
+ // Same input-focus discipline as selectPlanCardAction (askConfirm).
327
+ const { askConfirm } = await import('../ui/confirm-request.js');
328
+ const res = await askConfirm({
329
+ id: 'plan-waive-category',
330
+ tag: 'QUESTION',
331
+ title: question,
332
+ choices,
333
+ initial: 0,
334
+ footnote: 'Plan audit',
335
+ });
336
+ if (res.cancelled || res.value == null)
337
+ return 'accepted-risk';
338
+ return coerce(res.value);
339
+ }
340
+ const { select } = await import('@inquirer/prompts');
341
+ const { withInquirer } = await import('../repl/inquirer-guard.js');
342
+ const { inquirerTheme } = await import('../repl/inquirer-theme.js');
343
+ const picked = await withInquirer(() => select({
344
+ message: question,
345
+ theme: inquirerTheme,
346
+ choices: choices.map((c) => ({ value: c.value, name: c.label })),
347
+ }));
348
+ return coerce(picked);
349
+ }
@@ -0,0 +1,88 @@
1
+ import { saveSession } from '../session/store.js';
2
+ import { formatInfo } from '../renderer/notice-shape.js';
3
+ import { recordFullNotice } from '../renderer/notice-log.js';
4
+ import { paint } from '../renderer/theme.js';
5
+ import { backupBeforeCompact, buildSummarisationPrompt, serialiseForSummariser, } from './compact.js';
6
+ import { maybeAutoCompact } from './auto-compact.js';
7
+ import { summariseViaProvider } from '../agent/summarise-via-provider.js';
8
+ import { resetContextForPlanResume } from './plan-resume.js';
9
+ export function buildPhaseBoundaryPrompt() {
10
+ return [
11
+ buildSummarisationPrompt(),
12
+ '',
13
+ 'THIS IS A PLAN STEP BOUNDARY. The next turn runs the next step of the same plan.',
14
+ 'Also keep, word for word where short: every answer the developer gave (spec gaps, decisions),',
15
+ 'the package and transport, the name of every SAP object created or changed and whether it is active,',
16
+ 'and anything the developer asked to do differently. The plan file holds statuses; you hold the reasons.',
17
+ ].join('\n');
18
+ }
19
+ export function buildPhaseBoundaryMessage(summary) {
20
+ return {
21
+ role: 'user',
22
+ content: [
23
+ '[Summary of the earlier steps of this plan, written by CSPeach at a step boundary.',
24
+ 'The plan state and the spec-gap answers in the next message are authoritative.]',
25
+ '',
26
+ summary.trim(),
27
+ ].join('\n'),
28
+ };
29
+ }
30
+ export async function compactAtPhaseBoundary(session, summarise) {
31
+ if (session.messages.length === 0)
32
+ return { performed: false };
33
+ // Summarise FIRST; the session is untouched until a usable summary exists.
34
+ const summary = await summarise(session.messages);
35
+ if (!summary || summary.trim().length === 0)
36
+ throw new Error('the summary came back empty');
37
+ const backupPath = await backupBeforeCompact(session);
38
+ session.messages = [buildPhaseBoundaryMessage(summary)];
39
+ delete session.last_context_tokens;
40
+ await saveSession(session);
41
+ return { performed: true, backupPath };
42
+ }
43
+ export async function beginPlanPhaseContext(a) {
44
+ if (a.session.messages.length === 0)
45
+ return 'empty';
46
+ try {
47
+ const r = await compactAtPhaseBoundary(a.session, a.summarise);
48
+ if (r.performed) {
49
+ a.log(formatInfo('Earlier steps summarised · your answers and decisions carry on').trimEnd());
50
+ return 'compacted';
51
+ }
52
+ }
53
+ catch (err) {
54
+ // Task 13 — a fixed plain line; the error detail goes to the session log only.
55
+ recordFullNotice('plan', `step-boundary summary failed: ${err instanceof Error ? err.message : String(err)}`);
56
+ a.log(paint('dim', 'Could not summarise the earlier steps. This step starts fresh; the plan file keeps everything.'));
57
+ }
58
+ resetContextForPlanResume(a.session);
59
+ return 'reset';
60
+ }
61
+ // Controller ruling F3 — every provider rebuilds the `system` prompt from the
62
+ // skill named in X-CSForge-Skill (here: compact), so the client's `system`
63
+ // never reaches the model. The `system` arg stays the same as the /compact
64
+ // path; anything EXTRA the summary must keep travels in the USER content,
65
+ // ahead of the serialised conversation.
66
+ const COMPACT_CALL = {
67
+ maxTokens: 4000,
68
+ headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' },
69
+ };
70
+ export function makePhaseBoundarySummariser(provider, model) {
71
+ return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), buildPhaseBoundaryPrompt() + '\n\n' + serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers } });
72
+ }
73
+ /** The standard /compact summarisation (the compact skill carries the instructions). */
74
+ export function makeAutoCompactSummariser(provider, model) {
75
+ return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers } });
76
+ }
77
+ /** Auto-compact after a plan turn whose revision is already saved and that queued no next step. */
78
+ export async function maybeAutoCompactAfterPlanTurn(a) {
79
+ if (a.queued)
80
+ return;
81
+ await maybeAutoCompact({
82
+ session: a.session,
83
+ config: a.config,
84
+ turnNumber: a.session.turn_seq ?? 0,
85
+ summarise: a.summarise,
86
+ emit: a.emit,
87
+ });
88
+ }