@cspeach/cli 1.1.50 → 1.1.51
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.
- package/dist/agent/loop.js +106 -3
- package/dist/cli.js +1 -1
- package/dist/commands/compact.js +6 -0
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/export-audit.js +26 -3
- package/dist/commands/export-conversation.js +343 -0
- package/dist/commands/help.js +3 -0
- package/dist/commands/plan-boundary.js +5 -0
- package/dist/commands/plan-continue.js +11 -0
- package/dist/commands/plan-startup.js +5 -1
- package/dist/docx/doc-meta.js +9 -1
- package/dist/docx/markdown-to-docx.js +14 -1
- package/dist/lane/app-chain.js +24 -1
- package/dist/projects/document-save.js +65 -8
- package/dist/projects/document-skills.js +32 -1
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/slash-completer.js +4 -0
- package/dist/repl.js +34 -10
- package/dist/session/cli-record.js +165 -0
- package/dist/session/fresh-start.js +4 -0
- package/dist/ui/card-slot.js +18 -1
- package/package.json +1 -1
|
@@ -20,6 +20,7 @@ import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, stat
|
|
|
20
20
|
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
21
21
|
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
|
|
22
22
|
import { newestPlanFamilyFiles } from '../session/recap.js';
|
|
23
|
+
import { recordCliCard } from '../session/cli-record.js';
|
|
23
24
|
import { legacyDirsFor } from '../projects/project-folder.js';
|
|
24
25
|
import { getPlanGrant, installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
|
|
25
26
|
import { DEFAULT_PLAN_MODE } from './plan-gate.js';
|
|
@@ -256,6 +257,9 @@ export function classicStartupChooser(prompt, log) {
|
|
|
256
257
|
log('', q.title, ...q.facts.map((f) => ` ${f}`), ...q.choices.map((c, i) => ` ${i + 1}. ${c.label}`));
|
|
257
258
|
const ans = (await prompt(`Choose 1-${q.choices.length} (Enter = ${q.choices[0].label}): `)).trim();
|
|
258
259
|
const n = ans === '' ? 1 : Number(ans);
|
|
259
|
-
|
|
260
|
+
const picked = Number.isInteger(n) && n >= 1 && n <= q.choices.length ? q.choices[n - 1] : null;
|
|
261
|
+
// /export docx conversation: the classic startup choice is recorded like the Ink card.
|
|
262
|
+
recordCliCard({ question: q.title, options: q.choices.map((c) => c.label), ...(q.facts.length ? { facts: [...q.facts] } : {}), answer: picked?.label ?? 'Cancelled', by: 'user' });
|
|
263
|
+
return picked ? picked.value : null;
|
|
260
264
|
};
|
|
261
265
|
}
|
package/dist/docx/doc-meta.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// The metadata a Word copy carries on its title page. Filled by the caller
|
|
4
4
|
// (the save hook knows the session's SAP alias; the export command knows the
|
|
5
5
|
// file) and, for what the text itself says, by metaFromMarkdown (Task 3).
|
|
6
|
-
const KIND_TITLE = {
|
|
6
|
+
const KIND_TITLE = {
|
|
7
|
+
review: 'Code review', handover: 'Handover', document: 'Document',
|
|
8
|
+
explanation: 'Explanation', impact: 'Impact analysis', preflight: 'Preflight check',
|
|
9
|
+
'transport-analysis': 'Transport analysis', migration: 'Migration assessment',
|
|
10
|
+
'clean-core': 'Clean-core assessment', 'custom-code-analysis': 'Custom code analysis',
|
|
11
|
+
'spec-gap': 'Spec gaps', design: 'Design', estimate: 'Estimate', plan: 'Plan',
|
|
12
|
+
'upgrade-scan': 'Upgrade scan', 'upgrade-report': 'Upgrade report',
|
|
13
|
+
answer: 'CSPeach answer', conversation: 'CSPeach conversation',
|
|
14
|
+
};
|
|
7
15
|
/** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
|
|
8
16
|
const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
|
|
9
17
|
/** Inline Markdown → plain words: `code` → code, [text](url) → text, **x** → x. */
|
|
@@ -63,6 +63,10 @@ function titlePage(meta) {
|
|
|
63
63
|
facts.push(`System: ${meta.system}`);
|
|
64
64
|
if (meta.object)
|
|
65
65
|
facts.push(`Object: ${meta.object}`);
|
|
66
|
+
if (meta.session)
|
|
67
|
+
facts.push(`Session: ${meta.session}`);
|
|
68
|
+
if (meta.person)
|
|
69
|
+
facts.push(`Signed in as: ${meta.person}`);
|
|
66
70
|
return [
|
|
67
71
|
new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
|
|
68
72
|
...facts.map((f) => new Paragraph({ text: f })),
|
|
@@ -120,18 +124,27 @@ function list(l, ctx, depth) {
|
|
|
120
124
|
const mark = l.ordered
|
|
121
125
|
? { numbering: { reference: NUMBERED, level, instance } }
|
|
122
126
|
: { bullet: { level } };
|
|
127
|
+
// Task-list items (`- [ ]` / `- [x]`) keep their box at the start of the item (2026-10-23).
|
|
128
|
+
const box = item.task ? (item.checked ? '☒ ' : '☐ ') : '';
|
|
123
129
|
let first = true;
|
|
124
130
|
for (const child of item.tokens) {
|
|
125
131
|
if (child.type === 'list') {
|
|
126
132
|
out.push(...list(child, ctx, depth + 1));
|
|
127
133
|
continue;
|
|
128
134
|
}
|
|
135
|
+
if (first && box && (child.type === 'text' || child.type === 'paragraph')) {
|
|
136
|
+
const p = child;
|
|
137
|
+
const kids = 'tokens' in p && p.tokens ? inline(p.tokens) : [new TextRun(p.text ?? p.raw ?? '')];
|
|
138
|
+
out.push(new Paragraph({ ...mark, children: [new TextRun(box), ...kids] }));
|
|
139
|
+
first = false;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
129
142
|
// The first text block of an item carries the marker; later paragraphs are indented plain text.
|
|
130
143
|
out.push(...blockToDocx(child, ctx, depth, first ? mark : { indent: { left: 720 * (level + 1) } }));
|
|
131
144
|
first = false;
|
|
132
145
|
}
|
|
133
146
|
if (first)
|
|
134
|
-
out.push(new Paragraph({ ...mark, text: item.text ?? '' }));
|
|
147
|
+
out.push(new Paragraph({ ...mark, text: box + (item.text ?? '') }));
|
|
135
148
|
}
|
|
136
149
|
return out;
|
|
137
150
|
}
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -30,6 +30,7 @@ import { containSpecPath, specDisplayPath, SpecProtectedError } from './roots.js
|
|
|
30
30
|
import { resolveAtTokenAsync } from '../projects/workspace.js';
|
|
31
31
|
import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
32
32
|
import { rebuildStartedAfterPlanSave } from '../journey/rebuild.js';
|
|
33
|
+
import { recordCliCard } from '../session/cli-record.js';
|
|
33
34
|
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
34
35
|
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
35
36
|
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
@@ -239,6 +240,10 @@ function readSavedPlan(savedPath) {
|
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
|
|
243
|
+
/** The plan a "Build this now?" record is about: the plan file name and its version. */
|
|
244
|
+
function planRef(savedPath, version) {
|
|
245
|
+
return `${savedPath.split(/[\\/]/).pop() ?? savedPath} v${version}`;
|
|
246
|
+
}
|
|
242
247
|
/**
|
|
243
248
|
* The facts under "Build this now?": steps, the exact object list, transport,
|
|
244
249
|
* deploy, what stops. `grant: false` (the object-list approval cannot be
|
|
@@ -367,6 +372,9 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
367
372
|
return;
|
|
368
373
|
}
|
|
369
374
|
if (h.headless || !h.queueNext) {
|
|
375
|
+
// /export docx conversation: the plan gate was not shown; say so in the record.
|
|
376
|
+
if (h.headless)
|
|
377
|
+
recordCliCard({ question: START_QUESTION, ref: planRef(savedPath, saved.envelope.version), answer: 'Not asked (no one at the terminal)', by: 'headless' }, h.session);
|
|
370
378
|
h.emit(`Start building with: ${cmd}`);
|
|
371
379
|
end();
|
|
372
380
|
return;
|
|
@@ -389,27 +397,42 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
389
397
|
initial: 0,
|
|
390
398
|
aliases: { y: 'yes', n: 'no' },
|
|
391
399
|
});
|
|
400
|
+
// /export docx conversation (fix round 2, N-1): the record a change ticket
|
|
401
|
+
// needs — when, which plan file and version, the objects and transport shown,
|
|
402
|
+
// the answer, and whether the object-list approval was installed. Only when
|
|
403
|
+
// a card was really shown (an injected confirm, or a card listener).
|
|
404
|
+
const shown = h.confirm !== undefined || (await import('../ui/card-slot.js')).cardSlotEmitter.listenerCount('confirm') > 0;
|
|
405
|
+
const record = (answer) => {
|
|
406
|
+
if (shown)
|
|
407
|
+
recordCliCard({ question: START_QUESTION, ref: planRef(savedPath, saved.envelope.version), options: ['Yes', 'No'], facts: [...facts], answer, by: 'user' }, h.session);
|
|
408
|
+
};
|
|
392
409
|
if (res.value === 'yes' && !res.cancelled) {
|
|
393
410
|
// The plan's recorded transport becomes the session transport when the
|
|
394
411
|
// session has none, so the first step's writes land on (and are covered
|
|
395
412
|
// by) it without the model naming it — the same rule a resume uses.
|
|
396
413
|
adoptPlanTransportOnResume(saved.content.phases);
|
|
397
414
|
const sessionId = h.session.id;
|
|
415
|
+
let installed = true;
|
|
398
416
|
if (grantable && objects.length > 0 && typeof sessionId === 'string' && sessionId) {
|
|
399
417
|
const a = { planId: saved.envelope.id, sessionId, objects, ...(transport ? { transport } : {}) };
|
|
400
418
|
try {
|
|
401
419
|
if (h.grant)
|
|
402
420
|
await h.grant(a);
|
|
403
|
-
else if (!(await installPlanGrant({ ...a, ...(sapAlias ? { sapAlias } : {}) })))
|
|
421
|
+
else if (!(await installPlanGrant({ ...a, ...(sapAlias ? { sapAlias } : {}) }))) {
|
|
422
|
+
installed = false;
|
|
404
423
|
h.emit(OBJECT_LIST_FAILED);
|
|
424
|
+
}
|
|
405
425
|
}
|
|
406
426
|
catch {
|
|
427
|
+
installed = false;
|
|
407
428
|
h.emit(OBJECT_LIST_FAILED);
|
|
408
429
|
}
|
|
409
430
|
}
|
|
431
|
+
record(installed ? 'Yes' : 'Yes (object list not installed; each SAP write asks first)');
|
|
410
432
|
h.queueNext(cmd, APP_ORIGIN);
|
|
411
433
|
}
|
|
412
434
|
else {
|
|
435
|
+
record(res.value === 'no' ? 'No' : 'Cancelled');
|
|
413
436
|
h.emit(`Start it later with: ${cmd}`);
|
|
414
437
|
}
|
|
415
438
|
end();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// cspeach-cli/src/projects/document-save.ts
|
|
2
2
|
//
|
|
3
|
-
// The save for the
|
|
4
|
-
//
|
|
3
|
+
// The save for the document skills (2026-10-19, owner decision 2026-10-17;
|
|
4
|
+
// widened 2026-10-23, Word copy everywhere — see document-skills.ts).
|
|
5
|
+
// A review, a handover, an explanation, … is prose, not an envelope: it goes to
|
|
5
6
|
// <cwd>/cspeach-out/<date>-<kind>-<OBJECT>.md (the folder the model already
|
|
6
7
|
// writes extra copies to; skipped by the @-picker; exempt from the Rule 8
|
|
7
8
|
// card). Then one question, "Word copy too?", unless `word_copy` decides.
|
|
@@ -13,8 +14,9 @@ import { askYesNo, isYes } from './yes-no.js';
|
|
|
13
14
|
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
14
15
|
import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
|
|
15
16
|
import { safeFileStem } from '../register/xlsx.js';
|
|
16
|
-
import { DOCUMENT_SKILLS } from './document-skills.js';
|
|
17
|
-
|
|
17
|
+
import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from './document-skills.js';
|
|
18
|
+
import { handoverPathFor } from './handover-md.js';
|
|
19
|
+
export { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE };
|
|
18
20
|
export const OUT_DIR = 'cspeach-out';
|
|
19
21
|
export function documentFilename(kind, object, date) {
|
|
20
22
|
const stem = safeFileStem((object ?? 'document').replace(/\//g, ''));
|
|
@@ -44,19 +46,74 @@ export async function offerDocumentSave(p) {
|
|
|
44
46
|
return {};
|
|
45
47
|
}
|
|
46
48
|
p.emit(`Saved: ${path.resolve(mdPath)}`);
|
|
49
|
+
const docxPath = await offerWordCopy({
|
|
50
|
+
// Fix round 1 (m-1): the newer document skills' Word copy leaves out HTML comments
|
|
51
|
+
// (widget markers); review and handover pass their text exactly as before.
|
|
52
|
+
nextTo: mdPath, markdown: skill.yieldsToArtifact ? p.markdown.replace(/<!--[\s\S]*?-->\s*/g, '') : p.markdown, meta, headless: p.headless, wordCopy: p.wordCopy, confirm: p.confirm, emit: p.emit,
|
|
53
|
+
});
|
|
54
|
+
return docxPath ? { mdPath, docxPath } : { mdPath };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The Word step, shared by every save of a document (Word copy everywhere,
|
|
58
|
+
* 2026-10-23). `word_copy` decides: unset → ask "Word copy too?", on →
|
|
59
|
+
* always, off → never. Headless never asks: the copy is written only when
|
|
60
|
+
* `word_copy` is on. Never overwrites, never throws: a failure is one line.
|
|
61
|
+
* Returns the .docx path when one was written.
|
|
62
|
+
*/
|
|
63
|
+
export async function offerWordCopy(p) {
|
|
47
64
|
let wantWord = p.wordCopy === 'always';
|
|
48
65
|
if (p.wordCopy === 'ask' && !p.headless) {
|
|
49
66
|
wantWord = isYes(await askYesNo({ question: 'Word copy too?', confirm: p.confirm }));
|
|
50
67
|
}
|
|
51
68
|
if (!wantWord)
|
|
52
|
-
return
|
|
69
|
+
return undefined;
|
|
53
70
|
try {
|
|
54
|
-
const docxPath = await writeDocxNextTo(
|
|
71
|
+
const docxPath = await writeDocxNextTo(p.nextTo, p.markdown, p.meta);
|
|
55
72
|
p.emit(`Word copy: ${path.resolve(docxPath)}`);
|
|
56
|
-
return
|
|
73
|
+
return docxPath;
|
|
57
74
|
}
|
|
58
75
|
catch (e) {
|
|
59
76
|
p.emit(`Word copy not written. ${e.message}`);
|
|
60
|
-
return
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Artefact blocks (`<!-- cspeach:…-manifest … -->`, legacy `csforge:` too) are for CSPeach, not for the reader. */
|
|
81
|
+
const ARTIFACT_BLOCK_RE = /<!--\s*(?:csforge|cspeach):[\w-]+\s*\n[\s\S]*?-->\s*/g;
|
|
82
|
+
/** The YAML header at the top of a plan's handover Markdown. */
|
|
83
|
+
const FRONT_MATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n/;
|
|
84
|
+
/** The text a person reads: no artefact blocks; the YAML header only for a plan handover (final-review m-3). */
|
|
85
|
+
export function readerMarkdown(markdown, frontMatter = true) {
|
|
86
|
+
return (frontMatter ? markdown.replace(FRONT_MATTER_RE, '') : markdown).replace(ARTIFACT_BLOCK_RE, '').trim() + '\n';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The Word step after an artefact save ("Save as project file?" said Yes).
|
|
90
|
+
* The project file is the source of truth and is never touched here; the
|
|
91
|
+
* .docx is a copy for people and is never read back.
|
|
92
|
+
* - a plan: the copy is made from the plan's handover Markdown, next to it
|
|
93
|
+
* (that file is written by the plan save, exactly as before);
|
|
94
|
+
* - the others: from the answer, into cspeach-out/<date>-<kind>-<OBJECT>.docx.
|
|
95
|
+
* Skills not in WORD_AFTER_ARTIFACT_SAVE: nothing.
|
|
96
|
+
*/
|
|
97
|
+
export async function offerWordCopyAfterArtifactSave(p) {
|
|
98
|
+
const kind = WORD_AFTER_ARTIFACT_SAVE[p.saveSkill];
|
|
99
|
+
if (!kind)
|
|
100
|
+
return undefined;
|
|
101
|
+
const handover = kind === 'plan' ? handoverPathFor(p.savedPath) : null;
|
|
102
|
+
let source = p.markdown;
|
|
103
|
+
if (handover) {
|
|
104
|
+
try {
|
|
105
|
+
source = await fs.promises.readFile(handover, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined; // the plan save already said the handover was not written
|
|
109
|
+
}
|
|
61
110
|
}
|
|
111
|
+
const markdown = readerMarkdown(source, handover !== null);
|
|
112
|
+
if (markdown.trim().length === 0)
|
|
113
|
+
return undefined;
|
|
114
|
+
const meta = metaFromMarkdown(markdown, { date: p.today, kind, ...(p.sapSystem ? { system: p.sapSystem } : {}) });
|
|
115
|
+
const nextTo = handover ?? path.join(p.cwd, OUT_DIR, documentFilename(kind, meta.object, p.today));
|
|
116
|
+
return offerWordCopy({
|
|
117
|
+
nextTo, markdown, meta, headless: p.headless, wordCopy: p.wordCopy, confirm: p.confirm, emit: p.emit,
|
|
118
|
+
});
|
|
62
119
|
}
|
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
// cspeach-cli/src/projects/document-skills.ts
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
3
|
+
// The document skills, on their own so the agent loop can check a skill
|
|
4
4
|
// name without loading document-save.ts (and the docx package behind it) on
|
|
5
5
|
// every turn. Type-only import: nothing heavy loads from here.
|
|
6
|
+
//
|
|
7
|
+
// Word copy everywhere (2026-10-23, owner: "add this word feature everywhere"):
|
|
8
|
+
// - DOCUMENT_SKILLS: skills whose answer is a document a person keeps. After
|
|
9
|
+
// the turn: "Save this <noun> to a file?" (cspeach-out/<date>-<kind>-<OBJECT>.md),
|
|
10
|
+
// then the Word step. Review and handover were the first two (2026-10-19);
|
|
11
|
+
// the others join with `yieldsToArtifact`: when their answer carries a
|
|
12
|
+
// recognizable artefact block (a plan, a clean-core triage, …) the artefact
|
|
13
|
+
// save runs instead, exactly as before.
|
|
14
|
+
// - WORD_AFTER_ARTIFACT_SAVE: artefact saves (a .cspeach.json project file)
|
|
15
|
+
// whose answer is also a document. After "Save as project file?" says Yes,
|
|
16
|
+
// the same Word step runs. The project file and its save do not change.
|
|
6
17
|
export const DOCUMENT_SKILLS = {
|
|
7
18
|
'abap-review': { kind: 'review', noun: 'review' },
|
|
8
19
|
'abap-handover': { kind: 'handover', noun: 'handover' },
|
|
20
|
+
'abap-explain': { kind: 'explanation', noun: 'explanation', yieldsToArtifact: true },
|
|
21
|
+
'abap-impact': { kind: 'impact', noun: 'impact analysis', yieldsToArtifact: true },
|
|
22
|
+
'abap-preflight': { kind: 'preflight', noun: 'preflight check', yieldsToArtifact: true },
|
|
23
|
+
'abap-transport-analysis': { kind: 'transport-analysis', noun: 'transport analysis', yieldsToArtifact: true },
|
|
24
|
+
'abap-migrate': { kind: 'migration', noun: 'migration assessment', yieldsToArtifact: true },
|
|
25
|
+
'abap-clean-core': { kind: 'clean-core', noun: 'clean-core assessment', yieldsToArtifact: true },
|
|
26
|
+
'abap-cca': { kind: 'custom-code-analysis', noun: 'custom code analysis', yieldsToArtifact: true },
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Artefact saves that also get the Word step (skill name as saved → kind).
|
|
30
|
+
* A plan's Word copy is made from its handover Markdown (written next to the
|
|
31
|
+
* plan file); the others from the answer the person read.
|
|
32
|
+
*/
|
|
33
|
+
export const WORD_AFTER_ARTIFACT_SAVE = {
|
|
34
|
+
'abap-spec-gap': 'spec-gap',
|
|
35
|
+
'abap-design': 'design',
|
|
36
|
+
'abap-estimate': 'estimate',
|
|
37
|
+
'abap-plan': 'plan',
|
|
38
|
+
'abap-upgrade-scan': 'upgrade-scan',
|
|
39
|
+
'abap-upgrade-verify': 'upgrade-report',
|
|
9
40
|
};
|
|
@@ -33,7 +33,7 @@ export const BUILTIN_ENTRIES = [
|
|
|
33
33
|
{ name: '/cost', get description() { return costCommandDescription(); } },
|
|
34
34
|
{ name: '/usage', get description() { return usageCommandDescription(); } },
|
|
35
35
|
{ name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
|
|
36
|
-
{ name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy of a
|
|
36
|
+
{ name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy: of the last answer (/export docx, or /export docx <name>), of the whole conversation (/export docx conversation) or of a Markdown file (/export docx <file>.md)' },
|
|
37
37
|
{ name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
|
|
38
38
|
{ name: '/exit', description: 'Exit CSPeach' },
|
|
39
39
|
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
package/dist/repl.js
CHANGED
|
@@ -208,6 +208,17 @@ import './tools/subagent/schedule_draft_create.js';
|
|
|
208
208
|
import './tools/subagent/agent_run.js';
|
|
209
209
|
import './tools/subagent/read_agent.js';
|
|
210
210
|
import { folderTag, currentFolderKey } from './session/project-match.js';
|
|
211
|
+
import { bindCliCardSession } from './session/cli-record.js';
|
|
212
|
+
/** /export docx conversation (2026-10-23) — the origin of a turn CSPeach started itself. */
|
|
213
|
+
async function cliTurnOrigin(planResume, queuedCmd) {
|
|
214
|
+
if (planResume) {
|
|
215
|
+
const { planStepOriginLine } = await import('./commands/plan-continue.js');
|
|
216
|
+
return { userMessageOrigin: { kind: 'plan-step', line: planStepOriginLine(planResume) } };
|
|
217
|
+
}
|
|
218
|
+
if (queuedCmd)
|
|
219
|
+
return { userMessageOrigin: { kind: 'queued', line: `CSPeach ran the next step: ${queuedCmd.trim()}` } };
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
211
222
|
const HISTORY_SIZE = 500;
|
|
212
223
|
// Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
|
|
213
224
|
// the single writer shared with the Ink Footer (Task 8, ux-wave1).
|
|
@@ -793,6 +804,8 @@ export async function runRepl(opts) {
|
|
|
793
804
|
// served config), marks it 'predicted', saves, then stays pinned. Every
|
|
794
805
|
// resume entry (picker, --resume <id>) lands here via session/resume.ts.
|
|
795
806
|
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
|
|
807
|
+
// /export docx conversation — the plan startup, lane and journey cards are recorded on this session.
|
|
808
|
+
bindCliCardSession(session);
|
|
796
809
|
// Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
|
|
797
810
|
let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
|
|
798
811
|
// Round 7 item 8c — the footer shows a resumed session's spend at once.
|
|
@@ -1157,16 +1170,18 @@ export async function runRepl(opts) {
|
|
|
1157
1170
|
// session audit record (who did what, where, under which approval, at
|
|
1158
1171
|
// what cost) to a markdown file in cwd and print the path.
|
|
1159
1172
|
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
1160
|
-
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
1161
|
-
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
1173
|
+
const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
|
|
1174
|
+
const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
|
|
1162
1175
|
// Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
|
|
1176
|
+
// 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
|
|
1177
|
+
// The Word package loads only here: /export audit never needs it.
|
|
1163
1178
|
if (sub === 'docx') {
|
|
1164
|
-
const {
|
|
1165
|
-
await
|
|
1179
|
+
const { runExportDocxRepl } = await import('./commands/export-conversation.js');
|
|
1180
|
+
await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
|
|
1166
1181
|
return;
|
|
1167
1182
|
}
|
|
1168
1183
|
if (sub !== 'audit') {
|
|
1169
|
-
chunkEmitter.emit('chunk', chalk.dim(
|
|
1184
|
+
chunkEmitter.emit('chunk', chalk.dim(`\n${EXPORT_REPL_USAGE}\n`));
|
|
1170
1185
|
return;
|
|
1171
1186
|
}
|
|
1172
1187
|
await runExportAuditCommand({
|
|
@@ -2051,6 +2066,8 @@ export async function runRepl(opts) {
|
|
|
2051
2066
|
// turns (their post-turn persist indexes messages from turn start).
|
|
2052
2067
|
autoCompact: planResume === null && planContinue === null,
|
|
2053
2068
|
delegateSkill: planPhaseDelegate,
|
|
2069
|
+
// /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
|
|
2070
|
+
...(await cliTurnOrigin(planResume, submitOpts?.queued ? line : null)),
|
|
2054
2071
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
2055
2072
|
modelOverride: planModelOverride,
|
|
2056
2073
|
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
@@ -3026,6 +3043,8 @@ export async function runRepl(opts) {
|
|
|
3026
3043
|
// Task 13 — classic in-run hop flag: set by the loop-top consumer from planHops.take().
|
|
3027
3044
|
let classicPlanHop = false;
|
|
3028
3045
|
let classicLineBy = 'person';
|
|
3046
|
+
/** The command CSPeach queued for this line (null when the person typed it). */
|
|
3047
|
+
let classicQueuedCmd = null;
|
|
3029
3048
|
while (true) {
|
|
3030
3049
|
// Re-attach the bare-Esc detector every iteration. Inquirer prompts
|
|
3031
3050
|
// (slash-picker, approval, ask_question, safety-confirm) remove
|
|
@@ -3042,6 +3061,7 @@ export async function runRepl(opts) {
|
|
|
3042
3061
|
const classicQueued = pendingDispatchAccessor.take();
|
|
3043
3062
|
// Task 3 — who started this line: the CSPeach source that queued it, else the person.
|
|
3044
3063
|
classicLineBy = classicQueued?.origin?.by ?? 'person';
|
|
3064
|
+
classicQueuedCmd = classicQueued?.origin ? classicQueued.cmd : null;
|
|
3045
3065
|
if (classicQueued) {
|
|
3046
3066
|
const dispatchedCmd = classicQueued.cmd;
|
|
3047
3067
|
// No context clear here: beginPlanPhaseContext (plan-boundary.ts,
|
|
@@ -3197,16 +3217,18 @@ export async function runRepl(opts) {
|
|
|
3197
3217
|
// Task 4 (ux-wave3) — classic-mode /export audit [filename]. Mirrors the
|
|
3198
3218
|
// Ink handler: build the signable audit record, write it to cwd, print path.
|
|
3199
3219
|
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
3200
|
-
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
3201
|
-
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
3220
|
+
const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
|
|
3221
|
+
const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
|
|
3202
3222
|
// Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
|
|
3223
|
+
// 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
|
|
3224
|
+
// The Word package loads only here: /export audit never needs it.
|
|
3203
3225
|
if (sub === 'docx') {
|
|
3204
|
-
const {
|
|
3205
|
-
await
|
|
3226
|
+
const { runExportDocxRepl } = await import('./commands/export-conversation.js');
|
|
3227
|
+
await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => console.log(l) });
|
|
3206
3228
|
continue;
|
|
3207
3229
|
}
|
|
3208
3230
|
if (sub !== 'audit') {
|
|
3209
|
-
console.log(chalk.dim(
|
|
3231
|
+
console.log(chalk.dim(EXPORT_REPL_USAGE));
|
|
3210
3232
|
continue;
|
|
3211
3233
|
}
|
|
3212
3234
|
await runExportAuditCommand({
|
|
@@ -4133,6 +4155,8 @@ export async function runRepl(opts) {
|
|
|
4133
4155
|
// turns (their post-turn persist indexes messages from turn start).
|
|
4134
4156
|
autoCompact: planResume === null && planContinue === null,
|
|
4135
4157
|
delegateSkill: planPhaseDelegate,
|
|
4158
|
+
// /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
|
|
4159
|
+
...(await cliTurnOrigin(planResume, classicQueuedCmd)),
|
|
4136
4160
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
4137
4161
|
modelOverride: planModelOverride,
|
|
4138
4162
|
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// cspeach-cli/src/session/cli-record.ts
|
|
2
|
+
//
|
|
3
|
+
// /export docx conversation, fix round 1 (2026-10-23). Two small records kept
|
|
4
|
+
// on the session for the conversation export, NEVER sent to the model:
|
|
5
|
+
//
|
|
6
|
+
// - cli_messages: which user-role messages CSPeach wrote itself (a plan step
|
|
7
|
+
// prompt, the output-limit continuation, the watchdog note, a /compact or
|
|
8
|
+
// plan-boundary summary, a queued next step, a mid-turn steer). Each entry is
|
|
9
|
+
// the sha of the message content, not an index: compaction drops and moves
|
|
10
|
+
// messages, a content hash still finds the same message. The messages
|
|
11
|
+
// themselves are untouched, so every model request stays byte-identical.
|
|
12
|
+
// - cli_cards: the CLI's own cards that matter for a change ticket (the plan
|
|
13
|
+
// gate "Build this now?", the plan startup "Continue", the lane card, the
|
|
14
|
+
// journey cards), anchored by turn_seq. Never the save or Word cards.
|
|
15
|
+
//
|
|
16
|
+
// Both are optional session fields. A session file is JSON.parse'd and written
|
|
17
|
+
// whole (store.ts), so an older CLI that resumes the file keeps them as they are.
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
export const CLI_RECORD_CAP = 500;
|
|
20
|
+
export const CONTINUE_LINE = 'CSPeach asked the model to continue a long answer.';
|
|
21
|
+
export const WATCHDOG_LINE = 'CSPeach asked the model to wrap up a long turn.';
|
|
22
|
+
export const COMPACT_LINE = 'Earlier turns were summarised to keep the session small.';
|
|
23
|
+
export const PLAN_BOUNDARY_LINE = 'CSPeach summarised the earlier plan steps.';
|
|
24
|
+
function shaOf(content) {
|
|
25
|
+
return crypto.createHash('sha256').update(typeof content === 'string' ? content : JSON.stringify(content ?? null)).digest('hex').slice(0, 32);
|
|
26
|
+
}
|
|
27
|
+
function capped(list) {
|
|
28
|
+
return list.length > CLI_RECORD_CAP ? list.slice(list.length - CLI_RECORD_CAP) : list;
|
|
29
|
+
}
|
|
30
|
+
/** Note that CSPeach wrote this user-role message. Never throws. */
|
|
31
|
+
export function markCliMessage(session, content, kind, line) {
|
|
32
|
+
if (!session)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const s = session;
|
|
36
|
+
s.cli_messages = capped([...(s.cli_messages ?? []), { sha: shaOf(content), kind, line }]);
|
|
37
|
+
}
|
|
38
|
+
catch { /* the record is best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fixed-shape CLI texts, for sessions written before the record existed (or
|
|
42
|
+
* a mark the cap dropped). Prefixes only; the bodies are CSPeach's own words.
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_PREFIXES = [
|
|
45
|
+
{ prefix: 'Resume execution of the project plan', kind: 'plan-step', line: 'CSPeach ran the next plan step.' },
|
|
46
|
+
{ prefix: 'Your previous response was cut off at the output-token limit.', kind: 'continue', line: CONTINUE_LINE },
|
|
47
|
+
{ prefix: '[CSPeach watchdog]', kind: 'watchdog', line: WATCHDOG_LINE },
|
|
48
|
+
{ prefix: '[Context summary auto-generated', kind: 'compact', line: COMPACT_LINE },
|
|
49
|
+
{ prefix: '[Summary of the earlier steps of this plan', kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE },
|
|
50
|
+
];
|
|
51
|
+
function prefixMark(message) {
|
|
52
|
+
const text = typeof message.content === 'string' ? message.content
|
|
53
|
+
: Array.isArray(message.content) ? message.content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n') : '';
|
|
54
|
+
const t = text.trimStart();
|
|
55
|
+
const known = KNOWN_PREFIXES.find((k) => t.startsWith(k.prefix));
|
|
56
|
+
return known ? { sha: '', kind: known.kind, line: known.line } : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The mark of each message (undefined for the developer's own words), in
|
|
60
|
+
* message order. Fix round 2 (m-8): each mark is used once, in order — a
|
|
61
|
+
* message takes the first unused mark at or after the last one used that has
|
|
62
|
+
* its sha. So a command the developer retypes after CSPeach queued it, or a
|
|
63
|
+
* typed "continue" equal to an earlier steer, stays the developer's: that mark
|
|
64
|
+
* is already used. Marks of messages a compaction dropped are simply passed
|
|
65
|
+
* over. The prefix fallback is only for sessions with no records at all.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveCliMarks(session, messages) {
|
|
68
|
+
const marks = session?.cli_messages;
|
|
69
|
+
if (!marks || marks.length === 0)
|
|
70
|
+
return messages.map((m) => (m.role === 'user' ? prefixMark(m) : undefined));
|
|
71
|
+
// Final-review fix (I-1): used once, any order. Look from the last mark used
|
|
72
|
+
// on; when nothing matches there, look again from the start (a file an older
|
|
73
|
+
// CLI compacted has the summary's mark last). A used mark is never reused.
|
|
74
|
+
const used = new Set();
|
|
75
|
+
let next = 0;
|
|
76
|
+
return messages.map((m) => {
|
|
77
|
+
if (m.role !== 'user')
|
|
78
|
+
return undefined;
|
|
79
|
+
const sha = shaOf(m.content);
|
|
80
|
+
const at = (from, to) => {
|
|
81
|
+
for (let j = from; j < to; j++)
|
|
82
|
+
if (!used.has(j) && marks[j].sha === sha)
|
|
83
|
+
return j;
|
|
84
|
+
return -1;
|
|
85
|
+
};
|
|
86
|
+
let j = at(next, marks.length);
|
|
87
|
+
if (j < 0)
|
|
88
|
+
j = at(0, Math.min(next, marks.length));
|
|
89
|
+
if (j < 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
used.add(j);
|
|
92
|
+
next = j + 1;
|
|
93
|
+
return marks[j];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Final-review fix (I-1): after a compaction keeps `kept` (the tail of the old
|
|
98
|
+
* messages, from index `keepFrom`) behind a new summary message, rebuild the
|
|
99
|
+
* record in message order: the summary's mark first, then the marks of the
|
|
100
|
+
* kept messages (recomputed from their content, so a mark found by the prefix
|
|
101
|
+
* fallback in an older file becomes a real one). Marks of dropped messages go.
|
|
102
|
+
*/
|
|
103
|
+
export function rebuildCliMarksAfterCompact(session, oldMessages, keepFrom, summaryContent, summary) {
|
|
104
|
+
try {
|
|
105
|
+
const resolved = resolveCliMarks(session, oldMessages);
|
|
106
|
+
const kept = [];
|
|
107
|
+
for (let i = keepFrom; i < oldMessages.length; i++) {
|
|
108
|
+
const m = resolved[i];
|
|
109
|
+
if (m)
|
|
110
|
+
kept.push({ sha: shaOf(oldMessages[i].content), kind: m.kind, line: m.line });
|
|
111
|
+
}
|
|
112
|
+
session.cli_messages = capped([{ sha: shaOf(summaryContent), kind: summary.kind, line: summary.line }, ...kept]);
|
|
113
|
+
}
|
|
114
|
+
catch { /* the record is best-effort */ }
|
|
115
|
+
}
|
|
116
|
+
/** A turn of its own (the developer's prompt, a plan step, a queued step) vs. a message inside a turn. */
|
|
117
|
+
export function startsTurn(mark) {
|
|
118
|
+
return !mark || mark.kind === 'plan-step' || mark.kind === 'queued';
|
|
119
|
+
}
|
|
120
|
+
/* ── cards ────────────────────────────────────────────────────────────── */
|
|
121
|
+
let bound = null;
|
|
122
|
+
/** The REPL (and each top-level turn) binds the session the cards belong to. */
|
|
123
|
+
export function bindCliCardSession(session) { bound = session; }
|
|
124
|
+
/**
|
|
125
|
+
* The card ids the card slot records for the export: plan startup, lane,
|
|
126
|
+
* journey. Never save or Word cards. "Build this now?" (app-start-build) is
|
|
127
|
+
* recorded by afterPlanSave itself, which knows the plan file and whether the
|
|
128
|
+
* object-list approval was installed (fix round 2, N-1).
|
|
129
|
+
*/
|
|
130
|
+
export function isRecordedCard(id) {
|
|
131
|
+
return id === 'plan-continue' || id === 'app-lane' || id === 'app-lane-runtime' || id.startsWith('journey-');
|
|
132
|
+
}
|
|
133
|
+
/** Record one card on the bound session (or the one given), with the time. Never throws. */
|
|
134
|
+
export function recordCliCard(card, session = bound) {
|
|
135
|
+
if (!session)
|
|
136
|
+
return;
|
|
137
|
+
try {
|
|
138
|
+
const s = session;
|
|
139
|
+
const entry = { turn_seq: s.turn_seq ?? 0, at: new Date().toISOString(), ...card };
|
|
140
|
+
s.cli_cards = capped([...(s.cli_cards ?? []), entry]);
|
|
141
|
+
}
|
|
142
|
+
catch { /* the record is best-effort */ }
|
|
143
|
+
}
|
|
144
|
+
/* ── once-per-object document offers ──────────────────────────────────── */
|
|
145
|
+
/**
|
|
146
|
+
* Per session (in memory): the (skill, object) keys the newer document skills
|
|
147
|
+
* already offered "Save…?" for. Kept here, beside the records, so /new can
|
|
148
|
+
* clear it (final-review m-4).
|
|
149
|
+
*/
|
|
150
|
+
const documentOffers = new WeakMap();
|
|
151
|
+
export function documentOffersFor(session) {
|
|
152
|
+
let s = documentOffers.get(session);
|
|
153
|
+
if (!s) {
|
|
154
|
+
s = new Set();
|
|
155
|
+
documentOffers.set(session, s);
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
/** /new: the once-per-object offers and the records of the cleared conversation go too. */
|
|
160
|
+
export function clearConversationRecords(session) {
|
|
161
|
+
documentOffers.delete(session);
|
|
162
|
+
const s = session;
|
|
163
|
+
delete s.cli_messages;
|
|
164
|
+
delete s.cli_cards;
|
|
165
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearConversationRecords } from './cli-record.js';
|
|
1
2
|
export function startFreshConversation(session) {
|
|
2
3
|
session.messages = [];
|
|
3
4
|
session.turnInterrupted = false;
|
|
@@ -6,6 +7,9 @@ export function startFreshConversation(session) {
|
|
|
6
7
|
delete session.lastAutoCompactTurn;
|
|
7
8
|
// An open plan step does not survive: "continue" must not resume it.
|
|
8
9
|
session.plan_in_flight = null;
|
|
10
|
+
// Word export (final-review m-4): the once-per-object save offers and the
|
|
11
|
+
// export records describe the cleared conversation; they go with it.
|
|
12
|
+
clearConversationRecords(session);
|
|
9
13
|
}
|
|
10
14
|
/** The one line /new prints. */
|
|
11
15
|
export function freshStartText(lane) {
|
package/dist/ui/card-slot.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
10
|
import { suspendActiveSpinner } from '../renderer/tool-widget.js';
|
|
11
11
|
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
12
|
+
import { isRecordedCard, recordCliCard } from '../session/cli-record.js';
|
|
12
13
|
/**
|
|
13
14
|
* Claim exclusive input focus for the duration of `fn`: pause the in-place
|
|
14
15
|
* tool spinner and freeze the ticking status row so only the card repaints.
|
|
@@ -81,9 +82,25 @@ class CardSlotEmitter extends EventEmitter {
|
|
|
81
82
|
if (this.listenerCount('confirm') === 0) {
|
|
82
83
|
return Promise.resolve({ value: null, cancelled: true });
|
|
83
84
|
}
|
|
84
|
-
|
|
85
|
+
const shown = withFocusClaim(() => new Promise((resolve) => {
|
|
85
86
|
this.emit('confirm', { ...req, resolve });
|
|
86
87
|
}));
|
|
88
|
+
// /export docx conversation (2026-10-23): the plan gate, plan startup, lane
|
|
89
|
+
// and journey cards are kept on the session for the conversation export.
|
|
90
|
+
// Never the save or Word cards. Nothing here reaches the model.
|
|
91
|
+
if (!isRecordedCard(req.id))
|
|
92
|
+
return shown;
|
|
93
|
+
return shown.then((res) => {
|
|
94
|
+
const picked = res.value == null ? undefined : req.choices.find((c) => c.value === res.value);
|
|
95
|
+
recordCliCard({
|
|
96
|
+
question: req.title,
|
|
97
|
+
options: req.choices.map((c) => c.label),
|
|
98
|
+
...(req.facts?.length ? { facts: [...req.facts] } : {}),
|
|
99
|
+
answer: picked?.label ?? (res.value == null ? 'Cancelled' : String(res.value)),
|
|
100
|
+
by: 'user',
|
|
101
|
+
});
|
|
102
|
+
return res;
|
|
103
|
+
});
|
|
87
104
|
}
|
|
88
105
|
on(event, listener) {
|
|
89
106
|
return super.on(event, listener);
|