@cspeach/cli 1.1.27 → 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.
Files changed (65) hide show
  1. package/dist/agent/loop.js +33 -4
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/config/loader.js +9 -0
  26. package/dist/doctor/checks/forge-rules.js +67 -85
  27. package/dist/doctor/run.js +4 -4
  28. package/dist/lane/app-chain.js +88 -9
  29. package/dist/one-shot.js +1 -0
  30. package/dist/projects/extract-plan.js +34 -1
  31. package/dist/projects/handover-md.js +1 -1
  32. package/dist/projects/plan-objects.js +75 -0
  33. package/dist/projects/plan-run.js +74 -18
  34. package/dist/projects/plan-schema.js +6 -0
  35. package/dist/projects/plan-sources.js +199 -0
  36. package/dist/projects/promote-command.js +1 -1
  37. package/dist/projects/save-command.js +11 -13
  38. package/dist/projects/saved-lines.js +36 -0
  39. package/dist/renderer/checklist-format.js +5 -5
  40. package/dist/renderer/machine-blocks.js +141 -0
  41. package/dist/renderer/tool-labels.js +3 -0
  42. package/dist/repl/plan-hop.js +22 -0
  43. package/dist/repl/rule8-detector.js +16 -1
  44. package/dist/repl.js +320 -55
  45. package/dist/router/router-core.js +2 -2
  46. package/dist/sap/reachability-state.js +25 -0
  47. package/dist/session/audit-export.js +10 -16
  48. package/dist/session/recap.js +16 -26
  49. package/dist/skills/bundled-skills.js +1 -1
  50. package/dist/tools/_filesystem-shared.js +37 -0
  51. package/dist/tools/approval.js +48 -2
  52. package/dist/tools/dispatch-skill.js +3 -2
  53. package/dist/tools/filesystem/file-read.js +5 -3
  54. package/dist/tools/filesystem/glob.js +27 -4
  55. package/dist/tools/fiori/tools.js +6 -0
  56. package/dist/tools/lane/app-lane-resolve.js +1 -1
  57. package/dist/tools/sap-read.js +37 -3
  58. package/dist/tools/sap-write.js +65 -0
  59. package/dist/tools/text-elements/pool.js +250 -0
  60. package/dist/tools/text-elements/tools.js +433 -0
  61. package/dist/tools/tool-loading.js +3 -0
  62. package/dist/tools/transport.js +26 -5
  63. package/dist/tools/write-mode.js +4 -3
  64. package/package.json +120 -121
  65. package/dist/projects/email-template.js +0 -56
@@ -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 = 'Start building now? It runs every phase and stops only for your answers, SAP approvals or a failed check.';
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: when the saved plan carries `from.lane`, offer to start
281
- * the guarded run. Inside an active app flow the flow's lane must be the
282
- * plan's lane. A plan without `from.lane` → nothing.
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 lane = savedPlanLane(savedPath);
286
- if (!lane)
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('No <!-- csforge:plan-manifest --> block found in skill output');
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
- export function computeNextPhase(phases, statuses) {
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 (isPhaseSatisfied(s, p.audit) || s === 'blocked')
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) => isPhaseSatisfied(statuses[dep], byId.get(dep)?.audit)))
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
- function phaseTitle(p) {
127
- // C2 (2026-06-11, D29): an explicit seed-time title wins — the seed prose
128
- // instructs short human names ("Design", "Tables", "Service"). The label
129
- // maps remain the fallback for envelopes seeded before titles existed.
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
- if (explicit)
132
- return explicit;
133
- return DELEGATE_LABELS[p.delegateTo] ?? LAYER_LABELS[p.layer] ?? tidyId(p.id);
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
- case 'pending':
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.id} passed with ${notes.length} note${notes.length === 1 ? '' : 's'}: ${sanitizeControlChars(notes.join('; '))}.`
334
- : `${g.attention} ${p.id} passed with notes.`);
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.id} waived${cat}${reason}.`);
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.id} was NOT audit-verified (audit infrastructure failed — its own exit gate, activation + ATC, passed).`);
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 (Step 10, fiori_deploy) or record why not.`);
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. */