@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
package/lib/archive.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { STATE_HEADER } from "./constants.js";
|
|
5
|
+
import {
|
|
6
|
+
domainSpecStub,
|
|
7
|
+
mergeFeatureIntoDomain,
|
|
8
|
+
} from "./delta-merge.js";
|
|
9
|
+
import { ensureDir, readFileSafe, writeFileSafe } from "./fs-utils.js";
|
|
10
|
+
import { runGate } from "./gates.js";
|
|
11
|
+
import {
|
|
12
|
+
featureDir,
|
|
13
|
+
readFeatureArtifact,
|
|
14
|
+
resolveFeatureId,
|
|
15
|
+
} from "./specs-utils.js";
|
|
16
|
+
|
|
17
|
+
const ROADMAP_HEADER = `# Roadmap
|
|
18
|
+
|
|
19
|
+
Track milestones and archived features.
|
|
20
|
+
|
|
21
|
+
## Completed
|
|
22
|
+
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function validationPassed(text) {
|
|
30
|
+
const visible = text.replace(/<!--[\s\S]*?-->/g, "");
|
|
31
|
+
return /\b(?:PASS|PASSED)\b/.test(visible);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} cwd
|
|
36
|
+
* @param {string} featureId
|
|
37
|
+
* @param {string} domainRelPath
|
|
38
|
+
* @param {string[]} mergeSummary
|
|
39
|
+
*/
|
|
40
|
+
async function updateRoadmap(cwd, featureId, domainRelPath, mergeSummary) {
|
|
41
|
+
const roadmapPath = path.join(cwd, ".specs/project/ROADMAP.md");
|
|
42
|
+
await ensureDir(path.dirname(roadmapPath));
|
|
43
|
+
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = await readFileSafe(roadmapPath);
|
|
47
|
+
} catch {
|
|
48
|
+
content = ROADMAP_HEADER;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!content.includes("## Completed")) {
|
|
52
|
+
content = `${content.trimEnd()}\n\n## Completed\n\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
56
|
+
const validationLink = `.specs/features/${featureId}/validation.md`;
|
|
57
|
+
let entry = `- **${date}** \`${featureId}\` — archived. Validation: \`${validationLink}\``;
|
|
58
|
+
|
|
59
|
+
if (domainRelPath) {
|
|
60
|
+
entry += `\n - Merged → \`${domainRelPath}\``;
|
|
61
|
+
if (mergeSummary.length) {
|
|
62
|
+
entry += ` (${mergeSummary.join(", ")})`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
entry += "\n";
|
|
67
|
+
|
|
68
|
+
if (content.includes(entry.trim())) {
|
|
69
|
+
return { updated: false, path: roadmapPath };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const completedHeading = "## Completed";
|
|
73
|
+
const idx = content.indexOf(completedHeading);
|
|
74
|
+
const insertAt = idx + completedHeading.length;
|
|
75
|
+
const before = content.slice(0, insertAt);
|
|
76
|
+
const after = content.slice(insertAt).replace(/^\n*/, "\n\n");
|
|
77
|
+
content = `${before}\n${entry}${after}`;
|
|
78
|
+
|
|
79
|
+
await writeFileSafe(roadmapPath, content);
|
|
80
|
+
return { updated: true, path: roadmapPath };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} cwd
|
|
85
|
+
*/
|
|
86
|
+
async function resetState(cwd) {
|
|
87
|
+
const statePath = path.join(cwd, ".specs/STATE.md");
|
|
88
|
+
let content;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
content = await readFileSafe(statePath);
|
|
92
|
+
} catch {
|
|
93
|
+
content = STATE_HEADER;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
content = content.replace(/^-\s*Feature:\s*.*$/m, "- Feature: —");
|
|
97
|
+
content = content.replace(/^-\s*Phase:\s*.*$/m, "- Phase: —");
|
|
98
|
+
content = content.replace(/^-\s*Branch:\s*.*$/m, "- Branch: —");
|
|
99
|
+
content = content.replace(
|
|
100
|
+
/^## Next Step \(single item\)\n- \[ \].*$/m,
|
|
101
|
+
"## Next Step (single item)\n- [ ] —",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await writeFileSafe(statePath, content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Infer domain slug from feature id (003-chat-system → chat-system).
|
|
109
|
+
*
|
|
110
|
+
* @param {string} featureId
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function inferDomainFromFeature(featureId) {
|
|
114
|
+
const match = /^(\d{3})-(.+)$/.exec(featureId);
|
|
115
|
+
return match ? match[2] : featureId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Archive a verified feature: ROADMAP update, optional domain merge, STATE reset.
|
|
120
|
+
*
|
|
121
|
+
* @param {string | undefined} featureArg
|
|
122
|
+
* @param {{
|
|
123
|
+
* cwd?: string,
|
|
124
|
+
* domain?: string,
|
|
125
|
+
* skipVerify?: boolean,
|
|
126
|
+
* skipRoadmap?: boolean,
|
|
127
|
+
* skipState?: boolean,
|
|
128
|
+
* skipDomainMerge?: boolean,
|
|
129
|
+
* }} [options]
|
|
130
|
+
*/
|
|
131
|
+
export async function archiveFeature(featureArg, options = {}) {
|
|
132
|
+
const cwd = options.cwd ?? process.cwd();
|
|
133
|
+
const featureId = await resolveFeatureId(featureArg, cwd);
|
|
134
|
+
const featurePath = featureDir(featureId, cwd);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await fs.access(featurePath);
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error(`Feature directory not found: ${featurePath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!options.skipVerify) {
|
|
143
|
+
const validationText = await readFeatureArtifact(featureId, cwd, "validation.md").catch(
|
|
144
|
+
() => {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Missing validation.md for ${featureId}. Run validate-state before archive.`,
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (!validationPassed(validationText)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`validation.md for ${featureId} does not contain PASS/PASSED. Run validate-state first.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const gateCode = await runGate("validate-state", [featureId], {
|
|
158
|
+
cwd,
|
|
159
|
+
stdio: "pipe",
|
|
160
|
+
});
|
|
161
|
+
if (gateCode !== 0) {
|
|
162
|
+
throw new Error(`validate-state failed for ${featureId}. Fix gaps before archive.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const specText = await readFeatureArtifact(featureId, cwd, "spec.md");
|
|
167
|
+
let domainRelPath = "";
|
|
168
|
+
let mergeSummary = [];
|
|
169
|
+
|
|
170
|
+
if (!options.skipDomainMerge) {
|
|
171
|
+
const domain = options.domain ?? inferDomainFromFeature(featureId);
|
|
172
|
+
const domainDir = path.join(cwd, ".specs/domains", domain);
|
|
173
|
+
const domainSpecPath = path.join(domainDir, "spec.md");
|
|
174
|
+
domainRelPath = `.specs/domains/${domain}/spec.md`;
|
|
175
|
+
|
|
176
|
+
await ensureDir(domainDir);
|
|
177
|
+
|
|
178
|
+
let domainSpec;
|
|
179
|
+
try {
|
|
180
|
+
domainSpec = await readFileSafe(domainSpecPath);
|
|
181
|
+
} catch {
|
|
182
|
+
domainSpec = domainSpecStub(domain, featureId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const merged = mergeFeatureIntoDomain(domainSpec, specText);
|
|
186
|
+
mergeSummary = merged.summary;
|
|
187
|
+
await writeFileSafe(domainSpecPath, merged.spec);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let roadmap = { updated: false, path: "" };
|
|
191
|
+
if (!options.skipRoadmap) {
|
|
192
|
+
roadmap = await updateRoadmap(cwd, featureId, domainRelPath, mergeSummary);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!options.skipState) {
|
|
196
|
+
await resetState(cwd);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
featureId,
|
|
201
|
+
featureDir: path.join(".specs/features", featureId),
|
|
202
|
+
domainPath: domainRelPath || null,
|
|
203
|
+
mergeSummary,
|
|
204
|
+
roadmapUpdated: roadmap.updated,
|
|
205
|
+
roadmapPath: roadmap.path ? path.relative(cwd, roadmap.path) : null,
|
|
206
|
+
stateReset: !options.skipState,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/lib/assets.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FALLBACK_REPO_URL,
|
|
7
|
+
PACKAGE_NAME,
|
|
8
|
+
PINNED_REF,
|
|
9
|
+
assertSafeAssetBase,
|
|
10
|
+
resolveAssetOverride,
|
|
11
|
+
resolveAssetUrl,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
import { downloadToFile } from "./download.js";
|
|
14
|
+
import { assertSafeWriteTarget } from "./fs-utils.js";
|
|
15
|
+
|
|
16
|
+
const PACKAGE_ROOT = path.resolve(
|
|
17
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path of a guardrails asset shipped inside the npm package.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} remotePath path relative to the repo root (e.g. skills/agent-architecture.md)
|
|
24
|
+
*/
|
|
25
|
+
export function packagedAssetPath(remotePath) {
|
|
26
|
+
const resolved = path.resolve(PACKAGE_ROOT, remotePath);
|
|
27
|
+
const rootWithSep = PACKAGE_ROOT.endsWith(path.sep)
|
|
28
|
+
? PACKAGE_ROOT
|
|
29
|
+
: PACKAGE_ROOT + path.sep;
|
|
30
|
+
|
|
31
|
+
if (resolved !== PACKAGE_ROOT && !resolved.startsWith(rootWithSep)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Refusing packaged asset path outside package root: ${remotePath}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default install copies from the package. A remote fetch happens only when
|
|
42
|
+
* `SPEC_GUARDRAILS_REPO_URL` or `options.repoUrl` is set (forks and the test suite).
|
|
43
|
+
*
|
|
44
|
+
* @param {string} [repoUrl]
|
|
45
|
+
* @returns {{ mode: "package" } | { mode: "remote", repoUrl: string }}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveInstallSource(repoUrl) {
|
|
48
|
+
const override = repoUrl ?? resolveAssetOverride();
|
|
49
|
+
if (override) {
|
|
50
|
+
return { mode: "remote", repoUrl: assertSafeAssetBase(override) };
|
|
51
|
+
}
|
|
52
|
+
return { mode: "package" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} remotePath
|
|
57
|
+
* @param {string} destPath
|
|
58
|
+
*/
|
|
59
|
+
export async function copyPackagedAsset(remotePath, destPath) {
|
|
60
|
+
const source = packagedAssetPath(remotePath);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await fs.access(source);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === "ENOENT") {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Packaged guardrails asset missing: ${remotePath}. ` +
|
|
68
|
+
`Reinstall ${PACKAGE_NAME}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await assertSafeWriteTarget(destPath);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await fs.copyFile(source, destPath);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err.code === "ENOENT") {
|
|
80
|
+
throw new Error(`cannot write ${destPath} - destination directory missing`);
|
|
81
|
+
}
|
|
82
|
+
if (err.code === "EACCES" || err.code === "EPERM") {
|
|
83
|
+
throw new Error(`Permission denied: cannot write ${destPath}`);
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Download one asset. Pin-tag fallback applies only when fetching the official
|
|
91
|
+
* repo with no override (kept for the remote path; the default install no
|
|
92
|
+
* longer uses it).
|
|
93
|
+
*
|
|
94
|
+
* @param {{ remotePath: string, destPath: string, repoUrl?: string, state: { warned: boolean }, log: (msg: string) => void }} params
|
|
95
|
+
*/
|
|
96
|
+
export async function downloadRemoteAsset({
|
|
97
|
+
remotePath,
|
|
98
|
+
destPath,
|
|
99
|
+
repoUrl,
|
|
100
|
+
state,
|
|
101
|
+
log,
|
|
102
|
+
}) {
|
|
103
|
+
try {
|
|
104
|
+
await downloadToFile(resolveAssetUrl(remotePath, repoUrl), destPath);
|
|
105
|
+
return;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const missingPinnedAsset =
|
|
108
|
+
!repoUrl && /Download failed: 404/.test(err.message);
|
|
109
|
+
|
|
110
|
+
if (!missingPinnedAsset) {
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!state.warned) {
|
|
115
|
+
state.warned = true;
|
|
116
|
+
log(
|
|
117
|
+
`⚠️ Tag ${PINNED_REF} has no published assets yet — ` +
|
|
118
|
+
"falling back to the default branch.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await downloadToFile(
|
|
124
|
+
resolveAssetUrl(remotePath, FALLBACK_REPO_URL),
|
|
125
|
+
destPath,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {{ remotePath: string, destPath: string, source: ReturnType<typeof resolveInstallSource>, state: { warned: boolean }, log: (msg: string) => void }} params
|
|
131
|
+
*/
|
|
132
|
+
export async function installAsset({ remotePath, destPath, source, state, log }) {
|
|
133
|
+
if (source.mode === "package") {
|
|
134
|
+
await copyPackagedAsset(remotePath, destPath);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await downloadRemoteAsset({
|
|
139
|
+
remotePath,
|
|
140
|
+
destPath,
|
|
141
|
+
repoUrl: source.repoUrl,
|
|
142
|
+
state,
|
|
143
|
+
log,
|
|
144
|
+
});
|
|
145
|
+
}
|