@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { buildResumeCommand } from './plan-resume.js';
|
|
2
|
+
import { unreachableCode } from '../sap/unreachable.js';
|
|
3
|
+
import { getAndResetSapUnreachable, unreachableCodeInText } from '../sap/reachability-state.js';
|
|
4
|
+
import { recordFullNotice } from '../renderer/notice-log.js';
|
|
5
|
+
export const SAP_PAUSE_LINE = "Can't reach the SAP system from this machine. Nothing is lost. Check network access, then press Enter to retry.";
|
|
6
|
+
/** The line a paused build ends on (shared with later pause points). */
|
|
7
|
+
export const PAUSED_LINE = 'Paused. To continue later: open CSPeach in this folder and choose Continue.';
|
|
8
|
+
const RETRY_QUESTION = 'Press Enter to retry, or type stop: ';
|
|
9
|
+
const STOP_WORDS = new Set(['stop', 'q', 'quit', 'exit']);
|
|
10
|
+
/** The exact command that runs the interrupted step again (mode + audit flags kept). */
|
|
11
|
+
export function resumeCommandFor(prepared) {
|
|
12
|
+
return buildResumeCommand(prepared.path)
|
|
13
|
+
+ (prepared.mode ? ` --${prepared.mode}` : '')
|
|
14
|
+
+ (prepared.auditFlag === 'off' ? ' --no-audit' : prepared.auditFlag === 'on' ? ' --audit' : '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The pause itself failed (prompt aborted, probe threw): the user still gets
|
|
18
|
+
* one instruction on screen; the error text goes to the notices log.
|
|
19
|
+
*/
|
|
20
|
+
export function reportSapPauseFailure(prepared, err, log) {
|
|
21
|
+
recordFullNotice('plan', `SAP pause failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
22
|
+
log('', PAUSED_LINE, `To run this step again: ${resumeCommandFor(prepared)}`);
|
|
23
|
+
}
|
|
24
|
+
/** One retry-or-stop question: Enter (or anything else) retries; "stop" / "q" stops. */
|
|
25
|
+
export async function promptRetryOrStop(prompt, question = RETRY_QUESTION) {
|
|
26
|
+
const ans = String((await prompt(question)) ?? '').trim().toLowerCase();
|
|
27
|
+
return STOP_WORDS.has(ans) ? 'stop' : 'retry';
|
|
28
|
+
}
|
|
29
|
+
const CONTINUE_WORDS = new Set(['continue', 'c', 'go']);
|
|
30
|
+
/** The question after a lock that did not clear: check again (Enter), go on anyway, or stop. */
|
|
31
|
+
export const LOCKED_QUESTION = 'Press Enter to check again, type continue to go on anyway (the step will meet the lock itself), or type stop: ';
|
|
32
|
+
/**
|
|
33
|
+
* The lock question. Enter (or anything else) checks again; "continue" runs
|
|
34
|
+
* the step anyway (its own write meets the lock); "stop" / "q" pauses.
|
|
35
|
+
*/
|
|
36
|
+
export async function promptLockedChoice(prompt) {
|
|
37
|
+
const ans = String((await prompt(LOCKED_QUESTION)) ?? '').trim().toLowerCase();
|
|
38
|
+
if (STOP_WORDS.has(ans))
|
|
39
|
+
return 'stop';
|
|
40
|
+
if (CONTINUE_WORDS.has(ans))
|
|
41
|
+
return 'continue';
|
|
42
|
+
return 'retry';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Show the pause line and wait until SAP answers again. True when SAP is back
|
|
46
|
+
* (the caller re-runs the step); false when the user stopped, or nobody is
|
|
47
|
+
* there to press Enter (headless never waits).
|
|
48
|
+
*/
|
|
49
|
+
export async function pauseUntilSapReachable(a) {
|
|
50
|
+
if (!a.interactive) {
|
|
51
|
+
a.log("Can't reach the SAP system from this machine. Nothing is lost.", PAUSED_LINE);
|
|
52
|
+
if (a.resumeCommand)
|
|
53
|
+
a.log(`To run this step again: ${a.resumeCommand}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
for (;;) {
|
|
57
|
+
a.log('', SAP_PAUSE_LINE);
|
|
58
|
+
if ((await promptRetryOrStop(a.prompt)) === 'stop') {
|
|
59
|
+
a.log(PAUSED_LINE);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (await a.probe())
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Reachable unless the call fails with a network error (an HTTP error means SAP answered). */
|
|
67
|
+
export function makeSapProbe(adt) {
|
|
68
|
+
return async () => {
|
|
69
|
+
try {
|
|
70
|
+
await adt.inactiveObjects();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
const code = unreachableCode(e) ?? unreachableCodeInText(String(e));
|
|
75
|
+
if (code)
|
|
76
|
+
recordFullNotice('plan', `SAP still unreachable on retry: ${code}`);
|
|
77
|
+
return !code;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* After a plan step's turn: when a SAP tool hit an unreachable SAP and the
|
|
83
|
+
* step saved nothing, pause; on Enter (and SAP answering) queue the SAME step
|
|
84
|
+
* again. Always consumes the flag. 'resumed' = the step is queued (the run
|
|
85
|
+
* goes on); 'paused' = the user stopped (the run ends); 'none' = no pause.
|
|
86
|
+
*/
|
|
87
|
+
export async function handleSapUnreachableAfterPlanTurn(a) {
|
|
88
|
+
const code = getAndResetSapUnreachable();
|
|
89
|
+
if (!code || !a.prepared || a.persisted)
|
|
90
|
+
return 'none';
|
|
91
|
+
recordFullNotice('plan', `SAP unreachable during step ${a.prepared.nextPhaseId}: ${code}`);
|
|
92
|
+
const cmd = resumeCommandFor(a.prepared);
|
|
93
|
+
let ok;
|
|
94
|
+
try {
|
|
95
|
+
ok = await pauseUntilSapReachable({ ...a, resumeCommand: cmd });
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
reportSapPauseFailure(a.prepared, err, a.log);
|
|
99
|
+
return 'paused';
|
|
100
|
+
}
|
|
101
|
+
if (!ok)
|
|
102
|
+
return 'paused';
|
|
103
|
+
a.queue(cmd);
|
|
104
|
+
return 'resumed';
|
|
105
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readProjectFile } from '../projects/status.js';
|
|
2
|
+
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
3
|
+
import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, statusesFromItems, } from '../projects/plan-run.js';
|
|
4
|
+
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
5
|
+
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
|
|
6
|
+
import { newestPlanFamilyFiles } from '../session/recap.js';
|
|
7
|
+
import { getPlanGrant, installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
|
|
8
|
+
import { DEFAULT_PLAN_MODE } from './plan-gate.js';
|
|
9
|
+
/** Plans in the folder with a step left to build, newest first. */
|
|
10
|
+
export function listUnfinishedPlans(workspaceDir, max = 5) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const f of newestPlanFamilyFiles(workspaceDir)) {
|
|
13
|
+
if (out.length >= max)
|
|
14
|
+
break;
|
|
15
|
+
let env;
|
|
16
|
+
try {
|
|
17
|
+
env = readProjectFile(f.path);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (env.artefactType !== 'plan')
|
|
23
|
+
continue;
|
|
24
|
+
const pc = parsePlanContent(env.content);
|
|
25
|
+
if (!pc.ok)
|
|
26
|
+
continue;
|
|
27
|
+
const phases = pc.content.phases;
|
|
28
|
+
const statuses = statusesFromItems(env.interaction.items, phases);
|
|
29
|
+
// A plan whose steps are all built (only its end-of-run check owed) has
|
|
30
|
+
// no step to continue; any resume picks the check up.
|
|
31
|
+
const next = computeNextPhaseForRun(phases, statuses);
|
|
32
|
+
if (!next)
|
|
33
|
+
continue;
|
|
34
|
+
const transport = planBuildTransport(phases);
|
|
35
|
+
out.push({
|
|
36
|
+
path: f.path,
|
|
37
|
+
planId: env.id,
|
|
38
|
+
title: env.title,
|
|
39
|
+
// "Done" = built in a run: a deferred check counts (it runs at the end).
|
|
40
|
+
done: phases.filter((p) => isPhaseSatisfiedForRun(statuses[p.id], p.audit)).length,
|
|
41
|
+
total: phases.length,
|
|
42
|
+
nextTitle: phaseDisplayTitle(next, phases),
|
|
43
|
+
objects: collectPlannedObjects(pc.content),
|
|
44
|
+
...(transport ? { transport } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function progress(p) {
|
|
50
|
+
return `${p.done} of ${p.total} done, next: ${p.nextTitle ?? 'the independent check'}`;
|
|
51
|
+
}
|
|
52
|
+
export function continueQuestion(p) {
|
|
53
|
+
return `Continue '${p.title}' (${progress(p)})?`;
|
|
54
|
+
}
|
|
55
|
+
/** The object-list rows, the transport, and what the approval covers. */
|
|
56
|
+
function objectListFacts(objects, transport, grantable) {
|
|
57
|
+
if (objects.length === 0)
|
|
58
|
+
return ['The plan does not list its SAP objects, so CSPeach asks before each write.'];
|
|
59
|
+
return [
|
|
60
|
+
`It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`,
|
|
61
|
+
...plannedObjectFacts(objects).map((r) => ` ${r}`),
|
|
62
|
+
`Transport: ${transport ?? 'a new one for this build'}`,
|
|
63
|
+
...(grantable ? planGrantFactLines() : ['CSPeach asks before each write.']),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
|
|
67
|
+
/**
|
|
68
|
+
* The plan ids whose object list was just offered by the startup Continue: the
|
|
69
|
+
* resume it submits must not ask again (one-shot, consumed by that resume).
|
|
70
|
+
*/
|
|
71
|
+
const offeredAtStartup = new Set();
|
|
72
|
+
/**
|
|
73
|
+
* Plan ids whose typed resume opened on a re-audit pass (a crashed session
|
|
74
|
+
* left a check owed): the object list is offered on the hop that pass queues,
|
|
75
|
+
* once. Consumed by the next offer call for that plan.
|
|
76
|
+
*/
|
|
77
|
+
const offerOwedAfterReaudit = new Set();
|
|
78
|
+
/** Plan ids whose object list this function already showed in this session. */
|
|
79
|
+
const objectListShown = new Set();
|
|
80
|
+
async function grantObjectList(a) {
|
|
81
|
+
const g = { planId: a.planId, sessionId: a.sessionId, objects: a.objects, ...(a.transport ? { transport: a.transport } : {}) };
|
|
82
|
+
try {
|
|
83
|
+
if (a.grant) {
|
|
84
|
+
await a.grant(g);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (await installPlanGrant({ ...g, ...(a.sapAlias ? { sapAlias: a.sapAlias } : {}) }))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch { /* fall through */ }
|
|
91
|
+
a.log(OBJECT_LIST_FAILED);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
function grantInstalledFor(planId, sessionId) {
|
|
95
|
+
const g = getPlanGrant();
|
|
96
|
+
return g !== null && g.planId === planId && g.sessionId === sessionId
|
|
97
|
+
&& typeof g.expiresAt === 'number' && Date.now() < g.expiresAt;
|
|
98
|
+
}
|
|
99
|
+
export async function offerContinueAtStartup(a) {
|
|
100
|
+
const plans = listUnfinishedPlans(a.workspaceDir);
|
|
101
|
+
if (plans.length === 0)
|
|
102
|
+
return 'none';
|
|
103
|
+
const allowed = a.grantAllowed ?? planGrantAllowed;
|
|
104
|
+
let pick;
|
|
105
|
+
let grantable = false;
|
|
106
|
+
let approved = false;
|
|
107
|
+
if (plans.length === 1) {
|
|
108
|
+
const p = plans[0];
|
|
109
|
+
grantable = p.objects.length > 0 && await allowed(a.sapAlias).catch(() => false);
|
|
110
|
+
const v = await a.choose({
|
|
111
|
+
title: continueQuestion(p),
|
|
112
|
+
facts: objectListFacts(p.objects, p.transport, grantable),
|
|
113
|
+
choices: [{ value: 'continue', label: 'Continue' }, { value: 'later', label: 'Not now' }],
|
|
114
|
+
});
|
|
115
|
+
if (v === 'continue') {
|
|
116
|
+
pick = p;
|
|
117
|
+
approved = grantable;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const v = await a.choose({
|
|
122
|
+
title: 'Continue an unfinished plan?',
|
|
123
|
+
facts: ['Newest first.'],
|
|
124
|
+
choices: [
|
|
125
|
+
...plans.map((p, i) => ({ value: String(i), label: `${p.title} (${progress(p)})` })),
|
|
126
|
+
{ value: 'later', label: 'Not now' },
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
if (v !== null && /^\d+$/.test(v))
|
|
130
|
+
pick = plans[Number(v)];
|
|
131
|
+
// The list did not show the objects: the picked plan's list is shown now.
|
|
132
|
+
if (pick && pick.objects.length > 0) {
|
|
133
|
+
grantable = await allowed(a.sapAlias).catch(() => false);
|
|
134
|
+
if (grantable)
|
|
135
|
+
approved = await askObjectList(a.choose, pick.title, [], pick.objects, pick.transport);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!pick)
|
|
139
|
+
return 'declined';
|
|
140
|
+
offeredAtStartup.add(pick.planId);
|
|
141
|
+
if (approved) {
|
|
142
|
+
await grantObjectList({ ...a, planId: pick.planId, objects: pick.objects, ...(pick.transport ? { transport: pick.transport } : {}) });
|
|
143
|
+
}
|
|
144
|
+
a.submit(buildResumeCommand(pick.path));
|
|
145
|
+
return 'continued';
|
|
146
|
+
}
|
|
147
|
+
/** The object-list card. True on Yes. */
|
|
148
|
+
async function askObjectList(choose, planTitle, lead, objects, transport) {
|
|
149
|
+
const v = await choose({
|
|
150
|
+
title: `Approve the object list for '${planTitle}'?`,
|
|
151
|
+
facts: [...lead, ...objectListFacts(objects, transport, true)],
|
|
152
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No — ask before each write' }],
|
|
153
|
+
});
|
|
154
|
+
return v === 'yes';
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* F13 — before a typed `/abap-plan --resume` step in guarded mode: when the
|
|
158
|
+
* plan lists objects and no grant for it is installed, show the object list
|
|
159
|
+
* once. Yes installs the grant (same policy) and adopts the recorded
|
|
160
|
+
* transport; No runs the step with a card per write. Skipped on a
|
|
161
|
+
* chain-queued hop, in step mode (it keeps its own per-step prompt), for a
|
|
162
|
+
* re-audit-only pass, when the grant is not allowed, and for the resume the
|
|
163
|
+
* startup Continue submitted. A typed resume that opens on a re-audit pass
|
|
164
|
+
* (a crashed session left a check owed) moves the offer to the first step
|
|
165
|
+
* that pass queues: that one chain hop may show it, once, when no grant
|
|
166
|
+
* exists and the list was not shown yet in this session.
|
|
167
|
+
*/
|
|
168
|
+
export async function offerObjectListOnResume(a) {
|
|
169
|
+
const env = a.prepared.envelope;
|
|
170
|
+
const fromStartup = offeredAtStartup.delete(env.id);
|
|
171
|
+
if (fromStartup)
|
|
172
|
+
return 'skipped';
|
|
173
|
+
if (a.prepared.reauditPhaseId) {
|
|
174
|
+
if (!a.planHop)
|
|
175
|
+
offerOwedAfterReaudit.add(env.id);
|
|
176
|
+
return 'skipped';
|
|
177
|
+
}
|
|
178
|
+
const owed = offerOwedAfterReaudit.delete(env.id);
|
|
179
|
+
if (a.planHop && !(owed && !objectListShown.has(env.id)))
|
|
180
|
+
return 'skipped';
|
|
181
|
+
if ((a.prepared.effectiveMode ?? DEFAULT_PLAN_MODE) !== 'guarded')
|
|
182
|
+
return 'skipped';
|
|
183
|
+
if (grantInstalledFor(env.id, a.sessionId))
|
|
184
|
+
return 'skipped';
|
|
185
|
+
const pc = parsePlanContent(env.content);
|
|
186
|
+
if (!pc.ok)
|
|
187
|
+
return 'skipped';
|
|
188
|
+
const content = pc.content;
|
|
189
|
+
const objects = collectPlannedObjects(content);
|
|
190
|
+
if (objects.length === 0)
|
|
191
|
+
return 'skipped';
|
|
192
|
+
if (!(await (a.grantAllowed ?? planGrantAllowed)(a.sapAlias).catch(() => false)))
|
|
193
|
+
return 'skipped';
|
|
194
|
+
const next = content.phases.find((p) => p.id === a.prepared.nextPhaseId);
|
|
195
|
+
const transport = planBuildTransport(content.phases);
|
|
196
|
+
const lead = next ? [`Next step: ${phaseDisplayTitle(next, content.phases)}`] : [];
|
|
197
|
+
objectListShown.add(env.id);
|
|
198
|
+
if (!(await askObjectList(a.choose, env.title, lead, objects, transport)))
|
|
199
|
+
return 'declined';
|
|
200
|
+
adoptPlanTransportOnResume(content.phases);
|
|
201
|
+
const ok = await grantObjectList({ ...a, planId: env.id, objects, ...(transport ? { transport } : {}) });
|
|
202
|
+
return ok ? 'approved' : 'declined';
|
|
203
|
+
}
|
|
204
|
+
export function inkStartupChooser() {
|
|
205
|
+
return async (q) => {
|
|
206
|
+
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
207
|
+
const r = await askConfirm({ id: 'plan-continue', tag: 'QUESTION', title: q.title, facts: q.facts, choices: q.choices, initial: 0 });
|
|
208
|
+
return r.cancelled ? null : r.value;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export function classicStartupChooser(prompt, log) {
|
|
212
|
+
return async (q) => {
|
|
213
|
+
log('', q.title, ...q.facts.map((f) => ` ${f}`), ...q.choices.map((c, i) => ` ${i + 1}. ${c.label}`));
|
|
214
|
+
const ans = (await prompt(`Choose 1-${q.choices.length} (Enter = ${q.choices[0].label}): `)).trim();
|
|
215
|
+
const n = ans === '' ? 1 : Number(ans);
|
|
216
|
+
return Number.isInteger(n) && n >= 1 && n <= q.choices.length ? q.choices[n - 1].value : null;
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/config/loader.js
CHANGED
|
@@ -222,6 +222,9 @@ export async function loadConfig() {
|
|
|
222
222
|
plan_audit: parsed.plan_audit === 'on' || parsed.plan_audit === 'off'
|
|
223
223
|
? parsed.plan_audit
|
|
224
224
|
: undefined,
|
|
225
|
+
approvals: parsed.approvals === 'plan' || parsed.approvals === 'per-write'
|
|
226
|
+
? parsed.approvals
|
|
227
|
+
: undefined,
|
|
225
228
|
// audit_model: preserve only a non-empty string (trimmed); a wrong-type /
|
|
226
229
|
// empty value collapses to undefined so resolveAuditModel reads the safe
|
|
227
230
|
// Sonnet default. Mirrors the optional-key hygiene of plan_mode/render_smoke.
|
|
@@ -315,6 +318,8 @@ export async function loadConfig() {
|
|
|
315
318
|
// callers see a genuine "absent" shape (treated as 'on').
|
|
316
319
|
if (merged.plan_audit === undefined)
|
|
317
320
|
delete merged.plan_audit;
|
|
321
|
+
if (merged.approvals === undefined)
|
|
322
|
+
delete merged.approvals;
|
|
318
323
|
// Same for `audit_model`: absent-or-invalid must not serialise as
|
|
319
324
|
// `audit_model = undefined` — drop the key so `config show` stays clean and
|
|
320
325
|
// callers see a genuine "absent" shape (resolveAuditModel reads the default).
|
|
@@ -438,3 +443,7 @@ export async function saveConfig(config) {
|
|
|
438
443
|
}
|
|
439
444
|
await fs.writeFile(configFile(), toml.stringify(writable), 'utf-8');
|
|
440
445
|
}
|
|
446
|
+
/** The effective plan-run approval mode — absent/invalid ⇒ 'plan'. */
|
|
447
|
+
export function effectiveApprovals(cfg) {
|
|
448
|
+
return cfg.approvals === 'per-write' ? 'per-write' : 'plan';
|
|
449
|
+
}
|
|
@@ -1,33 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Doctor check: forge-rules
|
|
2
|
+
* Doctor check: forge-rules — READ-ONLY.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 2026-10-04 — doctor is a diagnostic and must never change a customer's SAP
|
|
5
|
+
* system. This check used to create a probe report Z_CSPEACH_DOCTOR_PROBE in
|
|
6
|
+
* $TMP, lock it, write its source, run a syntax check and delete it, to show
|
|
7
|
+
* that Rule 7 (snapshot before write) and Rule 10 (syntax check after write)
|
|
8
|
+
* fire. Both rules live in the sap-client write path (AdtClient.setSource)
|
|
9
|
+
* and are proven by its own tests; what can differ per laptop and per system
|
|
10
|
+
* is whether their two dependencies are there:
|
|
11
|
+
* - Rule 7: the local snapshot store (~/.cspeach/snapshots) can be written;
|
|
12
|
+
* - Rule 10: the system offers the ADT syntax-check endpoint (checkruns),
|
|
13
|
+
* read from the discovery document with a plain GET.
|
|
14
|
+
* That is what this check now answers.
|
|
7
15
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Doctor is non-interactive — minting an internal JWT adds complexity.
|
|
12
|
-
* - A known regression in the CLI handler (memory/project_phase3_lite_regression.md)
|
|
13
|
-
* causes double-dispatch that would always red this check.
|
|
14
|
-
* - Rule 7 (AUTO-SNAPSHOT at ~line 1994) and Rule 10 (AUTO-SYNTAX-CHECK at
|
|
15
|
-
* ~line 2114) live inside AdtClient.setSource itself. Calling AdtClient
|
|
16
|
-
* directly still exercises the full rule pipeline.
|
|
16
|
+
* What doctor can no longer tell: whether this SAP user is allowed to create
|
|
17
|
+
* and change objects (the old probe failed with 401/403 when not). The first
|
|
18
|
+
* real write in a session still reports that, with its own approval.
|
|
17
19
|
*/
|
|
20
|
+
import { promises as fs, constants as fsc } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
18
22
|
import { loadConfig } from '../../config/loader.js';
|
|
19
23
|
import { getConnection } from '../../sap/connection-manager.js';
|
|
20
|
-
import {
|
|
24
|
+
import { snapshots } from '@cspeach/sap-client';
|
|
21
25
|
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const DISCOVERY_PATH = '/sap/bc/adt/discovery';
|
|
27
|
+
const CHECKRUNS = '/sap/bc/adt/checkruns';
|
|
28
|
+
/** The snapshot folder, or its nearest existing parent, is writable. */
|
|
29
|
+
async function snapshotStoreWritable(dir) {
|
|
30
|
+
let cur = path.resolve(dir);
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(cur, fsc.W_OK);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e.code !== 'ENOENT')
|
|
38
|
+
return false;
|
|
39
|
+
const up = path.dirname(cur);
|
|
40
|
+
if (up === cur)
|
|
41
|
+
return false;
|
|
42
|
+
cur = up;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
26
46
|
export async function checkForgeRules() {
|
|
27
47
|
const cfg = await loadConfig();
|
|
28
|
-
// Standalone mode
|
|
29
|
-
// opted-in paste-mode user has none. Everything below (including the
|
|
30
|
-
// no-alias skip) is unchanged.
|
|
48
|
+
// Standalone mode — no live system, nothing to check.
|
|
31
49
|
if (sapChecksSkipped(cfg))
|
|
32
50
|
return standaloneSkip('forge-rules');
|
|
33
51
|
const aliases = Object.keys(cfg.sap);
|
|
@@ -40,79 +58,43 @@ export async function checkForgeRules() {
|
|
|
40
58
|
hint: 'Run: cspeach config add-sap',
|
|
41
59
|
};
|
|
42
60
|
}
|
|
61
|
+
// Rule 7 — local, no SAP call.
|
|
62
|
+
const snapDir = snapshots.getDefaultSnapshotDir();
|
|
63
|
+
if (!(await snapshotStoreWritable(snapDir))) {
|
|
64
|
+
return {
|
|
65
|
+
name: 'forge-rules',
|
|
66
|
+
ok: false,
|
|
67
|
+
message: `Rule 7: snapshot folder is not writable (${snapDir})`,
|
|
68
|
+
hint: 'Writes to SAP are refused until the snapshot folder can be written. Check the folder permissions.',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Rule 10 — one GET of the discovery document.
|
|
43
72
|
const alias = aliases[0];
|
|
44
73
|
const conn = await getConnection(alias, cfg.sap[alias]);
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
await adt.deleteObject('PROG', PROBE_NAME);
|
|
49
|
-
}
|
|
50
|
-
catch { /* best-effort */ }
|
|
51
|
-
try {
|
|
52
|
-
// Step 1: Create the probe shell (PROG in $TMP).
|
|
53
|
-
await adt.createObject('PROG', PROBE_NAME, PROBE_PACKAGE, 'cspeach doctor probe');
|
|
54
|
-
}
|
|
55
|
-
catch (err) {
|
|
56
|
-
const msg = String(err);
|
|
57
|
-
if (msg.includes('401') || msg.includes('403')) {
|
|
58
|
-
return {
|
|
59
|
-
name: 'forge-rules',
|
|
60
|
-
ok: false,
|
|
61
|
-
skipped: true,
|
|
62
|
-
message: 'write probe not permitted',
|
|
63
|
-
hint: `User ${alias} can't write to ${PROBE_PACKAGE} — Rule 7/10 not verified. Configure a probe_package or grant write permissions.`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
74
|
+
const r = await conn.get(DISCOVERY_PATH, 'application/atomsvc+xml');
|
|
75
|
+
if (r.status === 401 || r.status === 403) {
|
|
66
76
|
return {
|
|
67
77
|
name: 'forge-rules',
|
|
68
78
|
ok: false,
|
|
69
|
-
|
|
79
|
+
skipped: true,
|
|
80
|
+
message: `Rule 10 not verified: ${alias} answered HTTP ${r.status} on ADT discovery`,
|
|
81
|
+
hint: `Check the SAP user's ADT authorisations on ${alias}.`,
|
|
70
82
|
};
|
|
71
83
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// inside AdtClient. Bypasses the CLI handler intentionally — see file header.
|
|
77
|
-
await adt.setSource('PROG', PROBE_NAME, PROBE_SOURCE);
|
|
78
|
-
// Step 3: Verify Rule 7 fired — snapshot count should have increased.
|
|
79
|
-
const snapsAfter = await snapshots.list('PROG', PROBE_NAME);
|
|
80
|
-
if (snapsAfter.length <= snapsBefore.length) {
|
|
81
|
-
return {
|
|
82
|
-
name: 'forge-rules',
|
|
83
|
-
ok: false,
|
|
84
|
-
message: `Rule 7 not enforced: snapshot count did not increase (was ${snapsBefore.length}, now ${snapsAfter.length})`,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
// Step 4: Verify Rule 10 fired — syntax was checked. Re-run syntaxCheck and confirm clean.
|
|
88
|
-
// Re-review item 2 — through verifySyntax (fail closed): a check that did
|
|
89
|
-
// not run is ok:false with its reason, never a throw out of collectChecks.
|
|
90
|
-
const syntax = await verifySyntax(adt, PROBE_NAME, 'PROG');
|
|
91
|
-
if (syntax.didNotRun !== undefined) {
|
|
92
|
-
return {
|
|
93
|
-
name: 'forge-rules',
|
|
94
|
-
ok: false,
|
|
95
|
-
message: `Rule 10: syntax check did not run: ${syntax.didNotRun.replace(/^Syntax check did not run:?\s*/i, '')}`,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
if (!syntax.ok) {
|
|
99
|
-
return {
|
|
100
|
-
name: 'forge-rules',
|
|
101
|
-
ok: false,
|
|
102
|
-
message: `Rule 10: syntax check returned errors on probe: ${syntax.errors[0] ?? 'unknown'}`,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
84
|
+
if (r.status < 200 || r.status >= 300) {
|
|
85
|
+
return { name: 'forge-rules', ok: false, message: `Rule 10 not verified: ADT discovery returned HTTP ${r.status} on ${alias}` };
|
|
86
|
+
}
|
|
87
|
+
if (!r.body.includes(CHECKRUNS)) {
|
|
105
88
|
return {
|
|
106
89
|
name: 'forge-rules',
|
|
107
|
-
ok:
|
|
108
|
-
message: `Rule
|
|
90
|
+
ok: false,
|
|
91
|
+
message: `Rule 10: ${alias} does not offer the ADT syntax check (${CHECKRUNS})`,
|
|
92
|
+
hint: 'Writes are refused when the syntax check cannot run. Ask your basis team to enable ADT check runs.',
|
|
109
93
|
};
|
|
110
94
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
catch { /* best-effort */ }
|
|
117
|
-
}
|
|
95
|
+
return {
|
|
96
|
+
name: 'forge-rules',
|
|
97
|
+
ok: true,
|
|
98
|
+
message: `Rule 7 (snapshot folder) + Rule 10 (syntax check on ${alias}) ready — read-only check, nothing written`,
|
|
99
|
+
};
|
|
118
100
|
}
|
package/dist/doctor/run.js
CHANGED
|
@@ -85,10 +85,10 @@ export function formatDoctorRows(checks) {
|
|
|
85
85
|
return lines;
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
* Re-review 2 item 3 — the forge-rules
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
88
|
+
* Re-review 2 item 3 — any throw inside the forge-rules check (login refused,
|
|
89
|
+
* connection drop) is a failed row with a readable one-line message, never a
|
|
90
|
+
* crashed doctor run. The check is read-only since 2026-10-04 (one discovery
|
|
91
|
+
* GET; it never writes to SAP).
|
|
92
92
|
*/
|
|
93
93
|
async function safeForgeRules() {
|
|
94
94
|
try {
|