@cspeach/cli 1.1.23 → 1.1.24
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/loop.js +124 -0
- package/dist/classifier/client.js +27 -2
- package/dist/commands/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-schema.js +10 -0
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +23 -2
- package/dist/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- package/dist/tools/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +4 -0
- package/dist/tools/tool-loading.js +2 -0
- 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';
|
|
@@ -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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
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 ')}`);
|