@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.
- package/dist/agent/loop.js +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +120 -121
- 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)
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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
|
-
|
|
378
|
-
|
|
396
|
+
reauditPhaseIds = content.phases
|
|
397
|
+
.filter((p) => {
|
|
379
398
|
const st = p.audit?.state;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
422
|
+
if (reauditPhaseId)
|
|
423
|
+
next = null;
|
|
403
424
|
args.log('');
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
?
|
|
418
|
-
:
|
|
419
|
-
? `
|
|
420
|
-
: `
|
|
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} (
|
|
455
|
-
const unresolvedNote = unresolved.length > 0 ? `
|
|
456
|
-
args.log(`
|
|
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
|
-
? '
|
|
459
|
-
: '
|
|
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.
|
|
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
|
-
'<!--
|
|
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
|
-
|
|
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 <!--
|
|
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" —
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(`
|
|
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('
|
|
773
|
-
args.log(`
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
args
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
args.
|
|
810
|
-
|
|
811
|
-
args
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
1021
|
+
// contradict the progress line here — render it (and the completion hint).
|
|
900
1022
|
args.log('');
|
|
901
|
-
args.log(
|
|
902
|
-
|
|
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
|
|
919
|
-
|
|
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
|