@luizsantiago/spec-guardrails 4.6.0 → 4.7.0
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/README.md +8 -3
- package/index.js +1 -0
- package/lib/brownfield.js +86 -2
- package/lib/classify-change.js +24 -1
- package/lib/constants.js +9 -0
- package/lib/feature-overview.js +96 -1
- package/lib/gates.js +1 -0
- package/package.json +1 -1
- package/rules/engineering-baseline.mdc +2 -0
- package/scripts/_project_config.py +49 -0
- package/scripts/validate_ship_surface.py +237 -0
- package/skills/agent-architecture.md +5 -2
- package/skills/ai-engineering.md +68 -0
- package/skills/python-devops.md +60 -0
- package/skills/references/design.md +29 -0
- package/skills/references/implement.md +1 -0
- package/skills/references/tasks.md +8 -0
- package/skills/references/validate.md +3 -0
- package/templates/config.yaml.example +13 -2
- package/templates/presets/python-platform.yaml +78 -0
package/README.md
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
Spec Guardrails installs a working method into your repository: the agent writes down what it is going to build, gets your approval, implements in small waves, and proves the result before calling it done. Nothing about your stack changes — you get written requirements, a task plan, and verification evidence stored as files in the project.
|
|
12
12
|
|
|
13
|
-
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.
|
|
13
|
+
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.7.x**
|
|
14
|
+
|
|
15
|
+
### Who it's for
|
|
16
|
+
|
|
17
|
+
- **Any stack** — default SDD with gates and `.specs/` memory
|
|
18
|
+
- **Python platform teams** — backend + DevOps + AI in one repo: use preset `python-platform`, Ship/AI Surface, and [Tutorial 04](docs/guide/tutorials/04-python-platform-ship-surface.md). Not a live observability platform — see [limitations](docs/guide/python-platform.md#limitations-honest).
|
|
14
19
|
|
|
15
20
|
---
|
|
16
21
|
|
|
@@ -60,7 +65,7 @@ In short: Python turns "trust the agent" into "the agent has to prove it."
|
|
|
60
65
|
|
|
61
66
|
Which checks exist and what each one requires: [Gates](docs/guide/gates.md) · [Guarantees matrix](docs/guide/Guarantees-matrix.md)
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
| Go deeper | [Quick start](docs/guide/Quick-start.md) · [CHANGELOG](docs/CHANGELOG.md) · [Product history](docs/guide/Product-history.md) · [Tutorials](docs/guide/tutorials/README.md) · [Platform parity](docs/guide/Platform-parity.md) |
|
|
64
69
|
|
|
65
70
|
---
|
|
66
71
|
|
|
@@ -212,7 +217,7 @@ See [Guarantees matrix](docs/guide/Guarantees-matrix.md) for the full product vi
|
|
|
212
217
|
| Enforcement | [Gates](docs/guide/gates.md) | [Gates and guarantees](docs/guide/Gates-and-guarantees.md) |
|
|
213
218
|
| Requirements | [Requirements analysis](docs/guide/requirements-analysis.md) | [Agent commands → /elicit](docs/guide/agent-commands.md) |
|
|
214
219
|
| Long-running projects | [Memory](docs/guide/Memory.md) | [Brownfield context](docs/guide/brownfield-context.md) |
|
|
215
|
-
| Questions | [FAQ](docs/guide/FAQ.md) | [Glossary](docs/guide/Glossary.md) · [Stability policy](docs/guide/Stability-policy.md) |
|
|
220
|
+
| Questions | [FAQ](docs/guide/FAQ.md) | [Glossary](docs/guide/Glossary.md) · [Product history](docs/guide/Product-history.md) · [Stability policy](docs/guide/Stability-policy.md) |
|
|
216
221
|
|
|
217
222
|
Full index: [docs/guide/README.md](docs/guide/README.md)
|
|
218
223
|
|
package/index.js
CHANGED
|
@@ -183,6 +183,7 @@ Commands:
|
|
|
183
183
|
loop-plan [tasks.md|feature] Next Execute wave — parallel groups + sub-agent hints
|
|
184
184
|
[--json] Machine-readable plan for agents
|
|
185
185
|
validate-traceability [feature] REQ → tasks → validation coverage chain
|
|
186
|
+
validate-ship-surface [feature] Ship Surface + AI Surface when infra/AI paths in tasks
|
|
186
187
|
validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
|
|
187
188
|
validate-req-analysis [brief.md] Requirements brief gate before /specify (/elicit)
|
|
188
189
|
validate-state [feature] Completion gate before declaring a feature done
|
package/lib/brownfield.js
CHANGED
|
@@ -69,10 +69,41 @@ async function readRepoName(cwd) {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* @param {string} cwd
|
|
72
|
-
* @returns {Promise<
|
|
72
|
+
* @returns {Promise<boolean>}
|
|
73
|
+
*/
|
|
74
|
+
async function pathExists(target) {
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(target);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} dir
|
|
85
|
+
* @returns {Promise<boolean>}
|
|
86
|
+
*/
|
|
87
|
+
async function dirHasFilesMatching(dir, pattern) {
|
|
88
|
+
try {
|
|
89
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.isFile() && pattern.test(entry.name)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// missing dir
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} cwd
|
|
103
|
+
* @returns {Promise<{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[], hasCompose?: boolean, hasTerraform?: boolean, hasHelm?: boolean, hasCi?: boolean, hasAiStack?: boolean, hasEvalHarness?: boolean }>}
|
|
73
104
|
*/
|
|
74
105
|
export async function detectProjectStack(cwd) {
|
|
75
|
-
/** @type {{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[] }} */
|
|
106
|
+
/** @type {{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[], hasCompose?: boolean, hasTerraform?: boolean, hasHelm?: boolean, hasCi?: boolean, hasAiStack?: boolean, hasEvalHarness?: boolean }} */
|
|
76
107
|
const result = {
|
|
77
108
|
stack: "unknown",
|
|
78
109
|
testCommand: "(fill in)",
|
|
@@ -137,6 +168,45 @@ export async function detectProjectStack(cwd) {
|
|
|
137
168
|
}
|
|
138
169
|
}
|
|
139
170
|
|
|
171
|
+
result.hasCompose =
|
|
172
|
+
(await pathExists(path.join(cwd, "Dockerfile"))) ||
|
|
173
|
+
(await dirHasFilesMatching(cwd, /^docker-compose.*\.(ya?ml)$/i));
|
|
174
|
+
result.hasTerraform =
|
|
175
|
+
(await pathExists(path.join(cwd, "terraform"))) ||
|
|
176
|
+
(await dirHasFilesMatching(cwd, /\.tf$/i));
|
|
177
|
+
result.hasHelm =
|
|
178
|
+
(await pathExists(path.join(cwd, "charts"))) ||
|
|
179
|
+
(await pathExists(path.join(cwd, "helm")));
|
|
180
|
+
result.hasCi = await dirHasFilesMatching(
|
|
181
|
+
path.join(cwd, ".github", "workflows"),
|
|
182
|
+
/\.ya?ml$/i,
|
|
183
|
+
);
|
|
184
|
+
result.hasEvalHarness =
|
|
185
|
+
(await pathExists(path.join(cwd, "evals"))) ||
|
|
186
|
+
(await pathExists(path.join(cwd, "tests", "eval")));
|
|
187
|
+
|
|
188
|
+
if (result.stack === "Python") {
|
|
189
|
+
try {
|
|
190
|
+
const pyproject = await readFileSafe(path.join(cwd, "pyproject.toml"));
|
|
191
|
+
result.hasAiStack = /\[(project|tool\.poetry)\.[^\]]*?(llm|ai)[^\]]*\]/i.test(
|
|
192
|
+
pyproject,
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
result.hasAiStack = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const platformSignals =
|
|
199
|
+
result.hasCompose ||
|
|
200
|
+
result.hasTerraform ||
|
|
201
|
+
result.hasHelm ||
|
|
202
|
+
result.hasCi ||
|
|
203
|
+
result.hasAiStack ||
|
|
204
|
+
result.hasEvalHarness;
|
|
205
|
+
if (platformSignals) {
|
|
206
|
+
result.preset = "python-platform";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
140
210
|
return result;
|
|
141
211
|
}
|
|
142
212
|
|
|
@@ -222,6 +292,20 @@ export function buildProjectMarkdown(input) {
|
|
|
222
292
|
if (input.stack.lintCommand) {
|
|
223
293
|
stackLines += `\n- Lint: ${input.stack.lintCommand}`;
|
|
224
294
|
}
|
|
295
|
+
const flags = [
|
|
296
|
+
input.stack.hasCompose && "Docker/Compose",
|
|
297
|
+
input.stack.hasTerraform && "Terraform",
|
|
298
|
+
input.stack.hasHelm && "Helm",
|
|
299
|
+
input.stack.hasCi && "CI workflows",
|
|
300
|
+
input.stack.hasAiStack && "AI deps (pyproject)",
|
|
301
|
+
input.stack.hasEvalHarness && "eval harness dir",
|
|
302
|
+
].filter(Boolean);
|
|
303
|
+
if (flags.length) {
|
|
304
|
+
stackLines += `\n- Platform signals: ${flags.join(", ")}`;
|
|
305
|
+
}
|
|
306
|
+
if (input.stack.preset) {
|
|
307
|
+
stackLines += `\n- Suggested preset: \`${input.stack.preset}\``;
|
|
308
|
+
}
|
|
225
309
|
|
|
226
310
|
return `# Project: ${input.repoName}
|
|
227
311
|
|
package/lib/classify-change.js
CHANGED
|
@@ -34,6 +34,16 @@ const VAGUE_SIGNALS = [
|
|
|
34
34
|
/\bwithout\s+(?:criteria|details|spec)\b/i,
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
+
const AI_SIGNALS = [
|
|
38
|
+
/\bllm\b/i,
|
|
39
|
+
/\brag\b/i,
|
|
40
|
+
/\bembedding(s)?\b/i,
|
|
41
|
+
/\bmcp\b/i,
|
|
42
|
+
/\bagent(s)?\b/i,
|
|
43
|
+
/\bprompt(s)?\b/i,
|
|
44
|
+
/\bvector\b/i,
|
|
45
|
+
];
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* @param {{ description?: string, files?: string[] }} input
|
|
39
49
|
* @returns {{
|
|
@@ -41,6 +51,7 @@ const VAGUE_SIGNALS = [
|
|
|
41
51
|
* reasons: string[],
|
|
42
52
|
* next: string,
|
|
43
53
|
* suggestElicit: boolean,
|
|
54
|
+
* suggestAiSkill: boolean,
|
|
44
55
|
* fileCount: number,
|
|
45
56
|
* }}
|
|
46
57
|
*/
|
|
@@ -55,6 +66,11 @@ export function classifyChange(input = {}) {
|
|
|
55
66
|
const hasMedium = MEDIUM_SIGNALS.some((re) => re.test(haystack));
|
|
56
67
|
const hasNewDep = DEPENDENCY_SIGNALS.some((re) => re.test(haystack));
|
|
57
68
|
const hasVague = VAGUE_SIGNALS.some((re) => re.test(haystack));
|
|
69
|
+
const hasAi = AI_SIGNALS.some((re) => re.test(haystack));
|
|
70
|
+
|
|
71
|
+
if (hasAi) {
|
|
72
|
+
reasons.push("AI engineering signal (llm/rag/mcp/agent/prompt)");
|
|
73
|
+
}
|
|
58
74
|
|
|
59
75
|
if (hasComplex) {
|
|
60
76
|
reasons.push("sensitive surface or architecture signal in description/paths");
|
|
@@ -77,6 +93,9 @@ export function classifyChange(input = {}) {
|
|
|
77
93
|
|
|
78
94
|
if (hasComplex) {
|
|
79
95
|
tier = "complex";
|
|
96
|
+
} else if (hasAi && (hasMedium || hasNewDep || fileCount > 3)) {
|
|
97
|
+
tier = "complex";
|
|
98
|
+
reasons.push("AI surface with feature-scale change — use full pipeline + AI Surface in design");
|
|
80
99
|
} else if (
|
|
81
100
|
!hasNewDep &&
|
|
82
101
|
fileCount > 0 &&
|
|
@@ -121,7 +140,8 @@ export function classifyChange(input = {}) {
|
|
|
121
140
|
tier,
|
|
122
141
|
reasons,
|
|
123
142
|
next: nextByTier[tier],
|
|
124
|
-
suggestElicit: hasVague && !hasComplex,
|
|
143
|
+
suggestElicit: (hasVague || hasAi) && !hasComplex,
|
|
144
|
+
suggestAiSkill: hasAi,
|
|
125
145
|
fileCount,
|
|
126
146
|
};
|
|
127
147
|
}
|
|
@@ -141,5 +161,8 @@ export function formatClassifyChange(result) {
|
|
|
141
161
|
if (result.suggestElicit) {
|
|
142
162
|
lines.push("Suggest: /elicit (structured Q&A) or /specify if scope is already clear");
|
|
143
163
|
}
|
|
164
|
+
if (result.suggestAiSkill) {
|
|
165
|
+
lines.push("Suggest: load ai-engineering.md sister skill + document AI Surface in design.md");
|
|
166
|
+
}
|
|
144
167
|
return `${lines.join("\n")}\n`;
|
|
145
168
|
}
|
package/lib/constants.js
CHANGED
|
@@ -67,6 +67,14 @@ export const SKILL_ASSETS = [
|
|
|
67
67
|
file: "git-handoff.md",
|
|
68
68
|
remotePath: "skills/git-handoff.md",
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
file: "python-devops.md",
|
|
72
|
+
remotePath: "skills/python-devops.md",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
file: "ai-engineering.md",
|
|
76
|
+
remotePath: "skills/ai-engineering.md",
|
|
77
|
+
},
|
|
70
78
|
{
|
|
71
79
|
file: "task-graph-engineering.md",
|
|
72
80
|
remotePath: "skills/task-graph-engineering.md",
|
|
@@ -105,6 +113,7 @@ export const SCRIPT_ASSETS = [
|
|
|
105
113
|
{ file: "validate_tasks.py", remotePath: "scripts/validate_tasks.py" },
|
|
106
114
|
{ file: "validate_state.py", remotePath: "scripts/validate_state.py" },
|
|
107
115
|
{ file: "validate_traceability.py", remotePath: "scripts/validate_traceability.py" },
|
|
116
|
+
{ file: "validate_ship_surface.py", remotePath: "scripts/validate_ship_surface.py" },
|
|
108
117
|
{ file: "validate_quick.py", remotePath: "scripts/validate_quick.py" },
|
|
109
118
|
{ file: "analyze_artifacts.py", remotePath: "scripts/analyze_artifacts.py" },
|
|
110
119
|
{ file: "check_commit.py", remotePath: "scripts/check_commit.py" },
|
package/lib/feature-overview.js
CHANGED
|
@@ -10,6 +10,56 @@ const TASK_HEADING = /^#{2,6}\s*(T\d+)[:\s]+(.+)$/gim;
|
|
|
10
10
|
const TASK_FIELD =
|
|
11
11
|
/^\s*[-*]?\s*\*{0,2}([A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(.+?)\s*$/gim;
|
|
12
12
|
const EVIDENCE = /[\w./\\-]+\.[A-Za-z][A-Za-z0-9]{0,9}:\d{1,6}\b/g;
|
|
13
|
+
const SHIP_SECTION = /^##\s+Ship\s+Surface\s*$/im;
|
|
14
|
+
const AI_SECTION = /^##\s+AI\s+Surface\s*$/im;
|
|
15
|
+
const NEXT_SECTION = /^##\s+/gm;
|
|
16
|
+
const SURFACE_FIELD =
|
|
17
|
+
/^\s*(?:[-*]\s*)?\*{0,2}([^|*\n]+?)\*{0,2}\s*:\s*(.+?)\s*$/gim;
|
|
18
|
+
const SURFACE_TABLE =
|
|
19
|
+
/^\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$/gim;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @param {RegExp} heading
|
|
24
|
+
* @returns {string | null}
|
|
25
|
+
*/
|
|
26
|
+
function extractDesignSection(text, heading) {
|
|
27
|
+
const match = heading.exec(text);
|
|
28
|
+
if (!match || match.index === undefined) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const start = match.index + match[0].length;
|
|
32
|
+
const rest = text.slice(start);
|
|
33
|
+
const next = rest.search(/^##\s+/m);
|
|
34
|
+
return next >= 0 ? rest.slice(0, next) : rest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string | null} sectionText
|
|
39
|
+
* @returns {Record<string, string>}
|
|
40
|
+
*/
|
|
41
|
+
function parseSurfaceFields(sectionText) {
|
|
42
|
+
if (!sectionText) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
/** @type {Record<string, string>} */
|
|
46
|
+
const fields = {};
|
|
47
|
+
for (const match of sectionText.matchAll(SURFACE_FIELD)) {
|
|
48
|
+
const key = match[1].trim();
|
|
49
|
+
if (key.toLowerCase() === "field" || key === "---") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
fields[key] = match[2].trim();
|
|
53
|
+
}
|
|
54
|
+
for (const match of sectionText.matchAll(SURFACE_TABLE)) {
|
|
55
|
+
const key = match[1].trim();
|
|
56
|
+
if (key.toLowerCase() === "field" || key === "---") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
fields[key] = match[2].trim();
|
|
60
|
+
}
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
13
63
|
|
|
14
64
|
/**
|
|
15
65
|
* @param {string} text
|
|
@@ -96,12 +146,18 @@ export async function buildFeatureOverview(featureArg, options = {}) {
|
|
|
96
146
|
let specText = "";
|
|
97
147
|
let tasksText = "";
|
|
98
148
|
let validationText = "";
|
|
149
|
+
let designText = "";
|
|
99
150
|
|
|
100
151
|
try {
|
|
101
152
|
specText = await readFileSafe(path.join(dir, "spec.md"));
|
|
102
153
|
} catch {
|
|
103
154
|
// optional until Specify
|
|
104
155
|
}
|
|
156
|
+
try {
|
|
157
|
+
designText = await readFileSafe(path.join(dir, "design.md"));
|
|
158
|
+
} catch {
|
|
159
|
+
// optional until Design
|
|
160
|
+
}
|
|
105
161
|
try {
|
|
106
162
|
tasksText = await readFileSafe(path.join(dir, "tasks.md"));
|
|
107
163
|
} catch {
|
|
@@ -133,6 +189,13 @@ export async function buildFeatureOverview(featureArg, options = {}) {
|
|
|
133
189
|
evidence: validationText ? evidenceForReq(validationText, reqId) : null,
|
|
134
190
|
}));
|
|
135
191
|
|
|
192
|
+
const shipSurface = designText
|
|
193
|
+
? parseSurfaceFields(extractDesignSection(designText, SHIP_SECTION))
|
|
194
|
+
: {};
|
|
195
|
+
const aiSurface = designText
|
|
196
|
+
? parseSurfaceFields(extractDesignSection(designText, AI_SECTION))
|
|
197
|
+
: {};
|
|
198
|
+
|
|
136
199
|
return {
|
|
137
200
|
featureId,
|
|
138
201
|
generatedAt: new Date().toISOString(),
|
|
@@ -140,6 +203,8 @@ export async function buildFeatureOverview(featureArg, options = {}) {
|
|
|
140
203
|
status,
|
|
141
204
|
tasks,
|
|
142
205
|
traceability,
|
|
206
|
+
shipSurface,
|
|
207
|
+
aiSurface,
|
|
143
208
|
};
|
|
144
209
|
}
|
|
145
210
|
|
|
@@ -174,6 +239,20 @@ export function formatFeatureOverview(overview) {
|
|
|
174
239
|
.join("\n")
|
|
175
240
|
: "| — | — | — |";
|
|
176
241
|
|
|
242
|
+
const shipRows =
|
|
243
|
+
Object.keys(overview.shipSurface ?? {}).length > 0
|
|
244
|
+
? Object.entries(overview.shipSurface)
|
|
245
|
+
.map(([field, value]) => `| ${field} | ${value} |`)
|
|
246
|
+
.join("\n")
|
|
247
|
+
: "| — | — |";
|
|
248
|
+
|
|
249
|
+
const aiRows =
|
|
250
|
+
Object.keys(overview.aiSurface ?? {}).length > 0
|
|
251
|
+
? Object.entries(overview.aiSurface)
|
|
252
|
+
.map(([field, value]) => `| ${field} | ${value} |`)
|
|
253
|
+
.join("\n")
|
|
254
|
+
: "| — | — |";
|
|
255
|
+
|
|
177
256
|
const taskSummary = status.tasks
|
|
178
257
|
? `${status.tasks.complete}/${status.tasks.total} complete (${status.tasks.open} open)`
|
|
179
258
|
: "—";
|
|
@@ -211,7 +290,23 @@ ${taskRows}
|
|
|
211
290
|
| --- | --- | --- |
|
|
212
291
|
${traceRows}
|
|
213
292
|
|
|
214
|
-
|
|
293
|
+
## Operational traceability (Ship Surface)
|
|
294
|
+
|
|
295
|
+
| Field | Value |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
${shipRows}
|
|
298
|
+
|
|
299
|
+
_Parsed from \`design.md\` when present. Does not infer undeclared deploy paths._
|
|
300
|
+
|
|
301
|
+
## AI traceability (AI Surface)
|
|
302
|
+
|
|
303
|
+
| Field | Value |
|
|
304
|
+
| --- | --- |
|
|
305
|
+
${aiRows}
|
|
306
|
+
|
|
307
|
+
_Eval harness and fallback must be documented for AI paths — gate \`validate_ship_surface\` enforces structure only._
|
|
308
|
+
|
|
309
|
+
_Evidence rows populate after \`validation.md\` exists. Structural gaps are caught by \`validate-traceability\` and \`validate-ship-surface\`._
|
|
215
310
|
`;
|
|
216
311
|
}
|
|
217
312
|
|
package/lib/gates.js
CHANGED
|
@@ -33,6 +33,7 @@ const GATE_SCRIPTS = {
|
|
|
33
33
|
"validate-tasks": "validate_tasks.py",
|
|
34
34
|
"validate-state": "validate_state.py",
|
|
35
35
|
"validate-traceability": "validate_traceability.py",
|
|
36
|
+
"validate-ship-surface": "validate_ship_surface.py",
|
|
36
37
|
"validate-quick": "validate_quick.py",
|
|
37
38
|
"validate-req-analysis": "validate_req_analysis.py",
|
|
38
39
|
"analyze-artifacts": "analyze_artifacts.py",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"description": "Governed spec-driven development for AI coding agents. Your agent writes the spec, gets your approval, builds in small waves, and proves the result — plans and project memory stored as files in your repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,8 @@ Chat language is a personal preference. Set it in your own agent settings if you
|
|
|
34
34
|
| `.cursor/skills/code-simplify.md` | Simplification sister (load on demand) |
|
|
35
35
|
| `.cursor/skills/ship-ready.md` | Ship-ready sister (load on demand) |
|
|
36
36
|
| `.cursor/skills/git-handoff.md` | Git sync and session handoff |
|
|
37
|
+
| `.cursor/skills/python-devops.md` | Python DevOps — Ship Surface (load on demand) |
|
|
38
|
+
| `.cursor/skills/ai-engineering.md` | AI engineering — AI Surface (load on demand) |
|
|
37
39
|
<!-- guardrails-managed:skills-map:end -->
|
|
38
40
|
|
|
39
41
|
# Deterministic Gates
|
|
@@ -21,6 +21,27 @@ DEFAULT_SUPPRESSION_PATTERNS = [
|
|
|
21
21
|
|
|
22
22
|
DEFAULT_MAX_STAGED_LINES = 500
|
|
23
23
|
|
|
24
|
+
DEFAULT_INFRA_GLOBS = [
|
|
25
|
+
"**/Dockerfile",
|
|
26
|
+
"**/docker-compose*.yml",
|
|
27
|
+
"**/docker-compose*.yaml",
|
|
28
|
+
"**/terraform/**",
|
|
29
|
+
"**/*.tf",
|
|
30
|
+
"**/charts/**",
|
|
31
|
+
"**/helm/**",
|
|
32
|
+
"**/.github/workflows/**",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
DEFAULT_AI_GLOBS = [
|
|
36
|
+
"**/prompts/**",
|
|
37
|
+
"**/mcp/**",
|
|
38
|
+
"**/evals/**",
|
|
39
|
+
"**/tests/eval/**",
|
|
40
|
+
"**/*embed*",
|
|
41
|
+
"**/*llm*",
|
|
42
|
+
"**/*rag*",
|
|
43
|
+
]
|
|
44
|
+
|
|
24
45
|
|
|
25
46
|
def config_path(cwd: Path | str | None = None) -> Path:
|
|
26
47
|
root = Path(cwd) if cwd is not None else Path.cwd()
|
|
@@ -64,6 +85,10 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
|
|
|
64
85
|
"quality": {"checks": []},
|
|
65
86
|
"suppressions": {"patterns": list(DEFAULT_SUPPRESSION_PATTERNS)},
|
|
66
87
|
"commit": {"max_staged_lines": DEFAULT_MAX_STAGED_LINES},
|
|
88
|
+
"ship_surface": {
|
|
89
|
+
"infra_globs": list(DEFAULT_INFRA_GLOBS),
|
|
90
|
+
"ai_globs": list(DEFAULT_AI_GLOBS),
|
|
91
|
+
},
|
|
67
92
|
}
|
|
68
93
|
|
|
69
94
|
path = config_path(cwd)
|
|
@@ -102,9 +127,27 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
|
|
|
102
127
|
index += 1
|
|
103
128
|
continue
|
|
104
129
|
|
|
130
|
+
if stripped == "ship_surface:":
|
|
131
|
+
section = "ship_surface"
|
|
132
|
+
section_indent = indent
|
|
133
|
+
index += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
105
136
|
if section and indent <= section_indent and not stripped.endswith(":"):
|
|
106
137
|
section = None
|
|
107
138
|
|
|
139
|
+
if section == "ship_surface" and stripped == "infra_globs:":
|
|
140
|
+
items, index = _parse_list_block(lines, index + 1, indent)
|
|
141
|
+
if items:
|
|
142
|
+
config["ship_surface"]["infra_globs"] = items
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if section == "ship_surface" and stripped == "ai_globs:":
|
|
146
|
+
items, index = _parse_list_block(lines, index + 1, indent)
|
|
147
|
+
if items:
|
|
148
|
+
config["ship_surface"]["ai_globs"] = items
|
|
149
|
+
continue
|
|
150
|
+
|
|
108
151
|
if section == "quality" and stripped == "checks:":
|
|
109
152
|
items, index = _parse_list_block(lines, index + 1, indent)
|
|
110
153
|
config["quality"]["checks"] = items
|
|
@@ -124,3 +167,9 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
|
|
|
124
167
|
index += 1
|
|
125
168
|
|
|
126
169
|
return config
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_ship_surface_config(cwd: Path | str | None = None) -> dict:
|
|
173
|
+
"""Return ship_surface globs with defaults for validate_ship_surface.py."""
|
|
174
|
+
|
|
175
|
+
return load_project_config(cwd)["ship_surface"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ship Surface + AI Surface structural gate for python-platform teams.
|
|
3
|
+
|
|
4
|
+
Run after Tasks (before Execute) and again before Verify when infra or AI paths
|
|
5
|
+
are listed in task Files:
|
|
6
|
+
|
|
7
|
+
python3 validate_ship_surface.py auth
|
|
8
|
+
python3 validate_ship_surface.py .specs/features/003-rag-api
|
|
9
|
+
|
|
10
|
+
Checks (markdown structure only — not deploy safety or eval quality):
|
|
11
|
+
* when task Files match infra_globs → design.md ## Ship Surface with required fields
|
|
12
|
+
* when task Files match ai_globs → design.md ## AI Surface with Eval harness + Fallback
|
|
13
|
+
* fields must be non-empty and not placeholder tokens
|
|
14
|
+
|
|
15
|
+
Limitations (documented honestly):
|
|
16
|
+
* does not run terraform plan, helm diff, or live LLM eval
|
|
17
|
+
* does not validate semantic correctness of rollback or fallback strategies
|
|
18
|
+
* globs are configurable via ship_surface.infra_globs / ai_globs in .specs/config.yaml
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import fnmatch
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from _common import Report, resolve_feature_dir, visible_markdown
|
|
32
|
+
from _project_config import load_ship_surface_config
|
|
33
|
+
from validate_tasks import parse_files
|
|
34
|
+
|
|
35
|
+
GATE = "validate-ship-surface"
|
|
36
|
+
|
|
37
|
+
SHIP_SECTION = re.compile(r"^##\s+Ship\s+Surface\s*$", re.MULTILINE | re.IGNORECASE)
|
|
38
|
+
AI_SECTION = re.compile(r"^##\s+AI\s+Surface\s*$", re.MULTILINE | re.IGNORECASE)
|
|
39
|
+
NEXT_SECTION = re.compile(r"^##\s+", re.MULTILINE)
|
|
40
|
+
|
|
41
|
+
SHIP_REQUIRED_FIELDS = ("Deploy unit", "CI", "Rollback")
|
|
42
|
+
AI_REQUIRED_FIELDS = ("Eval harness", "Fallback / degrade")
|
|
43
|
+
|
|
44
|
+
FIELD_LINE = re.compile(
|
|
45
|
+
r"^\s*(?:[-*]\s*)?\*{0,2}(?P<key>[^|*\n]+?)\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
|
|
46
|
+
re.MULTILINE,
|
|
47
|
+
)
|
|
48
|
+
TABLE_ROW = re.compile(
|
|
49
|
+
r"^\s*\|\s*(?P<key>[^|]+?)\s*\|\s*(?P<value>[^|]+?)\s*\|\s*$",
|
|
50
|
+
re.MULTILINE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
PLACEHOLDER_VALUES = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"",
|
|
56
|
+
"—",
|
|
57
|
+
"-",
|
|
58
|
+
"n/a",
|
|
59
|
+
"na",
|
|
60
|
+
"none",
|
|
61
|
+
"tbd",
|
|
62
|
+
"todo",
|
|
63
|
+
"(fill in)",
|
|
64
|
+
"(fill me)",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_section(text: str, heading: re.Pattern[str]) -> str | None:
|
|
70
|
+
match = heading.search(text)
|
|
71
|
+
if not match:
|
|
72
|
+
return None
|
|
73
|
+
start = match.end()
|
|
74
|
+
rest = text[start:]
|
|
75
|
+
next_heading = NEXT_SECTION.search(rest)
|
|
76
|
+
end = start + next_heading.start() if next_heading else len(text)
|
|
77
|
+
return text[start:end]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_surface_fields(section_text: str | None) -> dict[str, str]:
|
|
81
|
+
if not section_text:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
fields: dict[str, str] = {}
|
|
85
|
+
visible = visible_markdown(section_text)
|
|
86
|
+
|
|
87
|
+
for match in FIELD_LINE.finditer(visible):
|
|
88
|
+
key = match.group("key").strip().strip("*")
|
|
89
|
+
value = match.group("value").strip().strip("*")
|
|
90
|
+
if key.lower() in {"field", "key", "---"}:
|
|
91
|
+
continue
|
|
92
|
+
fields[key] = value
|
|
93
|
+
|
|
94
|
+
for match in TABLE_ROW.finditer(visible):
|
|
95
|
+
key = match.group("key").strip()
|
|
96
|
+
value = match.group("value").strip()
|
|
97
|
+
if key.lower() in {"field", "key", "---"}:
|
|
98
|
+
continue
|
|
99
|
+
fields[key] = value
|
|
100
|
+
|
|
101
|
+
return fields
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_placeholder(value: str) -> bool:
|
|
105
|
+
cleaned = value.strip().strip("`").lower()
|
|
106
|
+
if cleaned in PLACEHOLDER_VALUES:
|
|
107
|
+
return True
|
|
108
|
+
return cleaned.startswith("(fill")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_path(path: str) -> str:
|
|
112
|
+
return path.replace("\\", "/").lstrip("./")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def path_matches_globs(file_path: str, globs: list[str]) -> bool:
|
|
116
|
+
normalized = normalize_path(file_path)
|
|
117
|
+
name = Path(normalized).name
|
|
118
|
+
for pattern in globs:
|
|
119
|
+
pat = pattern.replace("\\", "/").lstrip("./")
|
|
120
|
+
if fnmatch.fnmatch(normalized, pat) or fnmatch.fnmatch(name, pat):
|
|
121
|
+
return True
|
|
122
|
+
if pat.endswith("/**"):
|
|
123
|
+
prefix = pat[:-3].rstrip("/")
|
|
124
|
+
if normalized == prefix or normalized.startswith(f"{prefix}/"):
|
|
125
|
+
return True
|
|
126
|
+
tail = pat.rsplit("/", 1)[-1]
|
|
127
|
+
if tail and tail != "**" and "*" in tail and fnmatch.fnmatch(name, tail):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def collect_task_files(tasks_text: str) -> list[str]:
|
|
133
|
+
files: list[str] = []
|
|
134
|
+
for match in FIELD_LINE.finditer(tasks_text):
|
|
135
|
+
if match.group("key").strip().lower() != "files":
|
|
136
|
+
continue
|
|
137
|
+
files.extend(parse_files(match.group("value")))
|
|
138
|
+
return files
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def find_field(fields: dict[str, str], *candidates: str) -> str | None:
|
|
142
|
+
lowered = {key.lower(): value for key, value in fields.items()}
|
|
143
|
+
for candidate in candidates:
|
|
144
|
+
value = lowered.get(candidate.lower())
|
|
145
|
+
if value is not None:
|
|
146
|
+
return value
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def missing_required(fields: dict[str, str], required: tuple[str, ...]) -> list[str]:
|
|
151
|
+
missing: list[str] = []
|
|
152
|
+
for name in required:
|
|
153
|
+
value = find_field(fields, name)
|
|
154
|
+
if value is None or is_placeholder(value):
|
|
155
|
+
missing.append(name)
|
|
156
|
+
return missing
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_optional(feature_dir: Path, filename: str) -> str | None:
|
|
160
|
+
path = feature_dir / filename
|
|
161
|
+
if not path.is_file():
|
|
162
|
+
return None
|
|
163
|
+
text = path.read_text(encoding="utf-8")
|
|
164
|
+
return text if text.strip() else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_report(feature_dir: Path, cwd: Path | None = None) -> Report:
|
|
168
|
+
report = Report(gate=GATE, target=str(feature_dir))
|
|
169
|
+
config = load_ship_surface_config(cwd or feature_dir.parent.parent.parent)
|
|
170
|
+
infra_globs = config["infra_globs"]
|
|
171
|
+
ai_globs = config["ai_globs"]
|
|
172
|
+
|
|
173
|
+
tasks_text = read_optional(feature_dir, "tasks.md")
|
|
174
|
+
if not tasks_text:
|
|
175
|
+
report.warn("tasks.md missing — skipping ship/AI surface checks")
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
task_files = collect_task_files(tasks_text)
|
|
179
|
+
if not task_files:
|
|
180
|
+
report.warn("no Files paths in tasks.md — skipping ship/AI surface checks")
|
|
181
|
+
return report
|
|
182
|
+
|
|
183
|
+
needs_ship = any(path_matches_globs(path, infra_globs) for path in task_files)
|
|
184
|
+
needs_ai = any(path_matches_globs(path, ai_globs) for path in task_files)
|
|
185
|
+
|
|
186
|
+
if not needs_ship and not needs_ai:
|
|
187
|
+
report.ok("no infra or AI paths in task Files — gate not required")
|
|
188
|
+
return report
|
|
189
|
+
|
|
190
|
+
design_text = read_optional(feature_dir, "design.md")
|
|
191
|
+
if not design_text:
|
|
192
|
+
report.error("design.md missing or empty — required when tasks touch infra or AI paths")
|
|
193
|
+
return report
|
|
194
|
+
|
|
195
|
+
if needs_ship:
|
|
196
|
+
ship_fields = parse_surface_fields(extract_section(design_text, SHIP_SECTION))
|
|
197
|
+
if not ship_fields:
|
|
198
|
+
report.error("design.md missing or empty — required when tasks touch infra or AI paths")
|
|
199
|
+
else:
|
|
200
|
+
report.ok(f"Ship Surface: {len(ship_fields)} field(s) parsed")
|
|
201
|
+
for field in missing_required(ship_fields, SHIP_REQUIRED_FIELDS):
|
|
202
|
+
report.error(f"Ship Surface missing or placeholder field: {field}")
|
|
203
|
+
|
|
204
|
+
if needs_ai:
|
|
205
|
+
ai_fields = parse_surface_fields(extract_section(design_text, AI_SECTION))
|
|
206
|
+
if not ai_fields:
|
|
207
|
+
report.error("## AI Surface missing or empty in design.md")
|
|
208
|
+
else:
|
|
209
|
+
report.ok(f"AI Surface: {len(ai_fields)} field(s) parsed")
|
|
210
|
+
for field in missing_required(ai_fields, AI_REQUIRED_FIELDS):
|
|
211
|
+
report.error(f"AI Surface missing or placeholder field: {field}")
|
|
212
|
+
|
|
213
|
+
return report
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv: list[str] | None = None) -> int:
|
|
217
|
+
parser = argparse.ArgumentParser(description="Validate Ship Surface and AI Surface in design.md")
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"feature",
|
|
220
|
+
nargs="?",
|
|
221
|
+
help="Feature id, slug, or path to .specs/features/<feature>",
|
|
222
|
+
)
|
|
223
|
+
args = parser.parse_args(argv)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
feature_dir = resolve_feature_dir(args.feature)
|
|
227
|
+
except SystemExit as exc:
|
|
228
|
+
print(exc, file=sys.stderr)
|
|
229
|
+
return 2
|
|
230
|
+
|
|
231
|
+
cwd = feature_dir.parent.parent.parent
|
|
232
|
+
report = build_report(feature_dir, cwd)
|
|
233
|
+
return report.emit()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|
|
@@ -64,6 +64,7 @@ Structural gates run **before** owner review, so they cannot drift when the mode
|
|
|
64
64
|
| During Verify, when `quality.checks` is configured | `python3 .specs/guardrails/scripts/run_quality_checks.py` |
|
|
65
65
|
| Before declaring a feature done | `python3 .specs/guardrails/scripts/validate_state.py [feature]` |
|
|
66
66
|
| Traceability (Medium+ features) | `python3 .specs/guardrails/scripts/validate_traceability.py [feature]` |
|
|
67
|
+
| Ship / AI Surface (when infra or AI paths in tasks) | `python3 .specs/guardrails/scripts/validate_ship_surface.py [feature]` |
|
|
67
68
|
| Quick mode evidence | `python3 .specs/guardrails/scripts/validate_quick.py [feature]` |
|
|
68
69
|
| After Verify PASS | `npx @luizsantiago/spec-guardrails archive-feature [feature]` (Tier 0) |
|
|
69
70
|
| Before a phase procedure (optional) | `npx @luizsantiago/spec-guardrails phase-context <phase>` |
|
|
@@ -93,7 +94,7 @@ EXPLORE (optional) → ELICIT (optional) → SPECIFY → DISCUSS (conditional)
|
|
|
93
94
|
| **Tasks** | No | `references/tasks.md` | `task-graph-engineering.md` | `validate_tasks.py` |
|
|
94
95
|
| **Analyze** | Before task approval | `references/analyze.md` | — | `analyze_artifacts.py` |
|
|
95
96
|
| **Execute** | Yes | `references/implement.md` | `engineering-standards.md` | `check_commit.py`, `check_suppressions.py` |
|
|
96
|
-
| **Verify** | Yes | `references/validate.md` | `security-review.md` | `validate_traceability.py`, `validate_state.py`, `run_quality_checks.py` |
|
|
97
|
+
| **Verify** | Yes | `references/validate.md` | `security-review.md` | `validate_traceability.py`, `validate_ship_surface.py`, `validate_state.py`, `run_quality_checks.py` |
|
|
97
98
|
| **Archive** | After Verify PASS | `references/archive.md` | `git-handoff.md` | `archive-feature` |
|
|
98
99
|
| **Converge** | On drift | `references/converge.md` | — | `analyze_artifacts.py` |
|
|
99
100
|
| **Handoff** | Yes | `references/memory.md` | `git-handoff.md` | — |
|
|
@@ -113,8 +114,10 @@ Not in the default phase-map cell. Load only on `/verify` after `validate.md` +
|
|
|
113
114
|
| --- | --- | --- |
|
|
114
115
|
| `appsec.md` | **Complex**, or auth / payments / PII / secrets / upload / SSRF / network trust boundary | Quick; Simple without those surfaces; copy/docs/styling |
|
|
115
116
|
| `qa-strategy.md` | **Complex**, or multi-step user-facing flow, or owner asked for regression/QA | Quick; Simple one-file; evidence-or-zero alone is enough |
|
|
117
|
+
| `python-devops.md` | Infra/docker/terraform/helm/CI paths in tasks, or `python-platform` preset | No deploy surface in the change |
|
|
118
|
+
| `ai-engineering.md` | LLM/RAG/MCP/agent/prompt paths or classify-change AI signal | No AI paths in tasks |
|
|
116
119
|
|
|
117
|
-
**Sequence.** If both triggers fire: AppSec → write `## AppSec` → **drop** `appsec.md`
|
|
120
|
+
**Sequence.** If both AppSec and QA triggers fire: AppSec → write `## AppSec` → **drop** `appsec.md` → QA → write `## QA`. Never load AppSec + QA together. For platform work, `python-devops.md` and `ai-engineering.md` are independent of Verify sisters — load at most one on-demand sister during Design/Tasks/Execute when paths match; never with AppSec/QA in the same window.
|
|
118
121
|
|
|
119
122
|
## Complexity Router
|
|
120
123
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# AI Engineering
|
|
2
|
+
|
|
3
|
+
Sister skill for **AI Surface** — model scope, tools/MCP, eval harness, fallback, and PII policy in `design.md`. Load when `classify-change` detects llm/rag/mcp/agent/prompt signals or tasks touch `prompts/`, `evals/`, MCP paths.
|
|
4
|
+
|
|
5
|
+
**Not a substitute for** LangSmith/Coze Loop (live traces), MLOps platforms, or `security-review.md`.
|
|
6
|
+
|
|
7
|
+
## Limitations
|
|
8
|
+
|
|
9
|
+
- No live LLM observability — governance is versioned in git + structural gates, not production traces.
|
|
10
|
+
- Eval quality is the team's responsibility — the gate requires a documented harness, not passing scores.
|
|
11
|
+
- Semantic memory (`memory.retrieval.semantic`) is optional and off by default in `python-platform` preset.
|
|
12
|
+
- Does not run paid API calls during gates — offline eval (`pytest -m "not live"`) is the default pattern.
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
15
|
+
|
|
16
|
+
- Vague kickoff ("add RAG", "agent with tools") — pair with `/elicit` first
|
|
17
|
+
- Tasks list `Files` under `prompts/`, `evals/`, `tests/eval/`, `mcp/`, embeddings, or `*rag*`
|
|
18
|
+
- Spec criteria describe non-deterministic model behavior
|
|
19
|
+
|
|
20
|
+
## When NOT to Use
|
|
21
|
+
|
|
22
|
+
- Deterministic CRUD with no model — skip AI Surface
|
|
23
|
+
- Security-only review — use `security-review.md` / `appsec.md`
|
|
24
|
+
|
|
25
|
+
## AI Surface (design.md)
|
|
26
|
+
|
|
27
|
+
| Field | Purpose |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| **Capability** | chat, RAG, tool-use, batch embed, etc. |
|
|
30
|
+
| **Model / provider** | e.g. gpt-4o, claude-sonnet, Ollama — no API keys in git |
|
|
31
|
+
| **Tools / MCP scope** | allowlist / deny list for agent tools |
|
|
32
|
+
| **Eval harness** | `pytest tests/eval/` or dataset path — **required by gate** |
|
|
33
|
+
| **PII / data policy** | what must not reach the model; redaction path |
|
|
34
|
+
| **Fallback / degrade** | timeout, rate limit, safe response — **required by gate** |
|
|
35
|
+
| **Cost guard** | link to `execution-policy` / budget in `.specs/config.yaml` |
|
|
36
|
+
|
|
37
|
+
Gate requires **Eval harness** and **Fallback / degrade** when AI globs match task `Files`.
|
|
38
|
+
|
|
39
|
+
## Workflow connections (already shipped — do not reinvent)
|
|
40
|
+
|
|
41
|
+
| Tool | Use when |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `/elicit` + `req-analysis` | Brief is vague before `/specify` |
|
|
44
|
+
| `solution-explore` | Choosing RAG vs fine-tune vs rules |
|
|
45
|
+
| `memory-retrieve` | Finding past AI decisions in `.specs/` |
|
|
46
|
+
| Discrimination sensor (`validate.md`) | Adversarial checks on behavior tests |
|
|
47
|
+
| `execution-policy` + `sandbox` | Budget and dangerous commands |
|
|
48
|
+
| `lessons.py` | After verify FAIL on eval — feed next sprint |
|
|
49
|
+
|
|
50
|
+
## Specs for non-deterministic behavior
|
|
51
|
+
|
|
52
|
+
- Write **testable** criteria: golden set, score thresholds, property bounds — not "answers well".
|
|
53
|
+
- Version prompts in repo (`prompts/`) — anti-pattern: prompt only in code.
|
|
54
|
+
- Eval tasks get explicit `Gate` commands; add offline eval to `quality.checks` when stable.
|
|
55
|
+
|
|
56
|
+
## Anti-patterns
|
|
57
|
+
|
|
58
|
+
- Tool without allowlist / MCP scope documented
|
|
59
|
+
- Eval only manual in chat — no harness in repo
|
|
60
|
+
- No fallback when model times out or refuses
|
|
61
|
+
- PII in prompts without policy line in AI Surface
|
|
62
|
+
|
|
63
|
+
## Related
|
|
64
|
+
|
|
65
|
+
- `docs/guide/python-platform.md` — capabilities and honest limits
|
|
66
|
+
- `python-devops.md` — when the same feature also ships infra
|
|
67
|
+
- `references/design.md` — AI Surface template
|
|
68
|
+
- `security-review.md` — PII, secrets, SSRF at verify
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Python DevOps
|
|
2
|
+
|
|
3
|
+
Sister skill for **Ship Surface** — deploy, CI, IaC, and rollback evidence in `design.md`. Load on demand when tasks touch Docker, Compose, Terraform, Helm, CI workflows, or when the project uses the `python-platform` preset.
|
|
4
|
+
|
|
5
|
+
**Not a substitute for** `ship-ready.md` (owner go-live checklist) or `security-review.md` (secrets/PII).
|
|
6
|
+
|
|
7
|
+
## Limitations
|
|
8
|
+
|
|
9
|
+
- Structural gate only — `validate_ship_surface.py` checks that fields exist, not that `terraform plan` is safe or rollback was tested in production.
|
|
10
|
+
- No Kubernetes runtime management — Helm template validation is optional via `quality.checks`, not built-in.
|
|
11
|
+
- Framework-agnostic — FastAPI/Django/worker patterns live in tutorial appendices, not in this skill.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- Tasks list `Files` under `Dockerfile`, `docker-compose*.yml`, `terraform/`, `charts/`, `.github/workflows/`
|
|
16
|
+
- Owner asks about deploy, rollback, migrations before ship
|
|
17
|
+
- `project-init` suggested preset `python-platform`
|
|
18
|
+
|
|
19
|
+
## When NOT to Use
|
|
20
|
+
|
|
21
|
+
- Pure library/CLI changes with no infra touch — skip Ship Surface
|
|
22
|
+
- Live observability or incident response — use your APM stack, not Spec Guardrails
|
|
23
|
+
|
|
24
|
+
## Ship Surface (design.md)
|
|
25
|
+
|
|
26
|
+
Document before Execute when infra paths appear in tasks:
|
|
27
|
+
|
|
28
|
+
| Field | Purpose |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| **API / contract** | OpenAPI path, health route, or N/A for workers/CLIs |
|
|
31
|
+
| **Migrations** | Alembic, Django migrate, or N/A |
|
|
32
|
+
| **Env / secrets** | Which vars come from where — never values in git |
|
|
33
|
+
| **Deploy unit** | Container service, Helm release, worker + broker |
|
|
34
|
+
| **CI** | Workflow or command that must pass before merge |
|
|
35
|
+
| **Rollback** | How to undo a bad deploy |
|
|
36
|
+
| **Observability** | Logs, metrics, alerts minimum for this change |
|
|
37
|
+
|
|
38
|
+
Gate requires **Deploy unit**, **CI**, and **Rollback** non-empty when infra globs match task `Files`.
|
|
39
|
+
|
|
40
|
+
## Procedure
|
|
41
|
+
|
|
42
|
+
1. **Reuse existing infra** — extend compose/terraform/chart before inventing parallel paths.
|
|
43
|
+
2. **Migration before deploy** — document order in tasks; gate does not run migrations.
|
|
44
|
+
3. **Quality checks** — uncomment matching lines in `quality.checks` (`docker compose config`, `terraform validate`, `helm template`).
|
|
45
|
+
4. **Before Verify** — run `validate_ship_surface.py`; fix `design.md` on FAIL.
|
|
46
|
+
5. **Owner tiers** — local commits are Tier 0; push/deploy need explicit go-ahead (`git-handoff.md`).
|
|
47
|
+
|
|
48
|
+
## Anti-patterns
|
|
49
|
+
|
|
50
|
+
- Tasks touch `docker-compose.yml` but `design.md` has no Ship Surface
|
|
51
|
+
- Rollback = "revert commit" with no deploy-specific steps
|
|
52
|
+
- Secrets in spec or design artifacts
|
|
53
|
+
- Skipping CI gate in tasks when compose/terraform changed
|
|
54
|
+
|
|
55
|
+
## Related
|
|
56
|
+
|
|
57
|
+
- `docs/guide/python-platform.md` — full platform map
|
|
58
|
+
- `ai-engineering.md` — when the same feature also touches LLM/RAG/MCP paths
|
|
59
|
+
- `references/design.md` — Ship Surface template
|
|
60
|
+
- `ship-ready.md` — after Verify PASS, when owner asks to go live
|
|
@@ -75,11 +75,40 @@ Skipping Design is the default for Simple and Medium tiers.
|
|
|
75
75
|
|
|
76
76
|
## Out of Scope for This Design
|
|
77
77
|
- [explicitly excluded]
|
|
78
|
+
|
|
79
|
+
## Ship Surface
|
|
80
|
+
|
|
81
|
+
Fill when tasks touch infra paths (Docker, Compose, Terraform, Helm, CI workflows). Required fields for the gate: **Deploy unit**, **CI**, **Rollback**.
|
|
82
|
+
|
|
83
|
+
| Field | Value |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| API / contract | OpenAPI path, health route, or N/A (worker/CLI) |
|
|
86
|
+
| Migrations | Command or N/A |
|
|
87
|
+
| Env / secrets | Source of config — no secret values |
|
|
88
|
+
| Deploy unit | Service/chart/worker unit that ships |
|
|
89
|
+
| CI | Workflow or command before merge |
|
|
90
|
+
| Rollback | How to undo a bad deploy |
|
|
91
|
+
| Observability | Logs/metrics/alerts for this change |
|
|
92
|
+
|
|
93
|
+
## AI Surface
|
|
94
|
+
|
|
95
|
+
Fill when tasks touch AI paths (`prompts/`, `evals/`, MCP, embeddings, LLM/RAG code). Required fields for the gate: **Eval harness**, **Fallback / degrade**.
|
|
96
|
+
|
|
97
|
+
| Field | Value |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| Capability | chat, RAG, tool-use, embed batch, etc. |
|
|
100
|
+
| Model / provider | Model name — no API keys |
|
|
101
|
+
| Tools / MCP scope | Allowlist / deny list |
|
|
102
|
+
| Eval harness | `pytest tests/eval/` or dataset path |
|
|
103
|
+
| PII / data policy | What must not reach the model |
|
|
104
|
+
| Fallback / degrade | Behavior on timeout/failure/rate limit |
|
|
105
|
+
| Cost guard | Budget link or execution-policy note |
|
|
78
106
|
```
|
|
79
107
|
|
|
80
108
|
## Rules
|
|
81
109
|
|
|
82
110
|
- No implementation code in `design.md` — interfaces and signatures only when they clarify a contract.
|
|
111
|
+
- If tasks touch infra or AI `Files`, run `validate_ship_surface.py` after Tasks and before Verify.
|
|
83
112
|
- If the design reveals that the spec is wrong or incomplete, stop and update `spec.md` first; re-run the spec gate.
|
|
84
113
|
- When multiple defensible implementations exist for the same spec, use explicit **solution exploration** (`references/solution-exploration.md`) before Execute — not parallel ad-hoc spikes.
|
|
85
114
|
- Prefer the smallest design that satisfies the spec. Extensibility that no requirement asks for is speculation.
|
|
@@ -24,6 +24,7 @@ Orchestrate tasks from `tasks.md` and `task-graph.md`: **parallel waves with sub
|
|
|
24
24
|
1. Read this file completely.
|
|
25
25
|
2. **Discover this repo’s test command** from `package.json`, `Makefile`, CI, or README — prefer the focused command the task `Gate` names. Do not assume `npm test` if the project uses another runner.
|
|
26
26
|
3. Run `python3 .specs/guardrails/scripts/validate_tasks.py` when a formal `tasks.md` exists.
|
|
27
|
+
4. When tasks touch infra or AI `Files`, run `validate_ship_surface.py` before the first Execute edit on those paths.
|
|
27
28
|
4. If Tasks was skipped, list the atomic steps inline now. More than 5 steps or real dependencies means the Tasks phase was skipped in error — stop and create `tasks.md`.
|
|
28
29
|
5. **Plan the wave** — run `python3 .specs/guardrails/scripts/loop_plan.py [feature]` (or `loop-plan --json`) at the start of Execute and after every batch completes. It lists the next runnable tasks and marks **parallel groups** (disjoint `Files`) vs inline work.
|
|
29
30
|
6. When `loop-plan` shows a **parallel group** (2+ tasks), prepare isolated workspaces before dispatch:
|
|
@@ -46,6 +46,14 @@ Break the work into atomic tasks with real dependencies and binary done criteria
|
|
|
46
46
|
python3 .specs/guardrails/scripts/validate_tasks.py [feature]
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
**Ship / AI surfaces.** When task `Files` touch infra or AI paths (see `python-platform` preset globs), ensure `design.md` includes **Ship Surface** and/or **AI Surface** before approval. Run:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python3 .specs/guardrails/scripts/validate_ship_surface.py [feature]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Load `python-devops.md` or `ai-engineering.md` sister skills when those paths apply.
|
|
56
|
+
|
|
49
57
|
**Authoring vs gate.** `validate_tasks.py` already enforces REQ coverage and `Tests`/`Gate` fields. The matrix and Gate Check Commands sections are an owner/verifier checklist (judgment) until a future form gate — do not skip them when Tasks ran.
|
|
50
58
|
|
|
51
59
|
## Gate
|
|
@@ -110,12 +110,15 @@ The completion gate searches for `file:line` (for example `test/routes/login.tes
|
|
|
110
110
|
## Gate
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
+
python3 .specs/guardrails/scripts/validate_ship_surface.py [feature] # when infra/AI paths in tasks
|
|
113
114
|
python3 .specs/guardrails/scripts/validate_state.py .specs/features/[feature]
|
|
114
115
|
python3 .specs/guardrails/scripts/validate_state.py [feature]
|
|
115
116
|
python3 .specs/guardrails/scripts/validate_state.py # single-feature projects
|
|
116
117
|
npx @luizsantiago/spec-guardrails quality-checks # when quality.checks is configured
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
**Ship / AI surfaces (python-platform).** When `design.md` documents Ship or AI Surface, cite deploy/eval evidence in the report (CI workflow path, eval command output, or `file:line` from `tests/eval/`). `validate_ship_surface.py` checks design structure only — not eval quality or deploy safety.
|
|
121
|
+
|
|
119
122
|
Checks that the report exists, the verdict is exactly PASS in the **preamble** (before the first `##` section) or under a dedicated `## Verdict` / `## Result` / `## Status` heading, every spec requirement ID shares a line with test `file:line` evidence, and no task remains open. A `- Verdict: PASS` buried under Discrimination Sensor or Coverage does not count. Preamble and `## Verdict` must not disagree. Evidence inside fenced samples or HTML comments does not count. `PASS` with any surviving mutant on a sensor/mutant line fails. `PASS` with open `Gaps` bullets or Security Review `Result: fail` fails. On Medium+ features (`design.md` with content, 4+ tasks, or 2+ phases) a discrimination-sensor **outcome** is **blocking** — the section heading alone is not enough — and a Medium+ `PASS` requires at least one `killed` mutant in the sensor focus (`injected` alone, or `killed` only under Gaps, is not enough). Below Medium+ a missing outcome is a warning (`--strict` still promotes warnings). Non-zero exit means the feature is not done.
|
|
120
123
|
|
|
121
124
|
The gate cannot judge whether a cited test actually asserts the criterion. Run `quality-checks` when `.specs/config.yaml` lists project commands (`npm test`, …) and cite the passing output in the validation report. That judgment is still the verifier's; a green gate with a weak assertion is still a FAIL in the report.
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
schema: spec-driven
|
|
6
6
|
|
|
7
|
-
# Extend a built-in preset (default | node-ts | python):
|
|
8
|
-
# extends:
|
|
7
|
+
# Extend a built-in preset (default | node-ts | python | python-platform):
|
|
8
|
+
# extends: python-platform
|
|
9
9
|
|
|
10
10
|
context: |
|
|
11
11
|
Tech stack: (fill in)
|
|
@@ -101,6 +101,17 @@ quality:
|
|
|
101
101
|
checks:
|
|
102
102
|
- npm test
|
|
103
103
|
|
|
104
|
+
# Ship / AI surface globs (optional — used by validate_ship_surface.py)
|
|
105
|
+
# ship_surface:
|
|
106
|
+
# infra_globs:
|
|
107
|
+
# - "**/Dockerfile"
|
|
108
|
+
# - "**/docker-compose*.yml"
|
|
109
|
+
# - "**/.github/workflows/**"
|
|
110
|
+
# ai_globs:
|
|
111
|
+
# - "**/prompts/**"
|
|
112
|
+
# - "**/evals/**"
|
|
113
|
+
# - "**/tests/eval/**"
|
|
114
|
+
|
|
104
115
|
# Commit policy (optional — used with check-commit --staged)
|
|
105
116
|
commit:
|
|
106
117
|
max_staged_lines: 500
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Python platform preset — backend + DevOps + AI engineering
|
|
2
|
+
# See docs/guide/python-platform.md for capabilities and limitations.
|
|
3
|
+
schema: spec-driven
|
|
4
|
+
|
|
5
|
+
branch_prefix: feat
|
|
6
|
+
|
|
7
|
+
extends: python
|
|
8
|
+
|
|
9
|
+
context: |
|
|
10
|
+
Stack: Python 3.10+ (framework-agnostic — FastAPI, Django, workers, or plain modules)
|
|
11
|
+
Test command: pytest
|
|
12
|
+
Lint: ruff check .
|
|
13
|
+
Branch prefix: feat
|
|
14
|
+
DevOps: docker compose config, terraform validate, helm template (enable in quality.checks)
|
|
15
|
+
AI: run eval suite when AI Surface is present in design.md
|
|
16
|
+
Ship/AI surfaces: document in design.md; gate validate_ship_surface.py enforces structure
|
|
17
|
+
|
|
18
|
+
rules:
|
|
19
|
+
specify:
|
|
20
|
+
- Prefer EARS acceptance criteria
|
|
21
|
+
- Mark unknowns with [NEEDS CLARIFICATION: question]
|
|
22
|
+
- For model behavior, write testable criteria (golden set, bounds) — not only HTTP 200
|
|
23
|
+
tasks:
|
|
24
|
+
- Every REQ must appear in the Test Coverage Matrix
|
|
25
|
+
- Gate Check Commands must include pytest
|
|
26
|
+
- When Files touch infra or AI paths, design.md must include Ship Surface and/or AI Surface
|
|
27
|
+
implement:
|
|
28
|
+
- Run pytest before each commit
|
|
29
|
+
- Run infra/AI quality checks from quality.checks when the task Gate references them
|
|
30
|
+
verify:
|
|
31
|
+
- Evidence must cite test file:line paths under tests/
|
|
32
|
+
- When AI Surface exists, validation.md must cite eval evidence (file:line or command output)
|
|
33
|
+
- Run validate_ship_surface before validate_state when tasks touch infra or AI paths
|
|
34
|
+
|
|
35
|
+
elicitation:
|
|
36
|
+
max_questions_per_turn: 5
|
|
37
|
+
kickoff_discovery_paths:
|
|
38
|
+
- prd.md
|
|
39
|
+
- docs/brief.md
|
|
40
|
+
- docs/prd.md
|
|
41
|
+
- .specs/project/kickoff.md
|
|
42
|
+
- docs/ai-brief.md
|
|
43
|
+
|
|
44
|
+
memory:
|
|
45
|
+
retrieval:
|
|
46
|
+
# Semantic search is optional — enable after `memory-index embed` when the team wants it.
|
|
47
|
+
semantic: false
|
|
48
|
+
provider: none
|
|
49
|
+
model: text-embedding-3-small
|
|
50
|
+
|
|
51
|
+
quality:
|
|
52
|
+
checks:
|
|
53
|
+
- pytest
|
|
54
|
+
- ruff check .
|
|
55
|
+
# Uncomment the checks your repo uses:
|
|
56
|
+
# - docker compose config --quiet
|
|
57
|
+
# - terraform -chdir=infra validate
|
|
58
|
+
# - helm template release ./charts/app
|
|
59
|
+
# - pytest tests/eval -m "not live"
|
|
60
|
+
|
|
61
|
+
ship_surface:
|
|
62
|
+
infra_globs:
|
|
63
|
+
- "**/Dockerfile"
|
|
64
|
+
- "**/docker-compose*.yml"
|
|
65
|
+
- "**/docker-compose*.yaml"
|
|
66
|
+
- "**/terraform/**"
|
|
67
|
+
- "**/*.tf"
|
|
68
|
+
- "**/charts/**"
|
|
69
|
+
- "**/helm/**"
|
|
70
|
+
- "**/.github/workflows/**"
|
|
71
|
+
ai_globs:
|
|
72
|
+
- "**/prompts/**"
|
|
73
|
+
- "**/mcp/**"
|
|
74
|
+
- "**/evals/**"
|
|
75
|
+
- "**/tests/eval/**"
|
|
76
|
+
- "**/*embed*"
|
|
77
|
+
- "**/*llm*"
|
|
78
|
+
- "**/*rag*"
|