@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.
Files changed (74) hide show
  1. package/dist/agent/loop.js +41 -5
  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/commands/whoami.js +1 -1
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/checks/skill.js +60 -11
  29. package/dist/doctor/run.js +4 -6
  30. package/dist/lane/app-chain.js +116 -9
  31. package/dist/one-shot.js +1 -0
  32. package/dist/projects/extract-design.js +4 -3
  33. package/dist/projects/extract-estimate.js +4 -3
  34. package/dist/projects/extract-plan.js +34 -1
  35. package/dist/projects/extract-spec-gap.js +28 -5
  36. package/dist/projects/handover-md.js +1 -1
  37. package/dist/projects/plan-objects.js +75 -0
  38. package/dist/projects/plan-run.js +74 -18
  39. package/dist/projects/plan-schema.js +6 -0
  40. package/dist/projects/plan-sources.js +199 -0
  41. package/dist/projects/promote-command.js +1 -1
  42. package/dist/projects/save-command.js +15 -14
  43. package/dist/projects/saved-lines.js +36 -0
  44. package/dist/projects/title-heading.js +36 -0
  45. package/dist/renderer/checklist-format.js +5 -5
  46. package/dist/renderer/machine-blocks.js +141 -0
  47. package/dist/renderer/thinking-heartbeat.js +2 -1
  48. package/dist/renderer/tool-labels.js +3 -0
  49. package/dist/repl/plan-hop.js +22 -0
  50. package/dist/repl/rule8-detector.js +16 -1
  51. package/dist/repl.js +320 -55
  52. package/dist/router/router-core.js +2 -2
  53. package/dist/sap/reachability-state.js +25 -0
  54. package/dist/sap/unreachable.js +4 -1
  55. package/dist/session/audit-export.js +10 -16
  56. package/dist/session/recap.js +16 -26
  57. package/dist/skills/bundled-skills.js +1 -1
  58. package/dist/tools/_filesystem-shared.js +37 -0
  59. package/dist/tools/approval.js +48 -2
  60. package/dist/tools/dispatch-skill.js +3 -2
  61. package/dist/tools/filesystem/file-read.js +5 -3
  62. package/dist/tools/filesystem/glob.js +27 -4
  63. package/dist/tools/fiori/tools.js +6 -0
  64. package/dist/tools/lane/app-lane-resolve.js +1 -1
  65. package/dist/tools/sap-read.js +37 -3
  66. package/dist/tools/sap-write.js +65 -0
  67. package/dist/tools/text-elements/pool.js +250 -0
  68. package/dist/tools/text-elements/tools.js +433 -0
  69. package/dist/tools/tool-loading.js +3 -0
  70. package/dist/tools/transport.js +26 -5
  71. package/dist/tools/write-mode.js +4 -3
  72. package/package.json +3 -4
  73. package/dist/doctor/checks/zcspeach.js +0 -96
  74. 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
@@ -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 = '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?';
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
- * After a plan save: when the saved plan carries `from.lane`, offer to start
253
- * the guarded run. Inside an active app flow the flow's lane must be the
254
- * plan's lane. A plan without `from.lane` → nothing.
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 lane = savedPlanLane(savedPath);
258
- if (!lane)
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 TITLE_RE = /^#{1,3} ABAP Design [—–-] (.+)$/m;
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 = TITLE_RE.exec(markdown);
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[1].trim();
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 TITLE_RE = /^#{1,3} Effort Estimate [—–-] (.+)$/m;
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 = TITLE_RE.exec(markdown);
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[1].trim();
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('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 ')}`);
@@ -1,6 +1,9 @@
1
- // 2026-09-17 — tolerant on heading level (H1..H3) and dash, same reason as
2
- // extract-design.ts: a title written one level off must not lose the file.
3
- const TITLE_RE = /^#{1,3} Spec Gap Analysis [—–-] (.+)$/m;
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 = TITLE_RE.exec(markdown);
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
- 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. */