@cspeach/cli 1.1.55 → 1.1.57
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 +80 -7
- package/dist/lane/ready-spec.js +3 -1
- package/dist/projects/plan-sources.js +3 -2
- package/dist/projects/promote-command.js +4 -2
- package/dist/projects/spec-share.js +10 -0
- package/dist/projects/team-build-context.js +2 -1
- package/dist/repl/open-questions-gate.js +29 -3
- package/dist/repl/team-build-screen.js +27 -11
- package/dist/repl.js +2 -2
- package/dist/tools/sap-write.js +40 -11
- package/live-check-2026-10-17-1343.txt +41 -0
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { basename, dirname } from 'node:path';
|
|
2
|
+
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path';
|
|
3
3
|
import { dispatchTool } from './tool-dispatch.js';
|
|
4
4
|
import { listTools, getTool, isStandalone, toolsForContext, toAnthropicTools } from '../tools/index.js';
|
|
5
5
|
import { toolsForSeat } from '../tools/seat-gate.js';
|
|
@@ -54,7 +54,7 @@ import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
|
54
54
|
import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
|
|
55
55
|
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
56
56
|
import { bindCliCardSession, documentOffersFor, markCliMessage, CONTINUE_LINE, WATCHDOG_LINE } from '../session/cli-record.js';
|
|
57
|
-
import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
|
|
57
|
+
import { clearAppFlow, getAppFlow, setGapConfirmed, setSpecGap, startAppFlow } from '../lane/app-flow.js';
|
|
58
58
|
import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
|
|
59
59
|
import { afterRebuildTurn } from '../journey/rebuild.js';
|
|
60
60
|
import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
|
|
@@ -64,6 +64,7 @@ import { shownToken } from '../lane/card.js';
|
|
|
64
64
|
import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
|
|
65
65
|
import { input } from '@inquirer/prompts';
|
|
66
66
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
67
|
+
import { takeGateGoAhead } from '../repl/open-questions-gate.js';
|
|
67
68
|
import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
68
69
|
import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
|
|
69
70
|
import { expandImageChips } from '../ui/clipboard-image.js';
|
|
@@ -451,6 +452,51 @@ async function isSharedSpecSave(cwd, saveSkill, savedPath) {
|
|
|
451
452
|
return false;
|
|
452
453
|
}
|
|
453
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* F14 — the spec text the lane step quotes from: the Markdown copy next to the
|
|
457
|
+
* shared spec-gap (`<slug>.md`, else the one .md there), cwd-relative. The
|
|
458
|
+
* lane tool holds the model to the flow's spec, and the spec-gap file is never
|
|
459
|
+
* that spec. Undefined when there is none.
|
|
460
|
+
*/
|
|
461
|
+
async function sharedSpecText(cwd, gapAbs) {
|
|
462
|
+
const { readdir } = await import('node:fs/promises');
|
|
463
|
+
const dir = dirname(gapAbs);
|
|
464
|
+
const mds = (await readdir(dir).catch(() => [])).filter((f) => f.toLowerCase().endsWith('.md'));
|
|
465
|
+
const pick = mds.find((f) => f === `${basename(dir)}.md`) ?? (mds.length === 1 ? mds[0] : undefined);
|
|
466
|
+
return pick === undefined ? undefined : relative(cwd, resolvePath(dir, pick)).split(sep).join('/');
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* F14 (2026-10-30 live retest) — `/abap-app --from @specs/<slug>/<file>` in a
|
|
470
|
+
* team build folder with no flow for that file: start the flow at the lane
|
|
471
|
+
* step, so the lane tool queues `/abap-plan --from @specs/<slug>/<file>`.
|
|
472
|
+
* A flow already at the lane step for the same real file is kept as it is.
|
|
473
|
+
* True when the source is a shared spec here. Never throws: a folder it
|
|
474
|
+
* cannot read starts nothing.
|
|
475
|
+
*/
|
|
476
|
+
async function startSharedSpecFlow(session, cwd, token, source, yes) {
|
|
477
|
+
try {
|
|
478
|
+
const { buildFolderAt } = await import('../commands/team-spec.js');
|
|
479
|
+
const here = buildFolderAt(cwd);
|
|
480
|
+
if (!here)
|
|
481
|
+
return false;
|
|
482
|
+
const abs = resolvePath(cwd, source);
|
|
483
|
+
const { isSharedSpecPath } = await import('../projects/spec-share.js');
|
|
484
|
+
if (!isSharedSpecPath(here.dir, abs))
|
|
485
|
+
return false;
|
|
486
|
+
const flow = getAppFlow(session);
|
|
487
|
+
if (flow?.expect === 'lane' && flow.specGapPath && await sameRealFile(flow.specGapPath, abs))
|
|
488
|
+
return true;
|
|
489
|
+
startAppFlow(session, await sharedSpecText(cwd, abs) ?? token);
|
|
490
|
+
setSpecGap(session, abs);
|
|
491
|
+
if (yes !== undefined && await sameRealFile(yes, abs))
|
|
492
|
+
setGapConfirmed(session, abs);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// no flow: the lane step says no plan was queued, as before
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
454
500
|
async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
|
|
455
501
|
try {
|
|
456
502
|
const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
|
|
@@ -648,13 +694,12 @@ export async function maybeOfferSave(p) {
|
|
|
648
694
|
// Team build (Task 9): a spec in the shared specs/ folder is never
|
|
649
695
|
// rewritten — each answer is a record of its own, and the spec is read
|
|
650
696
|
// with the records applied. Found by the folder (R10), never activeProject().
|
|
651
|
-
const { sharedSpecTargetFor, isSharedSpecPath,
|
|
652
|
-
const { readProjectFile } = await import('../projects/status.js');
|
|
697
|
+
const { sharedSpecTargetFor, isSharedSpecPath, readComposedSpec, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
|
|
653
698
|
const shared = sharedSpecTargetFor(p.cwd ?? process.cwd(), 'spec-gap');
|
|
654
699
|
const records = shared && isSharedSpecPath(shared.teamDir, savedPath)
|
|
655
700
|
? {
|
|
656
701
|
// R11: only the answers recorded for this envelope.
|
|
657
|
-
readEnvelope:
|
|
702
|
+
readEnvelope: readComposedSpec,
|
|
658
703
|
recordAnswer: (a) => {
|
|
659
704
|
const specDir = dirname(savedPath);
|
|
660
705
|
writeAnswerRecord(specDir, {
|
|
@@ -1020,6 +1065,13 @@ export async function runTurn(params) {
|
|
|
1020
1065
|
// (plan step) skips it when it promotes the same real file.
|
|
1021
1066
|
const flowNow = !params.ctx.subagent ? getAppFlow(session) : undefined;
|
|
1022
1067
|
const confirmedGap = flowNow?.gapConfirmed;
|
|
1068
|
+
// F12 (2026-10-30 live retest) — the open-questions gate's go-ahead on a
|
|
1069
|
+
// shared spec was the yes: this turn's /abap-app promote of that same
|
|
1070
|
+
// real file asks nothing more. It counts for the plan step too.
|
|
1071
|
+
const gateYes = !params.ctx.subagent && params.skill === 'abap-app' ? takeGateGoAhead(session) : undefined;
|
|
1072
|
+
// F14 (2026-10-30 live retest) — the yes given this turn, kept for a flow
|
|
1073
|
+
// this promote starts (a shared spec built with no flow yet).
|
|
1074
|
+
let yesThisTurn;
|
|
1023
1075
|
const result = await runPromoteCommand({
|
|
1024
1076
|
sourcePath: fromPath,
|
|
1025
1077
|
targetSkill: params.skill,
|
|
@@ -1029,9 +1081,22 @@ export async function runTurn(params) {
|
|
|
1029
1081
|
// Final residual 4 — the same folder gateAppFlowForTurn resolves @X against.
|
|
1030
1082
|
cwd: params.ctx.cwd,
|
|
1031
1083
|
...(flowNow?.expect === 'lane' && params.skill === 'abap-app'
|
|
1032
|
-
? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) }
|
|
1084
|
+
? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) }
|
|
1085
|
+
: !params.ctx.subagent && params.skill === 'abap-app'
|
|
1086
|
+
? { onOpenWarningsConfirmed: (p) => { yesThisTurn = p; } } : {}),
|
|
1033
1087
|
...(flowNow?.expect === 'plan' && params.skill === 'abap-plan' && confirmedGap
|
|
1034
1088
|
? { skipOpenWarnings: (p) => sameRealFile(p, confirmedGap) } : {}),
|
|
1089
|
+
...(gateYes
|
|
1090
|
+
? {
|
|
1091
|
+
skipOpenWarnings: async (p) => {
|
|
1092
|
+
const same = await gateYes(p);
|
|
1093
|
+
if (same && getAppFlow(session)?.expect === 'lane')
|
|
1094
|
+
setGapConfirmed(session, p);
|
|
1095
|
+
if (same)
|
|
1096
|
+
yesThisTurn = p;
|
|
1097
|
+
return same;
|
|
1098
|
+
},
|
|
1099
|
+
} : {}),
|
|
1035
1100
|
});
|
|
1036
1101
|
if (!result) {
|
|
1037
1102
|
// User declined, source invalid, or unsupported target — skip the turn.
|
|
@@ -1040,6 +1105,12 @@ export async function runTurn(params) {
|
|
|
1040
1105
|
}
|
|
1041
1106
|
promotedFromForSave = result.promotedFrom;
|
|
1042
1107
|
promotedFromPathForSave = result.sourcePath;
|
|
1108
|
+
// F14 (2026-10-30 live retest) — a build started from a shared spec
|
|
1109
|
+
// (start screen, gate, F9c hand-off or typed) had no app flow, so the
|
|
1110
|
+
// lane step queued no plan. Start one here, at the lane step, as after a
|
|
1111
|
+
// chained spec-gap save. Team build folders only; the gate above has run.
|
|
1112
|
+
const sharedSpec = !params.ctx.subagent && params.skill === 'abap-app' && result.promotedFrom.artefactType === 'spec-gap'
|
|
1113
|
+
&& await startSharedSpecFlow(session, params.ctx.cwd ?? process.cwd(), fromPath, result.sourcePath, yesThisTurn);
|
|
1043
1114
|
// 2026-05-08: thread the original @<filename> token into the LLM
|
|
1044
1115
|
// prompt so skills that need to dispatch a follow-up command (e.g.
|
|
1045
1116
|
// dispatch_skill calling /abap-rap --from @<same-file>) can use the
|
|
@@ -1049,7 +1120,9 @@ export async function runTurn(params) {
|
|
|
1049
1120
|
// "foo-v1"), producing "no file matching" auto-routes. Use basename
|
|
1050
1121
|
// so absolute / relative paths normalise to the workspace @<token>
|
|
1051
1122
|
// form the picker resolves.
|
|
1052
|
-
|
|
1123
|
+
// F14: a shared spec keeps the path as typed (`specs/<slug>/<file>`); its
|
|
1124
|
+
// bare name resolves against the seat's work folder and fails.
|
|
1125
|
+
const sourceToken = sharedSpec ? `@${/\s/.test(fromPath) ? `"${fromPath}"` : fromPath}` : `@${basename(fromPath)}`;
|
|
1053
1126
|
userMessageForLLM = `Source file: ${sourceToken}\n${result.extendedSkillInput}\n\n---\n\n${rest}`;
|
|
1054
1127
|
userMessageForSave = rest;
|
|
1055
1128
|
}
|
package/dist/lane/ready-spec.js
CHANGED
|
@@ -9,7 +9,9 @@ export async function readySpecFor(cwd, request) {
|
|
|
9
9
|
const said = request.toLowerCase();
|
|
10
10
|
const isNamed = (s) => said.includes(s.title.toLowerCase()) || said.includes(s.slug.replace(/-/g, ' '));
|
|
11
11
|
const namedAll = all.filter(isNamed);
|
|
12
|
-
|
|
12
|
+
// F13 (2026-10-30 live retest): a spec being built stays buildable, so a
|
|
13
|
+
// build stopped after the gate, or a closed window, can be continued.
|
|
14
|
+
const ready = all.filter((s) => s.status === 'ready for build' || s.status === 'being built');
|
|
13
15
|
const named = ready.filter(isNamed);
|
|
14
16
|
// S1 — the request names a spec, and it is not one that is ready.
|
|
15
17
|
if (named.length === 0 && namedAll.length === 1) {
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
// continuous run) calls resolvePlanSources + renderPlanSourcesBlock.
|
|
23
23
|
import { promises as fs } from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
-
import { readProjectFile } from './status.js';
|
|
26
25
|
import { buildPromotedFromSnapshot } from './promote.js';
|
|
27
26
|
import { assertRealPathContained } from '../tools/_filesystem-shared.js';
|
|
28
27
|
import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
|
|
@@ -92,7 +91,9 @@ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord, planPath)
|
|
|
92
91
|
continue;
|
|
93
92
|
let env;
|
|
94
93
|
try {
|
|
95
|
-
|
|
94
|
+
// F12 — a shared spec's answer records reach the phase too.
|
|
95
|
+
const { readComposedSpec } = await import('./spec-share.js');
|
|
96
|
+
env = readComposedSpec(abs);
|
|
96
97
|
}
|
|
97
98
|
catch {
|
|
98
99
|
continue;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
|
-
import { readProjectFile } from './status.js';
|
|
3
2
|
import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
|
|
4
3
|
import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
|
|
5
4
|
import { askYesNo } from './yes-no.js';
|
|
@@ -105,9 +104,12 @@ export async function runPromoteCommand(args) {
|
|
|
105
104
|
args.log(`--from: ${resolvedPath} is inside CSPeach's own folder and cannot be read.`);
|
|
106
105
|
return null;
|
|
107
106
|
}
|
|
107
|
+
// 2026-10-30 live retest F12: a shared spec is read with its answer
|
|
108
|
+
// records applied, so answered questions do not count as open.
|
|
109
|
+
const { readComposedSpec } = await import('./spec-share.js');
|
|
108
110
|
let env;
|
|
109
111
|
try {
|
|
110
|
-
env =
|
|
112
|
+
env = readComposedSpec(resolvedPath);
|
|
111
113
|
}
|
|
112
114
|
catch (e) {
|
|
113
115
|
args.log(`--from source error: ${String(e instanceof Error ? e.message : e)}`);
|
|
@@ -161,6 +161,16 @@ export function composeSpec(env, answers) {
|
|
|
161
161
|
}
|
|
162
162
|
return out;
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* The spec at `absPath` as people read it: the file with the answers recorded
|
|
166
|
+
* for it applied (R11). A folder with no `answers/` gives the plain file.
|
|
167
|
+
* 2026-10-30 live retest F12: `--from` read the raw file and saw 25 answered
|
|
168
|
+
* questions as open.
|
|
169
|
+
*/
|
|
170
|
+
export function readComposedSpec(absPath) {
|
|
171
|
+
const file = path.basename(absPath);
|
|
172
|
+
return composeSpec(readEnvelopeFile(absPath), readAnswers(path.dirname(absPath)).filter((a) => a.file === file));
|
|
173
|
+
}
|
|
164
174
|
export async function writeSharedSpec(a) {
|
|
165
175
|
const slug = specSlug(a.env.title);
|
|
166
176
|
const specDir = path.join(a.teamDir, SPECS_DIR, slug);
|
|
@@ -38,7 +38,8 @@ export function teamBuildContextBlock(cwd, seat) {
|
|
|
38
38
|
lines.push('When the person says a spec is ready for the developer, CSPeach records it; this seat does not build.');
|
|
39
39
|
}
|
|
40
40
|
else {
|
|
41
|
-
|
|
41
|
+
// F13 (2026-10-30 live retest): a spec being built can be continued.
|
|
42
|
+
lines.push('To build a ready spec, or continue one being built: hand_off with skill "abap-app" and the spec\'s title in the request. CSPeach attaches the spec and shows its open questions first.');
|
|
42
43
|
}
|
|
43
44
|
lines.push('</team_build>');
|
|
44
45
|
return lines.join('\n');
|
|
@@ -27,6 +27,24 @@ export const STOPPED_LINE = 'Stopped. The spec stays ready for the developer.';
|
|
|
27
27
|
const markedThisRun = new Set();
|
|
28
28
|
const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
|
|
29
29
|
export function noteBeingBuiltThisRun(specDir) { markedThisRun.add(dirKey(specDir)); }
|
|
30
|
+
/**
|
|
31
|
+
* 2026-10-30 live retest F12 — the gate letting the build go on ("Go ahead",
|
|
32
|
+
* answered, or nothing open) is the developer's yes to the open items. The
|
|
33
|
+
* spec file is kept per session, and the promote in that same turn takes it
|
|
34
|
+
* (once) so it does not ask "still open … Promote anyway?" again.
|
|
35
|
+
*/
|
|
36
|
+
const goAhead = new WeakMap();
|
|
37
|
+
/** The gate's yes for this session, taken once: a check that the promote source is the same real file. */
|
|
38
|
+
export function takeGateGoAhead(session) {
|
|
39
|
+
const abs = goAhead.get(session);
|
|
40
|
+
if (abs === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
goAhead.delete(session);
|
|
43
|
+
return async (p) => {
|
|
44
|
+
const { sameRealFile } = await import('../lane/app-chain.js');
|
|
45
|
+
return sameRealFile(p, abs);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
30
48
|
function openQuestions(env) {
|
|
31
49
|
if (env.artefactType !== 'spec-gap')
|
|
32
50
|
return [];
|
|
@@ -50,6 +68,9 @@ export function gateChooser(choose) {
|
|
|
50
68
|
}
|
|
51
69
|
const cardTitle = (n) => (n === 1 ? '1 open question travels with this spec.' : `${n} open questions travel with this spec.`);
|
|
52
70
|
export async function openQuestionsGate(a) {
|
|
71
|
+
// F12: a yes from an earlier gate never carries over to this command.
|
|
72
|
+
if (a.session)
|
|
73
|
+
goAhead.delete(a.session);
|
|
53
74
|
// F10 (2026-10-29): a typed `/abap-app @<saved spec-gap>` (no --from) is
|
|
54
75
|
// the same request — the spec is already analysed — so it goes to the lane
|
|
55
76
|
// step as `--from`, never back through step 1. Anything else typed with @
|
|
@@ -91,11 +112,10 @@ export async function openQuestionsGate(a) {
|
|
|
91
112
|
const share = await import('../projects/spec-share.js');
|
|
92
113
|
if (!share.isSharedSpecPath(here.dir, abs) || !abs.endsWith('.cspeach.json'))
|
|
93
114
|
return null;
|
|
94
|
-
const { readProjectFile } = await import('../projects/status.js');
|
|
95
115
|
const specDir = path.dirname(abs);
|
|
96
116
|
const file = path.basename(abs);
|
|
97
117
|
// R11: only the answers recorded for this envelope.
|
|
98
|
-
const read =
|
|
118
|
+
const read = share.readComposedSpec;
|
|
99
119
|
let env;
|
|
100
120
|
try {
|
|
101
121
|
env = read(abs);
|
|
@@ -122,7 +142,13 @@ export async function openQuestionsGate(a) {
|
|
|
122
142
|
}
|
|
123
143
|
catch { /* the build still goes on */ }
|
|
124
144
|
};
|
|
125
|
-
const goOn = (r) => {
|
|
145
|
+
const goOn = (r) => {
|
|
146
|
+
markedThisRun.delete(dirKey(specDir));
|
|
147
|
+
markBeingBuilt();
|
|
148
|
+
if (a.session)
|
|
149
|
+
goAhead.set(a.session, abs);
|
|
150
|
+
return r;
|
|
151
|
+
};
|
|
126
152
|
const carried = (qs) => goOn({ command: `${command}\nOpen questions carried as assumptions:\n${qs.map((q) => `- ${q.id}: ${q.text}`).join('\n')}` });
|
|
127
153
|
for (;;) {
|
|
128
154
|
const qs = openQuestions(env);
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Team build (spec 2026-10-25) — the developer start screen.
|
|
3
3
|
*
|
|
4
4
|
* When CSPeach starts in a team build folder, it says what sits in the shared
|
|
5
|
-
* `specs/` folder: each spec ready for build with its summary
|
|
6
|
-
* questions, and one line for each draft
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* `specs/` folder: each spec ready for build or being built with its summary
|
|
6
|
+
* and open questions, and one line for each draft. A developer is then
|
|
7
|
+
* offered each ready spec (build it, ask about it first, or later) and each
|
|
8
|
+
* spec being built (continue building it, F13). A functional seat sees the
|
|
9
|
+
* same lines and is never offered a build.
|
|
9
10
|
*
|
|
10
11
|
* Ruling R10: the build folder is the folder CSPeach runs in, never
|
|
11
12
|
* `activeProject()`. Ruling R12: the status record goes to the spec folder.
|
|
@@ -13,12 +14,18 @@
|
|
|
13
14
|
*/
|
|
14
15
|
import path from 'node:path';
|
|
15
16
|
const day = (iso) => String(iso ?? '').slice(0, 10);
|
|
17
|
+
const trimQuestion = (q) => (q.length > 100 ? `${q.slice(0, 100)}…` : q);
|
|
16
18
|
export function startScreenLines(specs, _seat) {
|
|
17
19
|
const out = [];
|
|
18
20
|
for (const s of specs) {
|
|
19
21
|
const who = `"${s.title}" — ${s.author}, ${day(s.createdAt)}`;
|
|
20
|
-
if (s.status === 'ready for build') {
|
|
21
|
-
|
|
22
|
+
if (s.status === 'ready for build' || s.status === 'being built') {
|
|
23
|
+
// F13 (2026-10-30 live retest): a spec being built reads like a ready
|
|
24
|
+
// one, with who started it, so the developer can continue it.
|
|
25
|
+
const head = s.status === 'ready for build'
|
|
26
|
+
? `Ready to build: ${who}`
|
|
27
|
+
: `Being built: ${who}${s.statusBy ? ` · started by ${s.statusBy}, ${day(s.statusAt ?? '')}` : ''}`;
|
|
28
|
+
out.push(head, ` ${s.summary}`);
|
|
22
29
|
if (s.openQuestions.length) {
|
|
23
30
|
out.push(' Open questions:');
|
|
24
31
|
for (const q of s.openQuestions)
|
|
@@ -29,29 +36,32 @@ export function startScreenLines(specs, _seat) {
|
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
else {
|
|
32
|
-
out.push(
|
|
39
|
+
out.push(`Draft: ${who}`);
|
|
33
40
|
}
|
|
34
41
|
// Owner, 2026-10-29: an answer someone else replaced is said, so the
|
|
35
42
|
// spec's author learns the developer overrode it.
|
|
36
43
|
const changed = s.changedAnswers ?? [];
|
|
37
44
|
if (changed.length) {
|
|
38
45
|
out.push(' Answers changed by someone else:');
|
|
46
|
+
// M7 (2026-10-30 live retest): a long question took 6 lines; cut at 100.
|
|
39
47
|
for (const c of changed)
|
|
40
|
-
out.push(` - "${c.question}" now "${c.answer}" (${c.by}, ${day(c.at)}), was "${c.was}" (${c.wasBy})`);
|
|
48
|
+
out.push(` - "${trimQuestion(c.question)}" now "${c.answer}" (${c.by}, ${day(c.at)}), was "${c.was}" (${c.wasBy})`);
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
return out;
|
|
44
52
|
}
|
|
45
53
|
const BUILD = 'Build this spec';
|
|
54
|
+
const CONTINUE = 'Continue building';
|
|
46
55
|
const ASK = 'Ask about it first';
|
|
47
56
|
const LATER = 'Later';
|
|
48
57
|
export const askSentence = (s) => `Tell me about the spec "${s.title}" in specs/${s.slug}/ before we build it.`;
|
|
49
58
|
async function chooseFor(choose, s) {
|
|
50
59
|
const n = s.openQuestions.length;
|
|
60
|
+
const going = s.status === 'being built';
|
|
51
61
|
const v = await choose({
|
|
52
|
-
title: `Ready to build: "${s.title}"`,
|
|
62
|
+
title: going ? `Continue building: "${s.title}"` : `Ready to build: "${s.title}"`,
|
|
53
63
|
facts: [`${s.author}, ${day(s.createdAt)}`, n === 0 ? 'No open questions.' : n === 1 ? '1 open question.' : `${n} open questions.`],
|
|
54
|
-
choices: [{ value: 'build', label: BUILD }, { value: 'ask', label: ASK }, { value: 'later', label: LATER }],
|
|
64
|
+
choices: [{ value: 'build', label: going ? CONTINUE : BUILD }, { value: 'ask', label: ASK }, { value: 'later', label: LATER }],
|
|
55
65
|
});
|
|
56
66
|
if (v === 'build')
|
|
57
67
|
return { kind: 'build', spec: s };
|
|
@@ -68,7 +78,7 @@ export async function offerBuild(a) {
|
|
|
68
78
|
if (a.seat !== 'developer')
|
|
69
79
|
return false;
|
|
70
80
|
for (const s of a.specs) {
|
|
71
|
-
if (s.status !== 'ready for build')
|
|
81
|
+
if (s.status !== 'ready for build' && s.status !== 'being built')
|
|
72
82
|
continue;
|
|
73
83
|
const c = await chooseFor(a.choose, s);
|
|
74
84
|
if (c.kind === 'later')
|
|
@@ -83,6 +93,12 @@ export async function offerBuild(a) {
|
|
|
83
93
|
a.emit(`We could not start the build from specs/${s.slug}/. Run /abap-app --from @specs/${s.slug}/<file> yourself.`);
|
|
84
94
|
return false;
|
|
85
95
|
}
|
|
96
|
+
// F13: continuing a spec already being built writes no record, and it is
|
|
97
|
+
// not marked for this run, so a Stop at the gate leaves it being built.
|
|
98
|
+
if (s.status === 'being built') {
|
|
99
|
+
a.submit(`/abap-app --from ${t.token}`);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
86
102
|
const { writeStatusRecord } = await import('../projects/spec-share.js');
|
|
87
103
|
writeStatusRecord(s.specDir, {
|
|
88
104
|
projectId: a.projectId, slug: s.slug, file: path.basename(s.envelopePath),
|
package/dist/repl.js
CHANGED
|
@@ -1608,7 +1608,7 @@ export async function runRepl(opts) {
|
|
|
1608
1608
|
const g = await openQuestionsGate({
|
|
1609
1609
|
cwd: process.cwd(), command: effectiveInput, identity: getAuthorIdentity(),
|
|
1610
1610
|
emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1611
|
-
choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
1611
|
+
choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(), session,
|
|
1612
1612
|
});
|
|
1613
1613
|
if (g && 'stop' in g) {
|
|
1614
1614
|
chunkEmitter.emit('chunk', STOPPED_LINE + '\n');
|
|
@@ -3947,7 +3947,7 @@ export async function runRepl(opts) {
|
|
|
3947
3947
|
cwd: process.cwd(), command: resolvedTrimmed, identity: getAuthorIdentity(),
|
|
3948
3948
|
emit: (line) => console.log(line),
|
|
3949
3949
|
choose: gateChooser(classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x)))),
|
|
3950
|
-
answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
3950
|
+
answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(), session,
|
|
3951
3951
|
});
|
|
3952
3952
|
if (g && 'stop' in g) {
|
|
3953
3953
|
console.log(STOPPED_LINE);
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -34,7 +34,7 @@ import { teamFixOffers, cspeachWritesThisSession } from '../register/offer.js';
|
|
|
34
34
|
import { appendPending, finalizeToolCall } from '../session/pending.js';
|
|
35
35
|
import { checkTransportFit, checkTransportNeeded, reusedRequestRefusal, noteTransportNeededAsked, takeTransportNeededAsked, } from './transport-fit-check.js';
|
|
36
36
|
import { typeCode } from '../approvals/risk-floor.js';
|
|
37
|
-
import { auditLog, SapError, hasClassicParameterBlock } from '@cspeach/sap-client';
|
|
37
|
+
import { auditLog, SapError, hasClassicParameterBlock, documentWriteHint } from '@cspeach/sap-client';
|
|
38
38
|
import { ERR } from '../errors/codes.js';
|
|
39
39
|
import { interceptUpdateMethod, CancelledByUserError, } from './update-method-intercept.js';
|
|
40
40
|
// F-L2-1: statusline-only wire — records the transport a write ACTUALLY
|
|
@@ -50,13 +50,39 @@ import { snapshotChainState, writtenWithSyntaxErrors, chainActivation, journeyWo
|
|
|
50
50
|
*/
|
|
51
51
|
const FUNC_CLASSIC_BLOCK_TEXT = 'A function module\'s signature goes in the FUNCTION statement (IMPORTING / EXPORTING / CHANGING / TABLES / EXCEPTIONS), '
|
|
52
52
|
+ 'not in a *" comment block. Rewrite the header and try again.';
|
|
53
|
+
/**
|
|
54
|
+
* F15 review (2026-11-02): sap-client's document-type sentence names client
|
|
55
|
+
* methods (createDomain, maintainMessages, …); this names the CLI tool the
|
|
56
|
+
* model can actually call. Keyed by the bare upper-case type code.
|
|
57
|
+
*/
|
|
58
|
+
const DOCUMENT_TYPE_TOOL = {
|
|
59
|
+
DOMA: 'sap_create_domain',
|
|
60
|
+
DTEL: 'sap_create_data_element',
|
|
61
|
+
MSAG: 'sap_message_maintain',
|
|
62
|
+
TTYP: 'sap_create_table_type',
|
|
63
|
+
DEVC: 'sap_create_object',
|
|
64
|
+
SRVB: 'sap_create_object, then sap_service_binding_publish',
|
|
65
|
+
ENHO: 'sap_create_badi_implementation',
|
|
66
|
+
ENHS: 'no CLI tool yet (create the spot in ADT)',
|
|
67
|
+
};
|
|
53
68
|
const SET_SOURCE_SPEC = {
|
|
54
69
|
tool: 'sap_set_source',
|
|
55
70
|
// Part 2c D16 (review I-4): a function module's classic *" block is refused
|
|
56
71
|
// before the approval is spent, the snapshot is read or the WAL is opened.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
// F15 (2026-11-02): an ADT document type (DOMA, DTEL, MSAG, DEVC, SRVB, …) is
|
|
73
|
+
// refused at the same point, before the approval is spent, the snapshot is
|
|
74
|
+
// read or the WAL is opened — with sap-client's own sentence.
|
|
75
|
+
beforeApproval: (a) => {
|
|
76
|
+
if (typeCode(a.type) === 'FUNC' && hasClassicParameterBlock(String(a.source ?? ''))) {
|
|
77
|
+
return { error: 'classic_parameter_block', detail: FUNC_CLASSIC_BLOCK_TEXT };
|
|
78
|
+
}
|
|
79
|
+
const hint = documentWriteHint(a.type);
|
|
80
|
+
if (hint !== undefined) {
|
|
81
|
+
const useTool = DOCUMENT_TYPE_TOOL[typeCode(a.type)] ?? 'the tool for its kind';
|
|
82
|
+
return { error: 'not_a_source_object', detail: `${hint} CLI tool: ${useTool}.`, use_tool: useTool };
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
},
|
|
60
86
|
// Rule 7a in code (2026-10-22): on an existing class, a rewrite whose only
|
|
61
87
|
// change is inside method bodies is refused — sap_update_method per method.
|
|
62
88
|
methodOnlyGuard: (a) => ({ type: a.type, name: a.name, source: String(a.source ?? '') }),
|
|
@@ -123,7 +149,9 @@ registerTool({
|
|
|
123
149
|
+ 'Use for class-definition changes, new methods, or any change not confined to '
|
|
124
150
|
+ 'a single method body. For method-body-only edits use sap_update_method (Rule 7a). '
|
|
125
151
|
+ 'The transport auto-resolves to the owning request if the object is already locked. '
|
|
126
|
-
+ 'Types FUNC (function module), FINC (function-group include, e.g. L<GROUP>TOP)
|
|
152
|
+
+ 'Types FUNC (function module), FINC (function-group include, e.g. L<GROUP>TOP) and INCL (program include) are supported; give the bare name — CSPeach finds the group. '
|
|
153
|
+
+ 'For a number range object (NROB) the source is SAP\'s nrob-v1 JSON document (formatVersion "1"; header.description, header.originalLanguage; interval.numberLengthDomain, percentWarning, subType, untilYear, rolling, prefix; configuration.buffering mainBuffer|parallel|none, bufferedNumbers), sent as application/json. '
|
|
154
|
+
+ 'Domains, data elements, message classes, packages, bindings and table types have no source text: use their own tools.',
|
|
127
155
|
isMutating: true,
|
|
128
156
|
requiresSap: true,
|
|
129
157
|
input_schema: {
|
|
@@ -1379,14 +1407,15 @@ registerTool({
|
|
|
1379
1407
|
});
|
|
1380
1408
|
// ── sap_number_range_intervals (STUB) ─────────────────────────────────────────
|
|
1381
1409
|
//
|
|
1382
|
-
// STUB —
|
|
1383
|
-
//
|
|
1410
|
+
// STUB — number range intervals (SNRO) are not an ADT resource: discovery lists
|
|
1411
|
+
// no intervals collection (probe docs/research/2026-11-02-nrob-adt-probe.md).
|
|
1384
1412
|
registerTool({
|
|
1385
1413
|
name: 'sap_number_range_intervals',
|
|
1386
1414
|
requires: 'nrob',
|
|
1387
|
-
description: '
|
|
1388
|
-
+ '
|
|
1389
|
-
+ '
|
|
1415
|
+
description: 'Not available: number range intervals (SNRO) are not an ADT resource on the systems we run against — '
|
|
1416
|
+
+ 'the discovery document lists no intervals collection. '
|
|
1417
|
+
+ 'Create the number range object with sap_create_object and sap_set_source (nrob-v1 JSON); '
|
|
1418
|
+
+ 'the developer maintains its intervals in SNRO.',
|
|
1390
1419
|
isMutating: true,
|
|
1391
1420
|
requiresSap: true,
|
|
1392
1421
|
input_schema: {
|
|
@@ -1406,7 +1435,7 @@ registerTool({
|
|
|
1406
1435
|
return {
|
|
1407
1436
|
content: JSON.stringify({
|
|
1408
1437
|
error: 'not_implemented',
|
|
1409
|
-
detail: '
|
|
1438
|
+
detail: 'number range intervals are maintained in SNRO; ADT exposes the object, not its intervals',
|
|
1410
1439
|
}),
|
|
1411
1440
|
is_error: true,
|
|
1412
1441
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
CSPeach live check: system S4H, package ZTEST_LAS, run 2BUS
|
|
2
|
+
Transport S4HK903771 (given with --transport)
|
|
3
|
+
|
|
4
|
+
PASS CLI: sap_run and sap_transport_read (pipeline; D3, D18 refusals, one-shot run approval, dump window, spill; never a release) 90.6 s
|
|
5
|
+
PASS temp home 0.0 s alias S4H copied; write_mode approval-gated; safety cards off
|
|
6
|
+
covers: approval id + claims, Rule 9 transport, Rule 7 snapshot, WAL, Rule 10 verify, chain; not: Rule 8 (dispatchTool), the cards
|
|
7
|
+
PASS card input 0.0 s risk high (code_run); this runs code on the SAP system
|
|
8
|
+
PASS sap_create_object + sap_set_source CLAS 7.1 s ZCL_ZCSPLC2BUS_CR created and written; activated by the write and verified
|
|
9
|
+
info: object locked in S4HK903771 — writing under owning transport
|
|
10
|
+
info: snapshot ✓ CLAS ZCL_ZCSPLC2BUS_CR 2026-10-17T17-42-31-537Z
|
|
11
|
+
① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activate pending
|
|
12
|
+
audit: 2026-10-17T17:42:34.691Z | setSource | CLAS | ZCL_ZCSPLC2BUS_CR | success | 3146ms
|
|
13
|
+
audit: 2026-10-17T17:42:34.976Z | setSource | CLAS | ZCL_ZCSPLC2BUS_CR | success | 4539ms
|
|
14
|
+
PASS sap_run standard class refused 0.0 s run_refused before any request: CSPeach runs only your own classes and programs (names starting with Z or Y). CL_ABAP_UNIT_ASSERT is not one of them.
|
|
15
|
+
PASS sap_lock 0.3 s ZCL_ZCSPLC2BUS_CR locked through sap_lock (a manual lock is held)
|
|
16
|
+
PASS sap_run under a manual lock 0.0 s manual_lock_held before any request: A lock you took with sap_lock is still held. Unlock it (sap_unlock) first, then ask for the run again.
|
|
17
|
+
PASS sap_unlock 0.7 s ZCL_ZCSPLC2BUS_CR unlocked; no manual lock held
|
|
18
|
+
PASS sap_run CLAS 0.3 s the same approval id ran it once: {"ran":true,"type":"CLAS","name":"ZCL_ZCSPLC2BUS_CR","lines":9,"chars":107,"elapsed_ms":287,"session_ended":false}
|
|
19
|
+
info: run CLAS ZCL_ZCSPLC2BUS_CR · ran · 287 ms
|
|
20
|
+
PASS sap_run again with the same id 0.0 s APPROVAL_INVALID nonce_spent; no request
|
|
21
|
+
PASS sap_run PROG 4.2 s ran: {"ran":true,"type":"PROG","name":"ZCSPLC2BUS_CRP","lines":6,"chars":255,"elapsed_ms":291,"session_ended":false}
|
|
22
|
+
PASS sap_run CLAS dump 4.7 s run_dumped: "Division by zero", dump_window 20261018034313–20261018034713; next_step: sap_short_dump with fromDate and toDate = dump_window (SAP system time), then operation detail on the newest du...
|
|
23
|
+
PASS sap_short_dump in the dump window 1.5 s listed: ZCL_ZCSPLC2BUS_CRX============CP (20261018034513vhcals4hci_S4H_00%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20S.LAEEQ%20%20%20%20%20100%20%20%20%20%20%20%2012); 1 dump(s) in the window
|
|
24
|
+
PASS sap_run CLAS long output 17.9 s the content carries the pointer line; the spill file holds 20001 lines (the facts line and 20 000 output lines); {"ran":true,"type":"CLAS","name":"ZCL_ZCSPLC2BUS_CRB","lines":20001,"chars":1328895,...
|
|
25
|
+
PASS sap_transport_read request 12.9 s S4HK903771 status D, 1 task(s), object_count 691, can_release true
|
|
26
|
+
PASS sap_transport_read task 7.2 s S4HK903772: kind task of S4HK903771; note: S4HK903772 is a task of S4HK903771.
|
|
27
|
+
PASS release card input (never used) 13.2 s risk high (transport_release); no approval minted, never sent
|
|
28
|
+
Request S4HK903771 "CSPeach live check 2026-10-14" · owner S.LAEEQ · target CL5
|
|
29
|
+
1 task: S4HK903772 · 691 objects
|
|
30
|
+
Cannot be undone. Once released, the request can move to the other systems on the transport route.
|
|
31
|
+
CSPeach reads SAP's verdict after each step and checks the request afterwards. Check the transport in SE09 after the release.
|
|
32
|
+
PASS sap_delete_object CLAS ZCL_ZCSPLC2BUS_CRB 5.7 s deleted; a search finds nothing
|
|
33
|
+
PASS sap_delete_object CLAS ZCL_ZCSPLC2BUS_CRX 5.5 s deleted; a search finds nothing
|
|
34
|
+
PASS sap_delete_object PROG ZCSPLC2BUS_CRP 3.7 s deleted; a search finds nothing
|
|
35
|
+
PASS sap_delete_object CLAS ZCL_ZCSPLC2BUS_CR 5.6 s deleted; a search finds nothing
|
|
36
|
+
PASS Cleanup 0.0 s
|
|
37
|
+
|
|
38
|
+
2 passed, 0 failed
|
|
39
|
+
Transport: S4HK903771
|
|
40
|
+
The transport is NOT released. Delete or release it in SE09.
|
|
41
|
+
Leftover objects: none.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.57",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
55
55
|
"@azure/msal-node": "6.0.1",
|
|
56
56
|
"@cspeach/register-core": "0.4.2",
|
|
57
|
-
"@cspeach/sap-client": "0.1.
|
|
57
|
+
"@cspeach/sap-client": "0.1.32",
|
|
58
58
|
"@iarna/toml": "^2.2.5",
|
|
59
59
|
"@inkjs/ui": "^2.0.0",
|
|
60
60
|
"@inquirer/prompts": "^7.0.0",
|