@cspeach/cli 1.1.26 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +41 -5
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/commands/whoami.js +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +4 -6
- package/dist/lane/app-chain.js +116 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +15 -14
- package/dist/projects/saved-lines.js +36 -0
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/sap/unreachable.js +4 -1
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +3 -4
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
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
|
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
import { promises as fs, statSync } from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { readProjectFile } from '../projects/status.js';
|
|
18
|
+
import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
|
|
18
19
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
19
|
-
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';
|
|
20
25
|
import { canonicalLanePath } from './paths.js';
|
|
21
26
|
import { cardLine, interactiveRunLine, shownToken } from './card.js';
|
|
22
27
|
import { clearAppFlow, getAppFlow, setSpecGap } from './app-flow.js';
|
|
@@ -27,7 +32,7 @@ import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
|
27
32
|
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
28
33
|
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
29
34
|
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
30
|
-
export const START_QUESTION = '
|
|
35
|
+
export const START_QUESTION = 'Build this now?';
|
|
31
36
|
/** Trailing characters a sentence puts after a token: `see @spec.md.` / `(@spec.md)`. */
|
|
32
37
|
const TRAILING_PUNCT = /[.,;:!?)\]}>]+$/;
|
|
33
38
|
/**
|
|
@@ -212,6 +217,54 @@ export function savedPlanLane(savedPath) {
|
|
|
212
217
|
return undefined;
|
|
213
218
|
}
|
|
214
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
|
+
}
|
|
215
268
|
/**
|
|
216
269
|
* After a spec-gap save inside an app flow that is at its spec-gap step:
|
|
217
270
|
* record the saved path and queue stage 2. Runs once per flow (R11): a later
|
|
@@ -248,17 +301,48 @@ export async function afterSpecGapSave(h, savedPath) {
|
|
|
248
301
|
h.queueNext(cmd);
|
|
249
302
|
h.emit(STEP_2);
|
|
250
303
|
}
|
|
304
|
+
/** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
|
|
305
|
+
export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1: the spec-gap result could not be saved. Ask CSPeach to "re-issue the spec gap in the standard structure", or run /abap-app ${shownToken(specToken, 'spec file')} again.`;
|
|
306
|
+
/** The user said No to the save: the run ends here. */
|
|
307
|
+
export const SPEC_GAP_DECLINED_LINE = (specToken) => `The /abap-app run stopped at step 1 because the spec-gap result was not saved. To start again, run /abap-app ${shownToken(specToken, 'spec file')}.`;
|
|
251
308
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
309
|
+
* 2026-10-04 — a spec-gap turn inside an app flow at its spec-gap step that
|
|
310
|
+
* produced no file must never end the chain in silence (live 1.1.26: the
|
|
311
|
+
* result was written, the save failed on its heading, nothing more was said).
|
|
312
|
+
* - unreadable + the text IS a spec-gap result → one line; the flow stays at
|
|
313
|
+
* step 1, so a re-issued result saves and moves the chain on.
|
|
314
|
+
* - unreadable + no result (a question back to the user) → nothing: the
|
|
315
|
+
* conversation is still in step 1.
|
|
316
|
+
* - declined → one line; the flow ends.
|
|
317
|
+
*/
|
|
318
|
+
export function afterSpecGapNotSaved(h, reason, assistantText) {
|
|
319
|
+
const flow = getAppFlow(h.session);
|
|
320
|
+
if (!flow || flow.expect !== 'spec-gap' || flow.specGapPath !== undefined)
|
|
321
|
+
return;
|
|
322
|
+
if (reason === 'declined') {
|
|
323
|
+
clearAppFlow(h.session);
|
|
324
|
+
h.emit(SPEC_GAP_DECLINED_LINE(flow.specToken));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!looksLikeSpecGap(assistantText))
|
|
328
|
+
return;
|
|
329
|
+
h.emit(SPEC_GAP_NOT_SAVED_LINE(flow.specToken));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
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.
|
|
255
338
|
*/
|
|
256
339
|
export async function afterPlanSave(h, savedPath) {
|
|
257
|
-
const
|
|
258
|
-
if (!
|
|
340
|
+
const saved = readSavedPlan(savedPath);
|
|
341
|
+
if (!saved)
|
|
259
342
|
return;
|
|
343
|
+
const lane = saved.content.from.lane;
|
|
260
344
|
const flow = getAppFlow(h.session);
|
|
261
|
-
if (flow && (flow.expect !== 'plan' || !flow.lane
|
|
345
|
+
if (flow && (flow.expect !== 'plan' || !flow.lane || !lane
|
|
262
346
|
|| canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
|
|
263
347
|
return;
|
|
264
348
|
}
|
|
@@ -275,6 +359,11 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
275
359
|
end();
|
|
276
360
|
return;
|
|
277
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
|
+
});
|
|
278
367
|
const confirm = h.confirm ?? (async (req) => {
|
|
279
368
|
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
280
369
|
return askConfirm(req);
|
|
@@ -283,11 +372,29 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
283
372
|
id: 'app-start-build',
|
|
284
373
|
tag: 'QUESTION',
|
|
285
374
|
title: START_QUESTION,
|
|
375
|
+
facts,
|
|
286
376
|
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
|
|
287
377
|
initial: 0,
|
|
288
378
|
aliases: { y: 'yes', n: 'no' },
|
|
289
379
|
});
|
|
290
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
|
+
}
|
|
291
398
|
h.queueNext(cmd);
|
|
292
399
|
}
|
|
293
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,8 +1,9 @@
|
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
1
2
|
// 2026-09-17 — tolerant on heading level and dash: the skill prescribes
|
|
2
3
|
// "## ABAP Design — …" but a live standalone run wrote "# ABAP Design — …"
|
|
3
4
|
// and the whole design was silently lost (no file, so /abap-rap --from had
|
|
4
5
|
// nothing to start from). H1..H3 and em/en/plain dash all count.
|
|
5
|
-
const
|
|
6
|
+
const TITLE_SPEC = { phrase: String.raw `abap[ \t]+design`, skillPrefix: 'abap-design' }; // 2026-10-04 — tolerant title, see title-heading.ts
|
|
6
7
|
const OBJECTS_HEADING_RE = /^### Objects to Create\s*$/m;
|
|
7
8
|
const DEPENDENCY_HEADING_RE = /^### Dependency Sequence\s*$/m;
|
|
8
9
|
const NEXT_HEADING_RE = /^###\s/m;
|
|
@@ -93,11 +94,11 @@ function parseDependencySequence(section, nameToId) {
|
|
|
93
94
|
return out;
|
|
94
95
|
}
|
|
95
96
|
export function extractDesign(markdown) {
|
|
96
|
-
const titleMatch =
|
|
97
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
97
98
|
if (!titleMatch) {
|
|
98
99
|
throw new Error('Input is not a design output (no "## ABAP Design — ..." heading found)');
|
|
99
100
|
}
|
|
100
|
-
const title = titleMatch
|
|
101
|
+
const title = titleMatch.title || 'ABAP Design';
|
|
101
102
|
const objectsSection = sliceSection(markdown, OBJECTS_HEADING_RE);
|
|
102
103
|
if (!objectsSection) {
|
|
103
104
|
throw new Error('No "### Objects to Create" section found');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
1
2
|
// 2026-09-17 — tolerant on heading level (H1..H3) and dash, same reason as
|
|
2
3
|
// extract-design.ts.
|
|
3
|
-
const
|
|
4
|
+
const TITLE_SPEC = { phrase: String.raw `effort[ \t]+estimate`, skillPrefix: 'abap-estimate' }; // 2026-10-04 — tolerant title, see title-heading.ts
|
|
4
5
|
const COMPONENTS_HEADING_RE = /^### Component Breakdown\s*$/m;
|
|
5
6
|
const NEXT_HEADING_RE = /^###\s/m;
|
|
6
7
|
function sliceSection(markdown, headingRe) {
|
|
@@ -60,11 +61,11 @@ function parseComponentsTable(section) {
|
|
|
60
61
|
return rows;
|
|
61
62
|
}
|
|
62
63
|
export function extractEstimate(markdown) {
|
|
63
|
-
const titleMatch =
|
|
64
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
64
65
|
if (!titleMatch) {
|
|
65
66
|
throw new Error('Input is not an estimate output (no "## Effort Estimate — ..." heading found)');
|
|
66
67
|
}
|
|
67
|
-
const title = titleMatch
|
|
68
|
+
const title = titleMatch.title || 'Effort Estimate';
|
|
68
69
|
const section = sliceSection(markdown, COMPONENTS_HEADING_RE);
|
|
69
70
|
if (!section) {
|
|
70
71
|
throw new Error('No "### Component Breakdown" section found');
|
|
@@ -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 ')}`);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
2
|
+
// 2026-09-17 — tolerant on heading level and dash. 2026-10-04 — and on the
|
|
3
|
+
// separator (colon / none), casing, bold and an "abap-spec-gap —" prefix: a
|
|
4
|
+
// live /abap-app run wrote "Spec Gap Analysis: …" and the chain stopped at
|
|
5
|
+
// step 1 because nothing was saved. See title-heading.ts.
|
|
6
|
+
const TITLE_SPEC = { phrase: String.raw `spec(?:ification)?[ \t-]*gap[ \t]+analysis`, skillPrefix: 'abap-spec-gap' };
|
|
4
7
|
const DOMAIN_RE = /^\*\*Inferred Domain:\*\*\s*(.+?)(?:\s*\[INFERRED.*?\])?$/m;
|
|
5
8
|
const INPUT_TYPE_RE = /^\*\*Input Type:\*\*\s*(.+)$/m;
|
|
6
9
|
const SOURCE_DOC_RE = /^\*\*Source Document:\*\*\s*(.+)$/m;
|
|
@@ -54,14 +57,16 @@ const SKIP_SECTIONS = [
|
|
|
54
57
|
'Recommended Next Steps', // is a paragraph
|
|
55
58
|
];
|
|
56
59
|
export function extractSpecGap(markdown) {
|
|
57
|
-
const titleMatch =
|
|
60
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
58
61
|
if (!titleMatch) {
|
|
59
62
|
throw new Error('Input is not a spec-gap output (no "## Spec Gap Analysis — ..." heading found)');
|
|
60
63
|
}
|
|
61
|
-
const title = titleMatch[1].trim();
|
|
62
64
|
const inputType = (INPUT_TYPE_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
|
|
63
65
|
const inferredDomain = (DOMAIN_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
|
|
64
66
|
const sourceDocument = SOURCE_DOC_RE.exec(markdown)?.[1].trim() || undefined;
|
|
67
|
+
// A bare "## Spec Gap Analysis" heading still names the artefact: fall back
|
|
68
|
+
// to the source document, then to a plain label.
|
|
69
|
+
const title = titleMatch.title || sourceDocument || 'Spec Gap Analysis';
|
|
65
70
|
const sectionStarts = [];
|
|
66
71
|
let m;
|
|
67
72
|
const reCopy = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
|
|
@@ -103,6 +108,24 @@ export function extractSpecGap(markdown) {
|
|
|
103
108
|
items,
|
|
104
109
|
};
|
|
105
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* True when the text has the body of a spec-gap result (a known gap section
|
|
113
|
+
* heading, or a numbered [BLOCKER]/[IMPORTANT]/[NICE TO KNOW] line). Tells a
|
|
114
|
+
* result that failed to save apart from a conversational turn (a question back
|
|
115
|
+
* to the user), which is not an artefact at all.
|
|
116
|
+
*/
|
|
117
|
+
export function looksLikeSpecGap(markdown) {
|
|
118
|
+
if (/^\s*\d+\.\s*(?:\*\*)?\[(?:BLOCKER|IMPORTANT|NICE TO KNOW)\]/m.test(markdown))
|
|
119
|
+
return true;
|
|
120
|
+
const re = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
123
|
+
const name = m[1].toLowerCase();
|
|
124
|
+
if (KNOWN_GAP_SECTIONS.some((k) => name.includes(k.toLowerCase())))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
106
129
|
function parseGapsFromSection(sectionBody, nextId) {
|
|
107
130
|
const out = [];
|
|
108
131
|
const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
|
|
@@ -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. */
|