@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 +8 -3
- package/index.js +35 -0
- package/lib/agent-contract.js +1 -1
- package/lib/brownfield.js +86 -2
- package/lib/classify-change.js +24 -1
- package/lib/constants.js +9 -0
- package/lib/feature-overview.js +332 -0
- package/lib/gates.js +1 -0
- package/package.json +2 -2
- 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 +6 -2
- package/skills/ai-engineering.md +68 -0
- package/skills/python-devops.md +60 -0
- package/skills/references/converge.md +6 -4
- package/skills/references/design.md +29 -0
- package/skills/references/implement.md +7 -4
- package/skills/references/sub-agents.md +17 -0
- package/skills/references/tasks.md +8 -0
- package/skills/references/validate.md +3 -0
- package/skills/security-review.md +16 -0
- package/skills/task-graph-engineering.md +1 -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
|
@@ -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);
|
package/lib/agent-contract.js
CHANGED
|
@@ -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<
|
|
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" },
|
|
@@ -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.
|
|
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
|