@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
@@ -11,17 +11,33 @@ import { existsSync } from 'node:fs';
11
11
  import { leasePathFor, acquireRunLease } from '../projects/run-lease.js';
12
12
  import { readProjectFile } from '../projects/status.js';
13
13
  import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
14
- import { statusesFromItems } from '../projects/plan-run.js';
14
+ import { statusesFromItems, phaseDisplayTitle } from '../projects/plan-run.js';
15
15
  import { findNewestVersion } from './plan-resume.js';
16
16
  import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
17
17
  import { finishPlanResume } from './plan-resume.js';
18
18
  import { runPostPhaseChain } from './plan-chain.js';
19
+ import { DEFAULT_PLAN_MODE } from './plan-gate.js';
19
20
  import { loadPlanLaneForRun } from '../projects/plan-lane.js';
20
21
  /**
21
22
  * The same block shape loop.ts's manifest-on-interrupt hook scans for.
22
23
  * E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
23
24
  */
24
25
  const MANIFEST_BLOCK_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
26
+ /** Task 13 — a step's display title from a plan envelope; a plain fallback, never the id. */
27
+ function stepTitle(envelope, phaseId) {
28
+ const pc = parsePlanContent(envelope.content);
29
+ const p = pc.ok ? pc.content.phases.find((q) => q.id === phaseId) : undefined;
30
+ return p && pc.ok ? phaseDisplayTitle(p, pc.content.phases) : 'the interrupted step';
31
+ }
32
+ /** The one line shown when a bare "continue" re-attaches to an interrupted step. */
33
+ export function continuationNotice(prepared) {
34
+ return `Continuing ${stepTitle(prepared.envelope, prepared.nextPhaseId)} where it stopped`;
35
+ }
36
+ /** The dim line after a continuation turn that saved nothing: the step is not finished. */
37
+ export function stillInFlightLine(envelope, phaseId) {
38
+ const t = stepTitle(envelope, phaseId);
39
+ return `${t.charAt(0).toUpperCase()}${t.slice(1)} is not finished yet. Nothing was saved for it this turn.`;
40
+ }
25
41
  /**
26
42
  * Build the marker from the prepared resume the phase turn is about to run.
27
43
  * `family` mirrors leasePathFor's family-base convention (version suffix
@@ -113,10 +129,8 @@ export async function preparePlanContinuation(args) {
113
129
  // the marker stays so the user can finish there or take over via --resume.
114
130
  const lease = acquireRunLease(path, args.sessionId, { log: (l) => args.log(l) });
115
131
  if (!lease.acquired) {
116
- const holder = lease.holder;
117
- args.log(`[plan] in-flight phase ${marker.phaseId} not re-attached — another session holds the run lease`
118
- + (holder ? ` (session ${holder.sessionId}, pid ${holder.pid})` : '')
119
- + '. Finish there or /abap-plan --resume here to take over.');
132
+ const title = phaseDisplayTitle(phase, parsed.content.phases);
133
+ args.log(`${title} was not picked up — another CSPeach window is running this plan. Finish there, or type /abap-plan --resume here to take over.`);
120
134
  return { prepared: null, stale: false };
121
135
  }
122
136
  const prepared = {
@@ -149,7 +163,7 @@ export async function finishPlanContinuation(args) {
149
163
  if (cls === 'none')
150
164
  return { kind: 'none' };
151
165
  if (cls === 'foreign') {
152
- args.log(`[plan] this turn's manifest belongs to a different plan — NOT attached to in-flight phase ${args.marker.phaseId}.`);
166
+ args.log(`This turn's result belongs to a different plan — it was not added to ${stepTitle(args.prepared.envelope, args.marker.phaseId)}.`);
153
167
  return { kind: 'foreign' };
154
168
  }
155
169
  const outcome = await finishPlanResume({
@@ -177,7 +191,7 @@ export async function runContinuationChain(args) {
177
191
  sessionId: args.sessionId,
178
192
  cwd: args.cwd,
179
193
  ...(args.sapAlias ? { sapAlias: args.sapAlias } : {}),
180
- mode: args.prepared.effectiveMode ?? 'step',
194
+ mode: args.prepared.effectiveMode ?? DEFAULT_PLAN_MODE,
181
195
  modeFlag: args.prepared.mode,
182
196
  auditMode: args.prepared.auditMode,
183
197
  auditFlag: args.prepared.auditFlag,
@@ -7,22 +7,17 @@
7
7
  // arrives as an argument, and the caller (Task 6 wiring in the REPL's
8
8
  // offerNextPhase path) renders the returned decision.
9
9
  //
10
- // Semantics (binding, from the design doc):
11
- // step ⇒ always `prompt` — today's behaviour, one consent per phase.
12
- // guarded ⇒ `stop` when the PREVIOUS phase's audit is unresolved
13
- // ('audit-infra' for infra_failed, else 'audit-failed');
14
- // `prompt` with 'write-phase' when the NEXT phase writes to SAP
15
- // (phaseWrites is fail-safe: undeclared ⇒ writes). This
16
- // prompt IS the stop — consent to RUN the phase; it does
17
- // NOT pre-approve the writes inside it;
18
- // `auto` otherwise (declared non-write phase, clean prior audit).
19
- //
20
- // Precedence: the audit stop wins over the write-phase prompt when both
21
- // apply — a hard stop must never be downgraded to a consent prompt.
22
- // Legacy envelopes (phases with no audit field) never stop the chain:
23
- // isAuditResolved(undefined) === true.
24
- import { phaseWrites } from '../projects/plan-schema.js';
25
- import { isAuditResolved } from '../projects/plan-run.js';
10
+ // Semantics (2026-10-04, continuous run — spec §3.1/§3.3):
11
+ // step ⇒ always `prompt` — one consent per step (unchanged).
12
+ // guarded ⇒ `stop` when the PREVIOUS step's audit is unresolved for the run
13
+ // (a deferred 'pending' counts as fine; 'failed' / hand-edited do not);
14
+ // `auto` otherwise — write steps included. Writes are approved by the
15
+ // plan's object-list grant or by their own cards, never by a
16
+ // step-level prompt.
17
+ // Guarded is the DEFAULT (DEFAULT_PLAN_MODE): one approval, then a continuous run.
18
+ import { isAuditResolvedForRun } from '../projects/plan-run.js';
19
+ /** 2026-10-04 — an absent plan_mode / flag means the continuous run. */
20
+ export const DEFAULT_PLAN_MODE = 'guarded';
26
21
  /* ── guarded-run auto_approve override (Task 6, 2026-07-03) ────────────────
27
22
  * Module-level flag consulted by tools/approval.ts BEFORE its auto-approve
28
23
  * branch. The REPL sets it at guarded-chain start (right before the phase's
@@ -49,12 +44,21 @@ export function isGuardedRunActive() {
49
44
  * When the flag is off, agent_run's opt-in read_only behaviour is
50
45
  * byte-identical (skill-mode compatibility). */
51
46
  let planPhaseActive = false;
52
- export function setPlanPhaseActive(active) {
47
+ /* 2026-10-04 (continuous run) — the id of the plan whose step is running. The
48
+ * plan object-list grant covers requests only for THIS plan; a step started
49
+ * without an id (null) is never covered. Cleared with the flag. */
50
+ let activePlanId = null;
51
+ export function setPlanPhaseActive(active, planId) {
53
52
  planPhaseActive = active;
53
+ activePlanId = active && typeof planId === 'string' && planId ? planId : null;
54
54
  }
55
55
  export function isPlanPhaseActive() {
56
56
  return planPhaseActive;
57
57
  }
58
+ /** The plan whose step is running, or null (no step, or started without an id). */
59
+ export function getActivePlanId() {
60
+ return planPhaseActive ? activePlanId : null;
61
+ }
58
62
  /* Task 7 — per-phase subagent dispatch counter. agent_run increments it on
59
63
  * every dispatch made while a plan phase is active; the REPL reads it
60
64
  * (read+reset) in the runTurn finally that clears the plan-phase flag and
@@ -110,44 +114,19 @@ export function getAndResetPlanDeviation() {
110
114
  return d;
111
115
  }
112
116
  /**
113
- * Banner shown for every non-auto guarded outcome. The resume hint uses the
114
- * literal `@<plan>` placeholder on purpose — this module is pure and doesn't
115
- * know the saved path; the caller may substitute the real basename.
117
+ * Banner shown when a guarded run stops. The resume hint uses the literal
118
+ * `@<plan>` placeholder on purpose — this module is pure and doesn't know the
119
+ * saved path; the caller substitutes the real basename. Titles only, never ids.
116
120
  */
117
- const banner = (reason, next) => `\n■ STOPPED — ${reason}\n Resume with: /abap-plan --resume @<plan> (next phase: ${next})\n`;
121
+ const banner = (reason, nextTitle) => `\n■ STOPPED — ${reason}\n To continue: open CSPeach in this folder and choose Continue, or type /abap-plan --resume @<plan> (next: ${nextTitle})\n`;
118
122
  export function decideNextPhaseGate(args) {
119
- if (args.mode === 'step') {
120
- // Step mode is today's behaviour verbatim — a plain consent prompt with
121
- // no guarded decorations (no banner, no stopReason).
123
+ if (args.mode === 'step')
122
124
  return { action: 'prompt' };
123
- }
124
- // Guarded. Hard stop first: an unresolved prior audit must never be
125
- // downgraded to a runnable prompt by the write-phase branch below.
126
- if (!isAuditResolved(args.prevPhaseAudit)) {
127
- if (args.prevPhaseAudit?.state === 'infra_failed') {
128
- return {
129
- action: 'stop',
130
- stopReason: 'audit-infra',
131
- banner: banner('auditor infrastructure failure — verdict unknown (not a code finding)', args.nextPhase.id),
132
- };
133
- }
125
+ if (!isAuditResolvedForRun(args.prevPhaseAudit)) {
134
126
  return {
135
127
  action: 'stop',
136
128
  stopReason: 'audit-failed',
137
- banner: banner('prior phase audit is unresolved — fix the findings or explicitly waive them', args.nextPhase.id),
138
- };
139
- }
140
- if (phaseWrites(args.nextPhase)) {
141
- // The prompt IS the stop: consent to run the write phase. Writes inside
142
- // the phase still go through their own approval flow — this never
143
- // pre-approves them.
144
- return {
145
- action: 'prompt',
146
- stopReason: 'write-phase',
147
- // Task 3 review carry-over: the banner's resume hint already names the
148
- // phase (`next phase: <id>`), so the reason string stays generic —
149
- // naming it twice read as a rendering bug.
150
- banner: banner('next phase writes to SAP — consent required to run it', args.nextPhase.id),
129
+ banner: banner('an earlier step failed its independent check — fix it or waive it', args.nextPhase.title ?? 'the next step'),
151
130
  };
152
131
  }
153
132
  return { action: 'auto' };
@@ -0,0 +1,174 @@
1
+ import { isPhaseSatisfiedForRun } from '../projects/plan-run.js';
2
+ import { OBJECT_TYPE_LABELS, collectPlannedObjects, normalizeObjectName, normalizeObjectType } from '../projects/plan-objects.js';
3
+ import { recordedPlanTransport } from './plan-resume.js';
4
+ import { SAP_PAUSE_LINE } from './plan-sap-pause.js';
5
+ import { unreachableCode } from '../sap/unreachable.js';
6
+ import { unreachableCodeInText } from '../sap/reachability-state.js';
7
+ import { recordFullNotice } from '../renderer/notice-log.js';
8
+ const LOCK_WAIT_MS = 10_000;
9
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
+ /** Network code when SAP did not answer at all (an HTTP error means it answered). */
11
+ function unreachableOf(err) {
12
+ return unreachableCode(err) ?? unreachableCodeInText(String(err));
13
+ }
14
+ function errText(err) {
15
+ return err instanceof Error ? err.message : String(err);
16
+ }
17
+ /**
18
+ * Lock, then unlock at once. 'free' = the lock was taken (and released);
19
+ * 'held' = SAP refused the lock; 'unknown' = the probe itself failed (not a
20
+ * network failure) — never blocks the step. Unreachable SAP is rethrown.
21
+ */
22
+ async function probeLock(deps, type, name) {
23
+ let l;
24
+ try {
25
+ l = await deps.lock(type, name);
26
+ }
27
+ catch (err) {
28
+ if (unreachableOf(err))
29
+ throw err;
30
+ recordFullNotice('plan', `Lock check before a plan step failed for ${type} ${name}: ${errText(err)}`);
31
+ return 'unknown';
32
+ }
33
+ if (l.status !== 'locked')
34
+ return 'held';
35
+ // Nothing runs between the lock and this unlock, so what was locked is
36
+ // always released (an unlock error is logged; the object still counts free).
37
+ try {
38
+ await deps.unlock(type, name, l.lockHandle);
39
+ }
40
+ catch (err) {
41
+ recordFullNotice('plan', `Unlock after a lock check failed for ${type} ${name}: ${errText(err)}`);
42
+ if (unreachableOf(err))
43
+ throw err;
44
+ }
45
+ return 'free';
46
+ }
47
+ export async function regroundPlan(a) {
48
+ const tries = Math.max(1, a.lockWaitTries ?? 6);
49
+ const sleep = a.deps.sleep ?? defaultSleep;
50
+ // What finished steps of this plan built (their recorded object names).
51
+ const built = [];
52
+ for (const p of a.content.phases) {
53
+ if (!isPhaseSatisfiedForRun(a.statuses[p.id], p.audit))
54
+ continue;
55
+ for (const g of p.work?.generated ?? []) {
56
+ const n = normalizeObjectName(String(g ?? ''));
57
+ if (n && !built.includes(n))
58
+ built.push(n);
59
+ }
60
+ }
61
+ // Which objects belong to this plan: the planned list (type + name) and the
62
+ // built names. A planned name only matches its planned type(s).
63
+ const plannedTypes = new Map();
64
+ for (const o of collectPlannedObjects(a.content)) {
65
+ const s = plannedTypes.get(o.name) ?? new Set();
66
+ s.add(o.type);
67
+ plannedTypes.set(o.name, s);
68
+ }
69
+ const ofThisPlan = (type, name) => {
70
+ if (!Object.hasOwn(OBJECT_TYPE_LABELS, type))
71
+ return false;
72
+ const types = plannedTypes.get(name);
73
+ if (types)
74
+ return types.has(type);
75
+ return built.includes(name);
76
+ };
77
+ const transport = recordedPlanTransport(a.content.phases);
78
+ try {
79
+ const inactive = [];
80
+ for (const raw of await a.deps.inactiveObjects()) {
81
+ const type = normalizeObjectType(raw.type);
82
+ const name = normalizeObjectName(raw.name);
83
+ if (!ofThisPlan(type, name))
84
+ continue;
85
+ if (inactive.some((o) => o.type === type && o.name === name))
86
+ continue;
87
+ inactive.push({ type, name });
88
+ }
89
+ const stillLocked = [];
90
+ for (const o of inactive) {
91
+ let state = await probeLock(a.deps, o.type, o.name);
92
+ for (let i = 1; i < tries && state === 'held'; i++) {
93
+ if (i === 1)
94
+ a.log?.(`${o.name} is locked by another session. Waiting up to a minute for SAP to free it.`);
95
+ await sleep(LOCK_WAIT_MS);
96
+ state = await probeLock(a.deps, o.type, o.name);
97
+ }
98
+ if (state === 'held')
99
+ stillLocked.push(o.name);
100
+ }
101
+ let transportClosed = false;
102
+ if (transport) {
103
+ const t = await a.deps.openTransports();
104
+ transportClosed = t.complete && !t.list.some((x) => x.trim().toUpperCase() === transport.trim().toUpperCase());
105
+ }
106
+ // An object left inactive gets the inactive instruction only, never the
107
+ // "do not rewrite" one as well.
108
+ const inactiveNames = new Set(inactive.map((o) => o.name));
109
+ const builtActive = built.filter((n) => !inactiveNames.has(n));
110
+ const block = [
111
+ '<sap_state>',
112
+ 'Checked live in SAP before this step (authoritative — never repeat finished writes):',
113
+ ...(builtActive.length > 0
114
+ ? [`- Already built by earlier steps of this plan: ${builtActive.join(', ')}. Do not create or rewrite these; read them if this step needs them.`]
115
+ : built.length > 0 ? [] : ['- No earlier step of this plan has built SAP objects yet.']),
116
+ inactive.length > 0
117
+ ? `- Left inactive by an interrupted run: ${inactive.map((o) => o.name).join(', ')}. Run the syntax check and activate them before anything else; rewrite one only if its check fails.`
118
+ : '- Nothing of this plan is left inactive.',
119
+ ...(stillLocked.length > 0
120
+ ? [`- Still locked by another session: ${stillLocked.join(', ')}. The developer chose to go on. If a write or activation meets the lock, stop and report it; do not retry in a loop.`]
121
+ : []),
122
+ '- If an object of THIS step already exists and is active, verify it (read it, check it) instead of writing it again.',
123
+ transport
124
+ ? (transportClosed
125
+ ? `- The plan's transport ${transport} is no longer open. Do not write to it. Creating a new transport needs the developer's approval (it is not on the approved list).`
126
+ : `- Transport ${transport} is still open: use it.`)
127
+ : '- No transport is recorded yet: the first writing step creates one.',
128
+ '</sap_state>',
129
+ ];
130
+ if (stillLocked.length > 0) {
131
+ const one = stillLocked.length === 1;
132
+ return {
133
+ ok: false, reason: 'locked',
134
+ lines: [`${stillLocked.join(', ')} ${one ? 'is' : 'are'} still locked by another session. SAP frees ${one ? 'it' : 'them'} when that session ends or times out, usually within a few minutes.`],
135
+ sapStateBlock: block,
136
+ };
137
+ }
138
+ const n = inactive.length;
139
+ const summary = `Checked SAP before continuing: ${n} object${n === 1 ? '' : 's'} left inactive`
140
+ + (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '') + '.';
141
+ return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block };
142
+ }
143
+ catch (err) {
144
+ const code = unreachableOf(err);
145
+ if (code) {
146
+ recordFullNotice('plan', `SAP unreachable before a plan step: ${code}`);
147
+ return { ok: false, reason: 'unreachable', lines: [SAP_PAUSE_LINE] };
148
+ }
149
+ // Any other SAP error never blocks: the step grounds itself (Mode 2 step 4).
150
+ recordFullNotice('plan', `SAP check before a plan step failed: ${errText(err)}`);
151
+ return {
152
+ ok: true,
153
+ summary: 'Could not check SAP before continuing; the step checks it itself.',
154
+ quiet: false,
155
+ sapStateBlock: [
156
+ '<sap_state>',
157
+ 'CSPeach could not read the SAP state before this step. Ground on the live system first (Mode 2 step 4) and never repeat a write an earlier step finished.',
158
+ built.length > 0 ? `- Already built by earlier steps of this plan: ${built.join(', ')}.` : '- No earlier step of this plan has built SAP objects yet.',
159
+ '</sap_state>',
160
+ ],
161
+ };
162
+ }
163
+ }
164
+ export function makeRegroundDeps(adt) {
165
+ return {
166
+ inactiveObjects: () => adt.inactiveObjects(),
167
+ lock: (t, n) => adt.lock(t, n),
168
+ unlock: (t, n, h) => adt.unlock(t, n, h),
169
+ openTransports: async () => {
170
+ const r = await adt.transportList(undefined, 'modifiable');
171
+ return { list: r.transports.map((x) => x.transportNumber), complete: !r.note };
172
+ },
173
+ };
174
+ }