@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/config.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { readFileSafe } from "./fs-utils.js";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = ".specs/config.yaml";
|
|
6
|
+
|
|
7
|
+
/** Map CLI phase names to config.yaml rules keys. */
|
|
8
|
+
export const PHASE_ALIASES = {
|
|
9
|
+
explore: "explore",
|
|
10
|
+
constitution: "constitution",
|
|
11
|
+
specify: "specify",
|
|
12
|
+
discuss: "discuss",
|
|
13
|
+
design: "design",
|
|
14
|
+
tasks: "tasks",
|
|
15
|
+
analyze: "analyze",
|
|
16
|
+
implement: "implement",
|
|
17
|
+
execute: "implement",
|
|
18
|
+
loop: "implement",
|
|
19
|
+
verify: "verify",
|
|
20
|
+
validate: "verify",
|
|
21
|
+
archive: "archive",
|
|
22
|
+
converge: "converge",
|
|
23
|
+
quick: "quick",
|
|
24
|
+
handoff: "handoff",
|
|
25
|
+
memory: "handoff",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} GuardrailsConfigDocument
|
|
30
|
+
* @property {string} [schema]
|
|
31
|
+
* @property {string} [extends]
|
|
32
|
+
* @property {string} [branch_prefix]
|
|
33
|
+
* @property {string} [context]
|
|
34
|
+
* @property {Record<string, string[]>} [rules]
|
|
35
|
+
* @property {{ rules?: Record<string, string[]> }} [overrides]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Minimal YAML parser for the guardrails config schema (no external deps).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {GuardrailsConfigDocument}
|
|
43
|
+
*/
|
|
44
|
+
export function parseGuardrailsConfig(text) {
|
|
45
|
+
/** @type {GuardrailsConfigDocument} */
|
|
46
|
+
const config = {};
|
|
47
|
+
/** @type {Record<string, string[]> | undefined} */
|
|
48
|
+
let rules;
|
|
49
|
+
/** @type {Record<string, string[]> | undefined} */
|
|
50
|
+
let overrideRules;
|
|
51
|
+
/** @type {string | undefined} */
|
|
52
|
+
let currentRulesPhase;
|
|
53
|
+
/** @type {"rules" | "overrides" | undefined} */
|
|
54
|
+
let rulesSection;
|
|
55
|
+
let contextLines = null;
|
|
56
|
+
const lines = text.split("\n");
|
|
57
|
+
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
|
|
61
|
+
if (contextLines !== null) {
|
|
62
|
+
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
63
|
+
contextLines.push(line.replace(/^\s{2}/, ""));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
config.context = contextLines.join("\n").replace(/\n+$/, "");
|
|
68
|
+
contextLines = null;
|
|
69
|
+
|
|
70
|
+
if (trimmed === "") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scalar = trimmed.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
80
|
+
if (scalar) {
|
|
81
|
+
const [, key, value] = scalar;
|
|
82
|
+
if (key === "schema") {
|
|
83
|
+
config.schema = value.replace(/^['"]|['"]$/g, "");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (key === "extends") {
|
|
87
|
+
config.extends = value.replace(/^['"]|['"]$/g, "");
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (key === "branch_prefix") {
|
|
91
|
+
config.branch_prefix = value.replace(/^['"]|['"]$/g, "");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (key === "context" && value === "|") {
|
|
95
|
+
contextLines = [];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (key === "rules" && value === "") {
|
|
99
|
+
rules = {};
|
|
100
|
+
config.rules = rules;
|
|
101
|
+
rulesSection = "rules";
|
|
102
|
+
currentRulesPhase = undefined;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (key === "overrides" && value === "") {
|
|
106
|
+
config.overrides = {};
|
|
107
|
+
rulesSection = "overrides";
|
|
108
|
+
currentRulesPhase = undefined;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (rulesSection === "overrides") {
|
|
114
|
+
const nestedRules = trimmed.match(/^rules:\s*$/);
|
|
115
|
+
if (nestedRules) {
|
|
116
|
+
overrideRules = {};
|
|
117
|
+
config.overrides ??= {};
|
|
118
|
+
config.overrides.rules = overrideRules;
|
|
119
|
+
currentRulesPhase = undefined;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const activeRules = rulesSection === "overrides" ? overrideRules : rules;
|
|
125
|
+
const rulesPhase = trimmed.match(/^([a-zA-Z0-9_-]+):\s*$/);
|
|
126
|
+
if (rulesPhase && activeRules) {
|
|
127
|
+
currentRulesPhase = rulesPhase[1];
|
|
128
|
+
activeRules[currentRulesPhase] = [];
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const listItem = trimmed.match(/^-\s+(.+)$/);
|
|
133
|
+
if (listItem && currentRulesPhase && activeRules) {
|
|
134
|
+
activeRules[currentRulesPhase].push(listItem[1]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (contextLines !== null) {
|
|
139
|
+
config.context = contextLines.join("\n").replace(/\n+$/, "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {GuardrailsConfigDocument | null | undefined} base
|
|
147
|
+
* @param {GuardrailsConfigDocument | null | undefined} overlay
|
|
148
|
+
* @returns {GuardrailsConfigDocument}
|
|
149
|
+
*/
|
|
150
|
+
export function mergeGuardrailsConfigs(base, overlay) {
|
|
151
|
+
/** @type {GuardrailsConfigDocument} */
|
|
152
|
+
const merged = {
|
|
153
|
+
schema: overlay?.schema ?? base?.schema,
|
|
154
|
+
branch_prefix: overlay?.branch_prefix ?? base?.branch_prefix,
|
|
155
|
+
rules: mergeRules(base?.rules, overlay?.rules),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const contexts = [base?.context, overlay?.context]
|
|
159
|
+
.map((value) => value?.trim())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
if (contexts.length) {
|
|
162
|
+
merged.context = contexts.join("\n\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (overlay?.overrides?.rules) {
|
|
166
|
+
merged.rules = mergeRules(merged.rules, overlay.overrides.rules);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return merged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {Record<string, string[]> | undefined} base
|
|
174
|
+
* @param {Record<string, string[]> | undefined} overlay
|
|
175
|
+
* @returns {Record<string, string[]> | undefined}
|
|
176
|
+
*/
|
|
177
|
+
function mergeRules(base, overlay) {
|
|
178
|
+
if (!base && !overlay) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @type {Record<string, string[]>} */
|
|
183
|
+
const merged = { ...(base ?? {}) };
|
|
184
|
+
|
|
185
|
+
for (const [phase, items] of Object.entries(overlay ?? {})) {
|
|
186
|
+
merged[phase] = [...(merged[phase] ?? []), ...items];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return merged;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve extends chain and apply overrides.
|
|
194
|
+
*
|
|
195
|
+
* @param {GuardrailsConfigDocument} document
|
|
196
|
+
* @param {(name: string) => Promise<GuardrailsConfigDocument>} loadPreset
|
|
197
|
+
* @param {Set<string>} [seen]
|
|
198
|
+
* @returns {Promise<GuardrailsConfigDocument>}
|
|
199
|
+
*/
|
|
200
|
+
export async function resolveGuardrailsConfig(document, loadPreset, seen = new Set()) {
|
|
201
|
+
const { extends: presetName, overrides, ...local } = document;
|
|
202
|
+
|
|
203
|
+
if (!presetName) {
|
|
204
|
+
return mergeGuardrailsConfigs(null, { ...local, overrides });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (seen.has(presetName)) {
|
|
208
|
+
throw new Error(`Config preset cycle detected: ${[...seen, presetName].join(" → ")}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
seen.add(presetName);
|
|
212
|
+
const preset = await loadPreset(presetName);
|
|
213
|
+
const resolvedPreset = await resolveGuardrailsConfig(preset, loadPreset, seen);
|
|
214
|
+
const merged = mergeGuardrailsConfigs(resolvedPreset, local);
|
|
215
|
+
return mergeGuardrailsConfigs(merged, { overrides });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} [cwd]
|
|
220
|
+
* @returns {Promise<GuardrailsConfigDocument | null>}
|
|
221
|
+
*/
|
|
222
|
+
export async function loadGuardrailsConfig(cwd = process.cwd()) {
|
|
223
|
+
const configPath = path.join(cwd, CONFIG_PATH);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const text = await readFileSafe(configPath);
|
|
227
|
+
return parseGuardrailsConfig(text);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err.code === "ENOENT" || /cannot read/i.test(err.message)) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} phase
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
export function normalizePhase(phase) {
|
|
241
|
+
const key = phase.trim().toLowerCase();
|
|
242
|
+
const mapped = PHASE_ALIASES[key];
|
|
243
|
+
if (!mapped) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Unknown phase "${phase}". Known phases: ${[...new Set(Object.keys(PHASE_ALIASES))].sort().join(", ")}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return mapped;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build markdown injected before a phase procedure.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} phase
|
|
255
|
+
* @param {GuardrailsConfigDocument | null} config
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
export function formatPhaseContext(phase, config) {
|
|
259
|
+
const normalized = normalizePhase(phase);
|
|
260
|
+
const parts = [];
|
|
261
|
+
|
|
262
|
+
if (config?.context?.trim()) {
|
|
263
|
+
parts.push("## Project context (.specs/config.yaml)\n");
|
|
264
|
+
parts.push(config.context.trim());
|
|
265
|
+
parts.push("");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const rules = config?.rules?.[normalized] ?? [];
|
|
269
|
+
if (rules.length) {
|
|
270
|
+
parts.push(`## Phase rules (${normalized})\n`);
|
|
271
|
+
for (const rule of rules) {
|
|
272
|
+
parts.push(`- ${rule}`);
|
|
273
|
+
}
|
|
274
|
+
parts.push("");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!parts.length) {
|
|
278
|
+
return `# Phase context: ${normalized}\n\n(no .specs/config.yaml or no entries for this phase)\n`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return `# Phase context: ${normalized}\n\n${parts.join("\n").trimEnd()}\n`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {string} phase
|
|
286
|
+
* @param {{ cwd?: string }} [options]
|
|
287
|
+
*/
|
|
288
|
+
export async function phaseContext(phase, options = {}) {
|
|
289
|
+
const cwd = options.cwd ?? process.cwd();
|
|
290
|
+
const { loadResolvedConfig } = await import("./presets.js");
|
|
291
|
+
const config = await loadResolvedConfig(cwd);
|
|
292
|
+
return formatPhaseContext(phase, config);
|
|
293
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
export const { name: PACKAGE_NAME, version: PACKAGE_VERSION } =
|
|
4
|
+
createRequire(import.meta.url)("../package.json");
|
|
5
|
+
|
|
6
|
+
export const CLI_NAME = "spec-guardrails";
|
|
7
|
+
export const DISPLAY_NAME = "Spec Guardrails";
|
|
8
|
+
export const NPX = (command) => `npx ${PACKAGE_NAME}${command ? ` ${command}` : ""}`;
|
|
9
|
+
|
|
10
|
+
const REPO_RAW_BASE =
|
|
11
|
+
"https://raw.githubusercontent.com/luizssantiago92/spec-guardrails";
|
|
12
|
+
|
|
13
|
+
/** Assets are fetched from the tag matching the installed CLI version. */
|
|
14
|
+
export const PINNED_REF = `v${PACKAGE_VERSION}`;
|
|
15
|
+
|
|
16
|
+
/** Used only when the pinned tag is not published yet. */
|
|
17
|
+
export const FALLBACK_REF = "main";
|
|
18
|
+
|
|
19
|
+
export const REPO_RAW_URL = `${REPO_RAW_BASE}/${PINNED_REF}`;
|
|
20
|
+
|
|
21
|
+
export const FALLBACK_REPO_URL = `${REPO_RAW_BASE}/${FALLBACK_REF}`;
|
|
22
|
+
|
|
23
|
+
export const SKILL_DIRS = [".cursor/skills", ".claude/skills"];
|
|
24
|
+
|
|
25
|
+
export const CURSOR_RULES_DIR = ".cursor/rules";
|
|
26
|
+
|
|
27
|
+
export const REFERENCES_SUBDIR = "references";
|
|
28
|
+
|
|
29
|
+
export const GUARDRAILS_SCRIPTS_DIR = ".specs/guardrails/scripts";
|
|
30
|
+
|
|
31
|
+
/** @type {{ file: string, remotePath: string }[]} */
|
|
32
|
+
export const SKILL_ASSETS = [
|
|
33
|
+
{
|
|
34
|
+
file: "agent-architecture.md",
|
|
35
|
+
remotePath: "skills/agent-architecture.md",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
file: "engineering-standards.md",
|
|
39
|
+
remotePath: "skills/engineering-standards.md",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
file: "security-review.md",
|
|
43
|
+
remotePath: "skills/security-review.md",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
file: "appsec.md",
|
|
47
|
+
remotePath: "skills/appsec.md",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
file: "qa-strategy.md",
|
|
51
|
+
remotePath: "skills/qa-strategy.md",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
file: "code-simplify.md",
|
|
55
|
+
remotePath: "skills/code-simplify.md",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
file: "ship-ready.md",
|
|
59
|
+
remotePath: "skills/ship-ready.md",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
file: "git-handoff.md",
|
|
63
|
+
remotePath: "skills/git-handoff.md",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
file: "task-graph-engineering.md",
|
|
67
|
+
remotePath: "skills/task-graph-engineering.md",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Phase procedures loaded on demand by the hub skill. */
|
|
72
|
+
/** @type {{ file: string, remotePath: string }[]} */
|
|
73
|
+
export const REFERENCE_ASSETS = [
|
|
74
|
+
{ file: "explore.md", remotePath: "skills/references/explore.md" },
|
|
75
|
+
{ file: "project-init.md", remotePath: "skills/references/project-init.md" },
|
|
76
|
+
{ file: "constitution.md", remotePath: "skills/references/constitution.md" },
|
|
77
|
+
{ file: "specify.md", remotePath: "skills/references/specify.md" },
|
|
78
|
+
{ file: "discuss.md", remotePath: "skills/references/discuss.md" },
|
|
79
|
+
{ file: "design.md", remotePath: "skills/references/design.md" },
|
|
80
|
+
{ file: "tasks.md", remotePath: "skills/references/tasks.md" },
|
|
81
|
+
{ file: "analyze.md", remotePath: "skills/references/analyze.md" },
|
|
82
|
+
{ file: "implement.md", remotePath: "skills/references/implement.md" },
|
|
83
|
+
{ file: "validate.md", remotePath: "skills/references/validate.md" },
|
|
84
|
+
{ file: "converge.md", remotePath: "skills/references/converge.md" },
|
|
85
|
+
{ file: "archive.md", remotePath: "skills/references/archive.md" },
|
|
86
|
+
{ file: "memory.md", remotePath: "skills/references/memory.md" },
|
|
87
|
+
{ file: "quick-mode.md", remotePath: "skills/references/quick-mode.md" },
|
|
88
|
+
{ file: "context-limits.md", remotePath: "skills/references/context-limits.md" },
|
|
89
|
+
{ file: "lessons.md", remotePath: "skills/references/lessons.md" },
|
|
90
|
+
{ file: "sub-agents.md", remotePath: "skills/references/sub-agents.md" },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** Deterministic gates executed with python3. */
|
|
94
|
+
/** @type {{ file: string, remotePath: string }[]} */
|
|
95
|
+
export const SCRIPT_ASSETS = [
|
|
96
|
+
{ file: "_common.py", remotePath: "scripts/_common.py" },
|
|
97
|
+
{ file: "validate_spec.py", remotePath: "scripts/validate_spec.py" },
|
|
98
|
+
{ file: "validate_tasks.py", remotePath: "scripts/validate_tasks.py" },
|
|
99
|
+
{ file: "validate_state.py", remotePath: "scripts/validate_state.py" },
|
|
100
|
+
{ file: "analyze_artifacts.py", remotePath: "scripts/analyze_artifacts.py" },
|
|
101
|
+
{ file: "check_commit.py", remotePath: "scripts/check_commit.py" },
|
|
102
|
+
{ file: "lessons.py", remotePath: "scripts/lessons.py" },
|
|
103
|
+
{ file: "loop_plan.py", remotePath: "scripts/loop_plan.py" },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** @type {{ file: string, remotePath: string }[]} */
|
|
107
|
+
export const RULE_ASSETS = [
|
|
108
|
+
{
|
|
109
|
+
file: "engineering-baseline.mdc",
|
|
110
|
+
remotePath: "rules/engineering-baseline.mdc",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export const STATE_HEADER = `# 📝 Project State & Decisions
|
|
115
|
+
|
|
116
|
+
## Active Feature
|
|
117
|
+
- Feature: —
|
|
118
|
+
- Phase: —
|
|
119
|
+
- Branch: —
|
|
120
|
+
|
|
121
|
+
## Next Step (single item)
|
|
122
|
+
- [ ] —
|
|
123
|
+
|
|
124
|
+
## Blockers
|
|
125
|
+
- none
|
|
126
|
+
|
|
127
|
+
## Deferred Ideas
|
|
128
|
+
- none
|
|
129
|
+
|
|
130
|
+
## Decisions
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
export const LESSONS_HEADER = `# Lessons Learned
|
|
134
|
+
|
|
135
|
+
Generated by \`lessons.py\` from \`.specs/lessons.json\`. Do not edit this file.
|
|
136
|
+
A clean PASS records nothing. Only **confirmed** lessons below are guidance.
|
|
137
|
+
Inspect candidates with \`python3 .specs/guardrails/scripts/lessons.py list --status all\`.
|
|
138
|
+
|
|
139
|
+
- none yet
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
export const CURSORRULES_MARKER_BEGIN = "<!-- SPEC-GUARDRAILS:BEGIN -->";
|
|
143
|
+
|
|
144
|
+
export const CURSORRULES_MARKER_END = "<!-- SPEC-GUARDRAILS:END -->";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Prior product markers — recognized only so `install` can upgrade `.cursorrules`.
|
|
148
|
+
* Runtime does not dual-path scripts under these names.
|
|
149
|
+
*/
|
|
150
|
+
export const LEGACY_CURSORRULES_MARKER_PAIRS = [
|
|
151
|
+
["<!-- SPEC-SEATBELT:BEGIN -->", "<!-- SPEC-SEATBELT:END -->"],
|
|
152
|
+
["<!-- AGENTIC-HARNESS:BEGIN -->", "<!-- AGENTIC-HARNESS:END -->"],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/** @deprecated Use LEGACY_CURSORRULES_MARKER_PAIRS */
|
|
156
|
+
export const LEGACY_CURSORRULES_MARKER_BEGIN = "<!-- AGENTIC-HARNESS:BEGIN -->";
|
|
157
|
+
|
|
158
|
+
/** @deprecated Use LEGACY_CURSORRULES_MARKER_PAIRS */
|
|
159
|
+
export const LEGACY_CURSORRULES_MARKER_END = "<!-- AGENTIC-HARNESS:END -->";
|
|
160
|
+
|
|
161
|
+
export const CURSORRULES_BLOCK = `${CURSORRULES_MARKER_BEGIN}
|
|
162
|
+
# Execution Contract (Spec Guardrails)
|
|
163
|
+
When planning architecture, specs, or multi-step features, read the hub first:
|
|
164
|
+
- \`.cursor/skills/agent-architecture.md\` — SDD hub: contract, phases, gates, complexity router
|
|
165
|
+
- \`.cursor/skills/references/\` — phase procedures (explore, project-init, constitution, specify, discuss, design, tasks, analyze, implement, validate, converge, archive, memory, quick-mode, context-limits, lessons, sub-agents)
|
|
166
|
+
- \`.cursor/skills/task-graph-engineering.md\` — task DAG, parallelism, verify topology
|
|
167
|
+
- \`.cursor/skills/engineering-standards.md\` — secure coding, code quality, artifact language
|
|
168
|
+
- \`.cursor/skills/security-review.md\` — security checklist for /verify
|
|
169
|
+
- \`.cursor/skills/appsec.md\` — conditional AppSec (Complex / attack surface); never with other conditionals at once
|
|
170
|
+
- \`.cursor/skills/qa-strategy.md\` — conditional QA strategy; load after AppSec if both apply
|
|
171
|
+
- \`.cursor/skills/code-simplify.md\` — conditional simplify (Medium+ after A–D or owner ask)
|
|
172
|
+
- \`.cursor/skills/ship-ready.md\` — conditional ship checklist (owner ask; does not authorize push)
|
|
173
|
+
- \`.cursor/skills/git-handoff.md\` — git sync and session handoff for .specs/
|
|
174
|
+
|
|
175
|
+
Deterministic gates (python3, non-zero exit means STOP):
|
|
176
|
+
- Scripts in \`.specs/guardrails/scripts/\` — the **agent** runs them at phase boundaries (see hub).
|
|
177
|
+
- Humans: \`install\` once; optional \`feature-init\`, \`project-init\`, \`doctor\`. Full CLI: \`npx @luizsantiago/spec-guardrails --help\`.
|
|
178
|
+
- Onboarding: \`.specs/GETTING_STARTED.md\`
|
|
179
|
+
|
|
180
|
+
All project artifacts are written in English.
|
|
181
|
+
Persistent state: \`.specs/STATE.md\` (decisions/handoff), \`.specs/lessons.json\` (canonical lessons), and \`.specs/LESSONS.md\` (generated playbook).
|
|
182
|
+
Project rules: \`.cursor/rules/engineering-baseline.mdc\`
|
|
183
|
+
${CURSORRULES_MARKER_END}
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Assets become executable gate scripts on disk, so the source has to be
|
|
190
|
+
* trustworthy. Plain HTTP is rejected except against a local host, which the
|
|
191
|
+
* test suite uses.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} base
|
|
194
|
+
* @returns {string} the validated base, without a trailing slash
|
|
195
|
+
*/
|
|
196
|
+
function assertAllowedAssetUrl(raw) {
|
|
197
|
+
let url;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
url = new URL(raw);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`Invalid guardrails asset URL: ${raw}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isLocal = LOCAL_HOSTS.has(url.hostname);
|
|
206
|
+
|
|
207
|
+
if (url.protocol === "https:" || (url.protocol === "http:" && isLocal)) {
|
|
208
|
+
return url;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Refusing to download guardrails assets over ${url.protocol}//${url.hostname} ` +
|
|
213
|
+
"— only HTTPS sources are allowed. Guardrails assets include executable gate scripts.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function assertSafeAssetBase(base) {
|
|
218
|
+
assertAllowedAssetUrl(base);
|
|
219
|
+
return base.replace(/\/+$/, "");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate a full download URL (including path), including each redirect hop.
|
|
224
|
+
* Returns the URL string unchanged when the scheme/host policy passes.
|
|
225
|
+
* When `sourceUrl` is set (a redirect hop), the next URL must share that origin.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} raw
|
|
228
|
+
* @param {string} [sourceUrl] previous hop; cross-origin redirects are refused
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
export function assertSafeDownloadUrl(raw, sourceUrl) {
|
|
232
|
+
assertAllowedAssetUrl(raw);
|
|
233
|
+
|
|
234
|
+
if (sourceUrl) {
|
|
235
|
+
const next = new URL(raw);
|
|
236
|
+
const from = new URL(sourceUrl);
|
|
237
|
+
if (next.origin !== from.origin) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Refusing to follow a cross-origin redirect from ${from.origin} to ${next.origin}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return raw;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @returns {string | undefined} the override base, when one is configured */
|
|
248
|
+
export function resolveAssetOverride() {
|
|
249
|
+
const override = process.env.SPEC_GUARDRAILS_REPO_URL;
|
|
250
|
+
return override ? assertSafeAssetBase(override) : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} remotePath
|
|
255
|
+
* @param {string} [repoUrl]
|
|
256
|
+
*/
|
|
257
|
+
export function resolveAssetUrl(remotePath, repoUrl) {
|
|
258
|
+
const base = assertSafeAssetBase(
|
|
259
|
+
repoUrl ?? resolveAssetOverride() ?? REPO_RAW_URL,
|
|
260
|
+
);
|
|
261
|
+
return `${base}/${remotePath}`;
|
|
262
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CURSORRULES_BLOCK,
|
|
5
|
+
CURSORRULES_MARKER_BEGIN,
|
|
6
|
+
CURSORRULES_MARKER_END,
|
|
7
|
+
LEGACY_CURSORRULES_MARKER_PAIRS,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import {
|
|
10
|
+
appendFileSafe,
|
|
11
|
+
readFileSafe,
|
|
12
|
+
writeFileSafe,
|
|
13
|
+
} from "./fs-utils.js";
|
|
14
|
+
|
|
15
|
+
const MARKER_PAIRS = [
|
|
16
|
+
[CURSORRULES_MARKER_BEGIN, CURSORRULES_MARKER_END],
|
|
17
|
+
...LEGACY_CURSORRULES_MARKER_PAIRS,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} content
|
|
22
|
+
* @returns {{ start: number, end: number, begin: string, endMarker: string } | null}
|
|
23
|
+
*/
|
|
24
|
+
function locateGuardrailsBlock(content) {
|
|
25
|
+
for (const [begin, endMarker] of MARKER_PAIRS) {
|
|
26
|
+
const start = content.indexOf(begin);
|
|
27
|
+
const end = content.indexOf(endMarker);
|
|
28
|
+
|
|
29
|
+
if (start !== -1 && end !== -1 && end >= start) {
|
|
30
|
+
return { start, end, begin, endMarker };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractGuardrailsBlock(content) {
|
|
38
|
+
const located = locateGuardrailsBlock(content);
|
|
39
|
+
if (!located) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return content.slice(located.start, located.end + located.endMarker.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function replaceGuardrailsBlock(content) {
|
|
47
|
+
const located = locateGuardrailsBlock(content);
|
|
48
|
+
if (!located) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const before = content.slice(0, located.start);
|
|
53
|
+
const after = content.slice(located.end + located.endMarker.length);
|
|
54
|
+
const trimmedBlock = `${CURSORRULES_BLOCK.trim()}\n`;
|
|
55
|
+
|
|
56
|
+
return `${before}${trimmedBlock}${after.replace(/^\n+/, "")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function injectCursorRules(cwd) {
|
|
60
|
+
const rulesPath = path.join(cwd, ".cursorrules");
|
|
61
|
+
const expectedBlock = CURSORRULES_BLOCK.trim();
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const existing = await readFileSafe(rulesPath);
|
|
65
|
+
const currentBlock = extractGuardrailsBlock(existing);
|
|
66
|
+
|
|
67
|
+
if (currentBlock) {
|
|
68
|
+
if (currentBlock.trim() === expectedBlock) {
|
|
69
|
+
return { created: false, updated: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const replaced = replaceGuardrailsBlock(existing);
|
|
73
|
+
if (replaced === null) {
|
|
74
|
+
throw new Error(`Failed to upgrade guardrails block in ${rulesPath}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await writeFileSafe(rulesPath, replaced.endsWith("\n") ? replaced : `${replaced}\n`);
|
|
78
|
+
return { created: false, updated: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
82
|
+
await appendFileSafe(rulesPath, `${separator}${CURSORRULES_BLOCK}\n`);
|
|
83
|
+
return { created: false, updated: true };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code !== "ENOENT") {
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeFileSafe(rulesPath, `${CURSORRULES_BLOCK}\n`);
|
|
91
|
+
return { created: true, updated: false };
|
|
92
|
+
}
|