@nseng-ai/plans 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 +29 -0
- package/src/api.ts +25 -0
- package/src/cli.ts +430 -0
- package/src/content-slug-derivation.ts +127 -0
- package/src/index.ts +72 -0
- package/src/plan-persistence.ts +149 -0
- package/src/plan-store-gateway.ts +104 -0
- package/src/saved-plan-content-slug.ts +31 -0
- package/src/saved-plan-file.ts +583 -0
- package/src/saved-plan-selection.ts +361 -0
- package/src/testing.ts +129 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/exec";
|
|
5
|
+
import { RealGitGateway } from "@nseng-ai/capability-kit/git";
|
|
6
|
+
import type { GitGateway } from "@nseng-ai/capability-kit/git";
|
|
7
|
+
import { isPathInside, optionalEntry } from "@nseng-ai/foundation/primitives";
|
|
8
|
+
import { createRealPlanStoreGateway, type PlanStoreGateway } from "./plan-store-gateway.ts";
|
|
9
|
+
|
|
10
|
+
export { isPathInside } from "@nseng-ai/foundation/primitives";
|
|
11
|
+
|
|
12
|
+
export const MIN_PLAN_SLUG_WORDS = 3;
|
|
13
|
+
export const MAX_PLAN_SLUG_WORDS = 7;
|
|
14
|
+
|
|
15
|
+
const GENERIC_SLUG_WORDS = new Set([
|
|
16
|
+
"plan",
|
|
17
|
+
"task",
|
|
18
|
+
"tasks",
|
|
19
|
+
"work",
|
|
20
|
+
"implementation",
|
|
21
|
+
"implement",
|
|
22
|
+
"changes",
|
|
23
|
+
"change",
|
|
24
|
+
"update",
|
|
25
|
+
"updates",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function validatePlanSlug(slug: string): string | undefined {
|
|
29
|
+
const normalized = slug.trim();
|
|
30
|
+
if (normalized.length === 0) {
|
|
31
|
+
return "Slug is required.";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (normalized.toLowerCase().endsWith(".md")) {
|
|
35
|
+
return "Pass the slug without the .md suffix.";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) {
|
|
39
|
+
return "Slug must be lowercase kebab-case using only a-z, 0-9, and single hyphens.";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (/^(?:19|20)\d{2}-\d{1,2}-\d{1,2}$/.test(normalized)) {
|
|
43
|
+
return "Slug must not be a date.";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tokens = normalized.split("-");
|
|
47
|
+
if (tokens.length < MIN_PLAN_SLUG_WORDS) {
|
|
48
|
+
return `Slug must contain at least ${MIN_PLAN_SLUG_WORDS} words.`;
|
|
49
|
+
}
|
|
50
|
+
if (tokens.length > MAX_PLAN_SLUG_WORDS) {
|
|
51
|
+
return `Slug must contain at most ${MAX_PLAN_SLUG_WORDS} words.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (tokens.some((token) => /^(?:19|20)\d{2}$/.test(token))) {
|
|
55
|
+
return "Slug must not contain date-like year tokens.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (tokens.every((token) => GENERIC_SLUG_WORDS.has(token))) {
|
|
59
|
+
return "Slug must include at least one specific, non-generic word.";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizePlanFilePath(rawPath: string): string {
|
|
66
|
+
const trimmed = rawPath.trim();
|
|
67
|
+
const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
68
|
+
if (withoutAt === "~") {
|
|
69
|
+
return homedir();
|
|
70
|
+
}
|
|
71
|
+
if (withoutAt.startsWith("~/")) {
|
|
72
|
+
return join(homedir(), withoutAt.slice(2));
|
|
73
|
+
}
|
|
74
|
+
return withoutAt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResolvePlanSourceFileOptions {
|
|
78
|
+
cwd: string;
|
|
79
|
+
rawFilePath: string;
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
git?: GitGateway;
|
|
82
|
+
planStoreGateway?: PlanStoreGateway;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ResolveGitRepoRootOptions {
|
|
86
|
+
cwd: string;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
git?: GitGateway;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolvePlanSourceFile(
|
|
92
|
+
pi: CommandExecApi,
|
|
93
|
+
options: ResolvePlanSourceFileOptions,
|
|
94
|
+
): Promise<string> {
|
|
95
|
+
const git = options.git ?? new RealGitGateway(pi);
|
|
96
|
+
const planStoreGateway = options.planStoreGateway ?? createRealPlanStoreGateway();
|
|
97
|
+
const normalizedPath = normalizePlanFilePath(options.rawFilePath);
|
|
98
|
+
if (!isAbsolute(normalizedPath)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Plan file path must be absolute or home-relative; got ${displayNonEmpty(normalizedPath)}.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const fileStat = await planStoreGateway.statPath(normalizedPath);
|
|
105
|
+
if (fileStat === undefined) {
|
|
106
|
+
throw new Error(`Plan file does not exist or is not accessible: ${normalizedPath}`);
|
|
107
|
+
}
|
|
108
|
+
if (fileStat.type !== "file") {
|
|
109
|
+
throw new Error(`Plan file must be a regular file: ${normalizedPath}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const realFilePath = await planStoreGateway.realpathOrResolve(normalizedPath);
|
|
113
|
+
const repoRoot = await resolveGitRepoRoot(pi, {
|
|
114
|
+
cwd: options.cwd,
|
|
115
|
+
git,
|
|
116
|
+
...optionalEntry("signal", options.signal),
|
|
117
|
+
});
|
|
118
|
+
if (repoRoot !== undefined) {
|
|
119
|
+
const realRepoRoot = await planStoreGateway.realpathOrResolve(repoRoot);
|
|
120
|
+
if (isPathInside(realRepoRoot, realFilePath)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Plan file must be outside the repository; got ${realFilePath} inside ${realRepoRoot}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return realFilePath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function resolveGitRepoRoot(
|
|
131
|
+
pi: CommandExecApi,
|
|
132
|
+
options: ResolveGitRepoRootOptions,
|
|
133
|
+
): Promise<string | undefined> {
|
|
134
|
+
const git = options.git ?? new RealGitGateway(pi);
|
|
135
|
+
const root = await git.optionalRepoRoot({ cwd: options.cwd, signal: options.signal });
|
|
136
|
+
return root.type === "found" ? resolve(root.value) : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function normalizeSummary(summary: string | undefined): string | undefined {
|
|
140
|
+
if (summary === undefined) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const trimmed = summary.trim();
|
|
144
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function displayNonEmpty(value: string): string {
|
|
148
|
+
return value.length > 0 ? value : "(empty)";
|
|
149
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { open, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { ensurePrivateParentDirectory } from "@nseng-ai/capability-kit/xdg";
|
|
5
|
+
|
|
6
|
+
export interface PlanStoreGateway {
|
|
7
|
+
listDirectory(path: string): Promise<PlanStoreDirectoryRead>;
|
|
8
|
+
statPath(path: string): Promise<PlanStorePathStat | undefined>;
|
|
9
|
+
readTextFile(path: string): Promise<string>;
|
|
10
|
+
writeTextFileExclusive(path: string, content: string): Promise<void>;
|
|
11
|
+
realpathOrResolve(path: string): Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PlanStoreDirectoryRead =
|
|
15
|
+
| { type: "present"; entries: readonly PlanStoreDirectoryEntry[] }
|
|
16
|
+
| { type: "missing" };
|
|
17
|
+
|
|
18
|
+
export interface PlanStoreDirectoryEntry {
|
|
19
|
+
name: string;
|
|
20
|
+
type: PlanStorePathType;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PlanStorePathStat {
|
|
24
|
+
type: PlanStorePathType;
|
|
25
|
+
mtimeMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PlanStorePathType = "file" | "directory" | "other";
|
|
29
|
+
|
|
30
|
+
export class RealPlanStoreGateway implements PlanStoreGateway {
|
|
31
|
+
async listDirectory(path: string): Promise<PlanStoreDirectoryRead> {
|
|
32
|
+
try {
|
|
33
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
34
|
+
return {
|
|
35
|
+
type: "present",
|
|
36
|
+
entries: entries.map((entry) => ({ name: entry.name, type: direntType(entry) })),
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (isNodeError(error) && error.code === "ENOENT") return { type: "missing" };
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async statPath(path: string): Promise<PlanStorePathStat | undefined> {
|
|
45
|
+
try {
|
|
46
|
+
const pathStat = await stat(path);
|
|
47
|
+
return { type: statsType(pathStat), mtimeMs: pathStat.mtimeMs };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async readTextFile(path: string): Promise<string> {
|
|
55
|
+
return await readFile(path, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async writeTextFileExclusive(path: string, content: string): Promise<void> {
|
|
59
|
+
await ensurePrivateParentDirectory(path);
|
|
60
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
61
|
+
try {
|
|
62
|
+
file = await open(path, "wx");
|
|
63
|
+
await file.writeFile(content, "utf8");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isNodeError(error) && error.code === "EEXIST") {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Saved plan file already exists in the local plan store; refusing to overwrite.\nPath: ${path}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
await file?.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async realpathOrResolve(path: string): Promise<string> {
|
|
77
|
+
try {
|
|
78
|
+
return await realpath(path);
|
|
79
|
+
} catch {
|
|
80
|
+
// Missing paths still need stable containment comparisons at the domain layer.
|
|
81
|
+
return resolve(path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createRealPlanStoreGateway(): PlanStoreGateway {
|
|
87
|
+
return new RealPlanStoreGateway();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function direntType(entry: { isFile(): boolean; isDirectory(): boolean }): PlanStorePathType {
|
|
91
|
+
if (entry.isFile()) return "file";
|
|
92
|
+
if (entry.isDirectory()) return "directory";
|
|
93
|
+
return "other";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function statsType(pathStat: { isFile(): boolean; isDirectory(): boolean }): PlanStorePathType {
|
|
97
|
+
if (pathStat.isFile()) return "file";
|
|
98
|
+
if (pathStat.isDirectory()) return "directory";
|
|
99
|
+
return "other";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
103
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/exec";
|
|
2
|
+
import {
|
|
3
|
+
buildContentSlugPrompt,
|
|
4
|
+
deriveContentSlug,
|
|
5
|
+
type ContentSlugDerivationVariant,
|
|
6
|
+
type ContentSlugEvidence,
|
|
7
|
+
} from "./content-slug-derivation.ts";
|
|
8
|
+
|
|
9
|
+
export type SavedPlanContentSlugEvidence = ContentSlugEvidence;
|
|
10
|
+
|
|
11
|
+
const SAVED_PLAN_CONTENT_SLUG_VARIANT: ContentSlugDerivationVariant = {
|
|
12
|
+
slugKind: "saved-plan filename slug",
|
|
13
|
+
promptIntroLines: [
|
|
14
|
+
"Generate the saved-plan filename slug for the Markdown implementation plan content below.",
|
|
15
|
+
"Use only the final plan content. Do not use the current branch, repository name, request text, filename, or path.",
|
|
16
|
+
],
|
|
17
|
+
invalidSlugMessage: "Pi slug model output normalized to an invalid saved-plan filename slug.",
|
|
18
|
+
failureHeader: "Failed to derive saved-plan filename slug from plan content.",
|
|
19
|
+
noFallbackLine: "No assistant-generated slug or deterministic fallback was attempted.",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function deriveSavedPlanContentSlug(
|
|
23
|
+
pi: CommandExecApi,
|
|
24
|
+
input: { content: string; cwd: string; signal?: AbortSignal },
|
|
25
|
+
): Promise<SavedPlanContentSlugEvidence> {
|
|
26
|
+
return deriveContentSlug(pi, input, SAVED_PLAN_CONTENT_SLUG_VARIANT);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSavedPlanContentSlugPrompt(content: string): string {
|
|
30
|
+
return buildContentSlugPrompt(content, SAVED_PLAN_CONTENT_SLUG_VARIANT);
|
|
31
|
+
}
|