@cspeach/cli 1.1.23 → 1.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/classifier/client.js +27 -2
  3. package/dist/commands/plan-continue.js +11 -1
  4. package/dist/commands/plan-resume.js +38 -1
  5. package/dist/lane/app-chain.js +297 -0
  6. package/dist/lane/app-flow.js +55 -0
  7. package/dist/lane/card.js +148 -0
  8. package/dist/lane/chain-token.js +59 -0
  9. package/dist/lane/inputs.js +459 -0
  10. package/dist/lane/paths.js +21 -0
  11. package/dist/lane/resolve.js +109 -0
  12. package/dist/lane/roots.js +113 -0
  13. package/dist/lane/signals.js +80 -0
  14. package/dist/lane/store.js +232 -0
  15. package/dist/lane/types.js +1 -0
  16. package/dist/one-shot.js +1 -0
  17. package/dist/projects/detail-path.js +117 -0
  18. package/dist/projects/extract-plan.js +46 -6
  19. package/dist/projects/merge-cca.js +19 -0
  20. package/dist/projects/merge-upgrade.js +19 -1
  21. package/dist/projects/plan-lane.js +214 -0
  22. package/dist/projects/plan-schema.js +10 -0
  23. package/dist/projects/promote-command.js +8 -2
  24. package/dist/projects/promote.js +7 -1
  25. package/dist/projects/save-command.js +71 -25
  26. package/dist/projects/workspace.js +10 -0
  27. package/dist/register/records.js +7 -2
  28. package/dist/renderer/tool-labels.js +2 -1
  29. package/dist/repl/resume-standalone.js +3 -0
  30. package/dist/repl.js +23 -2
  31. package/dist/skill-catalog.js +14 -6
  32. package/dist/skills/bundled-skills.js +61 -54
  33. package/dist/tools/_filesystem-shared.js +363 -33
  34. package/dist/tools/cap/metadata-import.js +10 -4
  35. package/dist/tools/filesystem/file-write.js +12 -39
  36. package/dist/tools/filesystem/glob.js +25 -2
  37. package/dist/tools/filesystem/grep.js +25 -2
  38. package/dist/tools/fiori/apply.js +30 -21
  39. package/dist/tools/fiori/commit-gate.js +58 -0
  40. package/dist/tools/fiori/fe-extend.js +6 -1
  41. package/dist/tools/fiori/fe-scaffold.js +6 -1
  42. package/dist/tools/fiori/path-params.js +78 -0
  43. package/dist/tools/fiori/scaffold.js +2 -1
  44. package/dist/tools/fiori/tools.js +53 -14
  45. package/dist/tools/lane/app-lane-resolve.js +329 -0
  46. package/dist/tools/local-build.js +4 -0
  47. package/dist/tools/project/_merge-shared.js +36 -1
  48. package/dist/tools/project/cca_merge.js +4 -0
  49. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  50. package/dist/tools/tool-loading.js +2 -0
  51. package/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ // App lane routing — final fix wave I3. The ONE root the lane decision folder
2
+ // hangs off, and the spec containment rule, shared by every caller:
3
+ //
4
+ // - laneRootFor(cwd): the folder that contains the workspace `.cspeach` —
5
+ // the git root (or cwd) for the default workspace, the configured
6
+ // `workspace_folder` itself otherwise (R17; never the CLI home). The lane write, every pinned read
7
+ // and the relative `from.lane.path` (`.cspeach/lane/<id>.json`) all use it,
8
+ // so a lane written from a repo subfolder is found by a resume from the
9
+ // repo root (plans already resolve through the workspace folder).
10
+ // - containSpecPath(cwd, p): a spec is accepted inside cwd OR the workspace
11
+ // folder, string-contained AND realpath-contained (no link escapes), and
12
+ // refused when ANY of those roots (or the lane root) would denylist it.
13
+ import { promises as fs } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { resolveWorkspacePathFor } from '../projects/workspace.js';
17
+ import { loadConfig } from '../config/loader.js';
18
+ import { cspeachRoot } from '../config/paths.js';
19
+ import { isDenylistedPath, PathOutsideRootError } from '../tools/_filesystem-shared.js';
20
+ /** The workspace folder for this cwd (config override > git root > cwd), absolute. */
21
+ export async function workspaceFolderFor(cwd) {
22
+ try {
23
+ return path.resolve(await resolveWorkspacePathFor(cwd));
24
+ }
25
+ catch {
26
+ return path.resolve(cwd, '.cspeach');
27
+ }
28
+ }
29
+ /**
30
+ * The folder whose `.cspeach/lane/` (and `.cspeach/conventions.toml`, R18)
31
+ * belong to this project.
32
+ * - R17: a configured `workspace_folder` is the root ITSELF
33
+ * (`<workspace_folder>/.cspeach/lane`), never its parent — so `~/CSPeach`
34
+ * never puts lane files in the CLI home `~/.cspeach`.
35
+ * - Default workspace (`<git-root or cwd>/.cspeach`): the git root (or cwd).
36
+ */
37
+ export async function laneRootFor(cwd) {
38
+ let configured;
39
+ try {
40
+ configured = (await loadConfig()).workspace_folder?.trim() || undefined;
41
+ }
42
+ catch {
43
+ configured = undefined;
44
+ }
45
+ if (configured)
46
+ return workspaceFolderFor(cwd);
47
+ return path.dirname(await workspaceFolderFor(cwd));
48
+ }
49
+ /**
50
+ * R17 — refuse a lane root whose `.cspeach` is the CLI home (`~/.cspeach`,
51
+ * config + credentials): no lane or conventions file is ever written there.
52
+ */
53
+ export async function assertNotCliHome(root) {
54
+ // Final residual 2 — compare REAL paths, so a junction / symlink to home
55
+ // (or to ~/.cspeach) cannot slip past a string compare. `.cspeach` may not
56
+ // exist yet: then the root itself must not be the home folder.
57
+ const key = async (p) => {
58
+ const r = await realOr(p);
59
+ return process.platform === 'win32' ? r.toLowerCase() : r;
60
+ };
61
+ const [dot, cliHome, rootReal, home] = await Promise.all([
62
+ key(path.join(root, '.cspeach')), key(cspeachRoot()), key(root), key(os.homedir()),
63
+ ]);
64
+ if (dot === cliHome || rootReal === home) {
65
+ throw new Error('the lane folder would be inside the cspeach home folder (~/.cspeach). Set workspace_folder to a project folder.');
66
+ }
67
+ }
68
+ const inside = (p, root) => {
69
+ const rel = path.relative(root, p);
70
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
71
+ };
72
+ const realOr = async (p) => fs.realpath(p).catch(() => path.resolve(p));
73
+ export class SpecProtectedError extends Error {
74
+ constructor(specPath) {
75
+ super(`spec file is in a protected location and cannot be read: ${specPath}`);
76
+ this.name = 'SpecProtectedError';
77
+ }
78
+ }
79
+ /**
80
+ * Contain a spec path to cwd or the workspace folder. Returns the absolute
81
+ * path and its real path. Throws PathOutsideRootError (outside both roots, or
82
+ * a link out of them), SpecProtectedError (denylisted under any root) or the
83
+ * realpath error (ENOENT when the file does not exist).
84
+ */
85
+ export async function containSpecPath(cwd, specPath) {
86
+ if (typeof specPath !== 'string' || specPath.includes('\0'))
87
+ throw new PathOutsideRootError(String(specPath), cwd);
88
+ const absCwd = path.resolve(cwd);
89
+ const abs = path.isAbsolute(specPath) ? path.resolve(specPath) : path.resolve(absCwd, specPath);
90
+ const ws = await workspaceFolderFor(cwd);
91
+ const roots = [absCwd, ws];
92
+ if (!roots.some((r) => inside(abs, r)))
93
+ throw new PathOutsideRootError(specPath, cwd);
94
+ const real = await fs.realpath(abs);
95
+ const realRoots = await Promise.all(roots.map(realOr));
96
+ if (!realRoots.some((r) => inside(real, r)))
97
+ throw new PathOutsideRootError(specPath, cwd);
98
+ const guards = [...realRoots, await realOr(await laneRootFor(cwd))];
99
+ for (const r of guards) {
100
+ if (inside(real, r) && isDenylistedPath(real, r).blocked)
101
+ throw new SpecProtectedError(specPath);
102
+ }
103
+ return { abs, real };
104
+ }
105
+ /**
106
+ * The spec path as stored in the flow / the lane record and shown on the
107
+ * card: relative to cwd when inside it, else absolute — forward slashes.
108
+ */
109
+ export function specDisplayPath(cwd, abs) {
110
+ const absCwd = path.resolve(cwd);
111
+ const p = inside(abs, absCwd) ? path.relative(absCwd, abs) : path.resolve(abs);
112
+ return p.split(path.sep).join('/');
113
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ /** Fixed keys (spec §6). The model fills value + quote; strength is ours. */
3
+ export const SIGNAL_KEYS = [
4
+ 'app_requested', // the spec asks for an app (not only a report)
5
+ 'ui_only_service', // string: the published OData service the spec names, when it asks only for a UI
6
+ 'report_only', // classic list / ALV output, no app
7
+ 'analytical', // charts / KPIs
8
+ 'transactional', // create / update / delete
9
+ 'custom_ui', // custom layouts / controls a standard floorplan cannot express
10
+ 'cap_external_users', // users outside SAP
11
+ 'cap_multi_system', // data from several systems
12
+ 'cap_own_release_cycle', // own release cycle / scales on its own
13
+ 'cap_ui_on_released_api', // mostly UI + logic on top of a released SAP API
14
+ ];
15
+ const MIN_QUOTE = 8;
16
+ const entry = (value) => z.object({ value, evidence: z.string().max(300).optional() }).strict();
17
+ export const signalSheetSchema = z.object({
18
+ app_requested: entry(z.boolean()),
19
+ ui_only_service: entry(z.string().trim().min(1).max(200).nullable()),
20
+ report_only: entry(z.boolean()),
21
+ analytical: entry(z.boolean()),
22
+ transactional: entry(z.boolean()),
23
+ custom_ui: entry(z.boolean()),
24
+ cap_external_users: entry(z.boolean()),
25
+ cap_multi_system: entry(z.boolean()),
26
+ cap_own_release_cycle: entry(z.boolean()),
27
+ cap_ui_on_released_api: entry(z.boolean()),
28
+ }).strict();
29
+ /** Collapse whitespace runs to one space and trim. */
30
+ const collapse = (s) => s.replace(/\s+/g, ' ').trim();
31
+ /** Curly quotes and en/em dashes to ASCII, so a quote copied from Word still matches. */
32
+ const asciiPunct = (s) => s.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"').replace(/[\u2013\u2014]/g, '-');
33
+ /**
34
+ * Canonical quote matcher: whitespace collapsed and curly quotes / dashes mapped to
35
+ * ASCII on BOTH sides, then a case-sensitive substring check. No paraphrase tolerance.
36
+ */
37
+ export function findNormalised(text, quote) {
38
+ const q = collapse(asciiPunct(quote));
39
+ if (q === '')
40
+ return false;
41
+ return collapse(asciiPunct(text)).includes(q);
42
+ }
43
+ /**
44
+ * Strong = a truthy value AND a quote of ≥ 8 chars (after whitespace collapse) found word for word in the spec.
45
+ *
46
+ * I2 (final review) — `normalise` is the strip that produced `specText` from
47
+ * the document (readSpecText's `normalise`; identity for plain text). Each
48
+ * quote and the `ui_only_service` value are checked as written AND after that
49
+ * same strip — never any looser — so a quote copied from the turndown text
50
+ * (`ZI\_SALES`, `**bold**`) matches the stripped spec text.
51
+ */
52
+ export function withStrength(sheet, specText, find, normalise = (s) => s) {
53
+ const forms = (s) => {
54
+ const n = normalise(s);
55
+ return n === s ? [s] : [s, n];
56
+ };
57
+ const out = {};
58
+ for (const k of SIGNAL_KEYS) {
59
+ const e = sheet[k];
60
+ const quoteFound = forms(e.evidence ?? '').some((f) => {
61
+ const q = collapse(f);
62
+ return q.length >= MIN_QUOTE && find(specText, q);
63
+ });
64
+ const truthy = typeof e.value === 'string' ? e.value.trim() !== '' : e.value === true;
65
+ // ui_only_service: the named service must itself appear in the spec, so a real
66
+ // quote cannot carry a made-up service name. The value kept is the form found
67
+ // in the spec (a docx `ZSALES\_SRV` is stored as `ZSALES_SRV`).
68
+ let value = e.value;
69
+ let serviceNamed = k !== 'ui_only_service';
70
+ if (k === 'ui_only_service' && typeof e.value === 'string') {
71
+ const hit = forms(e.value).find((f) => findNormalised(specText, f));
72
+ if (hit !== undefined) {
73
+ serviceNamed = true;
74
+ value = hit;
75
+ }
76
+ }
77
+ out[k] = { value, evidence: e.evidence, quoteFound, strength: truthy && quoteFound && serviceNamed ? 'strong' : 'weak' };
78
+ }
79
+ return out;
80
+ }
@@ -0,0 +1,232 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises';
4
+ import { join, posix, resolve, sep } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { LANE_DIR, LANE_NAME_RE } from './paths.js';
7
+ export class LaneShaMismatchError extends Error {
8
+ path;
9
+ expected;
10
+ actual;
11
+ constructor(path, expected, actual) {
12
+ super(`The lane decision file ${path} was changed after the lane card was confirmed, so it will not be used. Run /abap-app again to choose the lane.`);
13
+ this.path = path;
14
+ this.expected = expected;
15
+ this.actual = actual;
16
+ this.name = 'LaneShaMismatchError';
17
+ }
18
+ }
19
+ const MAX_BYTES = 256 * 1024;
20
+ const NAME = LANE_NAME_RE;
21
+ const backendLane = z.enum(['rap', 'cap', 'none', 'abap-report', 'segw']);
22
+ const uiLane = z.enum(['fiori-elements', 'freestyle', 'none']);
23
+ const floorplan = z.enum(['listReport', 'worklist', 'ovp', 'alp']);
24
+ const runtime = z.enum(['btp', 'own-server', 'none']);
25
+ const decidedBy = z.enum(['pin', 'standards', 'rules', 'developer']);
26
+ const tier = z.enum(['s4-2020-plus', 's4-before-2020', 'ecc-ehp7-plus', 'ecc-old', 'netweaver-740-plus', 'netweaver-old', 'unknown']);
27
+ const laneDecisionSchema = z.object({
28
+ backend: backendLane.nullable(),
29
+ ui: uiLane,
30
+ floorplan: floorplan.optional(),
31
+ decidedBy,
32
+ decidedByPath: z.string().optional(),
33
+ neutral: z.boolean(),
34
+ noAppLane: z.boolean(),
35
+ needsRuntime: z.boolean(),
36
+ allowed: z.array(backendLane),
37
+ crossedOut: z.array(z.object({ lane: z.union([backendLane, z.literal('bopf-generated')]), reason: z.string() })),
38
+ reasons: z.array(z.string()),
39
+ weakHints: z.array(z.string()),
40
+ unverified: z.boolean(),
41
+ facts: z.object({ source: z.enum(['probed', 'declared', 'unknown']), tier, label: z.string() }),
42
+ });
43
+ const laneRecordSchema = z.object({
44
+ id: z.string().min(1),
45
+ createdAt: z.string().min(1),
46
+ spec: z.object({ path: z.string(), sha256: z.string() }),
47
+ specGap: z.object({ path: z.string() }).optional(),
48
+ recommendation: laneDecisionSchema,
49
+ final: z.object({ backend: backendLane, ui: uiLane, floorplan: floorplan.optional(), runtime: runtime.optional() }),
50
+ decidedBy,
51
+ }).superRefine((r, ctx) => {
52
+ if (r.final.backend === 'segw') {
53
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['final', 'backend'], message: 'segw is not a pickable lane yet' });
54
+ }
55
+ if (!r.recommendation.allowed.includes(r.final.backend)) {
56
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['final', 'backend'], message: `${r.final.backend} is not an allowed lane on this system` });
57
+ }
58
+ const changed = r.final.backend !== r.recommendation.backend || r.final.ui !== r.recommendation.ui;
59
+ if (changed && r.decidedBy !== 'developer') {
60
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['decidedBy'], message: 'a lane that differs from the recommendation must be decided by the developer' });
61
+ }
62
+ });
63
+ const shapeError = (rel, err) => {
64
+ const first = err.issues[0];
65
+ return new Error(`The lane decision file ${rel} is not a valid lane decision (${first?.path.join('.') || 'root'}: ${first?.message ?? 'bad shape'}).`);
66
+ };
67
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
68
+ /** sha256 of the file bytes. */
69
+ export async function laneFileSha(abs) {
70
+ return sha256(await readFile(abs));
71
+ }
72
+ const LANE_CHAIN = ['.cspeach', LANE_DIR];
73
+ async function assertPlainDir(cwd, rel) {
74
+ const st = await lstat(join(cwd, rel));
75
+ if (st.isSymbolicLink() || !st.isDirectory()) {
76
+ throw new Error(`${rel} must be a plain folder, not a symlink.`);
77
+ }
78
+ }
79
+ /** Refuse when any part of the lane folder chain is a symlink / junction (or not a folder). */
80
+ async function assertRealDirs(cwd) {
81
+ for (const rel of LANE_CHAIN)
82
+ await assertPlainDir(cwd, rel);
83
+ }
84
+ /**
85
+ * Create the lane folder chain one level at a time, checking each level BEFORE
86
+ * anything is created beneath it, so a linked `.cspeach` never gets a folder made
87
+ * inside its target.
88
+ */
89
+ async function ensureLaneDir(cwd) {
90
+ for (const rel of LANE_CHAIN) {
91
+ try {
92
+ await mkdir(join(cwd, rel));
93
+ }
94
+ catch (e) {
95
+ if (e.code !== 'EEXIST')
96
+ throw e;
97
+ }
98
+ await assertPlainDir(cwd, rel);
99
+ }
100
+ }
101
+ const today = () => new Date().toISOString().slice(0, 10).replace(/-/g, '');
102
+ /**
103
+ * Write a new lane decision file. Never overwrites: flag `wx`, fresh random id.
104
+ * Returns the workspace-relative path (forward slashes) and the sha256 of the bytes written.
105
+ */
106
+ export async function writeLaneDecision(cwd, rec) {
107
+ const probe = laneRecordSchema.safeParse({ id: 'lane-check', createdAt: new Date().toISOString(), ...rec });
108
+ if (!probe.success)
109
+ throw shapeError('(new)', probe.error);
110
+ await ensureLaneDir(cwd);
111
+ for (let attempt = 0; attempt < 5; attempt++) {
112
+ const id = `lane-${today()}-${randomBytes(3).toString('hex')}`;
113
+ const full = laneRecordSchema.parse({ id, createdAt: new Date().toISOString(), ...rec });
114
+ const text = `${JSON.stringify(full, null, 2)}\n`;
115
+ const rel = `${LANE_DIR}/${id}.json`;
116
+ try {
117
+ await writeFile(join(cwd, rel), text, { encoding: 'utf8', flag: 'wx' });
118
+ }
119
+ catch (e) {
120
+ if (e.code === 'EEXIST')
121
+ continue;
122
+ throw e;
123
+ }
124
+ return { path: rel, sha256: sha256(Buffer.from(text, 'utf8')) };
125
+ }
126
+ throw new Error('Could not pick a free name for the lane decision file.');
127
+ }
128
+ /** Only `.cspeach/lane/<name>.json`; no backslashes, no `..`, no absolute paths, no subfolders. */
129
+ function containedLanePath(relPath) {
130
+ const refuse = () => new Error(`Refused: a lane decision must be a file directly in ${LANE_DIR}/ (got ${JSON.stringify(relPath.slice(0, 200))}).`);
131
+ if (typeof relPath !== 'string' || relPath === '' || /[\\\u0000]/.test(relPath))
132
+ throw refuse();
133
+ if (posix.isAbsolute(relPath) || /^[A-Za-z]:/.test(relPath))
134
+ throw refuse();
135
+ if (relPath.split('/').includes('..'))
136
+ throw refuse();
137
+ const norm = posix.normalize(relPath);
138
+ if (!NAME.test(norm))
139
+ throw refuse();
140
+ return norm;
141
+ }
142
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
143
+ const tooLarge = (rel) => new Error(`Refused: ${rel} is too large (over 256 KB).`);
144
+ /** Open once, fstat the handle, read at most MAX+1 bytes from that same handle. */
145
+ async function readCapped(abs, rel) {
146
+ const fh = await open(abs, constants.O_RDONLY | NOFOLLOW);
147
+ try {
148
+ const st = await fh.stat();
149
+ if (!st.isFile())
150
+ throw new Error(`Refused: ${rel} is not a plain file.`);
151
+ if (st.size > MAX_BYTES)
152
+ throw tooLarge(rel);
153
+ const buf = Buffer.alloc(MAX_BYTES + 1);
154
+ let total = 0;
155
+ while (total < buf.length) {
156
+ const { bytesRead } = await fh.read(buf, total, buf.length - total, total);
157
+ if (bytesRead === 0)
158
+ break;
159
+ total += bytesRead;
160
+ }
161
+ if (total > MAX_BYTES)
162
+ throw tooLarge(rel);
163
+ return buf.subarray(0, total);
164
+ }
165
+ finally {
166
+ await fh.close();
167
+ }
168
+ }
169
+ /**
170
+ * Read a lane decision file. Refuses paths outside `.cspeach/lane/`, symlinks and
171
+ * files over 256 KB; validates the shape; throws LaneShaMismatchError when
172
+ * `expectedSha` is given and the file bytes differ. Internal / tests: callers
173
+ * that act on a lane use readPinnedLaneDecision.
174
+ */
175
+ export async function readLaneDecision(cwd, relPath, expectedSha) {
176
+ const rel = containedLanePath(relPath);
177
+ const root = resolve(cwd, LANE_DIR);
178
+ const abs = resolve(cwd, rel);
179
+ if (!abs.startsWith(root + sep))
180
+ throw new Error(`Refused: ${rel} is outside ${LANE_DIR}/.`);
181
+ let st;
182
+ try {
183
+ st = await lstat(abs);
184
+ }
185
+ catch (e) {
186
+ if (e.code === 'ENOENT')
187
+ throw new Error(`Lane decision file not found: ${rel}.`);
188
+ throw e;
189
+ }
190
+ if (st.isSymbolicLink())
191
+ throw new Error(`Refused: ${rel} is a symlink.`);
192
+ if (!st.isFile())
193
+ throw new Error(`Refused: ${rel} is not a plain file.`);
194
+ if (st.size > MAX_BYTES)
195
+ throw tooLarge(rel);
196
+ await assertRealDirs(cwd);
197
+ let bytes;
198
+ try {
199
+ bytes = await readCapped(abs, rel);
200
+ }
201
+ catch (e) {
202
+ if (e.code === 'ELOOP')
203
+ throw new Error(`Refused: ${rel} is a symlink.`);
204
+ throw e;
205
+ }
206
+ if (expectedSha !== undefined) {
207
+ const actual = sha256(bytes);
208
+ if (actual !== expectedSha)
209
+ throw new LaneShaMismatchError(rel, expectedSha, actual);
210
+ }
211
+ let raw;
212
+ try {
213
+ raw = JSON.parse(bytes.toString('utf8'));
214
+ }
215
+ catch {
216
+ throw new Error(`The lane decision file ${rel} is not valid JSON.`);
217
+ }
218
+ const parsed = laneRecordSchema.safeParse(raw);
219
+ if (!parsed.success)
220
+ throw shapeError(rel, parsed.error);
221
+ return parsed.data;
222
+ }
223
+ /**
224
+ * The only read for callers that act on a lane (plan creation, plan resume):
225
+ * the sha256 from the plan is required, so a hand-edited file is always refused.
226
+ */
227
+ export async function readPinnedLaneDecision(cwd, relPath, sha) {
228
+ if (typeof sha !== 'string' || !/^[0-9a-f]{64}$/.test(sha)) {
229
+ throw new Error('Refused: a pinned lane decision needs the sha256 recorded in the plan.');
230
+ }
231
+ return readLaneDecision(cwd, relPath, sha);
232
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/one-shot.js CHANGED
@@ -56,6 +56,7 @@ import './tools/cap/service-catalog.js';
56
56
  import './tools/cap/metadata-import.js';
57
57
  import './tools/cap/sap-link-tool.js';
58
58
  import './tools/cap/link-smoke.js';
59
+ import './tools/lane/app-lane-resolve.js';
59
60
  import './tools/extend-model/tool.js';
60
61
  import './tools/capability/tool.js';
61
62
  import './tools/web/web_fetch.js';
@@ -0,0 +1,117 @@
1
+ // cspeach-cli/src/projects/detail-path.ts
2
+ /**
3
+ * SEC (review round 2): `content.detailPath` inside a saved envelope is DATA
4
+ * the model can write — never a trusted path. Every reader that turns it into
5
+ * a file access (upgrade merge baseline hash, the register's CCA objects.json,
6
+ * promote's file_read hint) goes through here:
7
+ *
8
+ * - safeDetailRel: the string must be a plain project-relative path under a
9
+ * skill working folder — `.cspeach/<domain>/…` (or the legacy
10
+ * `.abapforge/<domain>/…`). No absolute paths, drive letters, `..`, `:`
11
+ * streams, control characters, trailing dots/spaces or device names.
12
+ * - resolveDetailFile(Sync): the REAL file must sit in that working folder
13
+ * under the project root (or the workspace folder) and not be a protected
14
+ * path. Returns null for refused AND missing alike, so callers can give ONE
15
+ * message and never act as an existence oracle.
16
+ */
17
+ import * as path from 'node:path';
18
+ import { realpathSync, statSync } from 'node:fs';
19
+ import { CSPEACH_WORK_SUBDIRS, isDenylistedPath, isWindowsReservedName } from '../tools/_filesystem-shared.js';
20
+ import { workspaceFolderFor } from '../lane/roots.js';
21
+ /** Working-folder roots a detail path may start with (current, legacy). */
22
+ const DETAIL_ROOTS = ['.cspeach', '.abapforge'];
23
+ /**
24
+ * The normalised '/'-separated project-relative detail path, or null when the
25
+ * value is not a safe path into `<root>/<domain>/` (any domain when omitted).
26
+ */
27
+ export function safeDetailRel(detailPath, domain) {
28
+ if (typeof detailPath !== 'string' || detailPath.length === 0 || detailPath.length > 512)
29
+ return null;
30
+ if (/[\u0000-\u001f\u007f]/.test(detailPath))
31
+ return null;
32
+ if (path.posix.isAbsolute(detailPath) || path.win32.isAbsolute(detailPath) || /^[A-Za-z]:/.test(detailPath))
33
+ return null;
34
+ const segs = detailPath.replace(/\\/g, '/').split('/');
35
+ while (segs[0] === '.')
36
+ segs.shift();
37
+ if (segs.length < 3)
38
+ return null;
39
+ for (const seg of segs) {
40
+ if (seg === '' || seg === '.' || seg === '..')
41
+ return null;
42
+ if (seg.includes(':') || /[. ]$/.test(seg) || /[<>"|?*]/.test(seg))
43
+ return null;
44
+ if (isWindowsReservedName(seg))
45
+ return null;
46
+ }
47
+ const rootSeg = segs[0].toLowerCase();
48
+ const domainSeg = segs[1].toLowerCase();
49
+ if (!DETAIL_ROOTS.includes(rootSeg))
50
+ return null;
51
+ if (domain ? domainSeg !== domain : !CSPEACH_WORK_SUBDIRS.includes(domainSeg))
52
+ return null;
53
+ return segs.join('/');
54
+ }
55
+ function realOrNull(p) {
56
+ try {
57
+ return realpathSync.native(p);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * The real path of the first candidate that exists, is a regular file, stays
65
+ * inside `<base>/<first two segments of rel>/` after links are followed, and
66
+ * is not denylisted relative to `cwd`. null otherwise.
67
+ */
68
+ function firstContained(cands, cwd) {
69
+ for (const { base, rel, keep } of cands) {
70
+ const realBase = realOrNull(path.resolve(base));
71
+ const real = realOrNull(path.join(path.resolve(base), ...rel.split('/')));
72
+ if (!realBase || !real)
73
+ continue;
74
+ const segs = rel.split('/');
75
+ const folder = path.join(realBase, ...segs.slice(0, keep));
76
+ const inner = path.relative(folder, real);
77
+ if (inner === '' || inner.startsWith('..') || path.isAbsolute(inner))
78
+ continue;
79
+ if (isDenylistedPath(real, cwd).blocked)
80
+ continue;
81
+ try {
82
+ if (!statSync(real).isFile())
83
+ continue;
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ return real;
89
+ }
90
+ return null;
91
+ }
92
+ /** Sync resolution under the project root only (callers without a workspace). */
93
+ export function resolveDetailFileSync(detailPath, domain, projectRoot) {
94
+ const rel = safeDetailRel(detailPath, domain);
95
+ if (!rel)
96
+ return null;
97
+ return firstContained([{ base: projectRoot, rel, keep: 2 }], projectRoot);
98
+ }
99
+ /**
100
+ * Resolution under the project root (cwd) and the resolved workspace folder
101
+ * (which may sit elsewhere: a git-root `.cspeach/`, a configured folder). For
102
+ * the workspace, a `.cspeach/<domain>/x` path maps to `<workspace>/<domain>/x`.
103
+ */
104
+ export async function resolveDetailFile(detailPath, domain, cwd) {
105
+ const rel = safeDetailRel(detailPath, domain);
106
+ if (!rel)
107
+ return null;
108
+ const cands = [{ base: cwd, rel, keep: 2 }];
109
+ const segs = rel.split('/');
110
+ if (segs[0].toLowerCase() === '.cspeach') {
111
+ try {
112
+ cands.push({ base: await workspaceFolderFor(cwd), rel: segs.slice(1).join('/'), keep: 1 });
113
+ }
114
+ catch { /* no workspace — project root only */ }
115
+ }
116
+ return firstContained(cands, cwd);
117
+ }
@@ -1,5 +1,6 @@
1
1
  import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
2
  import { computeNextPhase, isPhaseSatisfied } from './plan-run.js';
3
+ import { canonicalLanePath } from '../lane/paths.js';
3
4
  // E2 dual-read: accept BOTH the legacy `csforge:` prefix (old artifacts on
4
5
  // customer disks carry it forever) and the current `cspeach:` prefix. Legacy
5
6
  // acceptance is permanent.
@@ -103,14 +104,52 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
103
104
  };
104
105
  return { ...prior, phases, summary };
105
106
  }
107
+ /** R6: a plan's lane is fixed at creation; an app flow never replaces it. */
108
+ export const LANE_OTHER_PLAN = 'This plan was made for another lane decision. Start a new plan with /abap-app.';
109
+ /** R16: a save whose app-flow lane differs from the title-matched prior plan starts a new plan. */
110
+ export const LANE_NEW_FAMILY = 'This is a new plan for the new lane decision; the earlier plan is kept as it was.';
111
+ /** True when the app-flow lane and the prior plan's lane are different decisions (canonical path + sha). */
112
+ export function laneConflictsWithPrior(flowLane, priorContent) {
113
+ const prior = priorContent?.from.lane;
114
+ if (!flowLane || !prior)
115
+ return false;
116
+ return canonicalLanePath(flowLane.path) !== canonicalLanePath(prior.path) || flowLane.sha256 !== prior.sha256;
117
+ }
106
118
  /**
107
- * Parse the LAST plan-manifest block out of the skill's markdown output.
108
- * `priorContent` is the validated content of the envelope being resumed —
109
- * required to expand the compact shape; on the full shape it is the source
110
- * of truth for per-phase `audit` fields (Task 6 review I-1 — audits are
111
- * harness-authored, never model-authored). Seed sessions pass nothing.
119
+ * `from.lane` is CLI-authored only. Precedence: the app-flow lane (plan
120
+ * creation) > the prior envelope's lane (resume / revision — the lane never
121
+ * changes mid-plan) > none (any model-written lane is stripped, the same rule
122
+ * as the specGap sha guard). Never mutates the prior content: the compact
123
+ * merge shares its `from` object with the prior.
112
124
  */
113
- export function extractPlan(markdown, priorContent) {
125
+ function imposeLane(contentInput, priorContent, opts) {
126
+ if (typeof contentInput !== 'object' || contentInput === null || Array.isArray(contentInput))
127
+ return;
128
+ const ci = contentInput;
129
+ // R6 — never replace a prior plan's lane with a different app-flow lane.
130
+ if (laneConflictsWithPrior(opts.lane, priorContent))
131
+ throw new Error(LANE_OTHER_PLAN);
132
+ const lane = opts.lane
133
+ ? { path: canonicalLanePath(opts.lane.path), sha256: opts.lane.sha256 }
134
+ : priorContent?.from.lane
135
+ ? { ...priorContent.from.lane }
136
+ : undefined;
137
+ const from = ci.from;
138
+ if (lane) {
139
+ if (from === undefined)
140
+ ci.from = { lane };
141
+ else if (typeof from === 'object' && from !== null && !Array.isArray(from))
142
+ ci.from = { ...from, lane };
143
+ return;
144
+ }
145
+ // No CLI lane: strip a model-written one. Untouched when absent, so a
146
+ // lane-less plan stays byte-identical.
147
+ if (typeof from === 'object' && from !== null && !Array.isArray(from) && 'lane' in from) {
148
+ const { lane: _dropped, ...rest } = from;
149
+ ci.from = rest;
150
+ }
151
+ }
152
+ export function extractPlan(markdown, priorContent, opts = {}) {
114
153
  // Last block wins — defensive against a turn emitting more than one block.
115
154
  const re = new RegExp(MANIFEST_RE.source, MANIFEST_RE.flags);
116
155
  let m;
@@ -207,6 +246,7 @@ export function extractPlan(markdown, priorContent) {
207
246
  else {
208
247
  throw new Error('plan-manifest must carry either "content" (full shape) or "changed" (compact resume shape)');
209
248
  }
249
+ imposeLane(contentInput, priorContent, opts);
210
250
  const pc = parsePlanContent(contentInput);
211
251
  if (!pc.ok) {
212
252
  throw new Error(`plan-manifest content failed validation:\n ${pc.errors.join('\n ')}`);
@@ -27,6 +27,7 @@
27
27
  * file must be consulted for full coverage
28
28
  */
29
29
  import { OUTPUT_ROOT } from './output-paths.js';
30
+ import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
30
31
  /** Higher = more restrictive. Conflict resolution picks the max. */
31
32
  const RESTRICTIVENESS = {
32
33
  retire: 3,
@@ -94,6 +95,19 @@ export function scopePatternsIntersect(a, b) {
94
95
  function todayUtc() {
95
96
  return new Date().toISOString().slice(0, 10);
96
97
  }
98
+ /** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
99
+ export function assertMergeSlug(label, value) {
100
+ const ok = typeof value === 'string'
101
+ && value.length > 0 && value.length <= 100
102
+ && /^[A-Za-z0-9._-]+$/.test(value)
103
+ && !value.includes('..') && value !== '.'
104
+ && !/[. ]$/.test(value)
105
+ && !isWindowsReservedName(value);
106
+ if (!ok) {
107
+ throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
108
+ + '(no "..", no device names such as CON or NUL).');
109
+ }
110
+ }
97
111
  export function mergeCcaAssessments(inputs, opts = {}) {
98
112
  if (inputs.length < 2) {
99
113
  throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
@@ -214,6 +228,11 @@ export function mergeCcaAssessments(inputs, opts = {}) {
214
228
  // overlap is already warned about above).
215
229
  const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
216
230
  const date = opts.mergedDate ?? todayUtc();
231
+ // SEC (review round 2): projectId comes from INSIDE an envelope (model-
232
+ // writable) and becomes a folder name under .cspeach/cca/projects/. Only a
233
+ // plain slug may do that — never `../`, a drive, a stream or a device name.
234
+ assertMergeSlug('projectId', first.content.projectId);
235
+ assertMergeSlug('merged date', date);
217
236
  const mergedProjectId = `${first.content.projectId}_merged_${date}`;
218
237
  const scopes = [];
219
238
  for (const inp of inputs) {