@cspeach/cli 1.1.48 → 1.1.50
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/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +90 -8
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/models/server-config.js +13 -0
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/projects/workspace.js +6 -3
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +37 -14
- package/dist/router/router-core.js +5 -3
- package/dist/session/fresh-start.js +15 -0
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +24 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/footer-line.js +19 -1
- package/package.json +3 -3
- package/dist/projects/merge-cca.js +0 -311
- package/dist/tools/project/cca_merge.js +0 -174
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function startFreshConversation(session) {
|
|
2
|
+
session.messages = [];
|
|
3
|
+
session.turnInterrupted = false;
|
|
4
|
+
session.turnInterruptedReason = undefined;
|
|
5
|
+
delete session.last_context_tokens;
|
|
6
|
+
delete session.lastAutoCompactTurn;
|
|
7
|
+
// An open plan step does not survive: "continue" must not resume it.
|
|
8
|
+
session.plan_in_flight = null;
|
|
9
|
+
}
|
|
10
|
+
/** The one line /new prints. */
|
|
11
|
+
export function freshStartText(lane) {
|
|
12
|
+
return lane === 'chat'
|
|
13
|
+
? 'Fresh start · the conversation is cleared.'
|
|
14
|
+
: 'Fresh start · the conversation is cleared · the next prompt picks a skill again';
|
|
15
|
+
}
|
package/dist/skill-catalog.js
CHANGED
|
@@ -200,13 +200,6 @@ export const SKILL_CATALOG = [
|
|
|
200
200
|
whenToUse: 'pick this when a multi-dev team ran scoped fix slices in parallel — merges N progress files for /abap-upgrade-verify',
|
|
201
201
|
asks: 'which progress files. e.g. "merge the three @upgrade-progress files here"',
|
|
202
202
|
},
|
|
203
|
-
{
|
|
204
|
-
name: 'abap-cca-merge',
|
|
205
|
-
category: 'Upgrade / Migrate',
|
|
206
|
-
description: 'Reconcile multiple parallel /abap-cca assessment files into one consolidated cca-assessment',
|
|
207
|
-
whenToUse: 'pick this when multiple consultants ran scoped /abap-cca slices — merges N assessments into one team view',
|
|
208
|
-
asks: 'which assessments. e.g. "merge the ZFI and ZSD cca files"',
|
|
209
|
-
},
|
|
210
203
|
{
|
|
211
204
|
name: 'abap-migrate',
|
|
212
205
|
category: 'Upgrade / Migrate',
|
|
@@ -277,6 +270,10 @@ export const SKILL_CATALOG = [
|
|
|
277
270
|
export const SKILL_ALIASES = {
|
|
278
271
|
'abap-dump-analyze': 'abap-dump',
|
|
279
272
|
'abap-dump-triage': 'abap-dump',
|
|
273
|
+
// Drift spec §3.6 (2026-10-20) — /abap-cca-merge is cut. A stale proxy
|
|
274
|
+
// router or an old manifest may still answer with the old name; a chat
|
|
275
|
+
// hand_off to it lands here too (tools/hand-off.ts normaliseSkill).
|
|
276
|
+
'abap-cca-merge': 'abap-cca',
|
|
280
277
|
};
|
|
281
278
|
/**
|
|
282
279
|
* Resolve a (possibly retired) skill name to its current catalog name.
|
|
@@ -45,6 +45,13 @@ export const BLOCKED_BASENAME_RE = /^\.env(\.|$)/i;
|
|
|
45
45
|
* (skill detail files) — only the rest of `.cspeach/` is blocked.
|
|
46
46
|
*/
|
|
47
47
|
export const DENYLIST_DESCRIPTION = [...BLOCKED_PREFIXES, '.env*'].join(', ');
|
|
48
|
+
/**
|
|
49
|
+
* The paging trailer of file_read and read_document (also refused in SAP
|
|
50
|
+
* writes: tools/cut-markers.ts).
|
|
51
|
+
*/
|
|
52
|
+
export function rereadTrailer(more, nextOffset) {
|
|
53
|
+
return `... (truncated, ${more} more lines — re-read with offset=${nextOffset})`;
|
|
54
|
+
}
|
|
48
55
|
/**
|
|
49
56
|
* Executable / script file extensions the filesystem write tools refuse to
|
|
50
57
|
* create. Defense-in-depth: shell_exec / background_run only run a safelisted
|
|
@@ -131,3 +131,56 @@ export function pointsIntoSharedCspeach(cwd, realTarget) {
|
|
|
131
131
|
}
|
|
132
132
|
return false;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Task 4 (B3, 2026-10-20) — the files a team project is made of. CSPeach
|
|
136
|
+
* writes them (team commands, cards, team_lane_move); a model's file write
|
|
137
|
+
* never does. Root-relative path, either separator; segments compared the way
|
|
138
|
+
* Windows opens them, so `LANES.JSON`, `plan\x.json` or `lanes.json.` count.
|
|
139
|
+
*/
|
|
140
|
+
const TEAM_OWNED_FILES = ['cspeach-team.json', 'lanes.json'];
|
|
141
|
+
const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline'];
|
|
142
|
+
export function isTeamOwnedRel(rel) {
|
|
143
|
+
const segs = rel.split(/[\\/]+/).filter((s) => s !== '' && s !== '.');
|
|
144
|
+
if (!segs.length)
|
|
145
|
+
return false;
|
|
146
|
+
const first = nameKey(segs[0]);
|
|
147
|
+
if (TEAM_OWNED_DIRS.includes(first))
|
|
148
|
+
return true;
|
|
149
|
+
return segs.length === 1 && TEAM_OWNED_FILES.includes(first);
|
|
150
|
+
}
|
|
151
|
+
/** The refusal for a model write to one of the team project's own files. */
|
|
152
|
+
export const TEAM_FILE_REFUSAL = 'This file belongs to the project and CSPeach writes it. Say what you want changed instead.';
|
|
153
|
+
/**
|
|
154
|
+
* True when `cwd` is a team folder and the REAL landing path of a write lies
|
|
155
|
+
* on one of its own files (isTeamOwnedRel), whatever spelling, link or short
|
|
156
|
+
* name got it there. Only refuses; never maps anything. A folder without a
|
|
157
|
+
* team project: always false.
|
|
158
|
+
*/
|
|
159
|
+
export function pointsAtTeamFile(cwd, realTarget) {
|
|
160
|
+
if (teamWorkRoot(cwd) === null)
|
|
161
|
+
return false;
|
|
162
|
+
const absRoot = path.resolve(cwd);
|
|
163
|
+
const realOf = (p) => { try {
|
|
164
|
+
return realpathSync.native(p);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
} };
|
|
169
|
+
for (const base of new Set([absRoot, realOf(absRoot) ?? absRoot])) {
|
|
170
|
+
const rel = path.relative(base, realTarget);
|
|
171
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
172
|
+
continue;
|
|
173
|
+
if (isTeamOwnedRel(rel))
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// A link into one of the project folders: the real folder, not its name.
|
|
177
|
+
for (const dir of TEAM_OWNED_DIRS) {
|
|
178
|
+
const real = realOf(path.join(absRoot, dir));
|
|
179
|
+
if (real === null)
|
|
180
|
+
continue;
|
|
181
|
+
const rel = path.relative(real, realTarget);
|
|
182
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
package/dist/tools/approval.js
CHANGED
|
@@ -3,7 +3,7 @@ import { notModifiableText } from './release-texts.js';
|
|
|
3
3
|
import { isModifiable, knownObjectCount } from '@cspeach/sap-client';
|
|
4
4
|
import { registerTool } from './index.js';
|
|
5
5
|
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
6
|
-
import { effectiveRisk,
|
|
6
|
+
import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
|
|
7
7
|
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
8
8
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
9
9
|
/**
|
|
@@ -19,6 +19,7 @@ function noteApprovedTransport(t) {
|
|
|
19
19
|
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
20
20
|
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
21
21
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
22
|
+
import { collapseImplicitActivates } from '../approvals/visible-changes.js';
|
|
22
23
|
import { loadConfig, effectiveApprovals } from '../config/loader.js';
|
|
23
24
|
import { getPlanGrant, grantCovers, noteGrantUse, noteApprovalsSetting, grantClaimsFor } from '../approvals/plan-grant.js';
|
|
24
25
|
import { normalizeObjectType } from '../projects/plan-objects.js';
|
|
@@ -29,6 +30,8 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
|
29
30
|
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
30
31
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
31
32
|
import { displayTransport } from '../approvals/op-labels.js';
|
|
33
|
+
import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
|
|
34
|
+
import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
|
|
32
35
|
import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
|
|
33
36
|
import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
|
|
34
37
|
/**
|
|
@@ -171,6 +174,39 @@ async function attachReleaseFacts(changes, ctx) {
|
|
|
171
174
|
}
|
|
172
175
|
return null;
|
|
173
176
|
}
|
|
177
|
+
/** What the owning-request lookup accepts as a name (sap-client's identifier guard): nothing else is looked up. */
|
|
178
|
+
const LOOKUP_NAME = /^[A-Z0-9_/]+$/;
|
|
179
|
+
/**
|
|
180
|
+
* Task 8 (B11) — before any card: for every modify/delete, the open request
|
|
181
|
+
* that already holds the object, looked up the way the write tools resolve it
|
|
182
|
+
* (resolveWriteTransport: one owning request forces the write into it). The
|
|
183
|
+
* model's value is dropped first, on every change. One owning request →
|
|
184
|
+
* `c.owningTransport`; several candidates, a capped or failed lookup, or a
|
|
185
|
+
* free object → nothing, so the card is today's. Standalone's `ctx.adt`
|
|
186
|
+
* throws on every call, which is the failed-lookup path. Never refuses.
|
|
187
|
+
*/
|
|
188
|
+
async function attachOwningTransport(changes, ctx) {
|
|
189
|
+
for (const c of changes) {
|
|
190
|
+
if (!c)
|
|
191
|
+
continue;
|
|
192
|
+
// Never the model's — on any change.
|
|
193
|
+
delete c.owningTransport;
|
|
194
|
+
if (c.op !== 'modify' && c.op !== 'delete')
|
|
195
|
+
continue;
|
|
196
|
+
const name = bareObjectName(String(c.object ?? ''));
|
|
197
|
+
if (!LOOKUP_NAME.test(name))
|
|
198
|
+
continue;
|
|
199
|
+
try {
|
|
200
|
+
const r = await ctx.adt.transportForObject(typeCode(c.type), name);
|
|
201
|
+
const t = typeof r?.transport === 'string' ? r.transport.trim().toUpperCase() : '';
|
|
202
|
+
if (!r?.capped && TRANSPORT_REQUEST.test(t))
|
|
203
|
+
c.owningTransport = t;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Best-effort, as at write time: the card stays as it is today.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
registerTool({
|
|
175
211
|
name: 'request_approval',
|
|
176
212
|
description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
|
|
@@ -192,6 +228,7 @@ registerTool({
|
|
|
192
228
|
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
193
229
|
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
194
230
|
affects: { type: 'string', description: 'The standard SAP process this change runs inside, in plain words — REQUIRED for type ENHO (a BAdI implementation): name the BAdI, its spot and what calls it, e.g. "purchase order check before save (BAdI ME_PROCESS_PO_CUST, spot ES_MM_PO)". Shown on the approval card.' },
|
|
231
|
+
behaviour: { type: 'string', description: "What changes for callers or users, in plain words, before the write: a changed type, a dropped step, a check that returns fewer rows. One or two short sentences, no greeting, no joke. 'none' when nothing changes." },
|
|
195
232
|
},
|
|
196
233
|
required: ['op', 'object', 'type'],
|
|
197
234
|
},
|
|
@@ -274,6 +311,8 @@ registerTool({
|
|
|
274
311
|
const releaseRefusal = await attachReleaseFacts(args.changes ?? [], ctx);
|
|
275
312
|
if (releaseRefusal)
|
|
276
313
|
return { content: JSON.stringify(releaseRefusal), is_error: true };
|
|
314
|
+
// Task 8 (B11) — the request that already holds each modified object, before any card.
|
|
315
|
+
await attachOwningTransport(args.changes ?? [], ctx);
|
|
277
316
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
278
317
|
const riskCtx = {
|
|
279
318
|
sapAlias: ctx.sapAlias,
|
|
@@ -320,8 +359,7 @@ registerTool({
|
|
|
320
359
|
approved: true,
|
|
321
360
|
approved_by: 'the plan object list',
|
|
322
361
|
approval_ids: ids,
|
|
323
|
-
|
|
324
|
-
raised_by: eff.raisedBy.map(riskReasonText),
|
|
362
|
+
...riskResultFields(eff),
|
|
325
363
|
}),
|
|
326
364
|
};
|
|
327
365
|
}
|
|
@@ -334,9 +372,31 @@ registerTool({
|
|
|
334
372
|
// displayTransport in approvals/op-labels.ts. The plan gate is a batch
|
|
335
373
|
// summary: session fallback applies only when the batch contains a
|
|
336
374
|
// create (the only op it is true for).
|
|
375
|
+
//
|
|
376
|
+
// Task 8 (B11): a modify/delete already held by a request is forced into
|
|
377
|
+
// it (owningTransport, looked up above); with none named, the project
|
|
378
|
+
// request of this folder is used (null outside a joined project, so the
|
|
379
|
+
// card is today's). One order for card and write: owning lock
|
|
380
|
+
// (modify/delete) ?? explicit ?? project ?? session. The plan gate names
|
|
381
|
+
// the request the writes will use (owner decision, final review): each
|
|
382
|
+
// write's own transportForWrite choice, as on its per-change card. When
|
|
383
|
+
// they all land in one request, the card names it; when every write has a
|
|
384
|
+
// request and they differ, it says "several requests" (never one request
|
|
385
|
+
// for every object). A write with no request at all leaves today's "no
|
|
386
|
+
// transport". A plan with no write at all (activate only) keeps today's fact.
|
|
337
387
|
const sessionTransport = getCurrentTransport();
|
|
338
|
-
const
|
|
339
|
-
|
|
388
|
+
const project = projectTransport(ctx.cwd);
|
|
389
|
+
const session = changes.some((c) => c.op === 'create') ? sessionTransport : undefined;
|
|
390
|
+
const writes = changes.filter((c) => c.op === 'create' || c.op === 'modify' || c.op === 'delete');
|
|
391
|
+
const choices = writes.map((c) => transportForWrite({
|
|
392
|
+
op: c.op, explicit: args.transport, owning: c.owningTransport, project, session,
|
|
393
|
+
}));
|
|
394
|
+
const requests = new Set(choices.map((c) => c.request));
|
|
395
|
+
const planGateTransport = writes.length === 0
|
|
396
|
+
? args.transport ?? transportForWrite({ op: 'create', project, session }).label
|
|
397
|
+
: requests.size === 1
|
|
398
|
+
? (choices.every((c) => c.from === choices[0].from) ? choices[0].label : choices[0].request)
|
|
399
|
+
: requests.has(undefined) ? undefined : SEVERAL_REQUESTS_LABEL;
|
|
340
400
|
// Task 6 (agentic-flow, 2026-07-03) — guarded plan chains disable
|
|
341
401
|
// auto-approve entirely: the user's auto_approve consent was given for
|
|
342
402
|
// hand-driven turns, not for phases the harness auto-dispatched. When the
|
|
@@ -386,8 +446,7 @@ registerTool({
|
|
|
386
446
|
approved: true,
|
|
387
447
|
auto_approved: true,
|
|
388
448
|
approval_ids: ids,
|
|
389
|
-
|
|
390
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
449
|
+
...riskResultFields(eff),
|
|
391
450
|
}),
|
|
392
451
|
};
|
|
393
452
|
}
|
|
@@ -408,8 +467,7 @@ registerTool({
|
|
|
408
467
|
message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
|
|
409
468
|
'No approval IDs were minted — do not attempt any write operations. ' +
|
|
410
469
|
'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
|
|
411
|
-
|
|
412
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
470
|
+
...riskResultFields(eff),
|
|
413
471
|
}),
|
|
414
472
|
is_error: true,
|
|
415
473
|
};
|
|
@@ -419,16 +477,22 @@ registerTool({
|
|
|
419
477
|
// it skips the redundant per-change prompts that produced the "3 prompts
|
|
420
478
|
// for one task" complaint. Review-each falls back to the per-change
|
|
421
479
|
// gate. Cancel rejects the whole plan.
|
|
480
|
+
//
|
|
481
|
+
// Task 9 (C8): only for more than one VISIBLE change — the same count the
|
|
482
|
+
// plan gate shows (an activate whose write is in the list is not a change
|
|
483
|
+
// a person sees). A modify + its activate on one class gets the
|
|
484
|
+
// per-change card only; the activate follows its approved write below.
|
|
422
485
|
let skipPerChangeGates = false;
|
|
423
|
-
|
|
486
|
+
// Raw keys on purpose: the same collapse (and count) the plan gate displays.
|
|
487
|
+
const visibleCount = collapseImplicitActivates(changes).length;
|
|
488
|
+
if (visibleCount > 1) {
|
|
424
489
|
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
|
|
425
490
|
if (mode === 'cancel') {
|
|
426
491
|
return {
|
|
427
492
|
content: JSON.stringify({
|
|
428
493
|
approved: false,
|
|
429
494
|
reason: 'plan_rejected',
|
|
430
|
-
|
|
431
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
495
|
+
...riskResultFields(eff),
|
|
432
496
|
}),
|
|
433
497
|
};
|
|
434
498
|
}
|
|
@@ -461,7 +525,18 @@ registerTool({
|
|
|
461
525
|
if (c.op === 'modify' || c.op === 'create')
|
|
462
526
|
writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
463
527
|
}
|
|
464
|
-
|
|
528
|
+
// Task 9 (C8): with no plan gate, an activate listed before its own write
|
|
529
|
+
// waits for that write's card, so one change is one card. With the plan
|
|
530
|
+
// gate (more than one visible change) the order stays as today.
|
|
531
|
+
const followsItsWrite = (c) => c.op === 'activate' && writeKeys.has(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
532
|
+
const cardOrder = visibleCount > 1 ? changes : [
|
|
533
|
+
...changes.filter((c) => !followsItsWrite(c)),
|
|
534
|
+
...changes.filter(followsItsWrite),
|
|
535
|
+
];
|
|
536
|
+
// The cards may run in another order; the ids go back in the declared
|
|
537
|
+
// changes[] order (final review), so ids[i] still belongs to changes[i].
|
|
538
|
+
const idFor = new Map();
|
|
539
|
+
for (const c of cardOrder) {
|
|
465
540
|
const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
|
|
466
541
|
let outcome;
|
|
467
542
|
if (skipPerChangeGates) {
|
|
@@ -474,14 +549,14 @@ registerTool({
|
|
|
474
549
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
475
550
|
}
|
|
476
551
|
else {
|
|
477
|
-
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport));
|
|
552
|
+
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
|
|
478
553
|
}
|
|
479
554
|
if (outcome.approved) {
|
|
480
555
|
if (c.op === 'modify' || c.op === 'create')
|
|
481
556
|
autoApproveActivateFor.add(objKey);
|
|
482
557
|
// Task A3: mint via the canonical path (same code path the write
|
|
483
558
|
// tools' validators compare against).
|
|
484
|
-
|
|
559
|
+
idFor.set(c, await mintChangeApproval(c, ctx.session.id));
|
|
485
560
|
}
|
|
486
561
|
else {
|
|
487
562
|
if (outcome.reason === '__cancel_turn__') {
|
|
@@ -489,8 +564,7 @@ registerTool({
|
|
|
489
564
|
content: JSON.stringify({
|
|
490
565
|
approved: false,
|
|
491
566
|
reason: 'user_cancelled_turn',
|
|
492
|
-
|
|
493
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
567
|
+
...riskResultFields(eff),
|
|
494
568
|
}),
|
|
495
569
|
};
|
|
496
570
|
}
|
|
@@ -504,6 +578,11 @@ registerTool({
|
|
|
504
578
|
rejections.push({ object: c.object, op: c.op, reason: outcome.reason ?? 'skipped' });
|
|
505
579
|
}
|
|
506
580
|
}
|
|
581
|
+
for (const c of changes) {
|
|
582
|
+
const id = idFor.get(c);
|
|
583
|
+
if (id)
|
|
584
|
+
approvalIds.push(id);
|
|
585
|
+
}
|
|
507
586
|
// Tell Rule 8 the user has explicitly approved (some/all of) this
|
|
508
587
|
// batch via the plan-gate / per-change flow. Suppresses the redundant
|
|
509
588
|
// Rule 8 prompt at every subsequent dispatch this turn.
|
|
@@ -516,8 +595,7 @@ registerTool({
|
|
|
516
595
|
approved: approvalIds.length > 0,
|
|
517
596
|
approval_ids: approvalIds,
|
|
518
597
|
skipped: rejections,
|
|
519
|
-
|
|
520
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
598
|
+
...riskResultFields(eff),
|
|
521
599
|
}),
|
|
522
600
|
};
|
|
523
601
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cut-note guard for SAP writes (2026-10-22, owner go).
|
|
3
|
+
*
|
|
4
|
+
* Large tool results reach the model shortened, each with a note of ours:
|
|
5
|
+
* - agent/oversized-results.ts — "[result cut: N more chars …]" and
|
|
6
|
+
* "[removed: this result was too large to send …]";
|
|
7
|
+
* - agent/cold-prune.ts — "[result elided at cold start — N chars]";
|
|
8
|
+
* - tools/result-spill.ts — the spill preview: the pointer line ("… full
|
|
9
|
+
* output saved to …; read slices with tool_output_read(…)"), the omitted
|
|
10
|
+
* line ("[… lines A–B omitted …]" / "[… N lines omitted — tool_output_read
|
|
11
|
+
* offset=K …]") and a clipped line ("… [+N chars]");
|
|
12
|
+
* - the paging trailers (tool_output_read, file_read, read_document) and the
|
|
13
|
+
* @file attachment note "... [truncated, file exceeds 64 KB]".
|
|
14
|
+
* A model that writes back what it saw would write those notes into SAP in
|
|
15
|
+
* place of the real code. The write pipeline refuses any write whose
|
|
16
|
+
* arguments carry one of them, before the approval is spent or SAP is
|
|
17
|
+
* called. Only our own notes: "[image N]" and other bracket text can be
|
|
18
|
+
* legitimate ABAP and are never refused. A write without them is untouched.
|
|
19
|
+
*/
|
|
20
|
+
export const CUT_NOTE_REFUSAL = 'This source still contains a note that part of an earlier read was cut or moved to a file. Read the full source first (in parts if needed), then write the complete text.';
|
|
21
|
+
const CUT_NOTE_RES = [
|
|
22
|
+
/\[result cut: \d+ more chars\./,
|
|
23
|
+
/\[removed: this result was too large to send — \d+ chars\./,
|
|
24
|
+
/\[result elided at cold start — \d+ chars\]/,
|
|
25
|
+
/full output saved to .*; read slices with tool_output_read\(path, offset, limit\)\]/,
|
|
26
|
+
/\[… lines \d+–\d+ omitted …\]/,
|
|
27
|
+
/\[… \d+ lines omitted — tool_output_read offset=\d+ …\]/,
|
|
28
|
+
/… \[\+\d+ chars\]/,
|
|
29
|
+
// tool-output-read.ts sliceTrailer; _filesystem-shared.ts rereadTrailer
|
|
30
|
+
// (file_read, read_document); workspace.ts ATTACHMENT_TRUNCATED_NOTE (@file).
|
|
31
|
+
/\.\.\. \(\d+ more lines — next offset=\d+\)/,
|
|
32
|
+
/\.\.\. \(truncated, \d+ more lines — re-read with offset=\d+\)/,
|
|
33
|
+
/\.\.\. \[truncated, file exceeds 64 KB\]/,
|
|
34
|
+
];
|
|
35
|
+
/** True when `text` carries one of our cut / removed / spill notes. */
|
|
36
|
+
export function hasCutNote(text) {
|
|
37
|
+
return CUT_NOTE_RES.some((re) => re.test(text));
|
|
38
|
+
}
|
|
39
|
+
/** True when any string anywhere in `value` (objects, arrays) carries a cut note. */
|
|
40
|
+
export function argsHaveCutNote(value, depth = 0) {
|
|
41
|
+
if (typeof value === 'string')
|
|
42
|
+
return hasCutNote(value);
|
|
43
|
+
if (depth > 8 || value === null || typeof value !== 'object')
|
|
44
|
+
return false;
|
|
45
|
+
const items = Array.isArray(value) ? value : Object.values(value);
|
|
46
|
+
return items.some((v) => argsHaveCutNote(v, depth + 1));
|
|
47
|
+
}
|
|
@@ -50,11 +50,59 @@ async function extractPdf(buffer, maxPages) {
|
|
|
50
50
|
}).join('\n\n');
|
|
51
51
|
return { text: body, pages: total, warnings: hasText ? [] : [NO_TEXT_WARNING] };
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Longest line a document read hands to the model. A longer one is split
|
|
55
|
+
* into consecutive lines, so paging by line reaches every character; a real
|
|
56
|
+
* line (a dense PDF page is a few thousand chars) fits well inside it.
|
|
57
|
+
*/
|
|
58
|
+
export const DOC_MAX_LINE_CHARS = 10_000;
|
|
59
|
+
/** Longest picture description kept in an `[image N: …]` marker. */
|
|
60
|
+
const IMAGE_ALT_MAX_CHARS = 120;
|
|
61
|
+
// 2026-10-22 customer bug — mammoth's default inlines every picture as a
|
|
62
|
+
// base64 data: URI (a 1.5 MB picture ≈ 670k tokens on ONE line), and the
|
|
63
|
+
// prompt was then too long for every later turn. Pictures never become text:
|
|
64
|
+
// mammoth gets an img converter that never reads the bytes, and turndown
|
|
65
|
+
// writes each <img> as a short numbered marker.
|
|
66
|
+
const NO_IMAGE_DATA = mammoth.images.imgElement(async () => ({ src: '' }));
|
|
67
|
+
/** Remove any data: URI left in the text (a linked or pasted picture). */
|
|
68
|
+
export function stripDataUris(text) {
|
|
69
|
+
return text.replace(/data:[a-z0-9.+\/-]*(?:;[a-z0-9=._-]+)*;base64,[a-z0-9+\/=]*/gi, '');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Split every line longer than `maxLineChars` into consecutive lines of at
|
|
73
|
+
* most `maxLineChars`, so nothing is lost. Text whose lines all fit is
|
|
74
|
+
* returned unchanged (the same string).
|
|
75
|
+
*/
|
|
76
|
+
export function splitLongLines(text, maxLineChars) {
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
if (!lines.some((l) => l.length > maxLineChars))
|
|
79
|
+
return text;
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const l of lines) {
|
|
82
|
+
if (l.length <= maxLineChars) {
|
|
83
|
+
out.push(l);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (let i = 0; i < l.length; i += maxLineChars)
|
|
87
|
+
out.push(l.slice(i, i + maxLineChars));
|
|
88
|
+
}
|
|
89
|
+
return out.join('\n');
|
|
90
|
+
}
|
|
53
91
|
async function extractDocx(buffer) {
|
|
54
92
|
// Fix 2: esModuleInterop:true makes the default import correct — no interop guard needed.
|
|
55
|
-
const { value: html } = await mammoth.convertToHtml({ buffer });
|
|
93
|
+
const { value: html } = await mammoth.convertToHtml({ buffer }, { convertImage: NO_IMAGE_DATA });
|
|
56
94
|
const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
|
|
57
|
-
|
|
95
|
+
let images = 0;
|
|
96
|
+
td.addRule('pictureMarker', {
|
|
97
|
+
filter: 'img',
|
|
98
|
+
replacement: (_content, node) => {
|
|
99
|
+
images++;
|
|
100
|
+
const alt = (node.getAttribute('alt') ?? '')
|
|
101
|
+
.replace(/\s+/g, ' ').trim().slice(0, IMAGE_ALT_MAX_CHARS).trim();
|
|
102
|
+
return alt ? `[image ${images}: ${alt}]` : `[image ${images}]`;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const raw = stripDataUris(td.turndown(html)).trim();
|
|
58
106
|
// Turndown emits "- text" (3 spaces) for bullet items; collapse to "- text".
|
|
59
107
|
// The regex anchors on a line-leading "-" so mid-line double spaces (e.g. "Col A Col B")
|
|
60
108
|
// and indented nested bullets (leading whitespace is captured in $1) are both preserved.
|
|
@@ -15,7 +15,7 @@ import { promises as fs } from 'node:fs';
|
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import { registerTool } from '../index.js';
|
|
17
17
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
18
|
-
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
18
|
+
import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
19
19
|
export async function fileEditHandler(args, ctx) {
|
|
20
20
|
// 1. Validate args before any IO
|
|
21
21
|
if (args.old_string === args.new_string) {
|
|
@@ -74,6 +74,13 @@ export async function fileEditHandler(args, ctx) {
|
|
|
74
74
|
if (pointsIntoSharedCspeach(ctx.cwd, realAbs)) {
|
|
75
75
|
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
76
76
|
}
|
|
77
|
+
// 4d. Team folder (Task 4, B3): the project's own files — cspeach-team.json,
|
|
78
|
+
// lanes.json, writers/, rulings/, plan/, baseline/ — are CSPeach's to
|
|
79
|
+
// write. Judged on the real landing path. A folder without a team
|
|
80
|
+
// project never gets here.
|
|
81
|
+
if (pointsAtTeamFile(ctx.cwd, realAbs)) {
|
|
82
|
+
return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
|
|
83
|
+
}
|
|
77
84
|
// 5. Read the file
|
|
78
85
|
let content;
|
|
79
86
|
try {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import { registerTool } from '../index.js';
|
|
13
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
|
|
13
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile, rereadTrailer } from '../_filesystem-shared.js';
|
|
14
14
|
import { teamReadTarget } from '../_team-work-path.js';
|
|
15
15
|
import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
|
|
16
16
|
import { basename } from 'node:path';
|
|
@@ -74,7 +74,7 @@ export async function fileReadHandler(args, ctx) {
|
|
|
74
74
|
const numbered = window.map((line, i) => `${offset + i + 1}\t${line}`).join('\n');
|
|
75
75
|
const truncated = allLines.length > offset + window.length;
|
|
76
76
|
const trailer = truncated
|
|
77
|
-
? `\n
|
|
77
|
+
? `\n${rereadTrailer(allLines.length - (offset + window.length), offset + window.length)}`
|
|
78
78
|
: '';
|
|
79
79
|
// Task 17 (spec D12) — a window above 8 000 chars is saved to the session
|
|
80
80
|
// spill file and previewed; today's trailer stays last, byte-identical. A
|
|
@@ -19,7 +19,7 @@ import { promises as fs, readFileSync } from 'node:fs';
|
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { registerTool } from '../index.js';
|
|
21
21
|
import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
22
|
-
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
22
|
+
import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
23
23
|
/**
|
|
24
24
|
* 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
|
|
25
25
|
* nothing about it. He opened it in a text editor, then tried to drop it into
|
|
@@ -107,6 +107,13 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
107
107
|
if (pointsIntoSharedCspeach(ctx.cwd, target)) {
|
|
108
108
|
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
109
109
|
}
|
|
110
|
+
// 3d. Team folder (Task 4, B3): the project's own files — cspeach-team.json,
|
|
111
|
+
// lanes.json, writers/, rulings/, plan/, baseline/ — are CSPeach's to
|
|
112
|
+
// write. Judged on the real landing path. A folder without a team
|
|
113
|
+
// project never gets here.
|
|
114
|
+
if (pointsAtTeamFile(ctx.cwd, target)) {
|
|
115
|
+
return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
|
|
116
|
+
}
|
|
110
117
|
// 4. Ensure parent directory exists (no-op if it already does)
|
|
111
118
|
try {
|
|
112
119
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
import { promises as fs } from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { registerTool } from '../index.js';
|
|
10
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, } from '../_filesystem-shared.js';
|
|
10
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, rereadTrailer, } from '../_filesystem-shared.js';
|
|
11
11
|
import { teamWorkTarget } from '../_team-work-path.js';
|
|
12
|
-
import { extractDocument, DocumentTooLargeError } from './extract-document.js';
|
|
12
|
+
import { extractDocument, DocumentTooLargeError, splitLongLines, DOC_MAX_LINE_CHARS, } from './extract-document.js';
|
|
13
13
|
const MAX_LINES = 2000;
|
|
14
|
+
// 2026-10-22 customer bug — one read returned ~2.3M tokens and every later
|
|
15
|
+
// turn of the session failed. A read now has a hard size: long lines are split
|
|
16
|
+
// (DOC_MAX_LINE_CHARS) and one call returns at most DOC_MAX_CHARS_PER_READ
|
|
17
|
+
// chars (about 60k tokens); the offset trailer says where to read on.
|
|
18
|
+
export const DOC_MAX_CHARS_PER_READ = 200_000;
|
|
14
19
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
15
20
|
const MAX_PAGES = 150;
|
|
16
21
|
const SUPPORTED = new Set(['.pdf', '.docx', '.xlsx']);
|
|
@@ -89,14 +94,25 @@ export async function readDocumentHandler(args, ctx) {
|
|
|
89
94
|
: 'document';
|
|
90
95
|
const header = `${args.path} — ${scale}`;
|
|
91
96
|
const warnLines = result.warnings.map((w) => `[warning] ${w}`);
|
|
92
|
-
const allLines = result.text.replace(/\n$/, '').split('\n');
|
|
97
|
+
const allLines = splitLongLines(result.text.replace(/\n$/, ''), DOC_MAX_LINE_CHARS).split('\n');
|
|
93
98
|
const offset = Math.max(0, args.offset ?? 0);
|
|
94
99
|
const limit = Math.min(args.limit ?? MAX_LINES, MAX_LINES);
|
|
95
100
|
const window = allLines.slice(offset, offset + limit);
|
|
101
|
+
// Total cap: stop before the line that would pass it (always keep one line).
|
|
102
|
+
let used = 0;
|
|
103
|
+
let fit = 0;
|
|
104
|
+
for (const line of window) {
|
|
105
|
+
const cost = `${offset + fit + 1}\t${line}`.length + 1;
|
|
106
|
+
if (fit > 0 && used + cost > DOC_MAX_CHARS_PER_READ)
|
|
107
|
+
break;
|
|
108
|
+
used += cost;
|
|
109
|
+
fit++;
|
|
110
|
+
}
|
|
111
|
+
window.length = fit;
|
|
96
112
|
const numbered = window.map((line, i) => `${offset + i + 1}\t${line}`).join('\n');
|
|
97
113
|
const truncated = allLines.length > offset + window.length;
|
|
98
114
|
const trailer = truncated
|
|
99
|
-
? `\n
|
|
115
|
+
? `\n${rereadTrailer(allLines.length - (offset + window.length), offset + window.length)}`
|
|
100
116
|
: '';
|
|
101
117
|
return { content: [header, ...warnLines, numbered].join('\n') + trailer };
|
|
102
118
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared input-resolution for the
|
|
3
|
-
*
|
|
2
|
+
* Shared input-resolution for the merge tool (upgrade_merge_progress;
|
|
3
|
+
* its cca_merge twin was cut 2026-10-20, drift spec §3.6).
|
|
4
4
|
*
|
|
5
5
|
* Inputs arrive as the same `@<token>` forms the rest of the CLI uses
|
|
6
6
|
* (workspace.ts:resolveAtToken): absolute path, relative path, or a bare
|
|
@@ -13,10 +13,11 @@ import { registerTool } from '../index.js';
|
|
|
13
13
|
import { activeProject, readProjectFile, RegisterError } from '../../register/store.js';
|
|
14
14
|
import { addProposed, ruleWords } from '../../register/rules-edit.js';
|
|
15
15
|
import { setRuleProposal } from '../../register/rule-proposal.js';
|
|
16
|
+
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
16
17
|
export async function rulesProposeHandler(args, _ctx) {
|
|
17
18
|
const joined = activeProject();
|
|
18
19
|
if (!joined)
|
|
19
|
-
return { content:
|
|
20
|
+
return { content: NOT_JOINED_LINE, is_error: true };
|
|
20
21
|
try {
|
|
21
22
|
const project = readProjectFile(joined.projectDir);
|
|
22
23
|
const current = readDecisionRules(project);
|