@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.
Files changed (68) 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/agent/turn-error-ux.js +40 -0
  9. package/dist/approvals/grant-claims.js +70 -0
  10. package/dist/approvals/jwt.js +21 -1
  11. package/dist/approvals/plan-grant.js +297 -0
  12. package/dist/commands/config-set.js +12 -0
  13. package/dist/commands/deploy-card.js +10 -5
  14. package/dist/commands/export-audit.js +1 -1
  15. package/dist/commands/plan-audit-evidence.js +47 -1
  16. package/dist/commands/plan-audit.js +23 -8
  17. package/dist/commands/plan-batch-audit.js +349 -0
  18. package/dist/commands/plan-boundary.js +88 -0
  19. package/dist/commands/plan-chain.js +409 -566
  20. package/dist/commands/plan-continue.js +21 -7
  21. package/dist/commands/plan-gate.js +28 -49
  22. package/dist/commands/plan-reground.js +174 -0
  23. package/dist/commands/plan-resume.js +227 -103
  24. package/dist/commands/plan-sap-pause.js +105 -0
  25. package/dist/commands/plan-startup.js +218 -0
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/run.js +4 -4
  29. package/dist/lane/app-chain.js +88 -9
  30. package/dist/one-shot.js +1 -0
  31. package/dist/projects/extract-plan.js +34 -1
  32. package/dist/projects/handover-md.js +1 -1
  33. package/dist/projects/plan-objects.js +75 -0
  34. package/dist/projects/plan-run.js +74 -18
  35. package/dist/projects/plan-schema.js +6 -0
  36. package/dist/projects/plan-sources.js +199 -0
  37. package/dist/projects/promote-command.js +1 -1
  38. package/dist/projects/save-command.js +11 -13
  39. package/dist/projects/saved-lines.js +36 -0
  40. package/dist/renderer/checklist-format.js +5 -5
  41. package/dist/renderer/machine-blocks.js +141 -0
  42. package/dist/renderer/tool-labels.js +3 -0
  43. package/dist/repl/plan-hop.js +22 -0
  44. package/dist/repl/rule8-detector.js +16 -1
  45. package/dist/repl.js +320 -55
  46. package/dist/router/router-core.js +2 -2
  47. package/dist/sap/reachability-state.js +25 -0
  48. package/dist/session/audit-export.js +10 -16
  49. package/dist/session/recap.js +16 -26
  50. package/dist/skills/bundled-skills.js +81 -81
  51. package/dist/tools/_filesystem-shared.js +37 -0
  52. package/dist/tools/approval.js +48 -2
  53. package/dist/tools/dispatch-skill.js +3 -2
  54. package/dist/tools/filesystem/file-read.js +5 -3
  55. package/dist/tools/filesystem/glob.js +27 -4
  56. package/dist/tools/fiori/tools.js +6 -0
  57. package/dist/tools/lane/app-lane-resolve.js +1 -1
  58. package/dist/tools/sap-read.js +37 -3
  59. package/dist/tools/sap-write.js +65 -0
  60. package/dist/tools/text-elements/pool.js +250 -0
  61. package/dist/tools/text-elements/tools.js +433 -0
  62. package/dist/tools/tool-loading.js +3 -0
  63. package/dist/tools/transport.js +26 -5
  64. package/dist/tools/write-mode.js +4 -3
  65. package/dist/ui/app.js +4 -0
  66. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  67. package/package.json +120 -121
  68. 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
- export function renderDeployCard(f, nextPhaseId) {
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 phase: ${cardLine(nextPhaseId)})`,
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 ui.build)')}`,
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
- a.log(...renderDeployCard(facts, a.nextPhaseId));
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 ${a.nextPhaseId} with the stored SAP logon for ${facts.alias}?`,
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 ? ` window="this attempt only, since ${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 attempt only — rows recorded before ${sinceIso}`,
467
- '(earlier attempts of the same phase) have been filtered out. Judge the manifest against',
468
- 'exactly these rows; do not infer that a step is missing merely because an earlier attempt',
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
  }