@cspeach/cli 1.1.52 → 1.1.54
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/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +150 -17
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +17 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +22 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +74 -4
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +33 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +133 -18
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +83 -20
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +239 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +159 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +116 -0
- package/dist/repl/team-build-start.js +178 -0
- package/dist/repl.js +216 -11
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/_team-work-path.js +1 -1
- package/dist/tools/approval.js +12 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +243 -0
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +2 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spec_answers_record — record blocker answers the developer gives in chat
|
|
3
|
+
* (Task 18, F-O, 2026-10-26).
|
|
4
|
+
*
|
|
5
|
+
* Live: after "ok go ahead" the model ran `node -e` to edit the spec-gap
|
|
6
|
+
* `.cspeach.json` itself, inventing an `answeredBy`, and tripped the risky
|
|
7
|
+
* command card. This tool is the proper way: it runs the blocker form's own
|
|
8
|
+
* save step (saveBlockerAnswers: applyBlockerAnswers with the session's
|
|
9
|
+
* signed-in identity → validateEnvelope → write in place), so an answer
|
|
10
|
+
* recorded here is the same as one typed into the form.
|
|
11
|
+
*
|
|
12
|
+
* Path: resolved like file_read (teamReadTarget), so the folder-relative path
|
|
13
|
+
* the blocker note gives (`.cspeach/<name>.cspeach.json`) opens and saves the
|
|
14
|
+
* same file — in a team folder that is the seat copy. Containment and
|
|
15
|
+
* real-path checks are file_read's. Fix round 1: only a saved result the CLI
|
|
16
|
+
* writes (`.cspeach/<name>.cspeach.json`, isReadOnlyProjectFile), never the
|
|
17
|
+
* shared team `.cspeach` (teamReadTarget's fallback there is read-only), and
|
|
18
|
+
* only a spec-gap envelope.
|
|
19
|
+
*
|
|
20
|
+
* 2026-10-28: a spec shared in a team build (`specs/<slug>/<name>.cspeach.json`)
|
|
21
|
+
* is answered with the blocker form's record mode: the revision is validated,
|
|
22
|
+
* each answer becomes its own record in `specs/<slug>/answers/`, and the
|
|
23
|
+
* author's file is never written.
|
|
24
|
+
*
|
|
25
|
+
* Every refusal is one line and writes nothing: a sub-agent or reader child,
|
|
26
|
+
* a path outside the project, a missing file, a file that is not a spec-gap
|
|
27
|
+
* envelope in `.cspeach/`, the shared team `.cspeach`, an unknown id, a blank
|
|
28
|
+
* answer or one over 2,000 characters, an id given twice, a revision that
|
|
29
|
+
* fails validation. Ruling R18: any question id in the file is accepted.
|
|
30
|
+
*
|
|
31
|
+
* Approvals: like file_write for project files — category 'filesystem', so no
|
|
32
|
+
* card and no Rule 8 batch count (isLocalProjectWork). Never touches SAP, so
|
|
33
|
+
* the chat lane allows it.
|
|
34
|
+
*/
|
|
35
|
+
import { promises as fs, realpathSync } from 'node:fs';
|
|
36
|
+
import * as path from 'node:path';
|
|
37
|
+
import { registerTool } from '../index.js';
|
|
38
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isReadOnlyProjectFile, } from '../_filesystem-shared.js';
|
|
39
|
+
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamReadTarget } from '../_team-work-path.js';
|
|
40
|
+
import { SPEC_ANSWERS_RECORD_TOOL } from '../../projects/blocker-answers-note.js';
|
|
41
|
+
import { readProjectFile } from '../../projects/status.js';
|
|
42
|
+
import { applyBlockerAnswers, openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
|
|
43
|
+
import { validateEnvelope } from '../../projects/validate.js';
|
|
44
|
+
import { composeSpec, isSharedSpecPath, readAnswers, sharedSpecTargetFor, specAuthorFor, writeAnswerRecord, SPECS_DIR, } from '../../projects/spec-share.js';
|
|
45
|
+
export const SPEC_ANSWERS_RECORD = SPEC_ANSWERS_RECORD_TOOL;
|
|
46
|
+
/** Fix round 1 (item 4): the longest answer recorded. */
|
|
47
|
+
export const MAX_ANSWER = 2000;
|
|
48
|
+
const QUESTION_MAX = 80;
|
|
49
|
+
const NOTHING_SAVED = 'Nothing was saved.';
|
|
50
|
+
const fail = (line) => ({ content: `error: ${line}`, is_error: true });
|
|
51
|
+
function clip(text, max) {
|
|
52
|
+
const one = text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
53
|
+
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
|
|
54
|
+
}
|
|
55
|
+
/** The same identity loop.ts gives the blocker form (getAuthorIdentity). */
|
|
56
|
+
async function sessionIdentity() {
|
|
57
|
+
const { getAuthorIdentity } = await import('../../agent/loop.js');
|
|
58
|
+
return getAuthorIdentity();
|
|
59
|
+
}
|
|
60
|
+
function realOr(p) {
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync.native(p);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return path.resolve(p);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The build project when `real` is a spec right inside `<team>/specs/<slug>/`, else null. */
|
|
69
|
+
function sharedSpecFile(cwd, real) {
|
|
70
|
+
const target = sharedSpecTargetFor(cwd, 'spec-gap');
|
|
71
|
+
if (!target || !isSharedSpecPath(target.teamDir, real) || !/\.cspeach\.json$/i.test(real))
|
|
72
|
+
return null;
|
|
73
|
+
const rel = path.relative(realOr(path.join(target.teamDir, SPECS_DIR)), real);
|
|
74
|
+
return rel.split(path.sep).length === 2 ? { projectId: target.projectId } : null;
|
|
75
|
+
}
|
|
76
|
+
export async function specAnswersRecordHandler(args, ctx) {
|
|
77
|
+
// Fix round 1 (item 5): only the main conversation records answers (a child could race the form).
|
|
78
|
+
if (ctx.subagent || ctx.readerMode)
|
|
79
|
+
return fail(`answers are recorded in the main conversation only. ${NOTHING_SAVED}`);
|
|
80
|
+
if (typeof args.path !== 'string' || args.path.trim() === '') {
|
|
81
|
+
return fail(`give the path of the spec-gap file, for example .cspeach/<name>.cspeach.json. ${NOTHING_SAVED}`);
|
|
82
|
+
}
|
|
83
|
+
const given = args.path;
|
|
84
|
+
// The path as file_read opens it: in a team folder a saved result is the seat copy.
|
|
85
|
+
const t = teamReadTarget(ctx.cwd, given);
|
|
86
|
+
let real;
|
|
87
|
+
try {
|
|
88
|
+
real = await assertRealPathContained(resolveSafePath(t.root, t.path), t.root);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err instanceof PathOutsideRootError)
|
|
92
|
+
return fail(`that path is outside the project folder. ${NOTHING_SAVED}`);
|
|
93
|
+
if (err.code === 'ENOENT')
|
|
94
|
+
return fail(`file not found: ${given}. ${NOTHING_SAVED}`);
|
|
95
|
+
return fail(`${err instanceof Error ? err.message : String(err)}. ${NOTHING_SAVED}`);
|
|
96
|
+
}
|
|
97
|
+
// Fix round 1 (item 2): never the shared team .cspeach — teamReadTarget's
|
|
98
|
+
// fallback to it is for reading only, as for the write tools.
|
|
99
|
+
if (pointsIntoSharedCspeach(ctx.cwd, real))
|
|
100
|
+
return fail(`${SHARED_CSPEACH_REFUSAL} ${NOTHING_SAVED}`);
|
|
101
|
+
// 2026-10-28 — a spec shared in a team build (specs/<slug>/<name>.cspeach.json)
|
|
102
|
+
// is answered like the blocker form answers it there: one answer record per
|
|
103
|
+
// question beside it, the author's file never rewritten.
|
|
104
|
+
const shared = sharedSpecFile(ctx.cwd, real);
|
|
105
|
+
// Fix round 1 (item 3): only a saved result the CLI writes (.cspeach/<name>.cspeach.json),
|
|
106
|
+
// the same shape file_read's denylist carve-out opens (lane files excluded).
|
|
107
|
+
if (!shared && (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real))) {
|
|
108
|
+
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
109
|
+
}
|
|
110
|
+
if (!(await fs.stat(real)).isFile())
|
|
111
|
+
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
112
|
+
let env;
|
|
113
|
+
try {
|
|
114
|
+
env = readProjectFile(real);
|
|
115
|
+
// R11: only the answers recorded for this envelope.
|
|
116
|
+
if (shared)
|
|
117
|
+
env = composeSpec(env, readAnswers(path.dirname(real)).filter((a) => a.file === path.basename(real)));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
121
|
+
}
|
|
122
|
+
if (env.artefactType !== 'spec-gap')
|
|
123
|
+
return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
|
|
124
|
+
// The answers: every id must be a question in the file (ruling R18: any
|
|
125
|
+
// question, not only blockers), with a non-blank answer of at most MAX_ANSWER.
|
|
126
|
+
const list = Array.isArray(args.answers) ? args.answers : null;
|
|
127
|
+
if (!list || list.length === 0)
|
|
128
|
+
return fail(`give at least one answer as { id, answer }. ${NOTHING_SAVED}`);
|
|
129
|
+
const knownIds = new Set(env.interaction.items.map((it) => it.id));
|
|
130
|
+
const answers = new Map();
|
|
131
|
+
const order = [];
|
|
132
|
+
const problems = [];
|
|
133
|
+
for (const entry of list) {
|
|
134
|
+
const id = entry && typeof entry === 'object' && typeof entry.id === 'string'
|
|
135
|
+
? entry.id.trim() : '';
|
|
136
|
+
const raw = entry && typeof entry === 'object' ? entry.answer : undefined;
|
|
137
|
+
const answer = typeof raw === 'string' ? raw.trim() : '';
|
|
138
|
+
if (!id) {
|
|
139
|
+
problems.push('an answer has no id');
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (order.includes(id)) {
|
|
143
|
+
problems.push(`${id}: given twice`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
order.push(id);
|
|
147
|
+
if (!knownIds.has(id)) {
|
|
148
|
+
problems.push(`${id}: no question with this id in the file`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!answer) {
|
|
152
|
+
problems.push(`${id}: the answer is blank`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (answer.length > MAX_ANSWER) {
|
|
156
|
+
problems.push(`${id}: the answer is longer than 2,000 characters`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
answers.set(id, answer);
|
|
160
|
+
}
|
|
161
|
+
if (problems.length > 0)
|
|
162
|
+
return fail(`${problems.join('; ')}. ${NOTHING_SAVED}`);
|
|
163
|
+
const identity = await sessionIdentity();
|
|
164
|
+
const nowIso = new Date().toISOString();
|
|
165
|
+
let revised;
|
|
166
|
+
if (shared) {
|
|
167
|
+
// The blocker form's record mode: check the revision, write records, never the file.
|
|
168
|
+
revised = applyBlockerAnswers(env, answers, identity, nowIso);
|
|
169
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
|
|
170
|
+
if (!check.ok)
|
|
171
|
+
return fail(`the revised file did not validate (${check.error.message}). ${NOTHING_SAVED}`);
|
|
172
|
+
const specDir = path.dirname(real);
|
|
173
|
+
const by = specAuthorFor(shared.projectId, identity.name);
|
|
174
|
+
const done = [];
|
|
175
|
+
try {
|
|
176
|
+
for (const [gapId, answer] of answers) {
|
|
177
|
+
writeAnswerRecord(specDir, {
|
|
178
|
+
projectId: shared.projectId, slug: path.basename(specDir), file: path.basename(real), gapId, answer, by, at: nowIso,
|
|
179
|
+
});
|
|
180
|
+
done.push(gapId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// A shared folder can fail part-way (full, read-only, sync lock): say what was recorded.
|
|
185
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
186
|
+
return fail(done.length === 0
|
|
187
|
+
? `could not write the answer records (${why}). ${NOTHING_SAVED}`
|
|
188
|
+
: `could not write every answer record (${why}). Recorded: ${done.join(', ')}. The others were not saved.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// The form's own save step: apply with the session identity, validate, write in place.
|
|
193
|
+
const saved = saveBlockerAnswers(real, env, answers, identity, nowIso);
|
|
194
|
+
if (!saved.ok)
|
|
195
|
+
return fail(`the revised file did not validate (${saved.error}). ${NOTHING_SAVED}`);
|
|
196
|
+
revised = saved.revised;
|
|
197
|
+
}
|
|
198
|
+
const rel = path.relative(realOr(t.root), real).split(path.sep).join('/');
|
|
199
|
+
const open = openBlockers(revised);
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
shared ? `Recorded for ${rel} (the spec file itself is unchanged).` : `Saved in ${rel}.`,
|
|
203
|
+
...order.map((id) => `${id}: recorded.`),
|
|
204
|
+
open.length === 0
|
|
205
|
+
? 'No blockers are still open.'
|
|
206
|
+
: `Still open: ${open.map((b) => `${b.id} (${clip(b.text, QUESTION_MAX)})`).join(', ')}.`,
|
|
207
|
+
].join('\n'),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
registerTool({
|
|
211
|
+
name: SPEC_ANSWERS_RECORD,
|
|
212
|
+
description: 'Record answers the developer gives in chat to spec-gap questions, in the saved spec-gap file '
|
|
213
|
+
+ '(.cspeach/<name>.cspeach.json, or in a team build specs/<slug>/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
|
|
214
|
+
+ '(for example gap-004) with its answer, at most 2,000 characters. '
|
|
215
|
+
+ 'Answering a question that already has an answer replaces it. Nothing is saved if any id or answer is refused. '
|
|
216
|
+
+ 'Never edit .cspeach.json files with shell or file-edit tools. No SAP call.',
|
|
217
|
+
isMutating: true,
|
|
218
|
+
category: 'filesystem',
|
|
219
|
+
requiresSap: false,
|
|
220
|
+
input_schema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
path: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
description: 'Folder-relative path of the spec-gap file, for example .cspeach/<name>.cspeach.json.',
|
|
226
|
+
},
|
|
227
|
+
answers: {
|
|
228
|
+
type: 'array',
|
|
229
|
+
description: 'One entry per question answered.',
|
|
230
|
+
items: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
id: { type: 'string', description: 'The question id as in the file, for example gap-004.' },
|
|
234
|
+
answer: { type: 'string', description: "The developer's answer, in their words." },
|
|
235
|
+
},
|
|
236
|
+
required: ['id', 'answer'],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
required: ['path', 'answers'],
|
|
241
|
+
},
|
|
242
|
+
handler: (args, ctx) => specAnswersRecordHandler(args, ctx),
|
|
243
|
+
});
|
|
@@ -30,7 +30,7 @@ import { registerTool } from '../index.js';
|
|
|
30
30
|
import { findingsByObject, shownUpTo } from '../../register/findings.js';
|
|
31
31
|
import { registerFor } from '../../register/picture.js';
|
|
32
32
|
import { ruleProposalOf } from '../../register/decision-rules.js';
|
|
33
|
-
import { activeProject, readProjectFile } from '../../register/store.js';
|
|
33
|
+
import { activeProject, coreProject, readProjectFile } from '../../register/store.js';
|
|
34
34
|
import { shown } from '../../register/atc-file.js';
|
|
35
35
|
import { journeyPage } from '../../journey/flow.js';
|
|
36
36
|
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
@@ -98,7 +98,7 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
98
98
|
const laneId = args.lane?.trim();
|
|
99
99
|
const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
100
100
|
// The project view, built once: the scope, its parts and every row read it.
|
|
101
|
-
const view = projectView(register, project);
|
|
101
|
+
const view = projectView(register, coreProject(project));
|
|
102
102
|
let objects = view.all.objects;
|
|
103
103
|
let scope = { kind: 'project', name: project.name };
|
|
104
104
|
if (laneId) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { WRITE_TOOLS } from './write-mode.js';
|
|
2
|
+
export const SEAT_READ_ONLY_REASON = 'This seat reads SAP and never changes it. Describe the change in plain words, or write it up for the developer as cspeach-out/developer-request-<topic>.md. Do not call SAP write, transport, lock, run, test, ATC or approval tools.';
|
|
3
|
+
/**
|
|
4
|
+
* Refused beyond the generic rule: the two smokes write SAP data over OData
|
|
5
|
+
* although requiresSap is false; unit tests and ATC runs execute on SAP;
|
|
6
|
+
* team_lane_move writes the team project's lanes; fiori_deploy uploads an app.
|
|
7
|
+
* shell_exec and background_run (ruling R8): their safelist runs npm, pnpm,
|
|
8
|
+
* ui5 and node, a deploy route around the fiori_deploy refusal.
|
|
9
|
+
*/
|
|
10
|
+
export const SEAT_EXTRA_REFUSED = new Set([
|
|
11
|
+
'fiori_draft_smoke',
|
|
12
|
+
'cap_link_smoke',
|
|
13
|
+
'sap_run_unit_test',
|
|
14
|
+
'team_lane_move',
|
|
15
|
+
'sap_atc_run',
|
|
16
|
+
'fiori_deploy',
|
|
17
|
+
'shell_exec',
|
|
18
|
+
'background_run',
|
|
19
|
+
]);
|
|
20
|
+
export function isSeatRefusedTool(tool) {
|
|
21
|
+
return WRITE_TOOLS.has(tool.name) || tool.name === 'request_approval' || SEAT_EXTRA_REFUSED.has(tool.name)
|
|
22
|
+
|| (tool.isMutating === true && tool.requiresSap === true);
|
|
23
|
+
}
|
|
24
|
+
/** The model-facing refusal for this call on this seat, or null when it may run. */
|
|
25
|
+
export function seatRefusal(kind, tool, args) {
|
|
26
|
+
if (kind !== 'functional')
|
|
27
|
+
return null;
|
|
28
|
+
if (isSeatRefusedTool(tool))
|
|
29
|
+
return SEAT_READ_ONLY_REASON;
|
|
30
|
+
// No approval minted elsewhere can ever be replayed from a functional seat.
|
|
31
|
+
if (args && typeof args === 'object'
|
|
32
|
+
&& (Object.prototype.hasOwnProperty.call(args, 'approval_id') || Object.prototype.hasOwnProperty.call(args, 'approval_ids')))
|
|
33
|
+
return SEAT_READ_ONLY_REASON;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** The tools this seat is offered. Developers: the same array, untouched. */
|
|
37
|
+
export function toolsForSeat(tools, kind) {
|
|
38
|
+
if (kind !== 'functional')
|
|
39
|
+
return tools;
|
|
40
|
+
return tools.filter((t) => !isSeatRefusedTool(t));
|
|
41
|
+
}
|
|
@@ -63,6 +63,8 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
63
63
|
// Both flag-gated (LOCAL_BUILD_TOOLS / STANDALONE_FILE_TOOLS): skipped when off.
|
|
64
64
|
'abap-app': ['app_lane_resolve', 'read_document'],
|
|
65
65
|
'abap-atc-fix': ['sap_atc_run'],
|
|
66
|
+
// Task 18 (F-O): blocker answers given in chat are recorded with this tool, never a file edit.
|
|
67
|
+
'abap-spec-gap': ['spec_answers_record'],
|
|
66
68
|
'abap-cca': ['team_estate', 'sap_atc_run', 'sap_usage_references', 'sap_api_state'],
|
|
67
69
|
'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings', 'team_lane_move'],
|
|
68
70
|
'abap-data-model': ['sap_create_domain', 'sap_create_data_element', ...SESSION_TRANSPORT],
|
|
@@ -17,6 +17,7 @@ import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
|
|
|
17
17
|
import { getLook } from '../renderer/look.js';
|
|
18
18
|
import { roleProps, inkChip, legacyInkColor } from './role-props.js';
|
|
19
19
|
import { wrapRows } from '../renderer/wrap-rows.js';
|
|
20
|
+
import { decodeDisplayBlock } from '../renderer/html-entities.js';
|
|
20
21
|
import { uiWidth } from '../renderer/ui-width.js';
|
|
21
22
|
import { cardBorderStyle } from './ascii-chars.js';
|
|
22
23
|
/**
|
|
@@ -109,7 +110,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
109
110
|
const backAt = useRef(0);
|
|
110
111
|
const back = () => { backAt.current = Date.now(); setViewing(false); };
|
|
111
112
|
const viewLines = viewing && aff && change.diff
|
|
112
|
-
? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
|
|
113
|
+
? (aff.key === 'd' ? decodeDisplayBlock(change.diff).split('\n') : toSourceLines(change.diff))
|
|
113
114
|
: [];
|
|
114
115
|
const lastTop = Math.max(0, viewLines.length - viewRows);
|
|
115
116
|
useInput((input, key) => {
|