@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.
- package/framework/agents/backend-architect.md +1 -1
- package/framework/mcps/figma-mcp-go/README.md +0 -1
- package/framework/mcps/gitlab-mcp/README.md +0 -1
- package/framework/mcps/jira-mcp/README.md +0 -1
- package/framework/mcps/laravel-boost/README.md +0 -1
- package/framework/mcps/notion-mcp/README.md +0 -1
- package/framework/mcps/supabase-mcp/README.md +0 -1
- package/framework/plugins/opencode-goal-plugin/README.md +0 -1
- package/framework/references/standards/api.md +0 -1
- package/framework/references/standards/architecture.md +0 -1
- package/framework/references/standards/database.md +0 -1
- package/framework/references/standards/debugging.md +0 -1
- package/framework/references/standards/documentation.md +0 -2
- package/framework/references/standards/logging.md +0 -1
- package/framework/references/standards/naming.md +0 -1
- package/framework/references/standards/observability.md +0 -1
- package/framework/references/standards/performance.md +0 -1
- package/framework/references/standards/pull-requests.md +0 -1
- package/framework/references/standards/security.md +0 -1
- package/framework/references/standards/testing.md +0 -1
- package/framework/skills/README.md +15 -3
- package/framework/skills/codenavi/SKILL.md +306 -0
- package/framework/skills/codenavi/examples.md +33 -0
- package/framework/skills/codenavi/references/coding-principles.md +143 -0
- package/framework/skills/codenavi/references/notebook-spec.md +171 -0
- package/framework/skills/create-adr/SKILL.md +429 -0
- package/framework/skills/create-adr/examples.md +35 -0
- package/framework/skills/docs-writer/SKILL.md +39 -0
- package/framework/skills/docs-writer/examples.md +34 -0
- package/framework/skills/docs-writer/references/style-guide.md +72 -0
- package/framework/skills/frontend-design/SKILL.md +55 -0
- package/framework/skills/frontend-design/examples.md +45 -0
- package/framework/skills/humanizer/SKILL.md +412 -0
- package/framework/skills/humanizer/examples.md +46 -0
- package/framework/skills/learning-opportunities/SKILL.md +140 -0
- package/framework/skills/learning-opportunities/examples.md +34 -0
- package/framework/skills/learning-opportunities/references/PRINCIPLES.md +42 -0
- package/framework/skills/perf-web-optimization/SKILL.md +163 -0
- package/framework/skills/perf-web-optimization/examples.md +35 -0
- package/framework/skills/perf-web-optimization/references/bundle-optimization.md +180 -0
- package/framework/skills/perf-web-optimization/references/core-web-vitals.md +154 -0
- package/framework/skills/perf-web-optimization/references/image-optimization.md +170 -0
- package/framework/skills/security-best-practices/LICENSE.txt +201 -0
- package/framework/skills/security-best-practices/SKILL.md +89 -0
- package/framework/skills/security-best-practices/examples.md +35 -0
- package/framework/skills/security-best-practices/references/golang-general-backend-security.md +988 -0
- package/framework/skills/security-best-practices/references/javascript-express-web-server-security.md +1151 -0
- package/framework/skills/security-best-practices/references/javascript-general-web-frontend-security.md +725 -0
- package/framework/skills/security-best-practices/references/javascript-jquery-web-frontend-security.md +672 -0
- package/framework/skills/security-best-practices/references/javascript-typescript-nextjs-web-server-security.md +1138 -0
- package/framework/skills/security-best-practices/references/javascript-typescript-react-web-frontend-security.md +975 -0
- package/framework/skills/security-best-practices/references/javascript-typescript-vue-web-frontend-security.md +789 -0
- package/framework/skills/security-best-practices/references/python-django-web-server-security.md +880 -0
- package/framework/skills/security-best-practices/references/python-fastapi-web-server-security.md +1030 -0
- package/framework/skills/security-best-practices/references/python-flask-web-server-security.md +835 -0
- package/framework/skills/sentry/SKILL.md +127 -0
- package/framework/skills/sentry/examples.md +34 -0
- package/framework/skills/sentry/scripts/sentry_api.py +238 -0
- package/framework/skills/show-me/SKILL.md +127 -0
- package/framework/skills/show-me/examples.md +78 -0
- package/framework/skills/spec-driven-eval/SKILL.md +341 -0
- package/framework/skills/spec-driven-eval/examples.md +35 -0
- package/framework/skills/spec-driven-eval/references/quickstart.md +118 -0
- package/framework/skills/spec-driven-eval/references/reference.md +295 -0
- package/framework/skills/technical-design-doc-creator/README.md +411 -0
- package/framework/skills/technical-design-doc-creator/SKILL.md +1484 -0
- package/framework/skills/technical-design-doc-creator/examples.md +35 -0
- package/framework/skills/tlc-spec-driven/SKILL.md +184 -0
- package/framework/skills/tlc-spec-driven/examples.md +34 -0
- package/framework/skills/tlc-spec-driven/references/code-analysis.md +98 -0
- package/framework/skills/tlc-spec-driven/references/coding-principles.md +72 -0
- package/framework/skills/tlc-spec-driven/references/context-limits.md +31 -0
- package/framework/skills/tlc-spec-driven/references/design.md +199 -0
- package/framework/skills/tlc-spec-driven/references/discuss.md +159 -0
- package/framework/skills/tlc-spec-driven/references/implement.md +436 -0
- package/framework/skills/tlc-spec-driven/references/lessons.md +115 -0
- package/framework/skills/tlc-spec-driven/references/memory.md +144 -0
- package/framework/skills/tlc-spec-driven/references/specify.md +228 -0
- package/framework/skills/tlc-spec-driven/references/sub-agents.md +147 -0
- package/framework/skills/tlc-spec-driven/references/tasks.md +451 -0
- package/framework/skills/tlc-spec-driven/references/validate.md +355 -0
- package/framework/skills/tlc-spec-driven/scripts/check_commit.py +115 -0
- package/framework/skills/tlc-spec-driven/scripts/lessons.py +412 -0
- package/framework/skills/tlc-spec-driven/scripts/validate_spec.py +260 -0
- package/framework/skills/tlc-spec-driven/scripts/validate_state.py +162 -0
- package/framework/skills/tlc-spec-driven/scripts/validate_tasks.py +251 -0
- package/framework/skills/web-design-guidelines/SKILL.md +65 -0
- package/framework/skills/web-design-guidelines/examples.md +32 -0
- package/framework/skills/web-design-guidelines/references/guideline.md +174 -0
- package/package.json +1 -1
- package/src/catalog.js +15 -3
- package/framework/skills/backend-engineer/SKILL.md +0 -76
- package/framework/skills/backend-engineer/examples.md +0 -31
- package/framework/skills/documentation/SKILL.md +0 -74
- 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())
|