@luizsantiago/spec-guardrails 4.0.0 → 4.1.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 +5 -3
- package/index.js +40 -1
- package/lib/classify-change.js +18 -0
- package/lib/constants.js +2 -0
- package/lib/gates.js +2 -0
- package/package.json +1 -1
- package/scripts/req_context.py +145 -0
- package/scripts/validate_req_analysis.py +191 -0
- package/skills/agent-architecture.md +1 -1
- package/skills/references/elicitation.md +13 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
Spec Guardrails is a process kit that installs phase guides, persistent project memory, and optional structural checks into your repository. Teams keep ownership of requirements and approval gates; agents follow a repeatable path from written intent to verified delivery.
|
|
9
9
|
|
|
10
|
-
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.
|
|
10
|
+
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.1.x**
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -94,10 +94,11 @@ Enable when the work warrants them — most teams start with the core loop only.
|
|
|
94
94
|
| Episodic memory | Session notes → lessons for future runs |
|
|
95
95
|
| Code index | Lightweight brownfield file and symbol map |
|
|
96
96
|
| Solution exploration | Compare implementation options before committing |
|
|
97
|
-
| Sandbox
|
|
97
|
+
| Sandbox policy | Warn or block destructive shell commands (Cursor hook) |
|
|
98
|
+
| Execution policy | Path allowlists, budgets, read/write/delete effects |
|
|
98
99
|
| Semantic retrieval | Search by meaning — off by default |
|
|
99
100
|
|
|
100
|
-
Guides: [Memory](docs/guide/Memory.md) · [
|
|
101
|
+
Guides: [Memory](docs/guide/Memory.md) · [Cursor hooks and sandbox](docs/guide/Cursor-hooks-and-sandbox.md) · [Brownfield context](docs/guide/brownfield-context.md)
|
|
101
102
|
|
|
102
103
|
---
|
|
103
104
|
|
|
@@ -123,6 +124,7 @@ Start with the guide that matches your question; each page links deeper where ne
|
|
|
123
124
|
| --- | --- | --- |
|
|
124
125
|
| Orientation | [Overview](docs/guide/Overview.md) | [Concepts](docs/guide/concepts.md) |
|
|
125
126
|
| First session | [Quick start](docs/guide/Quick-start.md) | [Agent commands](docs/guide/agent-commands.md) |
|
|
127
|
+
| Cursor IDE protection | [Cursor hooks and sandbox](docs/guide/Cursor-hooks-and-sandbox.md) | [Guarantees matrix](docs/guide/Guarantees-matrix.md) |
|
|
126
128
|
| Process model | [How it works](docs/guide/How-it-works.md) | [Loop patterns](docs/guide/loop-patterns.md) |
|
|
127
129
|
| Enforcement | [Gates](docs/guide/gates.md) | [Gates and guarantees](docs/guide/Gates-and-guarantees.md) |
|
|
128
130
|
| Long-running projects | [Memory](docs/guide/Memory.md) | [Brownfield context](docs/guide/brownfield-context.md) |
|
package/index.js
CHANGED
|
@@ -87,6 +87,11 @@ Commands:
|
|
|
87
87
|
req-analysis discover List local kickoff sources (prd.md, kickoff.md, …)
|
|
88
88
|
req-analysis promote Print next steps after brief approval
|
|
89
89
|
[--scope project|feature] Match the brief scope
|
|
90
|
+
req-analysis validate [brief.md] Gate: approved requirements brief before /specify
|
|
91
|
+
req-analysis context Assemble kickoff + brief context for Specify
|
|
92
|
+
[--scope project|feature] Scope (default: project)
|
|
93
|
+
[--slug <feature-slug>] Feature slug when scope=feature
|
|
94
|
+
[--json] Machine-readable output
|
|
90
95
|
archive-feature [feature] Fold verified feature into ROADMAP + domain spec; reset STATE
|
|
91
96
|
[--domain <slug>] Domain folder under .specs/domains/ (default: feature slug)
|
|
92
97
|
[--skip-verify] Skip validate-state (tests / recovery only)
|
|
@@ -168,6 +173,7 @@ Commands:
|
|
|
168
173
|
[--json] Machine-readable plan for agents
|
|
169
174
|
validate-traceability [feature] REQ → tasks → validation coverage chain
|
|
170
175
|
validate-quick [quick-folder] Quick-mode TASK.md / SUMMARY.md structural gate
|
|
176
|
+
validate-req-analysis [brief.md] Requirements brief gate before /specify (/elicit)
|
|
171
177
|
validate-state [feature] Completion gate before declaring a feature done
|
|
172
178
|
check-commit --message "<msg>" Conventional Commits gate
|
|
173
179
|
lessons <add|list|penalize|prune|promote|graduate|status> Lessons engine
|
|
@@ -953,8 +959,41 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
953
959
|
}
|
|
954
960
|
}
|
|
955
961
|
console.log(formatPromoteMessage({ scope, description }));
|
|
962
|
+
} else if (sub === "validate") {
|
|
963
|
+
const briefPath = rest.find((arg) => !arg.startsWith("--"));
|
|
964
|
+
const code = await runGate("validate-req-analysis", briefPath ? [briefPath] : []);
|
|
965
|
+
process.exit(code);
|
|
966
|
+
} else if (sub === "context") {
|
|
967
|
+
let scope = "project";
|
|
968
|
+
let slug = "";
|
|
969
|
+
let json = false;
|
|
970
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
971
|
+
const arg = rest[i];
|
|
972
|
+
if (arg === "--json") {
|
|
973
|
+
json = true;
|
|
974
|
+
} else if (arg === "--scope" && rest[i + 1]) {
|
|
975
|
+
scope = rest[i + 1];
|
|
976
|
+
i += 1;
|
|
977
|
+
} else if (arg.startsWith("--scope=")) {
|
|
978
|
+
scope = arg.slice("--scope=".length);
|
|
979
|
+
} else if (arg === "--slug" && rest[i + 1]) {
|
|
980
|
+
slug = rest[i + 1];
|
|
981
|
+
i += 1;
|
|
982
|
+
} else if (arg.startsWith("--slug=")) {
|
|
983
|
+
slug = arg.slice("--slug=".length);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const scriptArgs = ["--scope", scope];
|
|
987
|
+
if (slug) {
|
|
988
|
+
scriptArgs.push("--slug", slug);
|
|
989
|
+
}
|
|
990
|
+
if (json) {
|
|
991
|
+
scriptArgs.push("--json");
|
|
992
|
+
}
|
|
993
|
+
const code = await runGuardrailsScript("req-context", scriptArgs);
|
|
994
|
+
process.exit(code);
|
|
956
995
|
} else {
|
|
957
|
-
throw new Error("Usage: req-analysis init | discover | promote");
|
|
996
|
+
throw new Error("Usage: req-analysis init | discover | promote | validate | context");
|
|
958
997
|
}
|
|
959
998
|
} catch (err) {
|
|
960
999
|
console.error(`❌ ${err.message}`);
|
package/lib/classify-change.js
CHANGED
|
@@ -26,12 +26,21 @@ const DEPENDENCY_SIGNALS = [
|
|
|
26
26
|
/\badd\s+package\b/i,
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
const VAGUE_SIGNALS = [
|
|
30
|
+
/\bimprove\b/i,
|
|
31
|
+
/\bmake\s+(?:it|this|things?)\s+better\b/i,
|
|
32
|
+
/\badd\s+(?:a\s+)?(?:interface|page|screen|ui|dashboard)\b/i,
|
|
33
|
+
/\b(?:somehow|something|stuff)\b/i,
|
|
34
|
+
/\bwithout\s+(?:criteria|details|spec)\b/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
29
37
|
/**
|
|
30
38
|
* @param {{ description?: string, files?: string[] }} input
|
|
31
39
|
* @returns {{
|
|
32
40
|
* tier: "quick" | "simple" | "medium" | "complex",
|
|
33
41
|
* reasons: string[],
|
|
34
42
|
* next: string,
|
|
43
|
+
* suggestElicit: boolean,
|
|
35
44
|
* fileCount: number,
|
|
36
45
|
* }}
|
|
37
46
|
*/
|
|
@@ -45,6 +54,7 @@ export function classifyChange(input = {}) {
|
|
|
45
54
|
const hasComplex = COMPLEX_SIGNALS.some((re) => re.test(haystack));
|
|
46
55
|
const hasMedium = MEDIUM_SIGNALS.some((re) => re.test(haystack));
|
|
47
56
|
const hasNewDep = DEPENDENCY_SIGNALS.some((re) => re.test(haystack));
|
|
57
|
+
const hasVague = VAGUE_SIGNALS.some((re) => re.test(haystack));
|
|
48
58
|
|
|
49
59
|
if (hasComplex) {
|
|
50
60
|
reasons.push("sensitive surface or architecture signal in description/paths");
|
|
@@ -103,10 +113,15 @@ export function classifyChange(input = {}) {
|
|
|
103
113
|
'feature-init → full pipeline (+ /discuss, /plan; optional AppSec/QA on verify)',
|
|
104
114
|
};
|
|
105
115
|
|
|
116
|
+
if (hasVague) {
|
|
117
|
+
reasons.push("vague delivery language — consider /elicit before /specify");
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
return {
|
|
107
121
|
tier,
|
|
108
122
|
reasons,
|
|
109
123
|
next: nextByTier[tier],
|
|
124
|
+
suggestElicit: hasVague && !hasComplex,
|
|
110
125
|
fileCount,
|
|
111
126
|
};
|
|
112
127
|
}
|
|
@@ -123,5 +138,8 @@ export function formatClassifyChange(result) {
|
|
|
123
138
|
...result.reasons.map((r) => ` - ${r}`),
|
|
124
139
|
`Next: ${result.next}`,
|
|
125
140
|
];
|
|
141
|
+
if (result.suggestElicit) {
|
|
142
|
+
lines.push("Suggest: /elicit (structured Q&A) or /specify if scope is already clear");
|
|
143
|
+
}
|
|
126
144
|
return `${lines.join("\n")}\n`;
|
|
127
145
|
}
|
package/lib/constants.js
CHANGED
|
@@ -118,6 +118,8 @@ export const SCRIPT_ASSETS = [
|
|
|
118
118
|
{ file: "_memory_embed.py", remotePath: "scripts/_memory_embed.py" },
|
|
119
119
|
{ file: "episodes.py", remotePath: "scripts/episodes.py" },
|
|
120
120
|
{ file: "code_index.py", remotePath: "scripts/code_index.py" },
|
|
121
|
+
{ file: "validate_req_analysis.py", remotePath: "scripts/validate_req_analysis.py" },
|
|
122
|
+
{ file: "req_context.py", remotePath: "scripts/req_context.py" },
|
|
121
123
|
];
|
|
122
124
|
|
|
123
125
|
/** @type {{ file: string, remotePath: string }[]} */
|
package/lib/gates.js
CHANGED
|
@@ -34,6 +34,7 @@ const GATE_SCRIPTS = {
|
|
|
34
34
|
"validate-state": "validate_state.py",
|
|
35
35
|
"validate-traceability": "validate_traceability.py",
|
|
36
36
|
"validate-quick": "validate_quick.py",
|
|
37
|
+
"validate-req-analysis": "validate_req_analysis.py",
|
|
37
38
|
"analyze-artifacts": "analyze_artifacts.py",
|
|
38
39
|
"check-commit": "check_commit.py",
|
|
39
40
|
lessons: "lessons.py",
|
|
@@ -47,6 +48,7 @@ const AUX_SCRIPTS = {
|
|
|
47
48
|
"memory-retrieve": "memory_retrieve.py",
|
|
48
49
|
episodes: "episodes.py",
|
|
49
50
|
"code-index": "code_index.py",
|
|
51
|
+
"req-context": "req_context.py",
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Spec-driven process kit for AI coding agents: write goals in .specs/, break into tasks, implement in waves, verify with proof. Process mode (Node) or Brakes mode (Node + Python gates). Works with Cursor, Claude, Copilot, and Codex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assemble kickoff + requirements brief context for the Specify phase.
|
|
3
|
+
|
|
4
|
+
python3 req_context.py --scope project
|
|
5
|
+
python3 req_context.py --scope feature --slug settings-page
|
|
6
|
+
python3 req_context.py --json
|
|
7
|
+
|
|
8
|
+
Read-only: lists discovered sources and brief paths; does not mutate files.
|
|
9
|
+
Exit codes: 0 success, 2 usage error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
PROJECT_DIR = Path(".specs/project")
|
|
20
|
+
KICKOFF_PATHS = (
|
|
21
|
+
Path("prd.md"),
|
|
22
|
+
Path("docs/brief.md"),
|
|
23
|
+
Path("docs/prd.md"),
|
|
24
|
+
PROJECT_DIR / "kickoff.md",
|
|
25
|
+
)
|
|
26
|
+
PROJECT_BRIEF = PROJECT_DIR / "requirements-brief.md"
|
|
27
|
+
FEATURE_BRIEFS = PROJECT_DIR / "feature-briefs"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def discover_sources(root: Path) -> list[dict[str, str | bool]]:
|
|
31
|
+
entries: list[dict[str, str | bool]] = []
|
|
32
|
+
seen: set[str] = set()
|
|
33
|
+
for rel in KICKOFF_PATHS:
|
|
34
|
+
key = rel.as_posix()
|
|
35
|
+
if key in seen:
|
|
36
|
+
continue
|
|
37
|
+
seen.add(key)
|
|
38
|
+
path = root / rel
|
|
39
|
+
entries.append({"path": key, "exists": path.is_file()})
|
|
40
|
+
return entries
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_brief(root: Path, scope: str, slug: str | None) -> Path | None:
|
|
44
|
+
if scope == "project":
|
|
45
|
+
candidate = root / PROJECT_BRIEF
|
|
46
|
+
return candidate if candidate.is_file() else None
|
|
47
|
+
|
|
48
|
+
if not slug:
|
|
49
|
+
briefs_root = root / FEATURE_BRIEFS
|
|
50
|
+
if not briefs_root.is_dir():
|
|
51
|
+
return None
|
|
52
|
+
matches = sorted(briefs_root.rglob("requirements-brief.md"))
|
|
53
|
+
return matches[0] if len(matches) == 1 else None
|
|
54
|
+
|
|
55
|
+
candidate = root / FEATURE_BRIEFS / slug / "requirements-brief.md"
|
|
56
|
+
return candidate if candidate.is_file() else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def excerpt(path: Path, max_chars: int = 1200) -> str:
|
|
60
|
+
text = path.read_text(encoding="utf-8")
|
|
61
|
+
trimmed = text.strip()
|
|
62
|
+
if len(trimmed) <= max_chars:
|
|
63
|
+
return trimmed
|
|
64
|
+
return f"{trimmed[: max_chars - 3].rstrip()}..."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_context(root: Path, scope: str, slug: str | None) -> dict:
|
|
68
|
+
sources = discover_sources(root)
|
|
69
|
+
brief = resolve_brief(root, scope, slug)
|
|
70
|
+
payload: dict = {
|
|
71
|
+
"scope": scope,
|
|
72
|
+
"sources": sources,
|
|
73
|
+
"brief_path": brief.relative_to(root).as_posix() if brief else None,
|
|
74
|
+
}
|
|
75
|
+
if brief:
|
|
76
|
+
payload["brief_excerpt"] = excerpt(brief)
|
|
77
|
+
if scope == "feature" and slug:
|
|
78
|
+
payload["slug"] = slug
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_markdown(ctx: dict) -> str:
|
|
83
|
+
lines = [
|
|
84
|
+
"# Requirements context",
|
|
85
|
+
"",
|
|
86
|
+
f"Scope: **{ctx['scope']}**",
|
|
87
|
+
"",
|
|
88
|
+
"## Kickoff sources",
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
for entry in ctx["sources"]:
|
|
92
|
+
label = "found" if entry["exists"] else "missing"
|
|
93
|
+
lines.append(f"- [{label}] {entry['path']}")
|
|
94
|
+
|
|
95
|
+
lines.extend(["", "## Requirements brief", ""])
|
|
96
|
+
if ctx.get("brief_path"):
|
|
97
|
+
lines.append(f"- Path: `{ctx['brief_path']}`")
|
|
98
|
+
if ctx.get("brief_excerpt"):
|
|
99
|
+
lines.extend(["", "### Excerpt", "", "```markdown", ctx["brief_excerpt"], "```"])
|
|
100
|
+
else:
|
|
101
|
+
lines.append("- No requirements brief found for this scope.")
|
|
102
|
+
|
|
103
|
+
lines.extend(
|
|
104
|
+
[
|
|
105
|
+
"",
|
|
106
|
+
"## Next",
|
|
107
|
+
"",
|
|
108
|
+
"- Run `validate-req-analysis` on the brief before `/specify`",
|
|
109
|
+
"- Derive spec.md from the brief — do not re-ask resolved questions",
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
return "\n".join(lines) + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> int:
|
|
116
|
+
parser = argparse.ArgumentParser(description="Requirements context for Specify")
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--scope",
|
|
119
|
+
choices=("project", "feature"),
|
|
120
|
+
default="project",
|
|
121
|
+
help="Elicitation scope (default: project)",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--slug",
|
|
125
|
+
help="Feature slug when scope=feature (default: sole feature brief if only one exists)",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON instead of markdown")
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
|
|
130
|
+
if args.scope == "feature" and args.slug and not args.slug.strip():
|
|
131
|
+
print("[req-context] USAGE - --slug must not be empty", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
ctx = build_context(Path("."), args.scope, args.slug.strip() if args.slug else None)
|
|
135
|
+
|
|
136
|
+
if args.json:
|
|
137
|
+
print(json.dumps(ctx, indent=2))
|
|
138
|
+
else:
|
|
139
|
+
print(format_markdown(ctx), end="")
|
|
140
|
+
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural gate for requirements briefs produced by /elicit.
|
|
3
|
+
|
|
4
|
+
python3 validate_req_analysis.py .specs/project/requirements-brief.md
|
|
5
|
+
python3 validate_req_analysis.py .specs/project/feature-briefs/settings-page/requirements-brief.md
|
|
6
|
+
|
|
7
|
+
Checks (markdown structure only):
|
|
8
|
+
* Required sections: Goal, Context sources, Owner approval
|
|
9
|
+
* Context sources lists at least one non-placeholder entry
|
|
10
|
+
* Open questions is empty or explicitly "- none"
|
|
11
|
+
* Owner approval indicates yes with a date
|
|
12
|
+
* No [NEEDS CLARIFICATION] or [OPEN QUESTION] markers remain
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from _common import Report, visible_markdown
|
|
25
|
+
|
|
26
|
+
GATE = "validate-req-analysis"
|
|
27
|
+
PROJECT_DIR = Path(".specs/project")
|
|
28
|
+
PROJECT_BRIEF = PROJECT_DIR / "requirements-brief.md"
|
|
29
|
+
FEATURE_BRIEFS = PROJECT_DIR / "feature-briefs"
|
|
30
|
+
|
|
31
|
+
HEADING = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
|
32
|
+
APPROVAL_YES = re.compile(
|
|
33
|
+
r"\b(?:approved|approval)\b[^\n]*:\s*(?:yes|approved|true)\b",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
APPROVAL_DATE = re.compile(
|
|
37
|
+
r"\bdate\b\s*:\s*(\d{4}-\d{2}-\d{2}|YYYY-MM-DD)",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
OPEN_NONE = re.compile(r"^\s*[-*]\s*(?:none|n/a)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
41
|
+
CLARIFICATION = re.compile(r"\[(?:NEEDS CLARIFICATION|OPEN QUESTION)\]", re.IGNORECASE)
|
|
42
|
+
PLACEHOLDER_SOURCE = re.compile(
|
|
43
|
+
r"^\s*[-*]\s*\(\s*list every file",
|
|
44
|
+
re.IGNORECASE | re.MULTILINE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fail_usage(message: str, target: str = ".") -> None:
|
|
49
|
+
print(f"[{GATE}] USAGE - {message}", file=sys.stderr)
|
|
50
|
+
raise SystemExit(2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_brief_path(raw: str | None, root: Path = Path(".")) -> Path:
|
|
54
|
+
if not raw:
|
|
55
|
+
candidate = root / PROJECT_BRIEF
|
|
56
|
+
if candidate.is_file():
|
|
57
|
+
return candidate
|
|
58
|
+
_fail_usage(
|
|
59
|
+
"no brief path given and .specs/project/requirements-brief.md is missing",
|
|
60
|
+
str(PROJECT_BRIEF),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
candidate = Path(raw).expanduser()
|
|
64
|
+
if candidate.is_absolute():
|
|
65
|
+
brief = candidate
|
|
66
|
+
else:
|
|
67
|
+
brief = root / candidate
|
|
68
|
+
|
|
69
|
+
if not brief.is_file():
|
|
70
|
+
_fail_usage(f"no such requirements brief: {raw}", raw or str(PROJECT_BRIEF))
|
|
71
|
+
|
|
72
|
+
rel = brief.resolve()
|
|
73
|
+
project_root = (root / PROJECT_DIR).resolve()
|
|
74
|
+
try:
|
|
75
|
+
rel.relative_to(project_root)
|
|
76
|
+
except ValueError:
|
|
77
|
+
_fail_usage(
|
|
78
|
+
f"brief must live under {PROJECT_DIR.as_posix()}/",
|
|
79
|
+
str(brief),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if brief.name != "requirements-brief.md":
|
|
83
|
+
_fail_usage(
|
|
84
|
+
"expected a requirements-brief.md file under .specs/project/",
|
|
85
|
+
str(brief),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return brief
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def section_body(text: str, title: str) -> str:
|
|
92
|
+
match = HEADING.search(text)
|
|
93
|
+
if not match:
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
pattern = re.compile(
|
|
97
|
+
rf"^##\s+{re.escape(title)}\s*$",
|
|
98
|
+
re.MULTILINE | re.IGNORECASE,
|
|
99
|
+
)
|
|
100
|
+
start = pattern.search(text)
|
|
101
|
+
if not start:
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
rest = text[start.end() :]
|
|
105
|
+
next_heading = HEADING.search(rest)
|
|
106
|
+
body = rest[: next_heading.start()] if next_heading else rest
|
|
107
|
+
return body.strip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_report(brief_path: Path) -> Report:
|
|
111
|
+
report = Report(GATE, brief_path.as_posix())
|
|
112
|
+
text = brief_path.read_text(encoding="utf-8")
|
|
113
|
+
visible = visible_markdown(text)
|
|
114
|
+
|
|
115
|
+
for section in ("Goal", "Context sources", "Owner approval"):
|
|
116
|
+
if not section_body(text, section):
|
|
117
|
+
report.error(f"missing ## {section} section")
|
|
118
|
+
|
|
119
|
+
goal_body = section_body(text, "Goal")
|
|
120
|
+
if goal_body and len(goal_body.strip()) < 8:
|
|
121
|
+
report.error("Goal section is too short to be actionable")
|
|
122
|
+
|
|
123
|
+
sources_body = section_body(text, "Context sources")
|
|
124
|
+
if sources_body:
|
|
125
|
+
if PLACEHOLDER_SOURCE.search(sources_body):
|
|
126
|
+
report.error("Context sources still contains the scaffold placeholder")
|
|
127
|
+
elif not re.search(r"^\s*[-*]\s+\S", sources_body, re.MULTILINE):
|
|
128
|
+
report.error("Context sources must list at least one bullet entry")
|
|
129
|
+
else:
|
|
130
|
+
report.ok("Context sources lists at least one entry")
|
|
131
|
+
else:
|
|
132
|
+
report.error("Context sources section is empty")
|
|
133
|
+
|
|
134
|
+
open_body = section_body(text, "Open questions")
|
|
135
|
+
if open_body:
|
|
136
|
+
bullets = [
|
|
137
|
+
line.strip()
|
|
138
|
+
for line in open_body.splitlines()
|
|
139
|
+
if line.strip().startswith(("-", "*"))
|
|
140
|
+
]
|
|
141
|
+
if bullets and not all(OPEN_NONE.match(line) for line in bullets):
|
|
142
|
+
report.error(
|
|
143
|
+
'Open questions must be "- none" or empty before /specify'
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
report.ok("Open questions closed")
|
|
147
|
+
else:
|
|
148
|
+
report.ok("Open questions section absent or empty")
|
|
149
|
+
|
|
150
|
+
approval_body = section_body(text, "Owner approval")
|
|
151
|
+
if approval_body:
|
|
152
|
+
if not APPROVAL_YES.search(approval_body):
|
|
153
|
+
report.error('Owner approval must include "Approved: yes" (or equivalent)')
|
|
154
|
+
if APPROVAL_DATE.search(approval_body) and "YYYY-MM-DD" in approval_body:
|
|
155
|
+
report.error("Owner approval date is still the scaffold placeholder")
|
|
156
|
+
elif not re.search(r"\d{4}-\d{2}-\d{2}", approval_body):
|
|
157
|
+
report.warn("Owner approval has no YYYY-MM-DD date")
|
|
158
|
+
else:
|
|
159
|
+
report.ok("Owner approval recorded")
|
|
160
|
+
else:
|
|
161
|
+
report.error("Owner approval section is empty")
|
|
162
|
+
|
|
163
|
+
if CLARIFICATION.search(visible):
|
|
164
|
+
report.error("[NEEDS CLARIFICATION] or [OPEN QUESTION] marker remains")
|
|
165
|
+
else:
|
|
166
|
+
report.ok("no clarification markers in brief")
|
|
167
|
+
|
|
168
|
+
return report
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> int:
|
|
172
|
+
parser = argparse.ArgumentParser(description="Validate a requirements brief from /elicit")
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"brief",
|
|
175
|
+
nargs="?",
|
|
176
|
+
help="Path to requirements-brief.md (default: .specs/project/requirements-brief.md)",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--strict",
|
|
180
|
+
action="store_true",
|
|
181
|
+
help="Treat warnings as blocking failures",
|
|
182
|
+
)
|
|
183
|
+
args = parser.parse_args(argv)
|
|
184
|
+
|
|
185
|
+
brief = resolve_brief_path(args.brief)
|
|
186
|
+
report = build_report(brief)
|
|
187
|
+
return report.emit(strict=args.strict)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
|
@@ -82,7 +82,7 @@ EXPLORE (optional) → ELICIT (optional) → SPECIFY → DISCUSS (conditional)
|
|
|
82
82
|
| Phase | Required | Reference | Sister skill | Gate |
|
|
83
83
|
| --- | --- | --- | --- | --- |
|
|
84
84
|
| **Explore** | Optional | `references/explore.md` | — | — |
|
|
85
|
-
| **Elicit** | Optional | `references/elicitation.md` |
|
|
85
|
+
| **Elicit** | Optional | `references/elicitation.md` | `validate-req-analysis` | — (before `/specify`; suggest-only entry) |
|
|
86
86
|
| **Constitution** | Once per project | `references/constitution.md` | — | — |
|
|
87
87
|
| **Specify** | Yes | `references/specify.md` | — | `validate_spec.py` |
|
|
88
88
|
| **Discuss** | Conditional | `references/discuss.md` | — | — |
|
|
@@ -136,8 +136,14 @@ Use the template from `req-analysis init`. Required sections:
|
|
|
136
136
|
|
|
137
137
|
Present the brief summary. Wait for explicit approval before `/specify`.
|
|
138
138
|
|
|
139
|
-
**
|
|
139
|
+
**Gate before `/specify` (Brakes mode):**
|
|
140
140
|
|
|
141
|
+
```bash
|
|
142
|
+
npx @luizsantiago/spec-guardrails req-analysis validate
|
|
143
|
+
# or: validate-req-analysis .specs/project/requirements-brief.md
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Checklist (Process mode — agent verifies when Python is unavailable):**
|
|
141
147
|
- [ ] Open questions is `- none` or empty
|
|
142
148
|
- [ ] Owner approval filled
|
|
143
149
|
- [ ] Context sources lists at least one input
|
|
@@ -160,6 +166,12 @@ Copy or link brief → `.specs/features/NNN-slug/requirements-brief.md` (optiona
|
|
|
160
166
|
|
|
161
167
|
Open `specify.md` — derive `spec.md` from brief; do **not** re-ask resolved questions.
|
|
162
168
|
|
|
169
|
+
Optional context bundle for Specify:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npx @luizsantiago/spec-guardrails req-analysis context --scope feature --slug settings-page
|
|
173
|
+
```
|
|
174
|
+
|
|
163
175
|
```bash
|
|
164
176
|
npx @luizsantiago/spec-guardrails req-analysis promote --scope feature
|
|
165
177
|
```
|