@mohammadhprp/system-prompt 0.11.1 → 0.11.2

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 (95) hide show
  1. package/framework/agents/backend-architect.md +1 -1
  2. package/framework/mcps/figma-mcp-go/README.md +0 -1
  3. package/framework/mcps/gitlab-mcp/README.md +0 -1
  4. package/framework/mcps/jira-mcp/README.md +0 -1
  5. package/framework/mcps/laravel-boost/README.md +0 -1
  6. package/framework/mcps/notion-mcp/README.md +0 -1
  7. package/framework/mcps/supabase-mcp/README.md +0 -1
  8. package/framework/plugins/opencode-goal-plugin/README.md +0 -1
  9. package/framework/references/standards/api.md +0 -1
  10. package/framework/references/standards/architecture.md +0 -1
  11. package/framework/references/standards/database.md +0 -1
  12. package/framework/references/standards/debugging.md +0 -1
  13. package/framework/references/standards/documentation.md +0 -2
  14. package/framework/references/standards/logging.md +0 -1
  15. package/framework/references/standards/naming.md +0 -1
  16. package/framework/references/standards/observability.md +0 -1
  17. package/framework/references/standards/performance.md +0 -1
  18. package/framework/references/standards/pull-requests.md +0 -1
  19. package/framework/references/standards/security.md +0 -1
  20. package/framework/references/standards/testing.md +0 -1
  21. package/framework/skills/README.md +15 -3
  22. package/framework/skills/codenavi/SKILL.md +306 -0
  23. package/framework/skills/codenavi/examples.md +33 -0
  24. package/framework/skills/codenavi/references/coding-principles.md +143 -0
  25. package/framework/skills/codenavi/references/notebook-spec.md +171 -0
  26. package/framework/skills/create-adr/SKILL.md +429 -0
  27. package/framework/skills/create-adr/examples.md +35 -0
  28. package/framework/skills/docs-writer/SKILL.md +39 -0
  29. package/framework/skills/docs-writer/examples.md +34 -0
  30. package/framework/skills/docs-writer/references/style-guide.md +72 -0
  31. package/framework/skills/frontend-design/SKILL.md +55 -0
  32. package/framework/skills/frontend-design/examples.md +45 -0
  33. package/framework/skills/humanizer/SKILL.md +412 -0
  34. package/framework/skills/humanizer/examples.md +46 -0
  35. package/framework/skills/learning-opportunities/SKILL.md +140 -0
  36. package/framework/skills/learning-opportunities/examples.md +34 -0
  37. package/framework/skills/learning-opportunities/references/PRINCIPLES.md +42 -0
  38. package/framework/skills/perf-web-optimization/SKILL.md +163 -0
  39. package/framework/skills/perf-web-optimization/examples.md +35 -0
  40. package/framework/skills/perf-web-optimization/references/bundle-optimization.md +180 -0
  41. package/framework/skills/perf-web-optimization/references/core-web-vitals.md +154 -0
  42. package/framework/skills/perf-web-optimization/references/image-optimization.md +170 -0
  43. package/framework/skills/security-best-practices/LICENSE.txt +201 -0
  44. package/framework/skills/security-best-practices/SKILL.md +89 -0
  45. package/framework/skills/security-best-practices/examples.md +35 -0
  46. package/framework/skills/security-best-practices/references/golang-general-backend-security.md +988 -0
  47. package/framework/skills/security-best-practices/references/javascript-express-web-server-security.md +1151 -0
  48. package/framework/skills/security-best-practices/references/javascript-general-web-frontend-security.md +725 -0
  49. package/framework/skills/security-best-practices/references/javascript-jquery-web-frontend-security.md +672 -0
  50. package/framework/skills/security-best-practices/references/javascript-typescript-nextjs-web-server-security.md +1138 -0
  51. package/framework/skills/security-best-practices/references/javascript-typescript-react-web-frontend-security.md +975 -0
  52. package/framework/skills/security-best-practices/references/javascript-typescript-vue-web-frontend-security.md +789 -0
  53. package/framework/skills/security-best-practices/references/python-django-web-server-security.md +880 -0
  54. package/framework/skills/security-best-practices/references/python-fastapi-web-server-security.md +1030 -0
  55. package/framework/skills/security-best-practices/references/python-flask-web-server-security.md +835 -0
  56. package/framework/skills/sentry/SKILL.md +127 -0
  57. package/framework/skills/sentry/examples.md +34 -0
  58. package/framework/skills/sentry/scripts/sentry_api.py +238 -0
  59. package/framework/skills/show-me/SKILL.md +127 -0
  60. package/framework/skills/show-me/examples.md +78 -0
  61. package/framework/skills/spec-driven-eval/SKILL.md +341 -0
  62. package/framework/skills/spec-driven-eval/examples.md +35 -0
  63. package/framework/skills/spec-driven-eval/references/quickstart.md +118 -0
  64. package/framework/skills/spec-driven-eval/references/reference.md +295 -0
  65. package/framework/skills/technical-design-doc-creator/README.md +411 -0
  66. package/framework/skills/technical-design-doc-creator/SKILL.md +1484 -0
  67. package/framework/skills/technical-design-doc-creator/examples.md +35 -0
  68. package/framework/skills/tlc-spec-driven/SKILL.md +184 -0
  69. package/framework/skills/tlc-spec-driven/examples.md +34 -0
  70. package/framework/skills/tlc-spec-driven/references/code-analysis.md +98 -0
  71. package/framework/skills/tlc-spec-driven/references/coding-principles.md +72 -0
  72. package/framework/skills/tlc-spec-driven/references/context-limits.md +31 -0
  73. package/framework/skills/tlc-spec-driven/references/design.md +199 -0
  74. package/framework/skills/tlc-spec-driven/references/discuss.md +159 -0
  75. package/framework/skills/tlc-spec-driven/references/implement.md +436 -0
  76. package/framework/skills/tlc-spec-driven/references/lessons.md +115 -0
  77. package/framework/skills/tlc-spec-driven/references/memory.md +144 -0
  78. package/framework/skills/tlc-spec-driven/references/specify.md +228 -0
  79. package/framework/skills/tlc-spec-driven/references/sub-agents.md +147 -0
  80. package/framework/skills/tlc-spec-driven/references/tasks.md +451 -0
  81. package/framework/skills/tlc-spec-driven/references/validate.md +355 -0
  82. package/framework/skills/tlc-spec-driven/scripts/check_commit.py +115 -0
  83. package/framework/skills/tlc-spec-driven/scripts/lessons.py +412 -0
  84. package/framework/skills/tlc-spec-driven/scripts/validate_spec.py +260 -0
  85. package/framework/skills/tlc-spec-driven/scripts/validate_state.py +162 -0
  86. package/framework/skills/tlc-spec-driven/scripts/validate_tasks.py +251 -0
  87. package/framework/skills/web-design-guidelines/SKILL.md +65 -0
  88. package/framework/skills/web-design-guidelines/examples.md +32 -0
  89. package/framework/skills/web-design-guidelines/references/guideline.md +174 -0
  90. package/package.json +1 -1
  91. package/src/catalog.js +15 -3
  92. package/framework/skills/backend-engineer/SKILL.md +0 -76
  93. package/framework/skills/backend-engineer/examples.md +0 -31
  94. package/framework/skills/documentation/SKILL.md +0 -74
  95. package/framework/skills/documentation/examples.md +0 -31
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ lessons.py - deterministic bookkeeping for the tlc-spec-driven lessons layer.
4
+
5
+ The LLM supplies judgment (which failure happened, how to phrase the lesson, what
6
+ signal grounds it). This script owns everything mechanical: IDs, distinct-feature
7
+ recurrence counting, candidate->confirmed promotion, pruning, demotion, and
8
+ rendering the human/agent-readable playbook. Bookkeeping by hand is exactly what
9
+ rots a lessons file, so it lives here, not in a prompt.
10
+
11
+ Canonical state: .specs/lessons.json (machine-owned - do NOT hand-edit)
12
+ Rendered view: .specs/LESSONS.md (regenerated on every write)
13
+
14
+ Pure standard library. No dependencies. The script file lives in this skill's
15
+ `scripts/` directory - invoke it as `python3 <skill-dir>/scripts/lessons.py ...`
16
+ (never `python3 scripts/lessons.py` from a consuming project root). Run with
17
+ cwd at the project root (the dir that contains .specs), or pass --root.
18
+
19
+ Commands:
20
+ add Record a grounded lesson from a verification signal.
21
+ list Print lessons (default: confirmed) for loading at Specify/Design.
22
+ penalize Mark a confirmed lesson as having failed when applied (-> quarantine).
23
+ prune Drop stale uncorroborated candidates (also runs automatically on add/list).
24
+ status Print counts (used by the self-check in validate.md).
25
+ init Create empty store + rendered file.
26
+ selftest Run stdlib regressions (normalization).
27
+
28
+ Exit codes: 0 ok, 2 usage/validation error (e.g. missing grounding).
29
+ """
30
+
31
+ import argparse
32
+ import datetime as _dt
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ import unicodedata
38
+
39
+ STORE_REL = os.path.join(".specs", "lessons.json")
40
+ RENDER_REL = os.path.join(".specs", "LESSONS.md")
41
+
42
+ SIGNALS = {
43
+ "ac_gap": "Acceptance criterion not covered / failed",
44
+ "surviving_mutant": "Discrimination sensor mutant survived (weak test)",
45
+ "spec_precision_gap": "Spec did not define a precise outcome",
46
+ "spec_deviation": "Implementation diverged from spec/design (SPEC_DEVIATION)",
47
+ "gate_fail": "Build-level gate check failed",
48
+ }
49
+
50
+ DEFAULTS = {"promote_threshold": 2, "window_days": 45, "quarantine_threshold": 2}
51
+
52
+
53
+ def _now():
54
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ def _parse_date(s):
58
+ try:
59
+ return _dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_dt.timezone.utc)
60
+ except Exception:
61
+ return _dt.datetime.now(_dt.timezone.utc)
62
+
63
+
64
+ def _store_path(root):
65
+ return os.path.join(root, STORE_REL)
66
+
67
+
68
+ def _render_path(root):
69
+ return os.path.join(root, RENDER_REL)
70
+
71
+
72
+ def _load(root):
73
+ path = _store_path(root)
74
+ if not os.path.exists(path):
75
+ return {
76
+ "schema": 1,
77
+ "promote_threshold": DEFAULTS["promote_threshold"],
78
+ "window_days": DEFAULTS["window_days"],
79
+ "quarantine_threshold": DEFAULTS["quarantine_threshold"],
80
+ "next_id": 1,
81
+ "lessons": [],
82
+ }
83
+ with open(path, "r", encoding="utf-8") as f:
84
+ data = json.load(f)
85
+ for k, v in DEFAULTS.items():
86
+ data.setdefault(k, v)
87
+ data.setdefault("schema", 1)
88
+ data.setdefault("next_id", 1)
89
+ data.setdefault("lessons", [])
90
+ return data
91
+
92
+
93
+ def _save(root, data):
94
+ os.makedirs(os.path.join(root, ".specs"), exist_ok=True)
95
+ with open(_store_path(root), "w", encoding="utf-8") as f:
96
+ json.dump(data, f, indent=2, ensure_ascii=False)
97
+ f.write("\n")
98
+ _render(root, data)
99
+
100
+
101
+ def _norm(text):
102
+ """Normalized dedup key for lesson text.
103
+
104
+ - casefold + NFD, strip combining marks (so Portuguese diacritics match ASCII peers)
105
+ - keep characters where str.isalnum() is true (any script) and whitespace
106
+ - drop other punctuation, collapse whitespace
107
+
108
+ Exact-after-normalization only - no semantic matching (stdlib-only limitation).
109
+ Phrase lessons tersely and canonically so recurrences actually merge.
110
+ """
111
+ t = unicodedata.normalize("NFD", text.casefold())
112
+ t = "".join(c for c in t if unicodedata.category(c) != "Mn")
113
+ t = "".join(c if (c.isalnum() or c.isspace()) else " " for c in t)
114
+ t = re.sub(r"\s+", " ", t).strip()
115
+ return t
116
+
117
+
118
+ def _selftest_norm():
119
+ """Regressions for #158: Portuguese diacritics + distinct non-Latin text."""
120
+ failures = []
121
+
122
+ def check(cond, msg):
123
+ if not cond:
124
+ failures.append(msg)
125
+
126
+ a = _norm("Não use datas locais")
127
+ b = _norm("Nao use datas locais")
128
+ check(a == b == "nao use datas locais", f"PT diacritics: {a!r} vs {b!r}")
129
+
130
+ jp1 = _norm("日本語の文です")
131
+ jp2 = _norm("別の日本語文")
132
+ check(jp1 != "", f"JP1 empty: {jp1!r}")
133
+ check(jp2 != "", f"JP2 empty: {jp2!r}")
134
+ check(jp1 != jp2, f"JP sentences collapsed: {jp1!r} == {jp2!r}")
135
+
136
+ check(_norm("café") == _norm("cafe") == "cafe", f"cafe: {_norm('café')!r}")
137
+
138
+ if failures:
139
+ for f in failures:
140
+ print(f"FAIL: {f}", file=sys.stderr)
141
+ return 1
142
+ print("selftest_norm: ok")
143
+ return 0
144
+
145
+
146
+ def _key(signal, text):
147
+ return signal + "::" + _norm(text)
148
+
149
+
150
+ def _auto_prune(data):
151
+ """Drop candidates that never recurred within the window. Mutates data."""
152
+ threshold = data["promote_threshold"]
153
+ window = data["window_days"]
154
+ now = _dt.datetime.now(_dt.timezone.utc)
155
+ kept = []
156
+ dropped = []
157
+ for l in data["lessons"]:
158
+ if l["status"] == "candidate" and l["recurrence"] < threshold:
159
+ age_days = (now - _parse_date(l.get("last_seen", l.get("created", _now())))).days
160
+ if age_days > window:
161
+ dropped.append(l["id"])
162
+ continue
163
+ kept.append(l)
164
+ data["lessons"] = kept
165
+ return dropped
166
+
167
+
168
+ def _find(data, signal, text):
169
+ k = _key(signal, text)
170
+ for l in data["lessons"]:
171
+ if l.get("key") == k:
172
+ return l
173
+ return None
174
+
175
+
176
+ def _render(root, data):
177
+ lines = []
178
+ lines.append("# LESSONS - auto-maintained by scripts/lessons.py")
179
+ lines.append("")
180
+ lines.append("> Machine-owned. Do NOT hand-edit. Changes are overwritten on the next `lessons.py` write.")
181
+ lines.append("> Canonical state lives in `.specs/lessons.json`. Edit lessons only via the script.")
182
+ lines.append(f"> promote_threshold={data['promote_threshold']} distinct features · window_days={data['window_days']} · quarantine_threshold={data['quarantine_threshold']}")
183
+ lines.append("")
184
+
185
+ by_status = {"confirmed": [], "candidate": [], "quarantined": []}
186
+ for l in data["lessons"]:
187
+ by_status.get(l["status"], by_status["candidate"]).append(l)
188
+
189
+ def block(title, items, note):
190
+ out = [f"## {title}", ""]
191
+ if note:
192
+ out.append(note)
193
+ out.append("")
194
+ if not items:
195
+ out.append("_none_")
196
+ out.append("")
197
+ return out
198
+ for l in sorted(items, key=lambda x: x["id"]):
199
+ scope = f" · scope: `{l['scope']}`" if l.get("scope") else ""
200
+ out.append(f"### {l['id']} - {l['text']}")
201
+ out.append(
202
+ f"- signal: `{l['signal']}` · recurrence: {l['recurrence']} feature(s){scope} · harmful: {l.get('harmful', 0)}"
203
+ )
204
+ feats = ", ".join(l.get("features", [])) or "-"
205
+ out.append(f"- features: {feats}")
206
+ ev = l.get("evidence", [])
207
+ if ev:
208
+ out.append(f"- evidence: {ev[0]}" + (f" (+{len(ev) - 1} more)" if len(ev) > 1 else ""))
209
+ out.append(f"- last seen: {l.get('last_seen', '-')}")
210
+ out.append("")
211
+ return out
212
+
213
+ lines += block(
214
+ "Confirmed (load these at Specify/Design)",
215
+ by_status["confirmed"],
216
+ "Corroborated across multiple features. Safe to apply as guidance.",
217
+ )
218
+ lines += block(
219
+ "Candidates (under observation - do NOT load as guidance yet)",
220
+ by_status["candidate"],
221
+ "Seen once or not yet corroborated. Tracked, not trusted.",
222
+ )
223
+ lines += block(
224
+ "Quarantined (failed when applied - ignore)",
225
+ by_status["quarantined"],
226
+ "A confirmed lesson that recurred alongside failure. Kept for the maintainer to review.",
227
+ )
228
+
229
+ with open(_render_path(root), "w", encoding="utf-8") as f:
230
+ f.write("\n".join(lines).rstrip() + "\n")
231
+
232
+
233
+ # ----------------------------- commands -----------------------------
234
+
235
+ def cmd_init(root, args):
236
+ data = _load(root)
237
+ _save(root, data)
238
+ print(f"Initialized lessons store at {_store_path(root)} and {_render_path(root)}")
239
+ return 0
240
+
241
+
242
+ def cmd_add(root, args):
243
+ signal = args.signal
244
+ source = (args.source or "").strip()
245
+ text = (args.text or "").strip()
246
+ feature = (args.feature or "").strip()
247
+
248
+ # Grounding is enforced here, deterministically - not left to the prompt.
249
+ if signal not in SIGNALS:
250
+ print(f"ERROR: --signal must be one of {sorted(SIGNALS)}", file=sys.stderr)
251
+ return 2
252
+ if not feature:
253
+ print("ERROR: --feature is required (the feature the signal came from).", file=sys.stderr)
254
+ return 2
255
+ if not source:
256
+ print("ERROR: --source is required (file:line / AC id / mutant id / SPEC_DEVIATION ref).", file=sys.stderr)
257
+ print(" A lesson with no grounding in validation.md is an opinion, not a lesson. Refused.", file=sys.stderr)
258
+ return 2
259
+ if len(text) < 12:
260
+ print("ERROR: --text too short. State the actionable lesson in one terse sentence.", file=sys.stderr)
261
+ return 2
262
+
263
+ data = _load(root)
264
+ _auto_prune(data)
265
+ existing = _find(data, signal, text)
266
+ now = _now()
267
+
268
+ if existing:
269
+ if feature not in existing["features"]:
270
+ existing["features"].append(feature)
271
+ existing["recurrence"] = len(existing["features"])
272
+ existing["last_seen"] = now
273
+ ev = source if not args.scope else f"{source} ({args.scope})"
274
+ if ev not in existing["evidence"]:
275
+ existing["evidence"].append(ev)
276
+ promoted = False
277
+ if existing["status"] == "candidate" and existing["recurrence"] >= data["promote_threshold"]:
278
+ existing["status"] = "confirmed"
279
+ promoted = True
280
+ _save(root, data)
281
+ msg = f"UPDATED {existing['id']} (recurrence={existing['recurrence']}, status={existing['status']})"
282
+ if promoted:
283
+ msg += " - PROMOTED to confirmed"
284
+ print(msg)
285
+ else:
286
+ lid = f"L-{data['next_id']:03d}"
287
+ data["next_id"] += 1
288
+ data["lessons"].append(
289
+ {
290
+ "id": lid,
291
+ "key": _key(signal, text),
292
+ "text": text,
293
+ "signal": signal,
294
+ "scope": (args.scope or "").strip(),
295
+ "status": "candidate",
296
+ "features": [feature],
297
+ "recurrence": 1,
298
+ "harmful": 0,
299
+ "evidence": [source if not args.scope else f"{source} ({args.scope})"],
300
+ "created": now,
301
+ "last_seen": now,
302
+ }
303
+ )
304
+ _save(root, data)
305
+ print(f"ADDED {lid} (status=candidate, recurrence=1)")
306
+ return 0
307
+
308
+
309
+ def cmd_penalize(root, args):
310
+ data = _load(root)
311
+ target = None
312
+ for l in data["lessons"]:
313
+ if l["id"].lower() == args.id.lower():
314
+ target = l
315
+ break
316
+ if not target:
317
+ print(f"ERROR: no lesson with id {args.id}", file=sys.stderr)
318
+ return 2
319
+ target["harmful"] = target.get("harmful", 0) + 1
320
+ target["last_seen"] = _now()
321
+ if target["harmful"] >= data["quarantine_threshold"]:
322
+ target["status"] = "quarantined"
323
+ _save(root, data)
324
+ print(f"PENALIZED {target['id']} (harmful={target['harmful']}, status={target['status']})")
325
+ return 0
326
+
327
+
328
+ def cmd_list(root, args):
329
+ data = _load(root)
330
+ if _auto_prune(data):
331
+ _save(root, data)
332
+ want = args.status
333
+ q = (args.query or "").lower().strip()
334
+ scope = (args.scope or "").lower().strip()
335
+ rows = []
336
+ for l in data["lessons"]:
337
+ if want != "all" and l["status"] != want:
338
+ continue
339
+ if q and q not in l["text"].lower():
340
+ continue
341
+ if scope and scope not in (l.get("scope", "").lower()):
342
+ continue
343
+ rows.append(l)
344
+ if not rows:
345
+ print(f"(no {want} lessons" + (f" matching '{q or scope}'" if (q or scope) else "") + ")")
346
+ return 0
347
+ for l in sorted(rows, key=lambda x: x["id"]):
348
+ sc = f" [scope:{l['scope']}]" if l.get("scope") else ""
349
+ print(f"{l['id']} ({l['status']}, x{l['recurrence']}){sc}: {l['text']}")
350
+ return 0
351
+
352
+
353
+ def cmd_prune(root, args):
354
+ data = _load(root)
355
+ dropped = _auto_prune(data)
356
+ _save(root, data)
357
+ print(f"Pruned {len(dropped)} stale candidate(s): {', '.join(dropped) if dropped else '-'}")
358
+ return 0
359
+
360
+
361
+ def cmd_status(root, args):
362
+ data = _load(root)
363
+ counts = {"confirmed": 0, "candidate": 0, "quarantined": 0}
364
+ for l in data["lessons"]:
365
+ counts[l["status"]] = counts.get(l["status"], 0) + 1
366
+ total = len(data["lessons"])
367
+ print(f"lessons: {total} total | confirmed={counts['confirmed']} candidate={counts['candidate']} quarantined={counts['quarantined']}")
368
+ return 0
369
+
370
+
371
+ def main(argv=None):
372
+ p = argparse.ArgumentParser(prog="lessons.py", description="Deterministic lessons bookkeeping for tlc-spec-driven.")
373
+ p.add_argument("--root", default=".", help="Project root containing .specs/ (default: current dir)")
374
+ sub = p.add_subparsers(dest="cmd", required=True)
375
+
376
+ sp = sub.add_parser("init", help="Create empty store + rendered file")
377
+ sp.set_defaults(fn=cmd_init)
378
+
379
+ sp = sub.add_parser("add", help="Record a grounded lesson")
380
+ sp.add_argument("--feature", required=True)
381
+ sp.add_argument("--signal", required=True, choices=sorted(SIGNALS))
382
+ sp.add_argument("--source", required=True, help="file:line / AC id / mutant id / SPEC_DEVIATION ref")
383
+ sp.add_argument("--text", required=True, help="One terse, actionable sentence")
384
+ sp.add_argument("--scope", default="", help="Optional: path/layer/tag for retrieval filtering")
385
+ sp.set_defaults(fn=cmd_add)
386
+
387
+ sp = sub.add_parser("penalize", help="Mark a confirmed lesson as failed-when-applied")
388
+ sp.add_argument("--id", required=True)
389
+ sp.set_defaults(fn=cmd_penalize)
390
+
391
+ sp = sub.add_parser("list", help="Print lessons for loading")
392
+ sp.add_argument("--status", default="confirmed", choices=["confirmed", "candidate", "quarantined", "all"])
393
+ sp.add_argument("--query", default="", help="Substring filter on lesson text")
394
+ sp.add_argument("--scope", default="", help="Substring filter on scope")
395
+ sp.set_defaults(fn=cmd_list)
396
+
397
+ sp = sub.add_parser("prune", help="Drop stale uncorroborated candidates")
398
+ sp.set_defaults(fn=cmd_prune)
399
+
400
+ sp = sub.add_parser("status", help="Print counts")
401
+ sp.set_defaults(fn=cmd_status)
402
+
403
+ sp = sub.add_parser("selftest", help="Run stdlib regressions (normalization)")
404
+ sp.set_defaults(fn=lambda root, args: _selftest_norm())
405
+
406
+ args = p.parse_args(argv)
407
+ root = os.path.abspath(args.root)
408
+ return args.fn(root, args)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ raise SystemExit(main())
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ validate_spec.py - deterministic closure-gate checks for a feature spec.md.
4
+
5
+ Turns the Requirement Closure Gate (Specify phase) into a checkable pass/fail
6
+ run BEFORE a spec is presented for confirmation, instead of trusting the model
7
+ to remember the checks. Pure standard library, zero dependencies. Operates only
8
+ on the spec.md markdown artifact - never on the target codebase - so it stays
9
+ stack-agnostic and tool-agnostic.
10
+
11
+ What it checks (heuristic markdown inspection, not a full parser):
12
+ ERROR - a required section is missing
13
+ ERROR - an acceptance criterion has no SHALL (not testable / not EARS-shaped)
14
+ ERROR - an Assumptions row has an empty "Chosen default" or "Rationale" cell
15
+ ERROR - a Requirement Traceability row has a malformed ID
16
+ WARN - an AC has SHALL but no recognizable EARS lead keyword
17
+ WARN - template placeholder rows are still present (spec not filled in)
18
+ WARN - open questions are not explicitly resolved
19
+
20
+ Usage:
21
+ python3 <skill-dir>/scripts/validate_spec.py [target] [--root DIR] [--strict]
22
+
23
+ Invoke from the skill directory that ships this script (not the project root).
24
+ target Path to a spec.md, a feature directory, or a project root.
25
+ Omitted -> auto-detect the single feature under <root>/.specs/features/.
26
+ --root Project root that contains .specs/ (default: current dir).
27
+ --strict Treat warnings as errors.
28
+
29
+ Exit codes: 0 pass, 1 errors found (or warnings under --strict), 2 usage error.
30
+ """
31
+
32
+ import argparse
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ REQUIRED_SECTIONS = [
38
+ "Problem Statement",
39
+ "Out of Scope",
40
+ "Assumptions & Open Questions",
41
+ "User Stories",
42
+ "Requirement Traceability",
43
+ ]
44
+
45
+ ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
46
+ PLACEHOLDER_RE = re.compile(r"^\s*\[.+\]\s*$")
47
+ STATUS_VALUES = {"pending", "in design", "in tasks", "implementing", "verified"}
48
+
49
+
50
+ def resolve_spec(target, root):
51
+ """Return the path to a spec.md from a file, dir, or auto-detect."""
52
+ if target:
53
+ if os.path.isfile(target):
54
+ return target
55
+ if os.path.isdir(target):
56
+ cand = os.path.join(target, "spec.md")
57
+ if os.path.isfile(cand):
58
+ return cand
59
+ # maybe it's a project root
60
+ return _autodetect(target)
61
+ # Not a path: treat as a feature name under <root>/.specs/features/<name>/
62
+ cand = os.path.join(root, ".specs", "features", target, "spec.md")
63
+ if os.path.isfile(cand):
64
+ return cand
65
+ return None
66
+ return _autodetect(root)
67
+
68
+
69
+ def _autodetect(root):
70
+ base = os.path.join(root, ".specs", "features")
71
+ if not os.path.isdir(base):
72
+ return None
73
+ features = [
74
+ d for d in sorted(os.listdir(base))
75
+ if os.path.isfile(os.path.join(base, d, "spec.md"))
76
+ ]
77
+ if len(features) == 1:
78
+ return os.path.join(base, features[0], "spec.md")
79
+ if len(features) == 0:
80
+ return None
81
+ # Ambiguous: signal the caller with the list.
82
+ raise SystemExit(
83
+ "validate_spec: multiple features found; pass one explicitly:\n "
84
+ + "\n ".join(os.path.join(base, f, "spec.md") for f in features)
85
+ )
86
+
87
+
88
+ def split_row(line):
89
+ cells = line.strip().strip("|").split("|")
90
+ return [c.strip() for c in cells]
91
+
92
+
93
+ def is_separator(line):
94
+ return bool(re.match(r"^\s*\|?[\s:|-]+\|?\s*$", line)) and "-" in line
95
+
96
+
97
+ def section_bounds(lines, name):
98
+ """Return (start, end) line indices for a `## name` section body."""
99
+ start = None
100
+ for i, ln in enumerate(lines):
101
+ if re.match(r"^#{1,3}\s+" + re.escape(name) + r"\s*$", ln.strip()):
102
+ start = i + 1
103
+ break
104
+ if start is None:
105
+ return None
106
+ end = len(lines)
107
+ for j in range(start, len(lines)):
108
+ if re.match(r"^#{1,3}\s+\S", lines[j]):
109
+ end = j
110
+ break
111
+ return (start, end)
112
+
113
+
114
+ def classify_ears(text):
115
+ """Return (ok, note). ok requires a SHALL; note records the EARS pattern."""
116
+ t = text.strip()
117
+ low = t.lower()
118
+ has_shall = bool(re.search(r"\bshall\b", low))
119
+ if not has_shall:
120
+ return (False, "no SHALL")
121
+ kws = []
122
+ if re.search(r"\bwhile\b", low):
123
+ kws.append("WHILE")
124
+ if re.search(r"\bwhen\b", low):
125
+ kws.append("WHEN")
126
+ if re.match(r"^\s*if\b", low) or re.search(r"\bif\b.*\bthen\b", low):
127
+ kws.append("IF/THEN")
128
+ if re.search(r"\bwhere\b", low):
129
+ kws.append("WHERE")
130
+ if len(kws) >= 2:
131
+ return (True, "complex (" + "+".join(kws) + ")")
132
+ if kws:
133
+ pattern = {
134
+ "WHILE": "state-driven",
135
+ "WHEN": "event-driven",
136
+ "IF/THEN": "unwanted-behavior",
137
+ "WHERE": "optional-feature",
138
+ }[kws[0]]
139
+ return (True, pattern)
140
+ if re.match(r"^\s*the\b", low):
141
+ return (True, "ubiquitous")
142
+ return (True, "warn: SHALL present but no EARS lead keyword")
143
+
144
+
145
+ def check(spec_path):
146
+ with open(spec_path, "r", encoding="utf-8") as f:
147
+ text = f.read()
148
+ lines = text.splitlines()
149
+ errors, warnings = [], []
150
+
151
+ # 1. Required sections.
152
+ for name in REQUIRED_SECTIONS:
153
+ if section_bounds(lines, name) is None:
154
+ errors.append(f"missing required section: ## {name}")
155
+
156
+ # 2. Acceptance criteria are EARS-shaped (have a SHALL).
157
+ in_ac = False
158
+ for i, ln in enumerate(lines, start=1):
159
+ stripped = ln.strip()
160
+ if re.match(r"^\*{0,2}Acceptance Criteria\*{0,2}\s*:?\s*$", stripped):
161
+ in_ac = True
162
+ continue
163
+ if in_ac:
164
+ m = re.match(r"^\s*\d+\.\s+(.*)$", ln)
165
+ if m:
166
+ item = m.group(1).strip()
167
+ if PLACEHOLDER_RE.match(item):
168
+ continue # untouched template row
169
+ ok, note = classify_ears(item)
170
+ if not ok:
171
+ errors.append(f"L{i}: acceptance criterion has no SHALL (not testable): {item[:70]}")
172
+ elif note.startswith("warn"):
173
+ warnings.append(f"L{i}: AC has SHALL but no EARS keyword (WHEN/WHILE/WHERE/IF or ubiquitous 'The … shall'): {item[:60]}")
174
+ elif stripped == "" or re.match(r"^#{1,3}\s", ln) or stripped.startswith("**"):
175
+ in_ac = False
176
+
177
+ # 3. Assumptions table cells filled.
178
+ b = section_bounds(lines, "Assumptions & Open Questions")
179
+ if b:
180
+ rows = [lines[i] for i in range(*b) if lines[i].strip().startswith("|")]
181
+ data = [r for r in rows if not is_separator(r)]
182
+ # drop the header row (first table row)
183
+ if data:
184
+ data = data[1:]
185
+ template_seen = False
186
+ for r in data:
187
+ cells = split_row(r)
188
+ if len(cells) < 3:
189
+ continue
190
+ assumption, chosen, rationale = cells[0], cells[1], cells[2]
191
+ if PLACEHOLDER_RE.match(assumption) and PLACEHOLDER_RE.match(chosen):
192
+ template_seen = True
193
+ continue
194
+ if not chosen or PLACEHOLDER_RE.match(chosen):
195
+ errors.append(f"assumption '{assumption[:40]}' has empty 'Chosen default'")
196
+ if not rationale or PLACEHOLDER_RE.match(rationale):
197
+ errors.append(f"assumption '{assumption[:40]}' has empty 'Rationale'")
198
+ if template_seen:
199
+ warnings.append("Assumptions table still contains template placeholder rows")
200
+ # open questions line
201
+ oq = [lines[i] for i in range(*b) if "open questions" in lines[i].lower()]
202
+ oq_clean = re.sub(r"[*_]", "", " ".join(oq)).lower()
203
+ if not oq:
204
+ warnings.append("no 'Open questions:' line in Assumptions section")
205
+ elif not re.search(r"open questions.*:\s*none", oq_clean):
206
+ warnings.append("open questions do not read as resolved ('Open questions: none')")
207
+
208
+ # 4. Requirement traceability IDs.
209
+ b = section_bounds(lines, "Requirement Traceability")
210
+ if b:
211
+ rows = [lines[i] for i in range(*b) if lines[i].strip().startswith("|")]
212
+ data = [r for r in rows if not is_separator(r)]
213
+ if data:
214
+ data = data[1:]
215
+ template_seen = False
216
+ real_ids = 0
217
+ for r in data:
218
+ cells = split_row(r)
219
+ if not cells:
220
+ continue
221
+ rid = cells[0]
222
+ if PLACEHOLDER_RE.match(rid) or "[" in rid:
223
+ template_seen = True
224
+ continue
225
+ if not rid:
226
+ continue
227
+ if not ID_RE.match(rid):
228
+ errors.append(f"malformed requirement ID: '{rid}' (expected e.g. AUTH-01)")
229
+ else:
230
+ real_ids += 1
231
+ if template_seen and real_ids == 0:
232
+ warnings.append("Requirement Traceability has only template rows (no real IDs yet)")
233
+
234
+ return errors, warnings
235
+
236
+
237
+ def main(argv=None):
238
+ p = argparse.ArgumentParser(prog="validate_spec.py", description="Closure-gate checks for a feature spec.md.")
239
+ p.add_argument("target", nargs="?", default=None)
240
+ p.add_argument("--root", default=".")
241
+ p.add_argument("--strict", action="store_true")
242
+ args = p.parse_args(argv)
243
+
244
+ spec = resolve_spec(args.target, args.root)
245
+ if not spec:
246
+ print("validate_spec: could not locate a spec.md. Pass a path or run from the project root.", file=sys.stderr)
247
+ return 2
248
+
249
+ errors, warnings = check(spec)
250
+ for w in warnings:
251
+ print(f" WARN {w}")
252
+ for e in errors:
253
+ print(f" ERROR {e}")
254
+ fail = errors or (warnings and args.strict)
255
+ print(f"\nvalidate_spec: {len(errors)} error(s), {len(warnings)} warning(s) in {spec}")
256
+ return 1 if fail else 0
257
+
258
+
259
+ if __name__ == "__main__":
260
+ raise SystemExit(main())