@luizsantiago/spec-guardrails 3.0.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. package/templates/presets/python.yaml +22 -0
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """Plan the next Execute wave from tasks.md — parallel groups and sub-agent hints.
3
+
4
+ Run at the start of each /loop round (and after every batch completes):
5
+
6
+ python3 loop_plan.py auth
7
+ python3 loop_plan.py .specs/features/auth/tasks.md
8
+ python3 loop_plan.py --json auth
9
+
10
+ Reads dependency edges and Files ownership from tasks.md. Tasks marked
11
+ `- [x] complete` are treated as done. The next wave is every incomplete task
12
+ whose dependencies are complete. Within that wave, tasks with disjoint Files
13
+ lists may run in parallel (sub-agents when 2+).
14
+
15
+ Exit codes: 0 plan emitted, 1 nothing ready / blocked, 2 usage error.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from _common import resolve_artifact, visible_markdown
27
+ from validate_tasks import parse_dependencies, parse_fields, parse_files, split_tasks
28
+
29
+ GATE = "loop-plan"
30
+ COMPLETE = re.compile(r"-\s*\[x\]\s*complete\b", re.IGNORECASE)
31
+ PARALLEL_GROUP = re.compile(
32
+ r"^\|\s*(?:T)?(?P<id>\d{1,6})\s*\|.*?\|\s*(?P<group>[^|]+?)\s*\|",
33
+ re.MULTILINE | re.IGNORECASE,
34
+ )
35
+
36
+
37
+ def is_complete(body: str) -> bool:
38
+ return bool(COMPLETE.search(body))
39
+
40
+
41
+ def parse_parallel_groups(task_graph_text: str | None) -> dict[str, str]:
42
+ if not task_graph_text:
43
+ return {}
44
+
45
+ groups: dict[str, str] = {}
46
+ for match in PARALLEL_GROUP.finditer(task_graph_text):
47
+ task_id = f"T{match.group('id')}"
48
+ group = match.group("group").strip()
49
+ if group and group not in {"—", "-", "n/a", "na", "none"}:
50
+ groups[task_id] = group
51
+ return groups
52
+
53
+
54
+ def build_plan(text: str, *, task_graph_text: str | None = None) -> dict:
55
+ visible = visible_markdown(text)
56
+ tasks = split_tasks(visible)
57
+ graph: dict[str, list[str]] = {}
58
+ files_by_task: dict[str, list[str]] = {}
59
+ completed: set[str] = set()
60
+ titles: dict[str, str] = {}
61
+
62
+ for task_id, title, body in tasks:
63
+ titles[task_id] = title
64
+ fields = parse_fields(body)
65
+ graph[task_id] = parse_dependencies(fields.get("depends on", ""))
66
+ files_by_task[task_id] = parse_files(fields.get("files", ""))
67
+ if is_complete(body):
68
+ completed.add(task_id)
69
+
70
+ incomplete = [task_id for task_id in graph if task_id not in completed]
71
+ ready = [
72
+ task_id
73
+ for task_id in incomplete
74
+ if all(dep in completed for dep in graph[task_id])
75
+ ]
76
+
77
+ parallel_groups_doc = parse_parallel_groups(task_graph_text)
78
+
79
+ def files_disjoint(left: str, right: str) -> bool:
80
+ left_files = set(files_by_task.get(left, []))
81
+ right_files = set(files_by_task.get(right, []))
82
+ return not left_files.intersection(right_files)
83
+
84
+ groups: list[dict] = []
85
+ remaining = set(ready)
86
+
87
+ while remaining:
88
+ batch: list[str] = []
89
+ batch_files: set[str] = set()
90
+
91
+ for task_id in sorted(remaining, key=lambda value: int(value[1:])):
92
+ task_files = set(files_by_task.get(task_id, []))
93
+ if task_files.intersection(batch_files):
94
+ continue
95
+ batch.append(task_id)
96
+ batch_files.update(task_files)
97
+
98
+ for task_id in batch:
99
+ remaining.discard(task_id)
100
+
101
+ mode = "parallel" if len(batch) > 1 else "inline"
102
+ groups.append(
103
+ {
104
+ "mode": mode,
105
+ "tasks": [
106
+ {
107
+ "id": task_id,
108
+ "title": titles.get(task_id, ""),
109
+ "files": files_by_task.get(task_id, []),
110
+ "parallel_group": parallel_groups_doc.get(task_id),
111
+ }
112
+ for task_id in batch
113
+ ],
114
+ "sub_agents": mode == "parallel",
115
+ }
116
+ )
117
+
118
+ blocked = [
119
+ {
120
+ "id": task_id,
121
+ "title": titles.get(task_id, ""),
122
+ "waiting_on": [dep for dep in graph[task_id] if dep not in completed],
123
+ }
124
+ for task_id in incomplete
125
+ if task_id not in ready
126
+ ]
127
+
128
+ return {
129
+ "completed": sorted(completed, key=lambda value: int(value[1:])),
130
+ "ready": ready,
131
+ "groups": groups,
132
+ "blocked": blocked,
133
+ "all_done": not incomplete,
134
+ "recommend_sub_agents": any(group["sub_agents"] for group in groups),
135
+ }
136
+
137
+
138
+ def format_plan(plan: dict) -> str:
139
+ lines: list[str] = []
140
+
141
+ if plan["all_done"]:
142
+ lines.append("All tasks complete — run /verify with a fresh context.")
143
+ return "\n".join(lines)
144
+
145
+ if not plan["ready"]:
146
+ lines.append("No tasks ready — resolve blocked dependencies first.")
147
+ for item in plan["blocked"][:5]:
148
+ waiting = ", ".join(item["waiting_on"]) or "unknown"
149
+ lines.append(f" {item['id']}: waiting on {waiting}")
150
+ return "\n".join(lines)
151
+
152
+ lines.append("Next Execute wave:")
153
+ for index, group in enumerate(plan["groups"], start=1):
154
+ if group["mode"] == "parallel":
155
+ lines.append(
156
+ f" Group {index} — PARALLEL ({len(group['tasks'])} tasks, use sub-agents):"
157
+ )
158
+ else:
159
+ lines.append(f" Group {index} — inline (orchestrator):")
160
+
161
+ for task in group["tasks"]:
162
+ files = ", ".join(task["files"]) or "(no files listed)"
163
+ group_hint = ""
164
+ if task.get("parallel_group"):
165
+ group_hint = f" [graph group {task['parallel_group']}]"
166
+ lines.append(f" {task['id']}: {task['title']} — {files}{group_hint}")
167
+
168
+ if plan["recommend_sub_agents"]:
169
+ lines.append("")
170
+ lines.append(
171
+ "Sub-agents: offer parallel dispatch per references/sub-agents.md "
172
+ "(owner must confirm before spawning)."
173
+ )
174
+
175
+ if plan["blocked"]:
176
+ lines.append("")
177
+ lines.append("Blocked (later waves):")
178
+ for item in plan["blocked"][:5]:
179
+ waiting = ", ".join(item["waiting_on"])
180
+ lines.append(f" {item['id']}: after {waiting}")
181
+
182
+ return "\n".join(lines)
183
+
184
+
185
+ def main(argv: list[str] | None = None) -> int:
186
+ parser = argparse.ArgumentParser(
187
+ description="Plan the next Execute wave with parallel groups"
188
+ )
189
+ parser.add_argument(
190
+ "tasks",
191
+ nargs="?",
192
+ help="feature name, feature directory, or path to tasks.md",
193
+ )
194
+ parser.add_argument("--json", action="store_true", help="emit JSON for agents")
195
+ args = parser.parse_args(argv)
196
+
197
+ path, text = resolve_artifact(args.tasks, "tasks.md", GATE)
198
+ graph_path = path.parent / "task-graph.md"
199
+ graph_text = (
200
+ graph_path.read_text(encoding="utf-8") if graph_path.is_file() else None
201
+ )
202
+ plan = build_plan(text, task_graph_text=graph_text)
203
+
204
+ if args.json:
205
+ print(json.dumps(plan, indent=2))
206
+ else:
207
+ print(format_plan(plan))
208
+
209
+ if plan["all_done"]:
210
+ return 0
211
+ if not plan["ready"]:
212
+ return 1
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Closure gate for `.specs/features/[feature]/spec.md`.
3
+
4
+ Run before confirming a spec with the project owner:
5
+
6
+ python3 validate_spec.py .specs/features/auth/spec.md
7
+
8
+ The feature can be named instead of pathed:
9
+
10
+ python3 validate_spec.py auth
11
+ python3 validate_spec.py # when the project has a single feature
12
+
13
+ Checks (full spec):
14
+ * required sections are present (Requirements, Assumptions, Out of Scope)
15
+ * at least one well-formed requirement ID (REQ-001 style)
16
+ * every requirement carries at least one acceptance criterion
17
+ * every criterion states a required outcome (SHALL or MUST)
18
+ * no unresolved placeholders (TBD, TODO, <fill me>) outside fences and HTML comments
19
+ * EARS shape (WHEN ... THEN ...) is reported as a warning
20
+ * open [NEEDS CLARIFICATION] markers are reported as warnings
21
+
22
+ Checks (delta spec — when ADDED/MODIFIED/REMOVED sections are present):
23
+ * Goal and Assumptions required; Out of Scope recommended
24
+ * ADDED/MODIFIED requirements follow the same SHALL/MUST rules
25
+ * REMOVED lists requirement IDs to retire
26
+
27
+ Exit codes: 0 pass, 1 blocking issues, 2 usage error.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import re
34
+ import sys
35
+
36
+ from _common import (
37
+ REQUIREMENTS_HEADING,
38
+ Report,
39
+ find_placeholders,
40
+ has_section,
41
+ resolve_artifact,
42
+ section_body,
43
+ visible_markdown,
44
+ )
45
+
46
+ GATE = "validate-spec"
47
+
48
+ REQUIREMENT_HEADING = re.compile(
49
+ r"^(?P<level>#{2,6})\s*(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\s*[:\-–]?\s*(?P<title>.*)$",
50
+ re.MULTILINE,
51
+ )
52
+ ANY_HEADING = re.compile(r"^(?P<level>#{1,6})\s+\S", re.MULTILINE)
53
+ MALFORMED_ID = re.compile(r"^#{2,6}\s*(REQ|req)[\s_]*(\d{1,4})\b", re.MULTILINE)
54
+ ACCEPTANCE_LABEL = re.compile(r"(acceptance criteri|\bAC\b)", re.IGNORECASE)
55
+ METADATA_KEY = re.compile(
56
+ r"^\*{0,2}(owner|priority|status|estimate|risk|risks|files|file|notes|note|"
57
+ r"tags|links|link|related|depends on|reuses|source|epic|milestone)\*{0,2}\s*:",
58
+ re.IGNORECASE,
59
+ )
60
+ # A criterion without a normative verb states an intention, not an outcome a test
61
+ # can assert, so it blocks. The EARS lead keyword sharpens it further and is
62
+ # reported as a warning.
63
+ NORMATIVE_VERB = re.compile(r"\b(SHALL|MUST)\b", re.IGNORECASE)
64
+ EARS_LEAD = re.compile(
65
+ r"\b(WHEN|IF|WHILE|WHERE)\b.*\bTHEN\b", re.IGNORECASE | re.DOTALL
66
+ )
67
+ REQUIRED_SECTIONS = ("Requirements", "Assumptions", "Out of Scope")
68
+ DELTA_SECTIONS = (
69
+ ("ADDED Requirements", "added"),
70
+ ("MODIFIED Requirements", "modified"),
71
+ ("REMOVED Requirements", "removed"),
72
+ )
73
+ CLARIFICATION = re.compile(r"\[NEEDS CLARIFICATION(?:\s*:\s*[^\]]+)?\]", re.IGNORECASE)
74
+ REMOVED_ID = re.compile(r"^\s*(?:-\s*)?(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b", re.MULTILINE)
75
+
76
+
77
+ def is_delta_spec(text: str) -> bool:
78
+ visible = visible_markdown(text)
79
+ return any(has_section(visible, heading) for heading, _ in DELTA_SECTIONS)
80
+
81
+
82
+ def split_requirements(text: str) -> list[tuple[str, str, str]]:
83
+ """Return (id, title, body) for each requirement heading under ``## Requirements``.
84
+
85
+ Headings under Assumptions, Out of Scope, or other sections are ignored so
86
+ NOTE-001-style notes never become acceptance-criteria obligations. A
87
+ requirement body ends at the next heading of the same or higher level.
88
+ """
89
+
90
+ scoped = section_body(text, REQUIREMENTS_HEADING)
91
+ if scoped is None:
92
+ return []
93
+
94
+ requirements: list[tuple[str, str, str]] = []
95
+
96
+ for match in REQUIREMENT_HEADING.finditer(scoped):
97
+ level = len(match.group("level"))
98
+ start = match.end()
99
+ end = len(scoped)
100
+
101
+ for heading in ANY_HEADING.finditer(scoped, start):
102
+ if len(heading.group("level")) <= level:
103
+ end = heading.start()
104
+ break
105
+
106
+ requirements.append(
107
+ (match.group("id"), match.group("title").strip(), scoped[start:end])
108
+ )
109
+
110
+ return requirements
111
+
112
+
113
+ def split_delta_requirements(text: str, section_heading: str) -> list[tuple[str, str, str]]:
114
+ scoped = section_body(text, re.compile(
115
+ rf"^(?P<level>#{2,6})\s*{re.escape(section_heading)}\b",
116
+ re.MULTILINE | re.IGNORECASE,
117
+ ))
118
+ if scoped is None:
119
+ return []
120
+
121
+ requirements: list[tuple[str, str, str]] = []
122
+ for match in REQUIREMENT_HEADING.finditer(scoped):
123
+ level = len(match.group("level"))
124
+ start = match.end()
125
+ end = len(scoped)
126
+ for heading in ANY_HEADING.finditer(scoped, start):
127
+ if len(heading.group("level")) <= level:
128
+ end = heading.start()
129
+ break
130
+ requirements.append(
131
+ (match.group("id"), match.group("title").strip(), scoped[start:end])
132
+ )
133
+ return requirements
134
+
135
+
136
+ def validate_requirement_block(
137
+ report: Report,
138
+ requirements: list[tuple[str, str, str]],
139
+ label: str,
140
+ ) -> None:
141
+ if not requirements:
142
+ report.warn(f"{label}: no requirement headings found")
143
+ return
144
+
145
+ report.ok(f"{label}: {len(requirements)} requirement(s) with well-formed IDs")
146
+ seen: set[str] = set()
147
+ for requirement_id, title, body in requirements:
148
+ if requirement_id in seen:
149
+ report.error(f"{label} {requirement_id}: duplicate requirement ID")
150
+ seen.add(requirement_id)
151
+
152
+ if not title:
153
+ report.error(f"{label} {requirement_id}: heading has no title")
154
+
155
+ criteria = acceptance_lines(body)
156
+ if not criteria:
157
+ report.error(f"{label} {requirement_id}: no acceptance criteria found")
158
+ continue
159
+
160
+ for item in criteria:
161
+ excerpt = item if len(item) <= 70 else f"{item[:67]}..."
162
+ if not NORMATIVE_VERB.search(item):
163
+ report.error(
164
+ f"{label} {requirement_id}: criterion is not testable, it states no "
165
+ f"required outcome (add SHALL or MUST): '{excerpt}'"
166
+ )
167
+ continue
168
+ if not EARS_LEAD.search(item):
169
+ report.warn(
170
+ f"{label} {requirement_id}: criterion has SHALL/MUST but no trigger "
171
+ f"(WHEN/IF ... THEN ...): '{excerpt}'"
172
+ )
173
+
174
+
175
+ def validate_removed_section(report: Report, text: str) -> None:
176
+ scoped = section_body(text, re.compile(
177
+ r"^(?P<level>#{2,6})\s*REMOVED Requirements\b",
178
+ re.MULTILINE | re.IGNORECASE,
179
+ ))
180
+ if scoped is None:
181
+ report.error("delta spec missing ## REMOVED Requirements (use '- none' when nothing removed)")
182
+ return
183
+
184
+ ids = [match.group("id") for match in REMOVED_ID.finditer(scoped)]
185
+ if not ids and re.search(r"\bnone\b", scoped, re.IGNORECASE):
186
+ report.ok("REMOVED Requirements: none")
187
+ return
188
+
189
+ if not ids:
190
+ report.error("REMOVED Requirements: list requirement IDs to retire, or '- none'")
191
+ return
192
+
193
+ report.ok(f"REMOVED Requirements: {len(ids)} requirement ID(s) listed")
194
+
195
+
196
+ def _split_criterion_label(cleaned: str) -> str | None:
197
+ """Return the criterion text after an Acceptance Criteria / AC label.
198
+
199
+ List markers are already stripped. Split on a colon, en-dash, em-dash, or a
200
+ space-hyphen-space so `- **Acceptance Criteria** - WHEN ...` still works
201
+ without treating the leading `-` of a bullet as a separator.
202
+ """
203
+
204
+ remainder = re.split(r"[:\u2013\u2014]|\s+-\s+", cleaned, maxsplit=1)
205
+ if len(remainder) == 2 and remainder[1].strip():
206
+ return remainder[1].strip()
207
+ return None
208
+
209
+
210
+ def acceptance_lines(body: str) -> list[str]:
211
+ """Collect candidate acceptance-criteria lines from a requirement body."""
212
+
213
+ lines: list[str] = []
214
+ in_labeled_block = False
215
+ in_fence = False
216
+
217
+ for raw_line in body.splitlines():
218
+ line = raw_line.strip()
219
+ if line.startswith("```"):
220
+ in_fence = not in_fence
221
+ continue
222
+ if in_fence or not line:
223
+ continue
224
+
225
+ # Markdown tables are documentation, not criteria.
226
+ if line.startswith("|"):
227
+ continue
228
+
229
+ if ACCEPTANCE_LABEL.search(line):
230
+ in_labeled_block = True
231
+ cleaned = line.lstrip("-* ").strip()
232
+ remainder = _split_criterion_label(cleaned)
233
+ if remainder:
234
+ lines.append(remainder)
235
+ continue
236
+
237
+ if line.startswith(("-", "*")) or re.match(r"^\d+\.", line):
238
+ cleaned = line.lstrip("-* ").strip()
239
+ if cleaned and not cleaned.startswith("---"):
240
+ if METADATA_KEY.match(cleaned):
241
+ continue
242
+ lines.append(cleaned)
243
+ continue
244
+
245
+ if in_labeled_block and not line.startswith("#"):
246
+ lines.append(line)
247
+
248
+ return lines
249
+
250
+
251
+ def build_report(target: str, text: str) -> Report:
252
+ report = Report(gate=GATE, target=target)
253
+ visible = visible_markdown(text)
254
+ delta = is_delta_spec(text)
255
+
256
+ if not has_section(visible, "Goal"):
257
+ report.error("missing required section: ## Goal")
258
+
259
+ if has_section(visible, "Assumptions"):
260
+ report.ok("section present: Assumptions")
261
+ else:
262
+ report.error("missing required section: ## Assumptions")
263
+
264
+ if delta:
265
+ report.ok("delta spec detected (ADDED/MODIFIED/REMOVED)")
266
+ if not has_section(visible, "Out of Scope"):
267
+ report.warn("delta spec: ## Out of Scope recommended")
268
+
269
+ for heading, label in DELTA_SECTIONS:
270
+ if has_section(visible, heading):
271
+ report.ok(f"section present: {heading}")
272
+ elif label != "removed":
273
+ report.warn(f"delta spec: ## {heading} not present")
274
+
275
+ validate_requirement_block(
276
+ report,
277
+ split_delta_requirements(visible, "ADDED Requirements"),
278
+ "ADDED",
279
+ )
280
+ validate_requirement_block(
281
+ report,
282
+ split_delta_requirements(visible, "MODIFIED Requirements"),
283
+ "MODIFIED",
284
+ )
285
+ validate_removed_section(report, visible)
286
+ else:
287
+ for section in REQUIRED_SECTIONS:
288
+ if has_section(visible, section):
289
+ report.ok(f"section present: {section}")
290
+ else:
291
+ report.error(f"missing required section: ## {section}")
292
+
293
+ requirements = split_requirements(visible)
294
+
295
+ if not requirements:
296
+ report.error(
297
+ "no requirement headings found - use '### REQ-001: Title' (prefix-NNN)"
298
+ )
299
+ else:
300
+ validate_requirement_block(report, requirements, "Requirements")
301
+
302
+ for malformed in MALFORMED_ID.finditer(visible):
303
+ raw = malformed.group(0).lstrip("# ").strip()
304
+ if not REQUIREMENT_HEADING.match(f"### {raw}"):
305
+ report.error(f"malformed requirement ID: '{raw}' - expected REQ-001 style")
306
+
307
+ placeholders = find_placeholders(text)
308
+ if placeholders:
309
+ for item in placeholders[:10]:
310
+ report.error(f"unresolved placeholder at {item}")
311
+ else:
312
+ report.ok("no unresolved placeholders")
313
+
314
+ clarifications = CLARIFICATION.findall(visible)
315
+ if clarifications:
316
+ report.warn(
317
+ f"{len(clarifications)} open [NEEDS CLARIFICATION] marker(s) — resolve before approval"
318
+ )
319
+ else:
320
+ report.ok("no open [NEEDS CLARIFICATION] markers")
321
+
322
+ return report
323
+
324
+
325
+ def main(argv: list[str] | None = None) -> int:
326
+ parser = argparse.ArgumentParser(description="Validate a feature spec.md")
327
+ parser.add_argument(
328
+ "spec",
329
+ nargs="?",
330
+ help="feature name, feature directory, or path to spec.md",
331
+ )
332
+ parser.add_argument(
333
+ "--strict",
334
+ action="store_true",
335
+ help="treat warnings as blocking failures",
336
+ )
337
+ args = parser.parse_args(argv)
338
+
339
+ path, text = resolve_artifact(args.spec, "spec.md", GATE)
340
+ report = build_report(str(path), text)
341
+ return report.emit(strict=args.strict)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ sys.exit(main())