@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. 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
+ }