@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.
@@ -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
- * 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 ')}`);