@luizsantiago/spec-guardrails 3.0.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/LICENSE +21 -0
- package/README.md +206 -0
- package/index.js +335 -0
- package/lib/archive.js +208 -0
- package/lib/assets.js +145 -0
- package/lib/brownfield.js +446 -0
- package/lib/config.js +293 -0
- package/lib/constants.js +262 -0
- package/lib/cursorrules.js +92 -0
- package/lib/delta-merge.js +248 -0
- package/lib/doctor.js +343 -0
- package/lib/download.js +133 -0
- package/lib/feature.js +272 -0
- package/lib/fs-utils.js +114 -0
- package/lib/gates.js +138 -0
- package/lib/install.js +140 -0
- package/lib/memory.js +34 -0
- package/lib/next-steps.js +50 -0
- package/lib/presets.js +176 -0
- package/lib/project-rules.js +210 -0
- package/lib/specs-utils.js +117 -0
- package/lib/token-cost.js +124 -0
- package/package.json +46 -0
- package/rules/engineering-baseline.mdc +56 -0
- package/scripts/_common.py +356 -0
- package/scripts/analyze_artifacts.py +187 -0
- package/scripts/check_commit.py +140 -0
- package/scripts/lessons.py +447 -0
- package/scripts/loop_plan.py +217 -0
- package/scripts/validate_spec.py +345 -0
- package/scripts/validate_state.py +385 -0
- package/scripts/validate_tasks.py +379 -0
- package/skills/agent-architecture.md +221 -0
- package/skills/appsec.md +83 -0
- package/skills/code-simplify.md +49 -0
- package/skills/engineering-standards.md +98 -0
- package/skills/git-handoff.md +213 -0
- package/skills/qa-strategy.md +83 -0
- package/skills/references/analyze.md +56 -0
- package/skills/references/archive.md +60 -0
- package/skills/references/constitution.md +66 -0
- package/skills/references/context-limits.md +73 -0
- package/skills/references/converge.md +47 -0
- package/skills/references/design.md +88 -0
- package/skills/references/discuss.md +68 -0
- package/skills/references/explore.md +61 -0
- package/skills/references/implement.md +175 -0
- package/skills/references/lessons.md +71 -0
- package/skills/references/memory.md +98 -0
- package/skills/references/project-init.md +62 -0
- package/skills/references/quick-mode.md +84 -0
- package/skills/references/specify.md +144 -0
- package/skills/references/sub-agents.md +117 -0
- package/skills/references/tasks.md +178 -0
- package/skills/references/validate.md +210 -0
- package/skills/security-review.md +120 -0
- package/skills/ship-ready.md +50 -0
- package/skills/task-graph-engineering.md +180 -0
- package/templates/GETTING_STARTED.md +61 -0
- package/templates/config.yaml.example +28 -0
- package/templates/presets/default.yaml +16 -0
- package/templates/presets/node-ts.yaml +22 -0
- package/templates/presets/python.yaml +22 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { domainSpecStub } from "./delta-merge.js";
|
|
5
|
+
import { ensureDir, readFileSafe, writeFileIfMissing, writeFileSafe } from "./fs-utils.js";
|
|
6
|
+
import { initGuardrailsMemory } from "./memory.js";
|
|
7
|
+
import { initProjectConfig } from "./presets.js";
|
|
8
|
+
|
|
9
|
+
const SKIP_DIRS = new Set([
|
|
10
|
+
".git",
|
|
11
|
+
".specs",
|
|
12
|
+
".cursor",
|
|
13
|
+
".claude",
|
|
14
|
+
"node_modules",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"coverage",
|
|
18
|
+
"vendor",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
".next",
|
|
21
|
+
".turbo",
|
|
22
|
+
"tmp",
|
|
23
|
+
"temp",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const SKIP_DOMAIN_NAMES = new Set([
|
|
27
|
+
"common",
|
|
28
|
+
"config",
|
|
29
|
+
"core",
|
|
30
|
+
"docs",
|
|
31
|
+
"lib",
|
|
32
|
+
"public",
|
|
33
|
+
"scripts",
|
|
34
|
+
"shared",
|
|
35
|
+
"static",
|
|
36
|
+
"test",
|
|
37
|
+
"tests",
|
|
38
|
+
"types",
|
|
39
|
+
"utils",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const ROADMAP_HEADER = `# Roadmap
|
|
43
|
+
|
|
44
|
+
Track milestones and archived features.
|
|
45
|
+
|
|
46
|
+
## Planned
|
|
47
|
+
|
|
48
|
+
## Completed
|
|
49
|
+
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} name
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function slugifyDomain(name) {
|
|
57
|
+
return name
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
+
.replace(/^-+|-+$/g, "")
|
|
61
|
+
.slice(0, 48);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} cwd
|
|
66
|
+
* @returns {Promise<string>}
|
|
67
|
+
*/
|
|
68
|
+
async function readRepoName(cwd) {
|
|
69
|
+
try {
|
|
70
|
+
const pkg = JSON.parse(await readFileSafe(path.join(cwd, "package.json")));
|
|
71
|
+
if (typeof pkg.name === "string" && pkg.name.trim()) {
|
|
72
|
+
return pkg.name.replace(/^@.*\//, "").trim();
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// not a Node project
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return path.basename(cwd);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} cwd
|
|
83
|
+
* @returns {Promise<{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[] }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function detectProjectStack(cwd) {
|
|
86
|
+
/** @type {{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[] }} */
|
|
87
|
+
const result = {
|
|
88
|
+
stack: "unknown",
|
|
89
|
+
testCommand: "(fill in)",
|
|
90
|
+
roots: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
95
|
+
const pkg = JSON.parse(await readFileSafe(pkgPath));
|
|
96
|
+
const scripts = pkg.scripts ?? {};
|
|
97
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
98
|
+
|
|
99
|
+
result.stack = deps.typescript ? "Node.js + TypeScript" : "Node.js";
|
|
100
|
+
result.preset = deps.typescript ? "node-ts" : "default";
|
|
101
|
+
result.testCommand = scripts.test ? "npm test" : result.testCommand;
|
|
102
|
+
if (scripts.lint) {
|
|
103
|
+
result.lintCommand = "npm run lint";
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// fall through
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.stack === "unknown") {
|
|
110
|
+
try {
|
|
111
|
+
await fs.access(path.join(cwd, "pyproject.toml"));
|
|
112
|
+
result.stack = "Python";
|
|
113
|
+
result.preset = "python";
|
|
114
|
+
result.testCommand = "pytest";
|
|
115
|
+
result.lintCommand = "ruff check .";
|
|
116
|
+
} catch {
|
|
117
|
+
try {
|
|
118
|
+
await fs.access(path.join(cwd, "requirements.txt"));
|
|
119
|
+
result.stack = "Python";
|
|
120
|
+
result.preset = "python";
|
|
121
|
+
result.testCommand = "pytest";
|
|
122
|
+
} catch {
|
|
123
|
+
try {
|
|
124
|
+
await fs.access(path.join(cwd, "go.mod"));
|
|
125
|
+
result.stack = "Go";
|
|
126
|
+
result.testCommand = "go test ./...";
|
|
127
|
+
} catch {
|
|
128
|
+
try {
|
|
129
|
+
await fs.access(path.join(cwd, "Cargo.toml"));
|
|
130
|
+
result.stack = "Rust";
|
|
131
|
+
result.testCommand = "cargo test";
|
|
132
|
+
} catch {
|
|
133
|
+
// unknown stack stays
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const candidate of ["src", "lib", "app", "apps", "packages", "services"]) {
|
|
141
|
+
try {
|
|
142
|
+
const stat = await fs.stat(path.join(cwd, candidate));
|
|
143
|
+
if (stat.isDirectory()) {
|
|
144
|
+
result.roots.push(candidate);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// missing
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} dir
|
|
156
|
+
* @returns {Promise<string[]>}
|
|
157
|
+
*/
|
|
158
|
+
async function listChildDirs(dir) {
|
|
159
|
+
try {
|
|
160
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
161
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} cwd
|
|
169
|
+
* @returns {Promise<{ domain: string, hint: string }[]>}
|
|
170
|
+
*/
|
|
171
|
+
export async function detectDomainCandidates(cwd) {
|
|
172
|
+
/** @type {Map<string, string>} */
|
|
173
|
+
const domains = new Map();
|
|
174
|
+
|
|
175
|
+
const addDomain = (rawName, hint) => {
|
|
176
|
+
const slug = slugifyDomain(rawName);
|
|
177
|
+
if (!slug || SKIP_DOMAIN_NAMES.has(slug)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!domains.has(slug)) {
|
|
181
|
+
domains.set(slug, hint);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const scanRoots = [
|
|
186
|
+
["packages", (name) => `packages/${name}`],
|
|
187
|
+
["apps", (name) => `apps/${name}`],
|
|
188
|
+
["services", (name) => `services/${name}`],
|
|
189
|
+
["domains", (name) => `domains/${name}`],
|
|
190
|
+
["src/domains", (name) => `src/domains/${name}`],
|
|
191
|
+
["src/modules", (name) => `src/modules/${name}`],
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
for (const [root, hintFn] of scanRoots) {
|
|
195
|
+
const rootPath = path.join(cwd, root);
|
|
196
|
+
for (const name of await listChildDirs(rootPath)) {
|
|
197
|
+
if (SKIP_DIRS.has(name)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
addDomain(name, hintFn(name));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return [...domains.entries()]
|
|
205
|
+
.map(([domain, hint]) => ({ domain, hint }))
|
|
206
|
+
.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {{
|
|
211
|
+
* repoName: string,
|
|
212
|
+
* stack: ReturnType<typeof detectProjectStack> extends Promise<infer T> ? T : never,
|
|
213
|
+
* domains: { domain: string, hint: string }[],
|
|
214
|
+
* }} input
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function buildProjectMarkdown(input) {
|
|
218
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
219
|
+
const domainRows =
|
|
220
|
+
input.domains.length === 0
|
|
221
|
+
? "| — | — | — |\n"
|
|
222
|
+
: input.domains
|
|
223
|
+
.map(
|
|
224
|
+
(item) =>
|
|
225
|
+
`| ${item.domain} | \`${item.hint}\` | \`.specs/domains/${item.domain}/spec.md\` |`,
|
|
226
|
+
)
|
|
227
|
+
.join("\n");
|
|
228
|
+
|
|
229
|
+
const roots =
|
|
230
|
+
input.stack.roots.length > 0 ? input.stack.roots.map((r) => `\`${r}/\``).join(", ") : "(none detected)";
|
|
231
|
+
|
|
232
|
+
let stackLines = `- Runtime: ${input.stack.stack}\n- Test: ${input.stack.testCommand}\n- Source roots: ${roots}`;
|
|
233
|
+
if (input.stack.lintCommand) {
|
|
234
|
+
stackLines += `\n- Lint: ${input.stack.lintCommand}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return `# Project: ${input.repoName}
|
|
238
|
+
|
|
239
|
+
> Generated by \`project-init\` on ${date}. Edit with owner-approved truth.
|
|
240
|
+
|
|
241
|
+
## Vision
|
|
242
|
+
|
|
243
|
+
(fill in — one paragraph on what this codebase delivers)
|
|
244
|
+
|
|
245
|
+
## Detected stack
|
|
246
|
+
|
|
247
|
+
${stackLines}
|
|
248
|
+
|
|
249
|
+
## Domain map
|
|
250
|
+
|
|
251
|
+
| Domain | Path hint | Spec |
|
|
252
|
+
| --- | --- | --- |
|
|
253
|
+
${domainRows}
|
|
254
|
+
|
|
255
|
+
## Constraints
|
|
256
|
+
|
|
257
|
+
- (fill in)
|
|
258
|
+
|
|
259
|
+
## Out of scope for agents
|
|
260
|
+
|
|
261
|
+
- (fill in)
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {string} domain
|
|
267
|
+
* @returns {string}
|
|
268
|
+
*/
|
|
269
|
+
export function buildDomainSpec(domain) {
|
|
270
|
+
return `${domainSpecStub(domain, "project-init")}
|
|
271
|
+
|
|
272
|
+
> Brownfield stub — populate stable requirements here or fold features in with \`archive-feature\`.
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {string[]} domains
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
function buildRoadmapPlanned(domains) {
|
|
281
|
+
if (!domains.length) {
|
|
282
|
+
return `- Review \`.specs/project/PROJECT.md\` and define domain boundaries with the owner\n`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return domains
|
|
286
|
+
.map(
|
|
287
|
+
(domain) =>
|
|
288
|
+
`- **${domain}** — draft \`.specs/domains/${domain}/spec.md\` from existing code (owner review)`,
|
|
289
|
+
)
|
|
290
|
+
.join("\n")
|
|
291
|
+
.concat("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {string} cwd
|
|
296
|
+
* @param {string[]} domains
|
|
297
|
+
*/
|
|
298
|
+
async function ensureRoadmap(cwd, domains, force) {
|
|
299
|
+
const roadmapPath = path.join(cwd, ".specs/project/ROADMAP.md");
|
|
300
|
+
await ensureDir(path.dirname(roadmapPath));
|
|
301
|
+
|
|
302
|
+
const planned = buildRoadmapPlanned(domains);
|
|
303
|
+
let content;
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
content = await readFileSafe(roadmapPath);
|
|
307
|
+
} catch {
|
|
308
|
+
content = ROADMAP_HEADER.replace("## Planned\n", `## Planned\n\n${planned}`);
|
|
309
|
+
await writeFileSafe(roadmapPath, content);
|
|
310
|
+
return { created: true, updated: false, path: roadmapPath };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (content.includes("## Planned") && !content.includes("project-init")) {
|
|
314
|
+
const marker = "## Planned";
|
|
315
|
+
const idx = content.indexOf(marker);
|
|
316
|
+
const insertAt = idx + marker.length;
|
|
317
|
+
const note = `\n\n<!-- project-init -->\n${planned}`;
|
|
318
|
+
if (!force && content.includes("<!-- project-init -->")) {
|
|
319
|
+
return { created: false, updated: false, path: roadmapPath, skipped: true };
|
|
320
|
+
}
|
|
321
|
+
content = `${content.slice(0, insertAt)}${note}${content.slice(insertAt)}`;
|
|
322
|
+
await writeFileSafe(roadmapPath, content);
|
|
323
|
+
return { created: false, updated: true, path: roadmapPath };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { created: false, updated: false, path: roadmapPath, skipped: true };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Initialize brownfield project memory from repo structure.
|
|
331
|
+
*
|
|
332
|
+
* @param {{
|
|
333
|
+
* cwd?: string,
|
|
334
|
+
* preset?: string,
|
|
335
|
+
* domains?: string[],
|
|
336
|
+
* skipDomains?: boolean,
|
|
337
|
+
* skipProject?: boolean,
|
|
338
|
+
* force?: boolean,
|
|
339
|
+
* dryRun?: boolean,
|
|
340
|
+
* }} [options]
|
|
341
|
+
*/
|
|
342
|
+
export async function projectInit(options = {}) {
|
|
343
|
+
const cwd = options.cwd ?? process.cwd();
|
|
344
|
+
const stack = await detectProjectStack(cwd);
|
|
345
|
+
const repoName = await readRepoName(cwd);
|
|
346
|
+
|
|
347
|
+
let domains = options.domains?.map((d) => ({
|
|
348
|
+
domain: slugifyDomain(d),
|
|
349
|
+
hint: `(manual: ${d})`,
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
if (!domains?.length && !options.skipDomains) {
|
|
353
|
+
domains = await detectDomainCandidates(cwd);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
domains = domains ?? [];
|
|
357
|
+
|
|
358
|
+
const preset = options.preset ?? stack.preset ?? "default";
|
|
359
|
+
|
|
360
|
+
if (options.dryRun) {
|
|
361
|
+
return {
|
|
362
|
+
dryRun: true,
|
|
363
|
+
repoName,
|
|
364
|
+
stack,
|
|
365
|
+
preset,
|
|
366
|
+
domains,
|
|
367
|
+
planned: [],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await initGuardrailsMemory(cwd);
|
|
372
|
+
|
|
373
|
+
/** @type {string[]} */
|
|
374
|
+
const planned = [];
|
|
375
|
+
|
|
376
|
+
if (!options.skipProject) {
|
|
377
|
+
const projectPath = path.join(cwd, ".specs/project/PROJECT.md");
|
|
378
|
+
const content = buildProjectMarkdown({ repoName, stack, domains });
|
|
379
|
+
if (options.force) {
|
|
380
|
+
await writeFileSafe(projectPath, content);
|
|
381
|
+
planned.push(".specs/project/PROJECT.md (written)");
|
|
382
|
+
} else {
|
|
383
|
+
const created = await writeFileIfMissing(projectPath, content);
|
|
384
|
+
planned.push(
|
|
385
|
+
created
|
|
386
|
+
? ".specs/project/PROJECT.md (created)"
|
|
387
|
+
: ".specs/project/PROJECT.md (kept existing)",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!options.skipDomains && domains.length) {
|
|
393
|
+
for (const { domain } of domains) {
|
|
394
|
+
const domainDir = path.join(cwd, ".specs/domains", domain);
|
|
395
|
+
await ensureDir(domainDir);
|
|
396
|
+
const specPath = path.join(domainDir, "spec.md");
|
|
397
|
+
const content = buildDomainSpec(domain);
|
|
398
|
+
if (options.force) {
|
|
399
|
+
await writeFileSafe(specPath, content);
|
|
400
|
+
planned.push(`.specs/domains/${domain}/spec.md (written)`);
|
|
401
|
+
} else {
|
|
402
|
+
const created = await writeFileIfMissing(specPath, content);
|
|
403
|
+
planned.push(
|
|
404
|
+
created
|
|
405
|
+
? `.specs/domains/${domain}/spec.md (created)`
|
|
406
|
+
: `.specs/domains/${domain}/spec.md (kept existing)`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const roadmap = await ensureRoadmap(
|
|
413
|
+
cwd,
|
|
414
|
+
domains.map((d) => d.domain),
|
|
415
|
+
options.force,
|
|
416
|
+
);
|
|
417
|
+
if (roadmap.created) {
|
|
418
|
+
planned.push(".specs/project/ROADMAP.md (created)");
|
|
419
|
+
} else if (roadmap.updated) {
|
|
420
|
+
planned.push(".specs/project/ROADMAP.md (updated planned section)");
|
|
421
|
+
} else if (roadmap.skipped) {
|
|
422
|
+
planned.push(".specs/project/ROADMAP.md (kept existing)");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const configResult = await initProjectConfig({
|
|
426
|
+
cwd,
|
|
427
|
+
preset,
|
|
428
|
+
force: options.force,
|
|
429
|
+
});
|
|
430
|
+
if (configResult.created) {
|
|
431
|
+
planned.push(`.specs/config.yaml (preset: ${preset})`);
|
|
432
|
+
} else if (configResult.skipped) {
|
|
433
|
+
planned.push(".specs/config.yaml (kept existing)");
|
|
434
|
+
} else if (configResult.updated) {
|
|
435
|
+
planned.push(`.specs/config.yaml (replaced, preset: ${preset})`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
dryRun: false,
|
|
440
|
+
repoName,
|
|
441
|
+
stack,
|
|
442
|
+
preset,
|
|
443
|
+
domains,
|
|
444
|
+
planned,
|
|
445
|
+
};
|
|
446
|
+
}
|