@luizsantiago/spec-guardrails 3.2.0 → 3.2.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.
@@ -40,7 +40,18 @@ FEATURE_IN_PATH = re.compile(
40
40
  r"(?:^|/)features/(?P<name>[^/]+)/", re.IGNORECASE
41
41
  )
42
42
 
43
- STATUSES = ("candidate", "confirmed", "quarantined")
43
+ STATUSES = (
44
+ "observed",
45
+ "repeated",
46
+ "candidate",
47
+ "approved",
48
+ "graduated",
49
+ "deprecated",
50
+ "quarantined",
51
+ )
52
+ LEGACY_CONFIRMED = "confirmed"
53
+ GUIDANCE_STATUSES = frozenset({"approved", "graduated", LEGACY_CONFIRMED})
54
+ PROMOTION_CHAIN = ("observed", "repeated", "candidate", "approved")
44
55
 
45
56
 
46
57
  def fail(message: str, code: int = EXIT_FAILED) -> int:
@@ -54,6 +65,30 @@ def ok(message: str) -> int:
54
65
  return EXIT_OK
55
66
 
56
67
 
68
+ def normalize_status(raw: str | None) -> str:
69
+ value = str(raw or "").strip().lower()
70
+ if value == LEGACY_CONFIRMED:
71
+ return "approved"
72
+ return value
73
+
74
+
75
+ def is_guidance_status(status: str) -> bool:
76
+ return normalize_status(status) in GUIDANCE_STATUSES
77
+
78
+
79
+ def next_promotion_status(status: str) -> str | None:
80
+ normalized = normalize_status(status)
81
+ if normalized == LEGACY_CONFIRMED:
82
+ return "graduated"
83
+ try:
84
+ index = PROMOTION_CHAIN.index(normalized)
85
+ except ValueError:
86
+ return None
87
+ if index + 1 >= len(PROMOTION_CHAIN):
88
+ return None
89
+ return PROMOTION_CHAIN[index + 1]
90
+
91
+
57
92
  def normalize(text: str) -> str:
58
93
  """Casefold, strip accents and punctuation, collapse whitespace."""
59
94
 
@@ -173,32 +208,47 @@ def parse_iso_date(raw: str) -> date | None:
173
208
 
174
209
 
175
210
  def render_markdown(store: dict) -> str:
176
- confirmed = [item for item in store["lessons"] if item.get("status") == "confirmed"]
211
+ guidance = [
212
+ item
213
+ for item in store["lessons"]
214
+ if is_guidance_status(str(item.get("status") or ""))
215
+ and normalize_status(str(item.get("status") or "")) != "graduated"
216
+ ]
217
+ graduated = [
218
+ item
219
+ for item in store["lessons"]
220
+ if normalize_status(str(item.get("status") or "")) == "graduated"
221
+ ]
177
222
  lines = [
178
223
  "# Lessons Learned",
179
224
  "",
180
225
  "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.",
226
+ "A clean PASS records nothing. Only **approved** lessons below are guidance.",
227
+ "Graduated rules live in engineering standards — never auto-graduate.",
182
228
  "Inspect candidates with `python3 .specs/guardrails/scripts/lessons.py list --status all`.",
183
229
  "",
184
230
  ]
185
231
 
186
- if not confirmed:
232
+ if not guidance:
187
233
  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
-
234
+ else:
235
+ for lesson in guidance:
236
+ lines.append(f"### {lesson.get('id', '?')}: {lesson.get('title', '')}")
237
+ if lesson.get("trigger"):
238
+ lines.append(f"- **Trigger**: {lesson['trigger']}")
239
+ lines.append(f"- **Rule**: {lesson.get('rule', '')}")
240
+ features = ", ".join(lesson.get("features") or [])
241
+ if features:
242
+ lines.append(f"- **Features**: {features}")
243
+ lines.append(f"- **Source**: {lesson.get('source', '—')}")
244
+ lines.append("")
245
+
246
+ if graduated:
247
+ lines.extend(["## Graduated", ""])
248
+ for lesson in graduated:
249
+ lines.append(f"- {lesson.get('id', '?')}: {lesson.get('title', '')}")
250
+
251
+ lines.append("")
202
252
  return "\n".join(lines)
203
253
 
204
254
 
@@ -271,13 +321,13 @@ def cmd_add(args: argparse.Namespace) -> int:
271
321
  existing["updated"] = now
272
322
  existing["source"] = source_raw
273
323
  if (
274
- existing.get("status") == "candidate"
324
+ normalize_status(str(existing.get("status"))) == "candidate"
275
325
  and len(features) >= PROMOTE_AFTER_FEATURES
276
326
  ):
277
- existing["status"] = "confirmed"
327
+ existing["status"] = "approved"
278
328
  save_store(store)
279
329
  return ok(
280
- f"{existing['id']} promoted to confirmed "
330
+ f"{existing['id']} promoted to approved "
281
331
  f"(seen in {len(features)} features)"
282
332
  )
283
333
 
@@ -306,13 +356,24 @@ def cmd_list(args: argparse.Namespace) -> int:
306
356
  if isinstance(store, int):
307
357
  return store
308
358
 
309
- wanted = args.status or "confirmed"
359
+ wanted = args.status or "approved"
310
360
  if wanted == "all":
311
361
  rows = store["lessons"]
362
+ elif wanted == LEGACY_CONFIRMED:
363
+ rows = [
364
+ item
365
+ for item in store["lessons"]
366
+ if is_guidance_status(str(item.get("status") or ""))
367
+ ]
312
368
  elif wanted in STATUSES:
313
- rows = [item for item in store["lessons"] if item.get("status") == wanted]
369
+ rows = [
370
+ item for item in store["lessons"] if normalize_status(item.get("status")) == wanted
371
+ ]
314
372
  else:
315
- return fail(f"unknown status '{wanted}' - use {', '.join(STATUSES)} or all", EXIT_USAGE)
373
+ return fail(
374
+ f"unknown status '{wanted}' - use {', '.join(STATUSES)}, confirmed, or all",
375
+ EXIT_USAGE,
376
+ )
316
377
 
317
378
  print(f"[{GATE}] {len(rows)} {wanted} lesson(s)")
318
379
  for lesson in rows:
@@ -336,9 +397,9 @@ def cmd_penalize(args: argparse.Namespace) -> int:
336
397
  if not lesson:
337
398
  return fail(f"no such lesson: {lesson_id}")
338
399
 
339
- if lesson.get("status") != "confirmed":
400
+ if normalize_status(str(lesson.get("status"))) not in {"approved", LEGACY_CONFIRMED}:
340
401
  return fail(
341
- f"{lesson_id} is {lesson.get('status')} - only confirmed lessons can be penalized"
402
+ f"{lesson_id} is {lesson.get('status')} - only approved lessons can be penalized"
342
403
  )
343
404
 
344
405
  try:
@@ -392,18 +453,80 @@ def cmd_status(args: argparse.Namespace) -> int:
392
453
  return store
393
454
 
394
455
  counts = {status: 0 for status in STATUSES}
456
+ legacy_confirmed = 0
395
457
  for lesson in store["lessons"]:
396
- status = lesson.get("status")
458
+ raw_status = str(lesson.get("status") or "")
459
+ if raw_status == LEGACY_CONFIRMED:
460
+ legacy_confirmed += 1
461
+ continue
462
+ status = normalize_status(raw_status)
397
463
  if status in counts:
398
464
  counts[status] += 1
399
465
 
400
- total = sum(counts.values())
466
+ total = len(store["lessons"])
401
467
  print(f"[{GATE}] {total} lesson(s) in {STORE_PATH}")
402
468
  for status in STATUSES:
403
469
  print(f" {status:<12} {counts[status]}")
470
+ if legacy_confirmed:
471
+ print(f" {'confirmed (legacy)':<12} {legacy_confirmed}")
404
472
  return EXIT_OK
405
473
 
406
474
 
475
+ def cmd_promote(args: argparse.Namespace) -> int:
476
+ store = load_store()
477
+ if isinstance(store, int):
478
+ return store
479
+
480
+ lesson_id = (args.id or "").strip().upper()
481
+ lesson = next((item for item in store["lessons"] if item.get("id") == lesson_id), None)
482
+ if not lesson:
483
+ return fail(f"no such lesson: {lesson_id}")
484
+
485
+ next_status = next_promotion_status(str(lesson.get("status") or ""))
486
+ if not next_status:
487
+ return fail(
488
+ f"{lesson_id} is {lesson.get('status')} - no manual promotion step available"
489
+ )
490
+
491
+ lesson["status"] = next_status
492
+ lesson["updated"] = today_iso()
493
+ save_store(store)
494
+ return ok(f"{lesson_id} promoted to {next_status}")
495
+
496
+
497
+ def cmd_graduate(args: argparse.Namespace) -> int:
498
+ source = validate_source(args.source)
499
+ if isinstance(source, int):
500
+ return source
501
+
502
+ store = load_store()
503
+ if isinstance(store, int):
504
+ return store
505
+
506
+ lesson_id = (args.id or "").strip().upper()
507
+ lesson = next((item for item in store["lessons"] if item.get("id") == lesson_id), None)
508
+ if not lesson:
509
+ return fail(f"no such lesson: {lesson_id}")
510
+
511
+ status = normalize_status(str(lesson.get("status")))
512
+ if status not in {"approved", LEGACY_CONFIRMED}:
513
+ return fail(
514
+ f"{lesson_id} is {lesson.get('status')} - only approved lessons can graduate"
515
+ )
516
+
517
+ evidence = (args.evidence or "").strip()
518
+ if not evidence:
519
+ return fail("--evidence is required - graduation must cite why this is a house rule")
520
+
521
+ lesson["status"] = "graduated"
522
+ lesson["graduated_from"] = str(lesson.get("source") or "")
523
+ lesson["graduation_evidence"] = evidence
524
+ lesson["source"] = source[1]
525
+ lesson["updated"] = today_iso()
526
+ save_store(store)
527
+ return ok(f"{lesson_id} graduated — stop loading as active guidance")
528
+
529
+
407
530
  def build_parser() -> argparse.ArgumentParser:
408
531
  parser = argparse.ArgumentParser(description="Manage grounded lessons")
409
532
  sub = parser.add_subparsers(dest="command")
@@ -416,10 +539,20 @@ def build_parser() -> argparse.ArgumentParser:
416
539
  add.add_argument("--feature", default="")
417
540
  add.set_defaults(func=cmd_add)
418
541
 
419
- listing = sub.add_parser("list", help="list lessons (default: confirmed)")
420
- listing.add_argument("--status", default="confirmed")
542
+ listing = sub.add_parser("list", help="list lessons (default: approved)")
543
+ listing.add_argument("--status", default="approved")
421
544
  listing.set_defaults(func=cmd_list)
422
545
 
546
+ promote = sub.add_parser("promote", help="advance one step in the graduation lifecycle")
547
+ promote.add_argument("--id", required=True)
548
+ promote.set_defaults(func=cmd_promote)
549
+
550
+ graduate = sub.add_parser("graduate", help="graduate an approved lesson into a house rule")
551
+ graduate.add_argument("--id", required=True)
552
+ graduate.add_argument("--source", required=True, help="path to validation.md evidence")
553
+ graduate.add_argument("--evidence", required=True, help="why this rule is stable enough to graduate")
554
+ graduate.set_defaults(func=cmd_graduate)
555
+
423
556
  penalize = sub.add_parser("penalize", help="mark a confirmed lesson that failed to prevent a repeat")
424
557
  penalize.add_argument("--id", required=True)
425
558
  penalize.add_argument("--source", required=True)
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """Rebuild the SQLite memory index from `.specs/` markdown artifacts.
3
+
4
+ Source of truth remains markdown under `.specs/`. The database is a derived index.
5
+
6
+ python3 memory_index.py rebuild
7
+ python3 memory_index.py rebuild --json
8
+
9
+ Exit codes: 0 ok, 1 failure, 2 usage error.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import re
17
+ import sqlite3
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE, requirement_ids
23
+
24
+ GATE = "memory-index"
25
+ SPECS_DIR = Path(".specs")
26
+ FEATURES_DIR = SPECS_DIR / "features"
27
+ MEMORY_DIR = SPECS_DIR / "memory"
28
+ DB_PATH = MEMORY_DIR / "memory.db"
29
+
30
+ TASK_HEADING = re.compile(
31
+ r"^#{2,6}\s*(?P<id>T\d{1,6})\s*[:\-–]?\s*(?P<title>.*)$",
32
+ re.MULTILINE | re.IGNORECASE,
33
+ )
34
+ TASK_FIELD = re.compile(
35
+ r"^\s*[-*]?\s*\*{0,2}(?P<key>[A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
36
+ re.MULTILINE,
37
+ )
38
+ REQ_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
39
+
40
+
41
+ def fail(message: str, code: int = EXIT_FAILED) -> int:
42
+ print(f"[{GATE}] FAIL - {DB_PATH}")
43
+ print(f" error {message}")
44
+ return code
45
+
46
+
47
+ def ok(message: str) -> int:
48
+ print(f"[{GATE}] PASS - {message}")
49
+ return EXIT_OK
50
+
51
+
52
+ def utc_now() -> str:
53
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
54
+
55
+
56
+ def init_schema(conn: sqlite3.Connection) -> None:
57
+ conn.executescript(
58
+ """
59
+ CREATE TABLE IF NOT EXISTS entities (
60
+ id TEXT PRIMARY KEY,
61
+ kind TEXT NOT NULL,
62
+ label TEXT NOT NULL,
63
+ source_path TEXT,
64
+ updated_at TEXT NOT NULL
65
+ );
66
+ CREATE TABLE IF NOT EXISTS relations (
67
+ from_id TEXT NOT NULL,
68
+ to_id TEXT NOT NULL,
69
+ kind TEXT NOT NULL,
70
+ PRIMARY KEY (from_id, to_id, kind)
71
+ );
72
+ CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5(
73
+ id UNINDEXED,
74
+ kind,
75
+ label,
76
+ source_path,
77
+ tokenize='porter'
78
+ );
79
+ """
80
+ )
81
+
82
+
83
+ def upsert_entity(
84
+ conn: sqlite3.Connection,
85
+ entity_id: str,
86
+ kind: str,
87
+ label: str,
88
+ source_path: str | None,
89
+ now: str,
90
+ ) -> None:
91
+ conn.execute(
92
+ """
93
+ INSERT INTO entities (id, kind, label, source_path, updated_at)
94
+ VALUES (?, ?, ?, ?, ?)
95
+ ON CONFLICT(id) DO UPDATE SET
96
+ kind=excluded.kind,
97
+ label=excluded.label,
98
+ source_path=excluded.source_path,
99
+ updated_at=excluded.updated_at
100
+ """,
101
+ (entity_id, kind, label, source_path, now),
102
+ )
103
+ conn.execute("DELETE FROM entity_fts WHERE id = ?", (entity_id,))
104
+ conn.execute(
105
+ "INSERT INTO entity_fts (id, kind, label, source_path) VALUES (?, ?, ?, ?)",
106
+ (entity_id, kind, label, source_path or ""),
107
+ )
108
+
109
+
110
+ def upsert_relation(conn: sqlite3.Connection, from_id: str, to_id: str, kind: str) -> None:
111
+ conn.execute(
112
+ """
113
+ INSERT OR IGNORE INTO relations (from_id, to_id, kind)
114
+ VALUES (?, ?, ?)
115
+ """,
116
+ (from_id, to_id, kind),
117
+ )
118
+
119
+
120
+ def task_requirement_ids(tasks_text: str) -> dict[str, set[str]]:
121
+ current_task: str | None = None
122
+ mapping: dict[str, set[str]] = {}
123
+
124
+ for line in tasks_text.splitlines():
125
+ heading = TASK_HEADING.match(line)
126
+ if heading:
127
+ current_task = heading.group("id").upper()
128
+ mapping.setdefault(current_task, set())
129
+ continue
130
+
131
+ if not current_task:
132
+ continue
133
+
134
+ field = TASK_FIELD.match(line)
135
+ if not field or field.group("key").strip().lower() != "requirement":
136
+ continue
137
+
138
+ mapping[current_task].update(REQ_REF.findall(field.group("value")))
139
+
140
+ return mapping
141
+
142
+
143
+ def task_file_paths(tasks_text: str) -> dict[str, set[str]]:
144
+ current_task: str | None = None
145
+ mapping: dict[str, set[str]] = {}
146
+
147
+ for line in tasks_text.splitlines():
148
+ heading = TASK_HEADING.match(line)
149
+ if heading:
150
+ current_task = heading.group("id").upper()
151
+ mapping.setdefault(current_task, set())
152
+ continue
153
+
154
+ if not current_task:
155
+ continue
156
+
157
+ field = TASK_FIELD.match(line)
158
+ if not field or field.group("key").strip().lower() != "files":
159
+ continue
160
+
161
+ raw = field.group("value").strip()
162
+ if raw.lower() in {"—", "-", "none", "n/a"}:
163
+ continue
164
+
165
+ for part in re.split(r"[,;]", raw):
166
+ cleaned = part.strip().strip("`")
167
+ if cleaned:
168
+ mapping[current_task].add(cleaned)
169
+
170
+ return mapping
171
+
172
+
173
+ def index_lessons(conn: sqlite3.Connection, now: str) -> int:
174
+ lessons_path = SPECS_DIR / "lessons.json"
175
+ if not lessons_path.is_file():
176
+ return 0
177
+
178
+ payload = json.loads(lessons_path.read_text(encoding="utf-8"))
179
+ count = 0
180
+ for lesson in payload.get("lessons") or []:
181
+ lesson_id = str(lesson.get("id") or "").strip()
182
+ if not lesson_id:
183
+ continue
184
+ upsert_entity(
185
+ conn,
186
+ lesson_id,
187
+ "lesson",
188
+ str(lesson.get("title") or lesson_id),
189
+ str(lesson.get("source") or ""),
190
+ now,
191
+ )
192
+ count += 1
193
+ return count
194
+
195
+
196
+ def rebuild(json_output: bool = False) -> int:
197
+ if not SPECS_DIR.is_dir():
198
+ return fail(".specs/ not found — run install first")
199
+
200
+ MEMORY_DIR.mkdir(parents=True, exist_ok=True)
201
+ now = utc_now()
202
+
203
+ conn = sqlite3.connect(DB_PATH)
204
+ try:
205
+ init_schema(conn)
206
+ conn.execute("DELETE FROM relations")
207
+ conn.execute("DELETE FROM entities")
208
+ conn.execute("DELETE FROM entity_fts")
209
+
210
+ entity_count = 0
211
+ relation_count = 0
212
+
213
+ if FEATURES_DIR.is_dir():
214
+ for feature_dir in sorted(FEATURES_DIR.iterdir()):
215
+ if not feature_dir.is_dir():
216
+ continue
217
+
218
+ feature_id = feature_dir.name
219
+ upsert_entity(
220
+ conn,
221
+ feature_id,
222
+ "feature",
223
+ feature_id,
224
+ str(feature_dir / "spec.md"),
225
+ now,
226
+ )
227
+ entity_count += 1
228
+
229
+ spec_path = feature_dir / "spec.md"
230
+ if spec_path.is_file():
231
+ spec_text = spec_path.read_text(encoding="utf-8")
232
+ for req_id in requirement_ids(spec_text):
233
+ upsert_entity(
234
+ conn,
235
+ req_id,
236
+ "requirement",
237
+ req_id,
238
+ str(spec_path),
239
+ now,
240
+ )
241
+ entity_count += 1
242
+ upsert_relation(conn, feature_id, req_id, "contains")
243
+ relation_count += 1
244
+
245
+ tasks_path = feature_dir / "tasks.md"
246
+ if tasks_path.is_file():
247
+ tasks_text = tasks_path.read_text(encoding="utf-8")
248
+ for match in TASK_HEADING.finditer(tasks_text):
249
+ task_id = match.group("id").upper()
250
+ title = match.group("title").strip() or task_id
251
+ upsert_entity(
252
+ conn,
253
+ task_id,
254
+ "task",
255
+ title,
256
+ str(tasks_path),
257
+ now,
258
+ )
259
+ entity_count += 1
260
+ upsert_relation(conn, feature_id, task_id, "contains")
261
+ relation_count += 1
262
+
263
+ for task_id, req_ids in task_requirement_ids(tasks_text).items():
264
+ for req_id in req_ids:
265
+ upsert_relation(conn, task_id, req_id, "implements")
266
+ relation_count += 1
267
+
268
+ for task_id, files in task_file_paths(tasks_text).items():
269
+ for file_path in files:
270
+ file_entity = f"file:{file_path}"
271
+ upsert_entity(
272
+ conn,
273
+ file_entity,
274
+ "file",
275
+ file_path,
276
+ str(tasks_path),
277
+ now,
278
+ )
279
+ entity_count += 1
280
+ upsert_relation(conn, task_id, file_entity, "touches")
281
+ relation_count += 1
282
+
283
+ entity_count += index_lessons(conn, now)
284
+ conn.commit()
285
+
286
+ summary = {
287
+ "database": str(DB_PATH),
288
+ "entities": entity_count,
289
+ "relations": relation_count,
290
+ "updated_at": now,
291
+ }
292
+
293
+ if json_output:
294
+ print(json.dumps(summary, indent=2))
295
+ else:
296
+ return ok(
297
+ f"indexed {entity_count} entities and {relation_count} relations -> {DB_PATH}"
298
+ )
299
+ return EXIT_OK
300
+ finally:
301
+ conn.close()
302
+
303
+
304
+ def build_parser() -> argparse.ArgumentParser:
305
+ parser = argparse.ArgumentParser(description="Rebuild SQLite memory index from .specs/")
306
+ sub = parser.add_subparsers(dest="command")
307
+
308
+ rebuild_cmd = sub.add_parser("rebuild", help="rebuild index from markdown artifacts")
309
+ rebuild_cmd.add_argument("--json", action="store_true")
310
+ rebuild_cmd.set_defaults(func=lambda args: rebuild(json_output=args.json))
311
+
312
+ return parser
313
+
314
+
315
+ def main(argv: list[str] | None = None) -> int:
316
+ parser = build_parser()
317
+ args = parser.parse_args(argv)
318
+ if not args.command:
319
+ parser.print_help()
320
+ return EXIT_USAGE
321
+ return args.func(args)
322
+
323
+
324
+ if __name__ == "__main__":
325
+ sys.exit(main())