@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/index.js +335 -0
- package/lib/archive.js +208 -0
- package/lib/assets.js +145 -0
- package/lib/brownfield.js +446 -0
- package/lib/config.js +293 -0
- package/lib/constants.js +262 -0
- package/lib/cursorrules.js +92 -0
- package/lib/delta-merge.js +248 -0
- package/lib/doctor.js +343 -0
- package/lib/download.js +133 -0
- package/lib/feature.js +272 -0
- package/lib/fs-utils.js +114 -0
- package/lib/gates.js +138 -0
- package/lib/install.js +140 -0
- package/lib/memory.js +34 -0
- package/lib/next-steps.js +50 -0
- package/lib/presets.js +176 -0
- package/lib/project-rules.js +210 -0
- package/lib/specs-utils.js +117 -0
- package/lib/token-cost.js +124 -0
- package/package.json +46 -0
- package/rules/engineering-baseline.mdc +56 -0
- package/scripts/_common.py +356 -0
- package/scripts/analyze_artifacts.py +187 -0
- package/scripts/check_commit.py +140 -0
- package/scripts/lessons.py +447 -0
- package/scripts/loop_plan.py +217 -0
- package/scripts/validate_spec.py +345 -0
- package/scripts/validate_state.py +385 -0
- package/scripts/validate_tasks.py +379 -0
- package/skills/agent-architecture.md +221 -0
- package/skills/appsec.md +83 -0
- package/skills/code-simplify.md +49 -0
- package/skills/engineering-standards.md +98 -0
- package/skills/git-handoff.md +213 -0
- package/skills/qa-strategy.md +83 -0
- package/skills/references/analyze.md +56 -0
- package/skills/references/archive.md +60 -0
- package/skills/references/constitution.md +66 -0
- package/skills/references/context-limits.md +73 -0
- package/skills/references/converge.md +47 -0
- package/skills/references/design.md +88 -0
- package/skills/references/discuss.md +68 -0
- package/skills/references/explore.md +61 -0
- package/skills/references/implement.md +175 -0
- package/skills/references/lessons.md +71 -0
- package/skills/references/memory.md +98 -0
- package/skills/references/project-init.md +62 -0
- package/skills/references/quick-mode.md +84 -0
- package/skills/references/specify.md +144 -0
- package/skills/references/sub-agents.md +117 -0
- package/skills/references/tasks.md +178 -0
- package/skills/references/validate.md +210 -0
- package/skills/security-review.md +120 -0
- package/skills/ship-ready.md +50 -0
- package/skills/task-graph-engineering.md +180 -0
- package/templates/GETTING_STARTED.md +61 -0
- package/templates/config.yaml.example +28 -0
- package/templates/presets/default.yaml +16 -0
- package/templates/presets/node-ts.yaml +22 -0
- package/templates/presets/python.yaml +22 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Conventional Commits gate for guardrails commits.
|
|
3
|
+
|
|
4
|
+
Run before each atomic commit:
|
|
5
|
+
|
|
6
|
+
python3 check_commit.py --message "feat(auth): add token refresh"
|
|
7
|
+
python3 check_commit.py --file .git/COMMIT_EDITMSG
|
|
8
|
+
|
|
9
|
+
Wire it as a git hook to enforce the format without agent involvement:
|
|
10
|
+
|
|
11
|
+
#!/bin/sh
|
|
12
|
+
python3 .specs/guardrails/scripts/check_commit.py --file "$1"
|
|
13
|
+
|
|
14
|
+
Checks:
|
|
15
|
+
* `type(scope): subject` shape with an allowed type
|
|
16
|
+
* subject is present, lowercase-initial, without a trailing period
|
|
17
|
+
* subject length within 72 characters
|
|
18
|
+
* body separated from subject by a blank line
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from _common import EXIT_USAGE, Report
|
|
31
|
+
|
|
32
|
+
GATE = "check-commit"
|
|
33
|
+
|
|
34
|
+
ALLOWED_TYPES = (
|
|
35
|
+
"feat",
|
|
36
|
+
"fix",
|
|
37
|
+
"docs",
|
|
38
|
+
"style",
|
|
39
|
+
"refactor",
|
|
40
|
+
"perf",
|
|
41
|
+
"test",
|
|
42
|
+
"build",
|
|
43
|
+
"ci",
|
|
44
|
+
"chore",
|
|
45
|
+
"revert",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
HEADER = re.compile(
|
|
49
|
+
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^()]+)\))?(?P<breaking>!)?:\s(?P<subject>.+)$"
|
|
50
|
+
)
|
|
51
|
+
MAX_SUBJECT_LENGTH = 72
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_report(message: str) -> Report:
|
|
55
|
+
lines = message.rstrip().splitlines()
|
|
56
|
+
header = lines[0].strip() if lines else ""
|
|
57
|
+
report = Report(gate=GATE, target=header or "(empty message)")
|
|
58
|
+
|
|
59
|
+
if not header:
|
|
60
|
+
report.error("commit message is empty")
|
|
61
|
+
return report
|
|
62
|
+
|
|
63
|
+
if header.startswith(("Merge ", "Revert ", "fixup!", "squash!")):
|
|
64
|
+
report.ok("merge/fixup commit - format check skipped")
|
|
65
|
+
return report
|
|
66
|
+
|
|
67
|
+
match = HEADER.match(header)
|
|
68
|
+
if not match:
|
|
69
|
+
report.error(
|
|
70
|
+
"header does not follow Conventional Commits "
|
|
71
|
+
"- expected 'type(scope): subject'"
|
|
72
|
+
)
|
|
73
|
+
return report
|
|
74
|
+
|
|
75
|
+
commit_type = match.group("type")
|
|
76
|
+
subject = match.group("subject").strip()
|
|
77
|
+
|
|
78
|
+
if commit_type in ALLOWED_TYPES:
|
|
79
|
+
report.ok(f"type '{commit_type}' is allowed")
|
|
80
|
+
else:
|
|
81
|
+
report.error(
|
|
82
|
+
f"unknown type '{commit_type}' - allowed: {', '.join(ALLOWED_TYPES)}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
scope = match.group("scope")
|
|
86
|
+
if scope is not None and not scope.strip():
|
|
87
|
+
report.error("scope parentheses are empty")
|
|
88
|
+
|
|
89
|
+
if not subject:
|
|
90
|
+
report.error("subject is empty")
|
|
91
|
+
else:
|
|
92
|
+
if subject.endswith("."):
|
|
93
|
+
report.error("subject must not end with a period")
|
|
94
|
+
if subject[0].isupper() and not subject.split()[0].isupper():
|
|
95
|
+
report.warn("subject starts with an uppercase letter - prefer lowercase")
|
|
96
|
+
if len(header) > MAX_SUBJECT_LENGTH:
|
|
97
|
+
report.error(
|
|
98
|
+
f"header is {len(header)} characters - keep it within {MAX_SUBJECT_LENGTH}"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
report.ok(f"header length {len(header)}/{MAX_SUBJECT_LENGTH}")
|
|
102
|
+
|
|
103
|
+
if len(lines) > 1 and lines[1].strip():
|
|
104
|
+
report.error("body must be separated from the subject by a blank line")
|
|
105
|
+
|
|
106
|
+
return report
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str] | None = None) -> int:
|
|
110
|
+
parser = argparse.ArgumentParser(description="Validate a commit message")
|
|
111
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
112
|
+
source.add_argument("--message", help="commit message text")
|
|
113
|
+
source.add_argument("--file", help="path to a file holding the commit message")
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--strict",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="treat warnings as blocking failures",
|
|
118
|
+
)
|
|
119
|
+
args = parser.parse_args(argv)
|
|
120
|
+
|
|
121
|
+
if args.file:
|
|
122
|
+
path = Path(args.file).expanduser()
|
|
123
|
+
if not path.exists():
|
|
124
|
+
print(f"[{GATE}] FAIL - {path}")
|
|
125
|
+
print(f" error file not found: {path}")
|
|
126
|
+
return EXIT_USAGE
|
|
127
|
+
message = path.read_text(encoding="utf-8")
|
|
128
|
+
else:
|
|
129
|
+
message = args.message or ""
|
|
130
|
+
|
|
131
|
+
comment_free = "\n".join(
|
|
132
|
+
line for line in message.splitlines() if not line.startswith("#")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
report = build_report(comment_free)
|
|
136
|
+
return report.emit(strict=args.strict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lessons engine for `.specs/lessons.json` and the generated `LESSONS.md`.
|
|
3
|
+
|
|
4
|
+
A lesson is recorded only from a grounded verification failure. A clean PASS
|
|
5
|
+
records nothing. Candidates are not guidance; only confirmed lessons are.
|
|
6
|
+
|
|
7
|
+
python3 lessons.py add --title "..." --rule "..." --source features/auth/validation.md
|
|
8
|
+
python3 lessons.py list --status confirmed
|
|
9
|
+
python3 lessons.py penalize --id L-001 --source features/auth/validation.md
|
|
10
|
+
python3 lessons.py prune
|
|
11
|
+
python3 lessons.py status
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 ok, 1 refused / corrupt store, 2 usage error.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import unicodedata
|
|
25
|
+
from datetime import date, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
|
|
29
|
+
|
|
30
|
+
GATE = "lessons"
|
|
31
|
+
STORE_PATH = Path(".specs/lessons.json")
|
|
32
|
+
MARKDOWN_PATH = Path(".specs/LESSONS.md")
|
|
33
|
+
SPECS_DIR = Path(".specs")
|
|
34
|
+
FEATURES_DIR = Path(".specs/features")
|
|
35
|
+
PRUNE_AFTER = timedelta(days=90)
|
|
36
|
+
PROMOTE_AFTER_FEATURES = 2
|
|
37
|
+
QUARANTINE_AFTER_PENALTIES = 2
|
|
38
|
+
SOURCE_LINE = re.compile(r":(\d{1,6})$")
|
|
39
|
+
FEATURE_IN_PATH = re.compile(
|
|
40
|
+
r"(?:^|/)features/(?P<name>[^/]+)/", re.IGNORECASE
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
STATUSES = ("candidate", "confirmed", "quarantined")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail(message: str, code: int = EXIT_FAILED) -> int:
|
|
47
|
+
print(f"[{GATE}] FAIL - {STORE_PATH}")
|
|
48
|
+
print(f" error {message}")
|
|
49
|
+
return code
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ok(message: str) -> int:
|
|
53
|
+
print(f"[{GATE}] PASS - {message}")
|
|
54
|
+
return EXIT_OK
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize(text: str) -> str:
|
|
58
|
+
"""Casefold, strip accents and punctuation, collapse whitespace."""
|
|
59
|
+
|
|
60
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
61
|
+
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
|
62
|
+
return re.sub(r"[^a-z0-9]+", " ", stripped.casefold()).strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_source(raw: str) -> tuple[Path, str | None]:
|
|
66
|
+
"""Split an optional `:line` suffix from a source path."""
|
|
67
|
+
|
|
68
|
+
match = SOURCE_LINE.search(raw)
|
|
69
|
+
if match:
|
|
70
|
+
return Path(raw[: match.start()]), match.group(1)
|
|
71
|
+
return Path(raw), None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def infer_feature(source: Path, explicit: str | None) -> str:
|
|
75
|
+
if explicit:
|
|
76
|
+
return explicit.strip()
|
|
77
|
+
|
|
78
|
+
# Path.as_posix() does not convert backslashes that were part of the original
|
|
79
|
+
# string on POSIX, so normalize both separators before matching.
|
|
80
|
+
normalized = str(source).replace("\\", "/")
|
|
81
|
+
match = FEATURE_IN_PATH.search(normalized)
|
|
82
|
+
if match:
|
|
83
|
+
return match.group("name")
|
|
84
|
+
|
|
85
|
+
return source.parent.name or "unknown"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _under_specs(path: Path) -> bool:
|
|
89
|
+
"""Return True when `path` resolves inside `.specs/` of the current project."""
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
resolved = path.expanduser().resolve()
|
|
93
|
+
specs = SPECS_DIR.expanduser().resolve()
|
|
94
|
+
resolved.relative_to(specs)
|
|
95
|
+
return True
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_source(raw: str) -> tuple[Path, str] | int:
|
|
101
|
+
"""Return (path, original) or an exit code."""
|
|
102
|
+
|
|
103
|
+
if not raw or not raw.strip():
|
|
104
|
+
return fail("--source is required - a lesson without evidence is opinion", EXIT_USAGE)
|
|
105
|
+
|
|
106
|
+
path, _line = parse_source(raw.strip())
|
|
107
|
+
if not path.exists() or not path.is_file():
|
|
108
|
+
return fail(f"source file not found: {path}")
|
|
109
|
+
|
|
110
|
+
if path.name.lower() != "validation.md":
|
|
111
|
+
return fail(
|
|
112
|
+
f"source must be a validation.md (got {path.name}) - "
|
|
113
|
+
"lessons are distilled from /verify, not from opinion"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not _under_specs(path):
|
|
117
|
+
return fail("source must live under .specs/")
|
|
118
|
+
|
|
119
|
+
if not path.read_text(encoding="utf-8").strip():
|
|
120
|
+
return fail(f"source is empty: {path}")
|
|
121
|
+
|
|
122
|
+
return path, raw.strip()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def empty_store() -> dict:
|
|
126
|
+
return {"version": 1, "lessons": []}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_store() -> dict | int:
|
|
130
|
+
if not STORE_PATH.exists():
|
|
131
|
+
return empty_store()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
payload = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
|
135
|
+
except json.JSONDecodeError as err:
|
|
136
|
+
return fail(f"lessons.json is corrupt: {err}")
|
|
137
|
+
|
|
138
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("lessons"), list):
|
|
139
|
+
return fail("lessons.json is corrupt: expected an object with a lessons array")
|
|
140
|
+
|
|
141
|
+
for index, item in enumerate(payload["lessons"]):
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
return fail(f"lessons.json is corrupt: lesson {index} is not an object")
|
|
144
|
+
if not str(item.get("title") or "").strip() or not str(item.get("rule") or "").strip():
|
|
145
|
+
identity = item.get("id") or f"index {index}"
|
|
146
|
+
return fail(f"lessons.json is corrupt: {identity} is missing title or rule")
|
|
147
|
+
|
|
148
|
+
return payload
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def next_id(lessons: list[dict]) -> str:
|
|
152
|
+
numbers = []
|
|
153
|
+
for lesson in lessons:
|
|
154
|
+
match = re.match(r"L-(\d+)$", str(lesson.get("id", "")))
|
|
155
|
+
if match:
|
|
156
|
+
numbers.append(int(match.group(1)))
|
|
157
|
+
return f"L-{max(numbers, default=0) + 1:03d}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fingerprint(lesson: dict) -> str:
|
|
161
|
+
return f"{normalize(lesson.get('title', ''))}\n{normalize(lesson.get('rule', ''))}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def today_iso() -> str:
|
|
165
|
+
return date.today().isoformat()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_iso_date(raw: str) -> date | None:
|
|
169
|
+
try:
|
|
170
|
+
return datetime.strptime(raw, "%Y-%m-%d").date()
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_markdown(store: dict) -> str:
|
|
176
|
+
confirmed = [item for item in store["lessons"] if item.get("status") == "confirmed"]
|
|
177
|
+
lines = [
|
|
178
|
+
"# Lessons Learned",
|
|
179
|
+
"",
|
|
180
|
+
"Generated by `lessons.py` from `.specs/lessons.json`. Do not edit this file.",
|
|
181
|
+
"A clean PASS records nothing. Only **confirmed** lessons below are guidance.",
|
|
182
|
+
"Inspect candidates with `python3 .specs/guardrails/scripts/lessons.py list --status all`.",
|
|
183
|
+
"",
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
if not confirmed:
|
|
187
|
+
lines.append("- none yet")
|
|
188
|
+
lines.append("")
|
|
189
|
+
return "\n".join(lines)
|
|
190
|
+
|
|
191
|
+
for lesson in confirmed:
|
|
192
|
+
lines.append(f"### {lesson.get('id', '?')}: {lesson.get('title', '')}")
|
|
193
|
+
if lesson.get("trigger"):
|
|
194
|
+
lines.append(f"- **Trigger**: {lesson['trigger']}")
|
|
195
|
+
lines.append(f"- **Rule**: {lesson.get('rule', '')}")
|
|
196
|
+
features = ", ".join(lesson.get("features") or [])
|
|
197
|
+
if features:
|
|
198
|
+
lines.append(f"- **Features**: {features}")
|
|
199
|
+
lines.append(f"- **Source**: {lesson.get('source', '—')}")
|
|
200
|
+
lines.append("")
|
|
201
|
+
|
|
202
|
+
return "\n".join(lines)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
206
|
+
"""Write `text` via a same-directory tempfile so a crash cannot truncate the store."""
|
|
207
|
+
|
|
208
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
210
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
214
|
+
handle.write(text)
|
|
215
|
+
Path(tmp_name).replace(path)
|
|
216
|
+
except Exception:
|
|
217
|
+
try:
|
|
218
|
+
os.unlink(tmp_name)
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def save_store(store: dict) -> None:
|
|
225
|
+
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
atomic_write(
|
|
227
|
+
STORE_PATH, json.dumps(store, indent=2, ensure_ascii=False) + "\n"
|
|
228
|
+
)
|
|
229
|
+
atomic_write(MARKDOWN_PATH, render_markdown(store))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_duplicate(store: dict, title: str, rule: str) -> dict | None:
|
|
233
|
+
needle = f"{normalize(title)}\n{normalize(rule)}"
|
|
234
|
+
for lesson in store["lessons"]:
|
|
235
|
+
if fingerprint(lesson) == needle:
|
|
236
|
+
return lesson
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
241
|
+
source = validate_source(args.source)
|
|
242
|
+
if isinstance(source, int):
|
|
243
|
+
return source
|
|
244
|
+
source_path, source_raw = source
|
|
245
|
+
|
|
246
|
+
title = (args.title or "").strip()
|
|
247
|
+
rule = (args.rule or "").strip()
|
|
248
|
+
if not title or not rule:
|
|
249
|
+
return fail("--title and --rule are required", EXIT_USAGE)
|
|
250
|
+
|
|
251
|
+
store = load_store()
|
|
252
|
+
if isinstance(store, int):
|
|
253
|
+
return store
|
|
254
|
+
|
|
255
|
+
feature = infer_feature(source_path, args.feature)
|
|
256
|
+
existing = find_duplicate(store, title, rule)
|
|
257
|
+
now = today_iso()
|
|
258
|
+
|
|
259
|
+
if existing:
|
|
260
|
+
features = list(existing.get("features") or [])
|
|
261
|
+
if feature in features:
|
|
262
|
+
existing["updated"] = now
|
|
263
|
+
save_store(store)
|
|
264
|
+
return ok(
|
|
265
|
+
f"{existing['id']} already recorded for '{feature}' - "
|
|
266
|
+
"same-feature recurrence does not promote"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
features.append(feature)
|
|
270
|
+
existing["features"] = features
|
|
271
|
+
existing["updated"] = now
|
|
272
|
+
existing["source"] = source_raw
|
|
273
|
+
if (
|
|
274
|
+
existing.get("status") == "candidate"
|
|
275
|
+
and len(features) >= PROMOTE_AFTER_FEATURES
|
|
276
|
+
):
|
|
277
|
+
existing["status"] = "confirmed"
|
|
278
|
+
save_store(store)
|
|
279
|
+
return ok(
|
|
280
|
+
f"{existing['id']} promoted to confirmed "
|
|
281
|
+
f"(seen in {len(features)} features)"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
save_store(store)
|
|
285
|
+
return ok(f"{existing['id']} recorded for '{feature}'")
|
|
286
|
+
|
|
287
|
+
lesson = {
|
|
288
|
+
"id": next_id(store["lessons"]),
|
|
289
|
+
"title": title,
|
|
290
|
+
"trigger": (args.trigger or "").strip(),
|
|
291
|
+
"rule": rule,
|
|
292
|
+
"status": "candidate",
|
|
293
|
+
"source": source_raw,
|
|
294
|
+
"features": [feature],
|
|
295
|
+
"created": now,
|
|
296
|
+
"updated": now,
|
|
297
|
+
"penalties": 0,
|
|
298
|
+
}
|
|
299
|
+
store["lessons"].append(lesson)
|
|
300
|
+
save_store(store)
|
|
301
|
+
return ok(f"{lesson['id']} stored as candidate from '{feature}'")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
305
|
+
store = load_store()
|
|
306
|
+
if isinstance(store, int):
|
|
307
|
+
return store
|
|
308
|
+
|
|
309
|
+
wanted = args.status or "confirmed"
|
|
310
|
+
if wanted == "all":
|
|
311
|
+
rows = store["lessons"]
|
|
312
|
+
elif wanted in STATUSES:
|
|
313
|
+
rows = [item for item in store["lessons"] if item.get("status") == wanted]
|
|
314
|
+
else:
|
|
315
|
+
return fail(f"unknown status '{wanted}' - use {', '.join(STATUSES)} or all", EXIT_USAGE)
|
|
316
|
+
|
|
317
|
+
print(f"[{GATE}] {len(rows)} {wanted} lesson(s)")
|
|
318
|
+
for lesson in rows:
|
|
319
|
+
trigger = f" — {lesson['trigger']}" if lesson.get("trigger") else ""
|
|
320
|
+
print(f" {lesson.get('id', '?')} {lesson.get('status', '?'):<12} {lesson.get('title', '')}{trigger}")
|
|
321
|
+
print(f" {lesson.get('rule', '')}")
|
|
322
|
+
return EXIT_OK
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_penalize(args: argparse.Namespace) -> int:
|
|
326
|
+
source = validate_source(args.source)
|
|
327
|
+
if isinstance(source, int):
|
|
328
|
+
return source
|
|
329
|
+
|
|
330
|
+
store = load_store()
|
|
331
|
+
if isinstance(store, int):
|
|
332
|
+
return store
|
|
333
|
+
|
|
334
|
+
lesson_id = (args.id or "").strip().upper()
|
|
335
|
+
lesson = next((item for item in store["lessons"] if item.get("id") == lesson_id), None)
|
|
336
|
+
if not lesson:
|
|
337
|
+
return fail(f"no such lesson: {lesson_id}")
|
|
338
|
+
|
|
339
|
+
if lesson.get("status") != "confirmed":
|
|
340
|
+
return fail(
|
|
341
|
+
f"{lesson_id} is {lesson.get('status')} - only confirmed lessons can be penalized"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
lesson["penalties"] = int(lesson.get("penalties") or 0) + 1
|
|
346
|
+
except (TypeError, ValueError):
|
|
347
|
+
return fail(f"{lesson_id} has a corrupt penalties field")
|
|
348
|
+
lesson["updated"] = today_iso()
|
|
349
|
+
lesson["source"] = source[1]
|
|
350
|
+
|
|
351
|
+
if lesson["penalties"] >= QUARANTINE_AFTER_PENALTIES:
|
|
352
|
+
lesson["status"] = "quarantined"
|
|
353
|
+
save_store(store)
|
|
354
|
+
return ok(
|
|
355
|
+
f"{lesson_id} quarantined after {lesson['penalties']} penalties - "
|
|
356
|
+
"stop loading it as guidance"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
save_store(store)
|
|
360
|
+
return ok(f"{lesson_id} penalized ({lesson['penalties']}/{QUARANTINE_AFTER_PENALTIES})")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
364
|
+
store = load_store()
|
|
365
|
+
if isinstance(store, int):
|
|
366
|
+
return store
|
|
367
|
+
|
|
368
|
+
cutoff = date.today() - PRUNE_AFTER
|
|
369
|
+
kept: list[dict] = []
|
|
370
|
+
removed: list[str] = []
|
|
371
|
+
|
|
372
|
+
for lesson in store["lessons"]:
|
|
373
|
+
updated = parse_iso_date(str(lesson.get("updated") or ""))
|
|
374
|
+
stale_candidate = lesson.get("status") == "candidate" and (
|
|
375
|
+
updated is None or updated <= cutoff
|
|
376
|
+
)
|
|
377
|
+
if stale_candidate:
|
|
378
|
+
removed.append(lesson.get("id", "?"))
|
|
379
|
+
else:
|
|
380
|
+
kept.append(lesson)
|
|
381
|
+
|
|
382
|
+
store["lessons"] = kept
|
|
383
|
+
save_store(store)
|
|
384
|
+
if removed:
|
|
385
|
+
return ok(f"pruned {len(removed)} stale candidate(s): {', '.join(removed)}")
|
|
386
|
+
return ok("no stale candidates to prune")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
390
|
+
store = load_store()
|
|
391
|
+
if isinstance(store, int):
|
|
392
|
+
return store
|
|
393
|
+
|
|
394
|
+
counts = {status: 0 for status in STATUSES}
|
|
395
|
+
for lesson in store["lessons"]:
|
|
396
|
+
status = lesson.get("status")
|
|
397
|
+
if status in counts:
|
|
398
|
+
counts[status] += 1
|
|
399
|
+
|
|
400
|
+
total = sum(counts.values())
|
|
401
|
+
print(f"[{GATE}] {total} lesson(s) in {STORE_PATH}")
|
|
402
|
+
for status in STATUSES:
|
|
403
|
+
print(f" {status:<12} {counts[status]}")
|
|
404
|
+
return EXIT_OK
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
408
|
+
parser = argparse.ArgumentParser(description="Manage grounded lessons")
|
|
409
|
+
sub = parser.add_subparsers(dest="command")
|
|
410
|
+
|
|
411
|
+
add = sub.add_parser("add", help="record a grounded lesson as a candidate")
|
|
412
|
+
add.add_argument("--title", required=True)
|
|
413
|
+
add.add_argument("--rule", required=True)
|
|
414
|
+
add.add_argument("--source", required=True, help="path to validation.md, optionally :line")
|
|
415
|
+
add.add_argument("--trigger", default="")
|
|
416
|
+
add.add_argument("--feature", default="")
|
|
417
|
+
add.set_defaults(func=cmd_add)
|
|
418
|
+
|
|
419
|
+
listing = sub.add_parser("list", help="list lessons (default: confirmed)")
|
|
420
|
+
listing.add_argument("--status", default="confirmed")
|
|
421
|
+
listing.set_defaults(func=cmd_list)
|
|
422
|
+
|
|
423
|
+
penalize = sub.add_parser("penalize", help="mark a confirmed lesson that failed to prevent a repeat")
|
|
424
|
+
penalize.add_argument("--id", required=True)
|
|
425
|
+
penalize.add_argument("--source", required=True)
|
|
426
|
+
penalize.set_defaults(func=cmd_penalize)
|
|
427
|
+
|
|
428
|
+
prune = sub.add_parser("prune", help="drop candidates idle for 90 days")
|
|
429
|
+
prune.set_defaults(func=cmd_prune)
|
|
430
|
+
|
|
431
|
+
status = sub.add_parser("status", help="counts by status")
|
|
432
|
+
status.set_defaults(func=cmd_status)
|
|
433
|
+
|
|
434
|
+
return parser
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def main(argv: list[str] | None = None) -> int:
|
|
438
|
+
parser = build_parser()
|
|
439
|
+
args = parser.parse_args(argv)
|
|
440
|
+
if not args.command:
|
|
441
|
+
parser.print_help()
|
|
442
|
+
return EXIT_USAGE
|
|
443
|
+
return args.func(args)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
sys.exit(main())
|