@luizsantiago/spec-guardrails 3.2.0 → 3.3.0

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.
@@ -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())
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded knowledge-graph traversal over the SQLite memory index.
3
+
4
+ python3 memory_query.py --from T1 --depth 2
5
+ python3 memory_query.py --from REQ-001 --depth 1 --json
6
+
7
+ Exit codes: 0 ok, 1 failure, 2 usage error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sqlite3
15
+ import sys
16
+ from collections import deque
17
+ from pathlib import Path
18
+
19
+ from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
20
+
21
+ GATE = "memory-query"
22
+ DB_PATH = Path(".specs/memory/memory.db")
23
+ DEFAULT_DEPTH = 2
24
+ MAX_DEPTH = 5
25
+
26
+
27
+ def fail(message: str, code: int = EXIT_FAILED) -> int:
28
+ print(f"[{GATE}] FAIL - {DB_PATH}")
29
+ print(f" error {message}")
30
+ return code
31
+
32
+
33
+ def normalize_entity_id(raw: str) -> str:
34
+ value = raw.strip()
35
+ if value.upper().startswith("T") and value[1:].isdigit():
36
+ return f"T{int(value[1:])}"
37
+ return value
38
+
39
+
40
+ def fetch_entity(conn: sqlite3.Connection, entity_id: str) -> dict | None:
41
+ row = conn.execute(
42
+ "SELECT id, kind, label, source_path, updated_at FROM entities WHERE id = ?",
43
+ (entity_id,),
44
+ ).fetchone()
45
+ if not row:
46
+ return None
47
+ return {
48
+ "id": row[0],
49
+ "kind": row[1],
50
+ "label": row[2],
51
+ "source_path": row[3],
52
+ "updated_at": row[4],
53
+ }
54
+
55
+
56
+ def fetch_neighbors(conn: sqlite3.Connection, entity_id: str) -> list[dict]:
57
+ rows = conn.execute(
58
+ """
59
+ SELECT r.kind, e.id, e.kind, e.label, e.source_path
60
+ FROM relations r
61
+ JOIN entities e ON e.id = r.to_id
62
+ WHERE r.from_id = ?
63
+ UNION
64
+ SELECT r.kind, e.id, e.kind, e.label, e.source_path
65
+ FROM relations r
66
+ JOIN entities e ON e.id = r.from_id
67
+ WHERE r.to_id = ?
68
+ """,
69
+ (entity_id, entity_id),
70
+ ).fetchall()
71
+
72
+ neighbors: list[dict] = []
73
+ for kind, nid, nkind, label, source_path in rows:
74
+ neighbors.append(
75
+ {
76
+ "relation": kind,
77
+ "entity": {
78
+ "id": nid,
79
+ "kind": nkind,
80
+ "label": label,
81
+ "source_path": source_path,
82
+ },
83
+ }
84
+ )
85
+ return neighbors
86
+
87
+
88
+ def traverse(from_id: str, depth: int) -> dict:
89
+ if not DB_PATH.is_file():
90
+ raise FileNotFoundError(
91
+ f"{DB_PATH} not found — run `memory-index rebuild` after install"
92
+ )
93
+
94
+ conn = sqlite3.connect(DB_PATH)
95
+ try:
96
+ root_id = normalize_entity_id(from_id)
97
+ root = fetch_entity(conn, root_id)
98
+ if not root:
99
+ raise LookupError(f"entity not found: {root_id}")
100
+
101
+ visited: set[str] = {root_id}
102
+ edges: list[dict] = []
103
+ entities: dict[str, dict] = {root_id: root}
104
+ queue: deque[tuple[str, int]] = deque([(root_id, 0)])
105
+
106
+ while queue:
107
+ current_id, current_depth = queue.popleft()
108
+ if current_depth >= depth:
109
+ continue
110
+
111
+ for neighbor in fetch_neighbors(conn, current_id):
112
+ target = neighbor["entity"]
113
+ target_id = target["id"]
114
+ edges.append(
115
+ {
116
+ "from": current_id,
117
+ "to": target_id,
118
+ "kind": neighbor["relation"],
119
+ }
120
+ )
121
+ if target_id not in entities:
122
+ entities[target_id] = target
123
+ if target_id not in visited:
124
+ visited.add(target_id)
125
+ queue.append((target_id, current_depth + 1))
126
+
127
+ return {
128
+ "from": root_id,
129
+ "depth": depth,
130
+ "entities": list(entities.values()),
131
+ "relations": edges,
132
+ }
133
+ finally:
134
+ conn.close()
135
+
136
+
137
+ def cmd_query(args: argparse.Namespace) -> int:
138
+ depth = args.depth if args.depth is not None else DEFAULT_DEPTH
139
+ if depth < 0 or depth > MAX_DEPTH:
140
+ return fail(f"--depth must be between 0 and {MAX_DEPTH}", EXIT_USAGE)
141
+
142
+ try:
143
+ package = traverse(args.from_id, depth)
144
+ except FileNotFoundError as err:
145
+ return fail(str(err))
146
+ except LookupError as err:
147
+ return fail(str(err))
148
+
149
+ if args.json:
150
+ print(json.dumps(package, indent=2))
151
+ else:
152
+ print(f"[{GATE}] context package from {package['from']} (depth {package['depth']})")
153
+ print(f" entities: {len(package['entities'])}")
154
+ print(f" relations: {len(package['relations'])}")
155
+ for entity in package["entities"]:
156
+ print(f" - {entity['id']} ({entity['kind']}): {entity['label']}")
157
+
158
+ return EXIT_OK
159
+
160
+
161
+ def build_parser() -> argparse.ArgumentParser:
162
+ parser = argparse.ArgumentParser(description="Query the SQLite knowledge graph")
163
+ parser.add_argument("--from", dest="from_id", required=True, help="root entity id")
164
+ parser.add_argument("--depth", type=int, default=DEFAULT_DEPTH)
165
+ parser.add_argument("--json", action="store_true")
166
+ parser.set_defaults(func=cmd_query)
167
+ return parser
168
+
169
+
170
+ def main(argv: list[str] | None = None) -> int:
171
+ parser = build_parser()
172
+ args = parser.parse_args(argv)
173
+ return args.func(args)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -311,6 +311,7 @@ def build_report(target: str, text: str) -> Report:
311
311
  "MODIFIED",
312
312
  )
313
313
  validate_removed_section(report, visible)
314
+ validate_out_of_scope(report, visible)
314
315
  else:
315
316
  for section in REQUIRED_SECTIONS:
316
317
  if has_section(visible, section):
@@ -46,6 +46,10 @@ Structural gates run **before** owner review, so they cannot drift when the mode
46
46
  | Before approving tasks | `python3 .specs/guardrails/scripts/analyze_artifacts.py [feature]` |
47
47
  | Before presenting tasks for approval | `python3 .specs/guardrails/scripts/validate_tasks.py [feature]` |
48
48
  | Before Execute waves (3+ tasks) | `npx @luizsantiago/spec-guardrails loop-plan [feature]` |
49
+ | Parallel wave (2+ tasks, disjoint Files) | `npx @luizsantiago/spec-guardrails workspace-prepare [feature] --tasks T1,T2` |
50
+ | After parallel wave merge | `npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --force` |
51
+ | Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails execution-policy check-path <path> [--op write]` |
52
+ | On gate retry (Execute playbook) | `npx @luizsantiago/spec-guardrails execution-policy record-retry Tn` |
49
53
  | On each commit | `python3 .specs/guardrails/scripts/check_commit.py --message "<message>"` |
50
54
  | Before declaring a feature done | `python3 .specs/guardrails/scripts/validate_state.py [feature]` |
51
55
  | Traceability (Medium+ features) | `python3 .specs/guardrails/scripts/validate_traceability.py [feature]` |
@@ -62,6 +62,22 @@ node_modules/
62
62
 
63
63
  Follow `engineering-standards.md` — separate commits for code vs docs when possible.
64
64
 
65
+ ## Execution policy (scope and budgets)
66
+
67
+ When `.specs/config.yaml` defines `budget`, `scope`, or `escalation`, consult policy before expanding edits:
68
+
69
+ ```bash
70
+ npx @luizsantiago/spec-guardrails execution-policy status
71
+ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
72
+ ```
73
+
74
+ - **Scope:** denied paths block with exit 1; allowed_paths (when set) restrict edits to listed globs.
75
+ - **Intent/effect:** pass `--op read|write|delete` to `check-path`; configure `effects.deny_*` and `effects.warn_*` globs per operation in `.specs/config.yaml`.
76
+ - **Retries:** after a task gate failure, run `execution-policy record-retry Tn`; stop at `max_retries_per_task` (default 3) and escalate per the Execute playbook — do not bypass with `--no-verify`.
77
+ - **Agent runs:** orchestrators may call `execution-policy record-run` when dispatching sub-agents; stop when budgets exhaust.
78
+
79
+ Policy is soft-enforced via CLI + skills — not an OS sandbox. Escalation defaults: scope expansion → human review; budget exhaustion → stop.
80
+
65
81
  ## Git Blast Radius (Tiers)
66
82
 
67
83
  Structural gates enforce artifact quality. Git tiers enforce **what leaves the machine**.
@@ -41,10 +41,13 @@ npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --tasks T1,T2 --fo
41
41
  7. Consult execution policy before touching files outside the task list:
42
42
 
43
43
  ```bash
44
- npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
44
+ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts --op write
45
+ npx @luizsantiago/spec-guardrails execution-policy check-path README.md --op read
45
46
  npx @luizsantiago/spec-guardrails execution-policy status
46
47
  ```
47
48
 
49
+ Declare the intended operation (`read`, `write`, or `delete`). When `--op` is omitted, the CLI infers `read` for common doc/config extensions and `write` otherwise. Effect rules in `.specs/config.yaml` (`effects.deny_*`, `effects.warn_*`) apply after scope checks.
50
+
48
51
  Record gate retries with `execution-policy record-retry T1` when a task fails its gate (respects `max_retries_per_task` in `.specs/config.yaml`).
49
52
 
50
53
  8. When `loop-plan` shows a **parallel group** (2+ tasks), offer sub-agent dispatch per `task-graph-engineering.md` and `sub-agents.md`. Offer and wait; never auto-spawn. Large features (roughly 8+ tasks total) also warrant batching across waves.
@@ -1,18 +1,20 @@
1
1
  # Lessons
2
2
 
3
- How to record, promote, and load grounded lessons. The engine owns the files; this reference owns the judgment.
3
+ How to record, promote, graduate, and load grounded lessons. The engine owns the files; this reference owns the judgment.
4
4
 
5
5
  ## When to Use
6
6
 
7
7
  - The last step of `/verify` when the verdict is FAIL for a grounded reason
8
- - The first step of `/specify` and `/plan`, loading only confirmed lessons
9
- - After a confirmed lesson was loaded and the same failure still happened (`penalize`)
8
+ - The first step of `/specify` and `/plan`, loading only approved lessons
9
+ - After a approved lesson was loaded and the same failure still happened (`penalize`)
10
+ - When a stable approved rule should become an engineering standard (`graduate` — human only)
10
11
 
11
12
  ## When NOT to Use
12
13
 
13
14
  - A clean PASS — record nothing
14
15
  - A preference, a style nit, or anything not evidenced in `validation.md`
15
16
  - Hand-editing `.specs/LESSONS.md` or `.specs/lessons.json`
17
+ - **Auto-graduation** — agents must never graduate without explicit human approval
16
18
 
17
19
  ## Files
18
20
 
@@ -20,7 +22,7 @@ How to record, promote, and load grounded lessons. The engine owns the files; th
20
22
  | --- | --- | --- |
21
23
  | `.specs/lessons.json` | `lessons.py` | Canonical store |
22
24
  | `.specs/LESSONS.md` | `lessons.py` | Rendered playbook — read, never write |
23
- | `.specs/guardrails/scripts/lessons.py` | Harness | `add`, `list`, `penalize`, `prune`, `status` |
25
+ | `.specs/guardrails/scripts/lessons.py` | Harness | `add`, `list`, `promote`, `graduate`, `penalize`, `prune`, `status` |
24
26
 
25
27
  ## How to Phrase
26
28
 
@@ -42,25 +44,29 @@ Titles and rules are deduped after normalization (casefold, accents stripped, pu
42
44
 
43
45
  ```
44
46
  FAIL with evidence → add (candidate)
45
- candidate seen in 2 distinct features → confirmed (guidance)
46
- confirmed loaded but the same failure recurs → penalize
47
+ observed → repeated → candidate → approved → graduated → deprecated
48
+ candidate seen in 2 distinct features → approved (automatic)
49
+ approved loaded but the same failure recurs → penalize
47
50
  2 penalties → quarantined (stop loading)
48
51
  candidate idle 90 days → prune
52
+ graduate (human) → approved → graduated with --evidence + validation.md
49
53
  ```
50
54
 
51
- Same-feature recurrence does not promote. One noisy feature must not turn a guess into a house rule.
55
+ Same-feature recurrence does not promote. One noisy feature must not turn a guess into a house rule. **Never auto-graduate** — use `graduate` only after human review.
56
+
57
+ Manual promotion (`promote --id L-001`) advances one step along `observed → repeated → candidate → approved` when corroboration is incomplete.
52
58
 
53
59
  ## When to Load
54
60
 
55
61
  At the start of Specify and Design:
56
62
 
57
63
  ```bash
58
- python3 .specs/guardrails/scripts/lessons.py list --status confirmed
64
+ python3 .specs/guardrails/scripts/lessons.py list --status approved
59
65
  ```
60
66
 
61
- Apply every confirmed rule that matches this work. Candidates are not guidance — they live in `lessons.json` until a second feature corroborates them.
67
+ Apply every approved rule that matches this work. Candidates are not guidance — they live in `lessons.json` until a second feature corroborates them or a human promotes them.
62
68
 
63
- If a confirmed lesson was in the working set and the same gap still appears in `/verify`, penalize it with the new `validation.md` as `--source`. Two penalties quarantine it.
69
+ If an approved lesson was in the working set and the same gap still appears in `/verify`, penalize it with the new `validation.md` as `--source`. Two penalties quarantine it.
64
70
 
65
71
  ## Degraded mode
66
72
 
@@ -39,6 +39,16 @@ escalation:
39
39
  on_budget_exhaustion: stop
40
40
  on_policy_violation: block
41
41
 
42
+ # Intent/effect rules (optional — pair with check-path --op read|write|delete)
43
+ effects:
44
+ deny_delete:
45
+ - "**/.git/**"
46
+ deny_write: []
47
+ deny_read: []
48
+ warn_write: []
49
+ warn_read: []
50
+ warn_delete: []
51
+
42
52
  # Project-specific overrides (appended on top of preset + rules above):
43
53
  # overrides:
44
54
  # rules: