@cspeach/cli 1.1.27 → 1.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/agent/turn-error-ux.js +40 -0
- 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 +81 -81
- 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/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { canonicalApprovalObject } from './canonical.js';
|
|
2
|
+
import { mintPlanGrantToken, verifyPlanGrantToken } from './jwt.js';
|
|
3
|
+
import { normalizeObjectType, toPlannedObject } from '../projects/plan-objects.js';
|
|
4
|
+
import { setRule8BatchApprovedProvider, toolSpendsApprovalId } from '../repl/rule8-detector.js';
|
|
5
|
+
import { isPlanPhaseActive, getActivePlanId } from '../commands/plan-gate.js';
|
|
6
|
+
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
7
|
+
import { loadConfig, effectiveApprovals, } from '../config/loader.js';
|
|
8
|
+
let active = null;
|
|
9
|
+
let boundTransport;
|
|
10
|
+
let transportCreated = false;
|
|
11
|
+
/** Canonical description of the one new transport the grant covered. */
|
|
12
|
+
let grantCtsObject;
|
|
13
|
+
/** The `approvals` setting as last read; anything but 'plan' turns the grant off for Rule 8. */
|
|
14
|
+
let approvalsMode = 'per-write';
|
|
15
|
+
/**
|
|
16
|
+
* Objects already offered on the "add to the approved list" card, per plan id
|
|
17
|
+
* ("TYPE:NAME"). Accepted or declined, they are never asked about again in
|
|
18
|
+
* this session. Cleared with the grant.
|
|
19
|
+
*/
|
|
20
|
+
const offeredDelta = new Map();
|
|
21
|
+
const TR_RE = /^[A-Z0-9]{3}K\d{6}$/;
|
|
22
|
+
function isLive(g) {
|
|
23
|
+
return typeof g.expiresAt === 'number' && Date.now() < g.expiresAt;
|
|
24
|
+
}
|
|
25
|
+
/** The grant may cover a request now: a step of ITS plan runs and it has not expired. */
|
|
26
|
+
function inScope(g) {
|
|
27
|
+
return isPlanPhaseActive() && getActivePlanId() === g.planId && isLive(g);
|
|
28
|
+
}
|
|
29
|
+
// Rule 8's batch card stays quiet only for tools that spend an approval id
|
|
30
|
+
// (each is approved one by one), never under approvals = per-write.
|
|
31
|
+
setRule8BatchApprovedProvider((tool) => active !== null && approvalsMode === 'plan' && inScope(active) && toolSpendsApprovalId(tool));
|
|
32
|
+
function normTransport(t) {
|
|
33
|
+
return t?.trim().toUpperCase() || undefined;
|
|
34
|
+
}
|
|
35
|
+
function fromPayload(p, token) {
|
|
36
|
+
return {
|
|
37
|
+
planId: p.plan_id,
|
|
38
|
+
sessionId: p.session_id,
|
|
39
|
+
objects: p.objects,
|
|
40
|
+
...(p.transport ? { transport: p.transport } : {}),
|
|
41
|
+
newTransportAllowed: p.new_transport === true,
|
|
42
|
+
token,
|
|
43
|
+
...(typeof p.exp === 'number' ? { expiresAt: p.exp * 1000 } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Record the `approvals` setting (request_approval calls this with the config it loaded). */
|
|
47
|
+
export function noteApprovalsSetting(mode) {
|
|
48
|
+
approvalsMode = mode === 'plan' ? 'plan' : 'per-write';
|
|
49
|
+
}
|
|
50
|
+
export async function mintPlanGrant(a) {
|
|
51
|
+
const transport = normTransport(a.transport);
|
|
52
|
+
// Rows are re-validated here: a malformed or never-on-the-list row
|
|
53
|
+
// (transport, package, deploy) is dropped, never signed.
|
|
54
|
+
const objects = (Array.isArray(a.objects) ? a.objects : [])
|
|
55
|
+
.map((o) => toPlannedObject(o))
|
|
56
|
+
.filter((o) => o !== null);
|
|
57
|
+
const token = await mintPlanGrantToken({
|
|
58
|
+
plan_id: a.planId, session_id: a.sessionId, objects,
|
|
59
|
+
...(transport ? { transport } : {}), new_transport: !transport,
|
|
60
|
+
});
|
|
61
|
+
const p = await verifyPlanGrantToken(token);
|
|
62
|
+
if (!p)
|
|
63
|
+
throw new Error('plan grant could not be signed');
|
|
64
|
+
return fromPayload(p, token);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Install a grant. Keeps the SIGNED payload, never the caller's copy; a token
|
|
68
|
+
* that does not verify (or names another plan or session) clears the grant.
|
|
69
|
+
* A re-mint for the SAME plan and session keeps the transport already bound
|
|
70
|
+
* and whether the one new transport was already used.
|
|
71
|
+
*/
|
|
72
|
+
export async function setPlanGrant(g) {
|
|
73
|
+
if (!g) {
|
|
74
|
+
clearPlanGrant();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const p = await verifyPlanGrantToken(g.token);
|
|
78
|
+
if (!p || p.plan_id !== g.planId || p.session_id !== g.sessionId) {
|
|
79
|
+
clearPlanGrant();
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
noteApprovalsSetting(effectiveApprovals(await loadConfig()));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
noteApprovalsSetting('per-write'); // unreadable config: Rule 8 keeps its card
|
|
87
|
+
}
|
|
88
|
+
const samePlan = active !== null && active.planId === p.plan_id && active.sessionId === p.session_id;
|
|
89
|
+
active = fromPayload(p, g.token);
|
|
90
|
+
if (samePlan) {
|
|
91
|
+
boundTransport = boundTransport ?? normTransport(p.transport);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
boundTransport = normTransport(p.transport);
|
|
95
|
+
transportCreated = false;
|
|
96
|
+
grantCtsObject = undefined;
|
|
97
|
+
offeredDelta.clear();
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
export function getPlanGrant() { return active; }
|
|
102
|
+
/** A live grant covers requests right now (a step of its plan runs, not expired). */
|
|
103
|
+
export function isPlanGrantInScope() {
|
|
104
|
+
return active !== null && approvalsMode === 'plan' && inScope(active);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The claims a grant-minted id carries for one covered change: the listed
|
|
108
|
+
* type, the listed row's package, and the bound transport.
|
|
109
|
+
*/
|
|
110
|
+
export function grantClaimsFor(c) {
|
|
111
|
+
const type = normalizeObjectType(c.type ?? '');
|
|
112
|
+
const transport = getGrantTransport();
|
|
113
|
+
const names = memberNames(c.object);
|
|
114
|
+
const row = names.length === 1 && active
|
|
115
|
+
? active.objects.find((o) => o.type === type && o.name === names[0])
|
|
116
|
+
: undefined;
|
|
117
|
+
// Package: the listed row's; else the request's; else the grant's only
|
|
118
|
+
// package. A row with no package never yields an "any package" id when the
|
|
119
|
+
// request or the plan names one.
|
|
120
|
+
let pkg = row?.package;
|
|
121
|
+
if (!pkg && type !== 'CTS') {
|
|
122
|
+
const reqPkg = typeof c.package === 'string' ? c.package.trim().toUpperCase() : '';
|
|
123
|
+
if (reqPkg)
|
|
124
|
+
pkg = reqPkg;
|
|
125
|
+
else if (active) {
|
|
126
|
+
const pkgs = new Set(active.objects.map((o) => o.package).filter((x) => !!x));
|
|
127
|
+
if (pkgs.size === 1)
|
|
128
|
+
pkg = [...pkgs][0];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// A batch activate names several objects of several types: keep each
|
|
132
|
+
// member's listed type so a single-object activate can be checked too.
|
|
133
|
+
const members = names.length > 1 && active
|
|
134
|
+
? names.map((n) => active.objects.find((o) => o.name === n)).filter((o) => !!o)
|
|
135
|
+
.map((o) => ({ type: o.type, name: o.name }))
|
|
136
|
+
: undefined;
|
|
137
|
+
return {
|
|
138
|
+
type,
|
|
139
|
+
...(pkg ? { package: pkg } : {}),
|
|
140
|
+
...(transport ? { transport } : {}),
|
|
141
|
+
...(members && members.length > 0 ? { members } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function clearPlanGrant() {
|
|
145
|
+
active = null;
|
|
146
|
+
boundTransport = undefined;
|
|
147
|
+
transportCreated = false;
|
|
148
|
+
grantCtsObject = undefined;
|
|
149
|
+
offeredDelta.clear();
|
|
150
|
+
}
|
|
151
|
+
/** The objects ("TYPE:NAME") already offered for this plan on the add-to-list card. */
|
|
152
|
+
export function offeredDeltaObjects(planId) {
|
|
153
|
+
return offeredDelta.get(planId) ?? new Set();
|
|
154
|
+
}
|
|
155
|
+
/** Remember objects offered on the add-to-list card (whatever the answer). */
|
|
156
|
+
export function noteDeltaOffered(planId, objects) {
|
|
157
|
+
const set = offeredDelta.get(planId) ?? new Set();
|
|
158
|
+
for (const o of objects)
|
|
159
|
+
set.add(`${o.type}:${o.name}`);
|
|
160
|
+
offeredDelta.set(planId, set);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* May an object-list grant be minted now? Never on a production alias, never
|
|
164
|
+
* in advisory-only mode, never under `approvals = per-write` (it would cover
|
|
165
|
+
* nothing), and never when the config cannot be read. Headless runs never get
|
|
166
|
+
* here: they show no card.
|
|
167
|
+
*/
|
|
168
|
+
export async function planGrantAllowed(sapAlias, deps = {}) {
|
|
169
|
+
let cfg;
|
|
170
|
+
try {
|
|
171
|
+
cfg = await (deps.loadConfig ?? loadConfig)();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (effectiveApprovals(cfg) !== 'plan')
|
|
177
|
+
return false;
|
|
178
|
+
const alias = sapAlias && sapAlias !== 'none' ? sapAlias : undefined;
|
|
179
|
+
const role = alias ? cfg.sap?.[alias]?.role : undefined;
|
|
180
|
+
if (role === 'prd')
|
|
181
|
+
return false;
|
|
182
|
+
const effective = (deps.effectiveWriteMode ?? (await import('../repl/mode-cycle.js')).getEffectiveWriteMode)(cfg.write_mode ?? 'approval-gated', role);
|
|
183
|
+
return effective !== 'advisory-only';
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The default grant install for a card that approves the object list: check
|
|
187
|
+
* the policy, mint, install. True when a grant is now installed.
|
|
188
|
+
*/
|
|
189
|
+
export async function installPlanGrant(a, deps = {}) {
|
|
190
|
+
if (!(await planGrantAllowed(a.sapAlias, deps)))
|
|
191
|
+
return false;
|
|
192
|
+
return setPlanGrant(await mintPlanGrant({
|
|
193
|
+
planId: a.planId, sessionId: a.sessionId, objects: a.objects, ...(a.transport ? { transport: a.transport } : {}),
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
/** The transport writes under this grant use (recorded at mint, or the grant's own new one). */
|
|
197
|
+
export function getGrantTransport() {
|
|
198
|
+
return boundTransport ?? active?.transport;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The transport create the grant covered succeeded: bind its number. Only the
|
|
202
|
+
* create the grant covered (same description) binds, and only once.
|
|
203
|
+
*/
|
|
204
|
+
export function bindGrantTransport(transportNumber, description) {
|
|
205
|
+
const nr = normTransport(transportNumber);
|
|
206
|
+
if (!active || !transportCreated || boundTransport || !nr || !TR_RE.test(nr))
|
|
207
|
+
return false;
|
|
208
|
+
if (!grantCtsObject || canonicalApprovalObject(description) !== grantCtsObject)
|
|
209
|
+
return false;
|
|
210
|
+
boundTransport = nr;
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
const COVERED_OPS = new Set(['create', 'modify', 'activate']);
|
|
214
|
+
/** The objects a change names: a batch activate may name several, comma-joined. */
|
|
215
|
+
function memberNames(object) {
|
|
216
|
+
return String(object ?? '').split(',').map((m) => canonicalApprovalObject(m));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True only when EVERY change is a create/modify/activate of a listed object
|
|
220
|
+
* (or the one new transport the plan allowed), in a listed package, on the
|
|
221
|
+
* grant's transport — while a step of the grant's plan runs and before the
|
|
222
|
+
* grant expires. `g` must be the installed grant.
|
|
223
|
+
*/
|
|
224
|
+
export function grantCovers(g, changes, requestTransport) {
|
|
225
|
+
if (g !== active || !inScope(g))
|
|
226
|
+
return false;
|
|
227
|
+
if (!Array.isArray(changes) || changes.length === 0)
|
|
228
|
+
return false;
|
|
229
|
+
const tr = normTransport(requestTransport);
|
|
230
|
+
const bound = boundTransport ?? g.transport;
|
|
231
|
+
if (tr && bound && tr !== bound)
|
|
232
|
+
return false;
|
|
233
|
+
// The one new transport was covered but its number is not bound yet: no
|
|
234
|
+
// other transport may be bound in its place.
|
|
235
|
+
if (tr && !bound && transportCreated)
|
|
236
|
+
return false;
|
|
237
|
+
const grantPackages = new Set(g.objects.map((o) => o.package).filter((x) => !!x));
|
|
238
|
+
for (const c of changes) {
|
|
239
|
+
if (!c || !COVERED_OPS.has(c.op))
|
|
240
|
+
return false;
|
|
241
|
+
const type = normalizeObjectType(c.type ?? '');
|
|
242
|
+
if (type === 'WAPA')
|
|
243
|
+
return false;
|
|
244
|
+
if (type === 'CTS') {
|
|
245
|
+
// A request that names a transport AND asks for a new one is two transports.
|
|
246
|
+
if (tr)
|
|
247
|
+
return false;
|
|
248
|
+
if (!(c.op === 'create' && g.newTransportAllowed && !transportCreated && !bound))
|
|
249
|
+
return false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// Every non-CTS change needs a BOUND transport: the plan's recorded one
|
|
253
|
+
// (or the session transport adopted at mint), or the one the grant's own
|
|
254
|
+
// transport create made (bindGrantTransport). A request never binds one,
|
|
255
|
+
// so the model cannot pick some other open transport. A named transport
|
|
256
|
+
// must equal it (checked above); with none named, the write falls back to
|
|
257
|
+
// the session transport, which must equal it. So every grant-minted id
|
|
258
|
+
// carries a transport claim.
|
|
259
|
+
if (!bound)
|
|
260
|
+
return false;
|
|
261
|
+
if (!tr && normTransport(getCurrentTransport()) !== bound)
|
|
262
|
+
return false;
|
|
263
|
+
const names = memberNames(c.object);
|
|
264
|
+
// A batch activate row carries one type for objects of several types.
|
|
265
|
+
const anyType = c.op === 'activate' && names.length > 1;
|
|
266
|
+
const reqPkg = typeof c.package === 'string' ? c.package.trim().toUpperCase() : '';
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
if (!name)
|
|
269
|
+
return false;
|
|
270
|
+
const listed = g.objects.find((o) => o.name === name && (anyType || o.type === type));
|
|
271
|
+
if (!listed)
|
|
272
|
+
return false;
|
|
273
|
+
if (reqPkg) {
|
|
274
|
+
if (listed.package) {
|
|
275
|
+
if (reqPkg !== listed.package)
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
else if (grantPackages.size > 0 && !grantPackages.has(reqPkg)) {
|
|
279
|
+
// A row with no package never means "any package" when the plan names one.
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Record a covered request: the one new transport is used up. A request never
|
|
289
|
+
* binds a transport (only the mint and bindGrantTransport do).
|
|
290
|
+
*/
|
|
291
|
+
export function noteGrantUse(changes) {
|
|
292
|
+
const cts = changes.find((c) => c.op === 'create' && normalizeObjectType(c.type ?? '') === 'CTS');
|
|
293
|
+
if (cts) {
|
|
294
|
+
transportCreated = true;
|
|
295
|
+
grantCtsObject = canonicalApprovalObject(cts.object);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -20,6 +20,7 @@ const MUTABLE_KEYS = new Set([
|
|
|
20
20
|
'write_mode',
|
|
21
21
|
'plan_mode',
|
|
22
22
|
'plan_audit',
|
|
23
|
+
'approvals',
|
|
23
24
|
'no_sap',
|
|
24
25
|
'llm.mode',
|
|
25
26
|
'llm.ai_hub_base_url',
|
|
@@ -72,6 +73,7 @@ const SYSTEM_ROLE_VALUES = ['dev', 'qas', 'prd'];
|
|
|
72
73
|
const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local'];
|
|
73
74
|
const PLAN_MODE_VALUES = ['step', 'guarded'];
|
|
74
75
|
const PLAN_AUDIT_VALUES = ['on', 'off'];
|
|
76
|
+
const APPROVALS_VALUES = ['plan', 'per-write'];
|
|
75
77
|
/**
|
|
76
78
|
* Thrown on validation failure. The outer CLI dispatcher converts to
|
|
77
79
|
* `console.error` + `process.exit(exitCode)`. Throwing instead of calling
|
|
@@ -555,6 +557,14 @@ export async function runConfigSet(args) {
|
|
|
555
557
|
}
|
|
556
558
|
cfg.plan_audit = value;
|
|
557
559
|
break;
|
|
560
|
+
case 'approvals':
|
|
561
|
+
// 'plan' = the plan's one approval covers its listed objects (default);
|
|
562
|
+
// 'per-write' = a card before every write.
|
|
563
|
+
if (!APPROVALS_VALUES.includes(value)) {
|
|
564
|
+
err(`Invalid approvals "${value}". Allowed: ${APPROVALS_VALUES.join(', ')}`);
|
|
565
|
+
}
|
|
566
|
+
cfg.approvals = value;
|
|
567
|
+
break;
|
|
558
568
|
case 'llm.mode':
|
|
559
569
|
if (!LLM_MODE_VALUES.includes(value)) {
|
|
560
570
|
err(`Invalid llm.mode "${value}". Allowed: ${LLM_MODE_VALUES.join(', ')}`);
|
|
@@ -679,6 +689,8 @@ export function validateValue(key, value) {
|
|
|
679
689
|
return PLAN_MODE_VALUES.includes(value);
|
|
680
690
|
case 'plan_audit':
|
|
681
691
|
return PLAN_AUDIT_VALUES.includes(value);
|
|
692
|
+
case 'approvals':
|
|
693
|
+
return APPROVALS_VALUES.includes(value);
|
|
682
694
|
case 'no_sap':
|
|
683
695
|
return coerceBool(value) !== null;
|
|
684
696
|
case 'llm.mode':
|
|
@@ -26,6 +26,7 @@ import { promises as fs } from 'node:fs';
|
|
|
26
26
|
import * as path from 'node:path';
|
|
27
27
|
import { readDeployYaml } from '../tools/fiori/deploy/yaml-read.js';
|
|
28
28
|
import { cardLine } from '../lane/card.js';
|
|
29
|
+
import { phaseDisplayTitle } from '../projects/plan-run.js';
|
|
29
30
|
/** The last value of a work field across phases (last written wins, as in planCompletionLines). */
|
|
30
31
|
function lastWork(phases, pick) {
|
|
31
32
|
for (const p of [...phases].reverse()) {
|
|
@@ -83,13 +84,14 @@ export async function gatherDeployCardFacts(a) {
|
|
|
83
84
|
}
|
|
84
85
|
return f;
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
+
/** `nextStepTitle` is the deploy step's display title (phaseDisplayTitle) — never its id. */
|
|
88
|
+
export function renderDeployCard(f, nextStepTitle) {
|
|
87
89
|
const v = (s, fallback) => (s ? cardLine(s) : fallback);
|
|
88
90
|
const lines = [
|
|
89
91
|
'',
|
|
90
|
-
`Deploy the app to SAP (next
|
|
92
|
+
`Deploy the app to SAP (next step: ${cardLine(nextStepTitle)})`,
|
|
91
93
|
` System ${v(f.alias, '(none)')}${f.systemUrl ? ` — ${cardLine(f.systemUrl)}` : ''}${f.client ? ` · client ${cardLine(f.client)}` : ''}`,
|
|
92
|
-
` App ${v(f.appDir, '(recorded by
|
|
94
|
+
` App ${v(f.appDir, '(recorded by the app build step)')}`,
|
|
93
95
|
` BSP name ${v(f.bsp, 'set in this phase (max 15 characters, Z/Y, uppercase)')}`,
|
|
94
96
|
` Package ${v(f.package, 'set in this phase (the backend package)')}`,
|
|
95
97
|
` Transport ${f.package === '$TMP' ? '(none — $TMP, local only)' : v(f.transport, 'the session transport')}`,
|
|
@@ -117,12 +119,15 @@ export async function askDeployCard(a) {
|
|
|
117
119
|
...(a.deps?.loadConfig ? { loadConfig: a.deps.loadConfig } : {}),
|
|
118
120
|
...(a.deps?.effectiveWriteMode ? { effectiveWriteMode: a.deps.effectiveWriteMode } : {}),
|
|
119
121
|
});
|
|
120
|
-
|
|
122
|
+
// Task 13 — the step title on screen, never the phase id.
|
|
123
|
+
const next = a.phases.find((p) => p.id === a.nextPhaseId);
|
|
124
|
+
const title = next ? phaseDisplayTitle(next, a.phases) : 'the deploy step';
|
|
125
|
+
a.log(...renderDeployCard(facts, title));
|
|
121
126
|
if (facts.blocked)
|
|
122
127
|
return { outcome: 'blocked', reason: facts.blocked };
|
|
123
128
|
const { askYesNo, isYes } = await import('../projects/yes-no.js');
|
|
124
129
|
const answer = await askYesNo({
|
|
125
|
-
question: `Deploy now — run ${
|
|
130
|
+
question: `Deploy now — run ${title} with the stored SAP logon for ${facts.alias}?`,
|
|
126
131
|
confirm: a.confirm,
|
|
127
132
|
prompt: a.prompt,
|
|
128
133
|
});
|
|
@@ -33,7 +33,7 @@ export async function runExportAuditCommand(opts) {
|
|
|
33
33
|
const costEntries = opts.costEntries ?? (await readCostLog(session.id));
|
|
34
34
|
// The exported .md is a file the customer opens and forwards, so it carries
|
|
35
35
|
// their credits (not our COGS) whenever credits mode is active.
|
|
36
|
-
const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot() });
|
|
36
|
+
const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot(), ...(opts.operator ? { operator: opts.operator } : {}) });
|
|
37
37
|
const name = opts.filename && opts.filename.trim().length > 0
|
|
38
38
|
? opts.filename.trim()
|
|
39
39
|
: defaultAuditFilename(session);
|
|
@@ -69,6 +69,29 @@ const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_o
|
|
|
69
69
|
const EXISTENCE_READ_RE = /^sap_(object_structure|get_source|transport_for_object)/;
|
|
70
70
|
/** Any row the auditor may see is the union of the two tiers. */
|
|
71
71
|
const EVIDENCE_TOOL_RE = new RegExp(`${PROTECTED_TOOL_RE.source}|${EXISTENCE_READ_RE.source}`);
|
|
72
|
+
/**
|
|
73
|
+
* 2026-10-04 (live W4 false-warn, FI Document Aging c1.design): RELEASE-STATE
|
|
74
|
+
* evidence. A design phase proves "I_JournalEntry is C1 released" by reading
|
|
75
|
+
* the API release registry — `sap_api_state`, or `sap_sql_query` against
|
|
76
|
+
* ARS_W_API_STATE (the only way to see COMPATIBILITY_CONTRACT). Neither tool
|
|
77
|
+
* matched the tiers above, so three successful ARS queries were filtered out
|
|
78
|
+
* and the auditor warned that only a sap_get_source read was "in the window".
|
|
79
|
+
* Like the existence reads these are ground truth the audited model did not
|
|
80
|
+
* author (a SELECT on the live registry), so they join the FILLABLE existence
|
|
81
|
+
* tier. sap_sql_query is admitted ONLY when the query reads an ARS_* API
|
|
82
|
+
* registry table — arbitrary data queries stay out (row-cap safety).
|
|
83
|
+
*/
|
|
84
|
+
const RELEASE_STATE_TOOL_RE = /^sap_api_state$/;
|
|
85
|
+
const RELEASE_STATE_SQL_RE = /\bars_(?:w_)?api_\w+/i;
|
|
86
|
+
/** True for a release-state proof row (tool + args). Exported for tests. */
|
|
87
|
+
export function isReleaseStateRow(toolName, args) {
|
|
88
|
+
if (RELEASE_STATE_TOOL_RE.test(toolName))
|
|
89
|
+
return true;
|
|
90
|
+
if (toolName !== 'sap_sql_query' || !args || typeof args !== 'object')
|
|
91
|
+
return false;
|
|
92
|
+
const q = args.query;
|
|
93
|
+
return typeof q === 'string' && RELEASE_STATE_SQL_RE.test(q);
|
|
94
|
+
}
|
|
72
95
|
/**
|
|
73
96
|
* CAP phase 2 (2026-09-30): a CAP phase writes LOCAL files and proves itself
|
|
74
97
|
* with `cds build` + the Fiori/CAP smokes — none of which are sap_* rows. Without
|
|
@@ -138,6 +161,9 @@ const ARGS_COMPACT_CHARS = 80;
|
|
|
138
161
|
*/
|
|
139
162
|
const INACTIVE_LIST_CAP = 4000;
|
|
140
163
|
const ATC_RESULT_CAP = 500;
|
|
164
|
+
/** Release-state rows: the auditor must see each object's RELEASE_STATE + contract, not a header. */
|
|
165
|
+
const RELEASE_STATE_RESULT_CAP = 800;
|
|
166
|
+
const SQL_QUERY_ARGS_CHARS = 200;
|
|
141
167
|
/** Appended when the inactive-objects list itself is clipped — see the contract. */
|
|
142
168
|
const INACTIVE_CLIP_MARKER = ' … (list clipped — absence not verifiable from this row; use object_structure version)';
|
|
143
169
|
function resultCapFor(toolName) {
|
|
@@ -145,6 +171,8 @@ function resultCapFor(toolName) {
|
|
|
145
171
|
return INACTIVE_LIST_CAP;
|
|
146
172
|
if (/^sap_atc_run/.test(toolName))
|
|
147
173
|
return ATC_RESULT_CAP;
|
|
174
|
+
if (toolName === 'sap_api_state' || toolName === 'sap_sql_query')
|
|
175
|
+
return RELEASE_STATE_RESULT_CAP;
|
|
148
176
|
return RESULT_EXCERPT_CHARS;
|
|
149
177
|
}
|
|
150
178
|
function normalizeRow(raw, capPhase) {
|
|
@@ -176,13 +204,22 @@ function normalizeRow(raw, capPhase) {
|
|
|
176
204
|
* arbitrary shell rows).
|
|
177
205
|
*/
|
|
178
206
|
function isEvidenceRow(row, capPhase) {
|
|
179
|
-
return EVIDENCE_TOOL_RE.test(row.toolName) || isCapProof(row.toolName, row.args, capPhase)
|
|
207
|
+
return EVIDENCE_TOOL_RE.test(row.toolName) || isCapProof(row.toolName, row.args, capPhase)
|
|
208
|
+
|| isReleaseStateRow(row.toolName, row.args);
|
|
180
209
|
}
|
|
181
210
|
/** Render the args as `name/type` when available, else a compact fallback. */
|
|
182
211
|
function renderArgs(args) {
|
|
183
212
|
if (args === null || args === undefined || typeof args !== 'object')
|
|
184
213
|
return '';
|
|
185
214
|
const a = args;
|
|
215
|
+
// A release-state SQL row is judged by WHICH objects it asked for — show
|
|
216
|
+
// the query itself (one line), not an 80-char JSON clip that cuts the WHERE.
|
|
217
|
+
if (typeof a.query === 'string') {
|
|
218
|
+
const q = a.query.replace(/\s+/g, ' ').trim();
|
|
219
|
+
return q.length <= SQL_QUERY_ARGS_CHARS ? q : q.slice(0, SQL_QUERY_ARGS_CHARS) + '…';
|
|
220
|
+
}
|
|
221
|
+
if (typeof a.objects === 'string')
|
|
222
|
+
return a.objects;
|
|
186
223
|
const name = a.name ?? a.object_name;
|
|
187
224
|
const type = a.type ?? a.object_type;
|
|
188
225
|
if (typeof name === 'string' && name.length > 0) {
|
|
@@ -244,6 +281,9 @@ function renderRow(row) {
|
|
|
244
281
|
* are excluded — so an audit judging attempt N of a phase never sees the
|
|
245
282
|
* rows of an earlier attempt that shares the same session JSONL (two
|
|
246
283
|
* attempts in one CLI process). Untimestamped rows are kept (fail-open).
|
|
284
|
+
* - When `untilIso` is given, rows at or after it are excluded too, so the
|
|
285
|
+
* window is [sinceIso, untilIso): in a continuous run the later phases'
|
|
286
|
+
* rows neither leak into nor evict this phase's evidence. Fail-open too.
|
|
247
287
|
* - D-F two-tier cap: chronological order is preserved (newest-last). When
|
|
248
288
|
* the evidence exceeds `maxRows` (default 60), the OLDEST existence-read
|
|
249
289
|
* rows are dropped first; a PROTECTED (write / verify-proof) row is dropped
|
|
@@ -259,6 +299,7 @@ export async function extractAuditEvidence(sessionId, opts) {
|
|
|
259
299
|
const maxRows = opts?.maxRows ?? DEFAULT_MAX_ROWS;
|
|
260
300
|
const capPhase = opts?.capPhase === true;
|
|
261
301
|
const sinceIso = opts?.sinceIso;
|
|
302
|
+
const untilIso = opts?.untilIso;
|
|
262
303
|
const filePath = path.join(checkpointsRoot(), sessionId, 'tool-calls.jsonl');
|
|
263
304
|
let content;
|
|
264
305
|
try {
|
|
@@ -287,6 +328,11 @@ export async function extractAuditEvidence(sessionId, opts) {
|
|
|
287
328
|
// timestamp is never excluded (fail-open).
|
|
288
329
|
if (sinceIso && row.at !== null && row.at < sinceIso)
|
|
289
330
|
continue;
|
|
331
|
+
// 2026-10-04 (continuous run) — the phase's END bound: later phases of the
|
|
332
|
+
// same run share this JSONL; their rows must neither be judged as this
|
|
333
|
+
// phase's evidence nor evict its rows from the 60-row cap.
|
|
334
|
+
if (untilIso && row.at !== null && row.at >= untilIso)
|
|
335
|
+
continue;
|
|
290
336
|
rows.push(row);
|
|
291
337
|
}
|
|
292
338
|
if (rows.length === 0)
|
|
@@ -170,6 +170,11 @@ export const PLAN_AUDIT_PROMPT_CONTRACT = [
|
|
|
170
170
|
' claim. Default this to WARN, never to fail. (This is the load-bearing rule: an unsure',
|
|
171
171
|
' judgment is a WARN, not a block.)',
|
|
172
172
|
'',
|
|
173
|
+
'RELEASE-STATE claims ("C1 released", "released API"): proven by an ok sap_api_state row, or an',
|
|
174
|
+
'ok sap_sql_query row on ARS_W_API_STATE whose result lists the object with RELEASE_STATE',
|
|
175
|
+
'RELEASED (and its COMPATIBILITY_CONTRACT, e.g. C1). One such query (e.g. LIKE \'I_JOURNALENTRY%\')',
|
|
176
|
+
'can prove several objects. A sap_get_source read alone does not prove a release state.',
|
|
177
|
+
'',
|
|
173
178
|
'OBJECT-LIFECYCLE PROOF TABLE (§5) — classify each claimed object by its type code (visible',
|
|
174
179
|
'in the evidence rows as name/TYPE and in work.generated) and require ONLY the proof its',
|
|
175
180
|
'class needs. The absence of sap_activate is a finding ONLY for the source-activatable class:',
|
|
@@ -406,7 +411,7 @@ export function parseAuditVerdict(text) {
|
|
|
406
411
|
* unknown phase) — runPhaseAudit converts those to infra_failed.
|
|
407
412
|
*/
|
|
408
413
|
async function buildAuditPrompt(args) {
|
|
409
|
-
const { envelope, phaseId, sessionId, sinceIso } = args;
|
|
414
|
+
const { envelope, phaseId, sessionId, sinceIso, untilIso } = args;
|
|
410
415
|
if (envelope.artefactType !== 'plan') {
|
|
411
416
|
throw new Error(`expected a plan envelope, got '${envelope.artefactType}'`);
|
|
412
417
|
}
|
|
@@ -438,9 +443,12 @@ async function buildAuditPrompt(args) {
|
|
|
438
443
|
const capPhase = typeof phase.layer === 'string' && phase.layer.startsWith('cap.');
|
|
439
444
|
const evidence = await extractAuditEvidence(sessionId, {
|
|
440
445
|
...(sinceIso ? { sinceIso } : {}),
|
|
446
|
+
...(untilIso ? { untilIso } : {}),
|
|
441
447
|
...(capPhase ? { capPhase } : {}),
|
|
442
448
|
});
|
|
443
|
-
const windowAttr = sinceIso
|
|
449
|
+
const windowAttr = sinceIso
|
|
450
|
+
? (untilIso ? ` window="this phase only, ${sinceIso} to ${untilIso}"` : ` window="this attempt only, since ${sinceIso}"`)
|
|
451
|
+
: '';
|
|
444
452
|
return [
|
|
445
453
|
`Independent audit of plan phase "${phaseId}" — plan "${envelope.title}" (envelope v${envelope.version}).`,
|
|
446
454
|
'',
|
|
@@ -461,15 +469,22 @@ async function buildAuditPrompt(args) {
|
|
|
461
469
|
// the first cut did, inside the static contract) was FALSE for unwindowed
|
|
462
470
|
// legacy re-audits over a multi-attempt JSONL — coaching the exact
|
|
463
471
|
// cross-attempt misread this fix exists to close.
|
|
464
|
-
...(sinceIso
|
|
472
|
+
...(sinceIso && untilIso
|
|
465
473
|
? [
|
|
466
|
-
`The <evidence> block above covers THIS phase
|
|
467
|
-
'(
|
|
468
|
-
'exactly these rows; do not infer that a step is missing merely because
|
|
469
|
-
'is absent.',
|
|
474
|
+
`The <evidence> block above covers THIS phase only — rows before ${sinceIso} and from ${untilIso} on`,
|
|
475
|
+
'(other phases of the same run, or earlier attempts) have been filtered out. Judge the manifest against',
|
|
476
|
+
'exactly these rows; do not infer that a step is missing merely because another phase\'s rows are absent.',
|
|
470
477
|
'',
|
|
471
478
|
]
|
|
472
|
-
:
|
|
479
|
+
: sinceIso
|
|
480
|
+
? [
|
|
481
|
+
`The <evidence> block above covers THIS phase attempt only — rows recorded before ${sinceIso}`,
|
|
482
|
+
'(earlier attempts of the same phase) have been filtered out. Judge the manifest against',
|
|
483
|
+
'exactly these rows; do not infer that a step is missing merely because an earlier attempt',
|
|
484
|
+
'is absent.',
|
|
485
|
+
'',
|
|
486
|
+
]
|
|
487
|
+
: []),
|
|
473
488
|
PLAN_AUDIT_PROMPT_CONTRACT,
|
|
474
489
|
].join('\n');
|
|
475
490
|
}
|