@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 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.6.x**
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
- Read more: [Quick start](docs/guide/Quick-start.md) · [Tutorials](docs/guide/tutorials/README.md) · [Platform parity](docs/guide/Platform-parity.md) · [CHANGELOG](docs/CHANGELOG.md)
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<{ stack: string, testCommand: string, lintCommand?: string, preset?: string, roots: string[] }>}
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
 
@@ -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" },
@@ -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
- _Evidence rows populate after \`validation.md\` exists. Structural gaps are caught by \`validate-traceability\`._
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.6.0",
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` from the working set → QA → write `## QA`. Never load both together. Neither section is enforced by `validate_state.py` (verifier judgment).
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: node-ts
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*"