@cspeach/cli 1.1.26 → 1.1.28
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 +41 -5
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/commands/whoami.js +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +4 -6
- package/dist/lane/app-chain.js +116 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +15 -14
- package/dist/projects/saved-lines.js +36 -0
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/sap/unreachable.js +4 -1
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +3 -4
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Plan sources — the files a plan was made from, carried by the plan itself.
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-04 (live run, FI Document Aging, CLI 1.1.27): phase c1.design said
|
|
4
|
+
// "the spec-gap answers file is not in the project folder, so I can't read
|
|
5
|
+
// gap-003/004/006/010/013/016". The file WAS in `.cspeach/`, and the plan
|
|
6
|
+
// envelope even held a snapshot of every gap item (`promotedFrom`). But the
|
|
7
|
+
// resume prompt carried only `content` (+ statuses): the phase got gap ids in
|
|
8
|
+
// `manifest.reads` and nothing behind them, then hunted with glob (which never
|
|
9
|
+
// walks `.cspeach/`) and gave up.
|
|
10
|
+
//
|
|
11
|
+
// The fix is one reusable mechanism, CLI-owned end to end:
|
|
12
|
+
// 1. At creation the plan records WHERE its spec-gap lives:
|
|
13
|
+
// `content.from.specGap = { path, sha256 }` (specGapRefForSave, imposed by
|
|
14
|
+
// extractPlan — never model-authored, like `from.lane`).
|
|
15
|
+
// 2. Every phase run resolves the plan's sources (resolvePlanSources: the
|
|
16
|
+
// live spec-gap file when it is still there and is the same envelope,
|
|
17
|
+
// else the snapshot the plan took at creation) and the phase prompt gets a
|
|
18
|
+
// <plan_sources> block with the gap items it reads, in full
|
|
19
|
+
// (renderPlanSourcesBlock). Phases never have to search for a file.
|
|
20
|
+
//
|
|
21
|
+
// Anything that runs a plan phase (today's per-phase resume, a future
|
|
22
|
+
// continuous run) calls resolvePlanSources + renderPlanSourcesBlock.
|
|
23
|
+
import { promises as fs } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { readProjectFile } from './status.js';
|
|
26
|
+
import { buildPromotedFromSnapshot } from './promote.js';
|
|
27
|
+
import { assertRealPathContained } from '../tools/_filesystem-shared.js';
|
|
28
|
+
import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
|
|
29
|
+
/** A spec-gap envelope file name (`...spec-gap-<4hex>-v<N>[-n].cspeach.json`). */
|
|
30
|
+
const SPEC_GAP_FILE_RE = /-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/i;
|
|
31
|
+
/** Path relative to `root` ('/'-separated) when inside it, else absolute. */
|
|
32
|
+
function relOrAbs(root, abs) {
|
|
33
|
+
const rel = path.relative(root, abs);
|
|
34
|
+
if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel))
|
|
35
|
+
return rel.split(path.sep).join('/');
|
|
36
|
+
return abs;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The `from.specGap` ref a plan SAVE records, from the CLI's promotion state
|
|
40
|
+
* (the resolved `--from` file + its promotedFrom snapshot). Undefined unless
|
|
41
|
+
* the plan really was promoted from a spec-gap file. The path is stored
|
|
42
|
+
* relative to the project folder (the folder holding `.cspeach`, the same root
|
|
43
|
+
* `from.lane.path` uses) so a team-shared folder stays portable.
|
|
44
|
+
*/
|
|
45
|
+
export async function specGapRefForSave(cwd, promotedFrom, sourcePath) {
|
|
46
|
+
if (!promotedFrom || promotedFrom.artefactType !== 'spec-gap' || !sourcePath)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!/^[0-9a-f]{64}$/.test(promotedFrom.sha256))
|
|
49
|
+
return undefined;
|
|
50
|
+
const abs = path.resolve(cwd, sourcePath);
|
|
51
|
+
const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
|
|
52
|
+
return { path: relOrAbs(root, abs), sha256: promotedFrom.sha256 };
|
|
53
|
+
}
|
|
54
|
+
async function containedIn(abs, root) {
|
|
55
|
+
if (!root)
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
await assertRealPathContained(abs, await fs.realpath(root));
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Find and read the plan's spec-gap file. Candidates: `from.specGap.path`,
|
|
67
|
+
* then the lane decision's `specGap.path`; each resolved against the project
|
|
68
|
+
* folder and cwd. A candidate is used only when it is a spec-gap file name,
|
|
69
|
+
* sits (real path) inside cwd or the workspace folder, parses as a spec-gap
|
|
70
|
+
* envelope, and — when the plan holds a promotedFrom snapshot — is the SAME
|
|
71
|
+
* envelope (id match). Returns undefined when none qualifies.
|
|
72
|
+
*/
|
|
73
|
+
async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
|
|
74
|
+
const recorded = [content.from.specGap?.path, laneRecord?.specGap?.path]
|
|
75
|
+
.filter((p) => typeof p === 'string' && p.trim() !== '');
|
|
76
|
+
if (recorded.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
|
|
79
|
+
const ws = await workspaceFolderFor(cwd).catch(() => undefined);
|
|
80
|
+
for (const rec of recorded) {
|
|
81
|
+
if (!SPEC_GAP_FILE_RE.test(path.basename(rec)))
|
|
82
|
+
continue;
|
|
83
|
+
for (const base of [root, path.resolve(cwd)]) {
|
|
84
|
+
const abs = path.resolve(base, rec);
|
|
85
|
+
if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root)))
|
|
86
|
+
continue;
|
|
87
|
+
let env;
|
|
88
|
+
try {
|
|
89
|
+
env = readProjectFile(abs);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (env.artefactType !== 'spec-gap')
|
|
95
|
+
continue;
|
|
96
|
+
if (promotedFrom && promotedFrom.id !== env.id)
|
|
97
|
+
continue;
|
|
98
|
+
return { env, shownPath: relOrAbs(root, abs) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the sources a plan was made from. Prefers the live spec-gap file
|
|
105
|
+
* (answers given after the plan was made reach the phase), falls back to the
|
|
106
|
+
* plan's own creation snapshot (`promotedFrom`). Never throws.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolvePlanSources(args) {
|
|
109
|
+
const pf = args.envelope.promotedFrom && args.envelope.promotedFrom.artefactType === 'spec-gap'
|
|
110
|
+
? args.envelope.promotedFrom : null;
|
|
111
|
+
try {
|
|
112
|
+
const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord);
|
|
113
|
+
if (live) {
|
|
114
|
+
const snap = await buildPromotedFromSnapshot(live.env);
|
|
115
|
+
const createdSha = args.content.from.specGap?.sha256 ?? pf?.sha256;
|
|
116
|
+
const sourceDocument = live.env.artefactType === 'spec-gap' ? live.env.content.sourceDocument : undefined;
|
|
117
|
+
return {
|
|
118
|
+
specGap: {
|
|
119
|
+
path: live.shownPath,
|
|
120
|
+
title: live.env.title,
|
|
121
|
+
version: live.env.version,
|
|
122
|
+
origin: 'file',
|
|
123
|
+
changedSinceCreate: createdSha !== undefined && createdSha !== snap.sha256,
|
|
124
|
+
...(sourceDocument ? { sourceDocument } : {}),
|
|
125
|
+
items: snap.snapshot.items,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// fall through to the snapshot — a read problem must never cost the phase its gaps
|
|
132
|
+
}
|
|
133
|
+
if (pf) {
|
|
134
|
+
const recordedPath = args.content.from.specGap?.path ?? args.laneRecord?.specGap?.path;
|
|
135
|
+
return {
|
|
136
|
+
specGap: {
|
|
137
|
+
...(recordedPath ? { path: recordedPath } : {}),
|
|
138
|
+
title: pf.title,
|
|
139
|
+
version: pf.version,
|
|
140
|
+
origin: 'snapshot',
|
|
141
|
+
changedSinceCreate: false,
|
|
142
|
+
...(args.laneRecord?.spec.path ? { sourceDocument: args.laneRecord.spec.path } : {}),
|
|
143
|
+
items: pf.snapshot.items,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
|
|
150
|
+
function renderItem(it) {
|
|
151
|
+
const sev = it.severity ? ` [${it.severity}]` : '';
|
|
152
|
+
const state = it.answer
|
|
153
|
+
? `ANSWERED: "${oneLine(it.answer)}"`
|
|
154
|
+
: it.status === 'open' ? 'OPEN (no answer yet)' : it.status.toUpperCase();
|
|
155
|
+
return `- ${it.id}${sev} ${state} — ${oneLine(it.text)}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The <plan_sources> block for one phase prompt: the gap items the phase's
|
|
159
|
+
* `manifest.reads` names, in full; every other ANSWERED item as a short line
|
|
160
|
+
* (an answer is a decision — it must never be lost to a narrow reads list).
|
|
161
|
+
* Returns [] when the plan has no spec-gap source (block omitted entirely, so
|
|
162
|
+
* goal-only plans keep a byte-identical prompt).
|
|
163
|
+
*/
|
|
164
|
+
export function renderPlanSourcesBlock(sources, phase) {
|
|
165
|
+
const sg = sources.specGap;
|
|
166
|
+
if (!sg || sg.items.length === 0)
|
|
167
|
+
return [];
|
|
168
|
+
const reads = new Set(phase.manifest.reads);
|
|
169
|
+
const read = sg.items.filter((it) => reads.has(it.id));
|
|
170
|
+
const otherAnswered = sg.items.filter((it) => !reads.has(it.id) && it.answer);
|
|
171
|
+
const openOthers = sg.items.filter((it) => !reads.has(it.id) && !it.answer).length;
|
|
172
|
+
const where = sg.path ? ` (${sg.path})` : '';
|
|
173
|
+
const lines = [
|
|
174
|
+
'<plan_sources>',
|
|
175
|
+
`Spec-gap file this plan was made from: "${sg.title}" v${sg.version}${where}.`,
|
|
176
|
+
sg.origin === 'file'
|
|
177
|
+
? 'CSPeach read it for this phase. The items below ARE its content: use them, do not search the folder for the file.'
|
|
178
|
+
: 'The file could not be read now, so these are the items as they were when the plan was made. Use them, do not search the folder for the file.',
|
|
179
|
+
];
|
|
180
|
+
if (sg.changedSinceCreate) {
|
|
181
|
+
lines.push('Note: the spec-gap answers changed after the plan was made. The answers below are the current ones.');
|
|
182
|
+
}
|
|
183
|
+
if (sg.sourceDocument)
|
|
184
|
+
lines.push(`Spec document: ${sg.sourceDocument}`);
|
|
185
|
+
if (read.length > 0) {
|
|
186
|
+
lines.push('', `Gap items this phase reads (manifest.reads):`, ...read.map(renderItem));
|
|
187
|
+
}
|
|
188
|
+
if (otherAnswered.length > 0) {
|
|
189
|
+
lines.push('', 'Other answered gap items (decisions already made):', ...otherAnswered.map(renderItem));
|
|
190
|
+
}
|
|
191
|
+
if (openOthers > 0)
|
|
192
|
+
lines.push('', `${openOthers} other gap item${openOthers === 1 ? ' is' : 's are'} still open and not read by this phase.`);
|
|
193
|
+
const openRead = read.filter((it) => !it.answer).map((it) => it.id);
|
|
194
|
+
if (openRead.length > 0) {
|
|
195
|
+
lines.push('', `Still open among this phase's items: ${openRead.join(', ')}. Decide them from the spec, ask the developer where a choice matters, and record each decision in work.notes.`);
|
|
196
|
+
}
|
|
197
|
+
lines.push('</plan_sources>');
|
|
198
|
+
return lines;
|
|
199
|
+
}
|
|
@@ -131,5 +131,5 @@ export async function runPromoteCommand(args) {
|
|
|
131
131
|
if (env.artefactType === 'spec-gap' && env.content.sourceDocument) {
|
|
132
132
|
extendedSkillInput = `${extendedSkillInput}\n\nSource spec document: ${env.content.sourceDocument}`;
|
|
133
133
|
}
|
|
134
|
-
return { promotedFrom, extendedSkillInput };
|
|
134
|
+
return { promotedFrom, extendedSkillInput, sourcePath: resolvedPath };
|
|
135
135
|
}
|
|
@@ -14,11 +14,12 @@ import { parsePlanContent } from './plan-schema.js';
|
|
|
14
14
|
import { validateEnvelope } from './validate.js';
|
|
15
15
|
import { readProjectFile } from './status.js';
|
|
16
16
|
import { saveProject } from './save.js';
|
|
17
|
-
import {
|
|
17
|
+
import { formatSavedLines } from './saved-lines.js';
|
|
18
18
|
import { writePlanHandover } from './handover-md.js';
|
|
19
19
|
import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
|
|
20
20
|
import { askYesNo, isYes } from './yes-no.js';
|
|
21
21
|
import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
22
|
+
import { specGapRefForSave } from './plan-sources.js';
|
|
22
23
|
const SKILL_REGISTRY = {
|
|
23
24
|
'abap-spec-gap': {
|
|
24
25
|
artefactType: 'spec-gap',
|
|
@@ -282,7 +283,11 @@ export async function runSaveCommand(args) {
|
|
|
282
283
|
let planExtract = null;
|
|
283
284
|
try {
|
|
284
285
|
if (spec.artefactType === 'plan') {
|
|
285
|
-
|
|
286
|
+
const specGap = await specGapRefForSave(args.cwd, args.promotedFrom, args.promotedFromPath);
|
|
287
|
+
planExtract = extractPlan(args.skillOutput, priorContent, {
|
|
288
|
+
...(args.lane ? { lane: { path: args.lane.path, sha256: args.lane.sha256 } } : {}),
|
|
289
|
+
...(specGap ? { specGap } : {}),
|
|
290
|
+
});
|
|
286
291
|
extract = planExtract;
|
|
287
292
|
}
|
|
288
293
|
else {
|
|
@@ -328,6 +333,7 @@ export async function runSaveCommand(args) {
|
|
|
328
333
|
args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
|
|
329
334
|
args.log(`[save] Ask CSPeach to "re-issue the ${spec.artefactType} in the standard structure" and it will be saved then.`);
|
|
330
335
|
}
|
|
336
|
+
args.onNotSaved?.('unreadable');
|
|
331
337
|
return null;
|
|
332
338
|
}
|
|
333
339
|
// App lane routing (Task 6) — a plan that carries a lane is checked against
|
|
@@ -366,8 +372,10 @@ export async function runSaveCommand(args) {
|
|
|
366
372
|
// prompt, give maybeOfferSave's headless prompt a question-aware answer
|
|
367
373
|
// first.
|
|
368
374
|
const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
|
|
369
|
-
if (!isYes(answer))
|
|
375
|
+
if (!isYes(answer)) {
|
|
376
|
+
args.onNotSaved?.('declined');
|
|
370
377
|
return null;
|
|
378
|
+
}
|
|
371
379
|
let env;
|
|
372
380
|
if (planExtract && prior) {
|
|
373
381
|
// C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
|
|
@@ -418,7 +426,9 @@ export async function runSaveCommand(args) {
|
|
|
418
426
|
// Best-effort by design (writePlanHandover logs one dim line on failure).
|
|
419
427
|
if (spec.artefactType === 'plan')
|
|
420
428
|
writePlanHandover(path, args.log);
|
|
421
|
-
|
|
429
|
+
// 1.1.28 — two plain lines; the old suggested-email block is gone (saved-lines.ts).
|
|
430
|
+
const [savedLine, reviewLine] = formatSavedLines(path, args.cwd);
|
|
431
|
+
args.log(savedLine);
|
|
422
432
|
// Project Register (2026-09-18): when this laptop has joined a project, a
|
|
423
433
|
// decision / fix record is written to the LOCAL outbox as a by-product of the
|
|
424
434
|
// save. No project joined → `activeProject()` is one missing-file read and
|
|
@@ -442,15 +452,6 @@ export async function runSaveCommand(args) {
|
|
|
442
452
|
if (planExtract && prior) {
|
|
443
453
|
args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
|
|
444
454
|
}
|
|
445
|
-
args.log(
|
|
446
|
-
args.log('');
|
|
447
|
-
args.log(renderEmailTemplate({
|
|
448
|
-
title: env.title,
|
|
449
|
-
artefactType: spec.artefactType,
|
|
450
|
-
filename: path.split(/[\\\/]/).pop(),
|
|
451
|
-
itemCount: env.interaction.items.length,
|
|
452
|
-
itemNoun: spec.itemNoun,
|
|
453
|
-
author: args.author.name,
|
|
454
|
-
}));
|
|
455
|
+
args.log(reviewLine);
|
|
455
456
|
return path;
|
|
456
457
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two lines printed after a project file is saved (every artefact type).
|
|
3
|
+
*
|
|
4
|
+
* 1.1.28 (owner decision): replaces the old "Suggested email — copy from
|
|
5
|
+
* below the line" block (subject, body, review steps, sign-off). It looked
|
|
6
|
+
* cluttered and signed itself with the OS account name ("Admin"). Now:
|
|
7
|
+
*
|
|
8
|
+
* Saved: <file name> (in this project's .cspeach folder)
|
|
9
|
+
* To review or share: open cspeach.dev/edit and drop the file in.
|
|
10
|
+
*
|
|
11
|
+
* Never prints a user name.
|
|
12
|
+
*/
|
|
13
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
14
|
+
function editorLabel() {
|
|
15
|
+
const url = process.env.CSPEACH_EDITOR_URL?.trim();
|
|
16
|
+
return url ? url : 'cspeach.dev/edit';
|
|
17
|
+
}
|
|
18
|
+
function sameDir(a, b) {
|
|
19
|
+
const norm = (p) => {
|
|
20
|
+
const r = resolve(p);
|
|
21
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
22
|
+
};
|
|
23
|
+
return norm(a) === norm(b);
|
|
24
|
+
}
|
|
25
|
+
/** `<file name> (in this project's .cspeach folder)` — or the folder, when elsewhere. */
|
|
26
|
+
export function savedLocation(savedPath, cwd) {
|
|
27
|
+
const dir = dirname(savedPath);
|
|
28
|
+
const where = sameDir(dir, join(cwd, '.cspeach')) ? "this project's .cspeach folder" : dir;
|
|
29
|
+
return `${basename(savedPath)} (in ${where})`;
|
|
30
|
+
}
|
|
31
|
+
export function formatSavedLines(savedPath, cwd) {
|
|
32
|
+
return [
|
|
33
|
+
`Saved: ${savedLocation(savedPath, cwd)}`,
|
|
34
|
+
`To review or share: open ${editorLabel()} and drop the file in.`,
|
|
35
|
+
];
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 2026-10-04 — one tolerant title-heading matcher for the prose artefacts
|
|
2
|
+
// (spec-gap, design, estimate). A live /abap-app run lost its spec-gap file
|
|
3
|
+
// because the model wrote "Spec Gap Analysis: …" and the saver only accepted
|
|
4
|
+
// "## Spec Gap Analysis — …". Models drift on the separator (dash, en/em dash,
|
|
5
|
+
// colon, none), the heading level (#..######, or none), the casing and an
|
|
6
|
+
// optional "<skill-name> —" prefix. All of those count; an unrelated heading
|
|
7
|
+
// does not.
|
|
8
|
+
// Horizontal whitespace only: a match never runs onto the next line.
|
|
9
|
+
const SEP = String.raw `[ \t]*[—–:-][ \t]*`;
|
|
10
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
function strip(title) {
|
|
12
|
+
return title
|
|
13
|
+
.replace(/\s*\*\*\s*$/, '') // closing bold
|
|
14
|
+
.replace(/\s+#+\s*$/, '') // closing ATX hashes
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Find the artefact's title heading. Heading lines (`#`..`######`) are tried
|
|
19
|
+
* first and may use any separator or none; a plain line (no `#`, optionally
|
|
20
|
+
* bold) must use a dash or colon, so ordinary prose never counts. The first
|
|
21
|
+
* heading line wins over any plain line.
|
|
22
|
+
*/
|
|
23
|
+
export function matchTitleHeading(markdown, spec) {
|
|
24
|
+
const prefix = String.raw `(?:${escapeRe(spec.skillPrefix)}${SEP})?`;
|
|
25
|
+
const phrase = `(?:${spec.phrase})`;
|
|
26
|
+
// `## [**][abap-x — ]Phrase[ — | : | ' ']Title[**]` or `## abap-x — Title`
|
|
27
|
+
const heading = new RegExp(String.raw `^[ \t]{0,3}#{1,6}[ \t]+(?:\*\*)?(?:${prefix}${phrase}(?:(?:${SEP}|[ \t]+)(.*?))?|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
|
|
28
|
+
const h = heading.exec(markdown);
|
|
29
|
+
if (h)
|
|
30
|
+
return { title: strip(h[1] ?? h[2] ?? '') };
|
|
31
|
+
const plain = new RegExp(String.raw `^[ \t]{0,3}(?:\*\*)?(?:${prefix}${phrase}${SEP}(.+?)|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
|
|
32
|
+
const p = plain.exec(markdown);
|
|
33
|
+
if (p)
|
|
34
|
+
return { title: strip(p[1] ?? p[2] ?? '') };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -78,7 +78,7 @@ function renderRow(r, width) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
|
-
* The checklist as display lines: `
|
|
81
|
+
* The checklist as display lines: `Tasks · N of M` header, then rows.
|
|
82
82
|
* - maxRows: cap on ITEM rows (header excluded). Not expanded → also capped
|
|
83
83
|
* at CHECKLIST_MAX_ROWS; expanded → only maxRows (the §2 height cap) applies.
|
|
84
84
|
* - foldedToOne: exactly one line (used while a card is open).
|
|
@@ -92,7 +92,7 @@ export function formatChecklist(list, o) {
|
|
|
92
92
|
const todos = list.map((t) => ({ ...t, text: sanitizeControlChars(t.text) }));
|
|
93
93
|
const width = Math.max(1, Math.floor(o.width));
|
|
94
94
|
const cur = currentTodoIndex(todos);
|
|
95
|
-
const count = `
|
|
95
|
+
const count = `Tasks · ${countLabel(doneCount(todos), todos.length)}`;
|
|
96
96
|
if (o.foldedToOne) {
|
|
97
97
|
const tail = cur >= 0 ? ` · ${getGlyphs().current} ${todos[cur].text}` : '';
|
|
98
98
|
return [chalk.bold(truncateToWidth(count + tail, width))];
|
|
@@ -103,15 +103,15 @@ export function formatChecklist(list, o) {
|
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* The one line a turn leaves in scrollback when its plan changed (spec §4):
|
|
106
|
-
* `✓
|
|
106
|
+
* `✓ Tasks · M of M done` (ok) when finished, else `Tasks · N of M done` (dim).
|
|
107
107
|
*/
|
|
108
108
|
export function formatPlanClose(todos) {
|
|
109
109
|
const done = doneCount(todos);
|
|
110
110
|
const total = todos.length;
|
|
111
111
|
if (total > 0 && done === total) {
|
|
112
|
-
return paint('ok', `${getGlyphs().done}
|
|
112
|
+
return paint('ok', `${getGlyphs().done} Tasks · ${countLabel(done, total)} done`);
|
|
113
113
|
}
|
|
114
|
-
return paint('dim', `
|
|
114
|
+
return paint('dim', `Tasks · ${countLabel(done, total)} done`);
|
|
115
115
|
}
|
|
116
116
|
let modeOverride;
|
|
117
117
|
/** Test seam — force a mode; `undefined` restores detection. */
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/renderer/machine-blocks.ts
|
|
2
|
+
//
|
|
3
|
+
// 1.1.28 — machine-readable blocks the skills emit for the CLI
|
|
4
|
+
// (`<!-- cspeach:plan-manifest {...} -->`, the legacy `<!-- csforge:... -->`
|
|
5
|
+
// prefix, and the cca / upgrade / modernize / test-coverage manifests) are
|
|
6
|
+
// parsed from the RAW assistant content (extract-plan.ts, plan-continue.ts,
|
|
7
|
+
// loop.ts onPlanManifest — untouched by this file) but must never reach the
|
|
8
|
+
// screen: a plan manifest is a multi-KB JSON blob.
|
|
9
|
+
//
|
|
10
|
+
// This is a streaming filter that sits in front of renderChunk (loop.ts), so
|
|
11
|
+
// it covers the Ink and classic renderers and one-shot stdout alike. It only
|
|
12
|
+
// changes what is DISPLAYED; the assistant content, the session file and the
|
|
13
|
+
// turn stream log keep the block byte-for-byte.
|
|
14
|
+
//
|
|
15
|
+
// Rule: a block starts with `<!--`, optional blanks, `cspeach:` or `csforge:`
|
|
16
|
+
// at the START of a line. It ends at a `-->` on the opener's own line, or
|
|
17
|
+
// else at a `-->` that starts a later line (one newline straight after it is
|
|
18
|
+
// dropped too). An inline mention mid-line is prose and stays.
|
|
19
|
+
// A chunk boundary can fall anywhere: a line that could still become an
|
|
20
|
+
// opener is held until it can be decided; inside a block a tail that could
|
|
21
|
+
// still become the closing `-->` is held the same way.
|
|
22
|
+
export function initialMachineBlockState() {
|
|
23
|
+
return { held: '', inBlock: false, firstLine: false, eatNewline: false, lineStart: true };
|
|
24
|
+
}
|
|
25
|
+
// A leading `\n` marks a line start (the probe prepends one when the text
|
|
26
|
+
// begins at a line start) — no `^`, which would match a mid-line chunk start.
|
|
27
|
+
const OPENER_RE = /\n[ \t]*<!--[ \t]*(?:cspeach|csforge):/;
|
|
28
|
+
const PREFIXES = ['cspeach:', 'csforge:'];
|
|
29
|
+
const CLOSER_RE = /\n[ \t]*-->/;
|
|
30
|
+
const PARTIAL_CLOSER_RE = /^\n[ \t]*-{0,2}$/;
|
|
31
|
+
/** Could `frag` (one incomplete line) still grow into a block opener? */
|
|
32
|
+
function couldBeOpener(frag) {
|
|
33
|
+
const s = frag.replace(/^[ \t]*/, '');
|
|
34
|
+
if (s.length <= 4)
|
|
35
|
+
return '<!--'.startsWith(s);
|
|
36
|
+
if (!s.startsWith('<!--'))
|
|
37
|
+
return false;
|
|
38
|
+
const r = s.slice(4).replace(/^[ \t]*/, '');
|
|
39
|
+
return PREFIXES.some((p) => p.startsWith(r));
|
|
40
|
+
}
|
|
41
|
+
export function filterMachineBlocks(chunk, prev) {
|
|
42
|
+
const st = { ...prev };
|
|
43
|
+
let work = st.held + chunk;
|
|
44
|
+
st.held = '';
|
|
45
|
+
let output = '';
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (st.eatNewline) {
|
|
48
|
+
if (work === '')
|
|
49
|
+
break;
|
|
50
|
+
if (work.startsWith('\r\n')) {
|
|
51
|
+
work = work.slice(2);
|
|
52
|
+
st.lineStart = true;
|
|
53
|
+
}
|
|
54
|
+
else if (work === '\r') {
|
|
55
|
+
st.held = work;
|
|
56
|
+
work = '';
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
else if (work[0] === '\n') {
|
|
60
|
+
work = work.slice(1);
|
|
61
|
+
st.lineStart = true;
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
st.lineStart = false;
|
|
65
|
+
st.eatNewline = false;
|
|
66
|
+
}
|
|
67
|
+
if (st.inBlock && st.firstLine) {
|
|
68
|
+
// Still on the opener line: any `-->` closes (`<!-- cspeach:x -->`).
|
|
69
|
+
const close = work.indexOf('-->');
|
|
70
|
+
const nl = work.indexOf('\n');
|
|
71
|
+
if (close >= 0 && (nl < 0 || close < nl)) {
|
|
72
|
+
work = work.slice(close + 3);
|
|
73
|
+
st.inBlock = false;
|
|
74
|
+
st.eatNewline = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (nl < 0) {
|
|
78
|
+
st.held = work.slice(-2);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
st.firstLine = false;
|
|
82
|
+
work = work.slice(nl); // keep the newline: it marks the next line start
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (st.inBlock) {
|
|
86
|
+
// Body lines: only a `-->` at the start of a line closes, the same rule
|
|
87
|
+
// as the extractors (`\n\s*-->`), so a note like "A --> B" inside the
|
|
88
|
+
// JSON does not end the block early.
|
|
89
|
+
const m = CLOSER_RE.exec(work);
|
|
90
|
+
if (!m) {
|
|
91
|
+
const lastNl = work.lastIndexOf('\n');
|
|
92
|
+
const tail = lastNl >= 0 ? work.slice(lastNl) : '';
|
|
93
|
+
st.held = PARTIAL_CLOSER_RE.test(tail) ? tail : '';
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
work = work.slice(m.index + m[0].length);
|
|
97
|
+
st.inBlock = false;
|
|
98
|
+
st.eatNewline = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const probe = (st.lineStart ? '\n' : '') + work;
|
|
102
|
+
const m = OPENER_RE.exec(probe);
|
|
103
|
+
if (m) {
|
|
104
|
+
const offset = st.lineStart ? 1 : 0;
|
|
105
|
+
const lineBegin = m.index + 1 - offset; // index in `work`, just after the newline
|
|
106
|
+
output += work.slice(0, lineBegin);
|
|
107
|
+
work = work.slice(m.index + m[0].length - offset);
|
|
108
|
+
st.inBlock = true;
|
|
109
|
+
st.firstLine = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// No opener yet: hold the last incomplete line if it could become one.
|
|
113
|
+
const lastNl = work.lastIndexOf('\n');
|
|
114
|
+
const fragStart = lastNl >= 0 ? lastNl + 1 : st.lineStart ? 0 : -1;
|
|
115
|
+
if (fragStart >= 0 && fragStart < work.length && couldBeOpener(work.slice(fragStart))) {
|
|
116
|
+
output += work.slice(0, fragStart);
|
|
117
|
+
st.held = work.slice(fragStart);
|
|
118
|
+
st.lineStart = true;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
output += work;
|
|
122
|
+
if (work.length > 0)
|
|
123
|
+
st.lineStart = work.endsWith('\n');
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
return { output, state: st };
|
|
128
|
+
}
|
|
129
|
+
/** End of stream: release held prose; an unterminated block shows nothing. */
|
|
130
|
+
export function flushMachineBlocks(state) {
|
|
131
|
+
if (state.inBlock)
|
|
132
|
+
return '';
|
|
133
|
+
if (state.eatNewline)
|
|
134
|
+
return state.held === '\r' ? '' : state.held;
|
|
135
|
+
return state.held;
|
|
136
|
+
}
|
|
137
|
+
/** Whole-text form (same rule) for output that is not streamed. */
|
|
138
|
+
export function stripMachineBlocks(text) {
|
|
139
|
+
const r = filterMachineBlocks(text, initialMachineBlockState());
|
|
140
|
+
return r.output + flushMachineBlocks(r.state);
|
|
141
|
+
}
|
|
@@ -49,7 +49,8 @@ const GENERIC_SUFFIXES = [
|
|
|
49
49
|
{ at: 30_000, suffix: ' (30s)' },
|
|
50
50
|
{ at: 60_000, suffix: ' (60s — still running, hold on)' },
|
|
51
51
|
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
52
|
-
|
|
52
|
+
// 2026-10-04: never assume a VPN — many users sit on the company network.
|
|
53
|
+
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check network access to the SAP system)' },
|
|
53
54
|
];
|
|
54
55
|
/**
|
|
55
56
|
* 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
|
|
@@ -122,6 +122,8 @@ const RULES = {
|
|
|
122
122
|
const incName = inc !== undefined ? (Object.hasOwn(INCLUDE_NAMES, inc) ? INCLUDE_NAMES[inc] : inc) : '';
|
|
123
123
|
return { verb: 'Wrote', object: shortRight(`${str(a['className']) ?? ''} ${incName}`.trim()) };
|
|
124
124
|
},
|
|
125
|
+
sap_get_text_elements: (a) => ({ verb: 'Read texts of', object: shortRight(`${str(a['name']) ?? ''} ${str(a['kind']) ?? ''}`.trim()) }),
|
|
126
|
+
sap_set_text_elements: (a) => ({ verb: 'Wrote texts', object: shortRight(`${str(a['name']) ?? ''} ${str(a['kind']) ?? ''}`.trim()) }),
|
|
125
127
|
sap_search_object: (a, r) => {
|
|
126
128
|
// "*AGING*" · PROG — the type filter tells otherwise identical searches apart.
|
|
127
129
|
const type = str(a['type']);
|
|
@@ -263,6 +265,7 @@ const PRESENT = {
|
|
|
263
265
|
Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
|
|
264
266
|
'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
|
|
265
267
|
'Checked capability': 'Checking capability',
|
|
268
|
+
'Read texts of': 'Reading texts of', 'Wrote texts': 'Writing texts',
|
|
266
269
|
};
|
|
267
270
|
export function toolActivityLabel(name, args) {
|
|
268
271
|
const l = toolLabel(name, args);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/repl/plan-hop.ts
|
|
2
|
+
//
|
|
3
|
+
// Task 13 fix round (2026-10-04) — an "in-run hop" is a plan-resume the plan
|
|
4
|
+
// chain ITSELF queued (runPostPhaseChain / runReauditPass /
|
|
5
|
+
// runContinuationChain: the next step, the re-run after a failed check).
|
|
6
|
+
// Marked at the source, never inferred from the command shape: the first step
|
|
7
|
+
// of an /abap-app run (lane/app-chain.ts) and a typed resume are NOT hops, so
|
|
8
|
+
// they show the board and the guarded notice once.
|
|
9
|
+
export function createPlanHopTracker(slot) {
|
|
10
|
+
let chainQueued = null;
|
|
11
|
+
return {
|
|
12
|
+
queue(cmd) {
|
|
13
|
+
chainQueued = cmd;
|
|
14
|
+
slot.set(cmd);
|
|
15
|
+
},
|
|
16
|
+
take(cmd) {
|
|
17
|
+
const hop = chainQueued !== null && chainQueued === cmd;
|
|
18
|
+
chainQueued = null;
|
|
19
|
+
return hop;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -26,6 +26,19 @@ let planGateApprovedThisTurn = false;
|
|
|
26
26
|
export function recordWriteOp(tool, args) {
|
|
27
27
|
writeOpsThisTurn.push({ tool, args, ts: new Date().toISOString() });
|
|
28
28
|
}
|
|
29
|
+
let batchApprovedProvider = null;
|
|
30
|
+
export function setRule8BatchApprovedProvider(fn) {
|
|
31
|
+
batchApprovedProvider = fn;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* True for a tool that cannot run without an approval id from request_approval
|
|
35
|
+
* (its schema requires `approval_id`): each such call is already approved one
|
|
36
|
+
* by one. Shell, file and other tools without an id are never in this set.
|
|
37
|
+
*/
|
|
38
|
+
export function toolSpendsApprovalId(tool) {
|
|
39
|
+
const req = tool.input_schema?.required;
|
|
40
|
+
return Array.isArray(req) && req.includes('approval_id');
|
|
41
|
+
}
|
|
29
42
|
/**
|
|
30
43
|
* Returns true on the 2nd (and later) mutating call of the turn — the gate
|
|
31
44
|
* fires here. The 1st mutating call proceeds without the prompt; only its
|
|
@@ -38,9 +51,11 @@ export function recordWriteOp(tool, args) {
|
|
|
38
51
|
* batches the model issues OUTSIDE the approval flow (rare but possible —
|
|
39
52
|
* e.g. recovery paths, ad-hoc tool calls), so the safety net stays.
|
|
40
53
|
*/
|
|
41
|
-
export function shouldGateRule8() {
|
|
54
|
+
export function shouldGateRule8(tool) {
|
|
42
55
|
if (planGateApprovedThisTurn)
|
|
43
56
|
return false;
|
|
57
|
+
if (tool && batchApprovedProvider?.(tool))
|
|
58
|
+
return false;
|
|
44
59
|
return writeOpsThisTurn.length >= 1;
|
|
45
60
|
}
|
|
46
61
|
/**
|