@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
|
@@ -13,12 +13,19 @@
|
|
|
13
13
|
* handed out (journey/flow.ts, at most 40 keys) is shown: the skill needs no
|
|
14
14
|
* change to work in pages.
|
|
15
15
|
*
|
|
16
|
-
* Decision rules (step 2, D6): every row carries `
|
|
16
|
+
* Decision rules (step 2, D6): every row carries `proposal` — the first
|
|
17
17
|
* rule of the team project (or the defaults) that decides it, or null when no
|
|
18
18
|
* rule does and the row is CSPeach's judgement. Rules the file names but this
|
|
19
19
|
* build cannot read give `rulesWarning` (one line) and the defaults.
|
|
20
|
+
*
|
|
21
|
+
* Task 6 (project view, R5/R6): every fact comes from the project view —
|
|
22
|
+
* `scope: { kind, name, total, parts }` says the whole scope (so a decided row
|
|
23
|
+
* is never read as "none triaged"), each row says its part, its decision
|
|
24
|
+
* (`{ what, by, reason, reason_from }`), its level and who decided it, and its
|
|
25
|
+
* target with where it came from (never a bare letter). The usage sentence the
|
|
26
|
+
* rows share is said once, in `usage_note`; a row with usage of its own keeps it.
|
|
20
27
|
*/
|
|
21
|
-
import {
|
|
28
|
+
import { DECISION_WORDS, partCounts, projectView, readDecisionRules, } from '@cspeach/register-core';
|
|
22
29
|
import { registerTool } from '../index.js';
|
|
23
30
|
import { findingsByObject, shownUpTo } from '../../register/findings.js';
|
|
24
31
|
import { registerFor } from '../../register/picture.js';
|
|
@@ -26,6 +33,7 @@ import { ruleProposalOf } from '../../register/decision-rules.js';
|
|
|
26
33
|
import { activeProject, readProjectFile } from '../../register/store.js';
|
|
27
34
|
import { shown } from '../../register/atc-file.js';
|
|
28
35
|
import { journeyPage } from '../../journey/flow.js';
|
|
36
|
+
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
29
37
|
const CAP = 200;
|
|
30
38
|
/** One page when `after` or `limit` is given. */
|
|
31
39
|
const PAGE = 40;
|
|
@@ -39,7 +47,7 @@ const REASON_CAP = 300;
|
|
|
39
47
|
*/
|
|
40
48
|
export const JOURNEY_NOTE = "This triage is one step of CSPeach's guided clean-core run. The person types no commands: "
|
|
41
49
|
+ 'never suggest a cspeach command (not cspeach team decide, not any other). Say instead that CSPeach asks about each row, '
|
|
42
|
-
+ 'and a row skipped is asked again
|
|
50
|
+
+ 'and a row skipped is asked about again at the end of this run, or when the person says carry on. Use plain words: "your objects", "your project" — never team, lane, outbox or writer.';
|
|
43
51
|
const match = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
|
|
44
52
|
function usageOf(u) {
|
|
45
53
|
return {
|
|
@@ -50,6 +58,30 @@ function usageOf(u) {
|
|
|
50
58
|
...(u.why ? { why: shownUpTo(u.why, REASON_CAP) } : {}),
|
|
51
59
|
};
|
|
52
60
|
}
|
|
61
|
+
/** The usage every row shares when none of them was read: unknown, with the one sentence that says why. */
|
|
62
|
+
const sharedUsage = (u) => u.state === 'unknown' && Object.keys(u).every((k) => k === 'state' || k === 'why');
|
|
63
|
+
export const usageNoteOf = (why) => `Usage, for every row without a usage field: unknown.${why ? ` ${why}` : ''}`;
|
|
64
|
+
export const OWN_USAGE_NOTE = 'Every row gives its own usage.';
|
|
65
|
+
/**
|
|
66
|
+
* Task 6 (R6): the usage sentence most rows of the page share goes to one note;
|
|
67
|
+
* those rows drop their usage. Any other row keeps its own.
|
|
68
|
+
*/
|
|
69
|
+
function usageNote(usages) {
|
|
70
|
+
const counts = new Map();
|
|
71
|
+
for (const u of usages)
|
|
72
|
+
if (sharedUsage(u))
|
|
73
|
+
counts.set(String(u.why ?? ''), (counts.get(String(u.why ?? '')) ?? 0) + 1);
|
|
74
|
+
if (!counts.size)
|
|
75
|
+
return { note: OWN_USAGE_NOTE, shared: () => false };
|
|
76
|
+
let why = '';
|
|
77
|
+
let most = 0;
|
|
78
|
+
for (const [w, n] of counts)
|
|
79
|
+
if (n > most) {
|
|
80
|
+
why = w;
|
|
81
|
+
most = n;
|
|
82
|
+
}
|
|
83
|
+
return { note: usageNoteOf(why || undefined), shared: (u) => sharedUsage(u) && String(u.why ?? '') === why };
|
|
84
|
+
}
|
|
53
85
|
/** A number only when the index was read; otherwise why not, in words. */
|
|
54
86
|
function callersOf(c) {
|
|
55
87
|
if (!c)
|
|
@@ -59,25 +91,30 @@ function callersOf(c) {
|
|
|
59
91
|
export async function teamFindingsHandler(args, ctx) {
|
|
60
92
|
const joined = activeProject();
|
|
61
93
|
if (!joined)
|
|
62
|
-
return { content:
|
|
94
|
+
return { content: NOT_JOINED_LINE, is_error: true };
|
|
63
95
|
try {
|
|
64
96
|
const project = readProjectFile(joined.projectDir);
|
|
65
97
|
const { register } = registerFor(joined.projectDir, joined.outboxDir);
|
|
66
98
|
const laneId = args.lane?.trim();
|
|
67
99
|
const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
68
|
-
|
|
69
|
-
|
|
100
|
+
// The project view, built once: the scope, its parts and every row read it.
|
|
101
|
+
const view = projectView(register, project);
|
|
102
|
+
let objects = view.all.objects;
|
|
103
|
+
let scope = { kind: 'project', name: project.name };
|
|
70
104
|
if (laneId) {
|
|
71
105
|
const lane = register.lanes.find((l) => l.id.toLowerCase() === laneId.toLowerCase());
|
|
72
106
|
if (!lane)
|
|
73
107
|
return { content: `There is no lane "${laneId}". Lanes: ${register.lanes.map((l) => l.id).join(', ') || 'none yet'}.`, is_error: true };
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
// The lane's own objects (design §2): an object another lane's pattern also matches is its owner's.
|
|
109
|
+
objects = view.lanes.find((s) => s.scope.kind === 'lane' && s.scope.id === lane.id)?.objects ?? [];
|
|
110
|
+
scope = { kind: 'lane', name: lane.id };
|
|
76
111
|
}
|
|
77
112
|
else if (patterns.length) {
|
|
78
113
|
objects = objects.filter((o) => patterns.some((p) => match(p, o.package)));
|
|
79
|
-
scope =
|
|
114
|
+
scope = { kind: 'packages', name: shown(patterns.join(', ')) };
|
|
80
115
|
}
|
|
116
|
+
// The header is the whole scope's, not the page's.
|
|
117
|
+
const header = { ...scope, total: objects.length, parts: partCounts(objects) };
|
|
81
118
|
// A journey triage sees only the page the journey handed out.
|
|
82
119
|
const page = journeyPage(ctx?.session);
|
|
83
120
|
if (page) {
|
|
@@ -100,28 +137,42 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
100
137
|
const more = objects.length - start - slice.length;
|
|
101
138
|
const findings = findingsByObject(joined.projectDir, joined.outboxDir);
|
|
102
139
|
const rules = readDecisionRules(project);
|
|
103
|
-
const
|
|
140
|
+
const registerObject = new Map(register.objects.map((o) => [o.key, o]));
|
|
141
|
+
const usages = slice.map((v) => usageOf(registerObject.get(v.key).evidence.usage));
|
|
142
|
+
const usage = usageNote(usages);
|
|
143
|
+
const list = slice.map((v, i) => {
|
|
144
|
+
const o = registerObject.get(v.key);
|
|
104
145
|
const f = findings.get(o.key);
|
|
105
|
-
const d =
|
|
146
|
+
const d = v.decision;
|
|
106
147
|
return {
|
|
107
|
-
type:
|
|
108
|
-
owner:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
148
|
+
type: v.type, name: v.name, package: v.package,
|
|
149
|
+
owner: v.owner?.name ?? null,
|
|
150
|
+
part: v.part,
|
|
151
|
+
// The view's decision, in words: who decided it and whose words the reason is. Never a bare target letter.
|
|
152
|
+
decided: d.what === 'none' ? null : {
|
|
153
|
+
what: DECISION_WORDS[d.what],
|
|
154
|
+
by: d.by ? shown(d.by.name) : null,
|
|
155
|
+
reason: d.reason?.text ? shownUpTo(d.reason.text, REASON_CAP) : null,
|
|
156
|
+
reason_from: d.reason?.from ?? 'none',
|
|
157
|
+
},
|
|
158
|
+
level: { decided: v.level.decided, by: v.level.by ? shown(v.level.by.name) : null },
|
|
159
|
+
target: { level: v.target.level, from: v.target.from },
|
|
160
|
+
// The rule that decides this row, from the latest counted run's findings; null = no rule decides.
|
|
161
|
+
proposal: ruleProposalOf(o, f, rules.rules),
|
|
162
|
+
baselineAt: f?.baselineAt ?? null, baseline: f?.baseline ?? [], openNow: v.proof.open,
|
|
163
|
+
// Evidence (spec 2026-10-06 §10): usage only with its source (the shared sentence is usage_note),
|
|
164
|
+
// callers only when the index was read, and when the code last CHANGED — which is never use.
|
|
165
|
+
...(usage.shared(usages[i]) ? {} : { usage: usages[i] }),
|
|
114
166
|
callers: callersOf(o.evidence.callers),
|
|
115
167
|
lastChanged: o.evidence.changed?.at ? shownUpTo(o.evidence.changed.at, 30) : null,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// The rule that decides this row, from the latest counted run's findings; null = no rule decides.
|
|
119
|
-
ruleProposal: ruleProposalOf(o, f, rules.rules),
|
|
168
|
+
priority: { band: v.priority.band, source: v.priority.source },
|
|
169
|
+
retireCandidate: d.what === 'retire-candidate',
|
|
120
170
|
};
|
|
121
171
|
});
|
|
122
172
|
return {
|
|
123
173
|
content: JSON.stringify({
|
|
124
|
-
|
|
174
|
+
// An empty page has no rows to share a usage: no note.
|
|
175
|
+
teamProject: project.name, scope: header, ...(list.length ? { usage_note: usage.note } : {}), objects: list,
|
|
125
176
|
...(page ? { journey: JOURNEY_NOTE } : {}),
|
|
126
177
|
...(rules.warning ? { rulesWarning: rules.warning } : {}),
|
|
127
178
|
...(more > 0 ? { more } : {}),
|
|
@@ -135,11 +186,13 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
135
186
|
}
|
|
136
187
|
registerTool({
|
|
137
188
|
name: 'team_findings',
|
|
138
|
-
description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined
|
|
139
|
-
+ '
|
|
140
|
-
+ '
|
|
141
|
-
+ '
|
|
142
|
-
+ '
|
|
189
|
+
description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined. '
|
|
190
|
+
+ 'scope gives the whole scope: total and parts (how many are decided, fixed, not decided yet). Per object: its part, '
|
|
191
|
+
+ 'the findings measurement zero recorded, any decision already recorded (decided: what, by whom, the reason and whose '
|
|
192
|
+
+ 'words it is), the level and who decided it, the target and where it came from, usage when it has its own (otherwise '
|
|
193
|
+
+ 'usage_note says it once), callers from the index (or why they were not read), last change (a change, not use), whether '
|
|
194
|
+
+ 'it is a retire candidate, its priority, and proposal: the decision rule that decides the row (null = no rule decides; '
|
|
195
|
+
+ 'your judgement). Use it for a /abap-clean-core triage. No SAP call.',
|
|
143
196
|
isMutating: false,
|
|
144
197
|
category: 'project',
|
|
145
198
|
requiresSap: false,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team_lane_move — the lead moves packages between people (Task 4, 2026-10-20;
|
|
3
|
+
* B2's tool part).
|
|
4
|
+
*
|
|
5
|
+
* On a skill turn the person asks to move packages; the model calls this,
|
|
6
|
+
* never file_write (lanes.json is CSPeach's: the file tools refuse it in a
|
|
7
|
+
* team folder). Code checks everything first — joined, the lead, package
|
|
8
|
+
* names in full, each a package of the baseline, a reason — then the person
|
|
9
|
+
* answers ONE card. Yes runs the same body as `cspeach team lane move`
|
|
10
|
+
* (moveLanes): lanes.json plus one plan record, always. No writes nothing.
|
|
11
|
+
* Headless: refused, nobody is there to answer the card.
|
|
12
|
+
*
|
|
13
|
+
* Chat refuses this tool at dispatch (CHAT_LANE_MOVE_REASON). Rule 8 does not
|
|
14
|
+
* count it (project category): its own card is the one card.
|
|
15
|
+
*/
|
|
16
|
+
import { registerTool } from '../index.js';
|
|
17
|
+
import { activeProject, readLanes, readProjectFile, RegisterError } from '../../register/store.js';
|
|
18
|
+
import { isLead, isPlainName, MAX_PERSON_NAME, moveLanes, planLaneMove } from '../../commands/team.js';
|
|
19
|
+
import { key as folderKey } from '../../projects/project-folder.js';
|
|
20
|
+
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
21
|
+
import { shown, shownUpTo } from '../../register/atc-file.js';
|
|
22
|
+
export { NOT_JOINED_LINE };
|
|
23
|
+
/** The session runs in another folder than the joined project's. */
|
|
24
|
+
export const notInProjectLine = (folder) => `Open CSPeach in the project folder to move work: ${folder}.`;
|
|
25
|
+
export const NAME_LINE = `Give the person's name as plain text, at most ${MAX_PERSON_NAME} characters.`;
|
|
26
|
+
export const NEEDS_REASON_LINE = "A move needs the person's reason.";
|
|
27
|
+
export const HEADLESS_LINE = 'This needs a person at the keyboard.';
|
|
28
|
+
/** Live re-run L1: one sentence so the model never reads to-fix as "no decision yet". */
|
|
29
|
+
export const AFTER_MOVE_NEXT = "Tell the person what moved: the moved packages' own counts. "
|
|
30
|
+
+ 'to-fix objects are decided as fix and not started; decided counts every recorded decision. Do not edit lanes.json.';
|
|
31
|
+
const MAX_REASON = 300;
|
|
32
|
+
const fail = (content) => ({ content, is_error: true });
|
|
33
|
+
const sentence = (s) => (/[.!?]$/.test(s) ? s : `${s}.`);
|
|
34
|
+
async function defaultConfirm(req) {
|
|
35
|
+
const { cardSlotEmitter } = await import('../../ui/card-slot.js');
|
|
36
|
+
if (cardSlotEmitter.listenerCount('confirm') > 0) {
|
|
37
|
+
const { askConfirm } = await import('../../ui/confirm-request.js');
|
|
38
|
+
return askConfirm(req);
|
|
39
|
+
}
|
|
40
|
+
const [{ lineConfirm }, { inkAwarePrompt }] = await Promise.all([import('../../journey/chain.js'), import('../../agent/loop.js')]);
|
|
41
|
+
return lineConfirm(inkAwarePrompt, (line) => console.log(line))(req);
|
|
42
|
+
}
|
|
43
|
+
async function defaultIdentity() {
|
|
44
|
+
const { getAuthorIdentity } = await import('../../agent/loop.js');
|
|
45
|
+
return getAuthorIdentity;
|
|
46
|
+
}
|
|
47
|
+
export async function teamLaneMoveHandler(args, ctx, hooks = {}) {
|
|
48
|
+
const joined = activeProject();
|
|
49
|
+
if (!joined)
|
|
50
|
+
return fail(NOT_JOINED_LINE);
|
|
51
|
+
// Review round 1 — the move is made from the project's own folder only.
|
|
52
|
+
if (folderKey(ctx.cwd) !== folderKey(joined.projectDir))
|
|
53
|
+
return fail(notInProjectLine(shownUpTo(joined.projectDir, 260)));
|
|
54
|
+
try {
|
|
55
|
+
const said = [];
|
|
56
|
+
const deps = {
|
|
57
|
+
identity: hooks.deps?.identity ?? (await defaultIdentity()),
|
|
58
|
+
...hooks.deps,
|
|
59
|
+
print: (line) => { said.push(line); },
|
|
60
|
+
};
|
|
61
|
+
const project = readProjectFile(joined.projectDir);
|
|
62
|
+
if (!isLead(project, deps))
|
|
63
|
+
return fail(`Only the lead, ${shown(project.lead.name)}, moves packages.`);
|
|
64
|
+
const packages = Array.isArray(args.packages) ? args.packages.filter((p) => typeof p === 'string') : [];
|
|
65
|
+
const to = typeof args.to === 'string' ? args.to : '';
|
|
66
|
+
if (!packages.some((p) => p.trim()))
|
|
67
|
+
return fail('Name the packages to move in full, for example ZSD_ORDERS.');
|
|
68
|
+
if (!to.trim())
|
|
69
|
+
return fail('Say who the packages go to: a person\'s name or a lane id.');
|
|
70
|
+
if (!isPlainName(to))
|
|
71
|
+
return fail(NAME_LINE);
|
|
72
|
+
const lanes = readLanes(joined.projectDir);
|
|
73
|
+
const plan = planLaneMove(packages, to, joined, lanes);
|
|
74
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : '';
|
|
75
|
+
if (!reason)
|
|
76
|
+
return fail(NEEDS_REASON_LINE);
|
|
77
|
+
if (reason.length > MAX_REASON)
|
|
78
|
+
return fail(`Keep the reason under ${MAX_REASON} characters.`);
|
|
79
|
+
if (plan.kind === 'nothing')
|
|
80
|
+
return { content: JSON.stringify({ moved: [], reason: plan.line }) };
|
|
81
|
+
const headless = hooks.headless ?? (await import('../../renderer/tty.js')).isHeadless();
|
|
82
|
+
if (headless)
|
|
83
|
+
return fail(HEADLESS_LINE);
|
|
84
|
+
const from = plan.holders.length ? plan.holders.map((l) => `${shown(l.owner)} (${l.id})`).join(', ') : 'nobody yet';
|
|
85
|
+
const res = await (hooks.confirm ?? defaultConfirm)({
|
|
86
|
+
id: 'team-lane-move',
|
|
87
|
+
tag: 'QUESTION',
|
|
88
|
+
title: `Move ${plan.names.join(', ')} to ${shown(plan.into.owner)}?`,
|
|
89
|
+
facts: [`From: ${from}.`, `Why: ${sentence(reason)}`, 'Recorded in the project history.'],
|
|
90
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
|
|
91
|
+
initial: 0,
|
|
92
|
+
aliases: { y: 'yes', n: 'no' },
|
|
93
|
+
});
|
|
94
|
+
if (res.cancelled || res.value !== 'yes')
|
|
95
|
+
return { content: JSON.stringify({ moved: [], reason: 'declined' }) };
|
|
96
|
+
// The card may have waited: the move works on the lanes as they are now.
|
|
97
|
+
// Task 6 (R5): the result is the view's after the move — each moved package's own
|
|
98
|
+
// objects and parts, and the lane's total apart from them.
|
|
99
|
+
const done = moveLanes(plan.names, to, reason, joined, readProjectFile(joined.projectDir), readLanes(joined.projectDir), deps);
|
|
100
|
+
return { content: JSON.stringify({ ...done, next: AFTER_MOVE_NEXT }) };
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e instanceof RegisterError)
|
|
104
|
+
return fail(e.message);
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
registerTool({
|
|
109
|
+
name: 'team_lane_move',
|
|
110
|
+
description: 'Move packages from one person to another in the team project this laptop joined (the lead only). '
|
|
111
|
+
+ 'Give package names in full (no patterns), the person\'s name or lane id, and the person\'s reason in their words. '
|
|
112
|
+
+ 'CSPeach checks it and asks the person on one card; on Yes it changes the lanes and records the move in the project history. '
|
|
113
|
+
+ 'Never edit lanes.json yourself. No SAP call.',
|
|
114
|
+
isMutating: true,
|
|
115
|
+
category: 'project',
|
|
116
|
+
requiresSap: false,
|
|
117
|
+
input_schema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
packages: { type: 'array', items: { type: 'string' }, description: 'Package names in full, for example ["ZSD_ORDERS", "ZMM_STOCK"].' },
|
|
121
|
+
to: { type: 'string', description: 'The person the packages go to (their name), or their lane id.' },
|
|
122
|
+
reason: { type: 'string', description: 'Why, in the person\'s words. Shown back on the card and kept in the history.' },
|
|
123
|
+
},
|
|
124
|
+
required: ['packages', 'to', 'reason'],
|
|
125
|
+
},
|
|
126
|
+
handler: (args, ctx) => teamLaneMoveHandler(args, ctx),
|
|
127
|
+
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* upgrade_merge_progress — deterministic reconciliation of N parallel
|
|
3
3
|
* /abap-upgrade-fix progress envelopes (the /abap-upgrade-merge skill's
|
|
4
|
-
* execution seam).
|
|
5
|
-
*
|
|
6
|
-
* pre-rendered manifest block is pasted verbatim and persisted by the
|
|
7
|
-
* normal save hook
|
|
4
|
+
* execution seam). The tool does the arithmetic (battery S4 D6/D7: models
|
|
5
|
+
* cannot keep counts consistent with rows by hand), the model presents;
|
|
6
|
+
* the pre-rendered manifest block is pasted verbatim and persisted by the
|
|
7
|
+
* normal save hook — persistence stays on the one existing save path
|
|
8
|
+
* instead of the tool minting envelopes through a second door, and the
|
|
9
|
+
* extractor's counts-vs-rows validation is a tamper backstop if the model
|
|
10
|
+
* edits the pasted block. (The twin cca_merge tool was cut 2026-10-20,
|
|
11
|
+
* drift spec §3.6.)
|
|
8
12
|
*
|
|
9
13
|
* Baseline discipline:
|
|
10
14
|
* - every input's baselineRef.sha256 must match the merge baseline —
|
|
@@ -35,6 +35,7 @@ import { appendPending, finalizeToolCall } from '../../session/pending.js';
|
|
|
35
35
|
import { snapshots, auditLog } from '@cspeach/sap-client';
|
|
36
36
|
import { ERR } from '../../errors/codes.js';
|
|
37
37
|
import { resolveWriteTransport } from '../transport-resolution.js';
|
|
38
|
+
import { projectTransportForWrite, transportForWrite } from '../../register/project-transport.js';
|
|
38
39
|
import { noteWriteTransport } from '../../repl/current-transport.js';
|
|
39
40
|
import { loadConfig } from '../../config/loader.js';
|
|
40
41
|
import { getEffectiveWriteMode } from '../../repl/mode-cycle.js';
|
|
@@ -246,7 +247,12 @@ registerTool({
|
|
|
246
247
|
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
247
248
|
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
248
249
|
}
|
|
249
|
-
|
|
250
|
+
// Task 8 (B11) / Task 6: the one choice (transportForWrite) — explicit ?? the project request of
|
|
251
|
+
// this folder ?? session; the owning lock is looked up inside resolveWriteTransport and wins.
|
|
252
|
+
const supplied = transportForWrite({
|
|
253
|
+
op: 'modify', explicit: args.transport, project: projectTransportForWrite(ctx, verify.payload), session: sessionTransport,
|
|
254
|
+
}).request;
|
|
255
|
+
const resolved = await resolveWriteTransport(ctx, 'PROG', name, supplied);
|
|
250
256
|
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
251
257
|
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
252
258
|
return errorEnvelope({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(name, resolved.owning) });
|
|
@@ -58,7 +58,7 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
58
58
|
'abap-app': ['app_lane_resolve', 'read_document'],
|
|
59
59
|
'abap-atc-fix': ['sap_atc_run'],
|
|
60
60
|
'abap-cca': ['team_estate', 'sap_atc_run', 'sap_usage_references', 'sap_api_state'],
|
|
61
|
-
'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings'],
|
|
61
|
+
'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings', 'team_lane_move'],
|
|
62
62
|
'abap-data-model': ['sap_create_domain', 'sap_create_data_element'],
|
|
63
63
|
'abap-document': ['sap_usage_references'],
|
|
64
64
|
'abap-handover': ['sap_usage_references'],
|
|
@@ -95,7 +95,11 @@ export async function toolOutputReadHandler(args, ctx) {
|
|
|
95
95
|
next = i + 1;
|
|
96
96
|
}
|
|
97
97
|
const rest = all.length - next;
|
|
98
|
-
return { content: out.join('\n') + (rest > 0 ? `\n
|
|
98
|
+
return { content: out.join('\n') + (rest > 0 ? `\n${sliceTrailer(rest, next)}` : '') };
|
|
99
|
+
}
|
|
100
|
+
/** The slice trailer (also refused in SAP writes: tools/cut-markers.ts). */
|
|
101
|
+
export function sliceTrailer(rest, next) {
|
|
102
|
+
return `... (${rest} more lines — next offset=${next})`;
|
|
99
103
|
}
|
|
100
104
|
registerTool({
|
|
101
105
|
name: 'tool_output_read',
|
|
@@ -28,7 +28,9 @@ export class CancelledByUserError extends Error {
|
|
|
28
28
|
*/
|
|
29
29
|
export async function interceptUpdateMethod(client, call, hook) {
|
|
30
30
|
const classSource = await client.getSource({ type: 'CLAS', name: call.className });
|
|
31
|
-
|
|
31
|
+
// C9 (Task 9): the proposed source is the body only, so compare body with
|
|
32
|
+
// body — the METHOD / ENDMETHOD wrapper lines are not part of the change.
|
|
33
|
+
const currentBody = methodBodyOf(extractMethodBody(classSource, call.methodName));
|
|
32
34
|
const proposedBody = call.methodSource;
|
|
33
35
|
const result = await hook({
|
|
34
36
|
className: call.className,
|
|
@@ -87,6 +89,21 @@ export function extractMethodBody(classSource, methodName) {
|
|
|
87
89
|
// diff still surfaces the partial body to the user.
|
|
88
90
|
return lines.slice(start).join('\n');
|
|
89
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* C9 (Task 9): the lines between `METHOD x.` and `ENDMETHOD.` of a block
|
|
94
|
+
* from extractMethodBody. Drops the first line when it opens the method and
|
|
95
|
+
* the last when it closes it; anything else is kept. Empty stays empty.
|
|
96
|
+
*/
|
|
97
|
+
export function methodBodyOf(block) {
|
|
98
|
+
if (!block)
|
|
99
|
+
return block;
|
|
100
|
+
const lines = block.split('\n');
|
|
101
|
+
if (lines.length > 0 && /^\s*METHOD\b/i.test(lines[0]))
|
|
102
|
+
lines.shift();
|
|
103
|
+
if (lines.length > 0 && /^\s*ENDMETHOD\s*\./i.test(lines[lines.length - 1]))
|
|
104
|
+
lines.pop();
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
90
107
|
function stripCommentsAndStrings(line) {
|
|
91
108
|
// Walk the line and replace string-literal contents and trailing `"` comments
|
|
92
109
|
// with spaces so keyword matching ignores them. Cheap and good enough for
|
|
@@ -28,6 +28,7 @@ import { auditLog, SapError } from '@cspeach/sap-client';
|
|
|
28
28
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
29
29
|
import { grantClaimMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
|
|
30
30
|
import { typeCode } from '../approvals/risk-floor.js';
|
|
31
|
+
import { projectTransportForWrite, transportForWrite } from '../register/project-transport.js';
|
|
31
32
|
import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
|
|
32
33
|
import { includeSnapshotType } from './include-snapshot.js';
|
|
33
34
|
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
@@ -43,6 +44,7 @@ import { resolveWriteTransport } from './transport-resolution.js';
|
|
|
43
44
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
44
45
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
45
46
|
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
47
|
+
import { argsHaveCutNote, CUT_NOTE_REFUSAL } from './cut-markers.js';
|
|
46
48
|
// ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
|
|
47
49
|
//
|
|
48
50
|
// Rule 7 auto-snapshots always fired but the transcript never said so
|
|
@@ -333,6 +335,10 @@ function auditOutcome(syntax, stillExists, postCheckError) {
|
|
|
333
335
|
*/
|
|
334
336
|
export async function runWritePipeline(args, ctx, spec) {
|
|
335
337
|
const raw = args;
|
|
338
|
+
// ── 2026-10-22 — never write our own cut / spill notes into SAP ────────────
|
|
339
|
+
// First, before the approval is spent or SAP is called (cut-markers.ts).
|
|
340
|
+
if (argsHaveCutNote(args))
|
|
341
|
+
return refuse({ error: 'source_has_cut_note', detail: CUT_NOTE_REFUSAL });
|
|
336
342
|
// ── Before the approval is spent (input / capability checks) ──────────────
|
|
337
343
|
if (spec.beforeApproval) {
|
|
338
344
|
const refusal = spec.beforeApproval(args, ctx);
|
|
@@ -367,9 +373,17 @@ export async function runWritePipeline(args, ctx, spec) {
|
|
|
367
373
|
if (spec.transport.mode === 'resolve' || spec.transport.mode === 'fallback-resolve') {
|
|
368
374
|
// D15/D20 — an object already locked in an open request is written under
|
|
369
375
|
// THAT request; any other transport is a guaranteed CTS lock conflict.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
376
|
+
// Task 8 (B11): one order for every write — owning lock (resolve below)
|
|
377
|
+
// ?? explicit ?? the project request of this folder ?? the session (where
|
|
378
|
+
// the mode has always used it). No joined project → null → as before.
|
|
379
|
+
// Task 6 (project view): the choice is transportForWrite's, the card's too; the
|
|
380
|
+
// owning lock is looked up below. A 'resolve' write has never used the session.
|
|
381
|
+
const supplied = transportForWrite({
|
|
382
|
+
op: spec.approval.op,
|
|
383
|
+
explicit: raw.transport,
|
|
384
|
+
project: projectTransportForWrite(ctx, verify.payload),
|
|
385
|
+
session: spec.transport.mode === 'fallback-resolve' ? ctx.currentTransport?.get() : undefined,
|
|
386
|
+
}).request;
|
|
373
387
|
const resolved = await resolveWriteTransport(ctx, spec.transport.type(args), spec.transport.name(args), supplied);
|
|
374
388
|
pre.transport = resolved.transport;
|
|
375
389
|
pre.owning = resolved.owning;
|
|
@@ -383,8 +397,13 @@ export async function runWritePipeline(args, ctx, spec) {
|
|
|
383
397
|
}
|
|
384
398
|
}
|
|
385
399
|
else {
|
|
386
|
-
// Three-tier fallback: explicit arg → session current-transport → none.
|
|
387
|
-
pre.transport =
|
|
400
|
+
// Three-tier fallback: explicit arg → (Task 8) project request → session current-transport → none.
|
|
401
|
+
pre.transport = transportForWrite({
|
|
402
|
+
op: spec.approval.op,
|
|
403
|
+
explicit: raw.transport,
|
|
404
|
+
project: projectTransportForWrite(ctx, verify.payload),
|
|
405
|
+
session: ctx.currentTransport?.get(),
|
|
406
|
+
}).request;
|
|
388
407
|
}
|
|
389
408
|
// ── Gate 2: Snapshot (Rule 7) ─────────────────────────────────────────────
|
|
390
409
|
const snapRefusal = await snapshotGate(args, ctx, spec, pre);
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
4
|
import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
|
|
5
|
-
import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } from '../approvals/op-labels.js';
|
|
5
|
+
import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourLines } from '../approvals/op-labels.js';
|
|
6
6
|
// BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
|
|
7
7
|
// DEAD — every keypress ignored — when it appeared after a guarded
|
|
8
8
|
// /abap-plan turn. Root cause is identical to the one AskCard
|
|
@@ -41,14 +41,17 @@ export function riskRows(effectiveRisk, width) {
|
|
|
41
41
|
export const AFFECTS_MAX_ROWS = 2;
|
|
42
42
|
/**
|
|
43
43
|
* The rows under the verb line: "Affects: …" (none when the change names no
|
|
44
|
-
* process), then
|
|
45
|
-
*
|
|
44
|
+
* process), then "Behaviour: …" (Task 7, up to 3 rows, none when absent),
|
|
45
|
+
* then — Part 3 D12 — a release card's fact lines, each wrapping onto up to
|
|
46
|
+
* AFFECTS_MAX_ROWS rows. Any other change: exactly as before.
|
|
46
47
|
*/
|
|
47
48
|
export function affectsRows(change, width) {
|
|
48
49
|
const cols = Math.max(1, width - MODAL_CHROME_COLS);
|
|
49
50
|
const line = affectsLine(change);
|
|
50
51
|
return [
|
|
51
52
|
...(line ? wrapRows(line, cols, AFFECTS_MAX_ROWS) : []),
|
|
53
|
+
// Task 7 — "Behaviour: …" (at most 3 rows; none when absent or 'none').
|
|
54
|
+
...behaviourLines(change, cols),
|
|
52
55
|
...releaseLines(change).flatMap((l) => wrapRows(l, cols, AFFECTS_MAX_ROWS)),
|
|
53
56
|
];
|
|
54
57
|
}
|
package/dist/ui/footer-line.js
CHANGED
|
@@ -3,6 +3,22 @@ import { Text, useStdout } from 'ink';
|
|
|
3
3
|
import { useSapState } from './sap-state-store.js';
|
|
4
4
|
import { formatFooterLine, footerStateFrom } from '../renderer/footer-line.js';
|
|
5
5
|
import { inputBoxWidth } from './footer.js';
|
|
6
|
+
import { projectTransport } from '../register/project-transport.js';
|
|
7
|
+
/** How long the footer trusts its last read of the project file (the line redraws often). */
|
|
8
|
+
const PROJECT_READ_MS = 2_000;
|
|
9
|
+
let lastRead = null;
|
|
10
|
+
/**
|
|
11
|
+
* Task 6 (project view, R8): the request of the project joined in this folder,
|
|
12
|
+
* read at most every two seconds. Null outside a project folder (never throws:
|
|
13
|
+
* `projectTransport` reads null for anything it cannot use).
|
|
14
|
+
*/
|
|
15
|
+
export function footerProjectTransport(cwd = process.cwd(), now = Date.now()) {
|
|
16
|
+
if (lastRead && lastRead.cwd === cwd && now - lastRead.at < PROJECT_READ_MS)
|
|
17
|
+
return lastRead.request;
|
|
18
|
+
const request = projectTransport(cwd);
|
|
19
|
+
lastRead = { cwd, at: now, request };
|
|
20
|
+
return request;
|
|
21
|
+
}
|
|
6
22
|
/**
|
|
7
23
|
* The one footer line under the input box (piece 2, spec §8) — replaces the
|
|
8
24
|
* StatusLine. Same width as the input box so the two align. Single truncating
|
|
@@ -12,5 +28,7 @@ export function FooterLine() {
|
|
|
12
28
|
const s = useSapState();
|
|
13
29
|
const { stdout } = useStdout();
|
|
14
30
|
const width = inputBoxWidth(stdout?.columns || 80);
|
|
15
|
-
|
|
31
|
+
// In a project folder with a request, the footer says it (a write that names none lands there).
|
|
32
|
+
const project = footerProjectTransport();
|
|
33
|
+
return _jsx(Text, { wrap: "truncate-end", children: formatFooterLine(footerStateFrom(s, project), width) });
|
|
16
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.50",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
55
55
|
"@azure/msal-node": "6.0.1",
|
|
56
|
-
"@cspeach/register-core": "0.4.
|
|
57
|
-
"@cspeach/sap-client": "0.1.
|
|
56
|
+
"@cspeach/register-core": "0.4.2",
|
|
57
|
+
"@cspeach/sap-client": "0.1.30",
|
|
58
58
|
"@iarna/toml": "^2.2.5",
|
|
59
59
|
"@inkjs/ui": "^2.0.0",
|
|
60
60
|
"@inquirer/prompts": "^7.0.0",
|