@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.
@@ -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, composeSpec, readAnswers, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
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: (path) => composeSpec(readProjectFile(path), readAnswers(dirname(path)).filter((a) => a.file === basename(path))),
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
- const sourceToken = `@${basename(fromPath)}`;
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
  }
@@ -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
- const ready = all.filter((s) => s.status === 'ready for build');
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
- env = readProjectFile(abs);
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 = readProjectFile(resolvedPath);
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
- lines.push('To build a ready spec: hand_off with skill "abap-app" and the spec\'s title in the request. CSPeach attaches the spec and shows its open questions first.');
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 = (p) => share.composeSpec(readProjectFile(p), share.readAnswers(path.dirname(p)).filter((x) => x.file === path.basename(p)));
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) => { markedThisRun.delete(dirKey(specDir)); markBeingBuilt(); return 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 and open
6
- * questions, and one line for each draft or spec being built. A developer is
7
- * then offered each ready spec: build it, ask about it first, or later. A
8
- * functional seat sees the same lines and is never offered a build.
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
- out.push(`Ready to build: ${who}`, ` ${s.summary}`);
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(`${s.status === 'draft' ? 'Draft' : 'Being built'}: ${who}`);
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);
@@ -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
- beforeApproval: (a) => (typeCode(a.type) === 'FUNC' && hasClassicParameterBlock(String(a.source ?? ''))
58
- ? { error: 'classic_parameter_block', detail: FUNC_CLASSIC_BLOCK_TEXT }
59
- : undefined),
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), INCL (program include) and TTYP (table type) are supported; give the bare name — CSPeach finds the group.',
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 — NROB interval write requires NR-object XML format not yet implemented
1383
- // in @cspeach/sap-client.
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: 'STUB — not currently implemented; do not call. '
1388
- + 'Writing NROB number-range intervals via ADT requires NR-object XML format '
1389
- + 'not yet implemented in @cspeach/sap-client.',
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: 'NROB interval write via ADT requires NR-object XML format not yet implemented in @cspeach/sap-client',
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.55",
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.31",
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",