@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.
Files changed (65) 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/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/config/loader.js +9 -0
  26. package/dist/doctor/checks/forge-rules.js +67 -85
  27. package/dist/doctor/run.js +4 -4
  28. package/dist/lane/app-chain.js +88 -9
  29. package/dist/one-shot.js +1 -0
  30. package/dist/projects/extract-plan.js +34 -1
  31. package/dist/projects/handover-md.js +1 -1
  32. package/dist/projects/plan-objects.js +75 -0
  33. package/dist/projects/plan-run.js +74 -18
  34. package/dist/projects/plan-schema.js +6 -0
  35. package/dist/projects/plan-sources.js +199 -0
  36. package/dist/projects/promote-command.js +1 -1
  37. package/dist/projects/save-command.js +11 -13
  38. package/dist/projects/saved-lines.js +36 -0
  39. package/dist/renderer/checklist-format.js +5 -5
  40. package/dist/renderer/machine-blocks.js +141 -0
  41. package/dist/renderer/tool-labels.js +3 -0
  42. package/dist/repl/plan-hop.js +22 -0
  43. package/dist/repl/rule8-detector.js +16 -1
  44. package/dist/repl.js +320 -55
  45. package/dist/router/router-core.js +2 -2
  46. package/dist/sap/reachability-state.js +25 -0
  47. package/dist/session/audit-export.js +10 -16
  48. package/dist/session/recap.js +16 -26
  49. package/dist/skills/bundled-skills.js +1 -1
  50. package/dist/tools/_filesystem-shared.js +37 -0
  51. package/dist/tools/approval.js +48 -2
  52. package/dist/tools/dispatch-skill.js +3 -2
  53. package/dist/tools/filesystem/file-read.js +5 -3
  54. package/dist/tools/filesystem/glob.js +27 -4
  55. package/dist/tools/fiori/tools.js +6 -0
  56. package/dist/tools/lane/app-lane-resolve.js +1 -1
  57. package/dist/tools/sap-read.js +37 -3
  58. package/dist/tools/sap-write.js +65 -0
  59. package/dist/tools/text-elements/pool.js +250 -0
  60. package/dist/tools/text-elements/tools.js +433 -0
  61. package/dist/tools/tool-loading.js +3 -0
  62. package/dist/tools/transport.js +26 -5
  63. package/dist/tools/write-mode.js +4 -3
  64. package/package.json +120 -121
  65. 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
+ }
@@ -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 (C.2)
2
+ * Doctor check: forge-rules — READ-ONLY.
3
3
  *
4
- * Verifies that Rule 7 (auto-snapshot before write) and Rule 10
5
- * (auto-syntax-check after write) are enforced end-to-end against the
6
- * customer's SAP system.
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
- * DEVIATION FROM PLAN: calls AdtClient.setSource directly, NOT the CLI
9
- * sap_set_source tool handler. Reasons:
10
- * - The CLI handler requires an approval_id JWT (minted by request_approval).
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 { AdtClient, snapshots } from '@cspeach/sap-client';
24
+ import { snapshots } from '@cspeach/sap-client';
21
25
  import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
22
- import { verifySyntax } from '../../tools/verify.js';
23
- const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
24
- const PROBE_PACKAGE = '$TMP';
25
- const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
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 ONLY — the Rule 7/10 probe needs a live system, and an
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 adt = new AdtClient(conn);
46
- // Cleanup: delete a leftover probe from a prior failed run, best-effort.
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
- message: `probe-create failed: ${msg.slice(0, 120)}`,
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
- try {
73
- // Capture snapshot count before write.
74
- const snapsBefore = await snapshots.list('PROG', PROBE_NAME);
75
- // Step 2: setSource — exercises Rule 7 (auto-snapshot) + Rule 10 (auto-syntax-check)
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: true,
108
- message: `Rule 7 (snapshot) + Rule 10 (syntax check) verified on ${PROBE_NAME}`,
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
- finally {
112
- // Always cleanup, success or failure.
113
- try {
114
- await adt.deleteObject('PROG', PROBE_NAME);
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
  }
@@ -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 probe writes to SAP (create, lock,
89
- * write, snapshot list, delete); any throw there (lock refused, snapshot
90
- * store unreadable, connection drop) is a failed row with a readable one-line
91
- * message, never a crashed doctor run.
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 {