@luizsantiago/spec-guardrails 4.5.2 → 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.5.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) · [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
@@ -25,6 +25,10 @@ import {
25
25
  savePolicyState,
26
26
  } from "./lib/execution-policy.js";
27
27
  import { featureInit } from "./lib/feature.js";
28
+ import {
29
+ featureOverview,
30
+ formatFeatureOverview,
31
+ } from "./lib/feature-overview.js";
28
32
  import { featureStatus, formatFeatureStatus } from "./lib/feature-status.js";
29
33
  import { GATE_COMMANDS, AUX_COMMANDS, runGate, runGuardrailsScript } from "./lib/gates.js";
30
34
  import { install } from "./lib/install.js";
@@ -105,6 +109,9 @@ Commands:
105
109
  [--json] Machine-readable output
106
110
  feature-status [feature] Artifact checklist + next step for a feature
107
111
  [--json] Machine-readable output
112
+ feature-overview [feature] REQ → task → evidence dashboard (markdown)
113
+ [--write] Save .specs/features/<feature>/overview.md
114
+ [--json] Machine-readable output (no markdown body)
108
115
  phase-context <phase> Print .specs/config.yaml context + rules for a phase
109
116
  doctor [path] Audit guardrails readiness (score + next actions)
110
117
  [--json] Machine-readable output
@@ -176,6 +183,7 @@ Commands:
176
183
  loop-plan [tasks.md|feature] Next Execute wave — parallel groups + sub-agent hints
177
184
  [--json] Machine-readable plan for agents
178
185
  validate-traceability [feature] REQ → tasks → validation coverage chain
186
+ validate-ship-surface [feature] Ship Surface + AI Surface when infra/AI paths in tasks
179
187
  validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
180
188
  validate-req-analysis [brief.md] Requirements brief gate before /specify (/elicit)
181
189
  validate-state [feature] Completion gate before declaring a feature done
@@ -1079,6 +1087,33 @@ if (command === "--version" || command === "-v" || command === "version") {
1079
1087
  console.error(`❌ ${err.message}`);
1080
1088
  process.exit(1);
1081
1089
  }
1090
+ } else if (command === "feature-overview") {
1091
+ try {
1092
+ let json = false;
1093
+ let write = false;
1094
+ const positional = [];
1095
+ for (const arg of args) {
1096
+ if (arg === "--json") {
1097
+ json = true;
1098
+ } else if (arg === "--write") {
1099
+ write = true;
1100
+ } else {
1101
+ positional.push(arg);
1102
+ }
1103
+ }
1104
+ const overview = await featureOverview(positional[0], { write });
1105
+ if (json) {
1106
+ console.log(JSON.stringify(overview, null, 2));
1107
+ } else {
1108
+ process.stdout.write(formatFeatureOverview(overview));
1109
+ if (overview.writtenTo) {
1110
+ process.stderr.write(`\nWrote ${overview.writtenTo}\n`);
1111
+ }
1112
+ }
1113
+ } catch (err) {
1114
+ console.error(`❌ ${err.message}`);
1115
+ process.exit(1);
1116
+ }
1082
1117
  } else if (AUX_COMMANDS.includes(command)) {
1083
1118
  try {
1084
1119
  const code = await runGuardrailsScript(command, args);
@@ -28,7 +28,7 @@ When planning architecture, specs, or multi-step features, read the hub first:
28
28
  Deterministic gates (\`python3\`, non-zero exit means STOP):
29
29
 
30
30
  - Scripts in \`.specs/guardrails/scripts/\` — the **agent** runs them at phase boundaries (see hub).
31
- - Humans: \`install\` once; optional \`feature-init\`, \`project-init\`, \`doctor\`, \`classify-change\`, \`feature-status\`.
31
+ - Humans: \`install\` once; optional \`feature-init\`, \`project-init\`, \`doctor\`, \`classify-change\`, \`feature-status\`, \`feature-overview\`.
32
32
  - Full CLI: \`npx @luizsantiago/spec-guardrails --help\`
33
33
  - Onboarding: \`.specs/GETTING_STARTED.md\`
34
34
 
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" },
@@ -0,0 +1,332 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readFileSafe } from "./fs-utils.js";
5
+ import { featureStatus } from "./feature-status.js";
6
+ import { featureDir, resolveFeatureId } from "./specs-utils.js";
7
+
8
+ const REQ_ID = /\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b/g;
9
+ const TASK_HEADING = /^#{2,6}\s*(T\d+)[:\s]+(.+)$/gim;
10
+ const TASK_FIELD =
11
+ /^\s*[-*]?\s*\*{0,2}([A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(.+?)\s*$/gim;
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
+ }
63
+
64
+ /**
65
+ * @param {string} text
66
+ * @returns {string[]}
67
+ */
68
+ function uniqueReqIds(text) {
69
+ return [...new Set(text.match(REQ_ID) ?? [])];
70
+ }
71
+
72
+ /**
73
+ * @param {string} tasksText
74
+ * @returns {Array<{ id: string, title: string, requirement: string | null, complete: boolean }>}
75
+ */
76
+ function parseTasks(tasksText) {
77
+ const headings = [...tasksText.matchAll(TASK_HEADING)];
78
+ /** @type {Array<{ id: string, title: string, requirement: string | null, complete: boolean }>} */
79
+ const tasks = [];
80
+
81
+ for (let i = 0; i < headings.length; i += 1) {
82
+ const start = headings[i].index ?? 0;
83
+ const end = headings[i + 1]?.index ?? tasksText.length;
84
+ const block = tasksText.slice(start, end);
85
+ const id = headings[i][1];
86
+ const title = headings[i][2].trim();
87
+ let requirement = null;
88
+ for (const match of block.matchAll(TASK_FIELD)) {
89
+ if (match[1].trim().toLowerCase() === "requirement") {
90
+ requirement = match[2].trim();
91
+ break;
92
+ }
93
+ }
94
+ const complete = /-\s*\[x\]\s*complete\b/i.test(block);
95
+ tasks.push({ id, title, requirement, complete });
96
+ }
97
+ return tasks;
98
+ }
99
+
100
+ /**
101
+ * @param {string} validationText
102
+ * @param {string} reqId
103
+ * @returns {string | null}
104
+ */
105
+ function evidenceForReq(validationText, reqId) {
106
+ for (const line of validationText.split(/\r?\n/)) {
107
+ if (!line.includes(reqId)) {
108
+ continue;
109
+ }
110
+ const hit = line.match(EVIDENCE);
111
+ if (hit?.[0]) {
112
+ return hit[0];
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * @param {string} specText
120
+ * @returns {string | null}
121
+ */
122
+ function summarizeGoal(specText) {
123
+ const title = specText.match(/^#\s+(.+)$/m)?.[1]?.trim();
124
+ if (title && !/^spec/i.test(title)) {
125
+ return title;
126
+ }
127
+ const firstReq = specText.match(
128
+ /^###\s+([A-Z][A-Z0-9]{1,9}-\d{2,4})\b[^\n]*\n+([^\n#]+)/m,
129
+ );
130
+ if (firstReq) {
131
+ return `${firstReq[1]} — ${firstReq[2].trim()}`;
132
+ }
133
+ return title ?? null;
134
+ }
135
+
136
+ /**
137
+ * @param {string} [featureArg]
138
+ * @param {{ cwd?: string }} [options]
139
+ */
140
+ export async function buildFeatureOverview(featureArg, options = {}) {
141
+ const cwd = options.cwd ?? process.cwd();
142
+ const featureId = await resolveFeatureId(featureArg, cwd);
143
+ const status = await featureStatus(featureId, { cwd });
144
+ const dir = featureDir(featureId, cwd);
145
+
146
+ let specText = "";
147
+ let tasksText = "";
148
+ let validationText = "";
149
+ let designText = "";
150
+
151
+ try {
152
+ specText = await readFileSafe(path.join(dir, "spec.md"));
153
+ } catch {
154
+ // optional until Specify
155
+ }
156
+ try {
157
+ designText = await readFileSafe(path.join(dir, "design.md"));
158
+ } catch {
159
+ // optional until Design
160
+ }
161
+ try {
162
+ tasksText = await readFileSafe(path.join(dir, "tasks.md"));
163
+ } catch {
164
+ // optional until Tasks
165
+ }
166
+ try {
167
+ validationText = await readFileSafe(path.join(dir, "validation.md"));
168
+ } catch {
169
+ // optional until Verify
170
+ }
171
+
172
+ const reqIds = uniqueReqIds(specText);
173
+ const tasks = tasksText ? parseTasks(tasksText) : [];
174
+ const reqToTasks = new Map();
175
+ for (const task of tasks) {
176
+ if (!task.requirement) {
177
+ continue;
178
+ }
179
+ for (const reqId of uniqueReqIds(task.requirement)) {
180
+ const list = reqToTasks.get(reqId) ?? [];
181
+ list.push(task.id);
182
+ reqToTasks.set(reqId, list);
183
+ }
184
+ }
185
+
186
+ const traceability = reqIds.map((reqId) => ({
187
+ reqId,
188
+ tasks: reqToTasks.get(reqId) ?? [],
189
+ evidence: validationText ? evidenceForReq(validationText, reqId) : null,
190
+ }));
191
+
192
+ const shipSurface = designText
193
+ ? parseSurfaceFields(extractDesignSection(designText, SHIP_SECTION))
194
+ : {};
195
+ const aiSurface = designText
196
+ ? parseSurfaceFields(extractDesignSection(designText, AI_SECTION))
197
+ : {};
198
+
199
+ return {
200
+ featureId,
201
+ generatedAt: new Date().toISOString(),
202
+ goal: specText ? summarizeGoal(specText) : null,
203
+ status,
204
+ tasks,
205
+ traceability,
206
+ shipSurface,
207
+ aiSurface,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * @param {Awaited<ReturnType<typeof buildFeatureOverview>>} overview
213
+ * @returns {string}
214
+ */
215
+ export function formatFeatureOverview(overview) {
216
+ const { status } = overview;
217
+ const artifactRows = Object.entries(status.artifacts)
218
+ .map(([name, present]) => `| ${name} | ${present ? "present" : "missing"} |`)
219
+ .join("\n");
220
+
221
+ const taskRows =
222
+ overview.tasks.length > 0
223
+ ? overview.tasks
224
+ .map(
225
+ (task) =>
226
+ `| ${task.id} | ${task.title} | ${task.requirement ?? "—"} | ${task.complete ? "done" : "open"} |`,
227
+ )
228
+ .join("\n")
229
+ : "| — | — | — | — |";
230
+
231
+ const traceRows =
232
+ overview.traceability.length > 0
233
+ ? overview.traceability
234
+ .map((row) => {
235
+ const tasks =
236
+ row.tasks.length > 0 ? row.tasks.join(", ") : "—";
237
+ return `| ${row.reqId} | ${tasks} | ${row.evidence ?? "—"} |`;
238
+ })
239
+ .join("\n")
240
+ : "| — | — | — |";
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
+
256
+ const taskSummary = status.tasks
257
+ ? `${status.tasks.complete}/${status.tasks.total} complete (${status.tasks.open} open)`
258
+ : "—";
259
+
260
+ return `# Feature overview: ${overview.featureId}
261
+
262
+ > Generated ${overview.generatedAt}. Refresh with \`feature-overview ${overview.featureId} --write\`.
263
+
264
+ ## Summary
265
+
266
+ | Field | Value |
267
+ | --- | --- |
268
+ | Goal | ${overview.goal ?? "—"} |
269
+ | Phase | ${status.phase ?? "—"} |
270
+ | Branch | ${status.branch ?? "—"} |
271
+ | Tasks | ${taskSummary} |
272
+ | Validation | ${status.verdict ?? "—"} |
273
+ | Next | ${status.next} |
274
+
275
+ ## Artifacts
276
+
277
+ | Artifact | Status |
278
+ | --- | --- |
279
+ ${artifactRows}
280
+
281
+ ## Tasks
282
+
283
+ | Task | Title | Requirement | Status |
284
+ | --- | --- | --- | --- |
285
+ ${taskRows}
286
+
287
+ ## Traceability (REQ → task → evidence)
288
+
289
+ | REQ | Task(s) | Test evidence |
290
+ | --- | --- | --- |
291
+ ${traceRows}
292
+
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\`._
310
+ `;
311
+ }
312
+
313
+ /**
314
+ * @param {string} [featureArg]
315
+ * @param {{ cwd?: string; write?: boolean }} [options]
316
+ */
317
+ export async function featureOverview(featureArg, options = {}) {
318
+ const cwd = options.cwd ?? process.cwd();
319
+ const overview = await buildFeatureOverview(featureArg, { cwd });
320
+ const markdown = formatFeatureOverview(overview);
321
+
322
+ if (options.write) {
323
+ const outPath = path.join(
324
+ featureDir(overview.featureId, cwd),
325
+ "overview.md",
326
+ );
327
+ await fs.writeFile(outPath, markdown, "utf8");
328
+ overview.writtenTo = outPath;
329
+ }
330
+
331
+ return overview;
332
+ }
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.5.2",
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": {
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "guardrails": "node index.js",
14
14
  "test": "npm run test:node && npm run test:gates",
15
- "test:node": "node --test test/install.test.js test/test_platform_detect.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js test/test_solution_exploration.test.js test/test_memory_doctor.test.js test/test_cursor_hooks_cleanup.test.js test/test_sandbox_policy.test.js test/test_req_analysis.test.js",
15
+ "test:node": "node --test test/install.test.js test/test_platform_detect.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_feature_overview.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js test/test_solution_exploration.test.js test/test_memory_doctor.test.js test/test_cursor_hooks_cleanup.test.js test/test_sandbox_policy.test.js test/test_req_analysis.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -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