@luizsantiago/spec-guardrails 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. package/templates/presets/python.yaml +22 -0
@@ -0,0 +1,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())