@cspeach/cli 1.1.27 → 1.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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/agent/turn-error-ux.js +40 -0
  9. package/dist/approvals/grant-claims.js +70 -0
  10. package/dist/approvals/jwt.js +21 -1
  11. package/dist/approvals/plan-grant.js +297 -0
  12. package/dist/commands/config-set.js +12 -0
  13. package/dist/commands/deploy-card.js +10 -5
  14. package/dist/commands/export-audit.js +1 -1
  15. package/dist/commands/plan-audit-evidence.js +47 -1
  16. package/dist/commands/plan-audit.js +23 -8
  17. package/dist/commands/plan-batch-audit.js +349 -0
  18. package/dist/commands/plan-boundary.js +88 -0
  19. package/dist/commands/plan-chain.js +409 -566
  20. package/dist/commands/plan-continue.js +21 -7
  21. package/dist/commands/plan-gate.js +28 -49
  22. package/dist/commands/plan-reground.js +174 -0
  23. package/dist/commands/plan-resume.js +227 -103
  24. package/dist/commands/plan-sap-pause.js +105 -0
  25. package/dist/commands/plan-startup.js +218 -0
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/run.js +4 -4
  29. package/dist/lane/app-chain.js +88 -9
  30. package/dist/one-shot.js +1 -0
  31. package/dist/projects/extract-plan.js +34 -1
  32. package/dist/projects/handover-md.js +1 -1
  33. package/dist/projects/plan-objects.js +75 -0
  34. package/dist/projects/plan-run.js +74 -18
  35. package/dist/projects/plan-schema.js +6 -0
  36. package/dist/projects/plan-sources.js +199 -0
  37. package/dist/projects/promote-command.js +1 -1
  38. package/dist/projects/save-command.js +11 -13
  39. package/dist/projects/saved-lines.js +36 -0
  40. package/dist/renderer/checklist-format.js +5 -5
  41. package/dist/renderer/machine-blocks.js +141 -0
  42. package/dist/renderer/tool-labels.js +3 -0
  43. package/dist/repl/plan-hop.js +22 -0
  44. package/dist/repl/rule8-detector.js +16 -1
  45. package/dist/repl.js +320 -55
  46. package/dist/router/router-core.js +2 -2
  47. package/dist/sap/reachability-state.js +25 -0
  48. package/dist/session/audit-export.js +10 -16
  49. package/dist/session/recap.js +16 -26
  50. package/dist/skills/bundled-skills.js +81 -81
  51. package/dist/tools/_filesystem-shared.js +37 -0
  52. package/dist/tools/approval.js +48 -2
  53. package/dist/tools/dispatch-skill.js +3 -2
  54. package/dist/tools/filesystem/file-read.js +5 -3
  55. package/dist/tools/filesystem/glob.js +27 -4
  56. package/dist/tools/fiori/tools.js +6 -0
  57. package/dist/tools/lane/app-lane-resolve.js +1 -1
  58. package/dist/tools/sap-read.js +37 -3
  59. package/dist/tools/sap-write.js +65 -0
  60. package/dist/tools/text-elements/pool.js +250 -0
  61. package/dist/tools/text-elements/tools.js +433 -0
  62. package/dist/tools/tool-loading.js +3 -0
  63. package/dist/tools/transport.js +26 -5
  64. package/dist/tools/write-mode.js +4 -3
  65. package/dist/ui/app.js +4 -0
  66. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  67. package/package.json +120 -121
  68. package/dist/projects/email-template.js +0 -56
@@ -38,18 +38,22 @@
38
38
  // that survives the session, so losing the write-back breaks the plan.
39
39
  import { readFileSync, readdirSync, statSync } from 'node:fs';
40
40
  import { basename, dirname, join } from 'node:path';
41
+ import { savedLocation } from '../projects/saved-lines.js';
41
42
  import chalk from 'chalk';
42
43
  import { readProjectFile } from '../projects/status.js';
43
44
  import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../projects/workspace.js';
44
45
  import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
45
- import { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied, isAuditResolved, planCompletionLines, findServiceBinding, } from '../projects/plan-run.js';
46
- import { extractPlan } from '../projects/extract-plan.js';
46
+ import { statusesFromItems, computeNextPhase, computeNextPhaseForRun, pendingAuditPhaseIds, renderPlanTracker, renderPlanProgressLine, buildPlanRevision, isPhaseSatisfied, isAuditResolved, planCompletionLines, findServiceBinding, phaseDisplayTitle, } from '../projects/plan-run.js';
47
+ import { extractPlan, NO_PLAN_MANIFEST_MESSAGE } from '../projects/extract-plan.js';
48
+ import { recordFullNotice } from '../renderer/notice-log.js';
49
+ import { isSapUnreachableNoted } from '../sap/reachability-state.js';
47
50
  import { saveProject } from '../projects/save.js';
48
51
  import { writePlanHandover } from '../projects/handover-md.js';
49
52
  import { acquireRunLease, releaseRunLease } from '../projects/run-lease.js';
50
53
  import { validateEnvelope } from '../projects/validate.js';
51
54
  import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
52
55
  import { getAuthorIdentity } from '../agent/loop.js';
56
+ import { DEFAULT_PLAN_MODE } from './plan-gate.js';
53
57
  import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
54
58
  import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
55
59
  import { askYesNo, isYes } from '../projects/yes-no.js';
@@ -58,6 +62,9 @@ import { checkLaneConsistency, checkRequiredDeploy, isDeployPhase, loadPlanLaneF
58
62
  import { askDeployCard } from './deploy-card.js';
59
63
  import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.js';
60
64
  import { FIORI_DEPLOY_HANDOVER_SCOPE } from '../tools/fiori/deploy/scope.js';
65
+ import { resolvePlanSources, renderPlanSourcesBlock } from '../projects/plan-sources.js';
66
+ import { regroundPlan } from './plan-reground.js';
67
+ import { PAUSED_LINE, makeSapProbe, pauseUntilSapReachable, promptLockedChoice, resumeCommandFor } from './plan-sap-pause.js';
61
68
  /**
62
69
  * Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
63
70
  * audit off-switch (`--audit` / `--no-audit`) flags from a plan-resume body.
@@ -102,15 +109,18 @@ export function parsePlanResumeFlags(body) {
102
109
  return { mode, audit, body: rest.join(' ') };
103
110
  }
104
111
  /**
105
- * A1 (defect D23) — the harness-owned continuation contract, stated to the
106
- * model verbatim in every resume prompt: the manifest ends the turn; the
107
- * model must not call dispatch_skill or ask a continuation question — the
108
- * CLI dispatches the next phase itself. Exported as a named constant so
109
- * tests pin the prompt to this exact block instead of brittle prose
110
- * regexes (the old contract let the model keep executing phases in-turn —
111
- * a 6-phase run once snowballed to a 3.24M-token context).
112
+ * A1 (defect D23), rewritten 2026-10-04 (continuous run) — the harness owns
113
+ * continuation. The manifest still ends the turn; CSPeach saves the step,
114
+ * summarises the conversation and runs the next step itself in the same
115
+ * session. The model never authors continuation.
112
116
  */
113
- export const PLAN_RESUME_HARNESS_OVERRIDE = 'HARNESS OVERRIDE of the skill\'s Mode 2 step 8: do NOT ask a continuation question, do NOT call dispatch_skill, and NEVER start another phase in this turn. After your manifest is persisted, the CLI itself asks the user whether to run the next phase and dispatches it with the exact saved file in a fresh bounded context. Phase end = turn end.';
117
+ export const PLAN_RESUME_HARNESS_OVERRIDE = 'HARNESS OVERRIDE of the skill\'s Mode 2 step 8: do NOT ask a continuation question, do NOT call dispatch_skill, and never start another step in this turn. '
118
+ + 'When your manifest is saved, CSPeach runs the next step itself. Step end = turn end. '
119
+ + 'Never show the user step ids, skill names, file paths under .cspeach/, gap ids or envelope field names — use the step titles and plain words.';
120
+ /** The first instruction line of every phase prompt. */
121
+ export function PLAN_PHASE_INSTRUCTION(id, title) {
122
+ return `Execute Mode 2 of the abap-plan skill for step "${title}" (phase id "${id}") ONLY — it is the computed next eligible step. Never execute any other step in this turn; CSPeach runs the next one after this one is saved.`;
123
+ }
114
124
  /** Harness-owned ui-phase context (Track 1). Same principle as
115
125
  * buildResumeCommand (D23): the model must never have to recall the
116
126
  * binding name, flavor, or app dir from a prior session — the envelope
@@ -297,7 +307,7 @@ export async function preparePlanResume(args) {
297
307
  args.log('Run lease taken over — resuming in this session.');
298
308
  }
299
309
  }
300
- const effectiveMode = flags.mode ?? args.configMode ?? 'step';
310
+ const effectiveMode = flags.mode ?? args.configMode ?? DEFAULT_PLAN_MODE;
301
311
  // Audit off-switch (2026-07-06, owner): per-run flag > config default > 'on'.
302
312
  const effectiveAudit = flags.audit ?? args.configAudit ?? 'on';
303
313
  // Explicit --no-audit is a strong, deliberate signal — it is the ONLY thing
@@ -330,10 +340,15 @@ export async function preparePlanResume(args) {
330
340
  // M5 — name every waived phase, and flag a FAILED-with-findings record
331
341
  // loudly: waiving a recorded failure is a bigger call than waiving a
332
342
  // never-judged pending, and the user must see which one they made.
333
- const names = unresolved.map((p) => p.audit?.state === 'failed'
334
- ? `${p.id} (FAILED audit — ${p.audit.findings?.length ?? 0} finding${(p.audit.findings?.length ?? 0) === 1 ? '' : 's'} waived)`
335
- : `${p.id} (${p.audit?.state ?? 'unresolved'})`);
336
- args.log(formatInfo(`audits off (--no-audit): waived ${unresolved.length} unresolved audit${unresolved.length === 1 ? '' : 's'} from an earlier run — ${names.join(', ')} (reason: audits turned off).`).trimEnd());
343
+ // Task 13 — step titles, never phase ids.
344
+ const names = unresolved.map((p) => {
345
+ const t = phaseDisplayTitle(p, content.phases);
346
+ const n = p.audit?.findings?.length ?? 0;
347
+ return p.audit?.state === 'failed'
348
+ ? `${t} (FAILED its check — ${n} finding${n === 1 ? '' : 's'} waived)`
349
+ : `${t} (not checked yet)`;
350
+ });
351
+ args.log(formatInfo(`Checks are off for this run · waived ${unresolved.length} open check${unresolved.length === 1 ? '' : 's'} from an earlier run: ${names.join(', ')}`).trimEnd());
337
352
  }
338
353
  }
339
354
  // Task 6 — hand-edit detection: the newest history entry was NOT authored
@@ -355,39 +370,37 @@ export async function preparePlanResume(args) {
355
370
  ? { ...p, audit: { ...(p.audit ?? {}), state: 'not_audited_hand_edited' } }
356
371
  : p),
357
372
  };
358
- args.log(formatAttention({ text: `Plan was last edited outside CSPeach (by ${lastHistory?.by?.name ?? 'unknown'})`, fact: 'satisfied phases without an audit show as hand-edited · file not rewritten' }).trimEnd());
373
+ // 1.1.28 — no "(by <name>)": history names can be the OS account name.
374
+ args.log(formatAttention({ text: 'Plan was last edited outside CSPeach', fact: 'satisfied phases without an audit show as hand-edited · file not rewritten' }).trimEnd());
359
375
  }
360
- // Task 6 — re-audit-on-resume: an unresolved persisted audit (a crash
361
- // between phase save and verdict save leaves 'pending'; an auditor outage
362
- // leaves 'infra_failed') is re-run FIRST — no new phase executes this
363
- // dispatch. Scan newest-last so the most recent execution is re-judged.
364
- // Fix-wave (item 10a): a persisted 'failed' on a phase that still CLAIMS
365
- // satisfied means the session died AT the failure menu (the verdict wrote,
366
- // the resolution never did) — re-audit it and re-offer the menu instead of
367
- // dead-ending in the "resolve by hand" message. A failed audit on a
368
- // non-satisfied phase (forced block / user chose re-run) was already
369
- // resolved and is NOT picked up.
376
+ // Task 6 — re-audit-on-resume. 2026-10-04 (continuous run): audits are
377
+ // deferred to the END of a run, so a 'pending' written by THIS session is
378
+ // normal mid-run state — it is left for the end-of-run batch and does not
379
+ // block the next step (computeNextPhaseForRun). A 'pending' from ANOTHER
380
+ // session (or with none recorded — a 1.1.27 file) was left by a run that
381
+ // died: those are checked first, all of them, in plan order, before any new
382
+ // step. Fix-wave (item 10a): a persisted 'failed' on a phase that still
383
+ // CLAIMS satisfied means the session died AT the failure menu — re-audit it
384
+ // and re-offer the menu. A failed audit on a non-satisfied phase (forced
385
+ // block / user chose re-run) was already resolved and is NOT picked up.
386
+ // audit-infra-continue (2026-07-09): 'infra_failed' is RESOLVED and never
387
+ // re-audited here.
370
388
  // Audit off-switch: under an explicit --no-audit the persisted unresolved
371
389
  // records were already waived above, so this scan finds nothing anyway; the
372
390
  // guard keeps the intent explicit. Under config-off the persisted scan STILL
373
391
  // runs (a stuck pending must resolve — config-off governs only NEW phases),
374
392
  // but the guarded HAND-EDIT re-verification (itself an audit pass) does not.
375
393
  let reauditPhaseIds = [];
394
+ let reauditForce = false;
376
395
  if (!auditsExplicitlyOff) {
377
- for (let i = content.phases.length - 1; i >= 0; i--) {
378
- const p = content.phases[i];
396
+ reauditPhaseIds = content.phases
397
+ .filter((p) => {
379
398
  const st = p.audit?.state;
380
- // audit-infra-continue (2026-07-09): 'infra_failed' is NO LONGER scanned
381
- // for re-audit — it is a RESOLVED state (the audit could not run; the
382
- // chain already continued past it). Re-auditing it here would wall an
383
- // already-continued phase on a later resume. Only a crash-left 'pending'
384
- // or a died-at-menu 'failed' on a still-satisfied phase re-audits.
385
- if (st === 'pending'
386
- || (st === 'failed' && isPhaseSatisfied(statuses[p.id]))) {
387
- reauditPhaseIds = [p.id];
388
- break;
389
- }
390
- }
399
+ if (st === 'failed')
400
+ return isPhaseSatisfied(statuses[p.id]);
401
+ return st === 'pending' && p.audit?.sessionId !== args.sessionId;
402
+ })
403
+ .map((p) => p.id);
391
404
  // Guarded hand-edit re-verification (review I-2): no persisted unresolved
392
405
  // audit, but the envelope was hand-touched — re-audit EVERY satisfied phase
393
406
  // the hand edit invalidated (plan order: dependencies first), in one pass
@@ -396,28 +409,40 @@ export async function preparePlanResume(args) {
396
409
  if (reauditPhaseIds.length === 0 && handEdited && effectiveMode === 'guarded'
397
410
  && effectiveAudit === 'on') {
398
411
  reauditPhaseIds = content.phases.filter(handEditDowngraded).map((p) => p.id);
412
+ reauditForce = reauditPhaseIds.length > 0;
399
413
  }
400
414
  }
415
+ let next = reauditPhaseIds.length > 0 ? null : computeNextPhaseForRun(content.phases, statuses);
416
+ // No step left but this session's deferred audits remain: this IS the end of
417
+ // the run — check them now instead of printing "No eligible phase".
418
+ if (reauditPhaseIds.length === 0 && !next && !auditsExplicitlyOff) {
419
+ reauditPhaseIds = pendingAuditPhaseIds(content);
420
+ }
401
421
  const reauditPhaseId = reauditPhaseIds[0];
402
- const next = reauditPhaseId ? null : computeNextPhase(content.phases, statuses);
422
+ if (reauditPhaseId)
423
+ next = null;
403
424
  args.log('');
404
- args.log(renderPlanTracker({
405
- title: envelope.title,
406
- version: envelope.version,
407
- content: trackerContent,
408
- statuses,
409
- currentId: reauditPhaseId ?? next?.id ?? null,
410
- auditsOff: effectiveAudit === 'off',
411
- }));
425
+ // Task 13 — an in-run hop prints one progress line; the first hop the board.
426
+ args.log(args.boardStyle === 'line'
427
+ ? renderPlanProgressLine({ content: trackerContent, statuses, currentId: reauditPhaseId ?? next?.id ?? null })
428
+ : renderPlanTracker({
429
+ title: envelope.title,
430
+ version: envelope.version,
431
+ content: trackerContent,
432
+ statuses,
433
+ currentId: reauditPhaseId ?? next?.id ?? null,
434
+ auditsOff: effectiveAudit === 'off',
435
+ }));
412
436
  args.log('');
413
437
  if (reauditPhaseId) {
414
438
  const reauditPhase = content.phases.find((p) => p.id === reauditPhaseId);
415
439
  const st = reauditPhase.audit?.state;
440
+ const reTitle = phaseDisplayTitle(reauditPhase, content.phases);
416
441
  args.log(st === 'failed'
417
- ? `Phase ${reauditPhaseId} has a failed audit with no recorded resolution — re-running the audit, then offering re-run / waive / block.`
418
- : st === 'pending'
419
- ? `Phase ${reauditPhaseId} has an unresolved audit (${st}) — re-running the audit before executing anything.`
420
- : `Guarded mode: re-auditing ${reauditPhaseIds.length} hand-edited phase${reauditPhaseIds.length === 1 ? '' : 's'} (${reauditPhaseIds.join(', ')}) before chaining.`);
442
+ ? `${reTitle} failed its independent check and no choice was recorded — checking it again, then asking what to do.`
443
+ : reauditForce
444
+ ? `The plan file was edited outside CSPeach — checking ${reauditPhaseIds.length} step${reauditPhaseIds.length === 1 ? '' : 's'} independently before continuing.`
445
+ : `Checking ${reauditPhaseIds.length} finished step${reauditPhaseIds.length === 1 ? '' : 's'} independently before continuing.`);
421
446
  return {
422
447
  path, envelope, statuses,
423
448
  nextPhaseId: reauditPhaseId,
@@ -429,10 +454,16 @@ export async function preparePlanResume(args) {
429
454
  auditMode: effectiveAudit,
430
455
  reauditPhaseId,
431
456
  reauditPhaseIds,
457
+ reauditForce,
432
458
  ...(laneRecord ? { laneRecord } : {}),
433
459
  };
434
460
  }
435
461
  if (!next) {
462
+ // Task 13 — titles on screen, never phase ids.
463
+ const titleOfId = (id) => {
464
+ const p = content.phases.find((q) => q.id === id);
465
+ return p ? phaseDisplayTitle(p, content.phases) : id;
466
+ };
436
467
  // C1 (D30 waiver): 'validated-with-waiver' is satisfied — a plan whose
437
468
  // last gate was explicitly waived is complete, not stuck.
438
469
  const allValidated = content.phases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit));
@@ -443,7 +474,7 @@ export async function preparePlanResume(args) {
443
474
  args.log(...planCompletionLines(content.phases));
444
475
  }
445
476
  else {
446
- const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
477
+ const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => titleOfId(p.id));
447
478
  // Task 2 review carry-over: a validated-but-audit-unresolved phase used
448
479
  // to surface as "Blocked: (none)" — name the real block reason. Rarely
449
480
  // reached since the fix-wave: pending/infra_failed AND satisfied-but-
@@ -451,12 +482,12 @@ export async function preparePlanResume(args) {
451
482
  // authored state (e.g. a user writing 'not_audited_hand_edited').
452
483
  const unresolved = content.phases
453
484
  .filter((p) => isPhaseSatisfied(statuses[p.id]) && !isAuditResolved(p.audit))
454
- .map((p) => `${p.id} (audit ${p.audit?.state ?? 'unknown'})`);
455
- const unresolvedNote = unresolved.length > 0 ? ` Unresolved audits: ${unresolved.join(', ')}.` : '';
456
- args.log(`No eligible phase. Blocked: ${blocked.join(', ') || '(none)'}.${unresolvedNote} Remaining phases wait on them.`);
485
+ .map((p) => `${titleOfId(p.id)} (check ${p.audit?.state ?? 'unknown'})`);
486
+ const unresolvedNote = unresolved.length > 0 ? ` Open checks: ${unresolved.join(', ')}.` : '';
487
+ args.log(`Nothing can run yet. Blocked: ${blocked.join(', ') || '(none)'}.${unresolvedNote} The remaining steps wait on them.`);
457
488
  args.log(unresolved.length > 0
458
- ? 'Resolve the unresolved audit (waive it or set the phase status back to todo for a re-run) and resume again.'
459
- : 'Unblock (fix + edit the envelope status back to todo) and resume again.');
489
+ ? 'Settle the open check (waive it, or mark the step to run again), then continue the plan.'
490
+ : 'Fix the blocked step (mark it to run again), then continue the plan.');
460
491
  }
461
492
  // Task 9 — terminal state: nothing will run this dispatch, so don't
462
493
  // leave a lease behind (a completed/deadlocked plan must be freely
@@ -484,13 +515,60 @@ export async function preparePlanResume(args) {
484
515
  });
485
516
  if (card.outcome !== 'yes') {
486
517
  args.log(card.outcome === 'blocked'
487
- ? `${next.id} cannot run here: ${card.reason}. Resume later with: ${buildResumeCommand(path)}`
518
+ ? `${phaseDisplayTitle(next, content.phases)} cannot run here: ${card.reason}. Resume later with: ${buildResumeCommand(path)}`
488
519
  : `Not deployed — resume later with: ${buildResumeCommand(path)}`);
489
520
  if (args.sessionId)
490
521
  releaseRunLease(path);
491
522
  return null;
492
523
  }
493
524
  }
525
+ // 2026-10-04 (continuous run, spec §3.5) — re-check SAP before the step:
526
+ // what this plan built, what a dead run left inactive (a stale lock is
527
+ // waited out, then reported), and whether the transport is still open.
528
+ let sapStateBlock = [];
529
+ if (args.reground) {
530
+ const regroundDeps = args.reground;
531
+ const canAsk = args.interactive === true && !!args.prompt;
532
+ const resumeCommand = resumeCommandFor({ path, mode: flags.mode, auditFlag: flags.audit });
533
+ for (;;) {
534
+ const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log });
535
+ if (r.ok) {
536
+ // An in-run hop stays quiet unless something needs attention.
537
+ if (!r.quiet || args.boardStyle !== 'line')
538
+ args.log(formatInfo(r.summary).trimEnd());
539
+ sapStateBlock = r.sapStateBlock;
540
+ break;
541
+ }
542
+ if (r.reason === 'unreachable') {
543
+ // The shared pause: spec line, Enter retries once SAP answers, stop pauses.
544
+ const back = await pauseUntilSapReachable({
545
+ probe: makeSapProbe(regroundDeps),
546
+ prompt: args.prompt ?? (async () => 'stop'),
547
+ interactive: canAsk,
548
+ log: args.log,
549
+ resumeCommand,
550
+ });
551
+ if (back)
552
+ continue;
553
+ }
554
+ else {
555
+ args.log('', ...r.lines);
556
+ // A lock that did not clear never dead-ends the plan: the user may
557
+ // go on, and the step meets the lock itself (its <sap_state> names it).
558
+ const choice = canAsk ? await promptLockedChoice(args.prompt) : 'stop';
559
+ if (choice === 'retry')
560
+ continue;
561
+ if (choice === 'continue') {
562
+ sapStateBlock = r.sapStateBlock;
563
+ break;
564
+ }
565
+ args.log(PAUSED_LINE);
566
+ }
567
+ if (args.sessionId)
568
+ releaseRunLease(path);
569
+ return null;
570
+ }
571
+ }
494
572
  // Inline the phase's declared rule files (coarse v1: whole files).
495
573
  // Paths resolve against cwd — dogfood runs sit inside a repo carrying
496
574
  // .claude/rules/. A missing file is noted, not fatal: the skill falls
@@ -523,7 +601,7 @@ export async function preparePlanResume(args) {
523
601
  // Mode 1, item 7a); resume's `changed.work` merge never re-authors it and
524
602
  // extractPlan preserves existing phase fields.
525
603
  const compactExample = [
526
- '<!-- csforge:plan-manifest',
604
+ '<!-- cspeach:plan-manifest',
527
605
  JSON.stringify({
528
606
  title: envelope.title,
529
607
  statuses: { ...statuses, [next.id]: '<validated | validated-with-waiver | blocked>' },
@@ -551,15 +629,21 @@ export async function preparePlanResume(args) {
551
629
  // '' already ends the prior element). Spread the blank AFTER the block so
552
630
  // context keeps single-blank separation on both sides.
553
631
  const uiContext = buildUiPhaseContext(content.phases, next);
632
+ // 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
633
+ // from the file the plan was made from (or the plan's creation snapshot).
634
+ // A phase must never have to hunt the folder for its gap answers.
635
+ const sourcesBlock = renderPlanSourcesBlock(await resolvePlanSources({ cwd: args.cwd, envelope, content, ...(laneRecord ? { laneRecord } : {}) }), next);
554
636
  const llmPrompt = [
555
637
  `Resume execution of the project plan "${envelope.title}" (envelope v${envelope.version}).`,
556
638
  '',
557
- `Execute Mode 2 of the abap-plan skill for phase "${next.id}" ONLY — it is the computed next eligible phase. Never execute any other phase in this turn; the CLI itself offers and dispatches the next phase (in a fresh bounded context) after this one is persisted.`,
639
+ PLAN_PHASE_INSTRUCTION(next.id, phaseDisplayTitle(next, content.phases)),
558
640
  '',
559
641
  '<plan_state>',
560
642
  planState,
561
643
  '</plan_state>',
562
644
  '',
645
+ ...(sourcesBlock.length ? [...sourcesBlock, ''] : []),
646
+ ...(sapStateBlock.length ? [...sapStateBlock, ''] : []),
563
647
  '<phase_rules>',
564
648
  ...ruleBlocks,
565
649
  '</phase_rules>',
@@ -573,7 +657,7 @@ export async function preparePlanResume(args) {
573
657
  // ended. The manifest now ENDS the turn; the harness owns continuation
574
658
  // (offerNextPhaseAutoRun) and dispatches the next phase itself with
575
659
  // the exact saved path, in reset context.
576
- 'CRITICAL — the write-back ENDS the turn: emit ONE COMPACT <!-- csforge:plan-manifest --> block as the LAST thing in your output, then END THE TURN. Compact shape = "title" + a "statuses" entry for EVERY phase + a "changed" map carrying ONLY the phase(s) you touched this turn (normally exactly this one). Each "changed" entry holds the phase\'s COMPLETE updated "work" (generated / transport / snapshot / notes — plus, on ui phases: app.dir + service{url,path,version} (ui.build) and deployedUrl (ui.deploy) — put the compact decision register in work.notes when the phase produced decisions rather than SAP objects); it replaces that phase\'s prior work wholesale. Do NOT re-emit "content" or the full phase list — the CLI already holds the full plan, merges your "changed" entries into it, and recomputes the summary. Exact shape for this turn:',
660
+ 'CRITICAL — the write-back ENDS the turn: emit ONE COMPACT <!-- cspeach:plan-manifest --> block as the LAST thing in your output, then END THE TURN. Compact shape = "title" + a "statuses" entry for EVERY phase + a "changed" map carrying ONLY the phase(s) you touched this turn (normally exactly this one). Each "changed" entry holds the phase\'s COMPLETE updated "work" (generated / transport / snapshot / notes — plus, on ui phases: app.dir + service{url,path,version} (ui.build) and deployedUrl (ui.deploy) — put the compact decision register in work.notes when the phase produced decisions rather than SAP objects); it replaces that phase\'s prior work wholesale. Do NOT re-emit "content" or the full phase list — CSPeach already holds the full plan, merges your "changed" entries into it, and recomputes the summary. Exact shape for this turn:',
577
661
  '',
578
662
  compactExample,
579
663
  '',
@@ -582,7 +666,7 @@ export async function preparePlanResume(args) {
582
666
  // "validated" the gate never earned (the c1.test AUnit-gap incident).
583
667
  'Status rules: "validated" ONLY when the exit gate actually held and was verified. If the gate could NOT be met but the user EXPLICITLY waived it this turn (e.g. "skip the test gate, continue anyway"), use "validated-with-waiver" and record what was waived and why in work.notes — never mark an unmet gate "validated", and never use the waiver status without an explicit user waiver. Otherwise the phase is "blocked".',
584
668
  '',
585
- 'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — the CLI accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
669
+ 'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — CSPeach accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
586
670
  '',
587
671
  ...(uiContext.length ? [...uiContext, ''] : []),
588
672
  PLAN_RESUME_HARNESS_OVERRIDE,
@@ -616,6 +700,15 @@ export function recordedPlanTransport(phases) {
616
700
  }
617
701
  return found;
618
702
  }
703
+ /**
704
+ * The transport an object-list approval binds and its card shows: the plan's
705
+ * recorded transport, else the session's current transport, else none (the
706
+ * card then says "a new one for this build" and only the grant's own new
707
+ * transport can be bound later).
708
+ */
709
+ export function planBuildTransport(phases) {
710
+ return recordedPlanTransport(phases) ?? getCurrentTransport() ?? undefined;
711
+ }
619
712
  /**
620
713
  * On a plan resume, adopt the envelope's recorded dedicated transport into the
621
714
  * session so the `tr <TRKORR>` status segment shows it (setCurrentTransport
@@ -673,6 +766,24 @@ export function isPlanResumeCommand(cmd) {
673
766
  const c = cmd.trim();
674
767
  return /^\/abap-plan\b/.test(c) && /(^|\s)--resume(\s|$)/.test(c);
675
768
  }
769
+ /** The "Auto-routed: <cmd>" echo — null for a plan hop (the chain already printed "Next: <title>"). */
770
+ export function autoRoutedNotice(cmd) {
771
+ return isPlanResumeCommand(cmd) ? null : `Auto-routed: ${cmd}`;
772
+ }
773
+ /** Task 13 — the one message for a step result that could not be saved. */
774
+ function logNotSaved(args, why, detail) {
775
+ // Task 13 fix round — the raw reason (marker names, phase ids, parser text)
776
+ // goes to the session's log file for support, never to the screen.
777
+ recordFullNotice('plan', `step result not saved (${args.prepared.nextPhaseId}): ${detail}`);
778
+ // 2026-10-04 (F22) — SAP went unreachable during the step: the REPL's pause
779
+ // line ("press Enter to retry") is the one instruction on screen.
780
+ if (isSapUnreachableNoted())
781
+ return;
782
+ args.log('');
783
+ args.log(`The step's result was not saved — ${why}`);
784
+ args.log('The plan file is unchanged. Check what the step built in SAP (transport, active objects), then try again:');
785
+ args.log(` ${buildResumeCommand(args.prepared.path)}`);
786
+ }
676
787
  /**
677
788
  * A1 (defect D23) — the HARNESS builds the next-phase resume command from
678
789
  * the exact path finishPlanResume just saved. Single construction site:
@@ -704,19 +815,20 @@ export function resetContextForPlanResume(session) {
704
815
  export async function offerNextPhaseAutoRun(args) {
705
816
  if (!args.outcome.nextPhaseId)
706
817
  return false;
707
- const delegate = args.outcome.nextDelegateTo ? ` (${args.outcome.nextDelegateTo})` : '';
818
+ // F8 (revised) — never a raw phase id on screen: no title, no name in the question.
819
+ const title = args.outcome.nextTitle;
708
820
  if (args.gate?.action === 'auto') {
709
- args.log(formatInfo(`Auto-running next phase ${args.outcome.nextPhaseId}${delegate} · guarded mode: declared non-write phase, prior audit clean.`).trimEnd());
821
+ args.log(formatInfo(title ? `Next: ${title}` : 'Next step').trimEnd());
710
822
  args.queueDispatch(args.outcome.resumeCommand);
711
823
  return true;
712
824
  }
713
825
  const answer = await askYesNo({
714
- question: `Run next phase ${args.outcome.nextPhaseId}${delegate} now in a fresh context?`,
826
+ question: title ? `Run the next step now: ${title}?` : 'Run the next step now?',
715
827
  confirm: args.confirm,
716
828
  prompt: args.prompt,
717
829
  });
718
830
  if (!isYes(answer)) {
719
- args.log(`Exiting — resume later with: ${args.outcome.resumeCommand}`);
831
+ args.log(`Stopped here. To continue later, open CSPeach in this folder and choose Continue (or type ${args.outcome.resumeCommand}).`);
720
832
  return false;
721
833
  }
722
834
  args.queueDispatch(args.outcome.resumeCommand);
@@ -768,9 +880,12 @@ export function findNewestVersion(path, picked) {
768
880
  export async function finishPlanResume(args) {
769
881
  const text = args.textOverride ?? collectTurnAssistantText(args.messages, args.messagesStart);
770
882
  if (text.trim().length === 0) {
883
+ // 2026-10-04 (F22) — SAP unreachable: the pause line takes over.
884
+ if (isSapUnreachableNoted())
885
+ return null;
771
886
  args.log('');
772
- args.log('[plan] turn produced no assistant output — plan envelope UNCHANGED.');
773
- args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
887
+ args.log('This step produced no output — the plan file is unchanged.');
888
+ args.log(`To try again: ${buildResumeCommand(args.prepared.path)}`);
774
889
  return null;
775
890
  }
776
891
  let extract;
@@ -788,11 +903,9 @@ export async function finishPlanResume(args) {
788
903
  // Loud by design: the phase may have built real SAP objects, but the
789
904
  // envelope write-back failed — the user must know state and file have
790
905
  // diverged before the next resume.
791
- args.log('');
792
- args.log(`[plan] PHASE RESULT NOT PERSISTED — ${e instanceof Error ? e.message : String(e)}`);
793
- args.log('[plan] The plan envelope is unchanged. Check what the phase actually built');
794
- args.log('[plan] (transport, activated objects), update the envelope statuses by hand or');
795
- args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
906
+ // Task 13 — the parser detail names markers and phase ids; the screen gets plain words.
907
+ const detail = e instanceof Error ? e.message : String(e);
908
+ logNotSaved(args, detail === NO_PLAN_MANIFEST_MESSAGE ? 'the step ended without writing its result' : 'its result could not be read', detail);
796
909
  return null;
797
910
  }
798
911
  // App lane routing (Task 6, Review Focus 4) — the write-back may restructure
@@ -803,13 +916,12 @@ export async function finishPlanResume(args) {
803
916
  // the rule existed keeps running unchanged (no-regression).
804
917
  const priorParsed = parsePlanContent(args.prepared.envelope.content);
805
918
  const priorMetDeploy = priorParsed.ok && checkRequiredDeploy(priorParsed.content, args.prepared.laneRecord) === null;
806
- const conflict = checkLaneConsistency(extract.content, args.prepared.laneRecord)
807
- ?? (priorMetDeploy ? checkRequiredDeploy(extract.content, args.prepared.laneRecord) : null);
808
- if (conflict) {
809
- args.log('');
810
- args.log(`[plan] PHASE RESULT NOT PERSISTED — ${conflict}`);
811
- args.log('[plan] The plan envelope is unchanged. Check what the phase actually built, then');
812
- args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
919
+ // Task 13 — the lane messages name phase ids and skills; the screen gets plain words.
920
+ const laneConflict = checkLaneConsistency(extract.content, args.prepared.laneRecord);
921
+ const deployConflict = !laneConflict && priorMetDeploy
922
+ ? checkRequiredDeploy(extract.content, args.prepared.laneRecord) : null;
923
+ if (laneConflict || deployConflict) {
924
+ logNotSaved(args, laneConflict ? 'a step no longer fits the app type the plan was made for. Re-run /abap-plan to rebuild the plan.' : 'it dropped the deploy step this app needs', (laneConflict ?? deployConflict));
813
925
  return null;
814
926
  }
815
927
  }
@@ -822,7 +934,10 @@ export async function finishPlanResume(args) {
822
934
  // post-turn messages and the outcome's nextPhaseId describe the happy path;
823
935
  // the post-phase chain (plan-chain.ts) recomputes from the real verdict.
824
936
  const provisionalPhases = extract.content.phases.map((p) => p.id === executedId ? { ...p, audit: undefined } : p);
825
- const next = computeNextPhase(provisionalPhases, statuses);
937
+ // 2026-10-04 (F21) — run semantics: an EARLIER step whose audit is deferred
938
+ // ('pending' until the end of the run) does not hold back its dependants, so
939
+ // the outcome names the same next step the continuous chain will pick.
940
+ const next = computeNextPhaseForRun(provisionalPhases, statuses);
826
941
  // Task 6 — persist the executed phase WITH audit:'pending' in the SAME
827
942
  // revision that records its result. Ordering is the crash-safety design:
828
943
  // a crash between this save and the verdict save leaves 'pending', which
@@ -848,6 +963,9 @@ export async function finishPlanResume(args) {
848
963
  // D-B item 1 — the attempt window for re-audits (fresh-session
849
964
  // re-audit of a multi-attempt JSONL must see only THIS attempt).
850
965
  ...(args.attemptStartedAt ? { attemptStartedAt: args.attemptStartedAt } : {}),
966
+ // 2026-10-04 (continuous run) — the phase's END: bounds the window so a
967
+ // deferred end-of-run audit sees only this phase's rows.
968
+ attemptEndedAt: nowIso,
851
969
  // A re-run after a failed audit carries the failure counter through
852
970
  // 'pending' so the NEXT failed verdict counts as consecutive.
853
971
  ...(carried?.state === 'failed' && carried.consecutiveFailures
@@ -860,8 +978,7 @@ export async function finishPlanResume(args) {
860
978
  // JSON round-trip simulates disk serialization for the validator.
861
979
  const check = validateEnvelope(JSON.parse(JSON.stringify(revision)));
862
980
  if (!check.ok) {
863
- args.log('');
864
- args.log(`[plan] PHASE RESULT NOT PERSISTED — revision failed validation: ${check.error.message}`);
981
+ logNotSaved(args, 'the updated plan did not pass its own checks', `revision failed validation: ${check.error.message}`);
865
982
  return null;
866
983
  }
867
984
  let outDir;
@@ -877,7 +994,8 @@ export async function finishPlanResume(args) {
877
994
  // and never blocks the run (the envelope is the source of truth).
878
995
  writePlanHandover(savedPath, args.log);
879
996
  args.log('');
880
- args.log(`Plan updated: ${savedPath}`);
997
+ // 1.1.28 — the file name, not the full absolute path.
998
+ args.log(`Plan updated: ${savedLocation(savedPath, process.cwd())}`);
881
999
  // D-E — DON'T render the progress board / tally / "Next:" line here when an
882
1000
  // audit is pending. Doing so used the PROVISIONAL (audit-erased) view, which
883
1001
  // counted the just-run phase as done ("2/3") and printed "Next: …" as if the
@@ -891,32 +1009,35 @@ export async function finishPlanResume(args) {
891
1009
  // phase (honest: the phase IS satisfied, no audit is coming).
892
1010
  const auditPending = auditEnabled
893
1011
  && (executedStatus === 'validated' || executedStatus === 'validated-with-waiver');
1012
+ // Task 13 — step titles and one progress line; the post-phase chain prints
1013
+ // "Next: <title>" itself, so nothing here names the next step.
1014
+ const executedPhase = extract.content.phases.find((p) => p.id === executedId);
1015
+ const executedTitle = executedPhase ? phaseDisplayTitle(executedPhase, extract.content.phases) : 'This step';
894
1016
  if (auditPending) {
895
- args.log(chalk.dim('Phase result recorded — audit pending…'));
1017
+ args.log(chalk.dim(`${executedTitle} done · checked independently at the end of the run`));
896
1018
  }
897
1019
  else {
898
1020
  // A blocked / no-audit phase claims nothing to verify, so no verdict will
899
- // contradict a board here — render it (and the next-step / completion hint).
1021
+ // contradict the progress line here — render it (and the completion hint).
900
1022
  args.log('');
901
- args.log(renderPlanTracker({
902
- title: revision.title,
903
- version: revision.version,
904
- content: { ...extract.content, phases: provisionalPhases },
905
- statuses,
906
- currentId: null,
907
- auditsOff: !auditEnabled,
908
- }));
909
- args.log('');
910
- if (next) {
911
- args.log(`Next: ${next.id} (${next.delegateTo}) — run /abap-plan --resume @${basename(savedPath)} in a fresh session.`);
912
- }
913
- else if (provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
1023
+ args.log(renderPlanProgressLine({ content: { ...extract.content, phases: provisionalPhases }, statuses, currentId: null }));
1024
+ if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
914
1025
  // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
915
1026
  args.log(...planCompletionLines(provisionalPhases));
916
1027
  }
917
- else {
918
- const blocked = provisionalPhases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
919
- args.log(`No eligible next phase. Blocked: ${blocked.join(', ')}. Unblock, then resume again.`);
1028
+ else if (!next) {
1029
+ const blocked = provisionalPhases.filter((p) => statuses[p.id] === 'blocked').map((p) => phaseDisplayTitle(p, provisionalPhases));
1030
+ // Task 13 fix round — a step with an open check (not blocked) is named
1031
+ // the same way the prepare branch names it, never "Blocked: (none)".
1032
+ const openChecks = provisionalPhases
1033
+ .filter((p) => isPhaseSatisfied(statuses[p.id]) && !isAuditResolved(p.audit))
1034
+ .map((p) => `${phaseDisplayTitle(p, provisionalPhases)} (check ${p.audit?.state ?? 'unknown'})`);
1035
+ const parts = [
1036
+ ...(blocked.length > 0 || openChecks.length === 0 ? [`Blocked: ${blocked.join(', ') || '(none)'}.`] : []),
1037
+ ...(openChecks.length > 0 ? [`Open checks: ${openChecks.join(', ')}.`] : []),
1038
+ ];
1039
+ args.log(`Nothing can run next. ${parts.join(' ')}`);
1040
+ args.log(`Fix it, then continue with: ${buildResumeCommand(savedPath)}`);
920
1041
  }
921
1042
  }
922
1043
  return {
@@ -924,6 +1045,7 @@ export async function finishPlanResume(args) {
924
1045
  resumeCommand: buildResumeCommand(savedPath),
925
1046
  nextPhaseId: next?.id ?? null,
926
1047
  nextDelegateTo: next?.delegateTo ?? null,
1048
+ nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
927
1049
  };
928
1050
  }
929
1051
  /**
@@ -1031,6 +1153,8 @@ export async function applyAuditResult(envelopePath, phaseId, verdict, opts) {
1031
1153
  // against the SAME attempt, so its window must survive the verdict write.
1032
1154
  if (prior?.attemptStartedAt)
1033
1155
  audit.attemptStartedAt = prior.attemptStartedAt;
1156
+ if (prior?.attemptEndedAt)
1157
+ audit.attemptEndedAt = prior.attemptEndedAt;
1034
1158
  if (verdict.state === 'failed') {
1035
1159
  // Fix-wave (item 10a): a re-confirmed failure (re-audit of an already-
1036
1160
  // 'failed' phase after a menu-death resume) holds the counter — the