@cspeach/cli 1.1.23 → 1.1.25
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 +124 -0
- package/dist/classifier/client.js +27 -2
- package/dist/commands/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/one-shot.js +1 -0
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-schema.js +10 -0
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/register/records.js +7 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +23 -2
- package/dist/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +53 -14
- package/dist/tools/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +4 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/tool-loading.js +2 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -37,6 +37,10 @@ import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
|
37
37
|
import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
|
|
38
38
|
import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
39
39
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
40
|
+
import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
41
|
+
import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
|
|
42
|
+
import { afterPlanSave, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
|
|
43
|
+
import { shownToken } from '../lane/card.js';
|
|
40
44
|
import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
|
|
41
45
|
import { input } from '@inquirer/prompts';
|
|
42
46
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
@@ -312,6 +316,10 @@ export async function maybeOfferSave(p) {
|
|
|
312
316
|
confirm: isHeadless() ? undefined : inkAwareConfirm,
|
|
313
317
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
314
318
|
promotedFrom: p.promotedFrom ?? null,
|
|
319
|
+
// Task 7 review round 2 — the app-flow lane only for the turn routed to
|
|
320
|
+
// the skill it saves (the /abap-plan turn that got the lane block); a plan
|
|
321
|
+
// found in another skill's output (detectArtifactSkill) never gets it.
|
|
322
|
+
...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
|
|
315
323
|
});
|
|
316
324
|
// After a spec-gap file is saved, let a developer answer the open BLOCKER
|
|
317
325
|
// questions inline (recorded as `answered` items in the SAME file) — the
|
|
@@ -328,6 +336,18 @@ export async function maybeOfferSave(p) {
|
|
|
328
336
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
329
337
|
});
|
|
330
338
|
}
|
|
339
|
+
// App lane routing (Task 7) — the harness-owned /abap-app chain. Built
|
|
340
|
+
// from the exact saved path, never from model text. Outside an app flow
|
|
341
|
+
// (spec-gap) or without from.lane (plan) these do nothing. Review round 1:
|
|
342
|
+
// only when the turn was routed to the skill it saved — an artefact found
|
|
343
|
+
// in another skill's output (detectArtifactSkill) never moves the chain.
|
|
344
|
+
if (savedPath && p.appChain && p.skill === saveSkill) {
|
|
345
|
+
const hooks = { ...p.appChain, headless: isHeadless(), emit: p.emit };
|
|
346
|
+
if (saveSkill === 'abap-spec-gap')
|
|
347
|
+
await afterSpecGapSave(hooks, savedPath);
|
|
348
|
+
else if (saveSkill === 'abap-plan')
|
|
349
|
+
await afterPlanSave(hooks, savedPath);
|
|
350
|
+
}
|
|
331
351
|
}
|
|
332
352
|
catch (err) {
|
|
333
353
|
p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
|
|
@@ -444,6 +464,64 @@ export async function runTurn(params) {
|
|
|
444
464
|
// prepend a "Promoted from..." block to the LLM input, and stash the
|
|
445
465
|
// snapshot for the post-turn save hook so the saved envelope's
|
|
446
466
|
// promotedFrom field is populated.
|
|
467
|
+
// App lane routing (Task 7) — stage 1 of the /abap-app chain. `/abap-app
|
|
468
|
+
// @<spec>` (no --from) starts the session's app flow with the cleaned,
|
|
469
|
+
// workspace-relative spec path and runs as abap-spec-gap, so the spec-gap
|
|
470
|
+
// skill body does the work. A spec that is missing or outside the workspace
|
|
471
|
+
// is refused with one line: no flow (a stale one is dropped), and the turn
|
|
472
|
+
// still runs as spec-gap so the skill can say what is wrong.
|
|
473
|
+
// I1 (final review) — only an explicit slash command, or a fresh classifier
|
|
474
|
+
// pick (the session was not already in abap-app), or any turn with no flow
|
|
475
|
+
// at the lane step (final residual 3), starts stage 1. A plain reply at the
|
|
476
|
+
// lane step ("ok", "use RAP") falls through to the gate and stays in the flow.
|
|
477
|
+
if (!readerChild && !params.ctx.subagent && params.skill === 'abap-app' && !parseFromFlag(params.userMessage).fromPath
|
|
478
|
+
&& (params.explicitSlash === true || session.skill !== 'abap-app' || getAppFlow(session)?.expect !== 'lane')) {
|
|
479
|
+
// I5 — the lane step needs app_lane_resolve (flag-gated: local_build or
|
|
480
|
+
// standalone). Check the same visible list the turn would send, and
|
|
481
|
+
// refuse before any model call rather than failing at stage 2.
|
|
482
|
+
if (!toolsForContext(listTools(), params.ctx).some((t) => t.name === 'app_lane_resolve')) {
|
|
483
|
+
clearAppFlow(session);
|
|
484
|
+
const raw = extractSpecToken(params.userMessage);
|
|
485
|
+
const tok = raw === undefined ? '' : cleanSpecToken(raw);
|
|
486
|
+
emit(chalk.yellow('/abap-app needs local build tools.'));
|
|
487
|
+
emit(chalk.yellow('Run: cspeach config set local_build on'));
|
|
488
|
+
emit(chalk.yellow(`Then restart cspeach and run /abap-app ${shownToken(tok || undefined, 'spec')} again.`));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const spec = await resolveAppSpec(params.ctx.cwd, params.userMessage);
|
|
492
|
+
if (spec.ok) {
|
|
493
|
+
startAppFlow(session, spec.rel);
|
|
494
|
+
emit(STEP_1);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
clearAppFlow(session);
|
|
498
|
+
emit(chalk.yellow(spec.line));
|
|
499
|
+
}
|
|
500
|
+
params = { ...params, skill: 'abap-spec-gap' };
|
|
501
|
+
}
|
|
502
|
+
else if (!readerChild && !params.ctx.subagent) {
|
|
503
|
+
// R11 (review round 1) — any turn that is not part of the flow's current
|
|
504
|
+
// step ends the flow, so a stale flow never changes a later run.
|
|
505
|
+
const gate = await gateAppFlowForTurn(session, {
|
|
506
|
+
skill: params.skill,
|
|
507
|
+
userMessage: params.userMessage,
|
|
508
|
+
explicitSlash: params.explicitSlash === true,
|
|
509
|
+
previousSkill: session.skill,
|
|
510
|
+
cwd: params.ctx.cwd,
|
|
511
|
+
});
|
|
512
|
+
// I4(a) — the flow ends here: say once, plainly, that this plan will not
|
|
513
|
+
// use the build lane. Interactive only (headless / one-shot keep no flow).
|
|
514
|
+
if (gate.ended && gate.hadState) {
|
|
515
|
+
const { isHeadless } = await import('../renderer/tty.js');
|
|
516
|
+
if (!isHeadless())
|
|
517
|
+
emit(chalk.yellow(FLOW_ENDED_LINE));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Stage 3 — the /abap-plan --from turn of an app flow at its plan step.
|
|
521
|
+
if (!readerChild && !params.ctx.subagent && params.skill === 'abap-plan' && params.suppressSaveHook !== true
|
|
522
|
+
&& parseFromFlag(params.userMessage).fromPath && getAppFlow(session)?.expect === 'plan') {
|
|
523
|
+
emit(STEP_3);
|
|
524
|
+
}
|
|
447
525
|
let promotedFromForSave = null;
|
|
448
526
|
let userMessageForLLM = params.userMessage;
|
|
449
527
|
let userMessageForSave = params.userMessage;
|
|
@@ -463,12 +541,23 @@ export async function runTurn(params) {
|
|
|
463
541
|
}
|
|
464
542
|
const { fromPath, rest } = parseFromFlag(docAttach ? '' : params.userMessage);
|
|
465
543
|
if (fromPath) {
|
|
544
|
+
// M4 — inside an /abap-app flow the open-items question is asked once:
|
|
545
|
+
// a yes at stage 2 (lane step) is recorded on the flow, and stage 3
|
|
546
|
+
// (plan step) skips it when it promotes the same real file.
|
|
547
|
+
const flowNow = !params.ctx.subagent ? getAppFlow(session) : undefined;
|
|
548
|
+
const confirmedGap = flowNow?.gapConfirmed;
|
|
466
549
|
const result = await runPromoteCommand({
|
|
467
550
|
sourcePath: fromPath,
|
|
468
551
|
targetSkill: params.skill,
|
|
469
552
|
prompt: inkAwarePrompt,
|
|
470
553
|
confirm: inkAwareConfirm,
|
|
471
554
|
log: (...lines) => lines.forEach((l) => emit(l)),
|
|
555
|
+
// Final residual 4 — the same folder gateAppFlowForTurn resolves @X against.
|
|
556
|
+
cwd: params.ctx.cwd,
|
|
557
|
+
...(flowNow?.expect === 'lane' && params.skill === 'abap-app'
|
|
558
|
+
? { onOpenWarningsConfirmed: (p) => setGapConfirmed(session, p) } : {}),
|
|
559
|
+
...(flowNow?.expect === 'plan' && params.skill === 'abap-plan' && confirmedGap
|
|
560
|
+
? { skipOpenWarnings: (p) => sameRealFile(p, confirmedGap) } : {}),
|
|
472
561
|
});
|
|
473
562
|
if (!result) {
|
|
474
563
|
// User declined, source invalid, or unsupported target — skip the turn.
|
|
@@ -519,6 +608,27 @@ ${imageExpansion.error}`));
|
|
|
519
608
|
if (!readerChild) {
|
|
520
609
|
userMessageForLLM = await expandTextFileAttachments(userMessageForLLM, params.ctx.cwd, (line) => emit(chalk.dim(line)));
|
|
521
610
|
}
|
|
611
|
+
// App lane routing (Task 6) — the /abap-plan creation turn inside an app
|
|
612
|
+
// flow gets the confirmed <lane_decision> block (read with the app-flow sha;
|
|
613
|
+
// a changed file is refused with a plain line). '' on every other turn, so
|
|
614
|
+
// the model input is unchanged. Resume turns add their own block.
|
|
615
|
+
if (!readerChild) {
|
|
616
|
+
const laneBlock = await planLaneBlockForTurn({
|
|
617
|
+
skill: params.skill,
|
|
618
|
+
session,
|
|
619
|
+
cwd: params.ctx.cwd,
|
|
620
|
+
emit: (line) => emit(chalk.yellow(line)),
|
|
621
|
+
resumeTurn: params.suppressSaveHook === true,
|
|
622
|
+
});
|
|
623
|
+
// M5 — the lane cannot be used: the refusal line is out; end the turn
|
|
624
|
+
// now (no model call) and end the flow, which cannot reach a saved plan.
|
|
625
|
+
if (laneBlock === null) {
|
|
626
|
+
clearAppFlow(session);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (laneBlock)
|
|
630
|
+
userMessageForLLM = `${userMessageForLLM}\n\n${laneBlock}`;
|
|
631
|
+
}
|
|
522
632
|
// 2026-05-06: alongside the deterministic prompt-fact extraction
|
|
523
633
|
// (package / transport / etc.), inject the connected SAP system's
|
|
524
634
|
// release + platform + ABAP version. One CVERS query the first time
|
|
@@ -1454,6 +1564,18 @@ ${imageExpansion.error}`));
|
|
|
1454
1564
|
model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
|
|
1455
1565
|
emit,
|
|
1456
1566
|
promotedFrom: promotedFromForSave,
|
|
1567
|
+
...(() => {
|
|
1568
|
+
const flowLane = getAppFlow(session)?.lane;
|
|
1569
|
+
return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
|
|
1570
|
+
})(),
|
|
1571
|
+
// Task 7 — subagent / reader turns never drive the app chain.
|
|
1572
|
+
...(!readerChild && !params.ctx.subagent ? {
|
|
1573
|
+
appChain: {
|
|
1574
|
+
session,
|
|
1575
|
+
cwd: params.ctx.cwd,
|
|
1576
|
+
...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
|
|
1577
|
+
},
|
|
1578
|
+
} : {}),
|
|
1457
1579
|
});
|
|
1458
1580
|
}
|
|
1459
1581
|
}
|
|
@@ -1691,6 +1813,8 @@ ${imageExpansion.error}`));
|
|
|
1691
1813
|
})();
|
|
1692
1814
|
const isInteractiveTool = block.name === 'ask_question' ||
|
|
1693
1815
|
block.name === 'request_approval' ||
|
|
1816
|
+
// App lane routing (Task 5): shows the lane card from inside the handler.
|
|
1817
|
+
block.name === 'app_lane_resolve' ||
|
|
1694
1818
|
willTripBatchGate;
|
|
1695
1819
|
// Interactive tools block on the USER. PAUSE the turn-status row (don't
|
|
1696
1820
|
// just relabel it): a live 250ms tick repaints the dynamic frame and
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
const RULES = [
|
|
2
|
+
// abap-app (app lane routing slice 1, 2026-10-01) — building an app FROM a
|
|
3
|
+
// spec / requirements document. FIRST so a spec-document build beats every
|
|
4
|
+
// stack-specific rule below (fiori-build, rap, cap, spec-gap, generate). A
|
|
5
|
+
// direct request without a spec document never matches and keeps its skill.
|
|
6
|
+
// Review round 1 (I2): a whole-prompt guard keeps "spec gap(s)" prompts on
|
|
7
|
+
// abap-spec-gap (whitespace only — a hyphenated spec-gap-….json file name
|
|
8
|
+
// does not trip it).
|
|
9
|
+
{ skill: 'abap-app', patterns: [
|
|
10
|
+
/^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?\b(build|create|make|develop)\b[^.?!\n]{0,60}\b(app|application)\b[^.?!\n]{0,60}(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)/i,
|
|
11
|
+
/^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)[^.?!\n]{0,60}\b(build|create|make|develop)\b[^.?!\n]{0,30}\b(app|application)\b/i,
|
|
12
|
+
] },
|
|
2
13
|
{ skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix/i] },
|
|
3
14
|
{ skill: 'abap-cca', patterns: [/custom\s+code\s+analysis|\bcca\b|clean\s+core\s+(assessment|analysis)/i] },
|
|
4
15
|
{ skill: 'abap-cloud-check', patterns: [/cloud\s+ready|clean\s+core(?!\s+(assessment|analysis))|s\/4hana\s+cloud\s+check/i] },
|
|
16
|
+
// abap-cap (2026-10-01) MUST precede abap-data-model ("CDS domain model"
|
|
17
|
+
// would hit its bare `domain`) and abap-fiori-build / abap-rap (a CAP app
|
|
18
|
+
// ships a Fiori Elements UI). Side-by-side / BTP framing → CAP.
|
|
19
|
+
// Review round 1 (I1): narrowed so no prompt that routed before abap-cap
|
|
20
|
+
// existed changes skill — "a cap on the quantity", "RAP/Fiori on BTP",
|
|
21
|
+
// "BTP destination in ZCL_X", "the CDS model ZI_X", "side by side" diffs.
|
|
22
|
+
// A bare BTP mention counts only when no RAP/ABAP/Fiori/UI5/Z-object word
|
|
23
|
+
// appears anywhere in the prompt.
|
|
24
|
+
{ skill: 'abap-cap', patterns: [/\b(sap\s+)?cap\b(?=\s+(app|application|project|service|model|backend|extension|handler)s?\b)|\b(in|with|using|use)\s+(sap\s+)?cap\b|cloud\s+application\s+programming|\bcds\s+(model|service)\b(?!\s+(definition|[zy]\w*))|side.?by.?side\s+(app|application|extension)|^(?![\s\S]*\b(rap|abap|fiori|ui5|[zy]\w*_\w+)\b)[\s\S]*\bbtp\b/i] },
|
|
5
25
|
{ skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)/i] },
|
|
6
26
|
{ skill: 'abap-design', patterns: [/solution\s+architecture|object\s+list|design\s+the\s+solution/i] },
|
|
7
27
|
{ skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover/i] },
|
|
@@ -61,12 +81,17 @@ const RULES = [
|
|
|
61
81
|
{ skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
|
|
62
82
|
{ skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
|
|
63
83
|
];
|
|
64
|
-
|
|
84
|
+
/** The skill each offline rule routes to, in rule order (one rule per skill). */
|
|
85
|
+
export const RULE_SKILLS = Object.freeze(RULES.map((r) => r.skill));
|
|
86
|
+
// 2026-10-01: 33 + abap-app + abap-cap = 35. The four merged-away donors
|
|
87
|
+
// (abap-cloud-check, abap-document, abap-performance, abap-radar) keep their
|
|
88
|
+
// rules: their .claude/skills folders are still on disk and still resolve.
|
|
89
|
+
if (RULES.length !== 35) {
|
|
65
90
|
// Warn-not-throw — managed-mode users never exercise classify() (the proxy
|
|
66
91
|
// does server-side classification), so a module-load crash would break
|
|
67
92
|
// them for a local-only consistency check. Unit tests assert the exact
|
|
68
93
|
// count.
|
|
69
|
-
console.warn(`[classifier] RULES drift: expected
|
|
94
|
+
console.warn(`[classifier] RULES drift: expected 35, got ${RULES.length} — reconcile with .claude/skills/`);
|
|
70
95
|
}
|
|
71
96
|
export function classify(prompt) {
|
|
72
97
|
for (const rule of RULES) {
|
|
@@ -16,6 +16,7 @@ import { findNewestVersion } from './plan-resume.js';
|
|
|
16
16
|
import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
|
|
17
17
|
import { finishPlanResume } from './plan-resume.js';
|
|
18
18
|
import { runPostPhaseChain } from './plan-chain.js';
|
|
19
|
+
import { loadPlanLaneForRun } from '../projects/plan-lane.js';
|
|
19
20
|
/**
|
|
20
21
|
* The same block shape loop.ts's manifest-on-interrupt hook scans for.
|
|
21
22
|
* E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
|
|
@@ -76,7 +77,7 @@ export function classifyContinuationText(text, envelopeTitle) {
|
|
|
76
77
|
* never leaves a fresh lock behind. Every null return means "run a normal
|
|
77
78
|
* turn"; `stale` additionally tells the caller to clear the marker.
|
|
78
79
|
*/
|
|
79
|
-
export function preparePlanContinuation(args) {
|
|
80
|
+
export async function preparePlanContinuation(args) {
|
|
80
81
|
const { marker } = args;
|
|
81
82
|
let path = marker.path;
|
|
82
83
|
if (!existsSync(path))
|
|
@@ -99,6 +100,14 @@ export function preparePlanContinuation(args) {
|
|
|
99
100
|
const phase = parsed.content.phases.find((p) => p.id === marker.phaseId);
|
|
100
101
|
if (!phase)
|
|
101
102
|
return { prepared: null, stale: true };
|
|
103
|
+
// App lane routing — same gate as preparePlanResume: pinned read, record
|
|
104
|
+
// re-validation, stored phases checked against the lane. Before the lease,
|
|
105
|
+
// so a paused plan never leaves a lock behind. Lane-less plans pass.
|
|
106
|
+
const laneGate = await loadPlanLaneForRun(args.cwd ?? process.cwd(), parsed.content);
|
|
107
|
+
if (!laneGate.ok) {
|
|
108
|
+
args.log(...laneGate.lines);
|
|
109
|
+
return { prepared: null, stale: false, paused: true };
|
|
110
|
+
}
|
|
102
111
|
// Lease LAST: a stale marker (missing file / unknown phase) must never leave
|
|
103
112
|
// a fresh lock behind. A LIVE other-session holder is contention, NOT stale —
|
|
104
113
|
// the marker stays so the user can finish there or take over via --resume.
|
|
@@ -122,6 +131,7 @@ export function preparePlanContinuation(args) {
|
|
|
122
131
|
...(marker.effectiveMode ? { effectiveMode: marker.effectiveMode } : {}),
|
|
123
132
|
...(marker.auditFlag ? { auditFlag: marker.auditFlag } : {}),
|
|
124
133
|
...(marker.auditMode ? { auditMode: marker.auditMode } : {}),
|
|
134
|
+
...(laneGate.rec ? { laneRecord: laneGate.rec } : {}),
|
|
125
135
|
};
|
|
126
136
|
return { prepared };
|
|
127
137
|
}
|
|
@@ -54,6 +54,7 @@ import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
|
|
|
54
54
|
import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
|
|
55
55
|
import { askYesNo, isYes } from '../projects/yes-no.js';
|
|
56
56
|
import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
57
|
+
import { checkLaneConsistency, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
|
|
57
58
|
/**
|
|
58
59
|
* Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
|
|
59
60
|
* audit off-switch (`--audit` / `--no-audit`) flags from a plan-resume body.
|
|
@@ -227,6 +228,19 @@ export async function preparePlanResume(args) {
|
|
|
227
228
|
return null;
|
|
228
229
|
}
|
|
229
230
|
let content = pc.content;
|
|
231
|
+
// App lane routing (Task 6, Review Focus 3) — a plan made for a lane reads
|
|
232
|
+
// the decision file with the sha it recorded, BEFORE anything else happens
|
|
233
|
+
// (no transport adoption, no lease, no phase). A changed or missing file
|
|
234
|
+
// pauses the plan; the edited lane is never used. Plans without from.lane
|
|
235
|
+
// skip this entirely.
|
|
236
|
+
// R5 fix round: the stored phases must also fit the lane, checked here
|
|
237
|
+
// before any phase, lease or transport work.
|
|
238
|
+
const laneGate = await loadPlanLaneForRun(args.cwd, content);
|
|
239
|
+
if (!laneGate.ok) {
|
|
240
|
+
args.log(...laneGate.lines);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const laneRecord = laneGate.rec;
|
|
230
244
|
// Transport display on resume (2026-07-09) — adopt the plan's dedicated
|
|
231
245
|
// transport into the session so the `tr <TRKORR>` status segment shows it.
|
|
232
246
|
// A fresh process resuming mid-plan runs no sap_transport_create (the
|
|
@@ -400,6 +414,7 @@ export async function preparePlanResume(args) {
|
|
|
400
414
|
auditMode: effectiveAudit,
|
|
401
415
|
reauditPhaseId,
|
|
402
416
|
reauditPhaseIds,
|
|
417
|
+
...(laneRecord ? { laneRecord } : {}),
|
|
403
418
|
};
|
|
404
419
|
}
|
|
405
420
|
if (!next) {
|
|
@@ -449,7 +464,13 @@ export async function preparePlanResume(args) {
|
|
|
449
464
|
ruleBlocks.push(`<rule file="${rel}" missing="true"/> <!-- not found locally — apply built-in Forge defaults -->`);
|
|
450
465
|
}
|
|
451
466
|
}
|
|
452
|
-
|
|
467
|
+
// App lane routing (Task 6): the lane ref is CLI-owned — the model sees the
|
|
468
|
+
// <lane_decision> block below, never the decision file path. A lane-less
|
|
469
|
+
// plan passes `content` itself (byte-identical plan_state).
|
|
470
|
+
const stateContent = content.from.lane
|
|
471
|
+
? { ...content, from: (({ lane: _lane, ...rest }) => rest)(content.from) }
|
|
472
|
+
: content;
|
|
473
|
+
const planState = JSON.stringify({ title: envelope.title, version: envelope.version, statuses, content: stateContent }, null, 2);
|
|
453
474
|
// A2 (defects D23/D30) — compact write-back: the model re-emitting the
|
|
454
475
|
// full plan content every phase cost ~6–10k output tokens/phase at Opus
|
|
455
476
|
// pricing for data the envelope already holds. The resume turn emits
|
|
@@ -502,6 +523,9 @@ export async function preparePlanResume(args) {
|
|
|
502
523
|
...ruleBlocks,
|
|
503
524
|
'</phase_rules>',
|
|
504
525
|
'',
|
|
526
|
+
// App lane routing (Task 6): the lane decision, so design and build phases
|
|
527
|
+
// stay in the lane. Absent ⇒ nothing added (byte-identical prompt).
|
|
528
|
+
...(laneRecord ? [renderLaneBlock(laneRecord), ''] : []),
|
|
505
529
|
// A1 (2026-06-10, defect D23): the old contract let the model ask a
|
|
506
530
|
// continuation question and keep executing phases in-turn — a 6-phase
|
|
507
531
|
// run snowballed to a 3.24M-token context because the turn never
|
|
@@ -531,6 +555,7 @@ export async function preparePlanResume(args) {
|
|
|
531
555
|
nextPhaseWrites: phaseWrites(next),
|
|
532
556
|
llmPrompt, mode: flags.mode, effectiveMode,
|
|
533
557
|
auditFlag: flags.audit, auditMode: effectiveAudit,
|
|
558
|
+
...(laneRecord ? { laneRecord } : {}),
|
|
534
559
|
};
|
|
535
560
|
}
|
|
536
561
|
/**
|
|
@@ -729,6 +754,18 @@ export async function finishPlanResume(args) {
|
|
|
729
754
|
args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
|
|
730
755
|
return null;
|
|
731
756
|
}
|
|
757
|
+
// App lane routing (Task 6, Review Focus 4) — the write-back may restructure
|
|
758
|
+
// the plan (full shape) or overlay a phase; it must still fit the lane.
|
|
759
|
+
if (args.prepared.laneRecord) {
|
|
760
|
+
const conflict = checkLaneConsistency(extract.content, args.prepared.laneRecord);
|
|
761
|
+
if (conflict) {
|
|
762
|
+
args.log('');
|
|
763
|
+
args.log(`[plan] PHASE RESULT NOT PERSISTED — ${conflict}`);
|
|
764
|
+
args.log('[plan] The plan envelope is unchanged. Check what the phase actually built, then');
|
|
765
|
+
args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
732
769
|
const nowIso = new Date().toISOString();
|
|
733
770
|
const statuses = extract.statuses;
|
|
734
771
|
const executedId = args.prepared.nextPhaseId;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// App lane routing slice 1, Task 7 — the harness-owned /abap-app chain.
|
|
2
|
+
//
|
|
3
|
+
// stage 1 /abap-app @<spec> → runs as abap-spec-gap
|
|
4
|
+
// stage 2 /abap-app --from @<specgap> → lane card (app_lane_resolve, Task 5)
|
|
5
|
+
// stage 3 /abap-plan --from @<specgap> → plan with the lane block (Task 6)
|
|
6
|
+
// start Yes → /abap-plan --resume @<saved plan> --guarded
|
|
7
|
+
//
|
|
8
|
+
// Every command queued here is built by CLI code from a path the CLI itself
|
|
9
|
+
// produced (the saved spec-gap / plan envelope). Model text never reaches a
|
|
10
|
+
// queued command. Headless (M2, what is true): no card is ever shown and no
|
|
11
|
+
// build starts — the start command is printed, never queued. The lane tool
|
|
12
|
+
// (app_lane_resolve) still queues the plan step (/abap-plan --from …) when
|
|
13
|
+
// the rules decide the lane; after a headless spec-gap save the CLI says to
|
|
14
|
+
// run /abap-app in an interactive session (no flow survives the process).
|
|
15
|
+
import { promises as fs, statSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { readProjectFile } from '../projects/status.js';
|
|
18
|
+
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
19
|
+
import { buildResumeCommand } from '../commands/plan-resume.js';
|
|
20
|
+
import { canonicalLanePath } from './paths.js';
|
|
21
|
+
import { cardLine, interactiveRunLine, shownToken } from './card.js';
|
|
22
|
+
import { clearAppFlow, getAppFlow, setSpecGap } from './app-flow.js';
|
|
23
|
+
import { chainFromToken } from './chain-token.js';
|
|
24
|
+
import { containSpecPath, specDisplayPath, SpecProtectedError } from './roots.js';
|
|
25
|
+
import { resolveAtTokenAsync } from '../projects/workspace.js';
|
|
26
|
+
import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
27
|
+
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
28
|
+
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
29
|
+
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
30
|
+
export const START_QUESTION = 'Start building now? It runs every phase and stops only for your answers, SAP approvals or a failed check.';
|
|
31
|
+
/** Trailing characters a sentence puts after a token: `see @spec.md.` / `(@spec.md)`. */
|
|
32
|
+
const TRAILING_PUNCT = /[.,;:!?)\]}>]+$/;
|
|
33
|
+
/**
|
|
34
|
+
* The first `@<token>` of a /abap-app message, as typed. `@"a b.docx"` and
|
|
35
|
+
* `@'a b.docx'` keep their inner spaces. Undefined when there is none.
|
|
36
|
+
*/
|
|
37
|
+
export function extractSpecToken(message) {
|
|
38
|
+
const m = /(?:^|\s)@(?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))/.exec(message);
|
|
39
|
+
if (!m)
|
|
40
|
+
return undefined;
|
|
41
|
+
return m[1] ?? m[2] ?? m[3];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clean a spec token: no leading `@`, no surrounding quotes, no trailing
|
|
45
|
+
* sentence punctuation (a file name never ends in `.`, `,` or `)`).
|
|
46
|
+
*/
|
|
47
|
+
export function cleanSpecToken(raw) {
|
|
48
|
+
let t = raw.trim();
|
|
49
|
+
// Punctuation can sit outside the quotes (`@"a b.md".`), so strip, unquote, strip.
|
|
50
|
+
for (let i = 0; i < 2; i++) {
|
|
51
|
+
t = t.replace(/^@/, '').replace(TRAILING_PUNCT, '');
|
|
52
|
+
const q = /^(["'])([\s\S]*)\1$/.exec(t);
|
|
53
|
+
if (q)
|
|
54
|
+
t = q[2];
|
|
55
|
+
t = t.trim();
|
|
56
|
+
}
|
|
57
|
+
return t;
|
|
58
|
+
}
|
|
59
|
+
const NOT_STARTED = 'The app flow did not start.';
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the /abap-app spec token to a stable path (forward slashes):
|
|
62
|
+
* cwd-relative inside cwd, absolute for a spec in the workspace folder outside
|
|
63
|
+
* cwd. The token is looked up with resolveAtTokenAsync — the same lookup the
|
|
64
|
+
* @-attachment expansion uses — so the pinned spec and the analysed spec are
|
|
65
|
+
* the same file. Refused, with one plain line, when the message names no
|
|
66
|
+
* file, the name is ambiguous, the file leaves cwd and the workspace folder
|
|
67
|
+
* (also through a link), sits in a protected place, or does not exist. The
|
|
68
|
+
* stored value is what app_lane_resolve's `sameFile` compares, and that check
|
|
69
|
+
* (like readSpecText) uses the same containment (lane/roots.ts).
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveAppSpec(cwd, message) {
|
|
72
|
+
const raw = extractSpecToken(message);
|
|
73
|
+
const tok = raw === undefined ? '' : cleanSpecToken(raw);
|
|
74
|
+
// M3 — the plain example, then the usual 'did not start' sentence.
|
|
75
|
+
if (tok === '')
|
|
76
|
+
return { ok: false, line: `Name the spec file with @, for example: /abap-app @specs/my-app.docx\n${NOT_STARTED}` };
|
|
77
|
+
const shown = cardLine(tok);
|
|
78
|
+
const outside = { ok: false, line: `The spec file must be inside this folder or the workspace folder: ${shown}. ${NOT_STARTED}` };
|
|
79
|
+
const missing = { ok: false, line: `Spec file not found: ${shown}. ${NOT_STARTED}` };
|
|
80
|
+
if (tok.includes('\0'))
|
|
81
|
+
return outside;
|
|
82
|
+
let found;
|
|
83
|
+
try {
|
|
84
|
+
const r = await resolveAtTokenAsync(tok, cwd);
|
|
85
|
+
if (r.kind === 'notFound')
|
|
86
|
+
return missing;
|
|
87
|
+
if (r.kind === 'ambiguous') {
|
|
88
|
+
return { ok: false, line: `More than one file matches ${shown}. Use the full name. ${NOT_STARTED}` };
|
|
89
|
+
}
|
|
90
|
+
found = r.path;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return missing;
|
|
94
|
+
}
|
|
95
|
+
// I3 — inside cwd OR the workspace folder, string + realpath contained,
|
|
96
|
+
// denylist kept (the same rule app_lane_resolve applies to the spec).
|
|
97
|
+
let abs;
|
|
98
|
+
let real;
|
|
99
|
+
try {
|
|
100
|
+
({ abs, real } = await containSpecPath(cwd, path.resolve(cwd, found)));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e instanceof SpecProtectedError) {
|
|
104
|
+
return { ok: false, line: `The spec file is in a protected folder and cannot be read: ${shown}. ${NOT_STARTED}` };
|
|
105
|
+
}
|
|
106
|
+
return e?.code === 'ENOENT' ? missing : outside;
|
|
107
|
+
}
|
|
108
|
+
let isFile = false;
|
|
109
|
+
try {
|
|
110
|
+
isFile = statSync(real).isFile();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
isFile = false;
|
|
114
|
+
}
|
|
115
|
+
if (!isFile)
|
|
116
|
+
return missing;
|
|
117
|
+
return { ok: true, rel: specDisplayPath(cwd, abs) };
|
|
118
|
+
}
|
|
119
|
+
/** The skill that runs each chain step. */
|
|
120
|
+
const STEP_SKILL = { 'spec-gap': 'abap-spec-gap', lane: 'abap-app', plan: 'abap-plan' };
|
|
121
|
+
const norm = (s) => s.trim().replace(/\s+/g, ' ');
|
|
122
|
+
/** Shown once when a turn ends a flow that already has a spec-gap file or a lane (interactive only). */
|
|
123
|
+
export const FLOW_ENDED_LINE = 'The /abap-app run ended here; this plan will not use the build lane. Run /abap-app again to use it.';
|
|
124
|
+
/** Real path, case-folded on Windows. Undefined when the file cannot be resolved. */
|
|
125
|
+
async function realKey(p) {
|
|
126
|
+
try {
|
|
127
|
+
const r = path.resolve(await fs.realpath(p));
|
|
128
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** True when both paths name the same existing real file (realpath compare, never strings). */
|
|
135
|
+
export async function sameRealFile(a, b) {
|
|
136
|
+
const [x, y] = await Promise.all([realKey(a), realKey(b)]);
|
|
137
|
+
return x !== undefined && x === y;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* I4(b) — true when a typed `--from @X` resolves (same lookup as the promote
|
|
141
|
+
* flow) to the flow's own spec-gap file. Paths are compared after realpath,
|
|
142
|
+
* never as strings.
|
|
143
|
+
*/
|
|
144
|
+
async function fromNamesFlowGap(cwd, userMessage, specGapPath) {
|
|
145
|
+
if (!specGapPath)
|
|
146
|
+
return false;
|
|
147
|
+
const { fromPath } = parseFromFlag(userMessage);
|
|
148
|
+
if (!fromPath)
|
|
149
|
+
return false;
|
|
150
|
+
let found;
|
|
151
|
+
try {
|
|
152
|
+
const r = await resolveAtTokenAsync(fromPath, cwd);
|
|
153
|
+
if (r.kind !== 'path')
|
|
154
|
+
return false;
|
|
155
|
+
found = path.resolve(cwd, r.path);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const [a, b] = await Promise.all([realKey(found), realKey(specGapPath)]);
|
|
161
|
+
return a !== undefined && a === b;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* R11 — runs at the top of every top-level turn except the stage-1 turn (which
|
|
165
|
+
* restarts the flow). The flow survives only a turn that is part of its
|
|
166
|
+
* current step:
|
|
167
|
+
* - the exact command the CLI queued (or printed) for this step; or
|
|
168
|
+
* - I4(b): the step's own `--from` command typed by hand, when its `@X`
|
|
169
|
+
* resolves (realpath) to the flow's spec-gap file —
|
|
170
|
+
* `/abap-app --from @X` at the lane step, `/abap-plan --from @X` at the
|
|
171
|
+
* plan step; or
|
|
172
|
+
* - a plain reply (not a slash command) while the session is already in the
|
|
173
|
+
* step's skill and the reply is routed to it.
|
|
174
|
+
* Any other turn — another slash command, a reply routed elsewhere — ends it.
|
|
175
|
+
*/
|
|
176
|
+
export async function gateAppFlowForTurn(session, t) {
|
|
177
|
+
const flow = getAppFlow(session);
|
|
178
|
+
if (!flow)
|
|
179
|
+
return { ended: false, hadState: false };
|
|
180
|
+
const stepSkill = STEP_SKILL[flow.expect];
|
|
181
|
+
if (t.explicitSlash) {
|
|
182
|
+
if (flow.queued !== undefined && norm(`/${t.skill} ${t.userMessage}`) === norm(flow.queued)) {
|
|
183
|
+
return { ended: false, hadState: false };
|
|
184
|
+
}
|
|
185
|
+
if ((flow.expect === 'lane' || flow.expect === 'plan') && t.skill === stepSkill
|
|
186
|
+
&& await fromNamesFlowGap(t.cwd, t.userMessage, flow.specGapPath)) {
|
|
187
|
+
return { ended: false, hadState: false };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (t.skill === stepSkill && t.previousSkill === stepSkill) {
|
|
191
|
+
return { ended: false, hadState: false };
|
|
192
|
+
}
|
|
193
|
+
clearAppFlow(session);
|
|
194
|
+
return { ended: true, hadState: flow.specGapPath !== undefined || flow.lane !== undefined };
|
|
195
|
+
}
|
|
196
|
+
/** The plan start command, built from the exact saved path (the offerNextPhaseAutoRun rule). */
|
|
197
|
+
export function startCommand(savedPlanPath) {
|
|
198
|
+
if (!/^[A-Za-z0-9._-]+$/.test(path.basename(savedPlanPath)))
|
|
199
|
+
return undefined;
|
|
200
|
+
return `${buildResumeCommand(savedPlanPath)} --guarded`;
|
|
201
|
+
}
|
|
202
|
+
/** The saved plan's `from.lane`, or undefined (not a plan, unreadable, no lane). */
|
|
203
|
+
export function savedPlanLane(savedPath) {
|
|
204
|
+
try {
|
|
205
|
+
const env = readProjectFile(savedPath);
|
|
206
|
+
if (env.artefactType !== 'plan')
|
|
207
|
+
return undefined;
|
|
208
|
+
const pc = parsePlanContent(env.content);
|
|
209
|
+
return pc.ok ? pc.content.from.lane : undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* After a spec-gap save inside an app flow that is at its spec-gap step:
|
|
217
|
+
* record the saved path and queue stage 2. Runs once per flow (R11): a later
|
|
218
|
+
* spec-gap save, or no app flow, does nothing (no line, no queue).
|
|
219
|
+
*/
|
|
220
|
+
export async function afterSpecGapSave(h, savedPath) {
|
|
221
|
+
const flow = getAppFlow(h.session);
|
|
222
|
+
if (!flow || flow.expect !== 'spec-gap' || flow.specGapPath !== undefined)
|
|
223
|
+
return;
|
|
224
|
+
const t = await chainFromToken(h.cwd, savedPath);
|
|
225
|
+
if (h.headless) {
|
|
226
|
+
// I4(c) — no flow survives a headless / one-shot run: point at the
|
|
227
|
+
// interactive chain instead of a command that would run without its flow.
|
|
228
|
+
if (t)
|
|
229
|
+
setSpecGap(h.session, savedPath, `/abap-app --from ${t.token}`);
|
|
230
|
+
else
|
|
231
|
+
clearAppFlow(h.session);
|
|
232
|
+
h.emit(interactiveRunLine(flow.specToken));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!t) {
|
|
236
|
+
// I4(b)/(c) — the flow stays at the lane step with nothing queued: the
|
|
237
|
+
// user's own `/abap-app --from @<file>` keeps it (the gate compares real paths).
|
|
238
|
+
setSpecGap(h.session, savedPath);
|
|
239
|
+
h.emit(`The saved spec-gap file cannot be queued as a command. To go on, run: /abap-app --from ${shownToken(path.basename(savedPath), 'spec-gap file')}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const cmd = `/abap-app --from ${t.token}`;
|
|
243
|
+
setSpecGap(h.session, savedPath, cmd);
|
|
244
|
+
if (!h.queueNext) {
|
|
245
|
+
h.emit(`Next: ${cmd}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
h.queueNext(cmd);
|
|
249
|
+
h.emit(STEP_2);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* After a plan save: when the saved plan carries `from.lane`, offer to start
|
|
253
|
+
* the guarded run. Inside an active app flow the flow's lane must be the
|
|
254
|
+
* plan's lane. A plan without `from.lane` → nothing.
|
|
255
|
+
*/
|
|
256
|
+
export async function afterPlanSave(h, savedPath) {
|
|
257
|
+
const lane = savedPlanLane(savedPath);
|
|
258
|
+
if (!lane)
|
|
259
|
+
return;
|
|
260
|
+
const flow = getAppFlow(h.session);
|
|
261
|
+
if (flow && (flow.expect !== 'plan' || !flow.lane
|
|
262
|
+
|| canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const end = () => { if (flow)
|
|
266
|
+
clearAppFlow(h.session); };
|
|
267
|
+
const cmd = startCommand(savedPath);
|
|
268
|
+
if (!cmd) {
|
|
269
|
+
h.emit('The plan file name cannot be used in a command. Run /abap-plan --resume @<plan file> --guarded yourself.');
|
|
270
|
+
end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (h.headless || !h.queueNext) {
|
|
274
|
+
h.emit(`Start building with: ${cmd}`);
|
|
275
|
+
end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const confirm = h.confirm ?? (async (req) => {
|
|
279
|
+
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
280
|
+
return askConfirm(req);
|
|
281
|
+
});
|
|
282
|
+
const res = await confirm({
|
|
283
|
+
id: 'app-start-build',
|
|
284
|
+
tag: 'QUESTION',
|
|
285
|
+
title: START_QUESTION,
|
|
286
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
|
|
287
|
+
initial: 0,
|
|
288
|
+
aliases: { y: 'yes', n: 'no' },
|
|
289
|
+
});
|
|
290
|
+
if (res.value === 'yes' && !res.cancelled) {
|
|
291
|
+
h.queueNext(cmd);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
h.emit(`Start it later with: ${cmd}`);
|
|
295
|
+
}
|
|
296
|
+
end();
|
|
297
|
+
}
|