@nseng-ai/branch-context 0.1.1
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/package.json +48 -0
- package/src/api/index.ts +43 -0
- package/src/api/prompt-assets.ts +4 -0
- package/src/core/attach.ts +455 -0
- package/src/core/attached-plan.ts +447 -0
- package/src/core/branch-context-creation.ts +495 -0
- package/src/core/branch-memory.ts +147 -0
- package/src/core/cli.ts +98 -0
- package/src/core/constants.ts +19 -0
- package/src/core/context.ts +45 -0
- package/src/core/existing-branch-reuse.ts +230 -0
- package/src/core/index.ts +47 -0
- package/src/core/operations.ts +590 -0
- package/src/core/plan-content-slug.ts +50 -0
- package/src/core/prompt-assets.ts +14 -0
- package/src/core/prompts/branch-context-impl.md +34 -0
- package/src/core/session-artifact.ts +114 -0
- package/src/ns/command.ts +33 -0
- package/src/ns/commands/attach.ts +19 -0
- package/src/ns/commands/check.ts +18 -0
- package/src/ns/commands/delete.ts +18 -0
- package/src/ns/commands/from-plan.ts +21 -0
- package/src/ns/commands/list.ts +17 -0
- package/src/ns/commands/load.ts +19 -0
- package/src/ns/repo-local-ns-extension.ts +32 -0
- package/src/pi/enriched-plan-save.ts +551 -0
- package/src/pi/extension.ts +154 -0
- package/src/pi/from-plan-commands.ts +1243 -0
- package/src/pi/gt/upstack-impl-launch.ts +154 -0
- package/src/pi/host-types.ts +135 -0
- package/src/pi/index.ts +16 -0
- package/src/pi/options.ts +32 -0
- package/src/pi/surfaces.ts +11 -0
- package/src/testing/index.ts +258 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { TextEncoder } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { formatErrorMessage, optionalEntries } from "@nseng-ai/foundation/primitives";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getBranchContextPlan,
|
|
9
|
+
listBranchContextPlans,
|
|
10
|
+
type AttachedPlanEntry,
|
|
11
|
+
unwrapBranchContextBrmemResult,
|
|
12
|
+
} from "./branch-memory.ts";
|
|
13
|
+
import {
|
|
14
|
+
BRANCH_CONTEXT_NAMESPACE,
|
|
15
|
+
UNSUPPORTED_ATTACHED_PLAN_KEY,
|
|
16
|
+
isSupportedBranchContextPlanKey,
|
|
17
|
+
} from "./constants.ts";
|
|
18
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/exec";
|
|
19
|
+
import type { GitGateway } from "@nseng-ai/capability-kit/git";
|
|
20
|
+
import { resolveSelectedSavedPlanFile } from "@nseng-ai/plans";
|
|
21
|
+
import type { BranchContextContext } from "./context.ts";
|
|
22
|
+
import { branchContextImplPromptTemplateUrl } from "./prompt-assets.ts";
|
|
23
|
+
|
|
24
|
+
const BRANCH_CONTEXT_IMPL_PROMPT_TEMPLATE = readFileSync(
|
|
25
|
+
branchContextImplPromptTemplateUrl(),
|
|
26
|
+
"utf8",
|
|
27
|
+
).trimEnd();
|
|
28
|
+
|
|
29
|
+
export type LoadedPlanSource = "attached" | "saved";
|
|
30
|
+
|
|
31
|
+
export interface LoadedAttachedPlan {
|
|
32
|
+
branch: string;
|
|
33
|
+
namespace: string;
|
|
34
|
+
selectedKey: string;
|
|
35
|
+
refName: string;
|
|
36
|
+
content: string;
|
|
37
|
+
byteCount: number;
|
|
38
|
+
availableKeys: string[];
|
|
39
|
+
source: LoadedPlanSource;
|
|
40
|
+
sourceFile?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LoadAttachedPlanParams {
|
|
44
|
+
requestedKey?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LoadAttachedPlanOptions {
|
|
48
|
+
cwd: string;
|
|
49
|
+
context: BranchContextContext;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
planStoreRoot?: string;
|
|
52
|
+
sessionEntries?: readonly unknown[];
|
|
53
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class NoAttachedBranchContextEntriesError extends Error {
|
|
57
|
+
readonly branch: string;
|
|
58
|
+
|
|
59
|
+
constructor(branch: string) {
|
|
60
|
+
super(
|
|
61
|
+
[
|
|
62
|
+
`No branch-context entries on branch \`${branch}\`.`,
|
|
63
|
+
"",
|
|
64
|
+
"Create a saved plan with `enriched-plan exec save`, attach it with `ns branch-context exec from-plan`,",
|
|
65
|
+
"or provide a branch/key that already has a canonical plan.",
|
|
66
|
+
].join("\n"),
|
|
67
|
+
);
|
|
68
|
+
this.name = "NoAttachedBranchContextEntriesError";
|
|
69
|
+
this.branch = branch;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AmbiguousBranchContextPlanEntryError extends Error {
|
|
74
|
+
readonly branch: string;
|
|
75
|
+
readonly availableKeys: readonly string[];
|
|
76
|
+
|
|
77
|
+
constructor(branch: string, availableKeys: readonly string[]) {
|
|
78
|
+
super(
|
|
79
|
+
[
|
|
80
|
+
`Multiple supported branch-context plan entries exist on branch \`${branch}\` in namespace \`${BRANCH_CONTEXT_NAMESPACE}\`.`,
|
|
81
|
+
"Pass an explicit named Markdown branch-context key to choose which plan to load.",
|
|
82
|
+
"",
|
|
83
|
+
"Supported keys:",
|
|
84
|
+
formatAvailableKeys([...availableKeys]),
|
|
85
|
+
].join("\n"),
|
|
86
|
+
);
|
|
87
|
+
this.name = "AmbiguousBranchContextPlanEntryError";
|
|
88
|
+
this.branch = branch;
|
|
89
|
+
this.availableKeys = [...availableKeys];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class UnsupportedBranchContextPlanKeyError extends Error {
|
|
94
|
+
readonly branch: string;
|
|
95
|
+
readonly key: string;
|
|
96
|
+
|
|
97
|
+
constructor(branch: string, key: string) {
|
|
98
|
+
super(formatUnsupportedBranchContextPlanKeyMessage(branch, key));
|
|
99
|
+
this.name = "UnsupportedBranchContextPlanKeyError";
|
|
100
|
+
this.branch = branch;
|
|
101
|
+
this.key = key;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class NoSupportedBranchContextPlanEntriesError extends Error {
|
|
106
|
+
readonly branch: string;
|
|
107
|
+
readonly availableKeys: readonly string[];
|
|
108
|
+
|
|
109
|
+
constructor(branch: string, availableKeys: readonly string[]) {
|
|
110
|
+
super(
|
|
111
|
+
[
|
|
112
|
+
`No supported branch-context plan entries exist on branch \`${branch}\` in namespace \`${BRANCH_CONTEXT_NAMESPACE}\`.`,
|
|
113
|
+
`Legacy key \`${UNSUPPORTED_ATTACHED_PLAN_KEY}\` is no longer supported; reattach the plan under a named Markdown key such as <slug>.md.`,
|
|
114
|
+
"",
|
|
115
|
+
"Available keys:",
|
|
116
|
+
formatAvailableKeys([...availableKeys]),
|
|
117
|
+
].join("\n"),
|
|
118
|
+
);
|
|
119
|
+
this.name = "NoSupportedBranchContextPlanEntriesError";
|
|
120
|
+
this.branch = branch;
|
|
121
|
+
this.availableKeys = [...availableKeys];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class RequestedBranchContextPlanKeyNotFoundError extends Error {
|
|
126
|
+
readonly branch: string;
|
|
127
|
+
readonly key: string;
|
|
128
|
+
readonly availableKeys: readonly string[];
|
|
129
|
+
readonly supportedKeys: readonly string[];
|
|
130
|
+
|
|
131
|
+
constructor(params: {
|
|
132
|
+
branch: string;
|
|
133
|
+
key: string;
|
|
134
|
+
availableKeys: readonly string[];
|
|
135
|
+
supportedKeys: readonly string[];
|
|
136
|
+
}) {
|
|
137
|
+
super(
|
|
138
|
+
[
|
|
139
|
+
`Requested branch-context key \`${params.key}\` was not found on branch \`${params.branch}\` in namespace \`${BRANCH_CONTEXT_NAMESPACE}\`.`,
|
|
140
|
+
"",
|
|
141
|
+
"Supported keys:",
|
|
142
|
+
formatAvailableKeys(params.supportedKeys),
|
|
143
|
+
"",
|
|
144
|
+
"Available keys:",
|
|
145
|
+
formatAvailableKeys(params.availableKeys),
|
|
146
|
+
].join("\n"),
|
|
147
|
+
);
|
|
148
|
+
this.name = "RequestedBranchContextPlanKeyNotFoundError";
|
|
149
|
+
this.branch = params.branch;
|
|
150
|
+
this.key = params.key;
|
|
151
|
+
this.availableKeys = [...params.availableKeys];
|
|
152
|
+
this.supportedKeys = [...params.supportedKeys];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class SavedPlanFallbackLoadError extends Error {
|
|
157
|
+
readonly branch: string;
|
|
158
|
+
readonly attachedMessage: string;
|
|
159
|
+
readonly fallbackMessage: string;
|
|
160
|
+
|
|
161
|
+
constructor(params: { branch: string; attachedError: Error; fallbackError: unknown }) {
|
|
162
|
+
const fallbackMessage = formatErrorMessage(params.fallbackError);
|
|
163
|
+
super(
|
|
164
|
+
[
|
|
165
|
+
"Failed to load an attached branch-context plan and no saved-plan fallback could be loaded.",
|
|
166
|
+
"",
|
|
167
|
+
"Attached-plan failure:",
|
|
168
|
+
params.attachedError.message,
|
|
169
|
+
"",
|
|
170
|
+
"Saved-plan fallback failure:",
|
|
171
|
+
fallbackMessage,
|
|
172
|
+
].join("\n"),
|
|
173
|
+
);
|
|
174
|
+
this.name = "SavedPlanFallbackLoadError";
|
|
175
|
+
this.branch = params.branch;
|
|
176
|
+
this.attachedMessage = params.attachedError.message;
|
|
177
|
+
this.fallbackMessage = fallbackMessage;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function loadBranchContextPlan(
|
|
182
|
+
pi: CommandExecApi,
|
|
183
|
+
params: LoadAttachedPlanParams,
|
|
184
|
+
options: LoadAttachedPlanOptions,
|
|
185
|
+
): Promise<LoadedAttachedPlan> {
|
|
186
|
+
try {
|
|
187
|
+
return await loadAttachedPlan(params, options);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (!isSavedPlanFallbackEligibleError(error) || params.requestedKey !== undefined) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
return await loadSavedPlanFallback(pi, error.branch, options);
|
|
195
|
+
} catch (fallbackError) {
|
|
196
|
+
throw new SavedPlanFallbackLoadError({
|
|
197
|
+
branch: error.branch,
|
|
198
|
+
attachedError: error,
|
|
199
|
+
fallbackError,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isSavedPlanFallbackEligibleError(
|
|
206
|
+
error: unknown,
|
|
207
|
+
): error is NoAttachedBranchContextEntriesError {
|
|
208
|
+
return error instanceof NoAttachedBranchContextEntriesError;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function loadAttachedPlan(
|
|
212
|
+
params: LoadAttachedPlanParams,
|
|
213
|
+
options: LoadAttachedPlanOptions,
|
|
214
|
+
): Promise<LoadedAttachedPlan> {
|
|
215
|
+
const branch = await resolveSafeImplementationBranch(
|
|
216
|
+
options.context.git,
|
|
217
|
+
options.cwd,
|
|
218
|
+
options.signal,
|
|
219
|
+
);
|
|
220
|
+
const entries = unwrapBranchContextBrmemResult(
|
|
221
|
+
await listBranchContextPlans(options.context.brmem, { branch }),
|
|
222
|
+
);
|
|
223
|
+
if (entries.length === 0) {
|
|
224
|
+
throw new NoAttachedBranchContextEntriesError(branch);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const selectionInput =
|
|
228
|
+
params.requestedKey === undefined
|
|
229
|
+
? { branch, entries }
|
|
230
|
+
: { branch, requestedKey: params.requestedKey, entries };
|
|
231
|
+
const selectedKey = selectAttachedPlanKey(selectionInput);
|
|
232
|
+
const data = unwrapBranchContextBrmemResult(
|
|
233
|
+
await getBranchContextPlan(options.context.brmem, {
|
|
234
|
+
branch,
|
|
235
|
+
key: selectedKey,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
const availableKeys = sortedUniqueKeys(entries);
|
|
239
|
+
return {
|
|
240
|
+
branch,
|
|
241
|
+
namespace: BRANCH_CONTEXT_NAMESPACE,
|
|
242
|
+
selectedKey,
|
|
243
|
+
refName: data.refName,
|
|
244
|
+
content: data.content,
|
|
245
|
+
byteCount: new TextEncoder().encode(data.content).length,
|
|
246
|
+
availableKeys,
|
|
247
|
+
source: "attached",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function loadSavedPlanFallback(
|
|
252
|
+
pi: CommandExecApi,
|
|
253
|
+
branch: string,
|
|
254
|
+
options: LoadAttachedPlanOptions,
|
|
255
|
+
): Promise<LoadedAttachedPlan> {
|
|
256
|
+
const selected = await resolveSelectedSavedPlanFile(pi, {
|
|
257
|
+
cwd: options.cwd,
|
|
258
|
+
git: options.context.git,
|
|
259
|
+
...optionalEntries({
|
|
260
|
+
planStoreRoot: options.planStoreRoot,
|
|
261
|
+
sessionEntries: options.sessionEntries,
|
|
262
|
+
}),
|
|
263
|
+
shouldAllowSessionSourceBranchMismatch: true,
|
|
264
|
+
shouldFallbackToLatest: true,
|
|
265
|
+
});
|
|
266
|
+
const fileInfo = selectedSavedPlanFileInfo(selected);
|
|
267
|
+
const readTextFile = options.readTextFile ?? defaultReadTextFile;
|
|
268
|
+
const content = await readTextFile(fileInfo.filePath);
|
|
269
|
+
return {
|
|
270
|
+
branch,
|
|
271
|
+
namespace: "local-plan-store",
|
|
272
|
+
selectedKey: fileInfo.fileName,
|
|
273
|
+
refName: fileInfo.filePath,
|
|
274
|
+
content,
|
|
275
|
+
byteCount: new TextEncoder().encode(content).length,
|
|
276
|
+
availableKeys: [fileInfo.fileName],
|
|
277
|
+
source: "saved",
|
|
278
|
+
sourceFile: fileInfo.filePath,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function defaultReadTextFile(path: string): Promise<string> {
|
|
283
|
+
return readFile(path, "utf8");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function selectedSavedPlanFileInfo(
|
|
287
|
+
selected: Awaited<ReturnType<typeof resolveSelectedSavedPlanFile>>,
|
|
288
|
+
): { filePath: string; fileName: string } {
|
|
289
|
+
if (selected.type === "explicit") {
|
|
290
|
+
return { filePath: selected.filePath, fileName: selected.fileName };
|
|
291
|
+
}
|
|
292
|
+
return { filePath: selected.plan.filePath, fileName: selected.plan.fileName };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function normalizeRequestedBranchContextKey(requestedKey: string): string {
|
|
296
|
+
const trimmed = requestedKey.trim();
|
|
297
|
+
if (trimmed.length === 0) {
|
|
298
|
+
throw new Error("Requested branch-context key is empty.");
|
|
299
|
+
}
|
|
300
|
+
if (trimmed.startsWith("/")) {
|
|
301
|
+
throw new Error("Requested branch-context key must not start with `/`.");
|
|
302
|
+
}
|
|
303
|
+
if (trimmed.includes("..")) {
|
|
304
|
+
throw new Error("Requested branch-context key must not contain `..`.");
|
|
305
|
+
}
|
|
306
|
+
return trimmed;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function selectAttachedPlanKey(input: {
|
|
310
|
+
branch: string;
|
|
311
|
+
requestedKey?: string;
|
|
312
|
+
entries: AttachedPlanEntry[];
|
|
313
|
+
}): string {
|
|
314
|
+
const availableKeys = sortedUniqueKeys(input.entries);
|
|
315
|
+
const supportedKeys = availableKeys.filter(isSupportedBranchContextPlanKey);
|
|
316
|
+
const supported = new Set(supportedKeys);
|
|
317
|
+
if (input.requestedKey === undefined) {
|
|
318
|
+
if (supportedKeys.length === 1) {
|
|
319
|
+
return supportedKeys[0]!;
|
|
320
|
+
}
|
|
321
|
+
if (supportedKeys.length === 0) {
|
|
322
|
+
if (availableKeys.length === 0) {
|
|
323
|
+
throw new NoAttachedBranchContextEntriesError(input.branch);
|
|
324
|
+
}
|
|
325
|
+
throw new NoSupportedBranchContextPlanEntriesError(input.branch, availableKeys);
|
|
326
|
+
}
|
|
327
|
+
throw new AmbiguousBranchContextPlanEntryError(input.branch, supportedKeys);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const key = normalizeRequestedBranchContextKey(input.requestedKey);
|
|
331
|
+
if (!isSupportedBranchContextPlanKey(key)) {
|
|
332
|
+
throw new UnsupportedBranchContextPlanKeyError(input.branch, key);
|
|
333
|
+
}
|
|
334
|
+
if (supported.has(key)) {
|
|
335
|
+
return key;
|
|
336
|
+
}
|
|
337
|
+
throw new RequestedBranchContextPlanKeyNotFoundError({
|
|
338
|
+
branch: input.branch,
|
|
339
|
+
key,
|
|
340
|
+
availableKeys,
|
|
341
|
+
supportedKeys,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function buildImplBranchContextPrompt(plan: LoadedAttachedPlan): string {
|
|
346
|
+
const isSavedPlan = plan.source === "saved";
|
|
347
|
+
return renderTemplate(BRANCH_CONTEXT_IMPL_PROMPT_TEMPLATE, {
|
|
348
|
+
loaded_plan_description: isSavedPlan
|
|
349
|
+
? "saved branch-context plan from the local plan store"
|
|
350
|
+
: "attached branch-context plan",
|
|
351
|
+
plan_label: isSavedPlan ? "SAVED PLAN" : "ATTACHED PLAN",
|
|
352
|
+
branch: plan.branch,
|
|
353
|
+
namespace: plan.namespace,
|
|
354
|
+
selected_key: plan.selectedKey,
|
|
355
|
+
ref: plan.refName,
|
|
356
|
+
byte_count: String(plan.byteCount),
|
|
357
|
+
attached_plan: plan.content,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function renderTemplate(template: string, values: Record<string, string>): string {
|
|
362
|
+
let rendered = template;
|
|
363
|
+
for (const [key, value] of Object.entries(values)) {
|
|
364
|
+
rendered = rendered.split(`{{${key}}}`).join(value);
|
|
365
|
+
}
|
|
366
|
+
return rendered;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function loadedPlanTitle(plan: Pick<LoadedAttachedPlan, "source">): string {
|
|
370
|
+
return plan.source === "saved"
|
|
371
|
+
? "Loaded saved branch-context plan from local plan store."
|
|
372
|
+
: "Loaded attached branch-context plan.";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function formatLoadedAttachedPlanEvidence(plan: LoadedAttachedPlan): string {
|
|
376
|
+
return [
|
|
377
|
+
loadedPlanTitle(plan),
|
|
378
|
+
`Branch: ${plan.branch}`,
|
|
379
|
+
`Namespace: ${plan.namespace}`,
|
|
380
|
+
`Selected key: ${plan.selectedKey}`,
|
|
381
|
+
`Ref: ${plan.refName}`,
|
|
382
|
+
`Bytes: ${plan.byteCount}`,
|
|
383
|
+
].join("\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function resolveSafeImplementationBranch(
|
|
387
|
+
git: GitGateway,
|
|
388
|
+
cwd: string,
|
|
389
|
+
signal: AbortSignal | undefined,
|
|
390
|
+
): Promise<string> {
|
|
391
|
+
const repoRoot = await git.repoRoot({ cwd, signal });
|
|
392
|
+
if (!repoRoot.ok) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
["Cannot load attached plan: not in a Git repository.", "", repoRoot.error.message].join(
|
|
395
|
+
"\n",
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const branchResult = await git.currentBranch({ cwd, signal });
|
|
401
|
+
if (branchResult.type === "detached") {
|
|
402
|
+
throw new Error(
|
|
403
|
+
"Cannot load attached plan from detached HEAD. Check out a feature branch first.",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (branchResult.type === "failure") {
|
|
407
|
+
throw new Error(
|
|
408
|
+
[
|
|
409
|
+
"Cannot load attached plan because the current branch could not be resolved.",
|
|
410
|
+
"",
|
|
411
|
+
branchResult.error.message,
|
|
412
|
+
].join("\n"),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const branch = branchResult.branch;
|
|
416
|
+
|
|
417
|
+
const trunkBranch = await git.trunkBranch({ cwd, signal });
|
|
418
|
+
const trunkBranchValue = trunkBranch.type === "found" ? trunkBranch.value : undefined;
|
|
419
|
+
if (branch === "main" || branch === "master" || branch === trunkBranchValue) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Refusing to implement directly on trunk (\`${branch}\`). Check out a feature branch first.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return branch;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sortedUniqueKeys(entries: AttachedPlanEntry[]): string[] {
|
|
429
|
+
return [...new Set(entries.map((entry) => entry.key))].sort();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatUnsupportedBranchContextPlanKeyMessage(branch: string, key: string): string {
|
|
433
|
+
if (key === UNSUPPORTED_ATTACHED_PLAN_KEY) {
|
|
434
|
+
return [
|
|
435
|
+
`Legacy branch-context key ${key} is no longer supported on branch \`${branch}\`.`,
|
|
436
|
+
"Attach the plan under a named Markdown key such as <slug>.md, then load that key.",
|
|
437
|
+
].join("\n");
|
|
438
|
+
}
|
|
439
|
+
return [
|
|
440
|
+
`Unsupported branch-context key ${key} on branch \`${branch}\`.`,
|
|
441
|
+
"Branch-context plans must use a named Markdown key such as <slug>.md.",
|
|
442
|
+
].join("\n");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function formatAvailableKeys(keys: readonly string[]): string {
|
|
446
|
+
return keys.length > 0 ? keys.map((key) => `- ${key}`).join("\n") : "(none)";
|
|
447
|
+
}
|