@ifi/pi-spec 0.2.14
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/LICENSE +21 -0
- package/README.md +528 -0
- package/extension/assets/templates/agent-file-template.md +28 -0
- package/extension/assets/templates/checklist-template.md +40 -0
- package/extension/assets/templates/commands/analyze.md +187 -0
- package/extension/assets/templates/commands/checklist.md +298 -0
- package/extension/assets/templates/commands/clarify.md +184 -0
- package/extension/assets/templates/commands/constitution.md +84 -0
- package/extension/assets/templates/commands/implement.md +201 -0
- package/extension/assets/templates/commands/plan.md +96 -0
- package/extension/assets/templates/commands/specify.md +242 -0
- package/extension/assets/templates/commands/tasks.md +203 -0
- package/extension/assets/templates/constitution-template.md +50 -0
- package/extension/assets/templates/plan-template.md +104 -0
- package/extension/assets/templates/spec-template.md +115 -0
- package/extension/assets/templates/tasks-template.md +251 -0
- package/extension/git.ts +64 -0
- package/extension/index.ts +366 -0
- package/extension/prompts.ts +124 -0
- package/extension/scaffold.ts +151 -0
- package/extension/status.ts +231 -0
- package/extension/types.ts +93 -0
- package/extension/workspace.ts +242 -0
- package/package.json +50 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { GitClient } from "./git.js";
|
|
4
|
+
import type { PreparedFeature, WorkflowPaths } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const STOP_WORDS = new Set([
|
|
7
|
+
"a",
|
|
8
|
+
"an",
|
|
9
|
+
"and",
|
|
10
|
+
"add",
|
|
11
|
+
"are",
|
|
12
|
+
"as",
|
|
13
|
+
"at",
|
|
14
|
+
"be",
|
|
15
|
+
"been",
|
|
16
|
+
"being",
|
|
17
|
+
"by",
|
|
18
|
+
"can",
|
|
19
|
+
"could",
|
|
20
|
+
"did",
|
|
21
|
+
"do",
|
|
22
|
+
"does",
|
|
23
|
+
"for",
|
|
24
|
+
"from",
|
|
25
|
+
"get",
|
|
26
|
+
"had",
|
|
27
|
+
"has",
|
|
28
|
+
"have",
|
|
29
|
+
"i",
|
|
30
|
+
"in",
|
|
31
|
+
"is",
|
|
32
|
+
"it",
|
|
33
|
+
"may",
|
|
34
|
+
"might",
|
|
35
|
+
"must",
|
|
36
|
+
"my",
|
|
37
|
+
"need",
|
|
38
|
+
"of",
|
|
39
|
+
"on",
|
|
40
|
+
"or",
|
|
41
|
+
"our",
|
|
42
|
+
"set",
|
|
43
|
+
"shall",
|
|
44
|
+
"should",
|
|
45
|
+
"that",
|
|
46
|
+
"the",
|
|
47
|
+
"their",
|
|
48
|
+
"these",
|
|
49
|
+
"this",
|
|
50
|
+
"those",
|
|
51
|
+
"to",
|
|
52
|
+
"want",
|
|
53
|
+
"was",
|
|
54
|
+
"were",
|
|
55
|
+
"will",
|
|
56
|
+
"with",
|
|
57
|
+
"would",
|
|
58
|
+
"your",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
export function findRepoRoot(cwd: string, git: GitClient): { repoRoot: string; hasGit: boolean } {
|
|
62
|
+
const gitRoot = git.getRepoRoot(cwd);
|
|
63
|
+
if (gitRoot) {
|
|
64
|
+
return { repoRoot: gitRoot, hasGit: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let current = path.resolve(cwd);
|
|
68
|
+
while (true) {
|
|
69
|
+
if (existsSync(path.join(current, ".specify"))) {
|
|
70
|
+
return { repoRoot: current, hasGit: false };
|
|
71
|
+
}
|
|
72
|
+
const parent = path.dirname(current);
|
|
73
|
+
if (parent === current) {
|
|
74
|
+
return { repoRoot: path.resolve(cwd), hasGit: false };
|
|
75
|
+
}
|
|
76
|
+
current = parent;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function listFeatureDirs(repoRoot: string): string[] {
|
|
81
|
+
const specsDir = path.join(repoRoot, "specs");
|
|
82
|
+
if (!existsSync(specsDir)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return readdirSync(specsDir)
|
|
87
|
+
.filter((entry) => /^\d{3}-/.test(entry))
|
|
88
|
+
.filter((entry) => {
|
|
89
|
+
const fullPath = path.join(specsDir, entry);
|
|
90
|
+
return existsSync(fullPath) && statSync(fullPath).isDirectory();
|
|
91
|
+
})
|
|
92
|
+
.sort((a, b) => a.localeCompare(b));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getLatestFeatureDir(repoRoot: string): string | undefined {
|
|
96
|
+
const features = listFeatureDirs(repoRoot);
|
|
97
|
+
return features.at(-1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function extractFeatureNumber(value: string): number | null {
|
|
101
|
+
const match = value.match(/^(\d{3})-/);
|
|
102
|
+
if (!match) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return Number.parseInt(match[1], 10);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveFeatureFromBranch(repoRoot: string, branchName: string): string | undefined {
|
|
109
|
+
const prefix = branchName.match(/^(\d{3})-/)?.[1];
|
|
110
|
+
if (!prefix) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const matches = listFeatureDirs(repoRoot).filter((entry) => entry.startsWith(`${prefix}-`));
|
|
114
|
+
if (matches.length === 1) {
|
|
115
|
+
return matches[0];
|
|
116
|
+
}
|
|
117
|
+
return matches.find((entry) => entry === branchName) ?? undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildWorkflowPaths(repoRoot: string, featureName?: string): WorkflowPaths {
|
|
121
|
+
const specifyDir = path.join(repoRoot, ".specify");
|
|
122
|
+
const templatesDir = path.join(specifyDir, "templates");
|
|
123
|
+
const memoryDir = path.join(specifyDir, "memory");
|
|
124
|
+
const featureDir = featureName ? path.join(repoRoot, "specs", featureName) : undefined;
|
|
125
|
+
return {
|
|
126
|
+
repoRoot,
|
|
127
|
+
specsDir: path.join(repoRoot, "specs"),
|
|
128
|
+
specifyDir,
|
|
129
|
+
templatesDir,
|
|
130
|
+
memoryDir,
|
|
131
|
+
constitutionFile: path.join(memoryDir, "constitution.md"),
|
|
132
|
+
agentContextFile: path.join(memoryDir, "pi-agent.md"),
|
|
133
|
+
extensionsConfigFile: path.join(specifyDir, "extensions.yml"),
|
|
134
|
+
workflowReadmeFile: path.join(specifyDir, "README.md"),
|
|
135
|
+
featureDir,
|
|
136
|
+
featureBranch: featureName,
|
|
137
|
+
featureNumber: featureName?.match(/^(\d{3})-/)?.[1],
|
|
138
|
+
featureSpec: featureDir ? path.join(featureDir, "spec.md") : undefined,
|
|
139
|
+
planFile: featureDir ? path.join(featureDir, "plan.md") : undefined,
|
|
140
|
+
tasksFile: featureDir ? path.join(featureDir, "tasks.md") : undefined,
|
|
141
|
+
researchFile: featureDir ? path.join(featureDir, "research.md") : undefined,
|
|
142
|
+
dataModelFile: featureDir ? path.join(featureDir, "data-model.md") : undefined,
|
|
143
|
+
quickstartFile: featureDir ? path.join(featureDir, "quickstart.md") : undefined,
|
|
144
|
+
contractsDir: featureDir ? path.join(featureDir, "contracts") : undefined,
|
|
145
|
+
checklistsDir: featureDir ? path.join(featureDir, "checklists") : undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function cleanBranchSegment(value: string): string {
|
|
150
|
+
return value
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
153
|
+
.replace(/-+/g, "-")
|
|
154
|
+
.replace(/^-|-$/g, "");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function generateBranchShortName(description: string): string {
|
|
158
|
+
const tokens = description.match(/[A-Za-z0-9]+/g) ?? [];
|
|
159
|
+
const meaningful: string[] = [];
|
|
160
|
+
|
|
161
|
+
for (const original of tokens) {
|
|
162
|
+
const normalized = original.toLowerCase();
|
|
163
|
+
if (!normalized || STOP_WORDS.has(normalized)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const isAcronymLike = /[A-Z]{2,}/.test(original) || /\d/.test(original);
|
|
168
|
+
if (normalized.length >= 3 || isAcronymLike) {
|
|
169
|
+
meaningful.push(normalized);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const selected = (meaningful.length > 0 ? meaningful : tokens.map((token) => token.toLowerCase()))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.slice(0, meaningful.length === 4 ? 4 : 3);
|
|
176
|
+
|
|
177
|
+
const fallback = selected.join("-") || "feature";
|
|
178
|
+
return cleanBranchSegment(fallback) || "feature";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function truncateBranchName(branchName: string): string {
|
|
182
|
+
const MAX_BRANCH_LENGTH = 244;
|
|
183
|
+
if (branchName.length <= MAX_BRANCH_LENGTH) {
|
|
184
|
+
return branchName;
|
|
185
|
+
}
|
|
186
|
+
const prefix = branchName.slice(0, 4);
|
|
187
|
+
const suffix = branchName.slice(4, MAX_BRANCH_LENGTH).replace(/-+$/g, "");
|
|
188
|
+
return `${prefix}${suffix}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function computeNextFeatureNumber(repoRoot: string, branches: string[]): number {
|
|
192
|
+
const featureNumbers = [
|
|
193
|
+
...listFeatureDirs(repoRoot)
|
|
194
|
+
.map((entry) => extractFeatureNumber(entry))
|
|
195
|
+
.filter((value): value is number => value != null),
|
|
196
|
+
...branches
|
|
197
|
+
.map((entry) => entry.replace(/^[^/]+\//, ""))
|
|
198
|
+
.map((entry) => extractFeatureNumber(entry))
|
|
199
|
+
.filter((value): value is number => value != null),
|
|
200
|
+
];
|
|
201
|
+
const highest = featureNumbers.length > 0 ? Math.max(...featureNumbers) : 0;
|
|
202
|
+
return highest + 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function prepareFeatureWorkspace(options: {
|
|
206
|
+
repoRoot: string;
|
|
207
|
+
description: string;
|
|
208
|
+
git: GitClient;
|
|
209
|
+
currentBranch: string;
|
|
210
|
+
hasGit: boolean;
|
|
211
|
+
shortName?: string;
|
|
212
|
+
}): PreparedFeature {
|
|
213
|
+
const featureNumber = String(
|
|
214
|
+
computeNextFeatureNumber(options.repoRoot, options.git.listBranches(options.repoRoot)),
|
|
215
|
+
).padStart(3, "0");
|
|
216
|
+
const branchSuffix =
|
|
217
|
+
cleanBranchSegment(options.shortName || generateBranchShortName(options.description)) || "feature";
|
|
218
|
+
const branchName = truncateBranchName(`${featureNumber}-${branchSuffix}`);
|
|
219
|
+
const featureDir = path.join(options.repoRoot, "specs", branchName);
|
|
220
|
+
const specFile = path.join(featureDir, "spec.md");
|
|
221
|
+
const checklistsDir = path.join(featureDir, "checklists");
|
|
222
|
+
|
|
223
|
+
mkdirSync(featureDir, { recursive: true });
|
|
224
|
+
mkdirSync(checklistsDir, { recursive: true });
|
|
225
|
+
|
|
226
|
+
let createdBranch = false;
|
|
227
|
+
if (options.hasGit) {
|
|
228
|
+
if (options.currentBranch !== branchName) {
|
|
229
|
+
options.git.createAndSwitchBranch(options.repoRoot, branchName);
|
|
230
|
+
createdBranch = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
branchName,
|
|
236
|
+
featureNumber,
|
|
237
|
+
featureDir,
|
|
238
|
+
specFile,
|
|
239
|
+
checklistsDir,
|
|
240
|
+
createdBranch,
|
|
241
|
+
};
|
|
242
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ifi/pi-spec",
|
|
3
|
+
"version": "0.2.14",
|
|
4
|
+
"description": "Native spec-kit workflow for pi with a /spec command, TypeScript scaffolding, and spec-driven prompts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"spec",
|
|
11
|
+
"spec-kit",
|
|
12
|
+
"planning",
|
|
13
|
+
"requirements"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./extension"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"extension",
|
|
25
|
+
"README.md",
|
|
26
|
+
"!**/*.test.ts"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/ifiokjr/oh-pi.git",
|
|
32
|
+
"directory": "packages/spec"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/ifiokjr/oh-pi/tree/main/packages/spec",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/ifiokjr/oh-pi/issues"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@mariozechner/pi-agent-core": "*",
|
|
40
|
+
"@mariozechner/pi-ai": "*",
|
|
41
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
42
|
+
"@mariozechner/pi-tui": "*",
|
|
43
|
+
"@sinclair/typebox": "*"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "pnpm run typecheck && pnpm run test:worktree",
|
|
47
|
+
"typecheck": "tsgo --project ./tsconfig.json --noEmit",
|
|
48
|
+
"test:worktree": "vitest run --config ./vitest.worktree.config.ts"
|
|
49
|
+
}
|
|
50
|
+
}
|