@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.
- package/README.md +2 -1
- package/index.js +99 -17
- package/lib/adapter-registry.js +128 -0
- package/lib/adapters.js +3 -9
- package/lib/constants.js +2 -0
- package/lib/execution-policy.js +239 -4
- package/lib/gates.js +2 -0
- package/lib/install.js +0 -4
- package/lib/memory-index.js +59 -0
- package/lib/workspace-isolation.js +33 -0
- package/package.json +2 -2
- package/rules/engineering-baseline.mdc +3 -1
- package/scripts/analyze_artifacts.py +23 -0
- package/scripts/lessons.py +163 -30
- package/scripts/memory_index.py +325 -0
- package/scripts/memory_query.py +177 -0
- package/scripts/validate_spec.py +1 -0
- package/skills/agent-architecture.md +4 -0
- package/skills/git-handoff.md +16 -0
- package/skills/references/implement.md +4 -1
- package/skills/references/lessons.md +16 -10
- package/templates/config.yaml.example +10 -0
|
@@ -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())
|
package/scripts/validate_spec.py
CHANGED
|
@@ -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]` |
|
package/skills/git-handoff.md
CHANGED
|
@@ -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
|
|
9
|
-
- After a
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
64
|
+
python3 .specs/guardrails/scripts/lessons.py list --status approved
|
|
59
65
|
```
|
|
60
66
|
|
|
61
|
-
Apply every
|
|
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
|
|
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:
|