@cspeach/cli 1.1.27 → 1.1.29
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 +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/agent/turn-error-ux.js +40 -0
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +81 -81
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
package/dist/lane/app-chain.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stage 1 /abap-app @<spec> → runs as abap-spec-gap
|
|
4
4
|
// stage 2 /abap-app --from @<specgap> → lane card (app_lane_resolve, Task 5)
|
|
5
5
|
// stage 3 /abap-plan --from @<specgap> → plan with the lane block (Task 6)
|
|
6
|
-
// start Yes → /abap-plan --resume @<saved plan> --guarded
|
|
6
|
+
// start Yes → /abap-plan --resume @<saved plan> --guarded (and the object-list grant)
|
|
7
7
|
//
|
|
8
8
|
// Every command queued here is built by CLI code from a path the CLI itself
|
|
9
9
|
// produced (the saved spec-gap / plan envelope). Model text never reaches a
|
|
@@ -17,7 +17,11 @@ import path from 'node:path';
|
|
|
17
17
|
import { readProjectFile } from '../projects/status.js';
|
|
18
18
|
import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
|
|
19
19
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
20
|
-
import { buildResumeCommand } from '../commands/plan-resume.js';
|
|
20
|
+
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from '../commands/plan-resume.js';
|
|
21
|
+
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
22
|
+
import { phaseDisplayTitle } from '../projects/plan-run.js';
|
|
23
|
+
import { isDeployPhase } from '../projects/plan-lane.js';
|
|
24
|
+
import { installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
|
|
21
25
|
import { canonicalLanePath } from './paths.js';
|
|
22
26
|
import { cardLine, interactiveRunLine, shownToken } from './card.js';
|
|
23
27
|
import { clearAppFlow, getAppFlow, setSpecGap } from './app-flow.js';
|
|
@@ -28,7 +32,7 @@ import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
|
28
32
|
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
29
33
|
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
30
34
|
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
31
|
-
export const START_QUESTION = '
|
|
35
|
+
export const START_QUESTION = 'Build this now?';
|
|
32
36
|
/** Trailing characters a sentence puts after a token: `see @spec.md.` / `(@spec.md)`. */
|
|
33
37
|
const TRAILING_PUNCT = /[.,;:!?)\]}>]+$/;
|
|
34
38
|
/**
|
|
@@ -213,6 +217,54 @@ export function savedPlanLane(savedPath) {
|
|
|
213
217
|
return undefined;
|
|
214
218
|
}
|
|
215
219
|
}
|
|
220
|
+
/** The saved plan (envelope + parsed content), or undefined. */
|
|
221
|
+
function readSavedPlan(savedPath) {
|
|
222
|
+
try {
|
|
223
|
+
const envelope = readProjectFile(savedPath);
|
|
224
|
+
if (envelope.artefactType !== 'plan')
|
|
225
|
+
return undefined;
|
|
226
|
+
const pc = parsePlanContent(envelope.content);
|
|
227
|
+
return pc.ok ? { envelope, content: pc.content } : undefined;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
|
|
234
|
+
/**
|
|
235
|
+
* The facts under "Build this now?": steps, the exact object list, transport,
|
|
236
|
+
* deploy, what stops. `grant: false` (the object-list approval cannot be
|
|
237
|
+
* given here: production, advisory-only, per-write approvals) says each write
|
|
238
|
+
* asks instead of promising no further card.
|
|
239
|
+
*/
|
|
240
|
+
export function buildStartCardFacts(content, opts = {}) {
|
|
241
|
+
const objects = collectPlannedObjects(content);
|
|
242
|
+
// The plan's recorded transport, else the session's current one: the grant
|
|
243
|
+
// is minted with it bound, so the card names it.
|
|
244
|
+
const transport = planBuildTransport(content.phases);
|
|
245
|
+
const facts = [`Steps: ${content.phases.map((p) => phaseDisplayTitle(p, content.phases)).join(' · ')}`];
|
|
246
|
+
if (objects.length > 0) {
|
|
247
|
+
facts.push(`It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`);
|
|
248
|
+
facts.push(...plannedObjectFacts(objects).map((r) => ` ${r}`));
|
|
249
|
+
facts.push(`Transport: ${transport ?? 'a new one for this build'}`);
|
|
250
|
+
if (opts.grant === false)
|
|
251
|
+
facts.push('CSPeach asks before each write.');
|
|
252
|
+
else
|
|
253
|
+
facts.push(...planGrantFactLines());
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
facts.push('The plan does not list its SAP objects, so CSPeach asks before each write.');
|
|
257
|
+
}
|
|
258
|
+
if (content.phases.some((p) => isDeployPhase(p))) {
|
|
259
|
+
facts.push(`Deploy: the app goes to ${opts.sapAlias ?? 'your SAP system'}. CSPeach asks again before deploying.`);
|
|
260
|
+
}
|
|
261
|
+
// The closing fact follows what was granted: with no object-list approval
|
|
262
|
+
// every SAP write asks, so it never promises "only a write not on the list".
|
|
263
|
+
facts.push(opts.grant !== false && objects.length > 0
|
|
264
|
+
? 'It runs every step and stops only for your answers, a write not on the list, a failed check, or the deploy.'
|
|
265
|
+
: 'It runs every step and stops for your answers, each SAP write, a failed check, or the deploy.');
|
|
266
|
+
return { facts, objects, ...(transport ? { transport } : {}) };
|
|
267
|
+
}
|
|
216
268
|
/**
|
|
217
269
|
* After a spec-gap save inside an app flow that is at its spec-gap step:
|
|
218
270
|
* record the saved path and queue stage 2. Runs once per flow (R11): a later
|
|
@@ -277,16 +329,20 @@ export function afterSpecGapNotSaved(h, reason, assistantText) {
|
|
|
277
329
|
h.emit(SPEC_GAP_NOT_SAVED_LINE(flow.specToken));
|
|
278
330
|
}
|
|
279
331
|
/**
|
|
280
|
-
* After a plan save:
|
|
281
|
-
*
|
|
282
|
-
*
|
|
332
|
+
* After a plan save: one "Build this now?" card that shows the exact SAP object
|
|
333
|
+
* list (spec §3.2). Yes approves the plan AND that list (Forge Rule 8: plan,
|
|
334
|
+
* then yes, then execute) and starts the continuous run. Inside an /abap-app
|
|
335
|
+
* flow only the flow's own lane plan gets the card; outside a flow every saved
|
|
336
|
+
* plan does (2026-10-04 — /abap-plan and revision plans too). Headless, or
|
|
337
|
+
* with no card listener (the card comes back cancelled), no grant is minted.
|
|
283
338
|
*/
|
|
284
339
|
export async function afterPlanSave(h, savedPath) {
|
|
285
|
-
const
|
|
286
|
-
if (!
|
|
340
|
+
const saved = readSavedPlan(savedPath);
|
|
341
|
+
if (!saved)
|
|
287
342
|
return;
|
|
343
|
+
const lane = saved.content.from.lane;
|
|
288
344
|
const flow = getAppFlow(h.session);
|
|
289
|
-
if (flow && (flow.expect !== 'plan' || !flow.lane
|
|
345
|
+
if (flow && (flow.expect !== 'plan' || !flow.lane || !lane
|
|
290
346
|
|| canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
|
|
291
347
|
return;
|
|
292
348
|
}
|
|
@@ -303,6 +359,11 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
303
359
|
end();
|
|
304
360
|
return;
|
|
305
361
|
}
|
|
362
|
+
const sapAlias = h.sapAlias && h.sapAlias !== 'none' ? h.sapAlias : undefined;
|
|
363
|
+
const grantable = await planGrantAllowed(sapAlias);
|
|
364
|
+
const { facts, objects, transport } = buildStartCardFacts(saved.content, {
|
|
365
|
+
...(sapAlias ? { sapAlias } : {}), grant: grantable,
|
|
366
|
+
});
|
|
306
367
|
const confirm = h.confirm ?? (async (req) => {
|
|
307
368
|
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
308
369
|
return askConfirm(req);
|
|
@@ -311,11 +372,29 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
311
372
|
id: 'app-start-build',
|
|
312
373
|
tag: 'QUESTION',
|
|
313
374
|
title: START_QUESTION,
|
|
375
|
+
facts,
|
|
314
376
|
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
|
|
315
377
|
initial: 0,
|
|
316
378
|
aliases: { y: 'yes', n: 'no' },
|
|
317
379
|
});
|
|
318
380
|
if (res.value === 'yes' && !res.cancelled) {
|
|
381
|
+
// The plan's recorded transport becomes the session transport when the
|
|
382
|
+
// session has none, so the first step's writes land on (and are covered
|
|
383
|
+
// by) it without the model naming it — the same rule a resume uses.
|
|
384
|
+
adoptPlanTransportOnResume(saved.content.phases);
|
|
385
|
+
const sessionId = h.session.id;
|
|
386
|
+
if (grantable && objects.length > 0 && typeof sessionId === 'string' && sessionId) {
|
|
387
|
+
const a = { planId: saved.envelope.id, sessionId, objects, ...(transport ? { transport } : {}) };
|
|
388
|
+
try {
|
|
389
|
+
if (h.grant)
|
|
390
|
+
await h.grant(a);
|
|
391
|
+
else if (!(await installPlanGrant({ ...a, ...(sapAlias ? { sapAlias } : {}) })))
|
|
392
|
+
h.emit(OBJECT_LIST_FAILED);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
h.emit(OBJECT_LIST_FAILED);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
319
398
|
h.queueNext(cmd);
|
|
320
399
|
}
|
|
321
400
|
else {
|
package/dist/one-shot.js
CHANGED
|
@@ -59,6 +59,7 @@ import './tools/cap/sap-link-tool.js';
|
|
|
59
59
|
import './tools/cap/link-smoke.js';
|
|
60
60
|
import './tools/lane/app-lane-resolve.js';
|
|
61
61
|
import './tools/extend-model/tool.js';
|
|
62
|
+
import './tools/text-elements/tools.js';
|
|
62
63
|
import './tools/capability/tool.js';
|
|
63
64
|
import './tools/web/web_fetch.js';
|
|
64
65
|
import './tools/web/web_search.js';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
|
|
2
2
|
import { computeNextPhase, isPhaseSatisfied } from './plan-run.js';
|
|
3
3
|
import { canonicalLanePath } from '../lane/paths.js';
|
|
4
|
+
/** The error message when a turn wrote no plan manifest block (callers match on it). */
|
|
5
|
+
export const NO_PLAN_MANIFEST_MESSAGE = 'No plan manifest block found in the output';
|
|
4
6
|
// E2 dual-read: accept BOTH the legacy `csforge:` prefix (old artifacts on
|
|
5
7
|
// customer disks carry it forever) and the current `cspeach:` prefix. Legacy
|
|
6
8
|
// acceptance is permanent.
|
|
@@ -75,6 +77,13 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
75
77
|
// phase's `ui` always survives the spread below.
|
|
76
78
|
delete overlay.ui;
|
|
77
79
|
normalizeWorkFields(overlay);
|
|
80
|
+
// 2026-10-04 (continuous run) — `work` replaces wholesale, but the planned
|
|
81
|
+
// object list was authored at plan creation: keep it when the overlay omits it.
|
|
82
|
+
const ow = overlay.work;
|
|
83
|
+
const priorWork = prior.phases.find((p) => p.id === id)?.work;
|
|
84
|
+
if (ow && typeof ow === 'object' && ow.plannedObjects === undefined && priorWork?.plannedObjects !== undefined) {
|
|
85
|
+
ow.plannedObjects = priorWork.plannedObjects;
|
|
86
|
+
}
|
|
78
87
|
}
|
|
79
88
|
// 1:1 map over the PRIOR phase list — changed entries overlay their phase,
|
|
80
89
|
// everything else passes through verbatim from the envelope.
|
|
@@ -149,6 +158,29 @@ function imposeLane(contentInput, priorContent, opts) {
|
|
|
149
158
|
ci.from = rest;
|
|
150
159
|
}
|
|
151
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* `from.specGap` precedence: the CLI's promotion state (plan creation) > the
|
|
163
|
+
* prior envelope's ref (resume / revision — the source never changes mid-plan)
|
|
164
|
+
* > whatever valid ref the model wrote (kept as before; junk refs are already
|
|
165
|
+
* stripped by the sha guard). Never mutates the prior content.
|
|
166
|
+
*/
|
|
167
|
+
function imposeSpecGap(contentInput, priorContent, opts) {
|
|
168
|
+
if (typeof contentInput !== 'object' || contentInput === null || Array.isArray(contentInput))
|
|
169
|
+
return;
|
|
170
|
+
const ref = opts.specGap
|
|
171
|
+
? { path: opts.specGap.path, sha256: opts.specGap.sha256 }
|
|
172
|
+
: priorContent?.from.specGap
|
|
173
|
+
? { ...priorContent.from.specGap }
|
|
174
|
+
: undefined;
|
|
175
|
+
if (!ref)
|
|
176
|
+
return;
|
|
177
|
+
const ci = contentInput;
|
|
178
|
+
const from = ci.from;
|
|
179
|
+
if (from === undefined)
|
|
180
|
+
ci.from = { specGap: ref };
|
|
181
|
+
else if (typeof from === 'object' && from !== null && !Array.isArray(from))
|
|
182
|
+
ci.from = { ...from, specGap: ref };
|
|
183
|
+
}
|
|
152
184
|
export function extractPlan(markdown, priorContent, opts = {}) {
|
|
153
185
|
// Last block wins — defensive against a turn emitting more than one block.
|
|
154
186
|
const re = new RegExp(MANIFEST_RE.source, MANIFEST_RE.flags);
|
|
@@ -157,7 +189,7 @@ export function extractPlan(markdown, priorContent, opts = {}) {
|
|
|
157
189
|
while ((m = re.exec(markdown)) !== null)
|
|
158
190
|
last = m;
|
|
159
191
|
if (!last) {
|
|
160
|
-
throw new Error(
|
|
192
|
+
throw new Error(NO_PLAN_MANIFEST_MESSAGE);
|
|
161
193
|
}
|
|
162
194
|
let parsed;
|
|
163
195
|
try {
|
|
@@ -247,6 +279,7 @@ export function extractPlan(markdown, priorContent, opts = {}) {
|
|
|
247
279
|
throw new Error('plan-manifest must carry either "content" (full shape) or "changed" (compact resume shape)');
|
|
248
280
|
}
|
|
249
281
|
imposeLane(contentInput, priorContent, opts);
|
|
282
|
+
imposeSpecGap(contentInput, priorContent, opts);
|
|
250
283
|
const pc = parsePlanContent(contentInput);
|
|
251
284
|
if (!pc.ok) {
|
|
252
285
|
throw new Error(`plan-manifest content failed validation:\n ${pc.errors.join('\n ')}`);
|
|
@@ -40,7 +40,7 @@ function auditWords(audit) {
|
|
|
40
40
|
case 'waived': return `· waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
|
|
41
41
|
case 'failed': return '✗ audit failed';
|
|
42
42
|
case 'not_audited_hand_edited': return '· hand-edited';
|
|
43
|
-
case 'pending':
|
|
43
|
+
case 'pending': return '! not independently checked yet';
|
|
44
44
|
case 'infra_failed':
|
|
45
45
|
default:
|
|
46
46
|
return '! audit not run';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
2
|
+
export const OBJECT_TYPE_LABELS = {
|
|
3
|
+
TABL: 'table', DDLS: 'CDS view', DDLX: 'metadata extension', DCLS: 'access control',
|
|
4
|
+
BDEF: 'behavior definition', CLAS: 'class', INTF: 'interface', SRVD: 'service definition',
|
|
5
|
+
SRVB: 'service binding', PROG: 'program', FUGR: 'function group', DOMA: 'domain',
|
|
6
|
+
DTEL: 'data element', MSAG: 'message class', NROB: 'number range object',
|
|
7
|
+
};
|
|
8
|
+
/** Never on the list: deploy (own card), transport and package (a different one must ask). */
|
|
9
|
+
const NEVER_ON_LIST = new Set(['WAPA', 'CTS', 'DEVC']);
|
|
10
|
+
const TYPE_RE = /^[A-Z]{4}$/;
|
|
11
|
+
const NAME_RE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
|
|
12
|
+
const PKG_RE = /^(\$[A-Z0-9_]{1,29}|(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30})$/;
|
|
13
|
+
export const MAX_PLANNED_OBJECTS = 200;
|
|
14
|
+
/** ADT type code from a raw type: `'ddls/df'` → `'DDLS'`. */
|
|
15
|
+
export function normalizeObjectType(raw) {
|
|
16
|
+
return String(raw ?? '').trim().toUpperCase().split('/')[0].trim();
|
|
17
|
+
}
|
|
18
|
+
/** Canonical upper-case object name (same folding as approvals). */
|
|
19
|
+
export function normalizeObjectName(raw) {
|
|
20
|
+
return canonicalApprovalObject(raw);
|
|
21
|
+
}
|
|
22
|
+
/** One validated row, or null for anything malformed or never-on-the-list. */
|
|
23
|
+
export function toPlannedObject(raw) {
|
|
24
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
25
|
+
return null;
|
|
26
|
+
const r = raw;
|
|
27
|
+
if (typeof r.type !== 'string' || typeof r.name !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
const type = normalizeObjectType(r.type);
|
|
30
|
+
const name = normalizeObjectName(r.name);
|
|
31
|
+
// Only repository object types the list knows are kept: a data run (DATA) or
|
|
32
|
+
// any other non-repository type is never on the list, so never signed.
|
|
33
|
+
if (!TYPE_RE.test(type) || NEVER_ON_LIST.has(type) || !Object.hasOwn(OBJECT_TYPE_LABELS, type) || !NAME_RE.test(name))
|
|
34
|
+
return null;
|
|
35
|
+
const pkg = typeof r.package === 'string' ? r.package.trim().toUpperCase() : '';
|
|
36
|
+
return { type, name, ...(pkg && PKG_RE.test(pkg) ? { package: pkg } : {}) };
|
|
37
|
+
}
|
|
38
|
+
/** Every phase's planned objects — validated, deduped by type+name, capped. */
|
|
39
|
+
export function collectPlannedObjects(content) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const p of content.phases) {
|
|
43
|
+
const list = p.work?.plannedObjects;
|
|
44
|
+
if (!Array.isArray(list))
|
|
45
|
+
continue;
|
|
46
|
+
for (const raw of list) {
|
|
47
|
+
const o = toPlannedObject(raw);
|
|
48
|
+
if (!o)
|
|
49
|
+
continue;
|
|
50
|
+
const key = `${o.type}:${o.name}`;
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
out.push(o);
|
|
55
|
+
if (out.length >= MAX_PLANNED_OBJECTS)
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Card rows: `ZI_X · CDS view · package ZPKG`, with a "more" line past max. */
|
|
62
|
+
export function plannedObjectFacts(objs, max = 12) {
|
|
63
|
+
const rows = objs.slice(0, max).map((o) => `${o.name} · ${OBJECT_TYPE_LABELS[o.type] ?? o.type}${o.package ? ` · package ${o.package}` : ''}`);
|
|
64
|
+
if (objs.length > max)
|
|
65
|
+
rows.push(`… and ${objs.length - max} more`);
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The two card lines that say what the object-list approval covers. One
|
|
70
|
+
* source for every card that installs the plan grant ("Build this now?",
|
|
71
|
+
* the startup Continue).
|
|
72
|
+
*/
|
|
73
|
+
export function planGrantFactLines() {
|
|
74
|
+
return ['Writes to these objects need no further card.', 'Anything else stops and asks.'];
|
|
75
|
+
}
|
|
@@ -74,27 +74,52 @@ export function statusesFromItems(items, phases) {
|
|
|
74
74
|
* session died mid-phase) qualify the same as 'todo' — the phase is
|
|
75
75
|
* re-entered from the top, gates and grounding included.
|
|
76
76
|
*/
|
|
77
|
-
|
|
77
|
+
function nextPhaseWith(phases, statuses, satisfied) {
|
|
78
78
|
// Audit verdicts live on the phase objects, not the status map — look each
|
|
79
79
|
// dependency up so a validated-but-audit-failed phase gates its dependants.
|
|
80
80
|
const byId = new Map(phases.map((p) => [p.id, p]));
|
|
81
81
|
for (const p of phases) {
|
|
82
82
|
const s = statuses[p.id] ?? 'todo';
|
|
83
|
-
if (
|
|
83
|
+
if (satisfied(s, p.audit) || s === 'blocked')
|
|
84
84
|
continue;
|
|
85
85
|
if (s !== 'todo' && !IN_PROGRESS.includes(s))
|
|
86
86
|
continue;
|
|
87
|
-
if (p.entryCriteria.every((dep) =>
|
|
87
|
+
if (p.entryCriteria.every((dep) => satisfied(statuses[dep], byId.get(dep)?.audit)))
|
|
88
88
|
return p;
|
|
89
89
|
}
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
|
+
export function computeNextPhase(phases, statuses) {
|
|
93
|
+
return nextPhaseWith(phases, statuses, isPhaseSatisfied);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 2026-10-04 (continuous run) — audits run once at the END of a run, so inside
|
|
97
|
+
* a run a 'pending' audit is deferred, not unresolved. On-disk semantics
|
|
98
|
+
* (isAuditResolved / isPhaseSatisfied / computeNextPhase) are unchanged.
|
|
99
|
+
*/
|
|
100
|
+
export function isAuditResolvedForRun(audit) {
|
|
101
|
+
return isAuditResolved(audit) || audit?.state === 'pending';
|
|
102
|
+
}
|
|
103
|
+
export function isPhaseSatisfiedForRun(s, audit) {
|
|
104
|
+
return (s === 'validated' || s === 'validated-with-waiver') && isAuditResolvedForRun(audit);
|
|
105
|
+
}
|
|
106
|
+
export function computeNextPhaseForRun(phases, statuses) {
|
|
107
|
+
return nextPhaseWith(phases, statuses, isPhaseSatisfiedForRun);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 2026-10-04 (continuous run) — every phase whose audit is still 'pending'
|
|
111
|
+
* (deferred to the end of the run), in plan order, whichever session wrote it.
|
|
112
|
+
* The single scan shared by the resume path and the end-of-run batch audit.
|
|
113
|
+
*/
|
|
114
|
+
export function pendingAuditPhaseIds(content) {
|
|
115
|
+
return content.phases.filter((p) => p.audit?.state === 'pending').map((p) => p.id);
|
|
116
|
+
}
|
|
92
117
|
/**
|
|
93
118
|
* Plan-progress board printed before and after every resume turn (and by
|
|
94
119
|
* `--status` for plan envelopes). Flows through chunkEmitter in Ink mode and
|
|
95
120
|
* console.log in classic mode alike.
|
|
96
121
|
*
|
|
97
|
-
* HR Dayforce Extract (v3) · 2 of 7 done
|
|
122
|
+
* HR Dayforce Extract (v3) · 2 of 7 phases done
|
|
98
123
|
*
|
|
99
124
|
* ✓ Types — ZDOM_DAYF_STATUS ✓ audited
|
|
100
125
|
* ◼ Tables — Reason checktable + text table active
|
|
@@ -123,14 +148,43 @@ const DELEGATE_LABELS = {
|
|
|
123
148
|
'abap-eml': 'Behaviour logic',
|
|
124
149
|
'abap-segw': 'OData (SEGW)',
|
|
125
150
|
};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
151
|
+
/** A component id that is not a human word: `c1`, `k1`, `C12`, `project`, `ui`. */
|
|
152
|
+
const MACHINE_COMPONENT = /^([a-z]\d+|project|ui)$/i;
|
|
153
|
+
/**
|
|
154
|
+
* 2026-10-04 (continuous run) - the ONE title every CSPeach line uses for a
|
|
155
|
+
* phase. Seed-time `title` wins, then the delegate / layer labels; the id is
|
|
156
|
+
* the last resort (tidied). With `phases`, a plan made of more than one NAMED
|
|
157
|
+
* component ("backend", "frontend") prefixes the component: "Backend: CDS views".
|
|
158
|
+
*/
|
|
159
|
+
export function phaseDisplayTitle(p, phases) {
|
|
130
160
|
const explicit = p.title?.trim();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
const base = explicit || (DELEGATE_LABELS[p.delegateTo] ?? LAYER_LABELS[p.layer] ?? tidyId(p.id));
|
|
162
|
+
if (!phases)
|
|
163
|
+
return base;
|
|
164
|
+
const named = new Set(phases.map((q) => q.component).filter((c) => c && !MACHINE_COMPONENT.test(c)));
|
|
165
|
+
if (named.size < 2 || !p.component || MACHINE_COMPONENT.test(p.component))
|
|
166
|
+
return base;
|
|
167
|
+
return `${p.component.charAt(0).toUpperCase()}${p.component.slice(1)}: ${base}`;
|
|
168
|
+
}
|
|
169
|
+
function phaseTitle(p) {
|
|
170
|
+
return phaseDisplayTitle(p);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 2026-10-04 - the one-line progress strip shown at each phase start:
|
|
174
|
+
* "Design ✓ · CDS views ◼ · Service · Fiori app · Deploy". Titles only.
|
|
175
|
+
*/
|
|
176
|
+
export function renderPlanProgressLine(a) {
|
|
177
|
+
const g = getGlyphs();
|
|
178
|
+
return a.content.phases.map((p) => {
|
|
179
|
+
const t = sanitizeControlChars(phaseDisplayTitle(p, a.content.phases));
|
|
180
|
+
if (isPhaseSatisfied(a.statuses[p.id]))
|
|
181
|
+
return `${t} ${paint('ok', g.done)}`;
|
|
182
|
+
if (p.id === a.currentId)
|
|
183
|
+
return `${t} ${paint('sap', g.current)}`;
|
|
184
|
+
if (a.statuses[p.id] === 'blocked')
|
|
185
|
+
return `${t} ${paint('bad', g.failed)}`;
|
|
186
|
+
return paint('dim', t);
|
|
187
|
+
}).join(paint('dim', ' · '));
|
|
134
188
|
}
|
|
135
189
|
function tidyId(id) {
|
|
136
190
|
const tail = id.split('.').pop() ?? id;
|
|
@@ -173,7 +227,9 @@ function auditTag(audit) {
|
|
|
173
227
|
}
|
|
174
228
|
case 'failed': return ` ${paint('bad', `${g.failed} audit failed`)}`;
|
|
175
229
|
case 'not_audited_hand_edited': return ` ${paint('dim', `${g.info} hand-edited`)}`;
|
|
176
|
-
|
|
230
|
+
// 2026-10-04 (continuous run) — 'pending' = the end-of-run check has not
|
|
231
|
+
// judged this step yet (deferred, or it could not run); the next resume runs it.
|
|
232
|
+
case 'pending': return ` ${paint('warn', `${g.attention} not independently checked yet`)}`;
|
|
177
233
|
case 'infra_failed':
|
|
178
234
|
default:
|
|
179
235
|
// Deliberate forward-compat: an unknown future state reads "audit not run".
|
|
@@ -212,7 +268,7 @@ export function renderPlanTracker(a) {
|
|
|
212
268
|
// plan-tracker-pin).
|
|
213
269
|
const validated = a.content.phases.filter((p) => isPhaseSatisfied(a.statuses[p.id], p.audit)).length;
|
|
214
270
|
if (a.includeHeader !== false) {
|
|
215
|
-
const tail = ` (v${a.version}) · ${countLabel(validated, total)} done`;
|
|
271
|
+
const tail = ` (v${a.version}) · ${countLabel(validated, total)} phases done`;
|
|
216
272
|
const head = truncateToWidth(sanitizeControlChars(a.title), Math.max(1, width - stringWidth(tail)));
|
|
217
273
|
lines.push(chalk.bold(head) + paint('dim', tail));
|
|
218
274
|
lines.push('');
|
|
@@ -330,8 +386,8 @@ g = getGlyphs()) {
|
|
|
330
386
|
const notes = p.audit.findings ?? [];
|
|
331
387
|
// Findings are model-written text: sanitise before they reach a terminal.
|
|
332
388
|
lines.push(notes.length > 0
|
|
333
|
-
? `${g.attention} ${p
|
|
334
|
-
: `${g.attention} ${p
|
|
389
|
+
? `${g.attention} ${sanitizeControlChars(phaseDisplayTitle(p, phases))} passed with ${notes.length} note${notes.length === 1 ? '' : 's'}: ${sanitizeControlChars(notes.join('; '))}.`
|
|
390
|
+
: `${g.attention} ${sanitizeControlChars(phaseDisplayTitle(p, phases))} passed with notes.`);
|
|
335
391
|
}
|
|
336
392
|
// §10 Q2 (audit-confidence redesign): a waived phase surfaces its light
|
|
337
393
|
// category + reason in the FINAL summary — an "all validated" plan never
|
|
@@ -344,7 +400,7 @@ g = getGlyphs()) {
|
|
|
344
400
|
// Same "· waived" tag as the board (auditTag); the reason is user-typed text.
|
|
345
401
|
const cat = p.audit.waiveCategory ? ` (${sanitizeControlChars(p.audit.waiveCategory)})` : '';
|
|
346
402
|
const reason = p.audit.waivedReason ? `: ${sanitizeControlChars(p.audit.waivedReason)}` : '';
|
|
347
|
-
lines.push(`${g.info} ${p
|
|
403
|
+
lines.push(`${g.info} ${sanitizeControlChars(phaseDisplayTitle(p, phases))} waived${cat}${reason}.`);
|
|
348
404
|
}
|
|
349
405
|
// audit-infra-continue (2026-07-09): a phase whose audit could NOT RUN
|
|
350
406
|
// surfaces in the FINAL summary as a DISTINCT "unverified" line — an "all
|
|
@@ -355,7 +411,7 @@ g = getGlyphs()) {
|
|
|
355
411
|
for (const p of phases) {
|
|
356
412
|
if (p.audit?.state !== 'infra_failed')
|
|
357
413
|
continue;
|
|
358
|
-
lines.push(`${g.attention} ${p
|
|
414
|
+
lines.push(`${g.attention} ${sanitizeControlChars(phaseDisplayTitle(p, phases))} was not independently checked (the check could not run — its own exit gate, activation + ATC, passed).`);
|
|
359
415
|
}
|
|
360
416
|
const hasUi = phases.some((p) => p.layer === 'ui' || p.layer === 'cap.ui' || p.delegateTo === 'abap-fiori-build');
|
|
361
417
|
const hasService = phases.some((p) => p.layer === 'service');
|
|
@@ -378,7 +434,7 @@ g = getGlyphs()) {
|
|
|
378
434
|
const optOut = phases.map((p) => p.ui?.deployOptOut?.trim()).find((w) => !!w);
|
|
379
435
|
lines.push(optOut
|
|
380
436
|
? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
|
|
381
|
-
: `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build
|
|
437
|
+
: `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build or record why not.`);
|
|
382
438
|
}
|
|
383
439
|
lines.push('Consider /abap-preflight on the produced transport(s) before release.');
|
|
384
440
|
return lines;
|
|
@@ -68,6 +68,11 @@ const planPhaseWorkSchema = z.object({
|
|
|
68
68
|
version: z.enum(['2.0', '4.0']).optional(),
|
|
69
69
|
}).optional().catch(undefined),
|
|
70
70
|
deployedUrl: z.string().min(1).optional().catch(undefined),
|
|
71
|
+
// 2026-10-04 (continuous run) — `plannedObjects` (the phase's planned SAP
|
|
72
|
+
// objects) is deliberately NOT declared here: .passthrough() carries it
|
|
73
|
+
// through parse and save exactly as written, so one malformed row (or a
|
|
74
|
+
// non-list) can never drop the valid rows or fail the envelope.
|
|
75
|
+
// collectPlannedObjects (plan-objects.ts) validates each row where used.
|
|
71
76
|
}).passthrough();
|
|
72
77
|
export const PLAN_UI_FLAVORS = ['fiori-elements', 'freestyle'];
|
|
73
78
|
export const PLAN_UI_FLOORPLANS = ['listReport', 'worklist', 'ovp', 'alp'];
|
|
@@ -119,6 +124,7 @@ const planPhaseAuditSchema = z.object({
|
|
|
119
124
|
// D-B review item 1 (2026-07-05): attempt-window cutoff for re-audits (see
|
|
120
125
|
// PlanPhaseAudit in types.ts). Must be in the schema or z.object strips it.
|
|
121
126
|
attemptStartedAt: z.string().optional(),
|
|
127
|
+
attemptEndedAt: z.string().optional(),
|
|
122
128
|
consecutiveFailures: z.number().int().nonnegative().optional(),
|
|
123
129
|
});
|
|
124
130
|
/** Fail-safe write-ness: a phase that doesn't declare is treated as writing. */
|